From bba3c2d63d887a69a62076c23bca0e055421464f Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 23 Mar 2026 17:15:30 +0200 Subject: [PATCH 1/4] feat: initial support for multi-column; lists and media fixes --- .../style-engine/src/ooxml/index.ts | 8 +- packages/super-editor/src/core/InputRule.d.ts | 2 + packages/super-editor/src/core/InputRule.js | 402 +++++++++++++++++- .../super-editor/src/core/InputRule.test.js | 35 ++ .../src/core/commands/toggleList.js | 55 ++- .../src/core/commands/toggleList.test.js | 68 ++- .../core/helpers/clipboardFragmentAnnotate.js | 160 +++++++ .../src/core/helpers/pasteListHelpers.js | 19 +- .../core/helpers/superdocClipboardSlice.js | 209 +++++++++ .../helpers/superdocClipboardSlice.test.js | 68 +++ .../core/inputRules/docx-paste/docx-paste.js | 6 + .../inputRules/docx-paste/docx-paste.test.js | 80 ++++ .../src/core/inputRules/html/html-helpers.js | 78 ++++ .../core/inputRules/html/html-helpers.test.js | 63 ++- .../src/core/renderers/ProseMirrorRenderer.ts | 356 +++++++++++++++- .../paragraph/helpers/parseAttrs.js | 26 +- .../paragraph/helpers/parseAttrs.test.js | 20 + .../extensions/paragraph/numberingPlugin.js | 18 + 18 files changed, 1608 insertions(+), 65 deletions(-) create mode 100644 packages/super-editor/src/core/helpers/clipboardFragmentAnnotate.js create mode 100644 packages/super-editor/src/core/helpers/superdocClipboardSlice.js create mode 100644 packages/super-editor/src/core/helpers/superdocClipboardSlice.test.js diff --git a/packages/layout-engine/style-engine/src/ooxml/index.ts b/packages/layout-engine/style-engine/src/ooxml/index.ts index 2e68faeff4..b486daed07 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.ts @@ -75,9 +75,7 @@ export function resolveRunProperties( if (!params.translatedLinkedStyles?.styles) { return inlineRpr ?? {}; } - if (!inlineRpr) { - inlineRpr = {} as RunProperties; - } + inlineRpr = inlineRpr ? { ...inlineRpr } : ({} as RunProperties); // Getting properties from style const paragraphStyleId = resolvedPpr?.styleId as string | undefined; const paragraphStyleProps = resolveStyleChain('runProperties', params, paragraphStyleId) as RunProperties; @@ -158,9 +156,7 @@ export function resolveParagraphProperties( inlineProps: ParagraphProperties | null | undefined, tableInfo: TableInfo | null | undefined, ): ParagraphProperties { - if (!inlineProps) { - inlineProps = {} as ParagraphProperties; - } + inlineProps = inlineProps ? { ...inlineProps } : ({} as ParagraphProperties); if (!params.translatedLinkedStyles?.styles) { return inlineProps; } diff --git a/packages/super-editor/src/core/InputRule.d.ts b/packages/super-editor/src/core/InputRule.d.ts index 0264a727df..3f7269f5f2 100644 --- a/packages/super-editor/src/core/InputRule.d.ts +++ b/packages/super-editor/src/core/InputRule.d.ts @@ -63,6 +63,8 @@ export function inputRulesPlugin(config: InputRulesPluginConfig): Plugin; */ export function isWordHtml(html: string): boolean; +export function isSuperdocOriginClipboardHtml(html: string | null | undefined): boolean; + /** * Handle HTML paste events */ diff --git a/packages/super-editor/src/core/InputRule.js b/packages/super-editor/src/core/InputRule.js index aef6598393..660e79276d 100644 --- a/packages/super-editor/src/core/InputRule.js +++ b/packages/super-editor/src/core/InputRule.js @@ -1,5 +1,5 @@ import { Plugin, PluginKey } from 'prosemirror-state'; -import { Fragment, DOMParser as PMDOMParser, Slice } from 'prosemirror-model'; +import { Fragment, DOMParser as PMDOMParser, DOMSerializer as PmDOMSerializer, Slice } from 'prosemirror-model'; import { CommandService } from './CommandService.js'; import { chainableEditorState } from './helpers/chainableEditorState.js'; import { getHTMLFromFragment } from './helpers/getHTMLFromFragment.js'; @@ -7,7 +7,8 @@ import { warnNoDOM } from './helpers/domWarnings.js'; import { getTextContentFromNodes } from './helpers/getTextContentFromNodes.js'; import { isRegExp } from './utilities/isRegExp.js'; import { handleDocxPaste, wrapTextsInRuns } from './inputRules/docx-paste/docx-paste.js'; -import { flattenListsInHtml } from './inputRules/html/html-helpers.js'; +import { ListHelpers } from '@helpers/list-numbering-helpers.js'; +import { flattenListsInHtml, unflattenListsInHtml } from './inputRules/html/html-helpers.js'; import { handleGoogleDocsHtml } from './inputRules/google-docs-paste/google-docs-paste.js'; import { detectPasteUrl, @@ -15,6 +16,81 @@ import { normalizePastedLinks, resolveLinkProtocols, } from './inputRules/paste-link-normalizer.js'; +import { getSectPrColumns } from './super-converter/section-properties.js'; +import { + SUPERDOC_SLICE_MIME, + SUPERDOC_MEDIA_MIME, + SUPERDOC_SLICE_ATTR, + SUPERDOC_BODY_SECT_PR_ATTR, + embedSliceInHtml, + extractSliceFromHtml, + stripSliceFromHtml, + extractBodySectPrFromHtml, + bodySectPrShouldEmbed, + collectReferencedImageMediaForClipboard, + mergeSuperdocClipboardMediaIntoEditor, +} from './helpers/superdocClipboardSlice.js'; +import { annotateFragmentDomWithClipboardData } from './helpers/clipboardFragmentAnnotate.js'; + +/** Heuristic: clipboard HTML from SuperDoc copy (slice attrs, list/section metadata). */ +export function isSuperdocOriginClipboardHtml(html) { + if (!html || typeof html !== 'string') return false; + if (html.includes(SUPERDOC_SLICE_ATTR) || html.includes(SUPERDOC_BODY_SECT_PR_ATTR)) { + return true; + } + if (/data-sd-sect-pr\s*=/i.test(html)) { + return true; + } + if (/data-sd-block-id\s*=/i.test(html)) { + return true; + } + if ( + /data-num-id\s*=/i.test(html) && + (/data-level\s*=/i.test(html) || /data-list-numbering-type\s*=/i.test(html) || /data-list-level\s*=/i.test(html)) + ) { + return true; + } + return false; +} + +/** Apply pasted body `sectPr` when target has single-column layout. */ +function tryApplyEmbeddedBodySectPr(editor, view, bodySectPr) { + if (!bodySectPr || typeof bodySectPr !== 'object') return; + + const incomingCols = getSectPrColumns(bodySectPr); + if (!incomingCols?.count || incomingCols.count <= 1) return; + + const current = view.state.doc.attrs?.bodySectPr; + const currentCols = current && getSectPrColumns(current); + if (currentCols?.count > 1) return; + + const clone = JSON.parse(JSON.stringify(bodySectPr)); + const tr = view.state.tr.setDocAttribute('bodySectPr', clone); + const converter = editor?.converter; + if (converter) { + converter.bodySectPr = clone; + } + view.dispatch(tr); +} + +/** Like tryApplyEmbeddedBodySectPr but on `tr` (one dispatch with slice paste meta). */ +function applyEmbeddedBodySectPrToTransaction(editor, tr, bodySectPr, docBeforePaste) { + if (!bodySectPr || typeof bodySectPr !== 'object') return; + + const incomingCols = getSectPrColumns(bodySectPr); + if (!incomingCols?.count || incomingCols.count <= 1) return; + + const current = docBeforePaste.attrs?.bodySectPr; + const currentCols = current && getSectPrColumns(current); + if (currentCols?.count > 1) return; + + const clone = JSON.parse(JSON.stringify(bodySectPr)); + tr.setDocAttribute('bodySectPr', clone); + const converter = editor?.converter; + if (converter) { + converter.bodySectPr = clone; + } +} export class InputRule { match; @@ -189,6 +265,10 @@ export const inputRulesPlugin = ({ editor, rules }) => { }, props: { + handleDOMEvents: { + cut: (view, event) => handleCutEvent(view, event, editor), + }, + handleTextInput(view, from, to, text) { return run({ editor, @@ -226,8 +306,6 @@ export const inputRulesPlugin = ({ editor, rules }) => { // Paste handler handlePaste(view, event, slice) { const clipboard = event.clipboardData; - const html = clipboard.getData('text/html'); - const plainText = clipboard.getData('text/plain'); // Allow specialised plugins (e.g., field-annotation) first shot. const fieldAnnotationContent = slice.content.content.filter((item) => item.type.name === 'fieldAnnotation'); @@ -235,6 +313,32 @@ export const inputRulesPlugin = ({ editor, rules }) => { return false; } + const rawHtml = clipboard.getData('text/html'); + const isSuperdocHtml = isSuperdocOriginClipboardHtml(rawHtml); + const embeddedBodySectPr = isSuperdocHtml ? extractBodySectPrFromHtml(rawHtml) : null; + + const superdocSliceData = clipboard.getData(SUPERDOC_SLICE_MIME) || extractSliceFromHtml(rawHtml); + if (isSuperdocHtml || superdocSliceData) { + mergeSuperdocClipboardMediaIntoEditor(editor, clipboard); + } + if (superdocSliceData) { + try { + if (handleSuperdocSlicePaste(superdocSliceData, editor, view, embeddedBodySectPr)) return true; + } catch (err) { + console.warn('Failed to paste SuperDoc slice, falling back to HTML:', err); + } + } + + const html = stripSliceFromHtml(rawHtml); + const plainText = clipboard.getData('text/plain'); + // SuperDoc HTML is still Word-shaped; use HTML paste, not DOCX converter. + if (isSuperdocHtml) { + const ok = handleHtmlPaste(html, editor); + if (ok && embeddedBodySectPr) { + tryApplyEmbeddedBodySectPr(editor, view, embeddedBodySectPr); + } + return ok; + } const result = handleClipboardPaste({ editor, view }, html, plainText); return result; }, @@ -461,6 +565,39 @@ export function sanitizeHtml(html, forbiddenTags = ['meta', 'svg', 'script', 'st const container = resolvedDocument.createElement('div'); container.innerHTML = html; + // Strip Word conditional list-marker spans so paste does not duplicate markers. + const stripWordListConditionalPrefixes = (root) => { + const stripFromNode = (node) => { + if (!node?.childNodes) return; + + for (let i = 0; i < node.childNodes.length; i += 1) { + const current = node.childNodes[i]; + if (current?.nodeType === Node.COMMENT_NODE && current.nodeValue?.includes('[if !supportLists]')) { + let j = i + 1; + while (j < node.childNodes.length) { + const next = node.childNodes[j]; + if (next?.nodeType === Node.COMMENT_NODE && next.nodeValue?.includes('[endif]')) { + node.removeChild(next); + break; + } + node.removeChild(next); + } + node.removeChild(current); + i -= 1; + continue; + } + + if (current?.nodeType === Node.ELEMENT_NODE) { + stripFromNode(current); + } + } + }; + + stripFromNode(root); + }; + + stripWordListConditionalPrefixes(container); + const walkAndClean = (node) => { for (const child of [...node.children]) { if (forbiddenTags.includes(child.tagName.toLowerCase())) { @@ -518,10 +655,10 @@ export function handleClipboardPaste({ editor, view }, html, plainText) { return handlePlainTextUrlPaste(editor, view, plainText, detected); } case 'word-html': - if (editor.options.mode === 'docx') { + if (editor.options.mode === 'docx' && !isSuperdocOriginClipboardHtml(html)) { return handleDocxPaste(html, editor, view); } - break; + return handleHtmlPaste(html, editor); case 'google-docs': return handleGoogleDocsHtml(html, editor, view); // falls through to browser-html handling when not in DOCX mode @@ -531,3 +668,256 @@ export function handleClipboardPaste({ editor, view }, html, plainText) { return false; } + +/** Cut: put slice + annotated HTML on clipboard, then delete selection (copy uses ProseMirrorRenderer). */ +function handleCutEvent(view, event, editor) { + const clipboardData = event.clipboardData; + if (!clipboardData) return false; + + const { from, to } = view.state.selection; + if (from === to) return false; + + event.preventDefault(); + + try { + const slice = view.state.doc.slice(from, to); + const fragment = slice.content; + const sliceJson = JSON.stringify(slice.toJSON()); + + clipboardData.setData(SUPERDOC_SLICE_MIME, sliceJson); + const mediaJson = collectReferencedImageMediaForClipboard(sliceJson, editor); + if (mediaJson) { + clipboardData.setData(SUPERDOC_MEDIA_MIME, mediaJson); + } + + const div = document.createElement('div'); + const serializer = PmDOMSerializer.fromSchema(view.state.schema); + div.appendChild(serializer.serializeFragment(fragment)); + + annotateFragmentDomWithClipboardData(div, fragment, editor); + + const html = unflattenListsInHtml(div.innerHTML); + const bodySectPr = view.state.doc.attrs?.bodySectPr; + const bodySectPrJson = bodySectPr && bodySectPrShouldEmbed(bodySectPr) ? JSON.stringify(bodySectPr) : ''; + clipboardData.setData('text/html', embedSliceInHtml(html, sliceJson, bodySectPrJson)); + clipboardData.setData('text/plain', fragment.textBetween(0, fragment.size, '\n\n')); + + view.dispatch(view.state.tr.deleteSelection().scrollIntoView()); + } catch (error) { + console.warn('Failed to handle cut:', error); + } + + return true; +} + +const BULLET_MARKER_CHARS = new Set(['•', '◦', '▪', '\u2022', '\u25E6', '\u25AA']); + +function numberingFmtForSliceRemap(lr) { + if (lr?.numberingType) { + return lr.numberingType; + } + const marker = (lr?.markerText || '').trim(); + if (marker && BULLET_MARKER_CHARS.has(marker)) { + return 'bullet'; + } + return 'decimal'; +} + +function lvlTextForRemap(fmt, ilvl, lr) { + if (fmt === 'bullet') { + return lr?.markerText?.trim() || '•'; + } + const stored = lr?.markerText; + if (stored?.includes?.('%')) { + return stored; + } + return `%${ilvl + 1}.`; +} + +/** Remap pasted list numIds and rebuild defs so target doc’s abstract ids don’t clash. */ +function createListIdAllocator(editor) { + const existingIds = new Set( + Object.keys(editor?.converter?.numbering?.definitions || {}) + .map((value) => Number(value)) + .filter(Number.isFinite), + ); + let nextId = Number(ListHelpers.getNewListId(editor)); + + return () => { + while (!Number.isFinite(nextId) || existingIds.has(nextId)) { + nextId = Number.isFinite(nextId) ? nextId + 1 : Number(ListHelpers.getNewListId(editor)); + } + const allocatedId = nextId; + existingIds.add(allocatedId); + nextId += 1; + return allocatedId; + }; +} + +function remapPastedListNumberingInFragment(fragment, editor) { + if (!editor?.converter || !fragment.size) { + return fragment; + } + + /** @type {Array<{ oldId: number, ilvl: number, fmt: string, lr: object | null | undefined, path: number[] | null }>} */ + const paragraphMeta = []; + + const collect = (node) => { + if (node.type.name === 'paragraph') { + const np = node.attrs.paragraphProperties?.numberingProperties; + if (np?.numId != null) { + const oldId = Number(np.numId); + if (Number.isFinite(oldId)) { + const rawIlvl = Number(np.ilvl ?? 0); + const ilvl = Number.isFinite(rawIlvl) ? rawIlvl : 0; + const lr = node.attrs.listRendering; + const fmt = numberingFmtForSliceRemap(lr); + const path = Array.isArray(lr?.path) ? lr.path.map((n) => Number(n)) : null; + paragraphMeta.push({ oldId, ilvl, fmt, lr, path }); + } + } + } + if (node.content?.size) { + node.content.forEach((child) => collect(child)); + } + }; + + fragment.forEach((node) => collect(node)); + + if (paragraphMeta.length === 0) { + return fragment; + } + + const oldToNew = new Map(); + const allocateListId = createListIdAllocator(editor); + for (const { oldId } of paragraphMeta) { + if (!oldToNew.has(oldId)) { + oldToNew.set(oldId, allocateListId()); + } + } + + const generatedLevels = new Set(); + + for (const { oldId, ilvl, fmt, lr, path } of paragraphMeta) { + const newId = oldToNew.get(oldId); + const genKey = `${newId}:${ilvl}`; + if (generatedLevels.has(genKey)) { + continue; + } + + const listType = fmt === 'bullet' ? 'bulletList' : 'orderedList'; + let start = 1; + if (Array.isArray(path) && path.length) { + const atLevel = path[ilvl]; + const parsedAt = Number(atLevel); + if (Number.isFinite(parsedAt)) { + start = parsedAt; + } else { + const tail = Number(path[path.length - 1]); + if (Number.isFinite(tail)) { + start = tail; + } + } + } + + const lvlText = lvlTextForRemap(fmt, ilvl, lr); + + ListHelpers.generateNewListDefinition({ + numId: newId, + listType, + level: String(ilvl), + start: String(start), + text: lvlText, + fmt, + editor, + }); + + if (start > 1) { + ListHelpers.setLvlOverride(editor, newId, ilvl, { startOverride: start }); + } + + generatedLevels.add(genKey); + } + + const rewriteFragment = (frag) => { + const out = []; + frag.forEach((node) => { + out.push(rewriteNode(node)); + }); + return Fragment.fromArray(out); + }; + + const rewriteNode = (node) => { + if (node.type.name === 'paragraph') { + const np = node.attrs.paragraphProperties?.numberingProperties; + if (np?.numId != null) { + const oldId = Number(np.numId); + if (oldToNew.has(oldId)) { + const nextNp = { ...np, numId: oldToNew.get(oldId) }; + const pp = { ...node.attrs.paragraphProperties, numberingProperties: nextNp }; + const attrs = { ...node.attrs, paragraphProperties: pp }; + return node.type.create(attrs, node.content, node.marks); + } + } + return node; + } + + if (node.content?.size) { + const nextContent = rewriteFragment(node.content); + if (nextContent !== node.content) { + return node.copy(nextContent); + } + } + + return node; + }; + + return rewriteFragment(fragment); +} + +function handleSuperdocSlicePaste(sliceData, editor, view, embeddedBodySectPr = null) { + const sliceJson = JSON.parse(sliceData); + const slice = Slice.fromJSON(editor.schema, sliceJson); + + if (!slice.content.size) return false; + + const stripped = stripBlockIds(slice.content); + const cleanContent = remapPastedListNumberingInFragment(stripped, editor); + const cleanSlice = new Slice(cleanContent, slice.openStart, slice.openEnd); + + const { dispatch, state } = view; + if (!dispatch) return false; + + const tr = state.tr.replaceSelection(cleanSlice); + tr.setMeta('superdocSlicePaste', true); + normalizePastedLinks(tr, editor); + if (embeddedBodySectPr) { + applyEmbeddedBodySectPrToTransaction(editor, tr, embeddedBodySectPr, state.doc); + } + dispatch(tr.scrollIntoView()); + + return true; +} + +function stripBlockIds(fragment) { + const children = []; + fragment.forEach((node) => { + let newNode = node; + + const needsClean = node.type.name === 'paragraph' || node.attrs.sdBlockId != null; + + if (needsClean) { + const cleanAttrs = { ...node.attrs, sdBlockId: null, sdBlockRev: 0 }; + newNode = node.type.create(cleanAttrs, node.childCount ? stripBlockIds(node.content) : node.content, node.marks); + } else if (node.childCount) { + const newContent = stripBlockIds(node.content); + if (newContent !== node.content) { + newNode = node.copy(newContent); + } + } + + children.push(newNode); + }); + + return Fragment.fromArray(children); +} diff --git a/packages/super-editor/src/core/InputRule.test.js b/packages/super-editor/src/core/InputRule.test.js index a874f28111..c3f03d9baf 100644 --- a/packages/super-editor/src/core/InputRule.test.js +++ b/packages/super-editor/src/core/InputRule.test.js @@ -30,6 +30,7 @@ import { handleHtmlPaste, handleClipboardPaste, isWordHtml, + isSuperdocOriginClipboardHtml, } from './InputRule.js'; const createEditorContext = (initialDoc) => { @@ -122,6 +123,15 @@ describe('InputRule helpers', () => { expect(isWordHtml('

plain

')).toBe(false); }); + it('detects SuperDoc clipboard HTML without the hidden slice div', () => { + expect( + isSuperdocOriginClipboardHtml( + '

A

', + ), + ).toBe(true); + expect(isSuperdocOriginClipboardHtml('

Plain

')).toBe(false); + }); + it('delegates clipboard handling for plain text', () => { const editor = { options: { mode: 'text' } }; const handled = handleClipboardPaste({ editor }, ''); @@ -140,6 +150,31 @@ describe('InputRule helpers', () => { expect(handled).toBe('docx-result'); }); + it('uses HTML paste for Word-shaped SuperDoc round-trip in docx mode (not DOCX converter)', () => { + const { editor, view } = createEditorContext(doc(p('Base'))); + editor.options.mode = 'docx'; + const html = + '' + + '

Item

'; + + const handled = handleClipboardPaste({ editor, view }, html); + + expect(handleDocxPasteMock).not.toHaveBeenCalled(); + expect(flattenListsInHtmlMock).toHaveBeenCalled(); + expect(handled).toBe(true); + }); + + it('falls back to browser HTML handling for Word HTML outside docx mode', () => { + const { editor } = createEditorContext(doc(p('Base'))); + const html = '

Content

'; + + const handled = handleClipboardPaste({ editor, view: editor.view }, html); + + expect(handleDocxPasteMock).not.toHaveBeenCalled(); + expect(flattenListsInHtmlMock).toHaveBeenCalled(); + expect(handled).toBe(true); + }); + it('uses Google Docs handler when matching markup is found', () => { const editor = { options: { mode: 'text' } }; const html = '
Content
'; diff --git a/packages/super-editor/src/core/commands/toggleList.js b/packages/super-editor/src/core/commands/toggleList.js index bd88d425e9..9d2218fad1 100644 --- a/packages/super-editor/src/core/commands/toggleList.js +++ b/packages/super-editor/src/core/commands/toggleList.js @@ -5,28 +5,42 @@ import { getResolvedParagraphProperties } from '@extensions/paragraph/resolvedPr import { isVisuallyEmptyParagraph } from './removeNumberingProperties.js'; import { TextSelection } from 'prosemirror-state'; +function numFmtIsBullet(numFmt) { + if (numFmt == null) return false; + const v = String(numFmt).toLowerCase(); + return v === 'bullet' || v === 'image' || v === 'none'; +} + +function getParagraphListKind(node, editor) { + const paraProps = getResolvedParagraphProperties(node); + if (!paraProps?.numberingProperties || !node.attrs.listRendering) { + return null; + } + const { numId, ilvl = 0 } = paraProps.numberingProperties; + const details = ListHelpers.getListDefinitionDetails({ numId, level: ilvl, editor }); + const fmt = details?.listNumberingType ?? node.attrs.listRendering?.numberingType; + if (fmt == null) { + return null; + } + return numFmtIsBullet(fmt) ? 'bullet' : 'ordered'; +} + +function paragraphMatchesToggleListType(node, editor, listType) { + const kind = getParagraphListKind(node, editor); + if (!kind) return false; + if (listType === 'bulletList') return kind === 'bullet'; + if (listType === 'orderedList') return kind === 'ordered'; + return false; +} + export const toggleList = (listType) => ({ editor, state, tr, dispatch }) => { - // 1. Find first paragraph in selection that is a list of the same type - let predicate; - if (listType === 'orderedList') { - predicate = (n) => { - const paraProps = getResolvedParagraphProperties(n); - return ( - paraProps.numberingProperties && n.attrs.listRendering && n.attrs.listRendering.numberingType !== 'bullet' - ); - }; - } else if (listType === 'bulletList') { - predicate = (n) => { - const paraProps = getResolvedParagraphProperties(n); - return ( - paraProps.numberingProperties && n.attrs.listRendering && n.attrs.listRendering.numberingType === 'bullet' - ); - }; - } else { + if (listType !== 'orderedList' && listType !== 'bulletList') { return false; } + + const predicate = (n) => paragraphMatchesToggleListType(n, editor, listType); const { selection } = state; const { from, to } = selection; let firstListNode = null; @@ -55,12 +69,11 @@ export const toggleList = hasNonListParagraphs = true; } } - // 2. If not found, check if the paragraph right before the selection is a list of the same type if (!firstListNode && from > 0) { const $from = state.doc.resolve(from); - const parentIndex = $from.index(-1); - if (parentIndex > 0) { - const beforeNode = $from.node(-1).child(parentIndex - 1); + const blockIndex = $from.index(0); + if (blockIndex > 0) { + const beforeNode = state.doc.child(blockIndex - 1); if (beforeNode && beforeNode.type.name === 'paragraph' && predicate(beforeNode)) { firstListNode = beforeNode; } diff --git a/packages/super-editor/src/core/commands/toggleList.test.js b/packages/super-editor/src/core/commands/toggleList.test.js index 17a0f34c54..767b325e9b 100644 --- a/packages/super-editor/src/core/commands/toggleList.test.js +++ b/packages/super-editor/src/core/commands/toggleList.test.js @@ -9,6 +9,7 @@ vi.mock('@helpers/list-numbering-helpers.js', () => ({ ListHelpers: { getNewListId: vi.fn(), generateNewListDefinition: vi.fn(), + getListDefinitionDetails: vi.fn(() => null), }, })); @@ -32,26 +33,23 @@ const createParagraph = (attrs, pos) => ({ pos, }); -const createState = (paragraphs, { from = 1, to = 10, beforeNode = null, parentIndex = 0 } = {}) => { - const parent = { - child: vi.fn(() => beforeNode), - }; - - return { - doc: { - nodesBetween: vi.fn((_from, _to, callback) => { - for (const { node, pos } of paragraphs) { - callback(node, pos); - } - }), - resolve: vi.fn(() => ({ - index: () => parentIndex, - node: () => parent, - })), - }, - selection: { from, to }, - }; -}; +const createState = (paragraphs, { from = 1, to = 10, beforeNode = null, blockIndex = 0 } = {}) => ({ + doc: { + nodesBetween: vi.fn((_from, _to, callback) => { + for (const { node, pos } of paragraphs) { + callback(node, pos); + } + }), + resolve: vi.fn(() => ({ + index: (depth) => (depth === 0 ? blockIndex : 0), + })), + child: vi.fn((i) => { + if (beforeNode != null && i === blockIndex - 1) return beforeNode; + return undefined; + }), + }, + selection: { from, to }, +}); describe('toggleList', () => { let editor; @@ -60,6 +58,7 @@ describe('toggleList', () => { beforeEach(() => { vi.clearAllMocks(); + ListHelpers.getListDefinitionDetails.mockReturnValue(null); editor = { converter: {} }; tr = { docChanged: false, @@ -181,6 +180,33 @@ describe('toggleList', () => { expect(dispatch).toHaveBeenCalledWith(tr); }); + it('does not borrow bullet numbering when applying ordered list to plain paragraphs below', () => { + ListHelpers.getNewListId.mockReturnValue(99); + const beforeNumbering = { numId: 7, ilvl: 0 }; + const beforeNode = { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { numberingProperties: beforeNumbering }, + // Missing numberingType used to be misread as "ordered" via `!== 'bullet'` + listRendering: { markerText: '•' }, + }, + }; + ListHelpers.getListDefinitionDetails.mockReturnValue({ listNumberingType: 'bullet' }); + const paragraphs = [createParagraph({ paragraphProperties: {} }, 4)]; + const state = createState(paragraphs, { beforeNode, blockIndex: 1, from: 4, to: 8 }); + const handler = toggleList('orderedList'); + + const result = handler({ editor, state, tr, dispatch }); + + expect(result).toBe(true); + expect(ListHelpers.generateNewListDefinition).toHaveBeenCalledWith({ + numId: 99, + listType: 'orderedList', + editor, + }); + expect(dispatch).toHaveBeenCalledWith(tr); + }); + it('borrows numbering from the previous list paragraph when selection lacks one', () => { const beforeNumbering = { numId: 88, ilvl: 3, restart: true }; const beforeNode = { @@ -194,7 +220,7 @@ describe('toggleList', () => { createParagraph({ paragraphProperties: {} }, 4), createParagraph({ paragraphProperties: {} }, 8), ]; - const state = createState(paragraphs, { beforeNode, parentIndex: 1 }); + const state = createState(paragraphs, { beforeNode, blockIndex: 1 }); const handler = toggleList('orderedList'); const result = handler({ editor, state, tr, dispatch }); diff --git a/packages/super-editor/src/core/helpers/clipboardFragmentAnnotate.js b/packages/super-editor/src/core/helpers/clipboardFragmentAnnotate.js new file mode 100644 index 0000000000..7f96bbebd3 --- /dev/null +++ b/packages/super-editor/src/core/helpers/clipboardFragmentAnnotate.js @@ -0,0 +1,160 @@ +import { DOMSerializer as PmDOMSerializer } from 'prosemirror-model'; +import { ListHelpers } from '@helpers/list-numbering-helpers.js'; + +/** + * Clipboard HTML helpers (browser): + * + * - {@link annotateFragmentDomWithClipboardData} — run on PM-serialized HTML (cut + copy fallback). + * Adds list/paragraph `data-*` from the document model; walks tables via `tbody`/`tr` so cells + * still match the fragment. + * + * - {@link mergeSerializedClipboardMetadataIntoDomContainer} — when copy uses the browser selection + * DOM (see `buildSelectionClipboardHtml`), structure can differ from the serializer mirror. We build + * a mirror, annotate it, then copy the same `data-*` onto matching `

` nodes in order. + * + * DOM shape must match `PmDOMSerializer` output (including table > tbody > tr). + * + * @param {HTMLElement} container + * @param {import('prosemirror-model').Fragment} fragment + * @param {import('../Editor').Editor} editor + */ +export function annotateFragmentDomWithClipboardData(container, fragment, editor) { + if (!editor) return; + const domChildren = Array.from(container.children); + let domIndex = 0; + + fragment.forEach((pmNode) => { + const domEl = domChildren[domIndex++]; + if (!domEl) return; + annotatePmNodeOnClipboardDom(domEl, pmNode, editor); + }); +} + +/** + * Selection HTML from the browser differs from the serializer mirror; merge list-related attrs by stable order. + * + * @param {HTMLElement} container cloned selection HTML + * @param {import('prosemirror-view').EditorView} view + * @param {import('../Editor').Editor} editor + */ +export function mergeSerializedClipboardMetadataIntoDomContainer(container, view, editor) { + if (!editor || !view || typeof document === 'undefined') return; + const { from, to } = view.state.selection; + if (from === to) return; + + const fragment = view.state.doc.slice(from, to).content; + const mirror = document.createElement('div'); + mirror.appendChild(PmDOMSerializer.fromSchema(view.state.schema).serializeFragment(fragment)); + annotateFragmentDomWithClipboardData(mirror, fragment, editor); + + copyParagraphClipboardAttrsParallel(container, mirror); +} + +const PARAGRAPH_CLIPBOARD_ATTRS = [ + 'data-num-id', + 'data-level', + 'data-list-numbering-type', + 'data-indent', + 'data-spacing', + 'styleid', + 'data-justification', + 'data-marker-type', + 'data-list-level', + 'data-num-fmt', + 'data-lvl-text', +]; + +/** + * @param {HTMLElement} target + * @param {HTMLElement} source + */ +function copyParagraphClipboardAttrsParallel(target, source) { + const src = source.querySelectorAll('p'); + const dst = target.querySelectorAll('p'); + const n = Math.min(src.length, dst.length); + for (let i = 0; i < n; i += 1) { + for (const attr of PARAGRAPH_CLIPBOARD_ATTRS) { + if (src[i].hasAttribute(attr)) { + dst[i].setAttribute(attr, src[i].getAttribute(attr) || ''); + } + } + } +} + +/** + * @param {HTMLElement} domEl + * @param {import('prosemirror-model').Node} pmNode + * @param {import('../Editor').Editor} editor + */ +function annotatePmNodeOnClipboardDom(domEl, pmNode, editor) { + if (pmNode.type.name === 'paragraph') { + const props = pmNode.attrs.paragraphProperties; + if (props) { + if (props.numberingProperties?.numId != null) { + domEl.setAttribute('data-num-id', String(props.numberingProperties.numId)); + } + if (props.numberingProperties?.ilvl != null) { + domEl.setAttribute('data-level', String(props.numberingProperties.ilvl)); + } + if (props.indent && Object.keys(props.indent).length) { + domEl.setAttribute('data-indent', JSON.stringify(props.indent)); + } + if (props.spacing && Object.keys(props.spacing).length) { + domEl.setAttribute('data-spacing', JSON.stringify(props.spacing)); + } + if (props.styleId) { + domEl.setAttribute('styleid', props.styleId); + } + if (props.justification) { + domEl.setAttribute('data-justification', props.justification); + } + } + + if (!domEl.hasAttribute('data-list-numbering-type') || !domEl.getAttribute('data-list-numbering-type')) { + const numId = props?.numberingProperties?.numId; + const level = props?.numberingProperties?.ilvl ?? 0; + if (numId != null) { + const lr = pmNode.attrs.listRendering; + if (lr?.numberingType) { + domEl.setAttribute('data-list-numbering-type', lr.numberingType); + } else { + try { + const details = ListHelpers.getListDefinitionDetails({ numId, level, editor }); + if (details?.listNumberingType) { + domEl.setAttribute('data-list-numbering-type', details.listNumberingType); + } + } catch { + /* ignore */ + } + } + } + } + return; + } + + if (pmNode.type.name === 'table') { + const tbody = domEl.querySelector('tbody'); + const rowElements = tbody ? Array.from(tbody.children).filter((el) => el.tagName.toLowerCase() === 'tr') : []; + let rowIndex = 0; + pmNode.forEach((row) => { + if (row.isInline) return; + const rowDom = rowElements[rowIndex++]; + if (rowDom) { + annotatePmNodeOnClipboardDom(rowDom, row, editor); + } + }); + return; + } + + if (pmNode.isInline || !pmNode.childCount || !domEl.children.length) return; + + const childDoms = Array.from(domEl.children); + let childIndex = 0; + pmNode.forEach((child) => { + if (child.isInline) return; + const childDom = childDoms[childIndex++]; + if (childDom) { + annotatePmNodeOnClipboardDom(childDom, child, editor); + } + }); +} diff --git a/packages/super-editor/src/core/helpers/pasteListHelpers.js b/packages/super-editor/src/core/helpers/pasteListHelpers.js index d5d6eb8517..363c311f1d 100644 --- a/packages/super-editor/src/core/helpers/pasteListHelpers.js +++ b/packages/super-editor/src/core/helpers/pasteListHelpers.js @@ -1,3 +1,18 @@ +function normalizeCssValue(value) { + if (!value) return value; + + const trimmedValue = value.trim(); + if ( + trimmedValue.length >= 2 && + ((trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) || + (trimmedValue.startsWith("'") && trimmedValue.endsWith("'"))) + ) { + return trimmedValue.slice(1, -1); + } + + return trimmedValue; +} + export const extractListLevelStyles = (cssText, listId, level, numId) => { const pattern = new RegExp(`@list\\s+l${listId}:level${level}(?:\\s+lfo${numId})?\\s*\\{([^}]+)\\}`, 'i'); const match = cssText.match(pattern); @@ -11,7 +26,7 @@ export const extractListLevelStyles = (cssText, listId, level, numId) => { const styleMap = {}; for (const style of rawStyles) { const [key, value] = style.split(':').map((s) => s.trim()); - styleMap[key] = value; + styleMap[key] = normalizeCssValue(value); } return styleMap; @@ -28,7 +43,7 @@ export const extractParagraphStyles = (cssText, selector) => { const styleMap = {}; for (const style of rawStyles) { const [key, value] = style.split(':').map((s) => s.trim()); - styleMap[key] = value; + styleMap[key] = normalizeCssValue(value); } return styleMap; }; diff --git a/packages/super-editor/src/core/helpers/superdocClipboardSlice.js b/packages/super-editor/src/core/helpers/superdocClipboardSlice.js new file mode 100644 index 0000000000..c8d46da71a --- /dev/null +++ b/packages/super-editor/src/core/helpers/superdocClipboardSlice.js @@ -0,0 +1,209 @@ +/** + * Clipboard slice embedding in HTML (copy/paste). In the browser uses `DOMParser` and `btoa`/`atob`; + * in Node (tests) uses `Buffer` for base64 when `btoa`/`atob` are missing. + */ +import { getSectPrColumns } from '../super-converter/section-properties.js'; + +export const SUPERDOC_SLICE_MIME = 'application/x-superdoc-slice'; +/** JSON map of package-relative image path → display URL (data URL, https, or blob URL). */ +export const SUPERDOC_MEDIA_MIME = 'application/x-superdoc-media'; +export const SUPERDOC_SLICE_ATTR = 'data-superdoc-slice'; +export const SUPERDOC_BODY_SECT_PR_ATTR = 'data-sd-body-sect-pr'; + +/** + * Walk a ProseMirror Slice JSON object and collect `editor.storage.image.media` + * entries for every image `attrs.src` in the slice. Needed for SuperDoc→SuperDoc + * paste: slice JSON only carries paths like `word/media/…`, not the bytes/URLs. + * + * @param {string} sliceJsonString + * @param {object} editor + * @returns {string} JSON string or '' if nothing to ship + */ +export function collectReferencedImageMediaForClipboard(sliceJsonString, editor) { + if (!sliceJsonString || !editor?.storage?.image?.media) return ''; + + let slice; + try { + slice = JSON.parse(sliceJsonString); + } catch { + return ''; + } + + const source = editor.storage.image.media; + const out = {}; + + const visit = (node) => { + if (!node || typeof node !== 'object') return; + if (node.type === 'image') { + const src = node.attrs?.src; + if (typeof src === 'string' && src.length > 0) { + const val = source[src]; + if (typeof val === 'string' && val.length > 0) { + out[src] = val; + } + } + } + const { content } = node; + if (Array.isArray(content)) { + for (const child of content) visit(child); + } + }; + + if (Array.isArray(slice.content)) { + for (const node of slice.content) visit(node); + } + + return Object.keys(out).length > 0 ? JSON.stringify(out) : ''; +} + +/** + * @param {object} editor + * @param {DataTransfer | null | undefined} clipboardData + */ +export function mergeSuperdocClipboardMediaIntoEditor(editor, clipboardData) { + if (!editor?.storage?.image) return; + const raw = clipboardData?.getData?.(SUPERDOC_MEDIA_MIME); + if (!raw || typeof raw !== 'string') return; + + let map; + try { + map = JSON.parse(raw); + } catch { + return; + } + if (!map || typeof map !== 'object') return; + + if (!editor.storage.image.media) { + editor.storage.image.media = {}; + } + + const yMedia = editor.options?.ydoc?.getMap?.('media'); + + for (const [path, data] of Object.entries(map)) { + if (typeof path !== 'string' || !path || typeof data !== 'string' || !data) continue; + editor.storage.image.media[path] = data; + yMedia?.set?.(path, data); + } +} + +/** Latin-1 / “binary” string → base64 (browser `btoa`, else Node `Buffer`). */ +function binaryStringToBase64(binary) { + if (typeof globalThis.btoa === 'function') { + return globalThis.btoa(binary); + } + if (typeof Buffer !== 'undefined') { + return Buffer.from(binary, 'latin1').toString('base64'); + } + throw new Error('[superdocClipboardSlice] base64 encode requires btoa (browser) or Buffer (Node)'); +} + +/** base64 → Latin-1 / “binary” string (browser `atob`, else Node `Buffer`). */ +function base64ToBinaryString(b64) { + if (typeof globalThis.atob === 'function') { + return globalThis.atob(b64); + } + if (typeof Buffer !== 'undefined') { + return Buffer.from(b64, 'base64').toString('latin1'); + } + throw new Error('[superdocClipboardSlice] base64 decode requires atob (browser) or Buffer (Node)'); +} + +/** + * UTF-8 string → base64. Same idea as `btoa(unescape(encodeURIComponent(s)))` without `unescape`. + * @param {string} input + */ +function encodeUtf8Base64(input) { + const binary = encodeURIComponent(input).replace(/%([0-9A-F]{2})/g, (_, hex) => + String.fromCharCode(parseInt(hex, 16)), + ); + return binaryStringToBase64(binary); +} + +/** + * base64 → UTF-8 string. Decodes bytes then UTF-8 via percent-encoding. + * @param {string} b64 + */ +function decodeUtf8Base64(b64) { + if (!b64) return ''; + try { + const bin = base64ToBinaryString(b64); + let pct = ''; + for (let i = 0; i < bin.length; i += 1) { + pct += `%${bin.charCodeAt(i).toString(16).padStart(2, '0')}`; + } + return decodeURIComponent(pct); + } catch { + return ''; + } +} + +export function bodySectPrShouldEmbed(bodySectPr) { + if (!bodySectPr || typeof bodySectPr !== 'object') return false; + const cols = getSectPrColumns(bodySectPr); + return !!(cols?.count && cols.count > 1); +} + +/** Embeds PM slice (base64 in element text) and optional body sectPr for multi-column paste. */ +export function embedSliceInHtml(html, sliceJson, bodySectPrJson = '') { + let out = html; + if (bodySectPrJson) { + const body64 = encodeUtf8Base64(bodySectPrJson); + out = `

${body64}
${out}`; + } + if (!sliceJson) return out; + const base64 = encodeUtf8Base64(sliceJson); + return `
${base64}
${out}`; +} + +/** + * Reads slice JSON from HTML produced by {@link embedSliceInHtml} (hidden div + base64 text). + */ +export function extractSliceFromHtml(html) { + if (!html || !html.includes(SUPERDOC_SLICE_ATTR)) return null; + if (typeof DOMParser === 'undefined') return null; + + try { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const el = doc.querySelector(`[${SUPERDOC_SLICE_ATTR}]`); + if (!el) return null; + + let b64 = el.textContent?.trim() ?? ''; + if (!b64) { + b64 = el.getAttribute(SUPERDOC_SLICE_ATTR)?.trim() ?? ''; + } + if (!b64) return null; + + const decoded = decodeUtf8Base64(b64); + return decoded || null; + } catch { + return null; + } +} + +export function stripSliceFromHtml(html) { + if (!html) return html; + let out = html; + if (out.includes(SUPERDOC_SLICE_ATTR)) { + out = out.replace(/]*data-superdoc-slice[^>]*>[\s\S]*?<\/div>/gi, ''); + } + if (out.includes(SUPERDOC_BODY_SECT_PR_ATTR)) { + out = out.replace(/]*data-sd-body-sect-pr[^>]*>[\s\S]*?<\/div>/gi, ''); + } + return out; +} + +export function extractBodySectPrFromHtml(html) { + if (!html || !html.includes(SUPERDOC_BODY_SECT_PR_ATTR)) return null; + if (typeof DOMParser === 'undefined') return null; + + try { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const el = doc.querySelector(`[${SUPERDOC_BODY_SECT_PR_ATTR}]`); + if (!el) return null; + const b64 = el.textContent?.trim() ?? ''; + if (!b64) return null; + return JSON.parse(decodeUtf8Base64(b64)); + } catch { + return null; + } +} diff --git a/packages/super-editor/src/core/helpers/superdocClipboardSlice.test.js b/packages/super-editor/src/core/helpers/superdocClipboardSlice.test.js new file mode 100644 index 0000000000..3e297f6c5c --- /dev/null +++ b/packages/super-editor/src/core/helpers/superdocClipboardSlice.test.js @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + collectReferencedImageMediaForClipboard, + mergeSuperdocClipboardMediaIntoEditor, + SUPERDOC_MEDIA_MIME, +} from './superdocClipboardSlice.js'; + +describe('superdocClipboardSlice image media', () => { + it('collectReferencedImageMediaForClipboard gathers paths from slice JSON', () => { + const editor = { + storage: { + image: { + media: { + 'word/media/a.png': 'data:image/png;base64,AAA', + 'word/media/b.png': 'data:image/png;base64,BBB', + }, + }, + }, + }; + + const sliceJson = JSON.stringify({ + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Hi' }, + { + type: 'image', + attrs: { src: 'word/media/a.png' }, + }, + ], + }, + ], + openStart: 0, + openEnd: 0, + }); + + const out = collectReferencedImageMediaForClipboard(sliceJson, editor); + expect(JSON.parse(out)).toEqual({ 'word/media/a.png': 'data:image/png;base64,AAA' }); + }); + + it('mergeSuperdocClipboardMediaIntoEditor merges into storage and ydoc media map', () => { + const ySet = vi.fn(); + const editor = { + storage: { + image: { + media: { 'word/media/existing.png': 'data:old' }, + }, + }, + options: { + ydoc: { + getMap: () => ({ set: ySet }), + }, + }, + }; + + const clipboardData = { + getData: (mime) => + mime === SUPERDOC_MEDIA_MIME ? JSON.stringify({ 'word/media/new.png': 'data:image/png;base64,XX' }) : '', + }; + + mergeSuperdocClipboardMediaIntoEditor(editor, clipboardData); + + expect(editor.storage.image.media['word/media/new.png']).toBe('data:image/png;base64,XX'); + expect(editor.storage.image.media['word/media/existing.png']).toBe('data:old'); + expect(ySet).toHaveBeenCalledWith('word/media/new.png', 'data:image/png;base64,XX'); + }); +}); diff --git a/packages/super-editor/src/core/inputRules/docx-paste/docx-paste.js b/packages/super-editor/src/core/inputRules/docx-paste/docx-paste.js index 8ad595ad5c..87b37dd14b 100644 --- a/packages/super-editor/src/core/inputRules/docx-paste/docx-paste.js +++ b/packages/super-editor/src/core/inputRules/docx-paste/docx-paste.js @@ -287,6 +287,12 @@ const transformWordLists = (container, editor) => { if (item.hasAttribute('data-spacing')) { pElement.setAttribute('data-spacing', item.getAttribute('data-spacing')); } + if (item.hasAttribute('data-sd-sect-pr')) { + pElement.setAttribute('data-sd-sect-pr', item.getAttribute('data-sd-sect-pr')); + } + if (item.hasAttribute('data-sd-page-break-source')) { + pElement.setAttribute('data-sd-page-break-source', item.getAttribute('data-sd-page-break-source')); + } if (item.hasAttribute('data-text-styles')) { const textStyles = JSON.parse(item.getAttribute('data-text-styles')); Object.keys(textStyles).forEach((key) => { diff --git a/packages/super-editor/src/core/inputRules/docx-paste/docx-paste.test.js b/packages/super-editor/src/core/inputRules/docx-paste/docx-paste.test.js index 44eb3a14be..b4bcdaa0e2 100644 --- a/packages/super-editor/src/core/inputRules/docx-paste/docx-paste.test.js +++ b/packages/super-editor/src/core/inputRules/docx-paste/docx-paste.test.js @@ -146,6 +146,86 @@ describe('handleDocxPaste', () => { expect(replaceSelectionWith).toHaveBeenCalledWith(parseResult, true); expect(dispatch).toHaveBeenCalledWith('next-tr'); }); + + it('strips CSS string quotes from bullet level text before normalizing markers', () => { + const html = ` + + + + + +

+ + Bullet item +

+ + + `; + + const dispatch = vi.fn(); + const replaceSelectionWith = vi.fn(() => 'next-tr'); + const editor = { + schema: {}, + converter: { convertedXml: '' }, + view: { dispatch }, + }; + const view = { state: { tr: { replaceSelectionWith } } }; + + handleDocxPaste(html, editor, view); + + expect(normalizeLvlTextCharMock).toHaveBeenCalledWith('•'); + }); + + it('preserves copied section metadata when rebuilding Word list paragraphs', () => { + const html = ` + + + + + +

+ 1. + Section list item +

+ + + `; + + const dispatch = vi.fn(); + const replaceSelectionWith = vi.fn(() => 'next-tr'); + const editor = { + schema: {}, + converter: { convertedXml: '' }, + view: { dispatch }, + }; + const view = { state: { tr: { replaceSelectionWith } } }; + + handleDocxPaste(html, editor, view); + + const parsedNode = parseSpy.mock.calls[0][0]; + const generatedParagraph = parsedNode.querySelector('p[data-list-level]'); + expect(generatedParagraph?.getAttribute('data-sd-sect-pr')).toContain('"w:sectPr"'); + expect(generatedParagraph?.getAttribute('data-sd-page-break-source')).toBe('sectPr'); + }); }); describe('wrapTextsInRuns', () => { diff --git a/packages/super-editor/src/core/inputRules/html/html-helpers.js b/packages/super-editor/src/core/inputRules/html/html-helpers.js index 2cd2329670..8e9bf61953 100644 --- a/packages/super-editor/src/core/inputRules/html/html-helpers.js +++ b/packages/super-editor/src/core/inputRules/html/html-helpers.js @@ -39,6 +39,7 @@ export function flattenListsInHtml(html, editor, domDocument) { const parser = new DOMParserConstructor(); const doc = removeWhitespaces(parser.parseFromString(html, 'text/html')); + restoreCopiedListParagraphDefinitions(doc.body, editor); // Keep processing until all lists are flattened let foundList; @@ -49,6 +50,83 @@ export function flattenListsInHtml(html, editor, domDocument) { return doc.body.innerHTML; } +function createListIdAllocator(editor) { + const existingIds = new Set( + Object.keys(editor?.converter?.numbering?.definitions || {}) + .map((value) => Number(value)) + .filter(Number.isFinite), + ); + let nextId = Number(ListHelpers.getNewListId(editor)); + + return () => { + while (!Number.isFinite(nextId) || existingIds.has(nextId)) { + nextId = Number.isFinite(nextId) ? nextId + 1 : Number(ListHelpers.getNewListId(editor)); + } + const allocatedId = nextId; + existingIds.add(allocatedId); + nextId += 1; + return allocatedId; + }; +} + +function restoreCopiedListParagraphDefinitions(container, editor) { + if (!editor?.converter) return; + + const copiedParagraphs = Array.from(container.querySelectorAll('p[data-list-numbering-type], p[data-num-id]')); + if (copiedParagraphs.length === 0) return; + + const allocateListId = createListIdAllocator(editor); + const remappedNumIds = new Map(); + const generatedLevels = new Set(); + + copiedParagraphs.forEach((node) => { + const originalNumId = node.getAttribute('data-num-id') || '__copied-list__'; + let numId = remappedNumIds.get(originalNumId); + if (numId == null) { + numId = allocateListId(); + remappedNumIds.set(originalNumId, numId); + } + + const level = Number.parseInt(node.getAttribute('data-level') || '0', 10) || 0; + const fmt = node.getAttribute('data-num-fmt') || node.getAttribute('data-list-numbering-type') || 'decimal'; + const listType = fmt === 'bullet' ? 'bulletList' : 'orderedList'; + const listLevel = parseListLevelAttribute(node.getAttribute('data-list-level')); + const start = String(getStartValueForLevel(listLevel, level) || 1); + const lvlText = + node.getAttribute('data-lvl-text') || getDefaultLvlText(fmt, level, node.getAttribute('data-marker-type')); + const generationKey = `${numId}:${level}`; + + if (!generatedLevels.has(generationKey)) { + ListHelpers.generateNewListDefinition({ + numId, + listType, + level: String(level), + start, + text: lvlText, + fmt, + editor, + }); + if (Number(start) > 1) { + ListHelpers.setLvlOverride(editor, numId, level, { startOverride: Number(start) }); + } + generatedLevels.add(generationKey); + } + + node.setAttribute('data-num-id', String(numId)); + node.setAttribute('data-level', String(level)); + node.setAttribute('data-num-fmt', fmt); + node.setAttribute('data-lvl-text', lvlText); + }); +} + +function getDefaultLvlText(fmt, level, markerText) { + if (fmt === 'bullet') { + return markerText || '•'; + } + + return `%${level + 1}.`; +} + /** * Finds the first list that needs flattening: * 1. Lists without data-list-id (completely unprocessed) diff --git a/packages/super-editor/src/core/inputRules/html/html-helpers.test.js b/packages/super-editor/src/core/inputRules/html/html-helpers.test.js index 6f70e08f93..e256a17183 100644 --- a/packages/super-editor/src/core/inputRules/html/html-helpers.test.js +++ b/packages/super-editor/src/core/inputRules/html/html-helpers.test.js @@ -4,12 +4,14 @@ let listIdCounter = 0; const getNewListIdMock = vi.hoisted(() => vi.fn(() => ++listIdCounter)); const generateNewListDefinitionMock = vi.hoisted(() => vi.fn()); +const setLvlOverrideMock = vi.hoisted(() => vi.fn()); const getListDefinitionDetailsMock = vi.hoisted(() => vi.fn(() => ({ listNumberingType: 'decimal', lvlText: '%1.' }))); vi.mock('@helpers/list-numbering-helpers.js', () => ({ ListHelpers: { getNewListId: getNewListIdMock, generateNewListDefinition: generateNewListDefinitionMock, + setLvlOverride: setLvlOverrideMock, getListDefinitionDetails: getListDefinitionDetailsMock, }, })); @@ -21,9 +23,10 @@ describe('html list helpers', () => { beforeEach(() => { listIdCounter = 0; - getNewListIdMock.mockClear(); - generateNewListDefinitionMock.mockClear(); - getListDefinitionDetailsMock.mockClear(); + getNewListIdMock.mockReset().mockImplementation(() => ++listIdCounter); + generateNewListDefinitionMock.mockReset(); + setLvlOverrideMock.mockReset(); + getListDefinitionDetailsMock.mockReset().mockReturnValue({ listNumberingType: 'decimal', lvlText: '%1.' }); }); it('flattens multi-item lists so each list has a single item', () => { @@ -76,6 +79,60 @@ describe('html list helpers', () => { expect(parsed.querySelectorAll('p[data-num-id]').length).toBe(0); }); + it('rebuilds numbering definitions for copied list paragraphs before parsing', () => { + const flattenedHtml = ` +

Item 4

+

Item 5

+ `; + + const restored = flattenListsInHtml(flattenedHtml, editor); + const parsed = new DOMParser().parseFromString(`${restored}`, 'text/html'); + const paragraphs = Array.from(parsed.querySelectorAll('p[data-num-id]')); + + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0].getAttribute('data-num-id')).toBe('1'); + expect(paragraphs[1].getAttribute('data-num-id')).toBe('1'); + expect(paragraphs[0].getAttribute('data-num-fmt')).toBe('upperRoman'); + expect(paragraphs[0].getAttribute('data-lvl-text')).toBe('%1.'); + expect(generateNewListDefinitionMock).toHaveBeenCalledWith( + expect.objectContaining({ + numId: 1, + listType: 'orderedList', + level: '0', + start: '4', + text: '%1.', + fmt: 'upperRoman', + editor, + }), + ); + expect(setLvlOverrideMock).toHaveBeenCalledWith(editor, 1, 0, { startOverride: 4 }); + }); + + it('assigns distinct remapped ids to different copied lists even when the helper returns the same next id', () => { + getNewListIdMock.mockReturnValue(7); + + const flattenedHtml = ` +

One

+

Two

+ `; + + const restored = flattenListsInHtml(flattenedHtml, editor); + const parsed = new DOMParser().parseFromString(`${restored}`, 'text/html'); + const paragraphs = Array.from(parsed.querySelectorAll('p[data-num-id]')); + + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0].getAttribute('data-num-id')).toBe('7'); + expect(paragraphs[1].getAttribute('data-num-id')).toBe('8'); + expect(generateNewListDefinitionMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ numId: 7, fmt: 'decimal', listType: 'orderedList' }), + ); + expect(generateNewListDefinitionMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ numId: 8, fmt: 'bullet', listType: 'bulletList' }), + ); + }); + it('preserves start attribute for ordered lists', () => { const flattenedHtml = `

Item 3

diff --git a/packages/super-editor/src/core/renderers/ProseMirrorRenderer.ts b/packages/super-editor/src/core/renderers/ProseMirrorRenderer.ts index 586ba991af..383ea103fb 100644 --- a/packages/super-editor/src/core/renderers/ProseMirrorRenderer.ts +++ b/packages/super-editor/src/core/renderers/ProseMirrorRenderer.ts @@ -1,7 +1,18 @@ import { EditorView } from 'prosemirror-view'; import type { DirectEditorProps } from 'prosemirror-view'; import { DOMSerializer as PmDOMSerializer } from 'prosemirror-model'; +import type { Node as PmNode } from 'prosemirror-model'; +import { + annotateFragmentDomWithClipboardData, + mergeSerializedClipboardMetadataIntoDomContainer, +} from '../helpers/clipboardFragmentAnnotate.js'; import { transformListsInCopiedContent } from '@core/inputRules/html/transform-copied-lists.js'; +import { + bodySectPrShouldEmbed, + collectReferencedImageMediaForClipboard, + embedSliceInHtml, + SUPERDOC_MEDIA_MIME, +} from '../helpers/superdocClipboardSlice.js'; import { applyStyleIsolationClass } from '../../utils/styleIsolation.js'; import { canUseDOM } from '../../utils/canUseDOM.js'; import type { EditorRenderer, EditorRendererAttachParams } from './EditorRenderer.js'; @@ -37,6 +48,320 @@ const DEFAULT_LINE_HEIGHT = 1.2; */ type ListenerCleanup = () => void; +const RUNTIME_COPY_STRIP_SELECTOR = ['.list-marker', '.sd-editor-tab', '.ProseMirror-trailingBreak'].join(', '); +const PARAGRAPH_CONTENT_SELECTOR = 'span.sd-paragraph-content'; +const BLOCK_COPY_CONTEXT_SELECTOR = 'p, div, h1, h2, h3, h4, h5, h6, blockquote, table'; +const WORD_HTML_META = ''; + +const WORD_NUM_FMT_BY_LIST_FMT = new Map([ + ['decimal', 'decimal'], + ['lowerLetter', 'alpha-lower'], + ['upperLetter', 'alpha-upper'], + ['lowerRoman', 'roman-lower'], + ['upperRoman', 'roman-upper'], + ['bullet', 'bullet'], +]); + +function closestCopyBlock(node: Node | null, root: HTMLElement): HTMLElement | null { + let current: Node | null = node; + + while (current && current !== root) { + if (current.nodeType === Node.ELEMENT_NODE) { + const element = current as HTMLElement; + if (element.matches(BLOCK_COPY_CONTEXT_SELECTOR)) { + return element; + } + } + current = current.parentNode; + } + + return null; +} + +function fragmentHasBlockElements(fragment: DocumentFragment): boolean { + const container = document.createElement('div'); + container.appendChild(fragment.cloneNode(true)); + return Boolean(container.querySelector(BLOCK_COPY_CONTEXT_SELECTOR)); +} + +function getInlineTextStyle(element: HTMLElement): string { + const styles: string[] = []; + const computedStyle = globalThis.getComputedStyle?.(element); + + const color = + element.style.color || (computedStyle?.color && computedStyle.color !== 'rgb(0, 0, 0)' ? computedStyle.color : ''); + const fontFamily = element.style.fontFamily || ''; + const fontSize = + element.style.fontSize || + (computedStyle?.fontSize && computedStyle.fontSize !== '16px' ? computedStyle.fontSize : ''); + const textTransform = + element.style.textTransform || + (computedStyle?.textTransform && computedStyle.textTransform !== 'none' ? computedStyle.textTransform : ''); + const fontWeight = + element.style.fontWeight || + (computedStyle?.fontWeight && computedStyle.fontWeight !== '400' ? computedStyle.fontWeight : ''); + + if (color) styles.push(`color: ${color}`); + if (fontFamily) styles.push(`font-family: ${fontFamily}`); + if (fontSize) styles.push(`font-size: ${fontSize}`); + if (textTransform) styles.push(`text-transform: ${textTransform}`); + if (fontWeight) styles.push(`font-weight: ${fontWeight}`); + + return styles.join('; '); +} + +function wrapInlineOnlyRange(range: Range, view: EditorView): DocumentFragment { + const fragment = range.cloneContents(); + if (fragmentHasBlockElements(fragment)) { + return fragment; + } + + const startBlock = closestCopyBlock(range.startContainer, view.dom); + const endBlock = closestCopyBlock(range.endContainer, view.dom); + if (!startBlock || startBlock !== endBlock) { + return fragment; + } + + const wrapper = startBlock.cloneNode(false) as HTMLElement; + const inheritedTextStyle = getInlineTextStyle(startBlock); + + if (inheritedTextStyle) { + const span = document.createElement('span'); + span.setAttribute('style', inheritedTextStyle); + span.appendChild(fragment); + wrapper.appendChild(span); + } else { + wrapper.appendChild(fragment); + } + + const contextualFragment = document.createDocumentFragment(); + contextualFragment.appendChild(wrapper); + return contextualFragment; +} + +/** + * Paragraphs use a NodeView whose DOM is `

` wrapping `.sd-paragraph-content`. + * `posAtDOM(p, 0)` usually lands inside content, so `doc.nodeAt(pos)` is an inline node — not the block. + * Walk resolved parents to find the paragraph for copy-time data-* annotations. + */ +function getParagraphNodeFromBlockDom(view: EditorView, blockDom: HTMLElement): PmNode | null { + let pos: number; + try { + pos = view.posAtDOM(blockDom, 0); + } catch { + return null; + } + const { doc } = view.state; + if (pos < 0 || pos > doc.content.size) { + return null; + } + const $pos = doc.resolve(pos); + for (let d = $pos.depth; d > 0; d -= 1) { + const n = $pos.node(d); + if (n.type.name === 'paragraph') { + return n; + } + } + return null; +} + +function normalizeCopiedListMetadata(container: HTMLElement): void { + container.querySelectorAll('p[data-num-id], p[data-list-numbering-type]').forEach((node) => { + const numberingType = node.getAttribute('data-list-numbering-type'); + const markerText = node.getAttribute('data-marker-type'); + + if (numberingType && !node.hasAttribute('data-num-fmt')) { + node.setAttribute('data-num-fmt', numberingType); + } + + if (markerText && !node.hasAttribute('data-lvl-text')) { + node.setAttribute('data-lvl-text', markerText); + } + }); +} + +function annotateCopiedSectionMetadata(container: HTMLElement, view: EditorView): void { + const copiedBlocks = Array.from(container.querySelectorAll('[data-sd-block-id]')); + if (copiedBlocks.length === 0) { + return; + } + + copiedBlocks.forEach((node) => { + const blockId = node.getAttribute('data-sd-block-id'); + if (!blockId) return; + + const sourceNode = view.dom.querySelector( + `[data-sd-block-id="${globalThis.CSS?.escape?.(blockId) ?? blockId}"]`, + ); + if (!sourceNode) return; + + const pmNode = getParagraphNodeFromBlockDom(view, sourceNode); + const paragraphProperties = pmNode?.attrs?.paragraphProperties; + const sectPr = paragraphProperties?.sectPr; + if (!sectPr || typeof sectPr !== 'object') { + return; + } + + node.setAttribute('data-sd-sect-pr', JSON.stringify(sectPr)); + + const pageBreakSource = pmNode?.attrs?.pageBreakSource; + if (typeof pageBreakSource === 'string' && pageBreakSource.length > 0) { + node.setAttribute('data-sd-page-break-source', pageBreakSource); + } + }); +} + +function parseCopiedListPath(pathAttr: string | null): number[] { + if (!pathAttr) return []; + + try { + const parsed = JSON.parse(pathAttr); + return Array.isArray(parsed) ? parsed.map((item) => Number(item)).filter((item) => Number.isFinite(item)) : []; + } catch { + return []; + } +} + +function getWordLevelText(level: number, fmt: string, markerText: string, storedLevelText: string | null): string { + if (fmt === 'bullet') { + return markerText || storedLevelText || '•'; + } + + if (storedLevelText?.includes('%')) { + return storedLevelText; + } + + const punctuation = markerText.match(/[.)]$/)?.[0] || '.'; + return `%${level + 1}${punctuation}`; +} + +function buildWordListPrefix(markerText: string): DocumentFragment { + const fragment = document.createDocumentFragment(); + fragment.appendChild(document.createComment('[if !supportLists]')); + + const span = document.createElement('span'); + span.textContent = markerText; + fragment.appendChild(span); + + fragment.appendChild(document.createComment('[endif]')); + return fragment; +} + +function serializeSelectionAsWordHtml(container: HTMLElement): string | null { + const copiedListParagraphs = Array.from( + container.querySelectorAll('p[data-num-id], p[data-list-numbering-type]'), + ); + if (copiedListParagraphs.length === 0) { + return null; + } + + const wordContainer = container.cloneNode(true) as HTMLElement; + const cssRules = ['.MsoNormal {}', '.MsoListParagraph {}']; + const seenLevels = new Set(); + + wordContainer.querySelectorAll('p[data-num-id], p[data-list-numbering-type]').forEach((node) => { + const level = Number.parseInt(node.getAttribute('data-level') || '0', 10) || 0; + const wordLevel = level + 1; + const importedNumId = node.getAttribute('data-num-id') || '1'; + const abstractId = importedNumId; + const fmt = node.getAttribute('data-num-fmt') || node.getAttribute('data-list-numbering-type') || 'decimal'; + const wordNumFmt = WORD_NUM_FMT_BY_LIST_FMT.get(fmt) || 'decimal'; + const markerText = node.getAttribute('data-marker-type') || node.getAttribute('data-lvl-text') || '1.'; + const levelText = getWordLevelText(level, fmt, markerText, node.getAttribute('data-lvl-text')); + const levelKey = `${abstractId}:${wordLevel}:${importedNumId}`; + const styleAttr = node.getAttribute('style'); + const msoListStyle = `mso-list:l${abstractId} level${wordLevel} lfo${importedNumId}`; + + node.setAttribute('class', 'MsoListParagraph'); + node.setAttribute('style', styleAttr ? `${msoListStyle};${styleAttr}` : msoListStyle); + + if (!seenLevels.has(levelKey)) { + cssRules.push( + `@list l${abstractId}:level${wordLevel} lfo${importedNumId} { mso-level-number-format: ${wordNumFmt}; mso-level-text: "${levelText}"; }`, + ); + seenLevels.add(levelKey); + } + + const path = parseCopiedListPath(node.getAttribute('data-list-level')); + const startValue = path[level] ?? path[path.length - 1] ?? 1; + const markerPrefix = fmt === 'bullet' ? markerText : `${markerText}`.trim() || `${startValue}.`; + node.prepend(buildWordListPrefix(markerPrefix)); + }); + + return `${WORD_HTML_META}${wordContainer.innerHTML}`; +} + +export function buildSelectionClipboardHtml(view: EditorView, editor?: Editor): string | null { + const rootSelection = + view.root instanceof Document || view.root instanceof ShadowRoot ? view.root.getSelection() : null; + if (!rootSelection || rootSelection.isCollapsed || rootSelection.rangeCount === 0) { + return null; + } + + const container = document.createElement('div'); + let appendedContent = false; + + for (let index = 0; index < rootSelection.rangeCount; index += 1) { + const range = rootSelection.getRangeAt(index); + const commonAncestor = + range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE + ? (range.commonAncestorContainer as Element) + : range.commonAncestorContainer.parentElement; + + if (commonAncestor && !view.dom.contains(commonAncestor)) { + continue; + } + + container.appendChild(wrapInlineOnlyRange(range, view)); + appendedContent = true; + } + + if (!appendedContent) { + return null; + } + + container.querySelectorAll(RUNTIME_COPY_STRIP_SELECTOR).forEach((node) => { + node.parentNode?.removeChild(node); + }); + + container.querySelectorAll(PARAGRAPH_CONTENT_SELECTOR).forEach((node) => { + const parent = node.parentNode; + if (!parent) return; + while (node.firstChild) { + parent.insertBefore(node.firstChild, node); + } + parent.removeChild(node); + }); + + container + .querySelectorAll('[contenteditable], [draggable], [spellcheck], [data-pm-slice]') + .forEach((node) => { + node.removeAttribute('contenteditable'); + node.removeAttribute('draggable'); + node.removeAttribute('spellcheck'); + node.removeAttribute('data-pm-slice'); + }); + + container.querySelectorAll('[class]').forEach((node) => { + const retainedClasses = (node.getAttribute('class') || '') + .split(/\s+/) + .filter(Boolean) + .filter((className) => !className.startsWith('ProseMirror')); + + if (retainedClasses.length > 0) { + node.setAttribute('class', retainedClasses.join(' ')); + } else { + node.removeAttribute('class'); + } + }); + + mergeSerializedClipboardMetadataIntoDomContainer(container, view, editor); + normalizeCopiedListMetadata(container); + annotateCopiedSectionMetadata(container, view); + + return serializeSelectionAsWordHtml(container) || container.innerHTML || null; +} + /** * Standard DOM-based renderer for the SuperDoc editor. * @@ -478,9 +803,9 @@ export class ProseMirrorRenderer implements EditorRenderer { * Wraps clipboard operations in try-catch to handle permission errors or API failures. * The listener is tracked for cleanup in destroy(). * - * @param _editor - The editor instance (unused, kept for interface compatibility) + * @param editor - SuperEditor instance (numbering defs + body section metadata on copy) */ - registerCopyHandler(_editor: Editor): void { + registerCopyHandler(editor: Editor): void { const dom = this.view?.dom; if (!dom || !canUseDOM()) { return; @@ -496,6 +821,27 @@ export class ProseMirrorRenderer implements EditorRenderer { if (!this.view) return; const { from, to } = this.view.state.selection; + let sliceJson = ''; + if (from !== to) { + const slice = this.view.state.doc.slice(from, to); + sliceJson = JSON.stringify(slice.toJSON()); + clipboardData.setData('application/x-superdoc-slice', sliceJson); + const mediaJson = collectReferencedImageMediaForClipboard(sliceJson, editor); + if (mediaJson) { + clipboardData.setData(SUPERDOC_MEDIA_MIME, mediaJson); + } + } + + const richHtml = buildSelectionClipboardHtml(this.view, editor); + const bodySectPr = this.view.state.doc.attrs?.bodySectPr; + const bodySectPrJson = bodySectPr && bodySectPrShouldEmbed(bodySectPr) ? JSON.stringify(bodySectPr) : ''; + + if (richHtml) { + clipboardData.setData('text/html', embedSliceInHtml(richHtml, sliceJson, bodySectPrJson)); + clipboardData.setData('text/plain', this.view.root.getSelection?.()?.toString() ?? ''); + return; + } + const slice = this.view.state.doc.slice(from, to); const fragment = slice.content; @@ -503,11 +849,13 @@ export class ProseMirrorRenderer implements EditorRenderer { const serializer = PmDOMSerializer.fromSchema(this.view.state.schema); div.appendChild(serializer.serializeFragment(fragment)); + annotateFragmentDomWithClipboardData(div, fragment, editor); + const html = transformListsInCopiedContent(div.innerHTML); - clipboardData.setData('text/html', html); + clipboardData.setData('text/html', embedSliceInHtml(html, sliceJson, bodySectPrJson)); + clipboardData.setData('text/plain', this.view.state.doc.textBetween(from, to, '\n')); } catch (error) { - // Log but don't crash - fallback to native copy behavior console.warn('Failed to transform copied content:', error); } }; diff --git a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js index d09444685b..e0edaaae10 100644 --- a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js +++ b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.js @@ -21,7 +21,9 @@ function parseCssLength(value) { export function parseAttrs(node) { const numberingProperties = {}; - let indent, spacing; + let indent, spacing, justification; + let sectionProperties = null; + let pageBreakSource = null; const { styleid: styleId, ...extraAttrs } = Array.from(node.attributes).reduce((acc, attr) => { if (attr.name === 'data-num-id') { numberingProperties.numId = parseInt(attr.value); @@ -47,6 +49,19 @@ export function parseAttrs(node) { } catch { // ignore invalid spacing value } + } else if (attr.name === 'data-justification') { + justification = attr.value; + } else if (attr.name === 'data-sd-sect-pr') { + try { + const parsedSectionProperties = JSON.parse(attr.value); + if (parsedSectionProperties && typeof parsedSectionProperties === 'object') { + sectionProperties = parsedSectionProperties; + } + } catch { + // ignore invalid section payload + } + } else if (attr.name === 'data-sd-page-break-source') { + pageBreakSource = attr.value || null; } else { acc[attr.name] = attr.value; } @@ -108,7 +123,6 @@ export function parseAttrs(node) { // CSS inline style fallback for text-align (e.g. Google Docs paste) // Skip 'left' — Google Docs sets text-align: left on every paragraph, // and storing it would bake in unnecessary direct formatting on export. - let justification; if (!justification && node.style) { const textAlign = node.style.textAlign; if (textAlign && CSS_ALIGN_TO_OOXML[textAlign]) { @@ -139,5 +153,13 @@ export function parseAttrs(node) { attrs.paragraphProperties.numberingProperties = numberingProperties; } + if (sectionProperties) { + attrs.paragraphProperties.sectPr = sectionProperties; + } + + if (pageBreakSource) { + attrs.pageBreakSource = pageBreakSource; + } + return attrs; } diff --git a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.test.js b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.test.js index 8bb7b3eab1..4b7d3e874b 100644 --- a/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.test.js +++ b/packages/super-editor/src/extensions/paragraph/helpers/parseAttrs.test.js @@ -51,6 +51,26 @@ describe('parseAttrs', () => { const result = parseAttrs(node); expect(result.paragraphProperties.indent.left).toBe(720); }); + + it('parses copied section metadata and restores sectPr/pageBreakSource', () => { + const node = createMockNode({ + 'data-sd-sect-pr': JSON.stringify({ + type: 'element', + name: 'w:sectPr', + elements: [{ type: 'element', name: 'w:cols', attributes: { 'w:num': '2', 'w:space': '720' } }], + }), + 'data-sd-page-break-source': 'sectPr', + }); + + const result = parseAttrs(node); + + expect(result.paragraphProperties.sectPr).toEqual({ + type: 'element', + name: 'w:sectPr', + elements: [{ type: 'element', name: 'w:cols', attributes: { 'w:num': '2', 'w:space': '720' } }], + }); + expect(result.pageBreakSource).toBe('sectPr'); + }); }); describe('CSS inline style fallback (Google Docs paste)', () => { diff --git a/packages/super-editor/src/extensions/paragraph/numberingPlugin.js b/packages/super-editor/src/extensions/paragraph/numberingPlugin.js index d588feacd2..8c27363ed2 100644 --- a/packages/super-editor/src/extensions/paragraph/numberingPlugin.js +++ b/packages/super-editor/src/extensions/paragraph/numberingPlugin.js @@ -6,6 +6,17 @@ import { generateOrderedListIndex } from '@helpers/orderedListUtils.js'; import { docxNumberingHelpers } from '@core/super-converter/v2/importer/listImporter.js'; import { calculateResolvedParagraphProperties } from './resolvedPropertiesCache.js'; +function blockRevIsFreshForSlicePaste(rev) { + return rev === 0 || rev === '0'; +} + +function shouldPreserveSlicePastedListRendering(node, transactions) { + if (node.type.name !== 'paragraph' || node.attrs.listRendering == null) return false; + if (node.attrs.sdBlockId != null) return false; + if (!blockRevIsFreshForSlicePaste(node.attrs.sdBlockRev)) return false; + return transactions.some((tr) => tr.getMeta('superdocSlicePaste')); +} + /** * Create a ProseMirror plugin that keeps `listRendering` data in sync with the * underlying Word numbering definitions. @@ -215,6 +226,13 @@ export function createNumberingPlugin(editor) { return; } + // Lossless SuperDoc slice paste: keep markers/list type from the slice. Running + // definition lookup first would clear listRendering when numIds are absent in + // the target doc, or overwrite markers when the same doc continues counters. + if (shouldPreserveSlicePastedListRendering(node, transactions)) { + return false; + } + // Retrieving numbering definition from docx const { numId, ilvl: level = 0 } = resolvedProps.numberingProperties; const definitionDetails = ListHelpers.getListDefinitionDetails({ numId, level, editor }); From 7076aab623a5788493a274c705a85ffce9c11649 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 23 Mar 2026 17:34:40 +0200 Subject: [PATCH 2/4] fix: build error --- .../src/core/renderers/ProseMirrorRenderer.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/core/renderers/ProseMirrorRenderer.ts b/packages/super-editor/src/core/renderers/ProseMirrorRenderer.ts index 383ea103fb..fb20253bcd 100644 --- a/packages/super-editor/src/core/renderers/ProseMirrorRenderer.ts +++ b/packages/super-editor/src/core/renderers/ProseMirrorRenderer.ts @@ -291,9 +291,26 @@ function serializeSelectionAsWordHtml(container: HTMLElement): string | null { return `${WORD_HTML_META}${wordContainer.innerHTML}`; } +/** + * `Selection` for ProseMirror `view.root`. `Document` has `getSelection()` in typings; `ShadowRoot` + * has it in Chromium but often not in `lib.dom`, so we call it via a narrow cast with fallback. + */ +function getSelectionFromViewRoot(root: Document | ShadowRoot | Element): Selection | null { + if (root instanceof Document) { + return root.getSelection(); + } + if (typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot) { + const extended = root as ShadowRoot & { getSelection?: () => Selection | null }; + if (typeof extended.getSelection === 'function') { + return extended.getSelection() ?? null; + } + return extended.ownerDocument.getSelection(); + } + return typeof document !== 'undefined' ? document.getSelection() : null; +} + export function buildSelectionClipboardHtml(view: EditorView, editor?: Editor): string | null { - const rootSelection = - view.root instanceof Document || view.root instanceof ShadowRoot ? view.root.getSelection() : null; + const rootSelection = getSelectionFromViewRoot(view.root); if (!rootSelection || rootSelection.isCollapsed || rootSelection.rangeCount === 0) { return null; } @@ -838,7 +855,7 @@ export class ProseMirrorRenderer implements EditorRenderer { if (richHtml) { clipboardData.setData('text/html', embedSliceInHtml(richHtml, sliceJson, bodySectPrJson)); - clipboardData.setData('text/plain', this.view.root.getSelection?.()?.toString() ?? ''); + clipboardData.setData('text/plain', getSelectionFromViewRoot(this.view.root)?.toString() ?? ''); return; } From 13190656fab47d4afe02dbef970de7c60249701a Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Wed, 25 Mar 2026 12:26:22 +0200 Subject: [PATCH 3/4] fix: address codex review comments --- packages/super-editor/src/core/InputRule.js | 59 ++++++--- .../src/core/commands/toggleList.js | 35 ++++- .../src/core/commands/toggleList.test.js | 17 ++- .../core/helpers/superdocClipboardSlice.js | 125 ++++++++++++++++-- .../helpers/superdocClipboardSlice.test.js | 106 ++++++++++++++- 5 files changed, 297 insertions(+), 45 deletions(-) diff --git a/packages/super-editor/src/core/InputRule.js b/packages/super-editor/src/core/InputRule.js index 660e79276d..f57770fe94 100644 --- a/packages/super-editor/src/core/InputRule.js +++ b/packages/super-editor/src/core/InputRule.js @@ -28,7 +28,7 @@ import { extractBodySectPrFromHtml, bodySectPrShouldEmbed, collectReferencedImageMediaForClipboard, - mergeSuperdocClipboardMediaIntoEditor, + applySuperdocClipboardMedia, } from './helpers/superdocClipboardSlice.js'; import { annotateFragmentDomWithClipboardData } from './helpers/clipboardFragmentAnnotate.js'; @@ -317,9 +317,9 @@ export const inputRulesPlugin = ({ editor, rules }) => { const isSuperdocHtml = isSuperdocOriginClipboardHtml(rawHtml); const embeddedBodySectPr = isSuperdocHtml ? extractBodySectPrFromHtml(rawHtml) : null; - const superdocSliceData = clipboard.getData(SUPERDOC_SLICE_MIME) || extractSliceFromHtml(rawHtml); + let superdocSliceData = clipboard.getData(SUPERDOC_SLICE_MIME) || extractSliceFromHtml(rawHtml); if (isSuperdocHtml || superdocSliceData) { - mergeSuperdocClipboardMediaIntoEditor(editor, clipboard); + superdocSliceData = applySuperdocClipboardMedia(editor, clipboard, superdocSliceData || null); } if (superdocSliceData) { try { @@ -881,7 +881,7 @@ function handleSuperdocSlicePaste(sliceData, editor, view, embeddedBodySectPr = if (!slice.content.size) return false; - const stripped = stripBlockIds(slice.content); + const stripped = stripSuperdocSliceBlockIdentities(slice.content); const cleanContent = remapPastedListNumberingInFragment(stripped, editor); const cleanSlice = new Slice(cleanContent, slice.openStart, slice.openEnd); @@ -899,23 +899,50 @@ function handleSuperdocSlicePaste(sliceData, editor, view, embeddedBodySectPr = return true; } -function stripBlockIds(fragment) { +/** + * Attrs cleared per node type when pasting a SuperDoc slice. Import uses + * {@link ./super-converter/v2/importer/normalizeDuplicateBlockIdentitiesInContent.js} + * so in-doc IDs are unique; slice paste must not keep `paraId` / legacy table ids / + * structured `id` from the copy source or `resolveBlockNodeId` will expose duplicate + * public block IDs (paragraphs prefer `paraId` over `sdBlockId`). + * + * @type {Record>} + */ +const SUPERDOC_SLICE_PASTE_IDENTITY_RESETS = { + paragraph: { paraId: null, sdBlockId: null, sdBlockRev: 0 }, + table: { paraId: null, sdBlockId: null }, + tableRow: { paraId: null, sdBlockId: null }, + tableCell: { paraId: null, sdBlockId: null }, + tableHeader: { sdBlockId: null }, + structuredContentBlock: { id: null }, + documentSection: { id: null, sdBlockId: null }, + documentPartObject: { id: null, sdBlockId: null }, + tableOfContents: { sdBlockId: null }, +}; + +/** + * @param {import('prosemirror-model').Fragment} fragment + */ +function stripSuperdocSliceBlockIdentities(fragment) { const children = []; fragment.forEach((node) => { - let newNode = node; - - const needsClean = node.type.name === 'paragraph' || node.attrs.sdBlockId != null; - - if (needsClean) { - const cleanAttrs = { ...node.attrs, sdBlockId: null, sdBlockRev: 0 }; - newNode = node.type.create(cleanAttrs, node.childCount ? stripBlockIds(node.content) : node.content, node.marks); - } else if (node.childCount) { - const newContent = stripBlockIds(node.content); - if (newContent !== node.content) { - newNode = node.copy(newContent); + const resets = SUPERDOC_SLICE_PASTE_IDENTITY_RESETS[node.type.name]; + let newContent = node.content; + if (node.childCount) { + const strippedChildren = stripSuperdocSliceBlockIdentities(node.content); + if (strippedChildren !== node.content) { + newContent = strippedChildren; } } + let newNode = node; + if (resets) { + const cleanAttrs = { ...node.attrs, ...resets }; + newNode = node.type.create(cleanAttrs, newContent, node.marks); + } else if (newContent !== node.content) { + newNode = node.copy(newContent); + } + children.push(newNode); }); diff --git a/packages/super-editor/src/core/commands/toggleList.js b/packages/super-editor/src/core/commands/toggleList.js index 9d2218fad1..30b002f7f5 100644 --- a/packages/super-editor/src/core/commands/toggleList.js +++ b/packages/super-editor/src/core/commands/toggleList.js @@ -33,6 +33,31 @@ function paragraphMatchesToggleListType(node, editor, listType) { return false; } +/** + * Previous paragraph sibling of the anchor block: `doc.resolve(pos).nodeBefore` where `pos` + * is the gap before the first selected paragraph (or before the paragraph containing `from`). + * + * @param {import('prosemirror-model').Node} doc + * @param {number} from + * @param {Array<{ node: import('prosemirror-model').Node, pos: number }>} paragraphsInSelection + * @returns {import('prosemirror-model').Node | null} + */ +function getPrecedingParagraphForListReuse(doc, from, paragraphsInSelection) { + let pos = paragraphsInSelection.length > 0 ? paragraphsInSelection[0].pos : null; + if (pos == null && from > 0) { + const $from = doc.resolve(from); + for (let d = $from.depth; d > 0; d -= 1) { + if ($from.node(d).type.name === 'paragraph') { + pos = $from.before(d); + break; + } + } + } + if (pos == null) return null; + const nb = doc.resolve(pos).nodeBefore; + return nb?.type?.name === 'paragraph' ? nb : null; +} + export const toggleList = (listType) => ({ editor, state, tr, dispatch }) => { @@ -70,13 +95,9 @@ export const toggleList = } } if (!firstListNode && from > 0) { - const $from = state.doc.resolve(from); - const blockIndex = $from.index(0); - if (blockIndex > 0) { - const beforeNode = state.doc.child(blockIndex - 1); - if (beforeNode && beforeNode.type.name === 'paragraph' && predicate(beforeNode)) { - firstListNode = beforeNode; - } + const beforeNode = getPrecedingParagraphForListReuse(state.doc, from, paragraphsInSelection); + if (beforeNode && predicate(beforeNode)) { + firstListNode = beforeNode; } } // 3. Resolve numbering properties diff --git a/packages/super-editor/src/core/commands/toggleList.test.js b/packages/super-editor/src/core/commands/toggleList.test.js index 767b325e9b..07abd3aa29 100644 --- a/packages/super-editor/src/core/commands/toggleList.test.js +++ b/packages/super-editor/src/core/commands/toggleList.test.js @@ -33,19 +33,18 @@ const createParagraph = (attrs, pos) => ({ pos, }); -const createState = (paragraphs, { from = 1, to = 10, beforeNode = null, blockIndex = 0 } = {}) => ({ +const createState = (paragraphs, { from = 1, to = 10, beforeNode = null } = {}) => ({ doc: { nodesBetween: vi.fn((_from, _to, callback) => { for (const { node, pos } of paragraphs) { callback(node, pos); } }), - resolve: vi.fn(() => ({ - index: (depth) => (depth === 0 ? blockIndex : 0), - })), - child: vi.fn((i) => { - if (beforeNode != null && i === blockIndex - 1) return beforeNode; - return undefined; + resolve: vi.fn((pos) => { + if (paragraphs.length > 0 && pos === paragraphs[0].pos) { + return { nodeBefore: beforeNode }; + } + return { nodeBefore: null }; }), }, selection: { from, to }, @@ -193,7 +192,7 @@ describe('toggleList', () => { }; ListHelpers.getListDefinitionDetails.mockReturnValue({ listNumberingType: 'bullet' }); const paragraphs = [createParagraph({ paragraphProperties: {} }, 4)]; - const state = createState(paragraphs, { beforeNode, blockIndex: 1, from: 4, to: 8 }); + const state = createState(paragraphs, { beforeNode, from: 4, to: 8 }); const handler = toggleList('orderedList'); const result = handler({ editor, state, tr, dispatch }); @@ -220,7 +219,7 @@ describe('toggleList', () => { createParagraph({ paragraphProperties: {} }, 4), createParagraph({ paragraphProperties: {} }, 8), ]; - const state = createState(paragraphs, { beforeNode, blockIndex: 1 }); + const state = createState(paragraphs, { beforeNode }); const handler = toggleList('orderedList'); const result = handler({ editor, state, tr, dispatch }); diff --git a/packages/super-editor/src/core/helpers/superdocClipboardSlice.js b/packages/super-editor/src/core/helpers/superdocClipboardSlice.js index c8d46da71a..3fef2efd65 100644 --- a/packages/super-editor/src/core/helpers/superdocClipboardSlice.js +++ b/packages/super-editor/src/core/helpers/superdocClipboardSlice.js @@ -43,6 +43,17 @@ export function collectReferencedImageMediaForClipboard(sliceJsonString, editor) } } } + if (node.type === 'shapeGroup' && Array.isArray(node.attrs?.shapes)) { + for (const shape of node.attrs.shapes) { + const src = shape?.attrs?.src; + if (typeof src === 'string' && src.length > 0) { + const val = source[src]; + if (typeof val === 'string' && val.length > 0) { + out[src] = val; + } + } + } + } const { content } = node; if (Array.isArray(content)) { for (const child of content) visit(child); @@ -57,33 +68,127 @@ export function collectReferencedImageMediaForClipboard(sliceJsonString, editor) } /** + * @param {string} originalPath + * @param {Record} store + * @param {Set} reserved keys allocated in this paste batch + */ +function allocateUniqueMediaPath(originalPath, store, reserved) { + const extMatch = originalPath.match(/(\.[^./]+)$/); + const ext = extMatch ? extMatch[1] : ''; + const dirMatch = originalPath.match(/^(.*\/)/); + const dir = dirMatch ? dirMatch[1] : 'word/media/'; + const id = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; + let candidate = `${dir}sd-paste-${id}${ext}`; + let n = 0; + while ((store[candidate] != null && store[candidate] !== '') || reserved.has(candidate)) { + candidate = `${dir}sd-paste-${id}-${n}${ext}`; + n += 1; + } + reserved.add(candidate); + return candidate; +} + +/** + * @param {unknown} node slice JSON node + * @param {Map} pathRemap old path → new path + */ +function rewriteImageSrcsInSliceJsonTree(node, pathRemap) { + if (!node || typeof node !== 'object') return; + if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') { + const next = pathRemap.get(node.attrs.src); + if (next) { + node.attrs = { ...node.attrs, src: next }; + } + } + if (node.type === 'shapeGroup' && Array.isArray(node.attrs?.shapes)) { + node.attrs = { + ...node.attrs, + shapes: node.attrs.shapes.map((shape) => { + if (!shape || typeof shape !== 'object' || !shape.attrs || typeof shape.attrs.src !== 'string') { + return shape; + } + const next = pathRemap.get(shape.attrs.src); + if (!next) return shape; + return { ...shape, attrs: { ...shape.attrs, src: next } }; + }), + }; + } + const { content } = node; + if (Array.isArray(content)) { + for (const child of content) rewriteImageSrcsInSliceJsonTree(child, pathRemap); + } +} + +/** + * Read `SUPERDOC_MEDIA_MIME` from the clipboard, merge into `editor.storage.image.media` (and Yjs), + * and optionally rewrite `sliceJson` so `image` / `shapeGroup` src keys stay in sync. + * + * DOCX-style paths (`word/media/image1.png`) collide across documents; if the target already has + * different data at a path, pasted bytes go under a new `word/media/sd-paste-…` key instead. + * * @param {object} editor * @param {DataTransfer | null | undefined} clipboardData + * @param {string | null} [sliceJson] SuperDoc slice JSON string, if any + * @returns {string | null} slice JSON to paste (updated when paths were remapped), or `sliceJson` unchanged */ -export function mergeSuperdocClipboardMediaIntoEditor(editor, clipboardData) { - if (!editor?.storage?.image) return; +export function applySuperdocClipboardMedia(editor, clipboardData, sliceJson = null) { const raw = clipboardData?.getData?.(SUPERDOC_MEDIA_MIME); - if (!raw || typeof raw !== 'string') return; + if (!editor?.storage?.image || !raw || typeof raw !== 'string') { + return sliceJson; + } let map; try { map = JSON.parse(raw); } catch { - return; + return sliceJson; + } + if (!map || typeof map !== 'object') { + return sliceJson; + } + + const entries = Object.entries(map).filter(([p, d]) => typeof p === 'string' && p && typeof d === 'string' && d); + if (entries.length === 0) { + return sliceJson; } - if (!map || typeof map !== 'object') return; if (!editor.storage.image.media) { editor.storage.image.media = {}; } - + const store = editor.storage.image.media; const yMedia = editor.options?.ydoc?.getMap?.('media'); - for (const [path, data] of Object.entries(map)) { - if (typeof path !== 'string' || !path || typeof data !== 'string' || !data) continue; - editor.storage.image.media[path] = data; - yMedia?.set?.(path, data); + /** @type {Map} */ + const renames = new Map(); + const reserved = new Set(); + + for (const [path, data] of entries) { + const existing = store[path]; + if (existing != null && existing !== '' && existing !== data) { + renames.set(path, allocateUniqueMediaPath(path, store, reserved)); + } + } + + let outSlice = sliceJson; + if (renames.size > 0 && sliceJson) { + try { + const slice = JSON.parse(sliceJson); + if (Array.isArray(slice.content)) { + for (const node of slice.content) rewriteImageSrcsInSliceJsonTree(node, renames); + } + outSlice = JSON.stringify(slice); + } catch { + outSlice = sliceJson; + } + } + + for (const [path, data] of entries) { + const key = renames.get(path) ?? path; + store[key] = data; + yMedia?.set?.(key, data); } + + return outSlice; } /** Latin-1 / “binary” string → base64 (browser `btoa`, else Node `Buffer`). */ diff --git a/packages/super-editor/src/core/helpers/superdocClipboardSlice.test.js b/packages/super-editor/src/core/helpers/superdocClipboardSlice.test.js index 3e297f6c5c..cbc1e8cdf7 100644 --- a/packages/super-editor/src/core/helpers/superdocClipboardSlice.test.js +++ b/packages/super-editor/src/core/helpers/superdocClipboardSlice.test.js @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { collectReferencedImageMediaForClipboard, - mergeSuperdocClipboardMediaIntoEditor, + applySuperdocClipboardMedia, SUPERDOC_MEDIA_MIME, } from './superdocClipboardSlice.js'; @@ -39,7 +39,7 @@ describe('superdocClipboardSlice image media', () => { expect(JSON.parse(out)).toEqual({ 'word/media/a.png': 'data:image/png;base64,AAA' }); }); - it('mergeSuperdocClipboardMediaIntoEditor merges into storage and ydoc media map', () => { + it('applySuperdocClipboardMedia merges into storage and ydoc media map', () => { const ySet = vi.fn(); const editor = { storage: { @@ -59,10 +59,110 @@ describe('superdocClipboardSlice image media', () => { mime === SUPERDOC_MEDIA_MIME ? JSON.stringify({ 'word/media/new.png': 'data:image/png;base64,XX' }) : '', }; - mergeSuperdocClipboardMediaIntoEditor(editor, clipboardData); + applySuperdocClipboardMedia(editor, clipboardData, null); expect(editor.storage.image.media['word/media/new.png']).toBe('data:image/png;base64,XX'); expect(editor.storage.image.media['word/media/existing.png']).toBe('data:old'); expect(ySet).toHaveBeenCalledWith('word/media/new.png', 'data:image/png;base64,XX'); }); + + it('applySuperdocClipboardMedia avoids overwriting a different image at the same path', () => { + const editor = { + storage: { + image: { + media: { + 'word/media/image1.png': 'data:image/png;base64,OLD', + }, + }, + }, + }; + + const clipboardData = { + getData: () => JSON.stringify({ 'word/media/image1.png': 'data:image/png;base64,NEW' }), + }; + + const sliceJson = JSON.stringify({ + content: [ + { + type: 'paragraph', + content: [{ type: 'image', attrs: { src: 'word/media/image1.png' } }], + }, + ], + openStart: 0, + openEnd: 0, + }); + + const outSlice = applySuperdocClipboardMedia(editor, clipboardData, sliceJson); + + const slice = JSON.parse(outSlice); + const img = slice.content[0].content[0]; + expect(img.attrs.src).not.toBe('word/media/image1.png'); + expect(img.attrs.src).toMatch(/^word\/media\/sd-paste-.*\.png$/); + + expect(editor.storage.image.media['word/media/image1.png']).toBe('data:image/png;base64,OLD'); + expect(editor.storage.image.media[img.attrs.src]).toBe('data:image/png;base64,NEW'); + }); + + it('applySuperdocClipboardMedia keeps the path when clipboard bytes match storage', () => { + const same = 'data:image/png;base64,SAME'; + const editor = { + storage: { + image: { + media: { 'word/media/image1.png': same }, + }, + }, + }; + const sliceJson = JSON.stringify({ + content: [ + { + type: 'paragraph', + content: [{ type: 'image', attrs: { src: 'word/media/image1.png' } }], + }, + ], + openStart: 0, + openEnd: 0, + }); + + const outSlice = applySuperdocClipboardMedia( + editor, + { getData: () => JSON.stringify({ 'word/media/image1.png': same }) }, + sliceJson, + ); + + expect(JSON.parse(outSlice).content[0].content[0].attrs.src).toBe('word/media/image1.png'); + }); + + it('applySuperdocClipboardMedia rewrites shapeGroup nested image src on collision', () => { + const editor = { + storage: { + image: { + media: { 'word/media/pic.png': 'data:image/png;base64,OLD' }, + }, + }, + }; + const sliceJson = JSON.stringify({ + content: [ + { + type: 'shapeGroup', + attrs: { + shapes: [{ attrs: { src: 'word/media/pic.png', kind: 'image', x: 0, y: 0, width: 10, height: 10 } }], + }, + }, + ], + openStart: 0, + openEnd: 0, + }); + + const outSlice = applySuperdocClipboardMedia( + editor, + { getData: () => JSON.stringify({ 'word/media/pic.png': 'data:image/png;base64,NEW' }) }, + sliceJson, + ); + + const shape = JSON.parse(outSlice).content[0]; + const newSrc = shape.attrs.shapes[0].attrs.src; + expect(newSrc).not.toBe('word/media/pic.png'); + expect(editor.storage.image.media['word/media/pic.png']).toBe('data:image/png;base64,OLD'); + expect(editor.storage.image.media[newSrc]).toBe('data:image/png;base64,NEW'); + }); }); From d4100518752cf6353f8ac2aeaf780de9a757edf6 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Fri, 27 Mar 2026 17:23:17 +0200 Subject: [PATCH 4/4] fix: remove duplicated functions and improve html parsing --- packages/super-editor/src/core/InputRule.js | 93 ++++++++----------- .../super-editor/src/core/InputRule.test.js | 15 +++ .../core/helpers/list-numbering-helpers.js | 28 ++++++ .../src/core/inputRules/html/html-helpers.js | 21 +---- .../core/inputRules/html/html-helpers.test.js | 18 ++++ 5 files changed, 103 insertions(+), 72 deletions(-) diff --git a/packages/super-editor/src/core/InputRule.js b/packages/super-editor/src/core/InputRule.js index f57770fe94..0da968e5df 100644 --- a/packages/super-editor/src/core/InputRule.js +++ b/packages/super-editor/src/core/InputRule.js @@ -7,7 +7,7 @@ import { warnNoDOM } from './helpers/domWarnings.js'; import { getTextContentFromNodes } from './helpers/getTextContentFromNodes.js'; import { isRegExp } from './utilities/isRegExp.js'; import { handleDocxPaste, wrapTextsInRuns } from './inputRules/docx-paste/docx-paste.js'; -import { ListHelpers } from '@helpers/list-numbering-helpers.js'; +import { ListHelpers, createListIdAllocator } from '@helpers/list-numbering-helpers.js'; import { flattenListsInHtml, unflattenListsInHtml } from './inputRules/html/html-helpers.js'; import { handleGoogleDocsHtml } from './inputRules/google-docs-paste/google-docs-paste.js'; import { @@ -53,43 +53,42 @@ export function isSuperdocOriginClipboardHtml(html) { return false; } -/** Apply pasted body `sectPr` when target has single-column layout. */ -function tryApplyEmbeddedBodySectPr(editor, view, bodySectPr) { +/** + * Apply pasted multi-column `bodySectPr` only when the document is still single-column. + * Caller supplies how the clone is written (own `tr` vs dispatch). + * + * @param {object} editor + * @param {object | null | undefined} bodySectPr + * @param {import('prosemirror-model').Node} docForCurrentAttrs + * @param {(clone: object) => void} applyClone + */ +function applyEmbeddedBodySectPrWhenAllowed(editor, bodySectPr, docForCurrentAttrs, applyClone) { if (!bodySectPr || typeof bodySectPr !== 'object') return; const incomingCols = getSectPrColumns(bodySectPr); if (!incomingCols?.count || incomingCols.count <= 1) return; - const current = view.state.doc.attrs?.bodySectPr; + const current = docForCurrentAttrs.attrs?.bodySectPr; const currentCols = current && getSectPrColumns(current); if (currentCols?.count > 1) return; const clone = JSON.parse(JSON.stringify(bodySectPr)); - const tr = view.state.tr.setDocAttribute('bodySectPr', clone); - const converter = editor?.converter; - if (converter) { - converter.bodySectPr = clone; + applyClone(clone); + if (editor?.converter) { + editor.converter.bodySectPr = clone; } - view.dispatch(tr); } -/** Like tryApplyEmbeddedBodySectPr but on `tr` (one dispatch with slice paste meta). */ -function applyEmbeddedBodySectPrToTransaction(editor, tr, bodySectPr, docBeforePaste) { - if (!bodySectPr || typeof bodySectPr !== 'object') return; - - const incomingCols = getSectPrColumns(bodySectPr); - if (!incomingCols?.count || incomingCols.count <= 1) return; - - const current = docBeforePaste.attrs?.bodySectPr; - const currentCols = current && getSectPrColumns(current); - if (currentCols?.count > 1) return; +function tryApplyEmbeddedBodySectPr(editor, view, bodySectPr) { + applyEmbeddedBodySectPrWhenAllowed(editor, bodySectPr, view.state.doc, (clone) => { + view.dispatch(view.state.tr.setDocAttribute('bodySectPr', clone)); + }); +} - const clone = JSON.parse(JSON.stringify(bodySectPr)); - tr.setDocAttribute('bodySectPr', clone); - const converter = editor?.converter; - if (converter) { - converter.bodySectPr = clone; - } +function applyEmbeddedBodySectPrToTransaction(editor, tr, bodySectPr, docBeforePaste) { + applyEmbeddedBodySectPrWhenAllowed(editor, bodySectPr, docBeforePaste, (clone) => { + tr.setDocAttribute('bodySectPr', clone); + }); } export class InputRule { @@ -573,15 +572,25 @@ export function sanitizeHtml(html, forbiddenTags = ['meta', 'svg', 'script', 'st for (let i = 0; i < node.childNodes.length; i += 1) { const current = node.childNodes[i]; if (current?.nodeType === Node.COMMENT_NODE && current.nodeValue?.includes('[if !supportLists]')) { - let j = i + 1; - while (j < node.childNodes.length) { + const nodesToStrip = []; + let endifComment = null; + for (let j = i + 1; j < node.childNodes.length; j += 1) { const next = node.childNodes[j]; if (next?.nodeType === Node.COMMENT_NODE && next.nodeValue?.includes('[endif]')) { - node.removeChild(next); + endifComment = next; break; } - node.removeChild(next); + nodesToStrip.push(next); + } + if (!endifComment) { + node.removeChild(current); + i -= 1; + continue; } + for (const n of nodesToStrip) { + node.removeChild(n); + } + node.removeChild(endifComment); node.removeChild(current); i -= 1; continue; @@ -677,8 +686,6 @@ function handleCutEvent(view, event, editor) { const { from, to } = view.state.selection; if (from === to) return false; - event.preventDefault(); - try { const slice = view.state.doc.slice(from, to); const fragment = slice.content; @@ -702,12 +709,13 @@ function handleCutEvent(view, event, editor) { clipboardData.setData('text/html', embedSliceInHtml(html, sliceJson, bodySectPrJson)); clipboardData.setData('text/plain', fragment.textBetween(0, fragment.size, '\n\n')); + event.preventDefault(); view.dispatch(view.state.tr.deleteSelection().scrollIntoView()); + return true; } catch (error) { console.warn('Failed to handle cut:', error); + return false; } - - return true; } const BULLET_MARKER_CHARS = new Set(['•', '◦', '▪', '\u2022', '\u25E6', '\u25AA']); @@ -735,25 +743,6 @@ function lvlTextForRemap(fmt, ilvl, lr) { } /** Remap pasted list numIds and rebuild defs so target doc’s abstract ids don’t clash. */ -function createListIdAllocator(editor) { - const existingIds = new Set( - Object.keys(editor?.converter?.numbering?.definitions || {}) - .map((value) => Number(value)) - .filter(Number.isFinite), - ); - let nextId = Number(ListHelpers.getNewListId(editor)); - - return () => { - while (!Number.isFinite(nextId) || existingIds.has(nextId)) { - nextId = Number.isFinite(nextId) ? nextId + 1 : Number(ListHelpers.getNewListId(editor)); - } - const allocatedId = nextId; - existingIds.add(allocatedId); - nextId += 1; - return allocatedId; - }; -} - function remapPastedListNumberingInFragment(fragment, editor) { if (!editor?.converter || !fragment.size) { return fragment; diff --git a/packages/super-editor/src/core/InputRule.test.js b/packages/super-editor/src/core/InputRule.test.js index c3f03d9baf..9ab6c76375 100644 --- a/packages/super-editor/src/core/InputRule.test.js +++ b/packages/super-editor/src/core/InputRule.test.js @@ -93,6 +93,21 @@ describe('InputRule helpers', () => { expect(div?.querySelector('span')?.textContent).toBe('ok'); }); + it('does not strip siblings when Word list conditional is missing [endif]', () => { + const html = '

Body

'; + const sanitized = sanitizeHtml(html); + const p = sanitized.querySelector('#keep'); + expect(p).not.toBeNull(); + expect(p?.textContent).toBe('Body'); + }); + + it('still strips Word list conditional when [endif] is present', () => { + const html = '

Next

'; + const sanitized = sanitizeHtml(html); + expect(sanitized.querySelector('span')).toBeNull(); + expect(sanitized.querySelector('#after')?.textContent).toBe('Next'); + }); + it('handles single paragraph HTML paste inside a paragraph', () => { const { editor, view } = createEditorContext(doc(p('Existing'))); diff --git a/packages/super-editor/src/core/helpers/list-numbering-helpers.js b/packages/super-editor/src/core/helpers/list-numbering-helpers.js index f59acf5fff..016e536f55 100644 --- a/packages/super-editor/src/core/helpers/list-numbering-helpers.js +++ b/packages/super-editor/src/core/helpers/list-numbering-helpers.js @@ -119,6 +119,33 @@ export const getNewListId = (editor, grouping = 'definitions') => { return getNextNumberingId(defs); }; +/** + * Allocator for unique list `numId`s when remapping pasted or HTML-copied lists. + * Seeds from existing `editor.converter.numbering.definitions` and tracks ids + * allocated in this batch so two paths (slice paste vs HTML) stay consistent. + * + * @param {import('../Editor').Editor} editor + * @returns {() => number} + */ +export const createListIdAllocator = (editor) => { + const existingIds = new Set( + Object.keys(editor?.converter?.numbering?.definitions || {}) + .map((value) => Number(value)) + .filter(Number.isFinite), + ); + let nextId = Number(getNewListId(editor)); + + return () => { + while (!Number.isFinite(nextId) || existingIds.has(nextId)) { + nextId = Number.isFinite(nextId) ? nextId + 1 : Number(getNewListId(editor)); + } + const allocatedId = nextId; + existingIds.add(allocatedId); + nextId += 1; + return allocatedId; + }; +}; + /** * Get the details of a list definition based on the numId and level. * Read-only — no migration needed (section 3.1). @@ -452,6 +479,7 @@ export const ListHelpers = { generateNewListDefinition, getBasicNumIdTag, getNewListId, + createListIdAllocator, hasListDefinition, removeListDefinitions, diff --git a/packages/super-editor/src/core/inputRules/html/html-helpers.js b/packages/super-editor/src/core/inputRules/html/html-helpers.js index 8e9bf61953..579ab565c8 100644 --- a/packages/super-editor/src/core/inputRules/html/html-helpers.js +++ b/packages/super-editor/src/core/inputRules/html/html-helpers.js @@ -1,4 +1,4 @@ -import { ListHelpers } from '@helpers/list-numbering-helpers.js'; +import { ListHelpers, createListIdAllocator } from '@helpers/list-numbering-helpers.js'; const removeWhitespaces = (node) => { const children = node.childNodes; @@ -50,25 +50,6 @@ export function flattenListsInHtml(html, editor, domDocument) { return doc.body.innerHTML; } -function createListIdAllocator(editor) { - const existingIds = new Set( - Object.keys(editor?.converter?.numbering?.definitions || {}) - .map((value) => Number(value)) - .filter(Number.isFinite), - ); - let nextId = Number(ListHelpers.getNewListId(editor)); - - return () => { - while (!Number.isFinite(nextId) || existingIds.has(nextId)) { - nextId = Number.isFinite(nextId) ? nextId + 1 : Number(ListHelpers.getNewListId(editor)); - } - const allocatedId = nextId; - existingIds.add(allocatedId); - nextId += 1; - return allocatedId; - }; -} - function restoreCopiedListParagraphDefinitions(container, editor) { if (!editor?.converter) return; diff --git a/packages/super-editor/src/core/inputRules/html/html-helpers.test.js b/packages/super-editor/src/core/inputRules/html/html-helpers.test.js index e256a17183..5ad6e430a6 100644 --- a/packages/super-editor/src/core/inputRules/html/html-helpers.test.js +++ b/packages/super-editor/src/core/inputRules/html/html-helpers.test.js @@ -14,6 +14,24 @@ vi.mock('@helpers/list-numbering-helpers.js', () => ({ setLvlOverride: setLvlOverrideMock, getListDefinitionDetails: getListDefinitionDetailsMock, }, + /** Mirrors `createListIdAllocator` from list-numbering-helpers (uses mocked getNewListId). */ + createListIdAllocator: (editor) => { + const existingIds = new Set( + Object.keys(editor?.converter?.numbering?.definitions || {}) + .map((value) => Number(value)) + .filter(Number.isFinite), + ); + let nextId = Number(getNewListIdMock(editor)); + return () => { + while (!Number.isFinite(nextId) || existingIds.has(nextId)) { + nextId = Number.isFinite(nextId) ? nextId + 1 : Number(getNewListIdMock(editor)); + } + const allocatedId = nextId; + existingIds.add(allocatedId); + nextId += 1; + return allocatedId; + }; + }, })); import { flattenListsInHtml, createSingleItemList, unflattenListsInHtml } from './html-helpers.js';