From d9cf9dc25491759173adabbac31cbe70a444bc7d Mon Sep 17 00:00:00 2001 From: SecPan Date: Fri, 10 Jul 2026 05:12:23 -0700 Subject: [PATCH] Extract editor selection lifecycle from App.vue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the Android editor selection machinery into a createEditorSelectionLifecycle factory in features/editor: DOM range capture/restore kept in sync with Muya's selection model (including the collapsed-restore, edge-collapse, and clear helpers), the selection-toolbar command sequencing for copy/cut/paste/select-all with its native-clipboard and execCommand paths, ActionMode dismissal with caret restoration, the selection-menu suppression bridge, and the native selection-tap listener lifecycle used to dismiss the toolbar. Muya internals and the toolbar UI are untouched. App injects the editor and element getters, screen/readiness refs, the input-diagnostics snapshot, and the logger, and destructures the same function names, so editor init/teardown, the toolbar bindings, and the link/image insert flows are textually unchanged. The insert-flow state (pendingInlineInsertRange) and the diagnostics installers stay in App — they belong to the insert and debugging features that consume selection capture, not to the lifecycle itself. Adds happy-dom as a root dev dependency so the new module tests can exercise real Range/Selection behavior; the Muya package already tests with the same environment. --- package.json | 1 + pnpm-lock.yaml | 65 ++- src/App.vue | 384 +--------------- .../editor/selectionLifecycle.test.ts | 258 +++++++++++ src/features/editor/selectionLifecycle.ts | 421 ++++++++++++++++++ 5 files changed, 761 insertions(+), 368 deletions(-) create mode 100644 src/features/editor/selectionLifecycle.test.ts create mode 100644 src/features/editor/selectionLifecycle.ts diff --git a/package.json b/package.json index 419fb21..6e65a34 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "eslint": "^10.6.0", "eslint-plugin-vue": "^10.9.2", "globals": "^17.7.0", + "happy-dom": "^20.10.6", "typescript": "~6.0.2", "typescript-eslint": "^8.62.0", "vite": "^8.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afc32fa..5e0eb00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: globals: specifier: ^17.7.0 version: 17.7.0 + happy-dom: + specifier: ^20.10.6 + version: 20.10.6 typescript: specifier: ~6.0.2 version: 6.0.3 @@ -68,7 +71,7 @@ importers: version: 8.1.0(@types/node@24.13.2) vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@24.13.2)(vite@8.1.0(@types/node@24.13.2)) + version: 4.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(vite@8.1.0(@types/node@24.13.2)) vue-tsc: specifier: ^3.3.5 version: 3.3.5(typescript@6.0.3) @@ -498,6 +501,12 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.62.0': resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -732,6 +741,10 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1196,6 +1209,10 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + happy-dom@20.10.6: + resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} + engines: {node: '>=20.0.0'} + html-tags@5.1.0: resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==} engines: {node: '>=20.10'} @@ -2045,6 +2062,10 @@ packages: webfontloader@1.6.28: resolution: {integrity: sha512-Egb0oFEga6f+nSgasH3E0M405Pzn6y3/9tOVanv/DLfa1YBIgcv90L18YyWnvXkRbIM17v5Kv6IT2N6g1x5tvQ==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2067,6 +2088,18 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} @@ -2595,6 +2628,12 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.13.2 + '@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.6.0)(typescript@6.0.3))(eslint@10.6.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2880,6 +2919,10 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 24.13.2 + chai@6.2.2: {} chownr@3.0.0: {} @@ -3343,6 +3386,19 @@ snapshots: hachure-fill@0.5.2: {} + happy-dom@20.10.6: + dependencies: + '@types/node': 24.13.2 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + html-tags@5.1.0: {} iconv-lite@0.6.3: @@ -4182,7 +4238,7 @@ snapshots: '@types/node': 24.13.2 fsevents: 2.3.3 - vitest@4.1.9(@types/node@24.13.2)(vite@8.1.0(@types/node@24.13.2)): + vitest@4.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(vite@8.1.0(@types/node@24.13.2)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@24.13.2)) @@ -4206,6 +4262,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.2 + happy-dom: 20.10.6 transitivePeerDependencies: - msw @@ -4241,6 +4298,8 @@ snapshots: webfontloader@1.6.28: {} + whatwg-mimetype@3.0.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -4264,6 +4323,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + ws@8.21.0: {} + xml-name-validator@4.0.0: {} xml2js@0.6.2: diff --git a/src/App.vue b/src/App.vue index 9dcbe36..0c821ad 100644 --- a/src/App.vue +++ b/src/App.vue @@ -31,20 +31,11 @@ import { createIncomingDocumentOrchestration, } from './features/android-documents/incomingDocumentOrchestration' import { - addAndroidSelectionTapListener, - finishAndroidEditorSelectionActionMode, isAndroidSelectionControlAvailable, - performAndroidNativeSelectAll, readAndroidClipboardText, - setAndroidEditorSelectionMenuSuppressed, - writeAndroidClipboardText, - type AndroidSelectionTapEvent, } from './lib/androidSelection' import { installAndroidSelectionDiagnostics } from './lib/androidSelectionDiagnostics' -import { - caretRangeAtPoint, - type SelectionToolbarCommandId, -} from './features/editor/selectionToolbar' +import { createEditorSelectionLifecycle } from './features/editor/selectionLifecycle' import { getAppBackButtonAction, getShowHomeAfterAndroidSaveAction, @@ -88,7 +79,6 @@ import { } from './features/editor/editorToolbarSettings' import { useEditorToolbar } from './features/editor/useEditorToolbar' import { - captureSelectionWithin, insertTextAtRestoredSelection, resolveEditorDomNode, } from './features/editor/editorInlineInsert' @@ -283,7 +273,6 @@ let pendingInlineInsertRange: Range | null = null let startupActionTimer: number | null = null let editorSelectionDiagnosticCleanup: (() => void) | null = null let editorInputDiagnosticCleanup: (() => void) | null = null -let nativeSelectionTapCleanup: (() => Promise) | null = null let systemColorSchemeCleanup: (() => void) | null = null const appLog = createLogger('app') @@ -585,10 +574,23 @@ function getEditorCommandTarget(): MobileEditorCommandTarget | null { return createMuyaMobileEditorCommandTarget(editor) } -function captureEditorSelection() { - return captureSelectionWithin(resolveEditorDomNode(editor, editorElement.value)) -} - +const { + captureEditorSelection, + restoreEditorSelectionRange, + finishSelectionToolbarOutsideTap, + runSelectionToolbarCommand, + setEditorSelectionMenuSuppression, + installNativeSelectionTapListener, + uninstallNativeSelectionTapListener, +} = createEditorSelectionLifecycle({ + currentScreen, + editorReady, + androidInputDiagnosticsEnabled, + getEditor: () => editor, + getEditorElement: () => editorElement.value, + describeEditorInputState: () => describeEditorInputState(), + logger: editorLog, +}) function insertMarkdownAtPendingSelection(markdown: string) { editor?.focus() return insertTextAtRestoredSelection(markdown, pendingInlineInsertRange) @@ -706,304 +708,6 @@ const canPasteSelection = isAndroidSelectionControlAvailable() || (typeof navigator !== 'undefined' && Boolean(navigator.clipboard?.readText)) -// The Android tap that presses a toolbar button can collapse the selection -// before the command handler runs; put the captured selection back so the -// clipboard commands still have a range to operate on. -function restoreEditorSelectionIfCollapsed(restoreRange: Range | null) { - if (!restoreRange || restoreRange.collapsed) { - return - } - - const selection = document.getSelection() - if (!selection || (selection.rangeCount > 0 && !selection.isCollapsed)) { - return - } - - try { - selection.removeAllRanges() - selection.addRange(restoreRange) - } catch (error) { - editorLog.debug('selection restore skipped', { error }) - } -} - -function restoreEditorSelectionRange(activeEditor: MuyaEditor, restoreRange: Range | null) { - if (!restoreRange || restoreRange.collapsed) { - return false - } - - const selection = document.getSelection() - if (!selection) { - return false - } - - try { - selection.removeAllRanges() - selection.addRange(restoreRange.cloneRange()) - syncMuyaSelectionFromDom(activeEditor) - return true - } catch (error) { - editorLog.debug('selection range restore skipped', { error }) - return false - } -} - -function getCurrentSelectionRangeClone() { - const selection = document.getSelection() - if (!selection || selection.rangeCount === 0) { - return null - } - - try { - return selection.getRangeAt(0).cloneRange() - } catch { - return null - } -} - -function focusEditorDomNode(activeEditor: MuyaEditor) { - try { - activeEditor.domNode.focus({ preventScroll: true }) - } catch { - activeEditor.domNode.focus() - } -} - -function syncMuyaSelectionFromDom(activeEditor: MuyaEditor) { - const selectionController = activeEditor.editor.selection - const liveSelection = selectionController.getSelection() - if (!liveSelection) { - return false - } - - selectionController.setSelection(liveSelection.anchor, liveSelection.focus) - activeEditor.editor.activeContentBlock = liveSelection.focus.block - return true -} - -function restoreCollapsedEditorRange(activeEditor: MuyaEditor, range: Range | null) { - if (!range || !range.collapsed) { - return false - } - - const selection = document.getSelection() - if (!selection) { - return false - } - - try { - selection.removeAllRanges() - selection.addRange(range.cloneRange()) - focusEditorDomNode(activeEditor) - syncMuyaSelectionFromDom(activeEditor) - return true - } catch (error) { - editorLog.debug('collapsed selection restore skipped', { error }) - return false - } -} - -function collapseEditorSelectionToRangeEdge( - activeEditor: MuyaEditor, - range: Range | null, - edge: 'start' | 'end', -) { - if (!range) { - return false - } - - const selection = document.getSelection() - if (!selection) { - return false - } - - try { - const collapsedRange = range.cloneRange() - collapsedRange.collapse(edge === 'start') - selection.removeAllRanges() - selection.addRange(collapsedRange) - focusEditorDomNode(activeEditor) - syncMuyaSelectionFromDom(activeEditor) - return true - } catch (error) { - editorLog.debug('selection collapse skipped', { edge, error }) - return false - } -} - -function clearEditorSelectionIfStillExpanded(activeEditor: MuyaEditor) { - const selection = document.getSelection() - if (selection && selection.rangeCount > 0 && !selection.isCollapsed) { - selection.removeAllRanges() - activeEditor.editor.selection.clear() - } -} - -async function finishEditorSelectionActionMode(reason: string) { - const finished = await finishAndroidEditorSelectionActionMode(reason) - if (finished) { - editorLog.debug('Android editor selection action mode finished', { reason }) - } - return finished -} - -async function finishEditorSelectionActionModeAndRestoreCaret( - reason: string, - activeEditor: MuyaEditor, - caretRange: Range | null, -) { - const finished = await finishEditorSelectionActionMode(reason) - if (finished && caretRange) { - restoreCollapsedEditorRange(activeEditor, caretRange) - } - return finished -} - -function finishSelectionToolbarOutsideTap(caretRange: Range | null) { - const activeEditor = editorReady.value ? editor : null - if (!activeEditor) { - void finishEditorSelectionActionMode('selection-toolbar-outside-tap') - return - } - - if (caretRange) { - restoreCollapsedEditorRange(activeEditor, caretRange) - } - void finishEditorSelectionActionModeAndRestoreCaret( - 'selection-toolbar-outside-tap', - activeEditor, - caretRange, - ).then(finished => { - if (finished) { - editorLog.debug('selection toolbar outside tap dismissed', { - restoredCaret: Boolean(caretRange), - }) - } - }) -} - -function getSelectionTextFallback(range: Range | null) { - const rangeText = range?.toString() ?? '' - if (rangeText.length > 0) { - return rangeText - } - - return document.getSelection()?.toString() ?? '' -} - -async function runSelectionToolbarCommand( - commandId: SelectionToolbarCommandId, - restoreRange: Range | null, -) { - const activeEditor = editorReady.value ? editor : null - if (!activeEditor) { - return - } - - try { - const rangeBeforeCommand = restoreRange?.cloneRange() ?? getCurrentSelectionRangeClone() - if (androidInputDiagnosticsEnabled.value) { - editorLog.debug('selection command state', { - commandId, - phase: 'before', - rangeText: (rangeBeforeCommand?.toString() ?? '').slice(0, 32), - ...describeEditorInputState(), - }) - } - if (commandId === 'copy' || commandId === 'cut') { - const restoredRange = restoreEditorSelectionRange(activeEditor, rangeBeforeCommand) - if (!restoredRange) { - restoreEditorSelectionIfCollapsed(restoreRange) - } - - if (isAndroidSelectionControlAvailable()) { - // Deterministic Android path: serialize the selection with Muya's own - // clipboard pipeline and write it through the native clipboard bridge. - // This avoids depending on execCommand + ClipboardEvent behavior in - // OEM WebViews, which cannot be debugged remotely. - const fallbackSelectionText = getSelectionTextFallback(rangeBeforeCommand) - const payload = - commandId === 'cut' - ? activeEditor.editor.clipboard.cutSelectionToClipboardData() - : activeEditor.editor.clipboard.getClipboardData() - const clipboardText = payload.text || fallbackSelectionText - if (!clipboardText) { - editorLog.warn('selection clipboard payload empty, command skipped', { - commandId, - hadRestoreRange: Boolean(rangeBeforeCommand), - }) - return - } - - const written = await writeAndroidClipboardText(clipboardText) - if (!written) { - editorLog.warn('android clipboard write failed', { commandId }) - return - } - } else { - // Web path: execCommand routes through Muya's document-level copy/cut - // handlers, which own Markdown serialization and history-safe deletion. - const executed = document.execCommand(commandId) - if (!executed) { - const selectionText = document.getSelection()?.toString() ?? '' - const written = - selectionText.length > 0 && (await writeAndroidClipboardText(selectionText)) - if (commandId === 'cut' && written) { - activeEditor.editor.clipboard.cutHandler() - } - editorLog.warn('selection execCommand rejected, used clipboard fallback', { - commandId, - written, - }) - } - } - - if (commandId === 'copy') { - collapseEditorSelectionToRangeEdge(activeEditor, rangeBeforeCommand, 'end') - } else if (!document.getSelection()?.isCollapsed) { - const collapsed = collapseEditorSelectionToRangeEdge( - activeEditor, - rangeBeforeCommand, - 'start', - ) - if (!collapsed) { - clearEditorSelectionIfStillExpanded(activeEditor) - } - } - await finishEditorSelectionActionModeAndRestoreCaret( - `selection-toolbar-${commandId}`, - activeEditor, - getCurrentSelectionRangeClone(), - ) - } else if (commandId === 'paste') { - restoreEditorSelectionRange(activeEditor, restoreRange) - await activeEditor.editor.clipboard.pasteAsPlainText() - await finishEditorSelectionActionModeAndRestoreCaret( - 'selection-toolbar-paste', - activeEditor, - getCurrentSelectionRangeClone(), - ) - } else { - // Prefer the native select-all: it keeps Chromium's touch-selection - // session (and its drag handles) alive. Muya's JS select-all replaces - // the range programmatically, which never shows handles on Android. - const nativeSelectAll = - isAndroidSelectionControlAvailable() && - (await performAndroidNativeSelectAll('selection-toolbar')) - if (!nativeSelectAll) { - activeEditor.selectAll() - } - } - - editorLog.debug('selection toolbar command handled', { - commandId, - modelCharacters: activeEditor.getMarkdown().length, - }) - } catch (error) { - editorLog.warn('selection toolbar command failed', { commandId, error }) - } -} - function runEditorToolbarCommand(commandId: MobileCommandId, restoreRange: Range | null = null) { const activeEditor = editorReady.value ? editor : null const commandRange = @@ -1194,17 +898,6 @@ async function initEditor(initialMarkdown: string) { } } -async function setEditorSelectionMenuSuppression(suppressed: boolean, reason: string) { - try { - const state = await setAndroidEditorSelectionMenuSuppressed(suppressed, reason) - if (state.native) { - editorLog.debug('Android editor selection menu suppression updated', state) - } - } catch (error) { - editorLog.warn('Android editor selection menu suppression update failed', error) - } -} - function installEditorSelectionDiagnostics() { uninstallEditorSelectionDiagnostics() @@ -1309,47 +1002,6 @@ function uninstallEditorInputDiagnostics() { editorInputDiagnosticCleanup = null } -function handleNativeSelectionTap(event: AndroidSelectionTapEvent) { - if (currentScreen.value !== 'editor' || !editorReady.value) { - return - } - - const selection = document.getSelection() - if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { - return - } - - const hitElement = document.elementFromPoint(event.x, event.y) - if (hitElement?.closest('[data-testid="mobile-selection-toolbar"]')) { - return - } - - const caret = caretRangeAtPoint(event.x, event.y) - editorLog.debug('native selection tap dismissal', { - x: Math.round(event.x), - y: Math.round(event.y), - hasCaret: Boolean(caret), - }) - finishSelectionToolbarOutsideTap(caret) -} - -async function installNativeSelectionTapListener() { - await uninstallNativeSelectionTapListener() - nativeSelectionTapCleanup = await addAndroidSelectionTapListener(handleNativeSelectionTap) -} - -async function uninstallNativeSelectionTapListener() { - const cleanup = nativeSelectionTapCleanup - nativeSelectionTapCleanup = null - if (cleanup) { - try { - await cleanup() - } catch (error) { - editorLog.debug('native selection tap listener cleanup failed', { error }) - } - } -} - function releaseEditorFocusAfterOpen() { window.requestAnimationFrame(() => { const activeElement = document.activeElement diff --git a/src/features/editor/selectionLifecycle.test.ts b/src/features/editor/selectionLifecycle.test.ts new file mode 100644 index 0000000..1a5e650 --- /dev/null +++ b/src/features/editor/selectionLifecycle.test.ts @@ -0,0 +1,258 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createEditorSelectionLifecycle } from './selectionLifecycle' +import type { MuyaEditor } from './editorRuntime' +import { + addAndroidSelectionTapListener, + finishAndroidEditorSelectionActionMode, + isAndroidSelectionControlAvailable, + performAndroidNativeSelectAll, + setAndroidEditorSelectionMenuSuppressed, + writeAndroidClipboardText, +} from '../../lib/androidSelection' + +vi.mock('../../lib/androidSelection', () => ({ + addAndroidSelectionTapListener: vi.fn(async () => vi.fn(async () => {})), + finishAndroidEditorSelectionActionMode: vi.fn(async () => true), + isAndroidSelectionControlAvailable: vi.fn(() => false), + performAndroidNativeSelectAll: vi.fn(async () => true), + setAndroidEditorSelectionMenuSuppressed: vi.fn(async () => ({ native: true })), + writeAndroidClipboardText: vi.fn(async () => true), +})) + +vi.mock('./selectionToolbar', () => ({ + caretRangeAtPoint: vi.fn(() => null), +})) + +const logger = { debug: vi.fn(), warn: vi.fn() } + +function createFakeEditor(root: HTMLElement) { + return { + domNode: root, + editor: { + selection: { + getSelection: vi.fn(() => ({ + anchor: { offset: 0 }, + focus: { offset: 1, block: { id: 'block-1' } }, + })), + setSelection: vi.fn(), + clear: vi.fn(), + }, + activeContentBlock: null as unknown, + clipboard: { + getClipboardData: vi.fn(() => ({ text: 'copied text' })), + cutSelectionToClipboardData: vi.fn(() => ({ text: 'cut text' })), + cutHandler: vi.fn(), + pasteAsPlainText: vi.fn(async () => {}), + }, + }, + selectAll: vi.fn(), + getMarkdown: vi.fn(() => '# markdown'), + } as unknown as MuyaEditor +} + +function createHarness(overrides: { editorReady?: boolean, screen?: string } = {}) { + document.body.innerHTML = '' + const root = document.createElement('div') + root.textContent = 'selected words here' + document.body.appendChild(root) + const fakeEditor = createFakeEditor(root) + + const lifecycle = createEditorSelectionLifecycle({ + currentScreen: ref(overrides.screen ?? 'editor'), + editorReady: ref(overrides.editorReady ?? true), + androidInputDiagnosticsEnabled: ref(false), + getEditor: () => fakeEditor, + getEditorElement: () => root, + describeEditorInputState: () => ({}), + logger, + }) + + return { lifecycle, root, fakeEditor } +} + +function selectAllOf(root: HTMLElement) { + const range = document.createRange() + range.selectNodeContents(root) + const selection = document.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + return range.cloneRange() +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(isAndroidSelectionControlAvailable).mockReturnValue(false) + vi.mocked(finishAndroidEditorSelectionActionMode).mockResolvedValue(true) + document.execCommand = vi.fn(() => true) + document.elementFromPoint = vi.fn(() => null) +}) + +describe('selectionLifecycle', () => { + it('captures the current selection inside the editor root', () => { + const { lifecycle, root } = createHarness() + selectAllOf(root) + + const captured = lifecycle.captureEditorSelection() + + expect(captured?.toString()).toBe('selected words here') + }) + + it('restores an expanded range and syncs the Muya selection model', () => { + const { lifecycle, root, fakeEditor } = createHarness() + const range = selectAllOf(root) + document.getSelection()!.removeAllRanges() + + const restored = lifecycle.restoreEditorSelectionRange(fakeEditor, range) + + expect(restored).toBe(true) + expect(document.getSelection()!.toString()).toBe('selected words here') + expect(fakeEditor.editor.selection.setSelection).toHaveBeenCalled() + expect(fakeEditor.editor.activeContentBlock).toEqual({ id: 'block-1' }) + }) + + it('copies through execCommand on the web path and dismisses the action mode', async () => { + const { lifecycle, root } = createHarness() + const range = selectAllOf(root) + + await lifecycle.runSelectionToolbarCommand('copy', range) + + expect(document.execCommand).toHaveBeenCalledWith('copy') + expect(finishAndroidEditorSelectionActionMode).toHaveBeenCalledWith('selection-toolbar-copy') + expect(document.getSelection()!.isCollapsed).toBe(true) + }) + + it('cuts through the native clipboard bridge when Android selection control exists', async () => { + vi.mocked(isAndroidSelectionControlAvailable).mockReturnValue(true) + const { lifecycle, root, fakeEditor } = createHarness() + const range = selectAllOf(root) + + await lifecycle.runSelectionToolbarCommand('cut', range) + + expect(fakeEditor.editor.clipboard.cutSelectionToClipboardData).toHaveBeenCalled() + expect(writeAndroidClipboardText).toHaveBeenCalledWith('cut text') + expect(document.execCommand).not.toHaveBeenCalled() + }) + + it('skips the clipboard write when the payload and fallback are both empty', async () => { + vi.mocked(isAndroidSelectionControlAvailable).mockReturnValue(true) + const { lifecycle, root, fakeEditor } = createHarness() + vi.mocked(fakeEditor.editor.clipboard.getClipboardData).mockReturnValue({ html: '', text: '' }) + root.textContent = '' + + await lifecycle.runSelectionToolbarCommand('copy', null) + + expect(writeAndroidClipboardText).not.toHaveBeenCalled() + expect(logger.warn).toHaveBeenCalledWith( + 'selection clipboard payload empty, command skipped', + expect.objectContaining({ commandId: 'copy' }), + ) + }) + + it('pastes as plain text after restoring the target range', async () => { + const { lifecycle, root, fakeEditor } = createHarness() + const range = selectAllOf(root) + + await lifecycle.runSelectionToolbarCommand('paste', range) + + expect(fakeEditor.editor.clipboard.pasteAsPlainText).toHaveBeenCalled() + expect(finishAndroidEditorSelectionActionMode).toHaveBeenCalledWith('selection-toolbar-paste') + }) + + it('prefers the native select-all and falls back to Muya select-all', async () => { + vi.mocked(isAndroidSelectionControlAvailable).mockReturnValue(true) + const native = createHarness() + await native.lifecycle.runSelectionToolbarCommand('selectAll', null) + expect(performAndroidNativeSelectAll).toHaveBeenCalledWith('selection-toolbar') + expect(native.fakeEditor.selectAll).not.toHaveBeenCalled() + + vi.mocked(performAndroidNativeSelectAll).mockResolvedValue(false) + const fallback = createHarness() + await fallback.lifecycle.runSelectionToolbarCommand('selectAll', null) + expect(fallback.fakeEditor.selectAll).toHaveBeenCalled() + }) + + it('reports but never throws when menu suppression fails', async () => { + const { lifecycle } = createHarness() + vi.mocked(setAndroidEditorSelectionMenuSuppressed).mockRejectedValue(new Error('bridge gone')) + + await expect( + lifecycle.setEditorSelectionMenuSuppression(true, 'test'), + ).resolves.toBeUndefined() + + expect(logger.warn).toHaveBeenCalledWith( + 'Android editor selection menu suppression update failed', + expect.any(Error), + ) + }) + + it('reinstalls the native tap listener idempotently and cleans up on uninstall', async () => { + const firstCleanup = vi.fn(async () => {}) + const secondCleanup = vi.fn(async () => {}) + vi.mocked(addAndroidSelectionTapListener) + .mockResolvedValueOnce(firstCleanup) + .mockResolvedValueOnce(secondCleanup) + const { lifecycle } = createHarness() + + await lifecycle.installNativeSelectionTapListener() + await lifecycle.installNativeSelectionTapListener() + expect(firstCleanup).toHaveBeenCalledTimes(1) + + await lifecycle.uninstallNativeSelectionTapListener() + expect(secondCleanup).toHaveBeenCalledTimes(1) + }) + + it('dismisses the toolbar only for expanded selections outside it', async () => { + const { lifecycle, root } = createHarness() + await lifecycle.installNativeSelectionTapListener() + const tapHandler = vi.mocked(addAndroidSelectionTapListener).mock.calls[0][0] + + // Collapsed selection: nothing to dismiss. + document.getSelection()!.removeAllRanges() + tapHandler({ x: 10, y: 10 }) + expect(finishAndroidEditorSelectionActionMode).not.toHaveBeenCalled() + + // Expanded selection + tap outside the toolbar: dismiss. + selectAllOf(root) + tapHandler({ x: 10, y: 10 }) + await Promise.resolve() + expect(finishAndroidEditorSelectionActionMode).toHaveBeenCalledWith( + 'selection-toolbar-outside-tap', + ) + }) + + it('keeps the selection when the tap lands on a selection-toolbar button', async () => { + const { lifecycle, root } = createHarness() + const toolbar = document.createElement('div') + toolbar.dataset.testid = 'mobile-selection-toolbar' + const button = document.createElement('button') + toolbar.appendChild(button) + document.body.appendChild(toolbar) + document.elementFromPoint = vi.fn(() => button) + + await lifecycle.installNativeSelectionTapListener() + const tapHandler = vi.mocked(addAndroidSelectionTapListener).mock.calls[0][0] + selectAllOf(root) + + tapHandler({ x: 10, y: 10 }) + await Promise.resolve() + + // Tapping Copy/Cut/Paste/Select-All must not tear down the selection the + // command is about to operate on. + expect(finishAndroidEditorSelectionActionMode).not.toHaveBeenCalled() + expect(document.getSelection()!.isCollapsed).toBe(false) + }) + + it('ignores taps while the editor screen is not active', async () => { + const { lifecycle, root } = createHarness({ screen: 'home' }) + await lifecycle.installNativeSelectionTapListener() + const tapHandler = vi.mocked(addAndroidSelectionTapListener).mock.calls[0][0] + selectAllOf(root) + + tapHandler({ x: 10, y: 10 }) + + expect(finishAndroidEditorSelectionActionMode).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/editor/selectionLifecycle.ts b/src/features/editor/selectionLifecycle.ts new file mode 100644 index 0000000..f9ba857 --- /dev/null +++ b/src/features/editor/selectionLifecycle.ts @@ -0,0 +1,421 @@ +import type { Ref } from 'vue' +import type { MuyaEditor } from './editorRuntime' +import { captureSelectionWithin, resolveEditorDomNode } from './editorInlineInsert' +import { caretRangeAtPoint, type SelectionToolbarCommandId } from './selectionToolbar' +import { + addAndroidSelectionTapListener, + finishAndroidEditorSelectionActionMode, + isAndroidSelectionControlAvailable, + performAndroidNativeSelectAll, + setAndroidEditorSelectionMenuSuppressed, + writeAndroidClipboardText, + type AndroidSelectionTapEvent, +} from '../../lib/androidSelection' + +interface SelectionLogger { + debug(message: string, context?: unknown): void + warn(message: string, context?: unknown): void +} + +export interface EditorSelectionLifecycleOptions { + currentScreen: Ref + editorReady: Ref + androidInputDiagnosticsEnabled: Ref + getEditor: () => MuyaEditor | null + getEditorElement: () => HTMLElement | null + describeEditorInputState: () => Record + logger: SelectionLogger +} + +export interface EditorSelectionLifecycle { + captureEditorSelection(): Range | null + restoreEditorSelectionRange(activeEditor: MuyaEditor, restoreRange: Range | null): boolean + finishSelectionToolbarOutsideTap(caretRange: Range | null): void + runSelectionToolbarCommand( + commandId: SelectionToolbarCommandId, + restoreRange: Range | null, + ): Promise + setEditorSelectionMenuSuppression(suppressed: boolean, reason: string): Promise + installNativeSelectionTapListener(): Promise + uninstallNativeSelectionTapListener(): Promise +} + +/** + * Owns the editor selection lifecycle on Android: DOM range capture/restore + * kept in sync with Muya's selection model, the selection-toolbar command + * sequencing (clipboard bridging, caret placement, ActionMode dismissal), the + * ActionMode menu suppression bridge, and the native selection-tap listener + * used to dismiss the selection toolbar. Muya internals and the toolbar UI + * are untouched. + */ +export function createEditorSelectionLifecycle( + options: EditorSelectionLifecycleOptions, +): EditorSelectionLifecycle { + let nativeSelectionTapCleanup: (() => Promise) | null = null + + function captureEditorSelection() { + return captureSelectionWithin( + resolveEditorDomNode(options.getEditor(), options.getEditorElement()), + ) + } + + // The Android tap that presses a toolbar button can collapse the selection + // before the command handler runs; put the captured selection back so the + // clipboard commands still have a range to operate on. + function restoreEditorSelectionIfCollapsed(restoreRange: Range | null) { + if (!restoreRange || restoreRange.collapsed) { + return + } + + const selection = document.getSelection() + if (!selection || (selection.rangeCount > 0 && !selection.isCollapsed)) { + return + } + + try { + selection.removeAllRanges() + selection.addRange(restoreRange) + } catch (error) { + options.logger.debug('selection restore skipped', { error }) + } + } + + function restoreEditorSelectionRange(activeEditor: MuyaEditor, restoreRange: Range | null) { + if (!restoreRange || restoreRange.collapsed) { + return false + } + + const selection = document.getSelection() + if (!selection) { + return false + } + + try { + selection.removeAllRanges() + selection.addRange(restoreRange.cloneRange()) + syncMuyaSelectionFromDom(activeEditor) + return true + } catch (error) { + options.logger.debug('selection range restore skipped', { error }) + return false + } + } + + function getCurrentSelectionRangeClone() { + const selection = document.getSelection() + if (!selection || selection.rangeCount === 0) { + return null + } + + try { + return selection.getRangeAt(0).cloneRange() + } catch { + return null + } + } + + function focusEditorDomNode(activeEditor: MuyaEditor) { + try { + activeEditor.domNode.focus({ preventScroll: true }) + } catch { + activeEditor.domNode.focus() + } + } + + function syncMuyaSelectionFromDom(activeEditor: MuyaEditor) { + const selectionController = activeEditor.editor.selection + const liveSelection = selectionController.getSelection() + if (!liveSelection) { + return false + } + + selectionController.setSelection(liveSelection.anchor, liveSelection.focus) + activeEditor.editor.activeContentBlock = liveSelection.focus.block + return true + } + + function restoreCollapsedEditorRange(activeEditor: MuyaEditor, range: Range | null) { + if (!range || !range.collapsed) { + return false + } + + const selection = document.getSelection() + if (!selection) { + return false + } + + try { + selection.removeAllRanges() + selection.addRange(range.cloneRange()) + focusEditorDomNode(activeEditor) + syncMuyaSelectionFromDom(activeEditor) + return true + } catch (error) { + options.logger.debug('collapsed selection restore skipped', { error }) + return false + } + } + + function collapseEditorSelectionToRangeEdge( + activeEditor: MuyaEditor, + range: Range | null, + edge: 'start' | 'end', + ) { + if (!range) { + return false + } + + const selection = document.getSelection() + if (!selection) { + return false + } + + try { + const collapsedRange = range.cloneRange() + collapsedRange.collapse(edge === 'start') + selection.removeAllRanges() + selection.addRange(collapsedRange) + focusEditorDomNode(activeEditor) + syncMuyaSelectionFromDom(activeEditor) + return true + } catch (error) { + options.logger.debug('selection collapse skipped', { edge, error }) + return false + } + } + + function clearEditorSelectionIfStillExpanded(activeEditor: MuyaEditor) { + const selection = document.getSelection() + if (selection && selection.rangeCount > 0 && !selection.isCollapsed) { + selection.removeAllRanges() + activeEditor.editor.selection.clear() + } + } + + async function finishEditorSelectionActionMode(reason: string) { + const finished = await finishAndroidEditorSelectionActionMode(reason) + if (finished) { + options.logger.debug('Android editor selection action mode finished', { reason }) + } + return finished + } + + async function finishEditorSelectionActionModeAndRestoreCaret( + reason: string, + activeEditor: MuyaEditor, + caretRange: Range | null, + ) { + const finished = await finishEditorSelectionActionMode(reason) + if (finished && caretRange) { + restoreCollapsedEditorRange(activeEditor, caretRange) + } + return finished + } + + function finishSelectionToolbarOutsideTap(caretRange: Range | null) { + const activeEditor = options.editorReady.value ? options.getEditor() : null + if (!activeEditor) { + void finishEditorSelectionActionMode('selection-toolbar-outside-tap') + return + } + + if (caretRange) { + restoreCollapsedEditorRange(activeEditor, caretRange) + } + void finishEditorSelectionActionModeAndRestoreCaret( + 'selection-toolbar-outside-tap', + activeEditor, + caretRange, + ).then((finished) => { + if (finished) { + options.logger.debug('selection toolbar outside tap dismissed', { + restoredCaret: Boolean(caretRange), + }) + } + }) + } + + function getSelectionTextFallback(range: Range | null) { + const rangeText = range?.toString() ?? '' + if (rangeText.length > 0) { + return rangeText + } + + return document.getSelection()?.toString() ?? '' + } + + async function runSelectionToolbarCommand( + commandId: SelectionToolbarCommandId, + restoreRange: Range | null, + ) { + const activeEditor = options.editorReady.value ? options.getEditor() : null + if (!activeEditor) { + return + } + + try { + const rangeBeforeCommand = restoreRange?.cloneRange() ?? getCurrentSelectionRangeClone() + if (options.androidInputDiagnosticsEnabled.value) { + options.logger.debug('selection command state', { + commandId, + phase: 'before', + rangeText: (rangeBeforeCommand?.toString() ?? '').slice(0, 32), + ...options.describeEditorInputState(), + }) + } + if (commandId === 'copy' || commandId === 'cut') { + const restoredRange = restoreEditorSelectionRange(activeEditor, rangeBeforeCommand) + if (!restoredRange) { + restoreEditorSelectionIfCollapsed(restoreRange) + } + + if (isAndroidSelectionControlAvailable()) { + // Deterministic Android path: serialize the selection with Muya's own + // clipboard pipeline and write it through the native clipboard bridge. + // This avoids depending on execCommand + ClipboardEvent behavior in + // OEM WebViews, which cannot be debugged remotely. + const fallbackSelectionText = getSelectionTextFallback(rangeBeforeCommand) + const payload + = commandId === 'cut' + ? activeEditor.editor.clipboard.cutSelectionToClipboardData() + : activeEditor.editor.clipboard.getClipboardData() + const clipboardText = payload.text || fallbackSelectionText + if (!clipboardText) { + options.logger.warn('selection clipboard payload empty, command skipped', { + commandId, + hadRestoreRange: Boolean(rangeBeforeCommand), + }) + return + } + + const written = await writeAndroidClipboardText(clipboardText) + if (!written) { + options.logger.warn('android clipboard write failed', { commandId }) + return + } + } else { + // Web path: execCommand routes through Muya's document-level copy/cut + // handlers, which own Markdown serialization and history-safe deletion. + const executed = document.execCommand(commandId) + if (!executed) { + const selectionText = document.getSelection()?.toString() ?? '' + const written + = selectionText.length > 0 && (await writeAndroidClipboardText(selectionText)) + if (commandId === 'cut' && written) { + activeEditor.editor.clipboard.cutHandler() + } + options.logger.warn('selection execCommand rejected, used clipboard fallback', { + commandId, + written, + }) + } + } + + if (commandId === 'copy') { + collapseEditorSelectionToRangeEdge(activeEditor, rangeBeforeCommand, 'end') + } else if (!document.getSelection()?.isCollapsed) { + const collapsed = collapseEditorSelectionToRangeEdge( + activeEditor, + rangeBeforeCommand, + 'start', + ) + if (!collapsed) { + clearEditorSelectionIfStillExpanded(activeEditor) + } + } + await finishEditorSelectionActionModeAndRestoreCaret( + `selection-toolbar-${commandId}`, + activeEditor, + getCurrentSelectionRangeClone(), + ) + } else if (commandId === 'paste') { + restoreEditorSelectionRange(activeEditor, restoreRange) + await activeEditor.editor.clipboard.pasteAsPlainText() + await finishEditorSelectionActionModeAndRestoreCaret( + 'selection-toolbar-paste', + activeEditor, + getCurrentSelectionRangeClone(), + ) + } else { + // Prefer the native select-all: it keeps Chromium's touch-selection + // session (and its drag handles) alive. Muya's JS select-all replaces + // the range programmatically, which never shows handles on Android. + const nativeSelectAll + = isAndroidSelectionControlAvailable() + && (await performAndroidNativeSelectAll('selection-toolbar')) + if (!nativeSelectAll) { + activeEditor.selectAll() + } + } + + options.logger.debug('selection toolbar command handled', { + commandId, + modelCharacters: activeEditor.getMarkdown().length, + }) + } catch (error) { + options.logger.warn('selection toolbar command failed', { commandId, error }) + } + } + + async function setEditorSelectionMenuSuppression(suppressed: boolean, reason: string) { + try { + const state = await setAndroidEditorSelectionMenuSuppressed(suppressed, reason) + if (state.native) { + options.logger.debug('Android editor selection menu suppression updated', state) + } + } catch (error) { + options.logger.warn('Android editor selection menu suppression update failed', error) + } + } + + function handleNativeSelectionTap(event: AndroidSelectionTapEvent) { + if (options.currentScreen.value !== 'editor' || !options.editorReady.value) { + return + } + + const selection = document.getSelection() + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + return + } + + const hitElement = document.elementFromPoint(event.x, event.y) + if (hitElement?.closest('[data-testid="mobile-selection-toolbar"]')) { + return + } + + const caret = caretRangeAtPoint(event.x, event.y) + options.logger.debug('native selection tap dismissal', { + x: Math.round(event.x), + y: Math.round(event.y), + hasCaret: Boolean(caret), + }) + finishSelectionToolbarOutsideTap(caret) + } + + async function installNativeSelectionTapListener() { + await uninstallNativeSelectionTapListener() + nativeSelectionTapCleanup = await addAndroidSelectionTapListener(handleNativeSelectionTap) + } + + async function uninstallNativeSelectionTapListener() { + const cleanup = nativeSelectionTapCleanup + nativeSelectionTapCleanup = null + if (cleanup) { + try { + await cleanup() + } catch (error) { + options.logger.debug('native selection tap listener cleanup failed', { error }) + } + } + } + + return { + captureEditorSelection, + restoreEditorSelectionRange, + finishSelectionToolbarOutsideTap, + runSelectionToolbarCommand, + setEditorSelectionMenuSuppression, + installNativeSelectionTapListener, + uninstallNativeSelectionTapListener, + } +}