From a04ba0a6395a833e363314c16a6dcecc9304f569 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 31 Jan 2026 06:50:33 -0300 Subject: [PATCH 1/3] fix: find tracked change for firefox --- .../findTrackedMarkBetween.js | 18 ++++- .../findTrackedMarkBetween.test.js | 65 ++++++++++++++++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js index 11e13274b6..a7a2a9e412 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js @@ -47,7 +47,17 @@ export const findTrackedMarkBetween = ({ const resolved = doc.resolve(pos); const before = resolved.nodeBefore; - if (before?.type?.name === 'run') { + const after = resolved.nodeAfter; + + // Check if nodeBefore is a text node directly (not wrapped in a run). + // This handles cases where text is inserted outside of run nodes, + // such as in Google Docs exports with paragraph > lineBreak structure. + // Firefox inserts text directly as paragraph children, while Chrome + // tends to use run wrappers, so we need to handle both cases. + if (before?.type?.name === 'text') { + const beforeStart = Math.max(pos - before.nodeSize, 0); + tryMatch(before, beforeStart); + } else if (before?.type?.name === 'run') { const beforeStart = Math.max(pos - before.nodeSize, 0); const node = before.content?.content?.[0]; if (node?.type?.name === 'text') { @@ -55,8 +65,10 @@ export const findTrackedMarkBetween = ({ } } - const after = resolved.nodeAfter; - if (after?.type?.name === 'run') { + // Check if nodeAfter is a text node directly (not wrapped in a run) + if (after?.type?.name === 'text') { + tryMatch(after, pos); + } else if (after?.type?.name === 'run') { const node = after.content?.content?.[0]; if (node?.type?.name === 'text') { tryMatch(node, pos); diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.test.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.test.js index 60069e9852..033a98584b 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.test.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.test.js @@ -1,6 +1,6 @@ import { beforeEach, afterEach, describe, expect, it } from 'vitest'; import { EditorState } from 'prosemirror-state'; -import { TrackDeleteMarkName } from '../constants.js'; +import { TrackDeleteMarkName, TrackInsertMarkName } from '../constants.js'; import { findTrackedMarkBetween } from './findTrackedMarkBetween.js'; import { initTestEditor } from '@tests/helpers/helpers.js'; @@ -114,4 +114,67 @@ describe('findTrackedMarkBetween', () => { }), ); }); + + it('finds trackInsert mark on text node directly (not wrapped in run) at start position', () => { + // This tests the fix for SD-1707: Google Docs exports can have text nodes + // directly as children of paragraph, not wrapped in run nodes. + const insertMark = schema.marks[TrackInsertMarkName].create({ + id: 'abc12345-1234-1234-1234-123456789abc', + author: user.name, + authorEmail: user.email, + date, + }); + // Create: paragraph > text("1" with trackInsert) + run > lineBreak + // This mimics the structure after typing in a Google Docs exported empty list item + const textNode = schema.text('1', [insertMark]); + const lineBreak = schema.nodes.lineBreak.create(); + const run = schema.nodes.run.create({}, lineBreak); + const paragraph = schema.nodes.paragraph.create({}, [textNode, run]); + const doc = schema.nodes.doc.create({}, paragraph); + + const state = createState(doc); + const tr = state.tr; + + // Search from position after the text node (where the run starts) + // This simulates what happens when inserting the 2nd character + const found = findTrackedMarkBetween({ + tr, + from: 3, // Position after text node "1" + to: 4, + markName: TrackInsertMarkName, + attrs: { authorEmail: user.email }, + }); + + expect(found).not.toBeNull(); + expect(found.mark.attrs.id).toBe('abc12345-1234-1234-1234-123456789abc'); + }); + + it('finds trackInsert mark on text node directly when nodeBefore is a text node', () => { + const insertMark = schema.marks[TrackInsertMarkName].create({ + id: 'def67890-5678-5678-5678-567890123def', + author: user.name, + authorEmail: user.email, + date, + }); + // Create: paragraph > text("ab" with trackInsert) + const textNode = schema.text('ab', [insertMark]); + const paragraph = schema.nodes.paragraph.create({}, [textNode]); + const doc = schema.nodes.doc.create({}, paragraph); + + const state = createState(doc); + const tr = state.tr; + + // Search at position right after the text - this is where new text would be inserted + // nodeBefore at pos 3 should be the text node "ab" + const found = findTrackedMarkBetween({ + tr, + from: 3, + to: 4, + markName: TrackInsertMarkName, + attrs: { authorEmail: user.email }, + }); + + expect(found).not.toBeNull(); + expect(found.mark.attrs.id).toBe('def67890-5678-5678-5678-567890123def'); + }); }); From 0f9538d24503d57583f88848573f8602601181d5 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 31 Jan 2026 07:07:12 -0300 Subject: [PATCH 2/3] test: add missing test --- .../findTrackedMarkBetween.test.js | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.test.js b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.test.js index 033a98584b..c321ea669e 100644 --- a/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.test.js +++ b/packages/super-editor/src/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.test.js @@ -177,4 +177,36 @@ describe('findTrackedMarkBetween', () => { expect(found).not.toBeNull(); expect(found.mark.attrs.id).toBe('def67890-5678-5678-5678-567890123def'); }); + + it('finds trackInsert mark on text node directly when nodeAfter is a text node', () => { + // This tests the nodeAfter branch of the fix - when the text node comes + // after the search position (e.g., inserting at paragraph start) + const insertMark = schema.marks[TrackInsertMarkName].create({ + id: 'ghi01234-9012-9012-9012-901234567ghi', + author: user.name, + authorEmail: user.email, + date, + }); + // Create: paragraph > text("xy" with trackInsert) + // Search at position 2 (start of paragraph content) where text node is nodeAfter + const textNode = schema.text('xy', [insertMark]); + const paragraph = schema.nodes.paragraph.create({}, [textNode]); + const doc = schema.nodes.doc.create({}, paragraph); + + const state = createState(doc); + const tr = state.tr; + + // Position 2 is at the start of paragraph content (after doc open + paragraph open) + // At this position, nodeAfter should be the text node "xy" + const found = findTrackedMarkBetween({ + tr, + from: 2, + to: 3, + markName: TrackInsertMarkName, + attrs: { authorEmail: user.email }, + }); + + expect(found).not.toBeNull(); + expect(found.mark.attrs.id).toBe('ghi01234-9012-9012-9012-901234567ghi'); + }); }); From 3524a16a4640f912d6160504619b6b9498581fd9 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 20 Feb 2026 14:05:40 -0800 Subject: [PATCH 3/3] fix: prevent typed text from being dropped in WebKit list items after Enter --- .../src/extensions/paragraph/paragraph.js | 72 +++++++- .../extensions/paragraph/paragraph.test.js | 162 ++++++++++++++++++ ...1707-list-enter-track-changes-with-br.docx | Bin 0 -> 7979 bytes ...nter-with-br-track-change-grouping.spec.ts | 38 ++++ 4 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 packages/super-editor/src/tests/data/sd-1707-list-enter-track-changes-with-br.docx create mode 100644 tests/behavior/tests/comments/list-enter-with-br-track-change-grouping.spec.ts diff --git a/packages/super-editor/src/extensions/paragraph/paragraph.js b/packages/super-editor/src/extensions/paragraph/paragraph.js index a910cc2b29..d47099fa53 100644 --- a/packages/super-editor/src/extensions/paragraph/paragraph.js +++ b/packages/super-editor/src/extensions/paragraph/paragraph.js @@ -15,6 +15,48 @@ import { createDropcapPlugin } from './dropcapPlugin.js'; import { shouldSkipNodeView } from '../../utils/headless-helpers.js'; import { parseAttrs } from './helpers/parseAttrs.js'; +/** + * Whether a paragraph's only inline leaf content is break placeholders + * (lineBreak / hardBreak), with no visible text or other embedded objects. + * + * Distinct from `isVisuallyEmptyParagraph`, which returns false when any + * break node is present. This predicate catches the complementary case: + * paragraphs that *look* empty to the user but technically contain a break. + * + * Context: after splitting a list item that ends with a trailing `w:br`, + * the new paragraph inherits that break. In WebKit the resulting DOM shape + * causes native text insertion to land in the list-marker element + * (`contenteditable="false"`) instead of the content area — and + * `ParagraphNodeView.ignoreMutation` silently drops it. Detecting + * this shape lets the `beforeinput` handler insert via ProseMirror + * transaction instead of relying on native DOM insertion. + * + * @param {import('prosemirror-model').Node} node + * @returns {boolean} + */ +export function hasOnlyBreakContent(node) { + if (!node || node.type.name !== 'paragraph') return false; + + const text = (node.textContent || '').replace(/\u200b/g, '').trim(); + if (text.length > 0) return false; + + let hasBreak = false; + let hasOtherContent = false; + + node.descendants((child) => { + if (!child.isInline || !child.isLeaf) return true; + + if (child.type.name === 'lineBreak' || child.type.name === 'hardBreak') { + hasBreak = true; + } else { + hasOtherContent = true; + } + return !hasOtherContent; + }); + + return hasBreak && !hasOtherContent; +} + /** * Input rule regex that matches a bullet list marker (-, +, or *) * @private @@ -294,7 +336,7 @@ export const Paragraph = OxmlNode.create({ addPmPlugins() { const dropcapPlugin = createDropcapPlugin(this.editor); const numberingPlugin = createNumberingPlugin(this.editor); - const listEmptyInputPlugin = new Plugin({ + const listInputFallbackPlugin = new Plugin({ props: { handleDOMEvents: { beforeinput: (view, event) => { @@ -307,11 +349,27 @@ export const Paragraph = OxmlNode.create({ const { selection } = state; if (!selection.empty) return false; - const $from = selection.$from; - const paragraph = $from.parent; - if (!paragraph || paragraph.type.name !== 'paragraph') return false; - if (!isList(paragraph)) return false; - if (!isVisuallyEmptyParagraph(paragraph)) return false; + // Find the enclosing paragraph directly from the resolved position. + // We avoid `findParentNode(isList)` here because `isList` depends on + // `getResolvedParagraphProperties`, a WeakMap cache keyed by node + // identity. After the numbering plugin's `appendTransaction` sets + // `listRendering`, the paragraph node object is replaced, leaving + // the new node uncached — causing `isList` to return false. + const { $from } = selection; + let paragraph = null; + for (let d = $from.depth; d >= 0; d--) { + const node = $from.node(d); + if (node.type.name === 'paragraph') { + paragraph = node; + break; + } + } + if (!paragraph) return false; + + const isListParagraph = + paragraph.attrs?.paragraphProperties?.numberingProperties && paragraph.attrs?.listRendering; + if (!isListParagraph) return false; + if (!isVisuallyEmptyParagraph(paragraph) && !hasOnlyBreakContent(paragraph)) return false; const tr = state.tr.insertText(event.data); view.dispatch(tr); @@ -321,6 +379,6 @@ export const Paragraph = OxmlNode.create({ }, }, }); - return [dropcapPlugin, numberingPlugin, listEmptyInputPlugin, createLeadingCaretPlugin()]; + return [dropcapPlugin, numberingPlugin, listInputFallbackPlugin, createLeadingCaretPlugin()]; }, }); diff --git a/packages/super-editor/src/extensions/paragraph/paragraph.test.js b/packages/super-editor/src/extensions/paragraph/paragraph.test.js index 3e27a216c8..ad93af4fd2 100644 --- a/packages/super-editor/src/extensions/paragraph/paragraph.test.js +++ b/packages/super-editor/src/extensions/paragraph/paragraph.test.js @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TextSelection } from 'prosemirror-state'; import { initTestEditor, loadTestDataForEditorTests } from '../../tests/helpers/helpers.js'; import { calculateResolvedParagraphProperties } from './resolvedPropertiesCache.js'; +import { hasOnlyBreakContent } from './paragraph.js'; describe('Paragraph Node', () => { let docx, media, mediaFiles, fonts, editor; @@ -124,4 +125,165 @@ describe('Paragraph Node', () => { expect(editor.state.doc.textContent).toBe('t'); }); + + describe('hasOnlyBreakContent', () => { + it('returns true for a list paragraph containing only a lineBreak', () => { + let paragraphPos = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && paragraphPos == null) { + paragraphPos = pos; + return false; + } + return true; + }); + + const lineBreakNode = editor.schema.nodes.lineBreak.create(); + const tr = editor.state.tr.insert(paragraphPos + 1, lineBreakNode); + editor.view.dispatch(tr); + + const paragraph = editor.state.doc.nodeAt(paragraphPos); + expect(hasOnlyBreakContent(paragraph)).toBe(true); + }); + + it('returns false for a paragraph with visible text', () => { + editor.commands.insertContent('visible text'); + const paragraph = editor.state.doc.content.content[0]; + expect(hasOnlyBreakContent(paragraph)).toBe(false); + }); + + it('returns false for an empty paragraph with no content at all', () => { + const paragraph = editor.state.doc.content.content[0]; + expect(hasOnlyBreakContent(paragraph)).toBe(false); + }); + + it('returns false for null or non-paragraph nodes', () => { + expect(hasOnlyBreakContent(null)).toBe(false); + expect(hasOnlyBreakContent(undefined)).toBe(false); + + const runNode = editor.schema.nodes.run.create(); + expect(hasOnlyBreakContent(runNode)).toBe(false); + }); + }); + + it('handles beforeinput in a list paragraph with only a lineBreak (SD-1707)', () => { + let paragraphPos = null; + let paragraphNode = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && paragraphPos == null) { + paragraphPos = pos; + paragraphNode = node; + return false; + } + return true; + }); + + const numberingProperties = { numId: 1, ilvl: 0 }; + const listRendering = { + markerText: '1.', + suffix: 'tab', + justification: 'left', + path: [1], + numberingType: 'decimal', + }; + + // Make the paragraph a list item + let tr = editor.state.tr.setNodeMarkup(paragraphPos, null, { + ...paragraphNode.attrs, + paragraphProperties: { + ...(paragraphNode.attrs.paragraphProperties || {}), + numberingProperties, + }, + numberingProperties, + listRendering, + }); + editor.view.dispatch(tr); + + // Insert a lineBreak so the paragraph has only break content + const lineBreakNode = editor.schema.nodes.lineBreak.create(); + tr = editor.state.tr.insert(paragraphPos + 1, lineBreakNode); + editor.view.dispatch(tr); + + const updatedParagraph = editor.state.doc.nodeAt(paragraphPos); + calculateResolvedParagraphProperties(editor, updatedParagraph, editor.state.doc.resolve(paragraphPos)); + + // Place cursor inside the paragraph + tr = editor.state.tr.setSelection(TextSelection.create(editor.state.doc, paragraphPos + 1)); + editor.view.dispatch(tr); + + const beforeInputEvent = new InputEvent('beforeinput', { + data: 'a', + inputType: 'insertText', + bubbles: true, + cancelable: true, + }); + editor.view.dom.dispatchEvent(beforeInputEvent); + + expect(editor.state.doc.textContent).toBe('a'); + }); + + it('does NOT intercept beforeinput for a list paragraph with visible text', () => { + let paragraphPos = null; + let paragraphNode = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && paragraphPos == null) { + paragraphPos = pos; + paragraphNode = node; + return false; + } + return true; + }); + + const numberingProperties = { numId: 1, ilvl: 0 }; + const listRendering = { + markerText: '1.', + suffix: 'tab', + justification: 'left', + path: [1], + numberingType: 'decimal', + }; + + // Insert text first, then make it a list item + editor.commands.insertContent('hello'); + + paragraphPos = null; + paragraphNode = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && paragraphPos == null) { + paragraphPos = pos; + paragraphNode = node; + return false; + } + return true; + }); + + let tr = editor.state.tr.setNodeMarkup(paragraphPos, null, { + ...paragraphNode.attrs, + paragraphProperties: { + ...(paragraphNode.attrs.paragraphProperties || {}), + numberingProperties, + }, + numberingProperties, + listRendering, + }); + editor.view.dispatch(tr); + + const updatedParagraph = editor.state.doc.nodeAt(paragraphPos); + calculateResolvedParagraphProperties(editor, updatedParagraph, editor.state.doc.resolve(paragraphPos)); + + // Place cursor at the end of the text + const endPos = paragraphPos + updatedParagraph.nodeSize - 1; + tr = editor.state.tr.setSelection(TextSelection.create(editor.state.doc, endPos)); + editor.view.dispatch(tr); + + const beforeInputEvent = new InputEvent('beforeinput', { + data: 'x', + inputType: 'insertText', + bubbles: true, + cancelable: true, + }); + + // The handler should NOT intercept because the paragraph has visible text + const prevented = !editor.view.dom.dispatchEvent(beforeInputEvent); + expect(prevented).toBe(false); + }); }); diff --git a/packages/super-editor/src/tests/data/sd-1707-list-enter-track-changes-with-br.docx b/packages/super-editor/src/tests/data/sd-1707-list-enter-track-changes-with-br.docx new file mode 100644 index 0000000000000000000000000000000000000000..aa8a6d15b7654f2380350081607c2ee320cd934b GIT binary patch literal 7979 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 J*W^Eb{U7da2ZjIu literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/comments/list-enter-with-br-track-change-grouping.spec.ts b/tests/behavior/tests/comments/list-enter-with-br-track-change-grouping.spec.ts new file mode 100644 index 0000000000..dd675f7529 --- /dev/null +++ b/tests/behavior/tests/comments/list-enter-with-br-track-change-grouping.spec.ts @@ -0,0 +1,38 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, getDocumentText, listTrackChanges } from '../../helpers/document-api.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve( + __dirname, + '../../../../packages/super-editor/src/tests/data/sd-1707-list-enter-track-changes-with-br.docx', +); + +test.use({ config: { toolbar: 'full', comments: 'on', trackChanges: true } }); + +test('SD-1707 list item with trailing break keeps typed text in one tracked change', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + await assertDocumentApiReady(superdoc.page); + + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + const listText = 'Body copy for repro'; + const listTextPos = await superdoc.findTextPos(listText); + await superdoc.setTextSelection(listTextPos + listText.length); + await superdoc.waitForStable(); + + await superdoc.newLine(); + await superdoc.waitForStable(); + + await superdoc.type('abcdef'); + await superdoc.waitForStable(); + + await expect.poll(() => getDocumentText(superdoc.page)).toContain('abcdef'); + await expect.poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total).toBe(1); + + const inserts = await listTrackChanges(superdoc.page, { type: 'insert' }); + expect(inserts.changes?.[0]?.excerpt).toBe('abcdef'); +});