From d476d3b1dfef4e3052e6375981100eaafc69ab8b Mon Sep 17 00:00:00 2001 From: L-Sun Date: Fri, 28 Feb 2025 08:23:26 +0000 Subject: [PATCH] fix(editor): android keyboard can not be opened (#10502) Close [BS-2674](https://linear.app/affine-design/issue/BS-2674/[android]-%E6%96%87%E6%9C%AC%E7%BC%96%E8%BE%91%E5%8C%BA%E5%9F%9F%E7%82%B9%E5%87%BB%E5%90%8E%E6%97%A0%E6%B3%95%E6%BF%80%E6%B4%BB%E9%94%AE%E7%9B%98) [BS-2609](https://linear.app/affine-design/issue/BS-2609/[android]-%E8%BE%93%E5%85%A5%E7%9A%84-toolbar-%E6%B2%A1%E6%9C%89%E4%BA%86) --- .../src/widgets/keyboard-toolbar/config.ts | 7 - .../src/widgets/keyboard-toolbar/index.ts | 21 +-- .../keyboard-toolbar/keyboard-toolbar.ts | 110 ++++-------- .../keyboard-toolbar/position-controller.ts | 58 +++++++ .../src/widgets/linked-doc/config.ts | 1 - .../src/widgets/linked-doc/index.ts | 1 - .../linked-doc/mobile-linked-doc-menu.ts | 24 +-- blocksuite/affine/components/package.json | 1 - .../components/src/doc-title/doc-title.ts | 4 + .../src/virtual-keyboard/controller.ts | 164 ------------------ .../components/src/virtual-keyboard/index.ts | 1 - .../affine/shared/src/services/index.ts | 1 + .../src/services/virtual-keyboard-service.ts | 12 ++ packages/frontend/apps/android/src/app.tsx | 47 ++++- .../frontend/apps/ios/capacitor.config.ts | 2 +- packages/frontend/apps/ios/src/app.tsx | 43 ++++- .../frontend/apps/ios/src/virtual-keyboard.ts | 162 ----------------- packages/frontend/apps/mobile/src/app.tsx | 39 +++++ .../block-suite-editor/lit-adaper.tsx | 2 +- .../extensions/entry/enable-mobile.ts | 100 +++++++++-- .../extensions/keyboard-toolbar-config.ts | 9 - .../src/mobile/components/app-tabs/index.tsx | 2 +- .../mobile/modules/virtual-keyboard/index.ts | 5 +- .../providers/virtual-keyboard.ts | 37 ++-- .../services/virtual-keyboard.ts | 27 +-- .../core/src/mobile/styles/mobile.css.ts | 3 - .../modules/at-menu-config/services/index.ts | 1 - 27 files changed, 356 insertions(+), 528 deletions(-) create mode 100644 blocksuite/affine/block-root/src/widgets/keyboard-toolbar/position-controller.ts delete mode 100644 blocksuite/affine/components/src/virtual-keyboard/controller.ts delete mode 100644 blocksuite/affine/components/src/virtual-keyboard/index.ts create mode 100644 blocksuite/affine/shared/src/services/virtual-keyboard-service.ts delete mode 100644 packages/frontend/apps/ios/src/virtual-keyboard.ts delete mode 100644 packages/frontend/core/src/blocksuite/extensions/keyboard-toolbar-config.ts diff --git a/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/config.ts b/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/config.ts index 44adc8fae6..5fa374aca3 100644 --- a/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/config.ts +++ b/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/config.ts @@ -113,12 +113,6 @@ import { export type KeyboardToolbarConfig = { items: KeyboardToolbarItem[]; - /** - * @description Whether to use the screen height as the keyboard height when the virtual keyboard API is not supported. - * It is useful when the app is running in a webview and the keyboard is not overlaid on the content. - * @default false - */ - useScreenHeight?: boolean; }; export type KeyboardToolbarItem = @@ -1106,5 +1100,4 @@ export const defaultKeyboardToolbarConfig: KeyboardToolbarConfig = { }, }, ], - useScreenHeight: false, }; diff --git a/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/index.ts b/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/index.ts index 4747f1769c..cf77141551 100644 --- a/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/index.ts +++ b/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/index.ts @@ -1,8 +1,8 @@ +import { getDocTitleByEditorHost } from '@blocksuite/affine-components/doc-title'; import type { RootBlockModel } from '@blocksuite/affine-model'; import { FeatureFlagService } from '@blocksuite/affine-shared/services'; import { WidgetComponent } from '@blocksuite/block-std'; import { IS_MOBILE } from '@blocksuite/global/env'; -import { assertType } from '@blocksuite/global/utils'; import { signal } from '@preact/signals-core'; import { html, nothing } from 'lit'; @@ -20,10 +20,10 @@ export class AffineKeyboardToolbarWidget extends WidgetComponent< > { private readonly _close = (blur: boolean) => { if (blur) { - if (document.activeElement === this._docTitle) { - this._docTitle?.blur(); + if (document.activeElement === this._docTitle?.inlineEditorContainer) { + this._docTitle?.inlineEditor?.setInlineRange(null); } else if (document.activeElement === this.block.rootComponent) { - this.block.rootComponent?.blur(); + this.std.selection.clear(); } } this._show$.value = false; @@ -31,12 +31,8 @@ export class AffineKeyboardToolbarWidget extends WidgetComponent< private readonly _show$ = signal(false); - private get _docTitle(): HTMLDivElement | null { - const docTitle = this.std.host - .closest('.affine-page-viewport') - ?.querySelector('doc-title rich-text .inline-editor'); - assertType(docTitle); - return docTitle; + private get _docTitle() { + return getDocTitleByEditorHost(this.std.host); } get config() { @@ -61,10 +57,11 @@ export class AffineKeyboardToolbarWidget extends WidgetComponent< } if (this._docTitle) { - this.disposables.addFromEvent(this._docTitle, 'focus', () => { + const { inlineEditorContainer } = this._docTitle; + this.disposables.addFromEvent(inlineEditorContainer, 'focus', () => { this._show$.value = true; }); - this.disposables.addFromEvent(this._docTitle, 'blur', () => { + this.disposables.addFromEvent(inlineEditorContainer, 'blur', () => { this._show$.value = false; }); } diff --git a/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/keyboard-toolbar.ts b/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/keyboard-toolbar.ts index 7cb91a6597..658aa117a3 100644 --- a/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/keyboard-toolbar.ts +++ b/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/keyboard-toolbar.ts @@ -1,8 +1,5 @@ -import { - VirtualKeyboardController, - type VirtualKeyboardControllerConfig, -} from '@blocksuite/affine-components/virtual-keyboard'; import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands'; +import { VirtualKeyboardProvider } from '@blocksuite/affine-shared/services'; import { PropTypes, requiredProperties, @@ -17,20 +14,21 @@ import { repeat } from 'lit/directives/repeat.js'; import { styleMap } from 'lit/directives/style-map.js'; import { when } from 'lit/directives/when.js'; -import { PageRootBlockComponent } from '../../page/page-root-block.js'; +import { PageRootBlockComponent } from '../../page/page-root-block'; import type { KeyboardIconType, KeyboardToolbarConfig, KeyboardToolbarContext, KeyboardToolbarItem, KeyboardToolPanelConfig, -} from './config.js'; -import { keyboardToolbarStyles, TOOLBAR_HEIGHT } from './styles.js'; +} from './config'; +import { PositionController } from './position-controller'; +import { keyboardToolbarStyles } from './styles'; import { isKeyboardSubToolBarConfig, isKeyboardToolBarActionItem, isKeyboardToolPanelConfig, -} from './utils.js'; +} from './utils'; export const AFFINE_KEYBOARD_TOOLBAR = 'affine-keyboard-toolbar'; @@ -43,11 +41,28 @@ 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); + + get std() { + return this.rootComponent.std; + } + + get keyboard() { + return this._context.std.get(VirtualKeyboardProvider); + } + + get panelOpened() { + return this._currentPanelIndex$.value !== -1; + } + private readonly _closeToolPanel = () => { - if (!this._isPanelOpened) return; + if (!this.panelOpened) return; this._currentPanelIndex$.value = -1; - this._keyboardController.show(); + this.keyboard.show(); }; private readonly _currentPanelIndex$ = signal(-1); @@ -55,7 +70,7 @@ export class AffineKeyboardToolbar extends SignalWatcher( private readonly _goPrevToolbar = () => { if (!this._isSubToolbarOpened) return; - if (this._isPanelOpened) this._closeToolPanel(); + if (this.panelOpened) this._closeToolPanel(); this._path$.value = this._path$.value.slice(0, -1); }; @@ -75,31 +90,25 @@ export class AffineKeyboardToolbar extends SignalWatcher( this._closeToolPanel(); } else { this._currentPanelIndex$.value = index; - this._keyboardController.hide(); - this.scrollCurrentBlockIntoView(); + this.keyboard.hide(); + this._scrollCurrentBlockIntoView(); } } this._lastActiveItem$.value = item; }; - private readonly _keyboardController = new VirtualKeyboardController(this); - private readonly _lastActiveItem$ = signal(null); - /** This field records the panel static height, which dose not aim to control the panel opening */ - private readonly _panelHeight$ = signal(0); - private readonly _path$ = signal([]); - private readonly scrollCurrentBlockIntoView = () => { - const { std } = this.rootComponent; - std.command + private readonly _scrollCurrentBlockIntoView = () => { + this.std.command .chain() .pipe(getSelectedModelsCommand) .pipe(({ selectedModels }) => { if (!selectedModels?.length) return; - const block = std.view.getBlock(selectedModels[0].id); + const block = this.std.view.getBlock(selectedModels[0].id); if (!block) return; const { y: y1 } = this.getBoundingClientRect(); @@ -118,7 +127,7 @@ export class AffineKeyboardToolbar extends SignalWatcher( private get _context(): KeyboardToolbarContext { return { - std: this.rootComponent.std, + std: this.std, rootComponent: this.rootComponent, closeToolbar: (blur = false) => { this.close(blur); @@ -130,7 +139,7 @@ export class AffineKeyboardToolbar extends SignalWatcher( } private get _currentPanelConfig(): KeyboardToolPanelConfig | null { - if (!this._isPanelOpened) return null; + if (!this.panelOpened) return null; const result = this._currentToolbarItems[this._currentPanelIndex$.value]; @@ -139,9 +148,7 @@ export class AffineKeyboardToolbar extends SignalWatcher( private get _currentToolbarItems(): KeyboardToolbarItem[] { let items = this.config.items; - // eslint-disable-next-line @typescript-eslint/prefer-for-of - for (let i = 0; i < this._path$.value.length; i++) { - const index = this._path$.value[i]; + for (const index of this._path$.value) { if (isKeyboardSubToolBarConfig(items[index])) { items = items[index].items; } else { @@ -156,21 +163,10 @@ export class AffineKeyboardToolbar extends SignalWatcher( ); } - private get _isPanelOpened() { - return this._currentPanelIndex$.value !== -1; - } - private get _isSubToolbarOpened() { return this._path$.value.length > 0; } - get virtualKeyboardControllerConfig(): VirtualKeyboardControllerConfig { - return { - useScreenHeight: this.config.useScreenHeight ?? false, - inputElement: this.rootComponent, - }; - } - private _renderIcon(icon: KeyboardIconType) { return typeof icon === 'function' ? icon(this._context) : icon; } @@ -252,35 +248,13 @@ export class AffineKeyboardToolbar extends SignalWatcher( e.preventDefault(); }); - this.disposables.add( - effect(() => { - if (this._keyboardController.opened) { - this._panelHeight$.value = this._keyboardController.keyboardHeight; - } else if (this._isPanelOpened && this._panelHeight$.peek() === 0) { - this._panelHeight$.value = 260; - } - }) - ); - - this.disposables.add( - effect(() => { - if (this._keyboardController.opened && !this.config.useScreenHeight) { - document.body.style.paddingBottom = `${this._keyboardController.keyboardHeight + TOOLBAR_HEIGHT}px`; - } else if (this._isPanelOpened) { - document.body.style.paddingBottom = `${this._panelHeight$.value + TOOLBAR_HEIGHT}px`; - } else { - document.body.style.paddingBottom = ''; - } - }) - ); - this.disposables.add( effect(() => { const std = this.rootComponent.std; std.selection.value; // wait cursor updated requestAnimationFrame(() => { - this.scrollCurrentBlockIntoView(); + this._scrollCurrentBlockIntoView(); }); }) ); @@ -328,24 +302,14 @@ export class AffineKeyboardToolbar extends SignalWatcher( ); } - override disconnectedCallback() { - super.disconnectedCallback(); - document.body.style.paddingBottom = ''; - } - override firstUpdated() { // workaround for the virtual keyboard showing transition animation setTimeout(() => { - this.scrollCurrentBlockIntoView(); + this._scrollCurrentBlockIntoView(); }, 700); } override render() { - this.style.bottom = - this.config.useScreenHeight && this._keyboardController.opened - ? `${-this._panelHeight$.value}px` - : '0px'; - return html`
${this._renderItems()} @@ -355,7 +319,7 @@ export class AffineKeyboardToolbar extends SignalWatcher( `; } diff --git a/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/position-controller.ts b/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/position-controller.ts new file mode 100644 index 0000000000..69bd9e8ce9 --- /dev/null +++ b/blocksuite/affine/block-root/src/widgets/keyboard-toolbar/position-controller.ts @@ -0,0 +1,58 @@ +import { type VirtualKeyboardProvider } from '@blocksuite/affine-shared/services'; +import type { BlockStdScope, ShadowlessElement } from '@blocksuite/block-std'; +import { DisposableGroup } from '@blocksuite/global/utils'; +import { effect, type Signal } from '@preact/signals-core'; +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +import { TOOLBAR_HEIGHT } from './styles'; + +/** + * 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, panelOpened } = this.host; + + this._disposables.add( + effect(() => { + if (keyboard.visible$.value) { + this.host.panelHeight$.value = keyboard.height$.value; + } + }) + ); + + this.host.style.bottom = '0px'; + this._disposables.add( + effect(() => { + if (keyboard.visible$.value) { + document.body.style.paddingBottom = `${keyboard.height$.value + TOOLBAR_HEIGHT}px`; + } else if (panelOpened) { + document.body.style.paddingBottom = `${this.host.panelHeight$.peek() + TOOLBAR_HEIGHT}px`; + } else { + document.body.style.paddingBottom = ''; + } + }) + ); + this._disposables.add(() => { + document.body.style.paddingBottom = ''; + }); + } + + hostDisconnected() { + this._disposables.dispose(); + } +} diff --git a/blocksuite/affine/block-root/src/widgets/linked-doc/config.ts b/blocksuite/affine/block-root/src/widgets/linked-doc/config.ts index aa8faa399e..c79d3c8d72 100644 --- a/blocksuite/affine/block-root/src/widgets/linked-doc/config.ts +++ b/blocksuite/affine/block-root/src/widgets/linked-doc/config.ts @@ -64,7 +64,6 @@ export interface LinkedWidgetConfig { ) => string | null; mobile: { - useScreenHeight?: boolean; /** * The linked doc menu widget will scroll the container to make sure the input cursor is visible in viewport. * It accepts a selector string, HTMLElement or Window diff --git a/blocksuite/affine/block-root/src/widgets/linked-doc/index.ts b/blocksuite/affine/block-root/src/widgets/linked-doc/index.ts index 88dcd048bc..0196ad2162 100644 --- a/blocksuite/affine/block-root/src/widgets/linked-doc/index.ts +++ b/blocksuite/affine/block-root/src/widgets/linked-doc/index.ts @@ -214,7 +214,6 @@ export class AffineLinkedDocWidget extends WidgetComponent< convertTriggerKey: true, getMenus, mobile: { - useScreenHeight: false, scrollContainer: getViewportElement(this.std.host) ?? window, scrollTopOffset: 46, }, diff --git a/blocksuite/affine/block-root/src/widgets/linked-doc/mobile-linked-doc-menu.ts b/blocksuite/affine/block-root/src/widgets/linked-doc/mobile-linked-doc-menu.ts index b00bdfe56c..21231b1135 100644 --- a/blocksuite/affine/block-root/src/widgets/linked-doc/mobile-linked-doc-menu.ts +++ b/blocksuite/affine/block-root/src/widgets/linked-doc/mobile-linked-doc-menu.ts @@ -2,10 +2,7 @@ import { cleanSpecifiedTail, getTextContentFromInlineRange, } from '@blocksuite/affine-components/rich-text'; -import { - VirtualKeyboardController, - type VirtualKeyboardControllerConfig, -} from '@blocksuite/affine-components/virtual-keyboard'; +import { VirtualKeyboardProvider } from '@blocksuite/affine-shared/services'; import { createKeydownObserver, getViewportElement, @@ -43,8 +40,6 @@ export class AffineMobileLinkedDocMenu extends SignalWatcher( private _firstActionItem: LinkedMenuItem | null = null; - private readonly _keyboardController = new VirtualKeyboardController(this); - private readonly _linkedDocGroup$ = signal([]); private readonly _renderGroup = (group: LinkedMenuGroup) => { @@ -159,11 +154,8 @@ export class AffineMobileLinkedDocMenu extends SignalWatcher( ); } - get virtualKeyboardControllerConfig(): VirtualKeyboardControllerConfig { - return { - useScreenHeight: this.context.config.mobile.useScreenHeight ?? false, - inputElement: this.rootComponent, - }; + get keyboard() { + return this.context.std.get(VirtualKeyboardProvider); } override connectedCallback() { @@ -230,8 +222,8 @@ export class AffineMobileLinkedDocMenu extends SignalWatcher( } override firstUpdated() { - if (!this._keyboardController.opened) { - this._keyboardController.show(); + if (!this.keyboard.visible$.value) { + this.keyboard.show(); } this._scrollInputToTop(); } @@ -244,11 +236,7 @@ export class AffineMobileLinkedDocMenu extends SignalWatcher( this._firstActionItem = resolveSignal(groups[0].items)[0]; - this.style.bottom = - this.context.config.mobile.useScreenHeight && - this._keyboardController.opened - ? '0px' - : `max(0px, ${this._keyboardController.keyboardHeight}px)`; + this.style.bottom = `${this.keyboard.height$.value}px`; return html` ${join(groups.map(this._renderGroup), html`
`)} diff --git a/blocksuite/affine/components/package.json b/blocksuite/affine/components/package.json index 1ad4818652..53380d58d9 100644 --- a/blocksuite/affine/components/package.json +++ b/blocksuite/affine/components/package.json @@ -55,7 +55,6 @@ "./date-picker": "./src/date-picker/index.ts", "./drop-indicator": "./src/drop-indicator/index.ts", "./filterable-list": "./src/filterable-list/index.ts", - "./virtual-keyboard": "./src/virtual-keyboard/index.ts", "./toggle-button": "./src/toggle-button/index.ts", "./toggle-switch": "./src/toggle-switch/index.ts", "./notification": "./src/notification/index.ts", diff --git a/blocksuite/affine/components/src/doc-title/doc-title.ts b/blocksuite/affine/components/src/doc-title/doc-title.ts index 8b29e5982a..2849d6263c 100644 --- a/blocksuite/affine/components/src/doc-title/doc-title.ts +++ b/blocksuite/affine/components/src/doc-title/doc-title.ts @@ -153,6 +153,10 @@ export class DocTitle extends WithDisposable(ShadowlessElement) { return this._richTextElement.inlineEditor; } + get inlineEditorContainer() { + return this._richTextElement.inlineEditorContainer; + } + override connectedCallback() { super.connectedCallback(); diff --git a/blocksuite/affine/components/src/virtual-keyboard/controller.ts b/blocksuite/affine/components/src/virtual-keyboard/controller.ts deleted file mode 100644 index fa394c6f89..0000000000 --- a/blocksuite/affine/components/src/virtual-keyboard/controller.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { IS_IOS } from '@blocksuite/global/env'; -import type * as GlobalTypes from '@blocksuite/global/types'; -import { DisposableGroup } from '@blocksuite/global/utils'; -import { signal } from '@preact/signals-core'; -import type { ReactiveController, ReactiveControllerHost } from 'lit'; - -declare type _GLOBAL_ = typeof GlobalTypes; - -function notSupportedWarning() { - console.warn('VirtualKeyboard API and VisualViewport API are not supported'); -} - -export type VirtualKeyboardControllerConfig = { - useScreenHeight: boolean; - inputElement: HTMLElement; -}; - -export class VirtualKeyboardController implements ReactiveController { - private readonly _disposables = new DisposableGroup(); - - private readonly _keyboardHeight$ = signal(0); - - private readonly _keyboardOpened$ = signal(false); - - private readonly _storeInitialInputElementAttributes = () => { - const { inputElement } = this.config; - if (navigator.virtualKeyboard) { - const { overlaysContent } = navigator.virtualKeyboard; - const { virtualKeyboardPolicy } = inputElement; - - this._disposables.add(() => { - if (!navigator.virtualKeyboard) return; - navigator.virtualKeyboard.overlaysContent = overlaysContent; - inputElement.virtualKeyboardPolicy = virtualKeyboardPolicy; - }); - } else if (visualViewport) { - const { inputMode } = inputElement; - this._disposables.add(() => { - inputElement.inputMode = inputMode; - }); - } - }; - - private readonly _updateKeyboardHeight = () => { - const { virtualKeyboard } = navigator; - if (virtualKeyboard) { - this._keyboardOpened$.value = virtualKeyboard.boundingRect.height > 0; - this._keyboardHeight$.value = virtualKeyboard.boundingRect.height; - } else if (visualViewport) { - const windowHeight = this.config.useScreenHeight - ? window.screen.height - : window.innerHeight; - - /** - * ┌───────────────┐ - window top - * │ │ - * │ │ - * │ │ - * │ │ - * │ │ - * └───────────────┘ - keyboard top -- - * │ │ │ keyboard height in layout viewport - * └───────────────┘ - page(html) bottom -- - * │ │ │ visualViewport.offsetTop - * └───────────────┘ - window bottom -- - */ - this._keyboardOpened$.value = windowHeight - visualViewport.height > 0; - this._keyboardHeight$.value = - windowHeight - - visualViewport.height - - (IS_IOS ? 0 : visualViewport.offsetTop); - } else { - notSupportedWarning(); - } - }; - - hide = () => { - if (navigator.virtualKeyboard) { - navigator.virtualKeyboard.hide(); - } else { - this.config.inputElement.inputMode = 'none'; - } - }; - - host: ReactiveControllerHost & { - virtualKeyboardControllerConfig: VirtualKeyboardControllerConfig; - hasUpdated: boolean; - }; - - show = () => { - if (navigator.virtualKeyboard) { - navigator.virtualKeyboard.show(); - } else { - this.config.inputElement.inputMode = ''; - } - }; - - toggle = () => { - if (this.opened) { - this.hide(); - } else { - this.show(); - } - }; - - get config() { - return this.host.virtualKeyboardControllerConfig; - } - - /** - * Return the height of keyboard in layout viewport - * see comment in the `_updateKeyboardHeight` method - */ - get keyboardHeight() { - return this._keyboardHeight$.value; - } - - get opened() { - return this._keyboardOpened$.value; - } - - constructor(host: VirtualKeyboardController['host']) { - (this.host = host).addController(this); - } - - hostConnected() { - this._storeInitialInputElementAttributes(); - - const { inputElement } = this.config; - - if (navigator.virtualKeyboard) { - navigator.virtualKeyboard.overlaysContent = true; - this.config.inputElement.virtualKeyboardPolicy = 'manual'; - - this._disposables.addFromEvent( - navigator.virtualKeyboard, - 'geometrychange', - this._updateKeyboardHeight - ); - } else if (visualViewport) { - this._disposables.addFromEvent( - visualViewport, - 'resize', - this._updateKeyboardHeight - ); - this._disposables.addFromEvent( - visualViewport, - 'scroll', - this._updateKeyboardHeight - ); - } else { - notSupportedWarning(); - } - - this._disposables.addFromEvent(inputElement, 'focus', this.show); - this._disposables.addFromEvent(inputElement, 'blur', this.hide); - - this._updateKeyboardHeight(); - } - - hostDisconnected() { - this._disposables.dispose(); - } -} diff --git a/blocksuite/affine/components/src/virtual-keyboard/index.ts b/blocksuite/affine/components/src/virtual-keyboard/index.ts deleted file mode 100644 index 534a0e5917..0000000000 --- a/blocksuite/affine/components/src/virtual-keyboard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './controller.js'; diff --git a/blocksuite/affine/shared/src/services/index.ts b/blocksuite/affine/shared/src/services/index.ts index 69ab3b324c..294e8f064e 100644 --- a/blocksuite/affine/shared/src/services/index.ts +++ b/blocksuite/affine/shared/src/services/index.ts @@ -18,3 +18,4 @@ export * from './quick-search-service'; export * from './sidebar-service'; export * from './telemetry-service'; export * from './theme-service'; +export * from './virtual-keyboard-service'; diff --git a/blocksuite/affine/shared/src/services/virtual-keyboard-service.ts b/blocksuite/affine/shared/src/services/virtual-keyboard-service.ts new file mode 100644 index 0000000000..57cbe0af0a --- /dev/null +++ b/blocksuite/affine/shared/src/services/virtual-keyboard-service.ts @@ -0,0 +1,12 @@ +import { createIdentifier } from '@blocksuite/global/di'; +import type { ReadonlySignal } from '@preact/signals-core'; + +export interface VirtualKeyboardProvider { + show: () => void; + hide: () => void; + readonly visible$: ReadonlySignal; + readonly height$: ReadonlySignal; +} + +export const VirtualKeyboardProvider = + createIdentifier('VirtualKeyboardProvider'); diff --git a/packages/frontend/apps/android/src/app.tsx b/packages/frontend/apps/android/src/app.tsx index dc5d4b892f..4413ae960e 100644 --- a/packages/frontend/apps/android/src/app.tsx +++ b/packages/frontend/apps/android/src/app.tsx @@ -97,15 +97,48 @@ framework.impl(ClientSchemeProvider, { }); framework.impl(VirtualKeyboardProvider, { - addEventListener: (event, callback) => { - Keyboard.addListener(event as any, callback as any).catch(e => { - console.error(e); + show: () => { + Keyboard.show().catch(console.error); + }, + hide: () => { + // In some cases, the keyboard will show again. for example, it will show again + // when this function is called in click event of button. It may be a bug of + // android webview or capacitor. + setTimeout(() => { + Keyboard.hide().catch(console.error); }); }, - removeAllListeners: () => { - Keyboard.removeAllListeners().catch(e => { - console.error(e); - }); + onChange: callback => { + let disposeRef = { + dispose: () => {}, + }; + + Promise.all([ + Keyboard.addListener('keyboardWillShow', info => { + callback({ + visible: true, + height: info.keyboardHeight, + }); + }), + Keyboard.addListener('keyboardWillHide', () => { + callback({ + visible: false, + height: 0, + }); + }), + ]) + .then(handlers => { + disposeRef.dispose = () => { + Promise.all(handlers.map(handler => handler.remove())).catch( + console.error + ); + }; + }) + .catch(console.error); + + return () => { + disposeRef.dispose(); + }; }, }); diff --git a/packages/frontend/apps/ios/capacitor.config.ts b/packages/frontend/apps/ios/capacitor.config.ts index a2ffe91b36..813644c162 100644 --- a/packages/frontend/apps/ios/capacitor.config.ts +++ b/packages/frontend/apps/ios/capacitor.config.ts @@ -20,7 +20,7 @@ const config: CapacitorConfig = { enabled: false, }, Keyboard: { - resize: KeyboardResize.Native, + resize: KeyboardResize.None, }, }, }; diff --git a/packages/frontend/apps/ios/src/app.tsx b/packages/frontend/apps/ios/src/app.tsx index f90388c079..a6544274f5 100644 --- a/packages/frontend/apps/ios/src/app.tsx +++ b/packages/frontend/apps/ios/src/app.tsx @@ -106,15 +106,40 @@ framework.impl(ValidatorProvider, { }, }); framework.impl(VirtualKeyboardProvider, { - addEventListener: (event, callback) => { - Keyboard.addListener(event as any, callback as any).catch(e => { - console.error(e); - }); - }, - removeAllListeners: () => { - Keyboard.removeAllListeners().catch(e => { - console.error(e); - }); + // We dose not provide show and hide because: + // - Keyboard.show() is not implemented + // - Keyboard.hide() will blur the current editor + onChange: callback => { + let disposeRef = { + dispose: () => {}, + }; + + Promise.all([ + Keyboard.addListener('keyboardDidShow', info => { + callback({ + visible: true, + height: info.keyboardHeight, + }); + }), + Keyboard.addListener('keyboardWillHide', () => { + callback({ + visible: false, + height: 0, + }); + }), + ]) + .then(handlers => { + disposeRef.dispose = () => { + Promise.all(handlers.map(handler => handler.remove())).catch( + console.error + ); + }; + }) + .catch(console.error); + + return () => { + disposeRef.dispose(); + }; }, }); framework.impl(NavigationGestureProvider, { diff --git a/packages/frontend/apps/ios/src/virtual-keyboard.ts b/packages/frontend/apps/ios/src/virtual-keyboard.ts deleted file mode 100644 index a767e436b9..0000000000 --- a/packages/frontend/apps/ios/src/virtual-keyboard.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { PluginListenerHandle } from '@capacitor/core/types/definitions'; -import { Keyboard } from '@capacitor/keyboard'; - -type VirtualKeyboardCallback = - | (( - this: VirtualKeyboard, - ev: VirtualKeyboardEventMap[K] - ) => any) - | EventListenerOrEventListenerObject; - -class NavigatorVirtualKeyboard implements VirtualKeyboard { - private readonly _boundingRect = new DOMRect(); - - private readonly _overlaysContent = false; - - private readonly _listeners = new Map< - string, - Set<{ - cb: VirtualKeyboardCallback; - options?: boolean | AddEventListenerOptions; - }> - >(); - - private _capacitorListenerHandles: PluginListenerHandle[] = []; - - private async _bindListener() { - const updateBoundingRect = (info?: { keyboardHeight: number }) => { - this.boundingRect.x = 0; - this.boundingRect.y = info ? window.innerHeight - info.keyboardHeight : 0; - this.boundingRect.width = window.innerWidth; - this.boundingRect.height = info ? info.keyboardHeight : 0; - this.dispatchEvent(new Event('geometrychange')); - }; - - this._capacitorListenerHandles = [ - await Keyboard.addListener('keyboardDidShow', updateBoundingRect), - await Keyboard.addListener('keyboardDidHide', updateBoundingRect), - ]; - } - - dispatchEvent = (event: Event) => { - const listeners = this._listeners.get(event.type); - if (listeners) { - for (const l of listeners) { - if (typeof l.cb === 'function') { - l.cb.call(this, event); - } else { - l.cb.handleEvent(event); - } - } - } - return !(event.cancelable && event.defaultPrevented); - }; - - constructor() { - this._bindListener().catch(e => { - console.error(e); - }); - } - - destroy() { - this._capacitorListenerHandles.forEach(handle => { - handle.remove().catch(e => { - console.error(e); - }); - }); - } - - get boundingRect(): DOMRect { - return this._boundingRect; - } - - get overlaysContent(): boolean { - return this._overlaysContent; - } - - set overlaysContent(_: boolean) { - console.warn( - 'overlaysContent is read-only in polyfill based on @capacitor/keyboard' - ); - } - - hide() { - Keyboard.hide().catch(e => { - console.error(e); - }); - } - - show() { - Keyboard.show().catch(e => { - console.error(e); - }); - } - - ongeometrychange: ((this: VirtualKeyboard, ev: Event) => any) | null = null; - - addEventListener( - type: K, - listener: VirtualKeyboardCallback, - options?: boolean | AddEventListenerOptions - ) { - if (!this._listeners.has(type)) { - this._listeners.set(type, new Set()); - } - - const listeners = this._listeners.get(type); - if (!listeners) return; - - listeners.add({ cb: listener, options }); - } - - removeEventListener( - type: K, - listener: VirtualKeyboardCallback, - options?: boolean | EventListenerOptions - ) { - const listeners = this._listeners.get(type); - if (!listeners) return; - - const sameCapture = ( - a?: boolean | AddEventListenerOptions, - b?: boolean | EventListenerOptions - ) => { - if (a === undefined && b === undefined) { - return true; - } - - if (typeof a === 'boolean' && typeof b === 'boolean') { - return a === b; - } - - if (typeof a === 'object' && typeof b === 'object') { - return a.capture === b.capture; - } - - if (typeof a === 'object' && typeof b === 'boolean') { - return a.capture === b; - } - - if (typeof a === 'boolean' && typeof b === 'object') { - return a === b.capture; - } - - return false; - }; - - let target = null; - for (const l of listeners) { - if (l.cb === listener && sameCapture(l.options, options)) { - target = l; - break; - } - } - - if (target) { - listeners.delete(target); - } - } -} - -// @ts-expect-error polyfill -navigator.virtualKeyboard = new NavigatorVirtualKeyboard(); diff --git a/packages/frontend/apps/mobile/src/app.tsx b/packages/frontend/apps/mobile/src/app.tsx index 3b7e5d86ca..eb8d56facf 100644 --- a/packages/frontend/apps/mobile/src/app.tsx +++ b/packages/frontend/apps/mobile/src/app.tsx @@ -2,6 +2,7 @@ import { AffineContext } from '@affine/core/components/context'; import { AppFallback } from '@affine/core/mobile/components/app-fallback'; import { configureMobileModules } from '@affine/core/mobile/modules'; import { HapticProvider } from '@affine/core/mobile/modules/haptics'; +import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard'; import { router } from '@affine/core/mobile/router'; import { configureCommonModules } from '@affine/core/modules'; import { I18nProvider } from '@affine/core/modules/i18n'; @@ -99,6 +100,44 @@ framework.impl(HapticProvider, { selectionChanged: () => Promise.reject('Not supported'), selectionEnd: () => Promise.reject('Not supported'), }); +framework.impl(VirtualKeyboardProvider, { + onChange: callback => { + if (!visualViewport) { + console.warn('visualViewport is not supported'); + return () => {}; + } + + const listener = () => { + if (!visualViewport) return; + const windowHeight = window.innerHeight; + + /** + * ┌───────────────┐ - window top + * │ │ + * │ │ + * │ │ + * │ │ + * │ │ + * └───────────────┘ - keyboard top -- + * │ │ │ keyboard height in layout viewport + * └───────────────┘ - page(html) bottom -- + * │ │ │ visualViewport.offsetTop + * └───────────────┘ - window bottom -- + */ + callback({ + visible: window.innerHeight - visualViewport.height > 0, + height: windowHeight - visualViewport.height - visualViewport.offsetTop, + }); + }; + + visualViewport.addEventListener('resize', listener); + visualViewport.addEventListener('scroll', listener); + return () => { + visualViewport?.removeEventListener('resize', listener); + visualViewport?.removeEventListener('scroll', listener); + }; + }, +}); const frameworkProvider = framework.provider(); // setup application lifecycle events, and emit application start event diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx index e112587225..62a22d678a 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx @@ -151,7 +151,7 @@ const usePatchSpecs = (mode: DocMode) => { builder.extend([patchForAttachmentEmbedViews(reactToLit)]); } if (BUILD_CONFIG.isMobileEdition) { - enableMobileExtension(builder); + enableMobileExtension(builder, framework); } if (BUILD_CONFIG.isElectron) { builder.extend([patchForClipboardInElectron(framework)].flat()); diff --git a/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts b/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts index 76ffedaf44..d94266c6c4 100644 --- a/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts +++ b/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts @@ -1,29 +1,36 @@ -import { createKeyboardToolbarConfig } from '@affine/core/blocksuite/extensions/keyboard-toolbar-config'; +import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard'; import { type BlockStdScope, ConfigIdentifier, LifeCycleWatcher, + LifeCycleWatcherIdentifier, } from '@blocksuite/affine/block-std'; import type { CodeBlockConfig, ReferenceNodeConfig, - RootBlockConfig, SpecBuilder, } from '@blocksuite/affine/blocks'; import { codeToolbarWidget, + DocModeProvider, embedCardToolbarWidget, FeatureFlagService, formatBarWidget, imageToolbarWidget, ParagraphBlockService, ReferenceNodeConfigIdentifier, - RootBlockConfigExtension, slashMenuWidget, surfaceRefToolbarWidget, + VirtualKeyboardProvider as BSVirtualKeyboardProvider, } from '@blocksuite/affine/blocks'; -import type { Container } from '@blocksuite/affine/global/di'; +import type { + Container, + ServiceIdentifier, +} from '@blocksuite/affine/global/di'; +import { DisposableGroup } from '@blocksuite/affine/global/utils'; import type { ExtensionType } from '@blocksuite/affine/store'; +import { batch, signal } from '@preact/signals-core'; +import type { FrameworkProvider } from '@toeverything/infra'; class MobileSpecsPatches extends LifeCycleWatcher { static override key = 'mobile-patches'; @@ -86,28 +93,85 @@ class MobileSpecsPatches extends LifeCycleWatcher { } } -const mobileExtensions: ExtensionType[] = [ +function KeyboardToolbarExtension(framework: FrameworkProvider): ExtensionType { + const affineVirtualKeyboardProvider = framework.get(VirtualKeyboardProvider); + + class BSVirtualKeyboardService + extends LifeCycleWatcher + implements BSVirtualKeyboardProvider { - setup: di => { - const prev = di.getFactory(RootBlockConfigExtension.identifier); + static override key = BSVirtualKeyboardProvider.identifierName; - di.override(RootBlockConfigExtension.identifier, provider => { - return { - ...prev?.(provider), - keyboardToolbar: createKeyboardToolbarConfig(), - } satisfies RootBlockConfig; + private readonly _disposables = new DisposableGroup(); + + private get _rootContentEditable() { + const editorMode = this.std.get(DocModeProvider).getEditorMode(); + if (editorMode !== 'page') return null; + + if (!this.std.host.doc.root) return; + return this.std.view.getBlock(this.std.host.doc.root.id); + } + + // eslint-disable-next-line rxjs/finnish + readonly visible$ = signal(false); + + // eslint-disable-next-line rxjs/finnish + readonly height$ = signal(0); + + show() { + if ('show' in affineVirtualKeyboardProvider) { + affineVirtualKeyboardProvider.show(); + } else if (this._rootContentEditable) { + this._rootContentEditable.inputMode = ''; + } + } + hide() { + if ('hide' in affineVirtualKeyboardProvider) { + affineVirtualKeyboardProvider.hide(); + } else if (this._rootContentEditable) { + this._rootContentEditable.inputMode = 'none'; + } + } + + static override setup(di: Container) { + super.setup(di); + di.addImpl(BSVirtualKeyboardProvider, provider => { + return provider.get( + LifeCycleWatcherIdentifier( + this.key + ) as ServiceIdentifier + ); }); - }, - }, - MobileSpecsPatches, -]; + } -export function enableMobileExtension(specBuilder: SpecBuilder): void { + override mounted() { + this._disposables.add( + affineVirtualKeyboardProvider.onChange(({ visible, height }) => { + batch(() => { + this.visible$.value = visible; + this.height$.value = height; + }); + }) + ); + } + + override unmounted() { + this._disposables.dispose(); + } + } + + return BSVirtualKeyboardService; +} + +export function enableMobileExtension( + specBuilder: SpecBuilder, + framework: FrameworkProvider +): void { specBuilder.omit(formatBarWidget); specBuilder.omit(embedCardToolbarWidget); specBuilder.omit(slashMenuWidget); specBuilder.omit(codeToolbarWidget); specBuilder.omit(imageToolbarWidget); specBuilder.omit(surfaceRefToolbarWidget); - specBuilder.extend(mobileExtensions); + specBuilder.extend([MobileSpecsPatches, KeyboardToolbarExtension(framework)]); } diff --git a/packages/frontend/core/src/blocksuite/extensions/keyboard-toolbar-config.ts b/packages/frontend/core/src/blocksuite/extensions/keyboard-toolbar-config.ts deleted file mode 100644 index 8a566f150e..0000000000 --- a/packages/frontend/core/src/blocksuite/extensions/keyboard-toolbar-config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type KeyboardToolbarConfig } from '@blocksuite/affine/blocks'; - -export function createKeyboardToolbarConfig(): Partial { - return { - // TODO(@L-Sun): check android following the PR - // https://github.com/toeverything/blocksuite/pull/8645 - useScreenHeight: BUILD_CONFIG.isIOS, - }; -} diff --git a/packages/frontend/core/src/mobile/components/app-tabs/index.tsx b/packages/frontend/core/src/mobile/components/app-tabs/index.tsx index b11f935b61..53e1c4c9b2 100644 --- a/packages/frontend/core/src/mobile/components/app-tabs/index.tsx +++ b/packages/frontend/core/src/mobile/components/app-tabs/index.tsx @@ -18,7 +18,7 @@ export const AppTabs = ({ fixed?: boolean; }) => { const virtualKeyboardService = useService(VirtualKeyboardService); - const virtualKeyboardVisible = useLiveData(virtualKeyboardService.show$); + const virtualKeyboardVisible = useLiveData(virtualKeyboardService.visible$); const tab = ( new VirtualKeyboardService(f.getOptional(VirtualKeyboardProvider)) - ); + framework.service(VirtualKeyboardService, [VirtualKeyboardProvider]); } diff --git a/packages/frontend/core/src/mobile/modules/virtual-keyboard/providers/virtual-keyboard.ts b/packages/frontend/core/src/mobile/modules/virtual-keyboard/providers/virtual-keyboard.ts index eacecca9b7..1637159f54 100644 --- a/packages/frontend/core/src/mobile/modules/virtual-keyboard/providers/virtual-keyboard.ts +++ b/packages/frontend/core/src/mobile/modules/virtual-keyboard/providers/virtual-keyboard.ts @@ -1,23 +1,28 @@ import { createIdentifier } from '@toeverything/infra'; -export type VirtualKeyboardEvent = - | 'keyboardWillShow' - | 'keyboardDidShow' - | 'keyboardWillHide' - | 'keyboardDidHide'; - -export interface VirtualKeyboardEventInfo { - keyboardHeight: number; +interface VirtualKeyboardInfo { + visible: boolean; + height: number; } -type VirtualKeyboardEventListener = (info: VirtualKeyboardEventInfo) => void; -export interface VirtualKeyboardProvider { - addEventListener: ( - event: VirtualKeyboardEvent, - callback: VirtualKeyboardEventListener - ) => void; - removeAllListeners: () => void; -} +type VirtualKeyboardAction = { + /** + * Open the virtual keyboard, the focused element should not be changed + */ + show: () => void; + /** + * Hide the virtual keyboard, the focused element should not be changed + */ + hide: () => void; +}; + +type VirtualKeyboardEvent = { + onChange: (callback: (info: VirtualKeyboardInfo) => void) => () => void; +}; + +export type VirtualKeyboardProvider = + | (VirtualKeyboardEvent & VirtualKeyboardAction) + | VirtualKeyboardEvent; export const VirtualKeyboardProvider = createIdentifier('VirtualKeyboardProvider'); diff --git a/packages/frontend/core/src/mobile/modules/virtual-keyboard/services/virtual-keyboard.ts b/packages/frontend/core/src/mobile/modules/virtual-keyboard/services/virtual-keyboard.ts index 42bc41197c..356d6381ca 100644 --- a/packages/frontend/core/src/mobile/modules/virtual-keyboard/services/virtual-keyboard.ts +++ b/packages/frontend/core/src/mobile/modules/virtual-keyboard/services/virtual-keyboard.ts @@ -3,32 +3,23 @@ import { LiveData, Service } from '@toeverything/infra'; import type { VirtualKeyboardProvider } from '../providers/virtual-keyboard'; export class VirtualKeyboardService extends Service { - show$ = new LiveData(false); - height$ = new LiveData(0); + readonly visible$ = new LiveData(false); + + readonly height$ = new LiveData(0); constructor( - private readonly virtualKeyboardProvider?: VirtualKeyboardProvider + private readonly virtualKeyboardProvider: VirtualKeyboardProvider ) { super(); this._observe(); } - override dispose() { - super.dispose(); - this.virtualKeyboardProvider?.removeAllListeners(); - } - private _observe() { - this.virtualKeyboardProvider?.addEventListener( - 'keyboardWillShow', - ({ keyboardHeight }) => { - this.show$.next(true); - this.height$.next(keyboardHeight); - } + this.disposables.push( + this.virtualKeyboardProvider.onChange(info => { + this.visible$.next(info.visible); + this.height$.next(info.height); + }) ); - this.virtualKeyboardProvider?.addEventListener('keyboardWillHide', () => { - this.show$.next(false); - this.height$.next(0); - }); } } diff --git a/packages/frontend/core/src/mobile/styles/mobile.css.ts b/packages/frontend/core/src/mobile/styles/mobile.css.ts index 9bf8ff8300..7beba3e019 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', { globalStyle('body:has(> #app-tabs)', { paddingBottom: globalVars.appTabSafeArea, }); -globalStyle('body:has(#app-tabs) affine-keyboard-toolbar[data-shrink="true"]', { - paddingBottom: globalVars.appTabSafeArea, -}); globalStyle('body:has(#app-tabs) affine-keyboard-tool-panel', { paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom) + 8px)`, }); diff --git a/packages/frontend/core/src/modules/at-menu-config/services/index.ts b/packages/frontend/core/src/modules/at-menu-config/services/index.ts index 88d0583ca7..b9ec20e7cf 100644 --- a/packages/frontend/core/src/modules/at-menu-config/services/index.ts +++ b/packages/frontend/core/src/modules/at-menu-config/services/index.ts @@ -322,7 +322,6 @@ export class AtMenuConfigService extends Service { private getMobileConfig(): Partial { return { - useScreenHeight: BUILD_CONFIG.isIOS, scrollContainer: window, scrollTopOffset: () => { const header = document.querySelector('header');