From 47e23a4646ff2575f8b426eab44356448d181fb0 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 29 Oct 2026 16:06:37 -0700 Subject: [PATCH] fix(editor): keep retained review highlight ranges mapped after edits (#34963) * fix(editor): keep retained review highlight ranges mapped after edits * test(editor): assert rendered review highlights instead of plugin state --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- ...kdown-annotation-highlight.mapping.test.ts | 97 +++++++++++++++++++ .../rich-markdown-annotation-highlight.ts | 35 +++++-- 2 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-annotation-highlight.mapping.test.ts diff --git a/src/renderer/src/components/editor/rich-markdown-annotation-highlight.mapping.test.ts b/src/renderer/src/components/editor/rich-markdown-annotation-highlight.mapping.test.ts new file mode 100644 index 00000000000..5c895ee9dc2 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-annotation-highlight.mapping.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment happy-dom +import { Editor } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { afterEach, describe, expect, it } from 'vitest' +import { + createRichMarkdownAnnotationHighlightExtension, + richMarkdownAnnotationHighlightPluginKey +} from './rich-markdown-annotation-highlight' + +const editors: Editor[] = [] + +afterEach(() => { + for (const editor of editors.splice(0)) { + editor.destroy() + } +}) + +function createEditor() { + const element = document.createElement('div') + const editor = new Editor({ + element, + extensions: [StarterKit, createRichMarkdownAnnotationHighlightExtension()], + content: '

before note after

' + }) + editors.push(editor) + editor.view.dispatch( + editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { + activeRange: { from: 8, to: 12 }, + noteRanges: [{ from: 8, to: 12 }] + }) + ) + return editor +} + +function highlightedText(editor: Editor): string[] { + return [...editor.view.dom.querySelectorAll('.rich-markdown-annotation-selection')].map( + (element) => element.textContent ?? '' + ) +} + +describe('review highlight positions across edits and metadata', () => { + it('keeps saved note highlights on the same text when clearing a pulse after an edit', () => { + const editor = createEditor() + editor.view.dispatch(editor.state.tr.insertText('prefix ', 1)) + expect(highlightedText(editor)).toEqual(['note']) + editor.view.dispatch(editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, null)) + expect(highlightedText(editor)).toEqual(['note']) + }) + + it('preserves mapped active and note ranges when metadata updates only one field', () => { + const editor = createEditor() + editor.view.dispatch(editor.state.tr.insertText('prefix ', 1)) + editor.view.dispatch( + editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { + noteRanges: [{ from: 15, to: 19 }] + }) + ) + const active = editor.view.dom.querySelector('.rich-markdown-annotation-selection-active') + expect(active?.textContent).toBe('note') + expect(highlightedText(editor)).toEqual(['note']) + }) + + it('drops deleted highlights instead of recreating them over surviving text', () => { + const editor = createEditor() + editor.view.dispatch(editor.state.tr.delete(8, 12)) + expect(highlightedText(editor)).toEqual([]) + editor.view.dispatch( + editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { + activeRange: null + }) + ) + expect(highlightedText(editor)).toEqual([]) + }) + + it('maps retained notes while accepting new-document metadata in the same edit transaction', () => { + const editor = createEditor() + editor.view.dispatch( + editor.state.tr.insertText('prefix ', 1).setMeta(richMarkdownAnnotationHighlightPluginKey, { + activeRange: null + }) + ) + expect(highlightedText(editor)).toEqual(['note']) + }) + + it('excludes boundary insertions and keeps undo/redo positions after rebuilding', () => { + const editor = createEditor() + editor.view.dispatch(editor.state.tr.insertText('end', 12).insertText('start', 8)) + editor.view.dispatch(editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, null)) + expect(highlightedText(editor)).toEqual(['note']) + expect(editor.commands.undo()).toBe(true) + editor.view.dispatch(editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, null)) + expect(highlightedText(editor)).toEqual(['note']) + expect(editor.commands.redo()).toBe(true) + editor.view.dispatch(editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, null)) + expect(highlightedText(editor)).toEqual(['note']) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-annotation-highlight.ts b/src/renderer/src/components/editor/rich-markdown-annotation-highlight.ts index 35de427ffed..2c81bd78097 100644 --- a/src/renderer/src/components/editor/rich-markdown-annotation-highlight.ts +++ b/src/renderer/src/components/editor/rich-markdown-annotation-highlight.ts @@ -1,6 +1,6 @@ import { Extension } from '@tiptap/core' import type { Node as ProseMirrorNode } from '@tiptap/pm/model' -import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state' import { Decoration, DecorationSet } from '@tiptap/pm/view' type RichMarkdownAnnotationHighlightState = { @@ -46,6 +46,18 @@ function createAnnotationDecorations( return decorations.length === 0 ? DecorationSet.empty : DecorationSet.create(doc, decorations) } +function mapAnnotationRange( + range: RichMarkdownAnnotationHighlightRange | null, + transaction: Transaction +): RichMarkdownAnnotationHighlightRange | null { + if (!range) { + return null + } + const from = transaction.mapping.map(Math.min(range.from, range.to), 1) + const to = transaction.mapping.map(Math.max(range.from, range.to), -1) + return from < to ? { from, to } : null +} + function createRichMarkdownAnnotationHighlightPlugin(): Plugin { return new Plugin({ key: richMarkdownAnnotationHighlightPluginKey, @@ -59,18 +71,26 @@ function createRichMarkdownAnnotationHighlightPlugin(): Plugin { + const mapped = mapAnnotationRange(range, tr) + return mapped ? [mapped] : [] + }) + : pluginState.noteRanges if (meta === null) { return { activeRange: null, - noteRanges: pluginState.noteRanges, - decorations: createAnnotationDecorations(tr.doc, null, pluginState.noteRanges) + noteRanges: previousNoteRanges, + decorations: createAnnotationDecorations(tr.doc, null, previousNoteRanges) } } if (meta) { const activeRange = - meta.activeRange === undefined ? pluginState.activeRange : meta.activeRange - const noteRanges = - meta.noteRanges === undefined ? pluginState.noteRanges : meta.noteRanges + meta.activeRange === undefined ? previousActiveRange : meta.activeRange + const noteRanges = meta.noteRanges === undefined ? previousNoteRanges : meta.noteRanges return { activeRange, noteRanges, @@ -79,7 +99,8 @@ function createRichMarkdownAnnotationHighlightPlugin(): Plugin