Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions packages/super-editor/src/extensions/paragraph/paragraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
Expand All @@ -321,6 +379,6 @@ export const Paragraph = OxmlNode.create({
},
},
});
return [dropcapPlugin, numberingPlugin, listEmptyInputPlugin, createLeadingCaretPlugin()];
return [dropcapPlugin, numberingPlugin, listInputFallbackPlugin, createLeadingCaretPlugin()];
},
});
162 changes: 162 additions & 0 deletions packages/super-editor/src/extensions/paragraph/paragraph.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,28 @@ 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') {
Comment thread
harbournick marked this conversation as resolved.
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') {
tryMatch(node, beforeStart);
}
}

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);
Expand Down
Loading
Loading