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

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

View File

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

View File

@@ -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, {

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 { 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

View File

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

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 {
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<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(embedCardToolbarWidget);
specBuilder.omit(slashMenuWidget);
specBuilder.omit(codeToolbarWidget);
specBuilder.omit(imageToolbarWidget);
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;
}) => {
const virtualKeyboardService = useService(VirtualKeyboardService);
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.show$);
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.visible$);
const tab = (
<SafeArea

View File

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

View File

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

View File

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

View File

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

View File

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