Skip to content

Commit d410051

Browse files
committed
fix: remove duplicated functions and improve html parsing
1 parent 1319065 commit d410051

5 files changed

Lines changed: 103 additions & 72 deletions

File tree

packages/super-editor/src/core/InputRule.js

Lines changed: 41 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { warnNoDOM } from './helpers/domWarnings.js';
77
import { getTextContentFromNodes } from './helpers/getTextContentFromNodes.js';
88
import { isRegExp } from './utilities/isRegExp.js';
99
import { handleDocxPaste, wrapTextsInRuns } from './inputRules/docx-paste/docx-paste.js';
10-
import { ListHelpers } from '@helpers/list-numbering-helpers.js';
10+
import { ListHelpers, createListIdAllocator } from '@helpers/list-numbering-helpers.js';
1111
import { flattenListsInHtml, unflattenListsInHtml } from './inputRules/html/html-helpers.js';
1212
import { handleGoogleDocsHtml } from './inputRules/google-docs-paste/google-docs-paste.js';
1313
import {
@@ -53,43 +53,42 @@ export function isSuperdocOriginClipboardHtml(html) {
5353
return false;
5454
}
5555

56-
/** Apply pasted body `sectPr` when target has single-column layout. */
57-
function tryApplyEmbeddedBodySectPr(editor, view, bodySectPr) {
56+
/**
57+
* Apply pasted multi-column `bodySectPr` only when the document is still single-column.
58+
* Caller supplies how the clone is written (own `tr` vs dispatch).
59+
*
60+
* @param {object} editor
61+
* @param {object | null | undefined} bodySectPr
62+
* @param {import('prosemirror-model').Node} docForCurrentAttrs
63+
* @param {(clone: object) => void} applyClone
64+
*/
65+
function applyEmbeddedBodySectPrWhenAllowed(editor, bodySectPr, docForCurrentAttrs, applyClone) {
5866
if (!bodySectPr || typeof bodySectPr !== 'object') return;
5967

6068
const incomingCols = getSectPrColumns(bodySectPr);
6169
if (!incomingCols?.count || incomingCols.count <= 1) return;
6270

63-
const current = view.state.doc.attrs?.bodySectPr;
71+
const current = docForCurrentAttrs.attrs?.bodySectPr;
6472
const currentCols = current && getSectPrColumns(current);
6573
if (currentCols?.count > 1) return;
6674

6775
const clone = JSON.parse(JSON.stringify(bodySectPr));
68-
const tr = view.state.tr.setDocAttribute('bodySectPr', clone);
69-
const converter = editor?.converter;
70-
if (converter) {
71-
converter.bodySectPr = clone;
76+
applyClone(clone);
77+
if (editor?.converter) {
78+
editor.converter.bodySectPr = clone;
7279
}
73-
view.dispatch(tr);
7480
}
7581

76-
/** Like tryApplyEmbeddedBodySectPr but on `tr` (one dispatch with slice paste meta). */
77-
function applyEmbeddedBodySectPrToTransaction(editor, tr, bodySectPr, docBeforePaste) {
78-
if (!bodySectPr || typeof bodySectPr !== 'object') return;
79-
80-
const incomingCols = getSectPrColumns(bodySectPr);
81-
if (!incomingCols?.count || incomingCols.count <= 1) return;
82-
83-
const current = docBeforePaste.attrs?.bodySectPr;
84-
const currentCols = current && getSectPrColumns(current);
85-
if (currentCols?.count > 1) return;
82+
function tryApplyEmbeddedBodySectPr(editor, view, bodySectPr) {
83+
applyEmbeddedBodySectPrWhenAllowed(editor, bodySectPr, view.state.doc, (clone) => {
84+
view.dispatch(view.state.tr.setDocAttribute('bodySectPr', clone));
85+
});
86+
}
8687

87-
const clone = JSON.parse(JSON.stringify(bodySectPr));
88-
tr.setDocAttribute('bodySectPr', clone);
89-
const converter = editor?.converter;
90-
if (converter) {
91-
converter.bodySectPr = clone;
92-
}
88+
function applyEmbeddedBodySectPrToTransaction(editor, tr, bodySectPr, docBeforePaste) {
89+
applyEmbeddedBodySectPrWhenAllowed(editor, bodySectPr, docBeforePaste, (clone) => {
90+
tr.setDocAttribute('bodySectPr', clone);
91+
});
9392
}
9493

9594
export class InputRule {
@@ -573,15 +572,25 @@ export function sanitizeHtml(html, forbiddenTags = ['meta', 'svg', 'script', 'st
573572
for (let i = 0; i < node.childNodes.length; i += 1) {
574573
const current = node.childNodes[i];
575574
if (current?.nodeType === Node.COMMENT_NODE && current.nodeValue?.includes('[if !supportLists]')) {
576-
let j = i + 1;
577-
while (j < node.childNodes.length) {
575+
const nodesToStrip = [];
576+
let endifComment = null;
577+
for (let j = i + 1; j < node.childNodes.length; j += 1) {
578578
const next = node.childNodes[j];
579579
if (next?.nodeType === Node.COMMENT_NODE && next.nodeValue?.includes('[endif]')) {
580-
node.removeChild(next);
580+
endifComment = next;
581581
break;
582582
}
583-
node.removeChild(next);
583+
nodesToStrip.push(next);
584+
}
585+
if (!endifComment) {
586+
node.removeChild(current);
587+
i -= 1;
588+
continue;
584589
}
590+
for (const n of nodesToStrip) {
591+
node.removeChild(n);
592+
}
593+
node.removeChild(endifComment);
585594
node.removeChild(current);
586595
i -= 1;
587596
continue;
@@ -677,8 +686,6 @@ function handleCutEvent(view, event, editor) {
677686
const { from, to } = view.state.selection;
678687
if (from === to) return false;
679688

680-
event.preventDefault();
681-
682689
try {
683690
const slice = view.state.doc.slice(from, to);
684691
const fragment = slice.content;
@@ -702,12 +709,13 @@ function handleCutEvent(view, event, editor) {
702709
clipboardData.setData('text/html', embedSliceInHtml(html, sliceJson, bodySectPrJson));
703710
clipboardData.setData('text/plain', fragment.textBetween(0, fragment.size, '\n\n'));
704711

712+
event.preventDefault();
705713
view.dispatch(view.state.tr.deleteSelection().scrollIntoView());
714+
return true;
706715
} catch (error) {
707716
console.warn('Failed to handle cut:', error);
717+
return false;
708718
}
709-
710-
return true;
711719
}
712720

713721
const BULLET_MARKER_CHARS = new Set(['•', '◦', '▪', '\u2022', '\u25E6', '\u25AA']);
@@ -735,25 +743,6 @@ function lvlTextForRemap(fmt, ilvl, lr) {
735743
}
736744

737745
/** Remap pasted list numIds and rebuild defs so target doc’s abstract ids don’t clash. */
738-
function createListIdAllocator(editor) {
739-
const existingIds = new Set(
740-
Object.keys(editor?.converter?.numbering?.definitions || {})
741-
.map((value) => Number(value))
742-
.filter(Number.isFinite),
743-
);
744-
let nextId = Number(ListHelpers.getNewListId(editor));
745-
746-
return () => {
747-
while (!Number.isFinite(nextId) || existingIds.has(nextId)) {
748-
nextId = Number.isFinite(nextId) ? nextId + 1 : Number(ListHelpers.getNewListId(editor));
749-
}
750-
const allocatedId = nextId;
751-
existingIds.add(allocatedId);
752-
nextId += 1;
753-
return allocatedId;
754-
};
755-
}
756-
757746
function remapPastedListNumberingInFragment(fragment, editor) {
758747
if (!editor?.converter || !fragment.size) {
759748
return fragment;

packages/super-editor/src/core/InputRule.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,21 @@ describe('InputRule helpers', () => {
9393
expect(div?.querySelector('span')?.textContent).toBe('ok');
9494
});
9595

96+
it('does not strip siblings when Word list conditional is missing [endif]', () => {
97+
const html = '<div><!--[if !supportLists]--><span>•</span><p id="keep">Body</p></div>';
98+
const sanitized = sanitizeHtml(html);
99+
const p = sanitized.querySelector('#keep');
100+
expect(p).not.toBeNull();
101+
expect(p?.textContent).toBe('Body');
102+
});
103+
104+
it('still strips Word list conditional when [endif] is present', () => {
105+
const html = '<div><!--[if !supportLists]--><span>•</span><!--[endif]--><p id="after">Next</p></div>';
106+
const sanitized = sanitizeHtml(html);
107+
expect(sanitized.querySelector('span')).toBeNull();
108+
expect(sanitized.querySelector('#after')?.textContent).toBe('Next');
109+
});
110+
96111
it('handles single paragraph HTML paste inside a paragraph', () => {
97112
const { editor, view } = createEditorContext(doc(p('Existing')));
98113

packages/super-editor/src/core/helpers/list-numbering-helpers.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,33 @@ export const getNewListId = (editor, grouping = 'definitions') => {
119119
return getNextNumberingId(defs);
120120
};
121121

122+
/**
123+
* Allocator for unique list `numId`s when remapping pasted or HTML-copied lists.
124+
* Seeds from existing `editor.converter.numbering.definitions` and tracks ids
125+
* allocated in this batch so two paths (slice paste vs HTML) stay consistent.
126+
*
127+
* @param {import('../Editor').Editor} editor
128+
* @returns {() => number}
129+
*/
130+
export const createListIdAllocator = (editor) => {
131+
const existingIds = new Set(
132+
Object.keys(editor?.converter?.numbering?.definitions || {})
133+
.map((value) => Number(value))
134+
.filter(Number.isFinite),
135+
);
136+
let nextId = Number(getNewListId(editor));
137+
138+
return () => {
139+
while (!Number.isFinite(nextId) || existingIds.has(nextId)) {
140+
nextId = Number.isFinite(nextId) ? nextId + 1 : Number(getNewListId(editor));
141+
}
142+
const allocatedId = nextId;
143+
existingIds.add(allocatedId);
144+
nextId += 1;
145+
return allocatedId;
146+
};
147+
};
148+
122149
/**
123150
* Get the details of a list definition based on the numId and level.
124151
* Read-only — no migration needed (section 3.1).
@@ -452,6 +479,7 @@ export const ListHelpers = {
452479
generateNewListDefinition,
453480
getBasicNumIdTag,
454481
getNewListId,
482+
createListIdAllocator,
455483
hasListDefinition,
456484
removeListDefinitions,
457485

packages/super-editor/src/core/inputRules/html/html-helpers.js

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ListHelpers } from '@helpers/list-numbering-helpers.js';
1+
import { ListHelpers, createListIdAllocator } from '@helpers/list-numbering-helpers.js';
22

33
const removeWhitespaces = (node) => {
44
const children = node.childNodes;
@@ -50,25 +50,6 @@ export function flattenListsInHtml(html, editor, domDocument) {
5050
return doc.body.innerHTML;
5151
}
5252

53-
function createListIdAllocator(editor) {
54-
const existingIds = new Set(
55-
Object.keys(editor?.converter?.numbering?.definitions || {})
56-
.map((value) => Number(value))
57-
.filter(Number.isFinite),
58-
);
59-
let nextId = Number(ListHelpers.getNewListId(editor));
60-
61-
return () => {
62-
while (!Number.isFinite(nextId) || existingIds.has(nextId)) {
63-
nextId = Number.isFinite(nextId) ? nextId + 1 : Number(ListHelpers.getNewListId(editor));
64-
}
65-
const allocatedId = nextId;
66-
existingIds.add(allocatedId);
67-
nextId += 1;
68-
return allocatedId;
69-
};
70-
}
71-
7253
function restoreCopiedListParagraphDefinitions(container, editor) {
7354
if (!editor?.converter) return;
7455

packages/super-editor/src/core/inputRules/html/html-helpers.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ vi.mock('@helpers/list-numbering-helpers.js', () => ({
1414
setLvlOverride: setLvlOverrideMock,
1515
getListDefinitionDetails: getListDefinitionDetailsMock,
1616
},
17+
/** Mirrors `createListIdAllocator` from list-numbering-helpers (uses mocked getNewListId). */
18+
createListIdAllocator: (editor) => {
19+
const existingIds = new Set(
20+
Object.keys(editor?.converter?.numbering?.definitions || {})
21+
.map((value) => Number(value))
22+
.filter(Number.isFinite),
23+
);
24+
let nextId = Number(getNewListIdMock(editor));
25+
return () => {
26+
while (!Number.isFinite(nextId) || existingIds.has(nextId)) {
27+
nextId = Number.isFinite(nextId) ? nextId + 1 : Number(getNewListIdMock(editor));
28+
}
29+
const allocatedId = nextId;
30+
existingIds.add(allocatedId);
31+
nextId += 1;
32+
return allocatedId;
33+
};
34+
},
1735
}));
1836

1937
import { flattenListsInHtml, createSingleItemList, unflattenListsInHtml } from './html-helpers.js';

0 commit comments

Comments
 (0)