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:
keepClamDown
2026-07-09 11:11:55 +08:00
committed by GitHub
parent 9f8e5c0eb3
commit a868f54eeb
19 changed files with 1095 additions and 28 deletions
@@ -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;