diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks-incremental.test.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks-incremental.test.ts new file mode 100644 index 00000000000..b82b8ec041d --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks-incremental.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + buildRichMarkdownCommentBlocks, + getRichMarkdownCommentBlocks +} from './rich-markdown-comment-blocks' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' + +const editors: Editor[] = [] + +function createEditor(source: string) { + const editor = new Editor({ + element: document.createElement('div'), + extensions: createRichMarkdownExtensions({ + codec: createRichMarkdownEditorCodec() + }), + content: source, + contentType: 'markdown' + }) + editors.push(editor) + return editor +} + +afterEach(() => { + for (const editor of editors.splice(0)) { + editor.destroy() + } + vi.restoreAllMocks() +}) + +describe('review source block serialization after edits', () => { + it('preserves exact block positions through multiline edits, moves, insertions, deletion and undo', () => { + const editor = createEditor( + 'First\n\n```text\none\ntwo\n```\n\n> quote\n>\n> second\n\n- item one\n- item two\n\nLast' + ) + const check = () => + expect(getRichMarkdownCommentBlocks(editor)).toEqual(buildRichMarkdownCommentBlocks(editor)) + check() + editor.view.dispatch( + editor.state.tr.insertText('extra\n', editor.state.doc.child(0).nodeSize + 1) + ) + check() + const node = editor.state.doc.child(0) + const move = editor.state.tr.delete(0, node.nodeSize) + move.insert(move.doc.content.size, node) + editor.view.dispatch(move) + check() + const paragraph = editor.schema.nodes.paragraph.create(null, editor.schema.text('inserted')) + editor.view.dispatch(editor.state.tr.insert(0, paragraph)) + check() + editor.view.dispatch(editor.state.tr.delete(0, paragraph.nodeSize)) + check() + expect(editor.commands.undo()).toBe(true) + check() + expect(editor.commands.redo()).toBe(true) + check() + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts index a416d6719f0..3d2cb7f4f4f 100644 --- a/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts @@ -130,15 +130,12 @@ describe('Markdown review source block reuse', () => { const from = selectLastParagraph(editor) vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 20, 80, 20)) const serialize = vi.spyOn(editor.markdown!, 'serialize') - const json = vi.spyOn(editor, 'getJSON') const note = comment() const expected = [{ from, to: from + 4 }] expect(getRichMarkdownAnnotationHighlightRanges(editor, [note], 20)).toEqual(expected) expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) - expect(json).toHaveBeenCalledTimes(1) serialize.mockClear() - json.mockClear() for (let index = 0; index < 20; index++) { expect(getRichMarkdownAnnotationHighlightRanges(editor, [note], 20)).toEqual(expected) @@ -153,7 +150,6 @@ describe('Markdown review source block reuse', () => { expect(getRichMarkdownReviewRailBlocks(editor)).toBe(getRichMarkdownCommentBlocks(editor)) } expect(serialize).not.toHaveBeenCalled() - expect(json).not.toHaveBeenCalled() }) it('updates selected text and viewport geometry without rebuilding source lines', () => { @@ -198,17 +194,13 @@ describe('Markdown review source block reuse', () => { editor.view.dispatch(editor.state.tr.insertText('new\n', codeFrom)) selectLastParagraph(editor) expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(10) - expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) expect( getRichMarkdownCommentAtPos(editor, [comment(30)], 20, editor.state.selection.from) ).toEqual(comment(30)) - expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) - serialize.mockClear() expect(editor.commands.undo()).toBe(true) selectLastParagraph(editor) expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) - expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) serialize.mockClear() expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) expect(serialize).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts index 261b982f0bd..a93e95be867 100644 --- a/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts +++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts @@ -1,4 +1,5 @@ import type { Editor, JSONContent } from '@tiptap/core' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' export type RichMarkdownCommentBlock = { @@ -9,30 +10,64 @@ export type RichMarkdownCommentBlock = { to: number } +function richMarkdownNodeJson(node: ProseMirrorNode): JSONContent { + return node.toJSON() +} + function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string { return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd() } +type RichMarkdownCommentBlockCounts = { + nodes: WeakMap + separators: WeakMap> +} + +function createCommentBlockCounts(): RichMarkdownCommentBlockCounts { + return { nodes: new WeakMap(), separators: new WeakMap() } +} + export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] { - const jsonContent = editor.getJSON().content ?? [] + return buildCommentBlocksWithCounts(editor, createCommentBlockCounts()) +} + +function buildCommentBlocksWithCounts( + editor: Editor, + counts: RichMarkdownCommentBlockCounts +): RichMarkdownCommentBlock[] { const blocks: RichMarkdownCommentBlock[] = [] let nextLine = 1 - let previousNodeJson: JSONContent | null = null + let previousNode: ProseMirrorNode | null = null + let previousNodeJson: JSONContent | undefined let previousNodeLineCount = 0 editor.state.doc.forEach((node, nodeOffset, index) => { - const nodeJson = jsonContent[index] - if (!nodeJson) { - return - } - const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson]) - const nodeLineCount = countRichMarkdownReviewMarkdownLines(nodeMarkdown) - if (previousNodeJson) { - const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson]) - const separatorLineCount = Math.max( - 0, - countRichMarkdownReviewMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount + let nodeJson: JSONContent | undefined + let nodeLineCount = counts.nodes.get(node) + if (nodeLineCount === undefined) { + nodeJson = richMarkdownNodeJson(node) + nodeLineCount = countRichMarkdownReviewMarkdownLines( + serializeRichMarkdownJson(editor, [nodeJson]) ) + counts.nodes.set(node, nodeLineCount) + } + if (previousNode) { + let separatorCounts = counts.separators.get(previousNode) + if (!separatorCounts) { + separatorCounts = new WeakMap() + counts.separators.set(previousNode, separatorCounts) + } + let separatorLineCount = separatorCounts.get(node) + if (separatorLineCount === undefined) { + previousNodeJson ??= richMarkdownNodeJson(previousNode) + nodeJson ??= richMarkdownNodeJson(node) + const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson]) + separatorLineCount = Math.max( + 0, + countRichMarkdownReviewMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount + ) + separatorCounts.set(node, separatorLineCount) + } nextLine += separatorLineCount } const startLine = nextLine @@ -46,6 +81,7 @@ export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownComm to: from + Math.max(0, node.nodeSize - 1) }) nextLine = endLine + 1 + previousNode = node previousNodeJson = nodeJson previousNodeLineCount = nodeLineCount }) @@ -62,6 +98,7 @@ type RichMarkdownCommentBlocksSnapshot = { markdown: Editor['markdown'] serialize: NonNullable['serialize'] | undefined blocks: readonly RichMarkdownCommentBlock[] + counts: RichMarkdownCommentBlockCounts } // Keep only the current document per editor; scrolling changes geometry, not source lines. @@ -76,7 +113,11 @@ export function getRichMarkdownCommentBlocks(editor: Editor): readonly RichMarkd return cached.blocks } - const blocks = buildRichMarkdownCommentBlocks(editor) - blocksByEditor.set(editor, { doc, markdown, serialize, blocks }) + const counts = + cached && cached.markdown === markdown && cached.serialize === serialize + ? cached.counts + : createCommentBlockCounts() + const blocks = buildCommentBlocksWithCounts(editor, counts) + blocksByEditor.set(editor, { doc, markdown, serialize, blocks, counts }) return blocks } diff --git a/src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts b/src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts index bdb6ea81b33..248792d92b4 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts @@ -62,7 +62,9 @@ describe('rich markdown annotation block reuse', () => { })) const doc = { forEach(callback: (node: unknown, offset: number, index: number) => void): void { - content.forEach((node, index) => callback(node, index * 10, index)) + content.forEach((node, index) => + callback({ ...node, toJSON: () => node }, index * 10, index) + ) }, // These fixtures never match selected text; traversal only needs to be supported. nodesBetween(): void {},