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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ const isFragment = (nodeOrFragment) => {

/**
* Checks if a string looks like it contains HTML tags.
* Matches complete tag pairs (e.g., <div>...</div>) or self-closing tags (e.g., <br/>, <img ...>).
* Detects a tag (tag pairs or self-closing tags) anywhere in the string.
*
* @param {string} str
* @returns {boolean}
*/
const looksLikeHTML = (str) =>
/^\s*<[a-zA-Z][^>]*>.*<\/[a-zA-Z][^>]*>\s*$/s.test(str) || // Complete tag pair
/^\s*<[a-zA-Z][^>]*\/>\s*$/.test(str) || // Self-closing tag
/^\s*<(br|hr|img|input|meta|link|area|base|col|embed|param|source|track|wbr)\b[^>]*>\s*$/i.test(str); // Void elements
/<[a-zA-Z][^>]*>[\s\S]*?<\/[a-zA-Z][^>]*>/.test(str) || // Tag pair somewhere (incl. inline formatting inside text)
/<[a-zA-Z][^>]*\/>/.test(str) || // Self-closing tag somewhere
/<(br|hr|img|input|meta|link|area|base|col|embed|param|source|track|wbr)\b[^>]*>/i.test(str); // Void element somewhere

/**
* Inserts content at the specified position.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,4 +240,40 @@ describe('insertContentAt', () => {
// empty textblock wrapper replacement [from-1, to+1]
expect(tr.replaceWith).toHaveBeenCalledWith(9, 11, blockNode);
});

it('parses inline HTML mixed with text via the HTML path (not literal insertText)', () => {
const value = '☒ <strong>yes</strong> ☐ no';

createNodeFromContent.mockImplementation(() => [
{ isText: true, isBlock: false, marks: [], check: vi.fn() },
{ isText: true, isBlock: false, marks: [{ type: 'bold' }], check: vi.fn() },
{ isText: true, isBlock: false, marks: [], check: vi.fn() },
]);

const tr = makeTr();
const editor = makeEditor();

const cmd = insertContentAt(5, value, { updateSelection: true });
const result = cmd({ tr, dispatch: true, editor });

expect(result).toBe(true);
expect(createNodeFromContent).toHaveBeenCalled(); // took the HTML path
expect(tr.replaceWith).toHaveBeenCalled();
expect(tr.insertText).not.toHaveBeenCalledWith(value, 5, 5); // not inserted verbatim
});

it('treats prose with stray angle brackets as plain text', () => {
const value = 'For all x, 5 < 10 and 20 > 3';

const tr = makeTr();
const editor = makeEditor();

const cmd = insertContentAt(5, value, { updateSelection: true });
const result = cmd({ tr, dispatch: true, editor });

expect(result).toBe(true);
expect(createNodeFromContent).not.toHaveBeenCalled(); // fast path, no HTML parsing
expect(tr.insertText).toHaveBeenCalledWith(value, 5, 5);
expect(tr.replaceWith).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { initTestEditor } from '@tests/helpers/helpers.js';
import { buildSelectionClipboardHtml } from './ProseMirrorRenderer.ts';

describe('buildSelectionClipboardHtml — structured content drag handle', () => {
/** Read the visible text a paste target would see, ignoring attributes/markup. */
function visibleText(html: string): string {
const container = document.createElement('div');
container.innerHTML = html;
return container.textContent ?? '';
}

function selectFirstParagraph(view: import('prosemirror-view').EditorView): void {
const paragraph = view.dom.querySelector('.sd-structured-content')?.closest('p');
if (!paragraph) throw new Error('structured content paragraph not rendered');
const range = document.createRange();
range.selectNodeContents(paragraph);
const selection = (view.root as Document).getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}

it('omits the SDT alias label from copied content', () => {
const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'structuredContent',
attrs: { alias: 'Anchored metadata', tag: 'meta-1', appearance: 'hidden' },
content: [{ type: 'text', text: 'text' }],
},
],
},
],
};

const { editor } = initTestEditor({ content, loadFromSchema: true });
selectFirstParagraph(editor.view);

const html = buildSelectionClipboardHtml(editor.view, editor);

// The control's real content survives, but the drag-handle chrome does not:
// a paste target sees only "text", never the "Anchored metadata" label.
expect(html).not.toBeNull();
expect(html).not.toContain('sd-structured-content-draggable');
expect(visibleText(html as string)).toBe('text');
});

it('preserves the alias as node data so a paste back into SuperDoc keeps the control', () => {
const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'structuredContent',
attrs: { alias: 'Anchored metadata', tag: 'meta-1', appearance: 'hidden' },
content: [{ type: 'text', text: 'text' }],
},
],
},
],
};

const { editor } = initTestEditor({ content, loadFromSchema: true });
selectFirstParagraph(editor.view);

const html = buildSelectionClipboardHtml(editor.view, editor) as string;

// The alias is stripped as *visible text* but retained as the `data-alias`
// attribute, which `StructuredContent.parseDOM` reads to restore the control.
expect(html).toContain('data-alias="Anchored metadata"');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,15 @@ const HEADER_FOOTER_LINE_HEIGHT = 1;
*/
type ListenerCleanup = () => void;

const RUNTIME_COPY_STRIP_SELECTOR = ['.list-marker', '.sd-editor-tab', '.ProseMirror-trailingBreak'].join(', ');
// Runtime-only chrome that node views inject for display but that is not part of
// the document model. Copy builds its HTML from the rendered DOM, so these must be
// stripped or they leak into the clipboard.
const RUNTIME_COPY_STRIP_SELECTOR = [
'.list-marker',
'.sd-editor-tab',
'.ProseMirror-trailingBreak',
'.sd-structured-content-draggable', // anchored metadata

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sanitize the plain-text clipboard value too

When a DOM selection already covers the structured-content drag handle (for example a whole-paragraph or select-all range), this only strips .sd-structured-content-draggable from the HTML fragment built by buildSelectionClipboardHtml; the copy handler's rich-HTML branch still writes text/plain from Selection.toString(). In that scenario plain-text paste targets still receive the SDT alias label even though the HTML clipboard is sanitized, so build the plain-text value from the sanitized fragment or from the ProseMirror document slice instead of the raw DOM selection.

Useful? React with 👍 / 👎.

].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 = '<meta name="Generator" content="Microsoft Word">';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,84 @@ export function resolveHyperlinkAttributes(instruction, docx) {
}

/**
* Processes a HYPERLINK instruction and creates a `w:hyperlink` node.
* @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine.
* Slips a `<w:hyperlink>` *inside* one paragraph, wrapping just its visible runs.
*
* @param {import('../../v2/types/index.js').OpenXmlNode} paragraph
* @param {Record<string, string | boolean>} linkAttributes
* @returns {import('../../v2/types/index.js').OpenXmlNode} A copy of the paragraph with its runs wrapped.
*/
function wrapParagraphRunsInHyperlink(paragraph, linkAttributes) {
const children = Array.isArray(paragraph.elements) ? paragraph.elements : [];
const wrappedChildren = [];
let pendingRuns = null;

const flushPendingRuns = () => {
if (!pendingRuns || pendingRuns.length === 0) {
pendingRuns = null;
return;
}
wrappedChildren.push({
name: 'w:hyperlink',
type: 'element',
attributes: { ...linkAttributes },
elements: pendingRuns,
});
pendingRuns = null;
};

for (const child of children) {
if (child?.name === 'w:r') {
if (!pendingRuns) pendingRuns = [];
pendingRuns.push(child);
Comment on lines +93 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict hyperlinks to runs inside the field

When a HYPERLINK field begins after ordinary text in its first paragraph or ends before ordinary text in its last paragraph, preProcessNodesForFldChar passes the whole boundary paragraphs here, including those outside-field runs. This loop wraps every direct w:r, so the prefix and suffix unexpectedly become clickable; preserve the field boundaries when splitting the hyperlink across paragraphs rather than treating every run in each collected paragraph as field content.

Useful? React with 👍 / 👎.

continue;
}
// Paragraph properties (w:pPr) and any other structural child must stay a
// direct child of the paragraph, outside the inline hyperlink.
flushPendingRuns();
wrappedChildren.push(child);
Comment on lines +98 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve links on runs nested in paragraph wrappers

When a cross-paragraph field's visible text is inside an inline wrapper such as w:sdt, this loop leaves the wrapper outside any hyperlink and therefore imports its text without a link mark. The changed preProcessNodesForFldChar test demonstrates this exact w:sdtContent shape and now expects the link to disappear; recurse into inline wrappers or otherwise apply the hyperlink to their visible runs while keeping the paragraph structure valid.

Useful? React with 👍 / 👎.

}
flushPendingRuns();

return { ...paragraph, elements: wrappedChildren };
}

/**
* Turns a HYPERLINK field code into real `<w:hyperlink>` tag(s).
*
* Old Word docs don't store a link as one tidy tag. They store "plumbing": a
* `begin` marker, the URL instruction, a `separate` marker, the visible text,
* then an `end` marker. By the time we get here that plumbing has been stripped
* and `nodesToCombine` holds just the visible content the link should cover. Our
* job is to wrap that content in a `<w:hyperlink>`.
*
* Common case — the whole link lives on one line, so `nodesToCombine` is just
* inline runs. One `<w:hyperlink>` around all of them is correct:
*
* <w:hyperlink><w:r>CSP - 1</w:r></w:hyperlink>
*
* Cross-paragraph case — the plumbing started on one line and finished on the
* next (very common when a link fills a table cell), so `nodesToCombine` holds
* whole `<w:p>` blocks, not loose runs. A `<w:hyperlink>` is *inline* (like
* `<b>`): it lives inside a line of text and cannot contain a `<w:p>`. Wrapping
* the paragraphs in one hyperlink...
*
* <w:hyperlink> <-- paragraphs can't live inside an inline tag
* <w:p>CSP - 1</w:p>
* <w:p>Data in transit</w:p>
* </w:hyperlink>
*
* ...makes the importer throw the paragraphs away (it only reads runs out of a
* hyperlink), which empties the table cell. An empty cell breaks the tableCell
* `block+` schema and aborts the whole document load.
*
* So for the cross-paragraph case we keep each paragraph and drop a *separate*
* `<w:hyperlink>` inside each one, all pointing at the same target — exactly how
* Word itself writes a link that spans more than one line:
*
* <w:p><w:hyperlink r:id="rId5"><w:r>CSP - 1</w:r></w:hyperlink></w:p>
* <w:p><w:hyperlink r:id="rId5"><w:r>Data in transit</w:r></w:hyperlink></w:p>
*
* @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The visible content the link should cover.
* @param {string} instruction The instruction text.
* @param {{ docx?: import('../../v2/docxHelper').ParsedDocx }} [options]
* @returns {import('../../v2/types/index.js').OpenXmlNode[]}
Expand All @@ -75,12 +151,30 @@ export function preProcessHyperlinkInstruction(nodesToCombine, instruction, opti
const docx = options.docx;
const linkAttributes = resolveHyperlinkAttributes(instruction, docx) ?? {};

return [
{
// A `<w:p>` in the gathered content means the link spans a paragraph break, so
// we can't use the simple "one hyperlink around everything" shape.
const spansParagraphBoundary = nodesToCombine.some((node) => node?.name === 'w:p');
if (!spansParagraphBoundary) {
return [
{
name: 'w:hyperlink',
type: 'element',
attributes: linkAttributes,
elements: nodesToCombine,
},
];
}

return nodesToCombine.map((node) => {
if (node?.name === 'w:p') {
return wrapParagraphRunsInHyperlink(node, linkAttributes);
}
// A loose run sitting between paragraphs still gets its own inline link.
return {
name: 'w:hyperlink',
type: 'element',
attributes: linkAttributes,
elements: nodesToCombine,
},
];
attributes: { ...linkAttributes },
elements: [node],
};
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,97 @@ describe('preProcessHyperlinkInstruction', () => {
},
]);
});

it('wraps runs per paragraph when the field spans a paragraph boundary', () => {
// A field whose collected content crosses a paragraph break yields whole
// <w:p> blocks. Because <w:hyperlink> is inline and cannot wrap a <w:p> or
// span the break, Word emits one <w:hyperlink> per paragraph sharing the
// same target. The paragraphs must survive as blocks so a table cell keeps
// its required block content.
const instruction = 'HYPERLINK "http://example.com"';
const mockDocx = {
'word/_rels/document.xml.rels': {
elements: [{ name: 'Relationships', elements: [] }],
},
};

const crossParagraphNodes = [
{
name: 'w:p',
elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'first ' }] }] }],
},
{
name: 'w:p',
elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'second' }] }] }],
},
];

const result = preProcessHyperlinkInstruction(crossParagraphNodes, instruction, { docx: mockDocx });

expect(result).toEqual([
{
name: 'w:p',
elements: [
{
name: 'w:hyperlink',
type: 'element',
attributes: { 'r:id': 'rIdabc12345' },
elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'first ' }] }] }],
},
],
},
{
name: 'w:p',
elements: [
{
name: 'w:hyperlink',
type: 'element',
attributes: { 'r:id': 'rIdabc12345' },
elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'second' }] }] }],
},
],
},
]);
});

it('keeps paragraph properties outside the per-paragraph hyperlink', () => {
// <w:pPr> is a paragraph-level property, not visible content, so it must stay
// a direct child of the paragraph rather than being pulled into the inline
// <w:hyperlink> alongside the runs.
const instruction = 'HYPERLINK "http://example.com"';
const mockDocx = {
'word/_rels/document.xml.rels': {
elements: [{ name: 'Relationships', elements: [] }],
},
};

const nodes = [
{
name: 'w:p',
elements: [
{ name: 'w:pPr', elements: [{ name: 'w:jc', attributes: { 'w:val': 'center' } }] },
{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'only' }] }] },
],
},
{
name: 'w:p',
elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'tail' }] }] }],
},
];

const result = preProcessHyperlinkInstruction(nodes, instruction, { docx: mockDocx });

expect(result[0]).toEqual({
name: 'w:p',
elements: [
{ name: 'w:pPr', elements: [{ name: 'w:jc', attributes: { 'w:val': 'center' } }] },
{
name: 'w:hyperlink',
type: 'element',
attributes: { 'r:id': 'rIdabc12345' },
elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'only' }] }] }],
},
],
});
});
});
Loading
Loading