fix(ios): stabilize keyboard toolbar, image picker, and scrolling (#15182)
## Summary - keep the editor active when iOS keyboard toolbar interactions move focus into range-sync excluded widgets - use the native iOS image picker/source sheet and sync native presentation with the app theme - pin `ListViewKit` to `1.1.6` so the iOS workspace resolves with Xcode 16.3 - restore vertical scrolling in the iOS `WKWebView` by removing the global `contentOffset` reset while preserving zoom prevention ## Test plan - [x] `yarn vitest --run --config \"vitest.config.ts\" --browser.enabled=false \"src/__tests__/inline/active.unit.spec.ts\"` - [x] `LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 xcodebuild -resolvePackageDependencies -workspace \"packages/frontend/apps/ios/App/App.xcworkspace\" -scheme \"App\"` - [x] `xcodebuild -workspace \"App.xcworkspace\" -scheme \"App\" -destination \"generic/platform=iOS Simulator\" build CODE_SIGNING_ALLOWED=NO ONLY_ACTIVE_ARCH=YES ARCHS=arm64` - [x] Xcode build validation for the updated PR branch
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getImageFilesFromLocal,
|
||||
registerNativeImageFilesPicker,
|
||||
} from '../../utils';
|
||||
|
||||
describe('getImageFilesFromLocal', () => {
|
||||
let originalShowPicker: ((this: HTMLInputElement) => void) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalShowPicker = (
|
||||
HTMLInputElement.prototype as HTMLInputElement & {
|
||||
showPicker?: (this: HTMLInputElement) => void;
|
||||
}
|
||||
).showPicker;
|
||||
|
||||
(
|
||||
HTMLInputElement.prototype as HTMLInputElement & {
|
||||
showPicker?: (this: HTMLInputElement) => void;
|
||||
}
|
||||
).showPicker = vi.fn(function (this: HTMLInputElement) {
|
||||
this.dispatchEvent(new Event('cancel'));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalShowPicker) {
|
||||
(
|
||||
HTMLInputElement.prototype as HTMLInputElement & {
|
||||
showPicker?: (this: HTMLInputElement) => void;
|
||||
}
|
||||
).showPicker = originalShowPicker;
|
||||
} else {
|
||||
delete (
|
||||
HTMLInputElement.prototype as unknown as {
|
||||
showPicker?: unknown;
|
||||
}
|
||||
).showPicker;
|
||||
}
|
||||
|
||||
registerNativeImageFilesPicker(null);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('prefers the registered native image picker when available', async () => {
|
||||
const file = new File(['picked-image'], 'picked.png', {
|
||||
type: 'image/png',
|
||||
lastModified: 123,
|
||||
});
|
||||
const nativePicker = vi.fn(async () => [file]);
|
||||
registerNativeImageFilesPicker(nativePicker);
|
||||
|
||||
const files = await getImageFilesFromLocal();
|
||||
|
||||
expect(nativePicker).toHaveBeenCalledTimes(1);
|
||||
expect(files).toEqual([file]);
|
||||
});
|
||||
|
||||
it('falls back to the standard picker when the native picker rejects', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const nativePicker = vi.fn(async () => {
|
||||
throw new Error('Native picker failed');
|
||||
});
|
||||
registerNativeImageFilesPicker(nativePicker);
|
||||
|
||||
const files = await getImageFilesFromLocal();
|
||||
|
||||
expect(nativePicker).toHaveBeenCalledTimes(1);
|
||||
expect(files).toEqual([]);
|
||||
expect(
|
||||
(
|
||||
HTMLInputElement.prototype as HTMLInputElement & {
|
||||
showPicker?: (this: HTMLInputElement) => void;
|
||||
}
|
||||
).showPicker
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,14 @@ interface OpenFilePickerOptions {
|
||||
multiple?: boolean | undefined;
|
||||
}
|
||||
|
||||
export type NativeImageFilesPicker = () => Promise<File[] | null>;
|
||||
|
||||
const NATIVE_IMAGE_FILES_PICKER_KEY =
|
||||
'__AFFINE_NATIVE_IMAGE_FILES_PICKER__' as const;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
[NATIVE_IMAGE_FILES_PICKER_KEY]?: NativeImageFilesPicker;
|
||||
// Window API: showOpenFilePicker
|
||||
showOpenFilePicker?: (
|
||||
options?: OpenFilePickerOptions
|
||||
@@ -29,6 +35,17 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export function registerNativeImageFilesPicker(
|
||||
picker: NativeImageFilesPicker | null
|
||||
) {
|
||||
if (picker) {
|
||||
window[NATIVE_IMAGE_FILES_PICKER_KEY] = picker;
|
||||
return;
|
||||
}
|
||||
|
||||
delete window[NATIVE_IMAGE_FILES_PICKER_KEY];
|
||||
}
|
||||
|
||||
// Minimal polyfill for FileSystemDirectoryHandle to iterate over files
|
||||
interface FileSystemDirectoryHandle {
|
||||
kind: 'directory';
|
||||
@@ -195,7 +212,18 @@ export async function snapshotFile(file: File, relativePath?: string) {
|
||||
}
|
||||
|
||||
export async function snapshotFiles(files: File[]) {
|
||||
return Promise.all(files.map(file => snapshotFile(file)));
|
||||
const results = await Promise.allSettled(
|
||||
files.map(file => snapshotFile(file))
|
||||
);
|
||||
const snapshots: File[] = [];
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
snapshots.push(result.value);
|
||||
} else {
|
||||
console.error('Failed to snapshot file', result.reason);
|
||||
}
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
function canUseFileSystemAccessAPI(
|
||||
@@ -332,7 +360,11 @@ export async function openDirectory(
|
||||
if (fileHandle.getFile) {
|
||||
const file = await fileHandle.getFile();
|
||||
if (options?.snapshot) {
|
||||
files.push(await snapshotFile(file, relativePath));
|
||||
try {
|
||||
files.push(await snapshotFile(file, relativePath));
|
||||
} catch (error) {
|
||||
console.error('Failed to snapshot file', relativePath, error);
|
||||
}
|
||||
} else {
|
||||
Object.defineProperty(file, 'webkitRelativePath', {
|
||||
value: relativePath,
|
||||
@@ -410,6 +442,15 @@ export async function openSingleFileWith(
|
||||
}
|
||||
|
||||
export async function getImageFilesFromLocal() {
|
||||
const nativePicker = window[NATIVE_IMAGE_FILES_PICKER_KEY];
|
||||
if (nativePicker) {
|
||||
try {
|
||||
return (await nativePicker()) ?? [];
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const files = await openFilesWith('Images');
|
||||
return files ?? [];
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/std';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
|
||||
import { effect, type Signal, signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
@@ -263,6 +264,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
|
||||
// There are two cases that `_expanded$` will be true:
|
||||
// 1. when virtual keyboard is opened, the panel need to be expanded and overlapped by the keyboard,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { shouldDeactivateEditorOnFocusOut } from '../../inline/range/active.js';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from '../../inline/range/consts.js';
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe('editor active helpers', () => {
|
||||
test('keeps editor active when focus moves into an excluded widget', () => {
|
||||
const host = document.createElement('editor-host');
|
||||
const paragraph = document.createElement('div');
|
||||
host.append(paragraph);
|
||||
|
||||
const widgetRoot = document.createElement('div');
|
||||
widgetRoot.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
const widgetButton = document.createElement('button');
|
||||
widgetRoot.append(widgetButton);
|
||||
|
||||
document.body.append(host, widgetRoot);
|
||||
|
||||
expect(shouldDeactivateEditorOnFocusOut(host, widgetButton)).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps editor active when focus stays inside the editor host', () => {
|
||||
const host = document.createElement('editor-host');
|
||||
const input = document.createElement('input');
|
||||
host.append(input);
|
||||
document.body.append(host);
|
||||
|
||||
expect(shouldDeactivateEditorOnFocusOut(host, input)).toBe(false);
|
||||
});
|
||||
|
||||
test('deactivates editor when focus moves to a regular external control', () => {
|
||||
const host = document.createElement('editor-host');
|
||||
const paragraph = document.createElement('div');
|
||||
host.append(paragraph);
|
||||
|
||||
const externalButton = document.createElement('button');
|
||||
document.body.append(host, externalButton);
|
||||
|
||||
expect(shouldDeactivateEditorOnFocusOut(host, externalButton)).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps editor active on focusout when related target is null', () => {
|
||||
// Focus leaving to nowhere (e.g. block-level selection) must not deactivate
|
||||
// the editor via `focusout`; the host `blur` handler owns that case.
|
||||
const host = document.createElement('editor-host');
|
||||
document.body.append(host);
|
||||
|
||||
expect(shouldDeactivateEditorOnFocusOut(host, null)).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps editor active when the related target is not a DOM node', () => {
|
||||
const host = document.createElement('editor-host');
|
||||
document.body.append(host);
|
||||
|
||||
expect(shouldDeactivateEditorOnFocusOut(host, new EventTarget())).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { signal } from '@preact/signals-core';
|
||||
|
||||
import { LifeCycleWatcher } from '../extension/index.js';
|
||||
import { KeymapIdentifier } from '../identifier.js';
|
||||
import { shouldDeactivateEditorOnFocusOut } from '../inline/range/active.js';
|
||||
import type { BlockStdScope } from '../scope/index.js';
|
||||
import { type BlockComponent, EditorHost } from '../view/index.js';
|
||||
import {
|
||||
@@ -174,16 +175,21 @@ export class UIEventDispatcher extends LifeCycleWatcher {
|
||||
this._setActive(true);
|
||||
});
|
||||
this.disposables.addFromEvent(document, 'focusout', e => {
|
||||
if (e.relatedTarget && !this.host.contains(e.relatedTarget as Node)) {
|
||||
if (shouldDeactivateEditorOnFocusOut(this.host, e.relatedTarget)) {
|
||||
this._setActive(false);
|
||||
}
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'blur', () => {
|
||||
this.disposables.addFromEvent(this.host, 'blur', e => {
|
||||
if (_dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setActive(false);
|
||||
if (
|
||||
!e.relatedTarget ||
|
||||
shouldDeactivateEditorOnFocusOut(this.host, e.relatedTarget)
|
||||
) {
|
||||
this._setActive(false);
|
||||
}
|
||||
});
|
||||
this.disposables.addFromEvent(this.host, 'dragover', () => {
|
||||
_dragging = true;
|
||||
|
||||
@@ -1,4 +1,46 @@
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from './consts';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from './consts.js';
|
||||
|
||||
function getClosestElement(target: EventTarget | null) {
|
||||
if (target instanceof Element) {
|
||||
return target;
|
||||
}
|
||||
|
||||
if (target instanceof Node) {
|
||||
return target.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isRangeSyncExcludedTarget(target: EventTarget | null) {
|
||||
return !!getClosestElement(target)?.closest(
|
||||
`[${RANGE_SYNC_EXCLUDE_ATTR}="true"]`
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldDeactivateEditorOnFocusOut(
|
||||
editorHost: HTMLElement,
|
||||
relatedTarget: EventTarget | null
|
||||
) {
|
||||
// A missing or non-DOM `relatedTarget` means focus is not moving to a
|
||||
// trackable element (e.g. block-level selection or a keyboard action). The
|
||||
// document `focusout` handler must keep the editor active in this case so the
|
||||
// current block selection is preserved; leaving-to-nowhere is handled
|
||||
// explicitly by the host `blur` handler instead.
|
||||
if (!relatedTarget || !(relatedTarget instanceof Node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (editorHost.contains(relatedTarget)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isRangeSyncExcludedTarget(relatedTarget)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the active element is in the editor host.
|
||||
@@ -11,8 +53,7 @@ export function isActiveInEditor(editorHost: HTMLElement) {
|
||||
const currentActiveElement = document.activeElement;
|
||||
if (!currentActiveElement) return false;
|
||||
// The input or textarea in the widget should be ignored.
|
||||
if (currentActiveElement.closest(`[${RANGE_SYNC_EXCLUDE_ATTR}="true"]`))
|
||||
return false;
|
||||
if (isRangeSyncExcludedTarget(currentActiveElement)) return false;
|
||||
const currentEditorHost = currentActiveElement?.closest('editor-host');
|
||||
if (!currentEditorHost) return false;
|
||||
return currentEditorHost === editorHost;
|
||||
|
||||
Reference in New Issue
Block a user