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