From d2f9c0ce2f05c9969dd86aa7dc78e23de5e59a47 Mon Sep 17 00:00:00 2001 From: runjuu Date: Fri, 4 Sep 2026 10:20:47 -0700 Subject: [PATCH] fix(editor): keep selections made through the accessibility API The bubble menu collapses any non-empty selection it did not see the user make with the pointer or keyboard, on the assumption that such a selection was restored from saved view state. That also collapses selections created through the macOS accessibility API (VoiceOver, writing assistants) within one frame, so those tools cannot act on the text they selected. Mark the transaction dispatched by restoreEditorViewState with a meta key and collapse only selections carrying that marker. Every other selection without pointer or keyboard intent still hides the menu but is left alone. --- src/app/core/main/editor/markdown/bubble-menu.tsx | 10 +++++++--- .../core/main/editor/markdown/selection-intent.ts | 8 ++++++++ src/app/core/main/editor/markdown/tiptap-editor.tsx | 13 ++++++++++--- 3 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 src/app/core/main/editor/markdown/selection-intent.ts diff --git a/src/app/core/main/editor/markdown/bubble-menu.tsx b/src/app/core/main/editor/markdown/bubble-menu.tsx index 2df8145e..1aae9af7 100644 --- a/src/app/core/main/editor/markdown/bubble-menu.tsx +++ b/src/app/core/main/editor/markdown/bubble-menu.tsx @@ -26,7 +26,8 @@ import { ListTodo, } from 'lucide-react' import { useState, useCallback, useEffect, useId, useLayoutEffect, useRef } from 'react' -import { TextSelection } from '@tiptap/pm/state' +import { TextSelection, type Transaction } from '@tiptap/pm/state' +import { VIEW_STATE_RESTORE_META } from './selection-intent' import { cn } from '@/lib/utils' import { useTranslations } from 'next-intl' import { toast } from '@/hooks/use-toast' @@ -203,7 +204,7 @@ export function BubbleMenu({ }, [customTranslateLang, handleTranslate, t]) // 更新定位 - const updatePosition = useCallback(() => { + const updatePosition = useCallback((props?: { transaction?: Transaction }) => { const { selection } = editor.state const { from, to } = selection @@ -218,8 +219,11 @@ export function BubbleMenu({ } // 应用启动或文件恢复时可能会还原一个非空选区,但这不是用户本次主动选择的文本。 + // 只折叠带有恢复标记的选区;辅助功能工具(VoiceOver、写作助手)通过 + // Accessibility API 创建的选区必须保留,否则它们无法继续操作。 if (!hasUserSelectionIntentRef.current) { - if (hasTextSelection(editor)) { + const isRestoredSelection = props?.transaction?.getMeta(VIEW_STATE_RESTORE_META) === true + if (isRestoredSelection && hasTextSelection(editor)) { collapseSelection() } hideMenu() diff --git a/src/app/core/main/editor/markdown/selection-intent.ts b/src/app/core/main/editor/markdown/selection-intent.ts new file mode 100644 index 00000000..772e766f --- /dev/null +++ b/src/app/core/main/editor/markdown/selection-intent.ts @@ -0,0 +1,8 @@ +/** + * Transaction meta key set by the editor when it re-applies a selection that + * was saved with the view state (app start, file reopen). The bubble menu + * collapses only selections carrying this marker; a selection created by an + * assistive tool through the accessibility API (VoiceOver, writing + * assistants) must survive so the tool can act on it. + */ +export const VIEW_STATE_RESTORE_META = 'noteGenViewStateRestore' diff --git a/src/app/core/main/editor/markdown/tiptap-editor.tsx b/src/app/core/main/editor/markdown/tiptap-editor.tsx index 9b47ee94..eca872dd 100644 --- a/src/app/core/main/editor/markdown/tiptap-editor.tsx +++ b/src/app/core/main/editor/markdown/tiptap-editor.tsx @@ -23,6 +23,7 @@ import { SearchAndReplace } from '@sereneinserenade/tiptap-search-and-replace' import { Extension, nodeInputRule, type Editor as CoreEditor, type JSONContent } from '@tiptap/core' import { Fragment, Slice, type Node as ProseMirrorNode, type NodeType } from '@tiptap/pm/model' import { AllSelection, EditorState, Plugin, PluginKey, TextSelection, type Selection } from '@tiptap/pm/state' +import { VIEW_STATE_RESTORE_META } from './selection-intent' import { redoDepth, undoDepth } from '@tiptap/pm/history' import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view' import { dropPoint } from '@tiptap/pm/transform' @@ -3290,16 +3291,22 @@ export function TipTapEditor({ return } + // The marker lets the bubble menu tell a restored selection from one + // made by the user or an assistive tool; only the former is collapsed. + const markViewStateRestore = ({ tr }: { tr: { setMeta: (key: string, value: unknown) => unknown } }) => { + tr.setMeta(VIEW_STATE_RESTORE_META, true) + return true + } if (isMobile) { - editor.commands.setTextSelection({ + editor.chain().setTextSelection({ from: selectionFrom, to: selectionTo, - }) + }).command(markViewStateRestore).run() } else { editor.chain().focus().setTextSelection({ from: selectionFrom, to: selectionTo, - }).run() + }).command(markViewStateRestore).run() } requestAnimationFrame(() => {