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(() => {