Skip to content

Commit a39cb68

Browse files
authored
fix: find tracked change for firefox (#1899)
* fix: find tracked change for firefox * test: add missing test * fix: prevent typed text from being dropped in WebKit list items after Enter
1 parent 3f55d23 commit a39cb68

6 files changed

Lines changed: 376 additions & 11 deletions

File tree

packages/super-editor/src/extensions/paragraph/paragraph.js

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,48 @@ import { createDropcapPlugin } from './dropcapPlugin.js';
1515
import { shouldSkipNodeView } from '../../utils/headless-helpers.js';
1616
import { parseAttrs } from './helpers/parseAttrs.js';
1717

18+
/**
19+
* Whether a paragraph's only inline leaf content is break placeholders
20+
* (lineBreak / hardBreak), with no visible text or other embedded objects.
21+
*
22+
* Distinct from `isVisuallyEmptyParagraph`, which returns false when any
23+
* break node is present. This predicate catches the complementary case:
24+
* paragraphs that *look* empty to the user but technically contain a break.
25+
*
26+
* Context: after splitting a list item that ends with a trailing `w:br`,
27+
* the new paragraph inherits that break. In WebKit the resulting DOM shape
28+
* causes native text insertion to land in the list-marker element
29+
* (`contenteditable="false"`) instead of the content area — and
30+
* `ParagraphNodeView.ignoreMutation` silently drops it. Detecting
31+
* this shape lets the `beforeinput` handler insert via ProseMirror
32+
* transaction instead of relying on native DOM insertion.
33+
*
34+
* @param {import('prosemirror-model').Node} node
35+
* @returns {boolean}
36+
*/
37+
export function hasOnlyBreakContent(node) {
38+
if (!node || node.type.name !== 'paragraph') return false;
39+
40+
const text = (node.textContent || '').replace(/\u200b/g, '').trim();
41+
if (text.length > 0) return false;
42+
43+
let hasBreak = false;
44+
let hasOtherContent = false;
45+
46+
node.descendants((child) => {
47+
if (!child.isInline || !child.isLeaf) return true;
48+
49+
if (child.type.name === 'lineBreak' || child.type.name === 'hardBreak') {
50+
hasBreak = true;
51+
} else {
52+
hasOtherContent = true;
53+
}
54+
return !hasOtherContent;
55+
});
56+
57+
return hasBreak && !hasOtherContent;
58+
}
59+
1860
/**
1961
* Input rule regex that matches a bullet list marker (-, +, or *)
2062
* @private
@@ -294,7 +336,7 @@ export const Paragraph = OxmlNode.create({
294336
addPmPlugins() {
295337
const dropcapPlugin = createDropcapPlugin(this.editor);
296338
const numberingPlugin = createNumberingPlugin(this.editor);
297-
const listEmptyInputPlugin = new Plugin({
339+
const listInputFallbackPlugin = new Plugin({
298340
props: {
299341
handleDOMEvents: {
300342
beforeinput: (view, event) => {
@@ -307,11 +349,27 @@ export const Paragraph = OxmlNode.create({
307349
const { selection } = state;
308350
if (!selection.empty) return false;
309351

310-
const $from = selection.$from;
311-
const paragraph = $from.parent;
312-
if (!paragraph || paragraph.type.name !== 'paragraph') return false;
313-
if (!isList(paragraph)) return false;
314-
if (!isVisuallyEmptyParagraph(paragraph)) return false;
352+
// Find the enclosing paragraph directly from the resolved position.
353+
// We avoid `findParentNode(isList)` here because `isList` depends on
354+
// `getResolvedParagraphProperties`, a WeakMap cache keyed by node
355+
// identity. After the numbering plugin's `appendTransaction` sets
356+
// `listRendering`, the paragraph node object is replaced, leaving
357+
// the new node uncached — causing `isList` to return false.
358+
const { $from } = selection;
359+
let paragraph = null;
360+
for (let d = $from.depth; d >= 0; d--) {
361+
const node = $from.node(d);
362+
if (node.type.name === 'paragraph') {
363+
paragraph = node;
364+
break;
365+
}
366+
}
367+
if (!paragraph) return false;
368+
369+
const isListParagraph =
370+
paragraph.attrs?.paragraphProperties?.numberingProperties && paragraph.attrs?.listRendering;
371+
if (!isListParagraph) return false;
372+
if (!isVisuallyEmptyParagraph(paragraph) && !hasOnlyBreakContent(paragraph)) return false;
315373

316374
const tr = state.tr.insertText(event.data);
317375
view.dispatch(tr);
@@ -321,6 +379,6 @@ export const Paragraph = OxmlNode.create({
321379
},
322380
},
323381
});
324-
return [dropcapPlugin, numberingPlugin, listEmptyInputPlugin, createLeadingCaretPlugin()];
382+
return [dropcapPlugin, numberingPlugin, listInputFallbackPlugin, createLeadingCaretPlugin()];
325383
},
326384
});

packages/super-editor/src/extensions/paragraph/paragraph.test.js

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
22
import { TextSelection } from 'prosemirror-state';
33
import { initTestEditor, loadTestDataForEditorTests } from '../../tests/helpers/helpers.js';
44
import { calculateResolvedParagraphProperties } from './resolvedPropertiesCache.js';
5+
import { hasOnlyBreakContent } from './paragraph.js';
56

67
describe('Paragraph Node', () => {
78
let docx, media, mediaFiles, fonts, editor;
@@ -124,4 +125,165 @@ describe('Paragraph Node', () => {
124125

125126
expect(editor.state.doc.textContent).toBe('t');
126127
});
128+
129+
describe('hasOnlyBreakContent', () => {
130+
it('returns true for a list paragraph containing only a lineBreak', () => {
131+
let paragraphPos = null;
132+
editor.state.doc.descendants((node, pos) => {
133+
if (node.type.name === 'paragraph' && paragraphPos == null) {
134+
paragraphPos = pos;
135+
return false;
136+
}
137+
return true;
138+
});
139+
140+
const lineBreakNode = editor.schema.nodes.lineBreak.create();
141+
const tr = editor.state.tr.insert(paragraphPos + 1, lineBreakNode);
142+
editor.view.dispatch(tr);
143+
144+
const paragraph = editor.state.doc.nodeAt(paragraphPos);
145+
expect(hasOnlyBreakContent(paragraph)).toBe(true);
146+
});
147+
148+
it('returns false for a paragraph with visible text', () => {
149+
editor.commands.insertContent('visible text');
150+
const paragraph = editor.state.doc.content.content[0];
151+
expect(hasOnlyBreakContent(paragraph)).toBe(false);
152+
});
153+
154+
it('returns false for an empty paragraph with no content at all', () => {
155+
const paragraph = editor.state.doc.content.content[0];
156+
expect(hasOnlyBreakContent(paragraph)).toBe(false);
157+
});
158+
159+
it('returns false for null or non-paragraph nodes', () => {
160+
expect(hasOnlyBreakContent(null)).toBe(false);
161+
expect(hasOnlyBreakContent(undefined)).toBe(false);
162+
163+
const runNode = editor.schema.nodes.run.create();
164+
expect(hasOnlyBreakContent(runNode)).toBe(false);
165+
});
166+
});
167+
168+
it('handles beforeinput in a list paragraph with only a lineBreak (SD-1707)', () => {
169+
let paragraphPos = null;
170+
let paragraphNode = null;
171+
editor.state.doc.descendants((node, pos) => {
172+
if (node.type.name === 'paragraph' && paragraphPos == null) {
173+
paragraphPos = pos;
174+
paragraphNode = node;
175+
return false;
176+
}
177+
return true;
178+
});
179+
180+
const numberingProperties = { numId: 1, ilvl: 0 };
181+
const listRendering = {
182+
markerText: '1.',
183+
suffix: 'tab',
184+
justification: 'left',
185+
path: [1],
186+
numberingType: 'decimal',
187+
};
188+
189+
// Make the paragraph a list item
190+
let tr = editor.state.tr.setNodeMarkup(paragraphPos, null, {
191+
...paragraphNode.attrs,
192+
paragraphProperties: {
193+
...(paragraphNode.attrs.paragraphProperties || {}),
194+
numberingProperties,
195+
},
196+
numberingProperties,
197+
listRendering,
198+
});
199+
editor.view.dispatch(tr);
200+
201+
// Insert a lineBreak so the paragraph has only break content
202+
const lineBreakNode = editor.schema.nodes.lineBreak.create();
203+
tr = editor.state.tr.insert(paragraphPos + 1, lineBreakNode);
204+
editor.view.dispatch(tr);
205+
206+
const updatedParagraph = editor.state.doc.nodeAt(paragraphPos);
207+
calculateResolvedParagraphProperties(editor, updatedParagraph, editor.state.doc.resolve(paragraphPos));
208+
209+
// Place cursor inside the paragraph
210+
tr = editor.state.tr.setSelection(TextSelection.create(editor.state.doc, paragraphPos + 1));
211+
editor.view.dispatch(tr);
212+
213+
const beforeInputEvent = new InputEvent('beforeinput', {
214+
data: 'a',
215+
inputType: 'insertText',
216+
bubbles: true,
217+
cancelable: true,
218+
});
219+
editor.view.dom.dispatchEvent(beforeInputEvent);
220+
221+
expect(editor.state.doc.textContent).toBe('a');
222+
});
223+
224+
it('does NOT intercept beforeinput for a list paragraph with visible text', () => {
225+
let paragraphPos = null;
226+
let paragraphNode = null;
227+
editor.state.doc.descendants((node, pos) => {
228+
if (node.type.name === 'paragraph' && paragraphPos == null) {
229+
paragraphPos = pos;
230+
paragraphNode = node;
231+
return false;
232+
}
233+
return true;
234+
});
235+
236+
const numberingProperties = { numId: 1, ilvl: 0 };
237+
const listRendering = {
238+
markerText: '1.',
239+
suffix: 'tab',
240+
justification: 'left',
241+
path: [1],
242+
numberingType: 'decimal',
243+
};
244+
245+
// Insert text first, then make it a list item
246+
editor.commands.insertContent('hello');
247+
248+
paragraphPos = null;
249+
paragraphNode = null;
250+
editor.state.doc.descendants((node, pos) => {
251+
if (node.type.name === 'paragraph' && paragraphPos == null) {
252+
paragraphPos = pos;
253+
paragraphNode = node;
254+
return false;
255+
}
256+
return true;
257+
});
258+
259+
let tr = editor.state.tr.setNodeMarkup(paragraphPos, null, {
260+
...paragraphNode.attrs,
261+
paragraphProperties: {
262+
...(paragraphNode.attrs.paragraphProperties || {}),
263+
numberingProperties,
264+
},
265+
numberingProperties,
266+
listRendering,
267+
});
268+
editor.view.dispatch(tr);
269+
270+
const updatedParagraph = editor.state.doc.nodeAt(paragraphPos);
271+
calculateResolvedParagraphProperties(editor, updatedParagraph, editor.state.doc.resolve(paragraphPos));
272+
273+
// Place cursor at the end of the text
274+
const endPos = paragraphPos + updatedParagraph.nodeSize - 1;
275+
tr = editor.state.tr.setSelection(TextSelection.create(editor.state.doc, endPos));
276+
editor.view.dispatch(tr);
277+
278+
const beforeInputEvent = new InputEvent('beforeinput', {
279+
data: 'x',
280+
inputType: 'insertText',
281+
bubbles: true,
282+
cancelable: true,
283+
});
284+
285+
// The handler should NOT intercept because the paragraph has visible text
286+
const prevented = !editor.view.dom.dispatchEvent(beforeInputEvent);
287+
expect(prevented).toBe(false);
288+
});
127289
});

packages/super-editor/src/extensions/track-changes/trackChangesHelpers/findTrackedMarkBetween.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,28 @@ export const findTrackedMarkBetween = ({
4747

4848
const resolved = doc.resolve(pos);
4949
const before = resolved.nodeBefore;
50-
if (before?.type?.name === 'run') {
50+
const after = resolved.nodeAfter;
51+
52+
// Check if nodeBefore is a text node directly (not wrapped in a run).
53+
// This handles cases where text is inserted outside of run nodes,
54+
// such as in Google Docs exports with paragraph > lineBreak structure.
55+
// Firefox inserts text directly as paragraph children, while Chrome
56+
// tends to use run wrappers, so we need to handle both cases.
57+
if (before?.type?.name === 'text') {
58+
const beforeStart = Math.max(pos - before.nodeSize, 0);
59+
tryMatch(before, beforeStart);
60+
} else if (before?.type?.name === 'run') {
5161
const beforeStart = Math.max(pos - before.nodeSize, 0);
5262
const node = before.content?.content?.[0];
5363
if (node?.type?.name === 'text') {
5464
tryMatch(node, beforeStart);
5565
}
5666
}
5767

58-
const after = resolved.nodeAfter;
59-
if (after?.type?.name === 'run') {
68+
// Check if nodeAfter is a text node directly (not wrapped in a run)
69+
if (after?.type?.name === 'text') {
70+
tryMatch(after, pos);
71+
} else if (after?.type?.name === 'run') {
6072
const node = after.content?.content?.[0];
6173
if (node?.type?.name === 'text') {
6274
tryMatch(node, pos);

0 commit comments

Comments
 (0)