L-Sun
2025-02-28 08:23:26 +00:00
parent f1df774188
commit d476d3b1df
27 changed files with 356 additions and 528 deletions

View File

@@ -113,12 +113,6 @@ import {
export type KeyboardToolbarConfig = { export type KeyboardToolbarConfig = {
items: KeyboardToolbarItem[]; 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 = export type KeyboardToolbarItem =
@@ -1106,5 +1100,4 @@ export const defaultKeyboardToolbarConfig: KeyboardToolbarConfig = {
}, },
}, },
], ],
useScreenHeight: false,
}; };

View File

@@ -1,8 +1,8 @@
import { getDocTitleByEditorHost } from '@blocksuite/affine-components/doc-title';
import type { RootBlockModel } from '@blocksuite/affine-model'; import type { RootBlockModel } from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services'; import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { WidgetComponent } from '@blocksuite/block-std'; import { WidgetComponent } from '@blocksuite/block-std';
import { IS_MOBILE } from '@blocksuite/global/env'; import { IS_MOBILE } from '@blocksuite/global/env';
import { assertType } from '@blocksuite/global/utils';
import { signal } from '@preact/signals-core'; import { signal } from '@preact/signals-core';
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
@@ -20,10 +20,10 @@ export class AffineKeyboardToolbarWidget extends WidgetComponent<
> { > {
private readonly _close = (blur: boolean) => { private readonly _close = (blur: boolean) => {
if (blur) { if (blur) {
if (document.activeElement === this._docTitle) { if (document.activeElement === this._docTitle?.inlineEditorContainer) {
this._docTitle?.blur(); this._docTitle?.inlineEditor?.setInlineRange(null);
} else if (document.activeElement === this.block.rootComponent) { } else if (document.activeElement === this.block.rootComponent) {
this.block.rootComponent?.blur(); this.std.selection.clear();
} }
} }
this._show$.value = false; this._show$.value = false;
@@ -31,12 +31,8 @@ export class AffineKeyboardToolbarWidget extends WidgetComponent<
private readonly _show$ = signal(false); private readonly _show$ = signal(false);
private get _docTitle(): HTMLDivElement | null { private get _docTitle() {
const docTitle = this.std.host return getDocTitleByEditorHost(this.std.host);
.closest('.affine-page-viewport')
?.querySelector('doc-title rich-text .inline-editor');
assertType<HTMLDivElement | null>(docTitle);
return docTitle;
} }
get config() { get config() {
@@ -61,10 +57,11 @@ export class AffineKeyboardToolbarWidget extends WidgetComponent<
} }
if (this._docTitle) { if (this._docTitle) {
this.disposables.addFromEvent(this._docTitle, 'focus', () => { const { inlineEditorContainer } = this._docTitle;
this.disposables.addFromEvent(inlineEditorContainer, 'focus', () => {
this._show$.value = true; this._show$.value = true;
}); });
this.disposables.addFromEvent(this._docTitle, 'blur', () => { this.disposables.addFromEvent(inlineEditorContainer, 'blur', () => {
this._show$.value = false; this._show$.value = false;
}); });
} }

View File

@@ -1,8 +1,5 @@
import {
VirtualKeyboardController,
type VirtualKeyboardControllerConfig,
} from '@blocksuite/affine-components/virtual-keyboard';
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands'; import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
import { VirtualKeyboardProvider } from '@blocksuite/affine-shared/services';
import { import {
PropTypes, PropTypes,
requiredProperties, requiredProperties,
@@ -17,20 +14,21 @@ import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js'; import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.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 { import type {
KeyboardIconType, KeyboardIconType,
KeyboardToolbarConfig, KeyboardToolbarConfig,
KeyboardToolbarContext, KeyboardToolbarContext,
KeyboardToolbarItem, KeyboardToolbarItem,
KeyboardToolPanelConfig, KeyboardToolPanelConfig,
} from './config.js'; } from './config';
import { keyboardToolbarStyles, TOOLBAR_HEIGHT } from './styles.js'; import { PositionController } from './position-controller';
import { keyboardToolbarStyles } from './styles';
import { import {
isKeyboardSubToolBarConfig, isKeyboardSubToolBarConfig,
isKeyboardToolBarActionItem, isKeyboardToolBarActionItem,
isKeyboardToolPanelConfig, isKeyboardToolPanelConfig,
} from './utils.js'; } from './utils';
export const AFFINE_KEYBOARD_TOOLBAR = 'affine-keyboard-toolbar'; export const AFFINE_KEYBOARD_TOOLBAR = 'affine-keyboard-toolbar';
@@ -43,11 +41,28 @@ export class AffineKeyboardToolbar extends SignalWatcher(
) { ) {
static override styles = keyboardToolbarStyles; 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 = () => { private readonly _closeToolPanel = () => {
if (!this._isPanelOpened) return; if (!this.panelOpened) return;
this._currentPanelIndex$.value = -1; this._currentPanelIndex$.value = -1;
this._keyboardController.show(); this.keyboard.show();
}; };
private readonly _currentPanelIndex$ = signal(-1); private readonly _currentPanelIndex$ = signal(-1);
@@ -55,7 +70,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
private readonly _goPrevToolbar = () => { private readonly _goPrevToolbar = () => {
if (!this._isSubToolbarOpened) return; if (!this._isSubToolbarOpened) return;
if (this._isPanelOpened) this._closeToolPanel(); if (this.panelOpened) this._closeToolPanel();
this._path$.value = this._path$.value.slice(0, -1); this._path$.value = this._path$.value.slice(0, -1);
}; };
@@ -75,31 +90,25 @@ export class AffineKeyboardToolbar extends SignalWatcher(
this._closeToolPanel(); this._closeToolPanel();
} else { } else {
this._currentPanelIndex$.value = index; this._currentPanelIndex$.value = index;
this._keyboardController.hide(); this.keyboard.hide();
this.scrollCurrentBlockIntoView(); this._scrollCurrentBlockIntoView();
} }
} }
this._lastActiveItem$.value = item; this._lastActiveItem$.value = item;
}; };
private readonly _keyboardController = new VirtualKeyboardController(this);
private readonly _lastActiveItem$ = signal<KeyboardToolbarItem | null>(null); private readonly _lastActiveItem$ = signal<KeyboardToolbarItem | null>(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<number[]>([]); private readonly _path$ = signal<number[]>([]);
private readonly scrollCurrentBlockIntoView = () => { private readonly _scrollCurrentBlockIntoView = () => {
const { std } = this.rootComponent; this.std.command
std.command
.chain() .chain()
.pipe(getSelectedModelsCommand) .pipe(getSelectedModelsCommand)
.pipe(({ selectedModels }) => { .pipe(({ selectedModels }) => {
if (!selectedModels?.length) return; if (!selectedModels?.length) return;
const block = std.view.getBlock(selectedModels[0].id); const block = this.std.view.getBlock(selectedModels[0].id);
if (!block) return; if (!block) return;
const { y: y1 } = this.getBoundingClientRect(); const { y: y1 } = this.getBoundingClientRect();
@@ -118,7 +127,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
private get _context(): KeyboardToolbarContext { private get _context(): KeyboardToolbarContext {
return { return {
std: this.rootComponent.std, std: this.std,
rootComponent: this.rootComponent, rootComponent: this.rootComponent,
closeToolbar: (blur = false) => { closeToolbar: (blur = false) => {
this.close(blur); this.close(blur);
@@ -130,7 +139,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
} }
private get _currentPanelConfig(): KeyboardToolPanelConfig | null { private get _currentPanelConfig(): KeyboardToolPanelConfig | null {
if (!this._isPanelOpened) return null; if (!this.panelOpened) return null;
const result = this._currentToolbarItems[this._currentPanelIndex$.value]; const result = this._currentToolbarItems[this._currentPanelIndex$.value];
@@ -139,9 +148,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
private get _currentToolbarItems(): KeyboardToolbarItem[] { private get _currentToolbarItems(): KeyboardToolbarItem[] {
let items = this.config.items; let items = this.config.items;
// eslint-disable-next-line @typescript-eslint/prefer-for-of for (const index of this._path$.value) {
for (let i = 0; i < this._path$.value.length; i++) {
const index = this._path$.value[i];
if (isKeyboardSubToolBarConfig(items[index])) { if (isKeyboardSubToolBarConfig(items[index])) {
items = items[index].items; items = items[index].items;
} else { } else {
@@ -156,21 +163,10 @@ export class AffineKeyboardToolbar extends SignalWatcher(
); );
} }
private get _isPanelOpened() {
return this._currentPanelIndex$.value !== -1;
}
private get _isSubToolbarOpened() { private get _isSubToolbarOpened() {
return this._path$.value.length > 0; return this._path$.value.length > 0;
} }
get virtualKeyboardControllerConfig(): VirtualKeyboardControllerConfig {
return {
useScreenHeight: this.config.useScreenHeight ?? false,
inputElement: this.rootComponent,
};
}
private _renderIcon(icon: KeyboardIconType) { private _renderIcon(icon: KeyboardIconType) {
return typeof icon === 'function' ? icon(this._context) : icon; return typeof icon === 'function' ? icon(this._context) : icon;
} }
@@ -252,35 +248,13 @@ export class AffineKeyboardToolbar extends SignalWatcher(
e.preventDefault(); 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( this.disposables.add(
effect(() => { effect(() => {
const std = this.rootComponent.std; const std = this.rootComponent.std;
std.selection.value; std.selection.value;
// wait cursor updated // wait cursor updated
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.scrollCurrentBlockIntoView(); this._scrollCurrentBlockIntoView();
}); });
}) })
); );
@@ -328,24 +302,14 @@ export class AffineKeyboardToolbar extends SignalWatcher(
); );
} }
override disconnectedCallback() {
super.disconnectedCallback();
document.body.style.paddingBottom = '';
}
override firstUpdated() { override firstUpdated() {
// workaround for the virtual keyboard showing transition animation // workaround for the virtual keyboard showing transition animation
setTimeout(() => { setTimeout(() => {
this.scrollCurrentBlockIntoView(); this._scrollCurrentBlockIntoView();
}, 700); }, 700);
} }
override render() { override render() {
this.style.bottom =
this.config.useScreenHeight && this._keyboardController.opened
? `${-this._panelHeight$.value}px`
: '0px';
return html` return html`
<div class="keyboard-toolbar"> <div class="keyboard-toolbar">
${this._renderItems()} ${this._renderItems()}
@@ -355,7 +319,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
<affine-keyboard-tool-panel <affine-keyboard-tool-panel
.config=${this._currentPanelConfig} .config=${this._currentPanelConfig}
.context=${this._context} .context=${this._context}
height=${this._panelHeight$.value} height=${this.panelHeight$.value}
></affine-keyboard-tool-panel> ></affine-keyboard-tool-panel>
`; `;
} }

View File

@@ -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<number>;
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();
}
}

View File

@@ -64,7 +64,6 @@ export interface LinkedWidgetConfig {
) => string | null; ) => string | null;
mobile: { mobile: {
useScreenHeight?: boolean;
/** /**
* The linked doc menu widget will scroll the container to make sure the input cursor is visible in viewport. * 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 * It accepts a selector string, HTMLElement or Window

View File

@@ -214,7 +214,6 @@ export class AffineLinkedDocWidget extends WidgetComponent<
convertTriggerKey: true, convertTriggerKey: true,
getMenus, getMenus,
mobile: { mobile: {
useScreenHeight: false,
scrollContainer: getViewportElement(this.std.host) ?? window, scrollContainer: getViewportElement(this.std.host) ?? window,
scrollTopOffset: 46, scrollTopOffset: 46,
}, },

View File

@@ -2,10 +2,7 @@ import {
cleanSpecifiedTail, cleanSpecifiedTail,
getTextContentFromInlineRange, getTextContentFromInlineRange,
} from '@blocksuite/affine-components/rich-text'; } from '@blocksuite/affine-components/rich-text';
import { import { VirtualKeyboardProvider } from '@blocksuite/affine-shared/services';
VirtualKeyboardController,
type VirtualKeyboardControllerConfig,
} from '@blocksuite/affine-components/virtual-keyboard';
import { import {
createKeydownObserver, createKeydownObserver,
getViewportElement, getViewportElement,
@@ -43,8 +40,6 @@ export class AffineMobileLinkedDocMenu extends SignalWatcher(
private _firstActionItem: LinkedMenuItem | null = null; private _firstActionItem: LinkedMenuItem | null = null;
private readonly _keyboardController = new VirtualKeyboardController(this);
private readonly _linkedDocGroup$ = signal<LinkedMenuGroup[]>([]); private readonly _linkedDocGroup$ = signal<LinkedMenuGroup[]>([]);
private readonly _renderGroup = (group: LinkedMenuGroup) => { private readonly _renderGroup = (group: LinkedMenuGroup) => {
@@ -159,11 +154,8 @@ export class AffineMobileLinkedDocMenu extends SignalWatcher(
); );
} }
get virtualKeyboardControllerConfig(): VirtualKeyboardControllerConfig { get keyboard() {
return { return this.context.std.get(VirtualKeyboardProvider);
useScreenHeight: this.context.config.mobile.useScreenHeight ?? false,
inputElement: this.rootComponent,
};
} }
override connectedCallback() { override connectedCallback() {
@@ -230,8 +222,8 @@ export class AffineMobileLinkedDocMenu extends SignalWatcher(
} }
override firstUpdated() { override firstUpdated() {
if (!this._keyboardController.opened) { if (!this.keyboard.visible$.value) {
this._keyboardController.show(); this.keyboard.show();
} }
this._scrollInputToTop(); this._scrollInputToTop();
} }
@@ -244,11 +236,7 @@ export class AffineMobileLinkedDocMenu extends SignalWatcher(
this._firstActionItem = resolveSignal(groups[0].items)[0]; this._firstActionItem = resolveSignal(groups[0].items)[0];
this.style.bottom = this.style.bottom = `${this.keyboard.height$.value}px`;
this.context.config.mobile.useScreenHeight &&
this._keyboardController.opened
? '0px'
: `max(0px, ${this._keyboardController.keyboardHeight}px)`;
return html` return html`
${join(groups.map(this._renderGroup), html`<div class="divider"></div>`)} ${join(groups.map(this._renderGroup), html`<div class="divider"></div>`)}

View File

@@ -55,7 +55,6 @@
"./date-picker": "./src/date-picker/index.ts", "./date-picker": "./src/date-picker/index.ts",
"./drop-indicator": "./src/drop-indicator/index.ts", "./drop-indicator": "./src/drop-indicator/index.ts",
"./filterable-list": "./src/filterable-list/index.ts", "./filterable-list": "./src/filterable-list/index.ts",
"./virtual-keyboard": "./src/virtual-keyboard/index.ts",
"./toggle-button": "./src/toggle-button/index.ts", "./toggle-button": "./src/toggle-button/index.ts",
"./toggle-switch": "./src/toggle-switch/index.ts", "./toggle-switch": "./src/toggle-switch/index.ts",
"./notification": "./src/notification/index.ts", "./notification": "./src/notification/index.ts",

View File

@@ -153,6 +153,10 @@ export class DocTitle extends WithDisposable(ShadowlessElement) {
return this._richTextElement.inlineEditor; return this._richTextElement.inlineEditor;
} }
get inlineEditorContainer() {
return this._richTextElement.inlineEditorContainer;
}
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();

View File

@@ -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();
}
}

View File

@@ -1 +0,0 @@
export * from './controller.js';

View File

@@ -18,3 +18,4 @@ export * from './quick-search-service';
export * from './sidebar-service'; export * from './sidebar-service';
export * from './telemetry-service'; export * from './telemetry-service';
export * from './theme-service'; export * from './theme-service';
export * from './virtual-keyboard-service';

View File

@@ -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<boolean>;
readonly height$: ReadonlySignal<number>;
}
export const VirtualKeyboardProvider =
createIdentifier<VirtualKeyboardProvider>('VirtualKeyboardProvider');

View File

@@ -97,15 +97,48 @@ framework.impl(ClientSchemeProvider, {
}); });
framework.impl(VirtualKeyboardProvider, { framework.impl(VirtualKeyboardProvider, {
addEventListener: (event, callback) => { show: () => {
Keyboard.addListener(event as any, callback as any).catch(e => { Keyboard.show().catch(console.error);
console.error(e); },
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: () => { onChange: callback => {
Keyboard.removeAllListeners().catch(e => { let disposeRef = {
console.error(e); 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();
};
}, },
}); });

View File

@@ -20,7 +20,7 @@ const config: CapacitorConfig = {
enabled: false, enabled: false,
}, },
Keyboard: { Keyboard: {
resize: KeyboardResize.Native, resize: KeyboardResize.None,
}, },
}, },
}; };

View File

@@ -106,15 +106,40 @@ framework.impl(ValidatorProvider, {
}, },
}); });
framework.impl(VirtualKeyboardProvider, { framework.impl(VirtualKeyboardProvider, {
addEventListener: (event, callback) => { // We dose not provide show and hide because:
Keyboard.addListener(event as any, callback as any).catch(e => { // - Keyboard.show() is not implemented
console.error(e); // - Keyboard.hide() will blur the current editor
}); onChange: callback => {
}, let disposeRef = {
removeAllListeners: () => { dispose: () => {},
Keyboard.removeAllListeners().catch(e => { };
console.error(e);
}); 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, { framework.impl(NavigationGestureProvider, {

View File

@@ -1,162 +0,0 @@
import type { PluginListenerHandle } from '@capacitor/core/types/definitions';
import { Keyboard } from '@capacitor/keyboard';
type VirtualKeyboardCallback =
| (<K extends keyof VirtualKeyboardEventMap>(
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<K extends keyof VirtualKeyboardEventMap>(
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<K extends keyof VirtualKeyboardEventMap>(
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();

View File

@@ -2,6 +2,7 @@ import { AffineContext } from '@affine/core/components/context';
import { AppFallback } from '@affine/core/mobile/components/app-fallback'; import { AppFallback } from '@affine/core/mobile/components/app-fallback';
import { configureMobileModules } from '@affine/core/mobile/modules'; import { configureMobileModules } from '@affine/core/mobile/modules';
import { HapticProvider } from '@affine/core/mobile/modules/haptics'; 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 { router } from '@affine/core/mobile/router';
import { configureCommonModules } from '@affine/core/modules'; import { configureCommonModules } from '@affine/core/modules';
import { I18nProvider } from '@affine/core/modules/i18n'; import { I18nProvider } from '@affine/core/modules/i18n';
@@ -99,6 +100,44 @@ framework.impl(HapticProvider, {
selectionChanged: () => Promise.reject('Not supported'), selectionChanged: () => Promise.reject('Not supported'),
selectionEnd: () => 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(); const frameworkProvider = framework.provider();
// setup application lifecycle events, and emit application start event // setup application lifecycle events, and emit application start event

View File

@@ -151,7 +151,7 @@ const usePatchSpecs = (mode: DocMode) => {
builder.extend([patchForAttachmentEmbedViews(reactToLit)]); builder.extend([patchForAttachmentEmbedViews(reactToLit)]);
} }
if (BUILD_CONFIG.isMobileEdition) { if (BUILD_CONFIG.isMobileEdition) {
enableMobileExtension(builder); enableMobileExtension(builder, framework);
} }
if (BUILD_CONFIG.isElectron) { if (BUILD_CONFIG.isElectron) {
builder.extend([patchForClipboardInElectron(framework)].flat()); builder.extend([patchForClipboardInElectron(framework)].flat());

View File

@@ -1,29 +1,36 @@
import { createKeyboardToolbarConfig } from '@affine/core/blocksuite/extensions/keyboard-toolbar-config'; import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard';
import { import {
type BlockStdScope, type BlockStdScope,
ConfigIdentifier, ConfigIdentifier,
LifeCycleWatcher, LifeCycleWatcher,
LifeCycleWatcherIdentifier,
} from '@blocksuite/affine/block-std'; } from '@blocksuite/affine/block-std';
import type { import type {
CodeBlockConfig, CodeBlockConfig,
ReferenceNodeConfig, ReferenceNodeConfig,
RootBlockConfig,
SpecBuilder, SpecBuilder,
} from '@blocksuite/affine/blocks'; } from '@blocksuite/affine/blocks';
import { import {
codeToolbarWidget, codeToolbarWidget,
DocModeProvider,
embedCardToolbarWidget, embedCardToolbarWidget,
FeatureFlagService, FeatureFlagService,
formatBarWidget, formatBarWidget,
imageToolbarWidget, imageToolbarWidget,
ParagraphBlockService, ParagraphBlockService,
ReferenceNodeConfigIdentifier, ReferenceNodeConfigIdentifier,
RootBlockConfigExtension,
slashMenuWidget, slashMenuWidget,
surfaceRefToolbarWidget, surfaceRefToolbarWidget,
VirtualKeyboardProvider as BSVirtualKeyboardProvider,
} from '@blocksuite/affine/blocks'; } 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 type { ExtensionType } from '@blocksuite/affine/store';
import { batch, signal } from '@preact/signals-core';
import type { FrameworkProvider } from '@toeverything/infra';
class MobileSpecsPatches extends LifeCycleWatcher { class MobileSpecsPatches extends LifeCycleWatcher {
static override key = 'mobile-patches'; 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 => { static override key = BSVirtualKeyboardProvider.identifierName;
const prev = di.getFactory(RootBlockConfigExtension.identifier);
di.override(RootBlockConfigExtension.identifier, provider => { private readonly _disposables = new DisposableGroup();
return {
...prev?.(provider), private get _rootContentEditable() {
keyboardToolbar: createKeyboardToolbarConfig(), const editorMode = this.std.get(DocModeProvider).getEditorMode();
} satisfies RootBlockConfig; 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<BSVirtualKeyboardService>
);
}); });
}, }
},
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(formatBarWidget);
specBuilder.omit(embedCardToolbarWidget); specBuilder.omit(embedCardToolbarWidget);
specBuilder.omit(slashMenuWidget); specBuilder.omit(slashMenuWidget);
specBuilder.omit(codeToolbarWidget); specBuilder.omit(codeToolbarWidget);
specBuilder.omit(imageToolbarWidget); specBuilder.omit(imageToolbarWidget);
specBuilder.omit(surfaceRefToolbarWidget); specBuilder.omit(surfaceRefToolbarWidget);
specBuilder.extend(mobileExtensions); specBuilder.extend([MobileSpecsPatches, KeyboardToolbarExtension(framework)]);
} }

View File

@@ -1,9 +0,0 @@
import { type KeyboardToolbarConfig } from '@blocksuite/affine/blocks';
export function createKeyboardToolbarConfig(): Partial<KeyboardToolbarConfig> {
return {
// TODO(@L-Sun): check android following the PR
// https://github.com/toeverything/blocksuite/pull/8645
useScreenHeight: BUILD_CONFIG.isIOS,
};
}

View File

@@ -18,7 +18,7 @@ export const AppTabs = ({
fixed?: boolean; fixed?: boolean;
}) => { }) => {
const virtualKeyboardService = useService(VirtualKeyboardService); const virtualKeyboardService = useService(VirtualKeyboardService);
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.show$); const virtualKeyboardVisible = useLiveData(virtualKeyboardService.visible$);
const tab = ( const tab = (
<SafeArea <SafeArea

View File

@@ -6,8 +6,5 @@ import { VirtualKeyboardService } from './services/virtual-keyboard';
export { VirtualKeyboardProvider, VirtualKeyboardService }; export { VirtualKeyboardProvider, VirtualKeyboardService };
export function configureMobileVirtualKeyboardModule(framework: Framework) { export function configureMobileVirtualKeyboardModule(framework: Framework) {
framework.service( framework.service(VirtualKeyboardService, [VirtualKeyboardProvider]);
VirtualKeyboardService,
f => new VirtualKeyboardService(f.getOptional(VirtualKeyboardProvider))
);
} }

View File

@@ -1,23 +1,28 @@
import { createIdentifier } from '@toeverything/infra'; import { createIdentifier } from '@toeverything/infra';
export type VirtualKeyboardEvent = interface VirtualKeyboardInfo {
| 'keyboardWillShow' visible: boolean;
| 'keyboardDidShow' height: number;
| 'keyboardWillHide'
| 'keyboardDidHide';
export interface VirtualKeyboardEventInfo {
keyboardHeight: number;
} }
type VirtualKeyboardEventListener = (info: VirtualKeyboardEventInfo) => void;
export interface VirtualKeyboardProvider { type VirtualKeyboardAction = {
addEventListener: ( /**
event: VirtualKeyboardEvent, * Open the virtual keyboard, the focused element should not be changed
callback: VirtualKeyboardEventListener */
) => void; show: () => void;
removeAllListeners: () => 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 = export const VirtualKeyboardProvider =
createIdentifier<VirtualKeyboardProvider>('VirtualKeyboardProvider'); createIdentifier<VirtualKeyboardProvider>('VirtualKeyboardProvider');

View File

@@ -3,32 +3,23 @@ import { LiveData, Service } from '@toeverything/infra';
import type { VirtualKeyboardProvider } from '../providers/virtual-keyboard'; import type { VirtualKeyboardProvider } from '../providers/virtual-keyboard';
export class VirtualKeyboardService extends Service { export class VirtualKeyboardService extends Service {
show$ = new LiveData(false); readonly visible$ = new LiveData(false);
height$ = new LiveData(0);
readonly height$ = new LiveData(0);
constructor( constructor(
private readonly virtualKeyboardProvider?: VirtualKeyboardProvider private readonly virtualKeyboardProvider: VirtualKeyboardProvider
) { ) {
super(); super();
this._observe(); this._observe();
} }
override dispose() {
super.dispose();
this.virtualKeyboardProvider?.removeAllListeners();
}
private _observe() { private _observe() {
this.virtualKeyboardProvider?.addEventListener( this.disposables.push(
'keyboardWillShow', this.virtualKeyboardProvider.onChange(info => {
({ keyboardHeight }) => { this.visible$.next(info.visible);
this.show$.next(true); this.height$.next(info.height);
this.height$.next(keyboardHeight); })
}
); );
this.virtualKeyboardProvider?.addEventListener('keyboardWillHide', () => {
this.show$.next(false);
this.height$.next(0);
});
} }
} }

View File

@@ -23,9 +23,6 @@ globalStyle('body', {
globalStyle('body:has(> #app-tabs)', { globalStyle('body:has(> #app-tabs)', {
paddingBottom: globalVars.appTabSafeArea, 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', { globalStyle('body:has(#app-tabs) affine-keyboard-tool-panel', {
paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom) + 8px)`, paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom) + 8px)`,
}); });

View File

@@ -322,7 +322,6 @@ export class AtMenuConfigService extends Service {
private getMobileConfig(): Partial<LinkedWidgetConfig['mobile']> { private getMobileConfig(): Partial<LinkedWidgetConfig['mobile']> {
return { return {
useScreenHeight: BUILD_CONFIG.isIOS,
scrollContainer: window, scrollContainer: window,
scrollTopOffset: () => { scrollTopOffset: () => {
const header = document.querySelector('header'); const header = document.querySelector('header');