-
Notifications
You must be signed in to change notification settings - Fork 192
fix(plan-engine): date.setValue and checkbox.setState update the visible content control, not just its stored value #3802
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
91ce2d6
f4056ff
8f74835
85355f1
d4fbf7f
3f1b9eb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a HYPERLINK field begins after ordinary text in its first paragraph or ends before ordinary text in its last paragraph, 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a cross-paragraph field's visible text is inside an inline wrapper such as 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[]} | ||
|
|
@@ -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], | ||
| }; | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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-draggablefrom the HTML fragment built bybuildSelectionClipboardHtml; the copy handler's rich-HTML branch still writestext/plainfromSelection.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 👍 / 👎.