From 4e1f047cf2b28eb735aa893203f056932b2a70c9 Mon Sep 17 00:00:00 2001 From: L-Sun Date: Fri, 1 Aug 2025 09:58:19 +0800 Subject: [PATCH] refactor(editor): always show keyboard toolbar in mobile (#13384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close [AF-2756](https://linear.app/affine-design/issue/AF-2756/激活输入区的时候,展示toolbar,适配不弹虚拟键盘的场景,比如实体键盘) #### PR Dependency Tree * **PR #13384** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit * **New Features** * Improved virtual keyboard handling by introducing static keyboard height and app tab safe area tracking for more consistent toolbar behavior. * **Bug Fixes** * Enhanced keyboard visibility detection on Android and iOS, especially when a physical keyboard is connected. * **Refactor** * Simplified and streamlined keyboard toolbar logic, including delayed panel closing and refined height calculations. * Removed unused or redundant toolbar closing methods and position management logic. * **Style** * Updated toolbar and panel styles for better positioning and layout consistency. * Adjusted and removed certain mobile-specific padding styles. #### PR Dependency Tree * **PR #13384** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) --- .../src/services/virtual-keyboard-service.ts | 8 ++ .../widgets/keyboard-toolbar/src/config.ts | 4 - .../src/keyboard-tool-panel.ts | 21 ++--- .../keyboard-toolbar/src/keyboard-toolbar.ts | 83 ++++++++++++++----- .../src/position-controller.ts | 42 ---------- .../widgets/keyboard-toolbar/src/styles.ts | 11 ++- .../widgets/keyboard-toolbar/src/widget.ts | 13 --- packages/frontend/apps/android/src/app.tsx | 4 +- packages/frontend/apps/ios/src/app.tsx | 4 +- .../mobile/keyboard-toolbar-extension.ts | 10 +++ .../core/src/mobile/styles/mobile.css.ts | 3 - 11 files changed, 103 insertions(+), 100 deletions(-) delete mode 100644 blocksuite/affine/widgets/keyboard-toolbar/src/position-controller.ts diff --git a/blocksuite/affine/shared/src/services/virtual-keyboard-service.ts b/blocksuite/affine/shared/src/services/virtual-keyboard-service.ts index e4dee0e3e..6b51ba949 100644 --- a/blocksuite/affine/shared/src/services/virtual-keyboard-service.ts +++ b/blocksuite/affine/shared/src/services/virtual-keyboard-service.ts @@ -4,6 +4,14 @@ import type { ReadonlySignal } from '@preact/signals-core'; export interface VirtualKeyboardProvider { readonly visible$: ReadonlySignal; readonly height$: ReadonlySignal; + /** + * The static height of the keyboard, it should record the last non-zero height of virtual keyboard + */ + readonly staticHeight$: ReadonlySignal; + /** + * The safe area of the app tab, it will be used when the keyboard is open or closed + */ + readonly appTabSafeArea$: ReadonlySignal; } export interface VirtualKeyboardProviderWithAction diff --git a/blocksuite/affine/widgets/keyboard-toolbar/src/config.ts b/blocksuite/affine/widgets/keyboard-toolbar/src/config.ts index 653417053..9cdf72e56 100644 --- a/blocksuite/affine/widgets/keyboard-toolbar/src/config.ts +++ b/blocksuite/affine/widgets/keyboard-toolbar/src/config.ts @@ -168,10 +168,6 @@ export type KeyboardSubToolbarConfig = { export type KeyboardToolbarContext = { std: BlockStdScope; rootComponent: BlockComponent; - /** - * Close tool bar, and blur the focus if blur is true, default is false - */ - closeToolbar: (blur?: boolean) => void; /** * Close current tool panel and show virtual keyboard */ diff --git a/blocksuite/affine/widgets/keyboard-toolbar/src/keyboard-tool-panel.ts b/blocksuite/affine/widgets/keyboard-toolbar/src/keyboard-tool-panel.ts index ea1495f14..dc5df01d2 100644 --- a/blocksuite/affine/widgets/keyboard-toolbar/src/keyboard-tool-panel.ts +++ b/blocksuite/affine/widgets/keyboard-toolbar/src/keyboard-tool-panel.ts @@ -71,21 +71,18 @@ export class AffineKeyboardToolPanel extends SignalWatcher( .map(group => (typeof group === 'function' ? group(this.context) : group)) .filter((group): group is KeyboardToolPanelGroup => group !== null); - return repeat( - groups, - group => group.name, - group => this._renderGroup(group) - ); + return html`
+ ${repeat( + groups, + group => group.name, + group => this._renderGroup(group) + )} +
`; } protected override willUpdate(changedProperties: PropertyValues) { if (changedProperties.has('height')) { - this.style.height = `${this.height}px`; - if (this.height === 0) { - this.style.padding = '0'; - } else { - this.style.padding = ''; - } + this.style.height = this.height; } } @@ -96,5 +93,5 @@ export class AffineKeyboardToolPanel extends SignalWatcher( accessor context!: KeyboardToolbarContext; @property({ attribute: false }) - accessor height = 0; + accessor height = ''; } diff --git a/blocksuite/affine/widgets/keyboard-toolbar/src/keyboard-toolbar.ts b/blocksuite/affine/widgets/keyboard-toolbar/src/keyboard-toolbar.ts index fba1dcba4..d3e4db782 100644 --- a/blocksuite/affine/widgets/keyboard-toolbar/src/keyboard-toolbar.ts +++ b/blocksuite/affine/widgets/keyboard-toolbar/src/keyboard-toolbar.ts @@ -8,7 +8,7 @@ import { requiredProperties, ShadowlessElement, } from '@blocksuite/std'; -import { effect, type Signal, signal, untracked } from '@preact/signals-core'; +import { effect, type Signal, signal } from '@preact/signals-core'; import { html } from 'lit'; import { property } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; @@ -22,7 +22,6 @@ import type { KeyboardToolbarItem, KeyboardToolPanelConfig, } from './config'; -import { PositionController } from './position-controller'; import { keyboardToolbarStyles } from './styles'; import { isKeyboardSubToolBarConfig, @@ -41,10 +40,7 @@ export class AffineKeyboardToolbar extends SignalWatcher( ) { static override styles = keyboardToolbarStyles; - /** This field records the panel static height same as the virtual keyboard height */ - panelHeight$ = signal(0); - - positionController = new PositionController(this); + private readonly _expanded$ = signal(false); get std() { return this.rootComponent.std; @@ -54,9 +50,31 @@ export class AffineKeyboardToolbar extends SignalWatcher( return this._currentPanelIndex$.value !== -1; } + private get panelHeight() { + return this._expanded$.value + ? `${ + this.keyboard.staticHeight$.value !== 0 + ? this.keyboard.staticHeight$.value + : 330 + }px` + : this.keyboard.appTabSafeArea$.value; + } + + /** + * Prevent flickering during keyboard opening + */ + private _resetPanelIndexTimeoutId: ReturnType | null = + null; private readonly _closeToolPanel = () => { - this._currentPanelIndex$.value = -1; if (!this.keyboard.visible$.peek()) this.keyboard.show(); + + if (this._resetPanelIndexTimeoutId) { + clearTimeout(this._resetPanelIndexTimeoutId); + this._resetPanelIndexTimeoutId = null; + } + this._resetPanelIndexTimeoutId = setTimeout(() => { + this._currentPanelIndex$.value = -1; + }, 100); }; private readonly _currentPanelIndex$ = signal(-1); @@ -83,6 +101,10 @@ export class AffineKeyboardToolbar extends SignalWatcher( if (this._currentPanelIndex$.value === index) { this._closeToolPanel(); } else { + if (this._resetPanelIndexTimeoutId) { + clearTimeout(this._resetPanelIndexTimeoutId); + this._resetPanelIndexTimeoutId = null; + } this._currentPanelIndex$.value = index; this.keyboard.hide(); this._scrollCurrentBlockIntoView(); @@ -123,9 +145,6 @@ export class AffineKeyboardToolbar extends SignalWatcher( return { std: this.std, rootComponent: this.rootComponent, - closeToolbar: (blur = false) => { - this.close(blur); - }, closeToolPanel: () => { this._closeToolPanel(); }, @@ -226,7 +245,15 @@ export class AffineKeyboardToolbar extends SignalWatcher( { - this.close(true); + if (this.keyboard.staticHeight$.value === 0) { + this._closeToolPanel(); + return; + } + if (this.keyboard.visible$.peek()) { + this.keyboard.hide(); + } else { + this.keyboard.show(); + } }} > ${KeyboardIcon()} @@ -237,6 +264,23 @@ export class AffineKeyboardToolbar extends SignalWatcher( override connectedCallback() { super.connectedCallback(); + // There are two cases that `_expanded$` will be true: + // 1. when virtual keyboard is opened, the panel need to be expanded and overlapped by the keyboard, + // so that the toolbar will be on the top of the keyboard. + // 2. the panel is opened, whether the keyboard is closed or not exists (e.g. a physical keyboard connected) + // + // There is one case that `_expanded$` will be false: + // 1. the panel is closed, and the keyboard is closed, the toolbar will be rendered at the bottom of the viewport + this._disposables.add( + effect(() => { + if (this.keyboard.visible$.value || this.panelOpened) { + this._expanded$.value = true; + } else { + this._expanded$.value = false; + } + }) + ); + // prevent editor blur when click item in toolbar this.disposables.addFromEvent(this, 'pointerdown', e => { e.preventDefault(); @@ -260,15 +304,17 @@ export class AffineKeyboardToolbar extends SignalWatcher( if (this.keyboard.visible$.value) { this._closeToolPanel(); } - // when keyboard is closed and the panel is not opened, we need to close the toolbar, - // this usually happens when user close keyboard from system side - else if (this.hasUpdated && untracked(() => !this.panelOpened)) { - this.close(true); - } }) ); this._watchAutoShow(); + + this.disposables.add(() => { + if (this._resetPanelIndexTimeoutId) { + clearTimeout(this._resetPanelIndexTimeoutId); + this._resetPanelIndexTimeoutId = null; + } + }); } private _watchAutoShow() { @@ -331,7 +377,7 @@ export class AffineKeyboardToolbar extends SignalWatcher( `; } @@ -339,9 +385,6 @@ export class AffineKeyboardToolbar extends SignalWatcher( @property({ attribute: false }) accessor keyboard!: VirtualKeyboardProviderWithAction; - @property({ attribute: false }) - accessor close: (blur: boolean) => void = () => {}; - @property({ attribute: false }) accessor config!: KeyboardToolbarConfig; diff --git a/blocksuite/affine/widgets/keyboard-toolbar/src/position-controller.ts b/blocksuite/affine/widgets/keyboard-toolbar/src/position-controller.ts deleted file mode 100644 index bf8a7337d..000000000 --- a/blocksuite/affine/widgets/keyboard-toolbar/src/position-controller.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { type VirtualKeyboardProvider } from '@blocksuite/affine-shared/services'; -import { DisposableGroup } from '@blocksuite/global/disposable'; -import type { BlockStdScope, ShadowlessElement } from '@blocksuite/std'; -import { effect, type Signal } from '@preact/signals-core'; -import type { ReactiveController, ReactiveControllerHost } from 'lit'; - -/** - * This controller is used to control the keyboard toolbar position - */ -export class PositionController implements ReactiveController { - private readonly _disposables = new DisposableGroup(); - - host: ReactiveControllerHost & - ShadowlessElement & { - std: BlockStdScope; - panelHeight$: Signal; - keyboard: VirtualKeyboardProvider; - panelOpened: boolean; - }; - - constructor(host: PositionController['host']) { - (this.host = host).addController(this); - } - - hostConnected() { - const { keyboard } = this.host; - - this._disposables.add( - effect(() => { - if (keyboard.visible$.value) { - this.host.panelHeight$.value = keyboard.height$.value; - } - }) - ); - - this.host.style.bottom = '0px'; - } - - hostDisconnected() { - this._disposables.dispose(); - } -} diff --git a/blocksuite/affine/widgets/keyboard-toolbar/src/styles.ts b/blocksuite/affine/widgets/keyboard-toolbar/src/styles.ts index 774bbed2b..193971044 100644 --- a/blocksuite/affine/widgets/keyboard-toolbar/src/styles.ts +++ b/blocksuite/affine/widgets/keyboard-toolbar/src/styles.ts @@ -7,6 +7,7 @@ export const keyboardToolbarStyles = css` position: fixed; display: block; width: 100vw; + bottom: 0; } .keyboard-toolbar { @@ -60,14 +61,18 @@ export const keyboardToolbarStyles = css` export const keyboardToolPanelStyles = css` affine-keyboard-tool-panel { + display: block; + overflow-y: auto; + box-sizing: border-box; + background-color: ${unsafeCSSVarV2('layer/background/primary')}; + } + + .affine-keyboard-tool-panel-container { display: flex; flex-direction: column; gap: 24px; width: 100%; padding: 16px 4px 8px 8px; - overflow-y: auto; - box-sizing: border-box; - background-color: ${unsafeCSSVarV2('layer/background/primary')}; } ${scrollbarStyle('affine-keyboard-tool-panel')} diff --git a/blocksuite/affine/widgets/keyboard-toolbar/src/widget.ts b/blocksuite/affine/widgets/keyboard-toolbar/src/widget.ts index 19b0455b7..7dda4385c 100644 --- a/blocksuite/affine/widgets/keyboard-toolbar/src/widget.ts +++ b/blocksuite/affine/widgets/keyboard-toolbar/src/widget.ts @@ -20,18 +20,6 @@ import { export const AFFINE_KEYBOARD_TOOLBAR_WIDGET = 'affine-keyboard-toolbar-widget'; export class AffineKeyboardToolbarWidget extends WidgetComponent { - private readonly _close = (blur: boolean) => { - if (blur) { - if (document.activeElement === this._docTitle?.inlineEditorContainer) { - this._docTitle?.inlineEditor?.setInlineRange(null); - this._docTitle?.inlineEditor?.eventSource?.blur(); - } else if (document.activeElement === this.block?.rootComponent) { - this.std.selection.clear(); - } - } - this._show$.value = false; - }; - private readonly _show$ = signal(false); private _initialInputMode: string = ''; @@ -129,7 +117,6 @@ export class AffineKeyboardToolbarWidget extends WidgetComponent .keyboard=${this.keyboard} .config=${this.config} .rootComponent=${this.block.rootComponent} - .close=${this._close} >`} >`; } diff --git a/packages/frontend/apps/android/src/app.tsx b/packages/frontend/apps/android/src/app.tsx index e4c14cc22..17c1b816c 100644 --- a/packages/frontend/apps/android/src/app.tsx +++ b/packages/frontend/apps/android/src/app.tsx @@ -120,7 +120,9 @@ framework.impl(VirtualKeyboardProvider, { const navBarHeight = (await AffineTheme.getSystemNavBarHeight()) .height; callback({ - visible: true, + // When an physical keyboard is connected, the virtual keyboard height is 0, + // even though the `keyboardWillShow` event is still triggered. + visible: info.keyboardHeight !== 0, height: info.keyboardHeight - navBarHeight, }); })().catch(console.error); diff --git a/packages/frontend/apps/ios/src/app.tsx b/packages/frontend/apps/ios/src/app.tsx index fe5cc9d4e..bb8bd6f2a 100644 --- a/packages/frontend/apps/ios/src/app.tsx +++ b/packages/frontend/apps/ios/src/app.tsx @@ -121,9 +121,9 @@ framework.impl(VirtualKeyboardProvider, { }; Promise.all([ - Keyboard.addListener('keyboardDidShow', info => { + Keyboard.addListener('keyboardWillShow', info => { callback({ - visible: true, + visible: info.keyboardHeight !== 0, height: info.keyboardHeight, }); }), diff --git a/packages/frontend/core/src/blocksuite/view-extensions/mobile/keyboard-toolbar-extension.ts b/packages/frontend/core/src/blocksuite/view-extensions/mobile/keyboard-toolbar-extension.ts index ff0d567a6..df3271757 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/mobile/keyboard-toolbar-extension.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/mobile/keyboard-toolbar-extension.ts @@ -1,4 +1,5 @@ import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard'; +import { globalVars } from '@affine/core/mobile/styles/variables.css'; import type { Container } from '@blocksuite/affine/global/di'; import { DisposableGroup } from '@blocksuite/affine/global/disposable'; import { @@ -29,6 +30,12 @@ export function KeyboardToolbarExtension( // eslint-disable-next-line rxjs/finnish readonly height$ = signal(0); + // eslint-disable-next-line rxjs/finnish + readonly staticHeight$ = signal(0); + + // eslint-disable-next-line rxjs/finnish + readonly appTabSafeArea$ = signal(`calc(${globalVars.appTabSafeArea})`); + static override setup(di: Container) { super.setup(di); di.addImpl(BSVirtualKeyboardProvider, provider => { @@ -40,6 +47,9 @@ export function KeyboardToolbarExtension( this._disposables.add( affineVirtualKeyboardProvider.onChange(({ visible, height }) => { batch(() => { + if (visible && this.staticHeight$.peek() !== height) { + this.staticHeight$.value = height; + } this.visible$.value = visible; this.height$.value = height; }); diff --git a/packages/frontend/core/src/mobile/styles/mobile.css.ts b/packages/frontend/core/src/mobile/styles/mobile.css.ts index 55f648072..968c3d2fb 100644 --- a/packages/frontend/core/src/mobile/styles/mobile.css.ts +++ b/packages/frontend/core/src/mobile/styles/mobile.css.ts @@ -23,9 +23,6 @@ globalStyle('body:has(>#app-tabs):not(:has(affine-keyboard-toolbar))', { globalStyle('body:has(affine-keyboard-toolbar)', { paddingBottom: `calc(${globalVars.appKeyboardStaticHeight} + 46px)`, }); -globalStyle('body:has(>#app-tabs) affine-keyboard-tool-panel', { - paddingBottom: `calc(${globalVars.appTabSafeArea} + 8px)`, -}); globalStyle('body:has(>#app-tabs) edgeless-toolbar-widget', { bottom: globalVars.appTabSafeArea, });