From 859910e36f6a6aee9453f38626d70c9582e969a9 Mon Sep 17 00:00:00 2001 From: janglad Date: Thu, 24 Sep 2026 07:44:54 +0200 Subject: [PATCH 1/5] Implement preservation of pasted formatting and text alignment This commit introduces functionality to preserve HTML block structure, inline marks, lists, and text alignment when pasting formatted HTML. Key changes include updates to the paste corpus to reflect these enhancements, as well as modifications to the DOM parsing and inline content handling to support text alignment properties. Additionally, tests have been added to ensure the correct preservation of marks and alignment during paste operations. --- .../web-4052-preserve-pasted-formatting.md | 9 + packages/docs/src/generated/pasteCorpus.ts | 29 +- packages/extensions/interop/PASTE-CORPUS.md | 17 +- .../importHtml.elementMapping.test.ts | 50 +++ .../html/import/__tests__/importHtml.test.ts | 124 +++++++ .../import/__tests__/pasteCorpus/CAPTURE.md | 4 +- .../pasteCorpus/apple-notes/clipboard.html | 36 +- .../pasteCorpus/apple-notes/expectation.json | 47 ++- .../pasteCorpus/apple-notes/plain.txt | 14 +- .../pasteCorpus/google-docs/expectation.json | 8 +- .../interop/src/html/import/domAdapter.ts | 35 -- .../interop/src/html/import/domToBlocks.ts | 65 +++- .../interop/src/html/import/inlineParser.ts | 271 +++++++++----- .../interop/src/html/import/sanitize.ts | 336 ++++++++++++++++-- packages/rendering/dom/api-report.md | 2 + .../dom/src/__tests__/mountEditor.test.ts | 24 ++ .../rendering/dom/src/host/documentTree.ts | 2 + packages/rendering/dom/src/index.ts | 4 + .../dom/src/utils/blockTextAlignment.ts | 23 ++ .../clipboard.importerParsing.test.ts | 53 +++ .../react/src/__tests__/dir2Block.test.ts | 21 ++ .../react/src/primitives/editor/block.tsx | 6 +- .../rendering/vue/src/__tests__/mount.test.ts | 25 ++ .../rendering/vue/src/components/PenBlock.ts | 10 +- .../src/__tests__/schemaDefault.test.ts | 27 ++ packages/schema/src/blocks/blockquote.ts | 10 +- packages/schema/src/blocks/bulletListItem.ts | 10 +- packages/schema/src/blocks/checkListItem.ts | 8 +- packages/schema/src/blocks/heading.ts | 7 +- .../schema/src/blocks/numberedListItem.ts | 10 +- packages/schema/src/blocks/paragraph.ts | 10 +- packages/schema/src/textAlignmentProp.ts | 40 +++ spec/packages/extensions/interop.md | 4 +- spec/packages/rendering/dom.md | 1 + spec/packages/schema.md | 1 + spec/rules/security.md | 4 +- 36 files changed, 1114 insertions(+), 233 deletions(-) create mode 100644 .changeset/web-4052-preserve-pasted-formatting.md create mode 100644 packages/rendering/dom/src/utils/blockTextAlignment.ts create mode 100644 packages/schema/src/textAlignmentProp.ts diff --git a/.changeset/web-4052-preserve-pasted-formatting.md b/.changeset/web-4052-preserve-pasted-formatting.md new file mode 100644 index 00000000..69108652 --- /dev/null +++ b/.changeset/web-4052-preserve-pasted-formatting.md @@ -0,0 +1,9 @@ +--- +"@input/pen-interop": patch +"@input/pen-schema": patch +"@input/pen-dom": patch +"@input/pen-react": patch +"@input/pen-vue": patch +--- + +Preserve HTML block structure, inline marks, lists, and text alignment when pasting formatted HTML. diff --git a/packages/docs/src/generated/pasteCorpus.ts b/packages/docs/src/generated/pasteCorpus.ts index 36a1037b..a8d93fea 100644 --- a/packages/docs/src/generated/pasteCorpus.ts +++ b/packages/docs/src/generated/pasteCorpus.ts @@ -88,34 +88,33 @@ export const PASTE_CORPUS_ROWS: readonly PasteCorpusRow[] = [ code: "none", links: "kept", images: "none", - marks: "wrapper bolds the entire paste; span font-weight / font-style / text-decoration lost", + marks: "span bold and italic kept; font-weight normal wrapper ignored", colors: "span color kept as textColor", intentionalLosses: [ - "The docs-internal-guid wrapper is treated as inline bold, so headings, lists, and tables collapse into one paragraph", - "Google Docs bold/italic/strike expressed as span font-weight / font-style / text-decoration do not become marks", + "The docs-internal-guid wrapper still collapses headings, lists, and tables into one paragraph", "Adjacent block text is concatenated with no separator", ], }, { id: "apple-notes", source: "Apple Notes", - provenance: "synthetic-until-capture", - captured: false, - application: "", - version: "", - capturedAt: "", - host: "", - approximates: "Apple Notes clipboard fragment: StartFragment, heading plus div-wrapped paragraphs, semantic lists. Notes typically uses
rather than

.", - headings: "h1", - lists: "flat bullets", + provenance: "captured: Apple Notes 4.13 (2026-09-23)", + captured: true, + application: "Apple Notes", + version: "4.13", + capturedAt: "2026-09-23", + host: "Arc (Chromium paste event)", + approximates: "n/a — hand-captured browser paste payload", + headings: "none", + lists: "flat numbered and bullets", tables: "none", code: "none", - links: "kept", + links: "none in capture", images: "none", - marks: "bold kept after the mixed-inline split", + marks: "bold, italic, and class-based underline kept", colors: "none", intentionalLosses: [ - "A Notes

with mixed text and fragments into adjacent paragraphs; leading text is trimmed", + "font family, size, margins, minimum heights, and list marker CSS are discarded", ], }, { diff --git a/packages/extensions/interop/PASTE-CORPUS.md b/packages/extensions/interop/PASTE-CORPUS.md index c7666120..00b02719 100644 --- a/packages/extensions/interop/PASTE-CORPUS.md +++ b/packages/extensions/interop/PASTE-CORPUS.md @@ -2,7 +2,7 @@ Clipboard `text/html` + `text/plain` pairs measured through the generic HTML import path (`parseHtmlToBlocks`). Pen does not sniff `mso` classes or `docs-internal-guid`. A documented flattening is the paste contract; an undocumented one is a regression. -These fixtures are **synthetic-until-capture**: documented approximations of what each application emits, not hand-captured clipboard dumps. A real Word clipboard payload is hundreds of kilobytes of `

normal, underline

', + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "paragraph", + content: "normal, underline", + }); + expect(blocks[0].marks).toContainEqual({ + type: "underline", + start: 8, + end: 17, + }); + }); + + it("IOP2 preserves text alignment on default text blocks", () => { + const blocks = convert( + '

Centered

Right

Quoted
  • Listed
', + ); + + expect(blocks).toMatchObject([ + { type: "paragraph", props: { textAlignment: "center" } }, + { type: "heading", props: { level: 2, textAlignment: "right" } }, + { type: "blockquote", props: { textAlignment: "justify" } }, + { type: "bulletListItem", props: { textAlignment: "end" } }, + ]); + }); + it("link mark with href (AC 34)", () => { const blocks = convert('

text

'); diff --git a/packages/extensions/interop/src/html/import/__tests__/importHtml.test.ts b/packages/extensions/interop/src/html/import/__tests__/importHtml.test.ts index 644ab529..65d38919 100644 --- a/packages/extensions/interop/src/html/import/__tests__/importHtml.test.ts +++ b/packages/extensions/interop/src/html/import/__tests__/importHtml.test.ts @@ -16,6 +16,43 @@ describe("sanitizeHTML", () => { expect(result).toContain("text"); }); + it("SEC3: resolves safe simple class rules before stripping stylesheets", () => { + const result = sanitizeHTML( + '

underlined

', + ); + expect(result).not.toContain("style>"); + expect(result).toContain('style="text-decoration: underline"'); + }); + + it("SEC3: keeps simple rules separated by CSS comments", () => { + const result = sanitizeHTML( + '

boldunderlined

', + ); + expect(result).toContain('class="bold" style="font-weight: bold"'); + expect(result).toContain( + 'class="underlined" style="text-decoration: underline"', + ); + }); + + it("SEC3: drops commented stylesheet values", () => { + const result = sanitizeHTML( + '

plain

', + ); + expect(result).not.toContain("font-weight"); + }); + + it("SEC3: ignores complex stylesheet selectors and unsafe declarations", () => { + const result = sanitizeHTML( + '

plain

', + ); + expect(result).not.toContain("font-weight"); + expect(result).not.toContain("font-style"); + expect(result).not.toContain("position"); + expect(result).not.toContain("background-image"); + expect(result).not.toContain("evil.example"); + expect(result).toContain('style="text-decoration: none"'); + }); + it("strips

ok

'); expect(result).not.toContain("iframe"); @@ -56,6 +93,25 @@ describe("sanitizeHTML", () => { expect(result).not.toContain("z-index:"); }); + it("SEC3: keeps enumerated mark-bearing inline styles", () => { + const result = sanitizeHTML( + 'styled', + ); + expect(result).toContain( + "font-weight: 700; font-style: italic; text-decoration: underline line-through", + ); + }); + + it("SEC3: drops unsupported mark-bearing inline style values", () => { + const result = sanitizeHTML( + 'plain', + ); + expect(result).toContain("plain"); + expect(result).not.toContain("font-weight"); + expect(result).not.toContain("font-style"); + expect(result).not.toContain("text-decoration"); + }); + it("SEC3: admits each enumerated text-align keyword and drops inherit", () => { for (const value of ["left", "right", "center", "justify", "start", "end"]) { const result = sanitizeHTML( @@ -201,4 +257,72 @@ describe("parseInlineContent", () => { expect(result.marks.some((m) => m.type === "bold")).toBe(true); expect(result.marks.some((m) => m.type === "italic")).toBe(true); }); + + it("uses the last declaration for the same property", () => { + const node: DOMNode = { + type: "element", + tagName: "span", + attributes: { + style: "font-weight: normal; font-weight: 700", + }, + children: [{ type: "text", textContent: "bold" }], + }; + expect(parseInlineContent(node).marks).toContainEqual({ + type: "bold", + start: 0, + end: 4, + }); + }); + + it("resolves text-decoration shorthand and longhand in source order", () => { + const node = (style: string): DOMNode => ({ + type: "element", + tagName: "span", + attributes: { style }, + children: [{ type: "text", textContent: "text" }], + }); + expect( + parseInlineContent( + node( + "text-decoration-line: underline; text-decoration: none", + ), + ).marks, + ).toEqual([]); + expect( + parseInlineContent( + node( + "text-decoration: none; text-decoration-line: underline", + ), + ).marks, + ).toEqual([{ type: "underline", start: 0, end: 4 }]); + }); + + it("lets explicit styles reset semantic and inherited marks", () => { + const node: DOMNode = { + type: "element", + tagName: "p", + attributes: { style: "font-weight: bold" }, + children: [ + { + type: "element", + tagName: "span", + attributes: { style: "font-weight: normal" }, + children: [{ type: "text", textContent: "normal" }], + }, + { type: "text", textContent: " bold" }, + { + type: "element", + tagName: "u", + attributes: { style: "text-decoration: none" }, + children: [{ type: "text", textContent: " plain" }], + }, + ], + }; + const result = parseInlineContent(node); + expect(result.text).toBe("normal bold plain"); + expect(result.marks).toEqual([{ type: "bold", start: 6, end: 17 }]); + expect(result.marks).not.toContainEqual( + expect.objectContaining({ type: "underline" }), + ); + }); }); diff --git a/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/CAPTURE.md b/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/CAPTURE.md index 4a0d5b16..6a3c2475 100644 --- a/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/CAPTURE.md +++ b/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/CAPTURE.md @@ -25,7 +25,9 @@ and bold / italic / strike (skip a structure the application cannot emit). 1. Build a short document in the source that exercises the structures above. 2. Select all and copy. -3. From this source directory, overwrite the pair (macOS): +3. Capture the `text/html` and `text/plain` flavors exactly as the paste target receives them. Some native applications, including Apple Notes, put RTF rather than HTML on the macOS pasteboard; for those sources, read `event.clipboardData.getData("text/html")` and `getData("text/plain")` from a browser paste event so the fixture includes the browser's RTF-to-HTML conversion. Do not paste through a chat input or another editor first because it may normalize the payload. + + For sources that place HTML directly on the macOS pasteboard, overwrite the pair from this source directory: ```bash pbpaste -Prefer public.html > clipboard.html diff --git a/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/clipboard.html b/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/clipboard.html index 53faec48..0f472bce 100644 --- a/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/clipboard.html +++ b/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/clipboard.html @@ -1,16 +1,34 @@ + - + + + + + + - -

Meeting notes

-
Hello world
-
    -
  • First item
  • -
  • Second item
  • +


    +


    +

    normal, bold, italic, underline

    +


    +
      +
    1. numbered
    2. +
    3. bullets
    4. +
    +


    +
      +
    • dotted
    • +
    • Bullets
    - - diff --git a/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/expectation.json b/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/expectation.json index 83cc7be2..0bc4636d 100644 --- a/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/expectation.json +++ b/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/expectation.json @@ -1,37 +1,46 @@ { "id": "apple-notes", "source": "Apple Notes", - "provenance": "synthetic-until-capture", - "approximates": "Apple Notes clipboard fragment: StartFragment, heading plus div-wrapped paragraphs, semantic lists. Notes typically uses
    rather than

    .", - "markers": ["StartFragment", "div-wrapped body"], + "provenance": { + "kind": "captured", + "application": "Apple Notes", + "version": "4.13", + "capturedAt": "2026-09-23", + "host": "Arc (Chromium paste event)" + }, + "approximates": "n/a — hand-captured browser paste payload", + "markers": ["Cocoa HTML Writer", "span.s1", "ol.ol1", "ul.ul1"], "imageCount": 0, "outcomes": { - "headings": "h1", - "lists": "flat bullets", + "headings": "none", + "lists": "flat numbered and bullets", "tables": "none", "code": "none", - "links": "kept", + "links": "none in capture", "images": "none", - "marks": "bold kept after the mixed-inline split", + "marks": "bold, italic, and class-based underline kept", "colors": "none" }, "intentionalLosses": [ - "A Notes

    with mixed text and fragments into adjacent paragraphs; leading text is trimmed" + "font family, size, margins, minimum heights, and list marker CSS are discarded" ], "blocks": [ - { "type": "heading", "level": 1, "text": "Meeting notes" }, - { "type": "paragraph", "text": "Hello" }, + { "type": "paragraph", "text": "\n" }, + { "type": "paragraph", "text": "\n" }, { "type": "paragraph", - "text": "world", - "marks": [{ "type": "bold", "text": "world" }] + "text": "normal, bold, italic, underline", + "marks": [ + { "type": "bold", "text": "bold" }, + { "type": "italic", "text": "italic" }, + { "type": "underline", "text": "underline" } + ] }, - { "type": "bulletListItem", "indent": 0, "text": "First item" }, - { "type": "bulletListItem", "indent": 0, "text": "Second item" }, - { - "type": "paragraph", - "text": "Notes link", - "marks": [{ "type": "link", "text": "Notes link" }] - } + { "type": "paragraph", "text": "\n" }, + { "type": "numberedListItem", "indent": 0, "text": "numbered" }, + { "type": "numberedListItem", "indent": 0, "text": "bullets" }, + { "type": "paragraph", "text": "\n" }, + { "type": "bulletListItem", "indent": 0, "text": "dotted" }, + { "type": "bulletListItem", "indent": 0, "text": "Bullets" } ] } diff --git a/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/plain.txt b/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/plain.txt index 1206901b..b5a9c289 100644 --- a/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/plain.txt +++ b/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/plain.txt @@ -1,5 +1,9 @@ -Meeting notes -Hello world -First item -Second item -Notes link + + +normal, bold, italic, underline + +1. numbered +2. bullets + +* dotted +* Bullets diff --git a/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/google-docs/expectation.json b/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/google-docs/expectation.json index 3944ade3..ab8ca13c 100644 --- a/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/google-docs/expectation.json +++ b/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/google-docs/expectation.json @@ -12,12 +12,11 @@ "code": "none", "links": "kept", "images": "none", - "marks": "wrapper bolds the entire paste; span font-weight / font-style / text-decoration lost", + "marks": "span bold and italic kept; font-weight normal wrapper ignored", "colors": "span color kept as textColor" }, "intentionalLosses": [ - "The docs-internal-guid wrapper is treated as inline bold, so headings, lists, and tables collapse into one paragraph", - "Google Docs bold/italic/strike expressed as span font-weight / font-style / text-decoration do not become marks", + "The docs-internal-guid wrapper still collapses headings, lists, and tables into one paragraph", "Adjacent block text is concatenated with no separator" ], "blocks": [ @@ -25,7 +24,8 @@ "type": "paragraph", "text": "Quarterly notesBold and italic redFirst itemABDocs link", "marks": [ - { "type": "bold", "text": "Quarterly notesBold and italic redFirst itemABDocs link" }, + { "type": "bold", "text": "Bold" }, + { "type": "italic", "text": "italic" }, { "type": "textColor", "text": " red" }, { "type": "link", "text": "Docs link" } ] diff --git a/packages/extensions/interop/src/html/import/domAdapter.ts b/packages/extensions/interop/src/html/import/domAdapter.ts index 91175f5d..fae4b244 100644 --- a/packages/extensions/interop/src/html/import/domAdapter.ts +++ b/packages/extensions/interop/src/html/import/domAdapter.ts @@ -10,45 +10,10 @@ export interface DOMNode { } export function parseHTML(html: string): DOMNode { - if (typeof globalThis.DOMParser !== "undefined") { - const doc = new globalThis.DOMParser().parseFromString(html, "text/html"); - return domNodeToDOMNode(doc.body); - } - const doc = parseDocument(html); return htmlparser2ToDOMNode(doc); } -function domNodeToDOMNode(node: globalThis.Node): DOMNode { - const result: DOMNode = { - type: - node.nodeType === 1 - ? "element" - : node.nodeType === 3 - ? "text" - : "other", - }; - - if (node.nodeType === 1) { - const el = node as globalThis.Element; - result.tagName = el.tagName.toLowerCase(); - result.attributes = {}; - for (const attr of el.attributes) { - result.attributes[attr.name.toLowerCase()] = attr.value; - } - } - - if (node.nodeType === 3) { - result.textContent = node.textContent ?? ""; - } - - if (node.childNodes.length > 0) { - result.children = Array.from(node.childNodes).map(domNodeToDOMNode); - } - - return result; -} - function htmlparser2ToDOMNode(node: Document | ChildNode): DOMNode { if (node.type === "text") { return { diff --git a/packages/extensions/interop/src/html/import/domToBlocks.ts b/packages/extensions/interop/src/html/import/domToBlocks.ts index 2be811ad..15fb5efd 100644 --- a/packages/extensions/interop/src/html/import/domToBlocks.ts +++ b/packages/extensions/interop/src/html/import/domToBlocks.ts @@ -1,5 +1,6 @@ import type { DOMNode } from "./domAdapter"; import { parseInlineContent } from "./inlineParser"; +import { parseSafeStyleDeclarations } from "./sanitize"; import type { BlockImportMatch, HTMLImportElement, @@ -9,14 +10,21 @@ import type { import type { PendingBlock } from "@input/pen-core"; const BLOCK_ELEMENT_MAP: Record PendingBlock> = { - h1: (node) => blockWithInline("heading", { level: 1 }, node), - h2: (node) => blockWithInline("heading", { level: 2 }, node), - h3: (node) => blockWithInline("heading", { level: 3 }, node), - h4: (node) => blockWithInline("heading", { level: 4 }, node), - h5: (node) => blockWithInline("heading", { level: 5 }, node), - h6: (node) => blockWithInline("heading", { level: 6 }, node), - p: (node) => blockWithInline("paragraph", {}, node), - blockquote: (node) => blockWithInline("blockquote", {}, node), + h1: (node) => + blockWithInline("heading", propsWithTextAlignment(node, { level: 1 }), node), + h2: (node) => + blockWithInline("heading", propsWithTextAlignment(node, { level: 2 }), node), + h3: (node) => + blockWithInline("heading", propsWithTextAlignment(node, { level: 3 }), node), + h4: (node) => + blockWithInline("heading", propsWithTextAlignment(node, { level: 4 }), node), + h5: (node) => + blockWithInline("heading", propsWithTextAlignment(node, { level: 5 }), node), + h6: (node) => + blockWithInline("heading", propsWithTextAlignment(node, { level: 6 }), node), + p: (node) => blockWithInline("paragraph", propsWithTextAlignment(node), node), + blockquote: (node) => + blockWithInline("blockquote", propsWithTextAlignment(node), node), hr: () => ({ type: "divider", props: {} }), pre: (node) => { const codeNode = node.children?.find((c) => c.tagName === "code"); @@ -140,6 +148,7 @@ function walkList( ): void { const items = (node.children ?? []).filter((c) => c.tagName === "li"); const olStart = ordered ? parseOlStart(node) : undefined; + const listAlignment = textAlignment(node); for (let itemIdx = 0; itemIdx < items.length; itemIdx++) { const li = items[itemIdx]; @@ -166,6 +175,7 @@ function walkList( props: { indent, checked: checkbox.attributes?.checked !== undefined, + ...textAlignmentProps(li, listAlignment), }, content: inline.text, marks: inline.marks, @@ -176,6 +186,7 @@ function walkList( props: { indent, start: itemIdx === 0 ? olStart : undefined, + ...textAlignmentProps(li, listAlignment), }, content: inline.text, marks: inline.marks, @@ -183,7 +194,7 @@ function walkList( } else { blocks.push({ type: "bulletListItem", - props: { indent }, + props: { indent, ...textAlignmentProps(li, listAlignment) }, content: inline.text, marks: inline.marks, }); @@ -267,6 +278,42 @@ function blockWithInline( return { type, props, content: inline.text, marks: inline.marks }; } +const TEXT_ALIGNMENTS = new Set([ + "left", + "right", + "center", + "justify", + "start", + "end", +]); + +function propsWithTextAlignment( + node: DOMNode, + props: Record = {}, +): Record { + return { ...props, ...textAlignmentProps(node) }; +} + +function textAlignmentProps( + node: DOMNode, + inherited?: string, +): Record { + const alignment = textAlignment(node) ?? inherited; + return alignment ? { textAlignment: alignment } : {}; +} + +function textAlignment(node: DOMNode): string | undefined { + let alignment = node.attributes?.align?.toLowerCase(); + for (const declaration of parseSafeStyleDeclarations( + node.attributes?.style ?? "", + )) { + if (declaration.property === "text-align") { + alignment = declaration.value; + } + } + return alignment && TEXT_ALIGNMENTS.has(alignment) ? alignment : undefined; +} + function extractText(node: DOMNode): string { if (node.type === "text") return node.textContent ?? ""; return (node.children ?? []).map(extractText).join(""); diff --git a/packages/extensions/interop/src/html/import/inlineParser.ts b/packages/extensions/interop/src/html/import/inlineParser.ts index 81614ff6..0bbe9ee2 100644 --- a/packages/extensions/interop/src/html/import/inlineParser.ts +++ b/packages/extensions/interop/src/html/import/inlineParser.ts @@ -1,102 +1,193 @@ import type { DOMNode } from "./domAdapter"; +import { parseSafeStyleDeclarations } from "./sanitize"; + +interface InlineMark { + type: string; + props?: Record; + start: number; + end: number; +} interface InlineResult { - text: string; - marks: Array<{ - type: string; - props?: Record; - start: number; - end: number; - }>; + text: string; + marks: InlineMark[]; } +type ActiveMark = Pick; +type ActiveMarks = Map; + const INLINE_MARK_MAP: Record = { - strong: "bold", - b: "bold", - em: "italic", - i: "italic", - u: "underline", - s: "strikethrough", - del: "strikethrough", - strike: "strikethrough", - code: "code", - mark: "highlight", + strong: "bold", + b: "bold", + em: "italic", + i: "italic", + u: "underline", + s: "strikethrough", + del: "strikethrough", + strike: "strikethrough", + code: "code", + mark: "highlight", }; export function parseInlineContent(node: DOMNode): InlineResult { - const result: InlineResult = { text: "", marks: [] }; - walkInline(node, result); - return result; + const result: InlineResult = { text: "", marks: [] }; + walkInline(node, result, new Map()); + return result; +} + +function walkInline( + node: DOMNode, + result: InlineResult, + activeMarks: ActiveMarks, +): void { + if (node.type === "text") { + appendText(result, node.textContent ?? "", activeMarks); + return; + } + + if (node.type !== "element" || !node.tagName) { + for (const child of node.children ?? []) { + walkInline(child, result, activeMarks); + } + return; + } + + if (node.tagName === "br") { + appendText(result, "\n", activeMarks); + return; + } + + const nextMarks = new Map(activeMarks); + const semanticMark = INLINE_MARK_MAP[node.tagName]; + if (semanticMark) { + setMark(nextMarks, semanticMark); + } + if (node.tagName === "a") { + setMark(nextMarks, "link", { + href: node.attributes?.href ?? "", + title: node.attributes?.title ?? undefined, + }); + } + applyInlineStyles(nextMarks, node.attributes?.style ?? ""); + + for (const child of node.children ?? []) { + walkInline(child, result, nextMarks); + } +} + +function applyInlineStyles(activeMarks: ActiveMarks, style: string): void { + for (const { property, value } of parseSafeStyleDeclarations(style)) { + if (property === "font-weight") { + if ( + value === "bold" || + value === "bolder" || + /^[5-9]00$/.test(value) + ) { + setMark(activeMarks, "bold"); + } else { + activeMarks.delete("bold"); + } + continue; + } + if (property === "font-style") { + if (value === "italic" || value === "oblique") { + setMark(activeMarks, "italic"); + } else { + activeMarks.delete("italic"); + } + continue; + } + if ( + property === "text-decoration" || + property === "text-decoration-line" + ) { + const values = new Set(value.split(/\s+/)); + setMarkEnabled(activeMarks, "underline", values.has("underline")); + setMarkEnabled( + activeMarks, + "strikethrough", + values.has("line-through"), + ); + continue; + } + if (property === "color") { + setMark(activeMarks, "textColor", { color: value }); + continue; + } + if (property === "background-color") { + setMark(activeMarks, "backgroundColor", { color: value }); + } + } +} + +function setMark( + activeMarks: ActiveMarks, + type: string, + props?: Record, +): void { + activeMarks.set(type, props ? { type, props } : { type }); +} + +function setMarkEnabled( + activeMarks: ActiveMarks, + type: string, + enabled: boolean, +): void { + if (enabled) { + setMark(activeMarks, type); + } else { + activeMarks.delete(type); + } +} + +function appendText( + result: InlineResult, + text: string, + activeMarks: ActiveMarks, +): void { + if (text.length === 0) { + return; + } + const start = result.text.length; + result.text += text; + const end = result.text.length; + for (const mark of activeMarks.values()) { + const previous = findPreviousMark(result.marks, mark); + if (previous?.end === start) { + previous.end = end; + } else { + result.marks.push({ ...mark, start, end }); + } + } +} + +function findPreviousMark( + marks: InlineMark[], + active: ActiveMark, +): InlineMark | undefined { + for (let index = marks.length - 1; index >= 0; index -= 1) { + const mark = marks[index]; + if (mark.type === active.type && sameProps(mark.props, active.props)) { + return mark; + } + } + return undefined; } -function walkInline(node: DOMNode, result: InlineResult): void { - if (node.type === "text") { - result.text += node.textContent ?? ""; - return; - } - - if (node.type !== "element" || !node.tagName) { - for (const child of node.children ?? []) walkInline(child, result); - return; - } - - const markType = INLINE_MARK_MAP[node.tagName]; - if (markType) { - const start = result.text.length; - for (const child of node.children ?? []) walkInline(child, result); - result.marks.push({ type: markType, start, end: result.text.length }); - return; - } - - if (node.tagName === "a") { - const start = result.text.length; - for (const child of node.children ?? []) walkInline(child, result); - result.marks.push({ - type: "link", - props: { - href: node.attributes?.href ?? "", - title: node.attributes?.title ?? undefined, - }, - start, - end: result.text.length, - }); - return; - } - - if (node.tagName === "span") { - const style = node.attributes?.style ?? ""; - const colorMatch = style.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i); - const bgMatch = style.match( - /(?:^|;)\s*background(?:-color)?\s*:\s*([^;]+)/i, - ); - - const start = result.text.length; - for (const child of node.children ?? []) walkInline(child, result); - const end = result.text.length; - - if (colorMatch) { - result.marks.push({ - type: "textColor", - props: { color: colorMatch[1].trim() }, - start, - end, - }); - } - if (bgMatch) { - result.marks.push({ - type: "backgroundColor", - props: { color: bgMatch[1].trim() }, - start, - end, - }); - } - return; - } - - if (node.tagName === "br") { - result.text += "\n"; - return; - } - - for (const child of node.children ?? []) walkInline(child, result); +function sameProps( + left: Record | undefined, + right: Record | undefined, +): boolean { + if (left === right) { + return true; + } + if (!left || !right) { + return false; + } + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every((key) => left[key] === right[key]) + ); } diff --git a/packages/extensions/interop/src/html/import/sanitize.ts b/packages/extensions/interop/src/html/import/sanitize.ts index d913f0c3..79df4499 100644 --- a/packages/extensions/interop/src/html/import/sanitize.ts +++ b/packages/extensions/interop/src/html/import/sanitize.ts @@ -1,9 +1,39 @@ import DOMPurify from "isomorphic-dompurify"; +import { DomUtils, parseDocument } from "htmlparser2"; +import type { ChildNode, Document, Element } from "domhandler"; const ALLOWED_INLINE_STYLE_PROPS = new Set([ "color", "background-color", + "font-style", + "font-weight", "text-align", + "text-decoration", + "text-decoration-line", +]); + +const ALLOWED_FONT_STYLE_VALUES = new Set(["normal", "italic", "oblique"]); + +const ALLOWED_FONT_WEIGHT_VALUES = new Set([ + "normal", + "bold", + "bolder", + "lighter", + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", +]); + +const ALLOWED_TEXT_DECORATION_VALUES = new Set([ + "none", + "underline", + "line-through", ]); const ALLOWED_TEXT_ALIGN_VALUES = new Set([ @@ -121,7 +151,10 @@ function admitKeywordValue( if (HOSTILE_STYLE_VALUE.test(raw)) { return null; } - const normalized = raw.replace(/\s*!important\s*$/i, "").trim().toLowerCase(); + const normalized = raw + .replace(/\s*!important\s*$/i, "") + .trim() + .toLowerCase(); if (!allowed.has(normalized)) { return null; } @@ -132,38 +165,290 @@ function admitStylePropertyValue( property: string, propertyValue: string, ): string | null { + if (property === "font-style") { + return admitKeywordValue(propertyValue, ALLOWED_FONT_STYLE_VALUES); + } + if (property === "font-weight") { + return admitKeywordValue(propertyValue, ALLOWED_FONT_WEIGHT_VALUES); + } if (property === "text-align") { return admitKeywordValue(propertyValue, ALLOWED_TEXT_ALIGN_VALUES); } + if (property === "text-decoration" || property === "text-decoration-line") { + return admitKeywordList(propertyValue, ALLOWED_TEXT_DECORATION_VALUES); + } return propertyValue; } +function admitKeywordList( + raw: string, + allowed: ReadonlySet, +): string | null { + if (HOSTILE_STYLE_VALUE.test(raw)) { + return null; + } + const values = raw + .replace(/\s*!important\s*$/i, "") + .trim() + .toLowerCase() + .split(/\s+/); + if ( + values.length === 0 || + values.some((value) => !allowed.has(value)) || + (values.includes("none") && values.length > 1) + ) { + return null; + } + return [...new Set(values)].join(" "); +} + +type SafeStyleDeclaration = { + property: string; + value: string; +}; + +export function parseSafeStyleDeclarations( + value: string, +): SafeStyleDeclaration[] { + const declarations = new Map(); + for (const rawDeclaration of value.split(";")) { + const declaration = rawDeclaration.trim(); + if (declaration.length === 0) { + continue; + } + const separatorIndex = declaration.indexOf(":"); + if (separatorIndex < 0) { + continue; + } + const property = declaration + .slice(0, separatorIndex) + .trim() + .toLowerCase(); + const propertyValue = declaration.slice(separatorIndex + 1).trim(); + if ( + !ALLOWED_INLINE_STYLE_PROPS.has(property) || + propertyValue.length === 0 + ) { + continue; + } + const admitted = admitStylePropertyValue(property, propertyValue); + if (admitted === null) { + continue; + } + declarations.delete(property); + declarations.set(property, { property, value: admitted }); + } + return [...declarations.values()]; +} + function filterInlineStyleDeclarations(value: string): string { - return value - .split(";") - .map((declaration) => declaration.trim()) - .filter(Boolean) - .map((declaration) => { - const separatorIndex = declaration.indexOf(":"); - if (separatorIndex < 0) { - return null; + return parseSafeStyleDeclarations(value) + .map(({ property, value: propertyValue }) => + `${property}: ${propertyValue}`, + ) + .join("; "); +} + +type SafeStylesheetRule = { + tagName?: string; + className: string; + declarations: SafeStyleDeclaration[]; + specificity: number; +}; + +type CascadedStyleDeclaration = SafeStyleDeclaration & { + specificity: number; + order: number; +}; + +type SafeStylesheetClassRules = { + classDeclarations: Map; + tagDeclarations: Map>; +}; + +const SIMPLE_CLASS_SELECTOR = /^([a-z][a-z0-9-]*)?\.([_a-z][-_a-z0-9]*)$/i; + +function parseSafeStylesheetRules(css: string): SafeStylesheetRule[] { + const rules: SafeStylesheetRule[] = []; + css = stripCssComments(css); + let selectorStart = 0; + let bodyStart = 0; + let depth = 0; + let hasNestedBlock = false; + + for (let index = 0; index < css.length; index += 1) { + const character = css[index]; + if (character === "{") { + if (depth === 0) { + bodyStart = index + 1; + hasNestedBlock = false; + } else { + hasNestedBlock = true; + } + depth += 1; + continue; + } + if (character !== "}" || depth === 0) { + continue; + } + depth -= 1; + if (depth !== 0) { + continue; + } + + const selectorText = css.slice(selectorStart, bodyStart - 1).trim(); + const declarations = hasNestedBlock + ? [] + : parseSafeStyleDeclarations(css.slice(bodyStart, index)); + if (declarations.length > 0 && !selectorText.startsWith("@")) { + for (const selector of selectorText.split(",")) { + const match = SIMPLE_CLASS_SELECTOR.exec(selector.trim()); + if (!match) { + continue; + } + rules.push({ + ...(match[1] ? { tagName: match[1].toLowerCase() } : {}), + className: match[2]!, + declarations, + specificity: match[1] ? 11 : 10, + }); + } + } + selectorStart = index + 1; + } + + return rules; +} + +function stripCssComments(css: string): string { + let result = ""; + let index = 0; + while (index < css.length) { + if (css[index] === "/" && css[index + 1] === "*") { + const end = css.indexOf("*/", index + 2); + result += " "; + index = end < 0 ? css.length : end + 2; + continue; + } + result += css[index]; + index += 1; + } + return result; +} + +function collectSafeStylesheetRules( + node: Document | ChildNode, +): SafeStylesheetRule[] { + const rules: SafeStylesheetRule[] = []; + if (node.type === "style") { + rules.push(...parseSafeStylesheetRules(DomUtils.textContent(node))); + } + if ("children" in node) { + for (const child of node.children) { + rules.push(...collectSafeStylesheetRules(child)); + } + } + return rules; +} + +function inlineSafeStylesheetDeclarations(html: string): string { + if (!/)/i.test(html)) { + return html; + } + const document = parseDocument(html); + const rules = collectSafeStylesheetRules(document).map((rule, order) => ({ + ...rule, + order, + })); + if (rules.length === 0) { + return html; + } + const rulesByClass = new Map(); + for (const rule of rules) { + const classRules = rulesByClass.get(rule.className) ?? { + classDeclarations: new Map(), + tagDeclarations: new Map(), + }; + const declarations = rule.tagName + ? (classRules.tagDeclarations.get(rule.tagName) ?? new Map()) + : classRules.classDeclarations; + for (const declaration of rule.declarations) { + declarations.set(declaration.property, { + ...declaration, + specificity: rule.specificity, + order: rule.order, + }); + } + if (rule.tagName) { + classRules.tagDeclarations.set(rule.tagName, declarations); + } + rulesByClass.set(rule.className, classRules); + } + + function visit(node: Document | ChildNode): void { + if (node.type === "tag") { + const element = node as Element; + const classNames = new Set( + (element.attribs.class ?? "").split(/\s+/).filter(Boolean), + ); + const declarations = new Map(); + for (const className of classNames) { + const classRules = rulesByClass.get(className); + if (!classRules) { + continue; + } + for (const candidates of [ + classRules.classDeclarations, + classRules.tagDeclarations.get(element.name), + ]) { + if (!candidates) { + continue; + } + mergeCascadedDeclarations(declarations, candidates); + } } - const property = declaration.slice(0, separatorIndex).trim().toLowerCase(); - const propertyValue = declaration.slice(separatorIndex + 1).trim(); - if ( - !ALLOWED_INLINE_STYLE_PROPS.has(property) || - propertyValue.length === 0 - ) { - return null; + + if (declarations.size > 0) { + for (const declaration of parseSafeStyleDeclarations( + element.attribs.style ?? "", + )) { + declarations.set(declaration.property, { + ...declaration, + specificity: Number.POSITIVE_INFINITY, + order: Number.POSITIVE_INFINITY, + }); + } + element.attribs.style = [...declarations.values()] + .map(({ property, value }) => `${property}: ${value}`) + .join("; "); } - const admitted = admitStylePropertyValue(property, propertyValue); - if (admitted === null) { - return null; + } + if ("children" in node) { + for (const child of node.children) { + visit(child); } - return `${property}: ${admitted}`; - }) - .filter((declaration): declaration is string => declaration !== null) - .join("; "); + } + } + + visit(document); + return DomUtils.getOuterHTML(document); +} + +function mergeCascadedDeclarations( + target: Map, + candidates: Map, +): void { + for (const declaration of candidates.values()) { + const current = target.get(declaration.property); + if ( + current === undefined || + declaration.specificity > current.specificity || + (declaration.specificity === current.specificity && + declaration.order > current.order) + ) { + target.set(declaration.property, declaration); + } + } } function uponSanitizeAttribute( @@ -198,7 +483,10 @@ function uponSanitizeAttribute( export function sanitizeHTML(html: string): string { DOMPurify.addHook("uponSanitizeAttribute", uponSanitizeAttribute); try { - return DOMPurify.sanitize(html, PURIFY_CONFIG) as string; + return DOMPurify.sanitize( + inlineSafeStylesheetDeclarations(html), + PURIFY_CONFIG, + ) as string; } finally { DOMPurify.removeHook("uponSanitizeAttribute"); } diff --git a/packages/rendering/dom/api-report.md b/packages/rendering/dom/api-report.md index 4a36be6a..7f1482ba 100644 --- a/packages/rendering/dom/api-report.md +++ b/packages/rendering/dom/api-report.md @@ -36,6 +36,7 @@ - mountEditor - registerInlineAtomInteractionRoot - registerVerticalCaretMeasure +- resolveBlockTextAlignment - resolveEditorUrl - resolveRegionRect - resolveShiftClickInlineAtomSelection @@ -94,6 +95,7 @@ - BidiRun - BidiRunGeometry - BindEditorDocumentKeyDownOptions +- BlockTextAlignment - ContentGestureRegionGesture - ContentGestureState - FieldEditorPointerActivateOptions diff --git a/packages/rendering/dom/src/__tests__/mountEditor.test.ts b/packages/rendering/dom/src/__tests__/mountEditor.test.ts index ee9b5b09..c35833c3 100644 --- a/packages/rendering/dom/src/__tests__/mountEditor.test.ts +++ b/packages/rendering/dom/src/__tests__/mountEditor.test.ts @@ -62,6 +62,30 @@ describe("mountEditor", () => { expect(editor.facet(fieldEditorHostFacet)).toBe(mounted.fieldEditor); }); + it("renders validated block text alignment", () => { + const editor = createBareEditor(); + const blockId = editor.firstBlock()!.id; + editor.apply([ + { + type: "set-props", + blockId, + props: { textAlignment: "center" }, + }, + ]); + const root = document.createElement("div"); + document.body.append(root); + const mounted = mountEditor(editor, root); + cleanups.push(() => { + mounted.destroy(); + editor.destroy(); + }); + + const block = root.querySelector( + `[${DATA_ATTRS.blockId}="${blockId}"]`, + ); + expect(block?.style.textAlign).toBe("center"); + }); + it("activates FieldEditorImpl on inline pointer down", () => { const editor = createBareEditor(); const firstBlock = editor.firstBlock(); diff --git a/packages/rendering/dom/src/host/documentTree.ts b/packages/rendering/dom/src/host/documentTree.ts index f8a05079..4ad5cdbe 100644 --- a/packages/rendering/dom/src/host/documentTree.ts +++ b/packages/rendering/dom/src/host/documentTree.ts @@ -9,6 +9,7 @@ import type { FieldEditorImpl } from "../field-editor/fieldEditorImpl"; import { fullReconcileDeltasToDOM } from "../field-editor/reconciler"; import { urlPolicyFromEditor } from "../security/resolveEditorUrl"; import { buildDataAttributes, DATA_ATTRS } from "../utils/dataAttributes"; +import { resolveBlockTextAlignment } from "../utils/blockTextAlignment"; import { getChildBlockIds, getRootBlockIds } from "../utils/parentIdTree"; export interface DocumentTree { @@ -157,6 +158,7 @@ function updateBlockNodes( nodes.element.removeAttribute("dir"); } nodes.element.style.unicodeBidi = "isolate"; + nodes.element.style.textAlign = resolveBlockTextAlignment(block) ?? ""; if (nodes.inline) { nodes.inline.style.unicodeBidi = "isolate"; // RI5: stored newlines and repeated spaces are document characters; under diff --git a/packages/rendering/dom/src/index.ts b/packages/rendering/dom/src/index.ts index d13e0a6b..eb51af3d 100644 --- a/packages/rendering/dom/src/index.ts +++ b/packages/rendering/dom/src/index.ts @@ -88,6 +88,10 @@ export { type EditorSelectAllBehavior, } from "./constants/selectAll"; export type { PasteImporters } from "./types/paste"; +export { + resolveBlockTextAlignment, + type BlockTextAlignment, +} from "./utils/blockTextAlignment"; export { urlPolicy, type UrlContext, diff --git a/packages/rendering/dom/src/utils/blockTextAlignment.ts b/packages/rendering/dom/src/utils/blockTextAlignment.ts new file mode 100644 index 00000000..3a1b3dd3 --- /dev/null +++ b/packages/rendering/dom/src/utils/blockTextAlignment.ts @@ -0,0 +1,23 @@ +import type { BlockHandle } from "@input/pen-types"; + +export type BlockTextAlignment = + | "left" + | "right" + | "center" + | "justify" + | "start" + | "end"; + +export function resolveBlockTextAlignment( + block: Pick, +): BlockTextAlignment | undefined { + const alignment = block.props.textAlignment; + return alignment === "left" || + alignment === "right" || + alignment === "center" || + alignment === "justify" || + alignment === "start" || + alignment === "end" + ? alignment + : undefined; +} diff --git a/packages/rendering/react/src/__tests__/clipboard.importerParsing.test.ts b/packages/rendering/react/src/__tests__/clipboard.importerParsing.test.ts index 42f8d39f..2d416d48 100644 --- a/packages/rendering/react/src/__tests__/clipboard.importerParsing.test.ts +++ b/packages/rendering/react/src/__tests__/clipboard.importerParsing.test.ts @@ -5,6 +5,7 @@ import { createEditor as createCoreEditor } from "@input/pen-core"; import type { AssetProvider } from "@input/pen-types"; import { defaultPreset } from "@input/pen"; import { + getPasteImporters, handleClipboardPaste, handleCopy, } from "@input/pen-dom/field-editor/clipboard"; @@ -105,6 +106,58 @@ function seedTable( } describe("@input/pen-react clipboard: importer parsing", () => { + it("preserves Apple Notes numbered and bullet lists through the paste pipeline", async () => { + const editor = createEditor(); + const emptyBlockId = editor.firstBlock()!.id; + const clipboardData = createClipboardData(); + const fieldEditor = createFieldEditorStub(); + + const html = + '

    normal, bold, italic, underline

    1. numbered
    2. bullets


    • dotted
    • Bullets
    '; + clipboardData.setData("text/html", html); + editor.selectText(emptyBlockId, 0, 0); + + handleClipboardPaste( + { clipboardData } as ClipboardEvent, + editor, + fieldEditor, + getPasteImporters(editor), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const blocks = editor.documentState.blockOrder.map((blockId) => + editor.getBlock(blockId), + ); + expect( + blocks.map((block) => ({ + type: block?.type, + text: block?.textContent(), + textAlignment: block?.props.textAlignment, + })), + ).toEqual([ + { + type: "paragraph", + text: "normal, bold, italic, underline", + textAlignment: "center", + }, + { type: "numberedListItem", text: "numbered", textAlignment: undefined }, + { type: "numberedListItem", text: "bullets", textAlignment: undefined }, + { type: "paragraph", text: "\n", textAlignment: undefined }, + { type: "bulletListItem", text: "dotted", textAlignment: undefined }, + { type: "bulletListItem", text: "Bullets", textAlignment: undefined }, + ]); + expect(blocks[0]?.textDeltas()).toEqual([ + { insert: "normal, " }, + { insert: "bold", attributes: { bold: true } }, + { insert: ", " }, + { insert: "italic", attributes: { italic: true } }, + { insert: ", " }, + { insert: "underline", attributes: { underline: true } }, + ]); + + editor.destroy(); + }); + it("keeps HTML paragraph parsing when inline marks are preserved", async () => { const editor = createEditor(); const emptyBlockId = editor.firstBlock()!.id; diff --git a/packages/rendering/react/src/__tests__/dir2Block.test.ts b/packages/rendering/react/src/__tests__/dir2Block.test.ts index 5b8d00c4..955be2bc 100644 --- a/packages/rendering/react/src/__tests__/dir2Block.test.ts +++ b/packages/rendering/react/src/__tests__/dir2Block.test.ts @@ -54,6 +54,27 @@ async function cleanup( } describe("@input/pen-react DIR2", () => { + it("renders validated block text alignment", async () => { + const editor = createDirEditor(); + const blockId = editor.firstBlock()!.id; + editor.apply([ + { + type: "set-props", + blockId, + props: { textAlignment: "center" }, + }, + ]); + + const { container, root } = await renderEditor(editor); + expect( + container.querySelector( + `[data-block-id="${blockId}"]`, + )?.style.textAlign, + ).toBe("center"); + + await cleanup(editor, root, container); + }); + it("DIR2: sets dir on the block content host from props.direction ltr or rtl", async () => { const editor = createDirEditor(); const ltrId = editor.firstBlock()!.id; diff --git a/packages/rendering/react/src/primitives/editor/block.tsx b/packages/rendering/react/src/primitives/editor/block.tsx index 85af1cd0..de746daf 100644 --- a/packages/rendering/react/src/primitives/editor/block.tsx +++ b/packages/rendering/react/src/primitives/editor/block.tsx @@ -20,6 +20,7 @@ import { buildDataAttributes, DATA_ATTRS, } from "@input/pen-dom/utils/dataAttributes"; +import { resolveBlockTextAlignment } from "@input/pen-dom"; import { useBlockDropPreview } from "./dropPreviewContext"; export interface EditorBlockProps extends AsChildProps { @@ -99,7 +100,10 @@ export function EditorBlock(props: EditorBlockProps) { [DATA_ATTRS.blockId]: blockId, [DATA_ATTRS.blockType]: blockType, dir, - style: { unicodeBidi: "isolate" }, + style: { + unicodeBidi: "isolate", + textAlign: resolveBlockTextAlignment(block), + }, "data-level": headingLevel, [DATA_ATTRS.surfaceRole]: surfaceRole ?? undefined, [DATA_ATTRS.dropPosition]: externalDropPosition, diff --git a/packages/rendering/vue/src/__tests__/mount.test.ts b/packages/rendering/vue/src/__tests__/mount.test.ts index 15cfc201..8ef3b2c1 100644 --- a/packages/rendering/vue/src/__tests__/mount.test.ts +++ b/packages/rendering/vue/src/__tests__/mount.test.ts @@ -452,6 +452,31 @@ describe("@input/pen-vue", () => { editor.destroy(); }); + it("renders validated block text alignment", () => { + const editor = createTestEditor({ + blocks: [ + { + id: "paragraph-centered", + type: "paragraph", + props: { textAlignment: "center" }, + content: "Centered", + }, + ], + }); + + const wrapper = mount(PenEditor, { + attachTo: document.body, + props: { editor }, + }); + + expect( + wrapper.get('[data-block-id="paragraph-centered"]').attributes("style"), + ).toContain("text-align: center"); + + wrapper.unmount(); + editor.destroy(); + }); + it("DIR2: sets dir on the block content host from props.direction ltr or rtl", () => { const editor = createTestEditor({ blocks: [ diff --git a/packages/rendering/vue/src/components/PenBlock.ts b/packages/rendering/vue/src/components/PenBlock.ts index 8519302a..3f6ad321 100644 --- a/packages/rendering/vue/src/components/PenBlock.ts +++ b/packages/rendering/vue/src/components/PenBlock.ts @@ -3,7 +3,10 @@ import { resolveEditorMessage, resolveSchemaA11y, } from "@input/pen-core"; -import { resolveEditorUrl } from "@input/pen-dom"; +import { + resolveBlockTextAlignment, + resolveEditorUrl, +} from "@input/pen-dom"; import { buildDataAttributes, DATA_ATTRS, @@ -145,7 +148,10 @@ export const PenBlock = defineComponent({ }), [DATA_ATTRS.surfaceRole]: surfaceRole ?? undefined, dir: resolvedContentDir(editor, block), - style: { unicodeBidi: "isolate" }, + style: { + unicodeBidi: "isolate", + textAlign: resolveBlockTextAlignment(block), + }, tabIndex: -1, contentEditable: surfaceRole != null && surfaceRole !== "editable-inline" diff --git a/packages/schema/src/__tests__/schemaDefault.test.ts b/packages/schema/src/__tests__/schemaDefault.test.ts index c1b9cf92..8096cd60 100644 --- a/packages/schema/src/__tests__/schemaDefault.test.ts +++ b/packages/schema/src/__tests__/schemaDefault.test.ts @@ -63,6 +63,33 @@ describe("AC 24 — paragraph and heading serialization", () => { expect(heading.serialize!.toMarkdown!(block)).toBe("### Title"); }); + it("text block HTML serializers preserve text alignment", () => { + expect( + paragraph.serialize!.toHTML!({ + id: "p", + type: "paragraph", + props: { textAlignment: "center" }, + content: "Centered", + }), + ).toBe('

    Centered

    '); + expect( + heading.serialize!.toHTML!({ + id: "h", + type: "heading", + props: { level: 2, textAlignment: "right" }, + content: "Right", + }), + ).toBe('

    Right

    '); + expect( + bulletListItem.serialize!.toHTML!({ + id: "li", + type: "bulletListItem", + props: { indent: 0, textAlignment: "end" }, + content: "Item", + }), + ).toBe('
  • Item
  • '); + }); + it("divider.serialize.toMarkdown returns ---", () => { const block = { id: "1", type: "divider" as const, props: {}, content: "" }; expect(divider.serialize!.toMarkdown!(block)).toBe("---"); diff --git a/packages/schema/src/blocks/blockquote.ts b/packages/schema/src/blocks/blockquote.ts index adbecd64..2ce29f2e 100644 --- a/packages/schema/src/blocks/blockquote.ts +++ b/packages/schema/src/blocks/blockquote.ts @@ -3,11 +3,16 @@ import { prop, } from "@input/pen-core"; import { directionProp } from "../directionProp"; +import { + textAlignmentProp, + textAlignmentStyle, +} from "../textAlignmentProp"; export const blockquote = defineBlock("blockquote", { props: { parentId: prop.string().optional().describe("Container parent block"), direction: directionProp, + textAlignment: textAlignmentProp, }, content: "inline", fieldEditor: "richtext", @@ -20,6 +25,9 @@ export const blockquote = defineBlock("blockquote", { }, serialize: { toMarkdown: (block) => `> ${block.content ?? ""}`, - toHTML: (block) => `
    ${block.content ?? ""}
    `, + toHTML: (block) => { + // SEC5: alignment is serialized from a closed enum; content is serialized inline HTML. + return `${block.content ?? ""}`; + }, }, }); diff --git a/packages/schema/src/blocks/bulletListItem.ts b/packages/schema/src/blocks/bulletListItem.ts index 3c93be2e..258e5246 100644 --- a/packages/schema/src/blocks/bulletListItem.ts +++ b/packages/schema/src/blocks/bulletListItem.ts @@ -3,12 +3,17 @@ import { prop, } from "@input/pen-core"; import { directionProp } from "../directionProp"; +import { + textAlignmentProp, + textAlignmentStyle, +} from "../textAlignmentProp"; export const bulletListItem = defineBlock("bulletListItem", { props: { indent: prop.number().default(0).min(0).describe("Nesting depth"), parentId: prop.string().optional().describe("Container parent block"), direction: directionProp, + textAlignment: textAlignmentProp, }, content: "inline", fieldEditor: "richtext", @@ -24,6 +29,9 @@ export const bulletListItem = defineBlock("bulletListItem", { const indent = " ".repeat((block.props.indent as number) ?? 0); return `${indent}- ${block.content ?? ""}`; }, - toHTML: (block) => `
  • ${block.content ?? ""}
  • `, + toHTML: (block) => { + // SEC5: alignment is serialized from a closed enum; content is serialized inline HTML. + return `${block.content ?? ""}`; + }, }, }); diff --git a/packages/schema/src/blocks/checkListItem.ts b/packages/schema/src/blocks/checkListItem.ts index 2ff5f837..997f2630 100644 --- a/packages/schema/src/blocks/checkListItem.ts +++ b/packages/schema/src/blocks/checkListItem.ts @@ -3,6 +3,10 @@ import { prop, } from "@input/pen-core"; import { directionProp } from "../directionProp"; +import { + textAlignmentProp, + textAlignmentStyle, +} from "../textAlignmentProp"; export const checkListItem = defineBlock("checkListItem", { props: { @@ -13,6 +17,7 @@ export const checkListItem = defineBlock("checkListItem", { .default(false) .describe("Whether the item is checked"), direction: directionProp, + textAlignment: textAlignmentProp, }, content: "inline", fieldEditor: "richtext", @@ -31,7 +36,8 @@ export const checkListItem = defineBlock("checkListItem", { }, toHTML: (block) => { const checked = block.props.checked ? " checked" : ""; - return `
  • ${block.content ?? ""}
  • `; + // SEC5: alignment and checked are closed values; content is serialized inline HTML. + return `${block.content ?? ""}`; }, }, }); diff --git a/packages/schema/src/blocks/heading.ts b/packages/schema/src/blocks/heading.ts index 219d9676..81aae1d7 100644 --- a/packages/schema/src/blocks/heading.ts +++ b/packages/schema/src/blocks/heading.ts @@ -3,11 +3,16 @@ import { prop, } from "@input/pen-core"; import { directionProp } from "../directionProp"; +import { + textAlignmentProp, + textAlignmentStyle, +} from "../textAlignmentProp"; export const heading = defineBlock("heading", { props: { level: prop.enum([1, 2, 3, 4, 5, 6]).default(1).describe("Heading level"), direction: directionProp, + textAlignment: textAlignmentProp, }, content: "inline", fieldEditor: "richtext", @@ -25,7 +30,7 @@ export const heading = defineBlock("heading", { const raw = Number(block.props.level); const level = raw >= 1 && raw <= 6 && Number.isInteger(raw) ? raw : 1; // SEC5: clamped heading level - return `${block.content ?? ""}`; + return `${block.content ?? ""}`; }, }, normalize: (block) => { diff --git a/packages/schema/src/blocks/numberedListItem.ts b/packages/schema/src/blocks/numberedListItem.ts index 7ac44697..9c6d6173 100644 --- a/packages/schema/src/blocks/numberedListItem.ts +++ b/packages/schema/src/blocks/numberedListItem.ts @@ -3,6 +3,10 @@ import { prop, } from "@input/pen-core"; import { directionProp } from "../directionProp"; +import { + textAlignmentProp, + textAlignmentStyle, +} from "../textAlignmentProp"; export const numberedListItem = defineBlock("numberedListItem", { props: { @@ -13,6 +17,7 @@ export const numberedListItem = defineBlock("numberedListItem", { .optional() .describe("Restart numbering from this value"), direction: directionProp, + textAlignment: textAlignmentProp, }, content: "inline", fieldEditor: "richtext", @@ -29,6 +34,9 @@ export const numberedListItem = defineBlock("numberedListItem", { const start = (block.props.start as number) ?? 1; return `${indent}${start}. ${block.content ?? ""}`; }, - toHTML: (block) => `
  • ${block.content ?? ""}
  • `, + toHTML: (block) => { + // SEC5: alignment is serialized from a closed enum; content is serialized inline HTML. + return `${block.content ?? ""}`; + }, }, }); diff --git a/packages/schema/src/blocks/paragraph.ts b/packages/schema/src/blocks/paragraph.ts index 5cb25ac9..e335a22d 100644 --- a/packages/schema/src/blocks/paragraph.ts +++ b/packages/schema/src/blocks/paragraph.ts @@ -1,9 +1,14 @@ import { defineBlock } from "@input/pen-core"; import { directionProp } from "../directionProp"; +import { + textAlignmentProp, + textAlignmentStyle, +} from "../textAlignmentProp"; export const paragraph = defineBlock("paragraph", { props: { direction: directionProp, + textAlignment: textAlignmentProp, }, content: "inline", fieldEditor: "richtext", @@ -16,6 +21,9 @@ export const paragraph = defineBlock("paragraph", { }, serialize: { toMarkdown: (block) => block.content ?? "", - toHTML: (block) => `

    ${block.content ?? ""}

    `, + toHTML: (block) => { + // SEC5: alignment is serialized from a closed enum; content is serialized inline HTML. + return `${block.content ?? ""}

    `; + }, }, }); diff --git a/packages/schema/src/textAlignmentProp.ts b/packages/schema/src/textAlignmentProp.ts new file mode 100644 index 00000000..422a874b --- /dev/null +++ b/packages/schema/src/textAlignmentProp.ts @@ -0,0 +1,40 @@ +import { + prop, + resolveSchema, +} from "@input/pen-core"; + +const TEXT_ALIGNMENTS = [ + "left", + "right", + "center", + "justify", + "start", + "end", +] as const; + +export const textAlignmentProp = resolveSchema( + prop + .enum(TEXT_ALIGNMENTS) + .optional() + .default(undefined) + .describe("Block text alignment"), +); + +export function textAlignmentStyle(value: unknown): string { + switch (value) { + case "left": + return ' style="text-align: left"'; + case "right": + return ' style="text-align: right"'; + case "center": + return ' style="text-align: center"'; + case "justify": + return ' style="text-align: justify"'; + case "start": + return ' style="text-align: start"'; + case "end": + return ' style="text-align: end"'; + default: + return ""; + } +} diff --git a/spec/packages/extensions/interop.md b/spec/packages/extensions/interop.md index 91dbc9c9..8e758c76 100644 --- a/spec/packages/extensions/interop.md +++ b/spec/packages/extensions/interop.md @@ -87,7 +87,7 @@ flowchart TD - `capRawHtmlSource()` slices the raw string to `INGEST_MAX_TEXT_SIZE` (preferring a newline boundary) before sanitize/parse. Overflow is a `text-size-exceeded` drop, not a hard refuse. `parseHtmlSource()` throws if a caller bypasses the cap and hands it a longer string. - `admitProviderImageUrl()` decides on the parsed URL protocol (`new URL(...)`), not a regex over the raw string. Local provider schemes (`blob:`, `memory:`) pass; everything else goes through `urlPolicy`. -- Sanitize after the cap, then parse, then normalize. Imported content only becomes document state after conversion into operations and `editor.apply(...)`. The SEC3 style hook admits `color`, `background-color`, and enumerated `text-align` keywords (`left` / `right` / `center` / `justify` / `start` / `end`), plus a validated HTML `align` attribute (`left` / `right` / `center` / `justify`). Mapping those onto block props stays a host `fromHTML` concern. +- Sanitize after the cap, then parse, then normalize. Imported content only becomes document state after conversion into operations and `editor.apply(...)`. Before the final DOMPurify pass removes `

    Imported bold underlined link

    • bullet
    ', ); await s.assert.textContains("Imported"); await s.assert.textContains("bold"); + await s.assert.textContains("underlined"); await s.assert.textContains("link"); + const snapshot = await page.evaluate(() => + window.__penConformance.documentSnapshot(), + ); + const underlined = snapshot.blocks + .flatMap((block) => block.deltas) + .find((delta) => delta.insert === "underlined"); + expect(underlined?.attributes).toMatchObject({ underline: true }); + expect(snapshot.blocks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "bulletListItem", + text: "bullet", + }), + ]), + ); await s.pasteHtml("

    Pasted heading text

    "); await s.assert.textContains("Pasted heading text"); diff --git a/spec/rules/security.md b/spec/rules/security.md index b2a51166..8efd82f1 100644 --- a/spec/rules/security.md +++ b/spec/rules/security.md @@ -6,7 +6,7 @@ The `SEC` family is the complete security posture for the workspace: nothing sec - SEC1. One module owns URL admission for everything the library renders: `urlPolicy` in `@input/pen-core` (`packages/core/src/security/urlPolicy.ts`), re-exported by `@input/pen-dom` so renderer hosts keep their import path, with the shape `resolve(rawValue: unknown, context: "link" | "image" | "media" | "download") => string | null` where `null` means inert. The default policy admits `http:`, `https:`, `mailto:`, and `tel:` plus relative and protocol-relative URLs, admits `data:image/(png|jpeg|gif|webp|avif)` in `image` context only, and returns `null` for everything else including `javascript:`, `vbscript:`, `file:`, `data:text/html`, unparsable values, and non-string values; admission parses with `new URL(value, "https://pen.invalid/")` and compares the parsed protocol, never a regex over the raw string. A `null` resolution renders the element without its URL-bearing attribute plus `data-pen-blocked-url=""`, and the raw URL is never echoed into the DOM, logs, or diagnostics beyond its scheme; every URL sink in library rendering and export goes through the policy, including `htmlExporter`'s built-in image serializer and every `` in a schema `toHTML` result (a host override does not skip admission), and hosts customize it through the single-value `pen.urlPolicy` facet, which receives the default policy to delegate to, so loosening to arbitrary schemes is possible but explicit. - SEC2. No library code turns a string into markup. The `no-html-injection-sinks` lint runs unscoped across every linted file in the workspace and reports assignment to `innerHTML` or `outerHTML`, any use of `insertAdjacentHTML` or `Range.createContextualFragment`, `document.write` and `document.writeln`, and the `dangerouslySetInnerHTML` JSX attribute; reading `innerHTML` stays legal because assignment is the sink, and all rendering builds DOM through `createElement`, `textContent`, and attribute setters. Parsing untrusted HTML into a detached document with `DOMParser.parseFromString` is permitted because parsing is not injection, but the parsed tree's text and attributes only ever enter the document as data and are never re-serialized into live DOM; model output is text by construction, flowing through `TextStreamWriter.append` or ops, so no path exists by which it becomes markup. Two helpers apply a decoration's attribute bag to an element and both carry the discipline: `applyElementAttributes` in `@input/pen-dom` writes inline decoration attributes, and `mergeBlockDecorationAttributes` in `@input/pen-react` (`primitives/editor/block.tsx`) turns block decoration attributes into props on the block host. Both skip every key matching `/^on/i` and every key that lowercases to `style`, because event handlers and stylesheets are not data. Their skip sets are deliberately not identical: a React prop name reaches sinks a DOM attribute name cannot, so the React helper also skips `dangerouslySetInnerHTML`, which `setAttribute` renders inert and a React prop does not. The URL-policy clause — `href`, `src`, and `xlink:href` admitted only through the policy, writing `data-pen-blocked-url=""` when admission fails — belongs to the DOM helper, whose callers render URL-bearing elements. The block helper omits it because no producer emits a URL-bearing key on a block decoration and the default host element is a `
    `; `asChild` lets a host supply a URL-bearing element instead, so the omission rests on the absent producer rather than on the tag, and gaining such a producer means gaining the clause. The discipline lives in the helpers rather than in each caller's judgment, so a decoration that carries presentation in an inline `style` attribute has no effect and class hooks are the working contract for styling decorations. The rule names both helpers because naming one taught readers there was one: `@input/pen-multiplayer` emitted a `style` attribute on remote presence decorations for the colour, correctly inert on the DOM path and a thrown React `setValueForStyles` error on the block path that unmounted the editor's root the moment a peer selected a block. On a React prop this rule is a crash guard, not only a policy, and `no-html-injection-sinks` cannot stand in for it: that lint reads literal JSX attribute names and a key arriving through a spread is invisible to it. -- SEC3. HTML sanitization has exactly one owner, the `html` surface of `@input/pen-interop` (`packages/extensions/interop/src/html/import/sanitize.ts`), built on DOMPurify with an explicit tag and attribute allowlist, and no regex ever rewrites sanitized markup. Clipboard producers may express formatting in stylesheet classes, so before the final DOMPurify pass removes every `

    underlined

    ', + ); + expect(result).toContain( + 'class="underlined" style="text-decoration: underline"', + ); + }); + it("SEC3: drops commented stylesheet values", () => { const result = sanitizeHTML( '

    plain

    ', diff --git a/packages/extensions/interop/src/html/import/sanitize.ts b/packages/extensions/interop/src/html/import/sanitize.ts index 37085254..e841f55a 100644 --- a/packages/extensions/interop/src/html/import/sanitize.ts +++ b/packages/extensions/interop/src/html/import/sanitize.ts @@ -278,6 +278,14 @@ function parseSafeStylesheetRules(css: string): SafeStylesheetRule[] { for (let index = 0; index < css.length; index += 1) { const character = css[index]; + if ( + character === ";" && + depth === 0 && + css.slice(selectorStart, index).trimStart().startsWith("@") + ) { + selectorStart = index + 1; + continue; + } if (character === "{") { if (depth === 0) { bodyStart = index + 1; From b4710463042bc5df517c0211acac234d5afbbfeb Mon Sep 17 00:00:00 2001 From: janglad Date: Thu, 24 Sep 2026 15:16:16 +0200 Subject: [PATCH 4/5] Enhance HTML paste handling to preserve blank-line spacing and breaks This commit improves the handling of pasted HTML by ensuring that blank-line spacing and breaks are preserved in the resulting content structure. Key changes include updates to the inline content parsing logic and the addition of tests to verify the correct preservation of breaks and empty paragraphs during HTML imports. These enhancements aim to provide a more accurate representation of the original formatting when pasting content. --- .../web-4052-preserve-pasted-formatting.md | 2 +- .../importHtml.elementMapping.test.ts | 37 +++++++++++++++---- .../pasteCorpus/apple-notes/expectation.json | 8 ++-- .../interop/src/html/import/inlineParser.ts | 10 +++++ spec/rules/empty-blocks.md | 2 +- 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/.changeset/web-4052-preserve-pasted-formatting.md b/.changeset/web-4052-preserve-pasted-formatting.md index 69108652..79471254 100644 --- a/.changeset/web-4052-preserve-pasted-formatting.md +++ b/.changeset/web-4052-preserve-pasted-formatting.md @@ -6,4 +6,4 @@ "@input/pen-vue": patch --- -Preserve HTML block structure, inline marks, lists, and text alignment when pasting formatted HTML. +Preserve HTML block structure, blank-line spacing, inline marks, lists, and text alignment when pasting formatted HTML. diff --git a/packages/extensions/interop/src/html/import/__tests__/importHtml.elementMapping.test.ts b/packages/extensions/interop/src/html/import/__tests__/importHtml.elementMapping.test.ts index 5ab5b1e0..42c1f2e3 100644 --- a/packages/extensions/interop/src/html/import/__tests__/importHtml.elementMapping.test.ts +++ b/packages/extensions/interop/src/html/import/__tests__/importHtml.elementMapping.test.ts @@ -38,6 +38,26 @@ describe("@input/pen-interop/html dom-to-blocks: element mapping", () => { }); }); + it("IOP2 imports a placeholder break as one empty paragraph", () => { + const blocks = convert( + "

    hello there


    this is a test

    ", + ); + + expect(blocks).toMatchObject([ + { type: "paragraph", content: "hello there" }, + { type: "paragraph", content: "" }, + { type: "paragraph", content: "this is a test" }, + ]); + }); + + it("IOP2 preserves a break between inline text", () => { + const blocks = convert("

    hello
    there

    "); + + expect(blocks).toMatchObject([ + { type: "paragraph", content: "hello\nthere" }, + ]); + }); + it("script tag is stripped (AC 29)", () => { const blocks = convert('

    safe

    '); @@ -50,9 +70,7 @@ describe("@input/pen-interop/html dom-to-blocks: element mapping", () => { const blocks = convert('
    text
    '); expect(blocks.length).toBeGreaterThanOrEqual(1); - const hasText = blocks.some( - (b) => b.content?.includes("text"), - ); + const hasText = blocks.some((b) => b.content?.includes("text")); expect(hasText).toBe(true); }); @@ -161,9 +179,7 @@ describe("@input/pen-interop/html dom-to-blocks: element mapping", () => { }); it("nested list with indent (AC 37)", () => { - const blocks = convert( - "
    • a
      • b
    ", - ); + const blocks = convert("
    • a
      • b
    "); expect(blocks).toHaveLength(2); expect(blocks[0]).toMatchObject({ @@ -257,7 +273,12 @@ describe("@input/pen-interop/html dom-to-blocks: element mapping", () => { const dom = parseHTML("bold at root"); const blocks = domToBlocks(dom, stubRegistry); - expect(blocks.some((b) => b.type === "paragraph" && b.content?.includes("bold at root"))).toBe(true); + expect( + blocks.some( + (b) => + b.type === "paragraph" && + b.content?.includes("bold at root"), + ), + ).toBe(true); }); - }); diff --git a/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/expectation.json b/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/expectation.json index 0bc4636d..bcd44938 100644 --- a/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/expectation.json +++ b/packages/extensions/interop/src/html/import/__tests__/pasteCorpus/apple-notes/expectation.json @@ -25,8 +25,8 @@ "font family, size, margins, minimum heights, and list marker CSS are discarded" ], "blocks": [ - { "type": "paragraph", "text": "\n" }, - { "type": "paragraph", "text": "\n" }, + { "type": "paragraph", "text": "" }, + { "type": "paragraph", "text": "" }, { "type": "paragraph", "text": "normal, bold, italic, underline", @@ -36,10 +36,10 @@ { "type": "underline", "text": "underline" } ] }, - { "type": "paragraph", "text": "\n" }, + { "type": "paragraph", "text": "" }, { "type": "numberedListItem", "indent": 0, "text": "numbered" }, { "type": "numberedListItem", "indent": 0, "text": "bullets" }, - { "type": "paragraph", "text": "\n" }, + { "type": "paragraph", "text": "" }, { "type": "bulletListItem", "indent": 0, "text": "dotted" }, { "type": "bulletListItem", "indent": 0, "text": "Bullets" } ] diff --git a/packages/extensions/interop/src/html/import/inlineParser.ts b/packages/extensions/interop/src/html/import/inlineParser.ts index 0bbe9ee2..83ec31eb 100644 --- a/packages/extensions/interop/src/html/import/inlineParser.ts +++ b/packages/extensions/interop/src/html/import/inlineParser.ts @@ -32,9 +32,19 @@ const INLINE_MARK_MAP: Record = { export function parseInlineContent(node: DOMNode): InlineResult { const result: InlineResult = { text: "", marks: [] }; walkInline(node, result, new Map()); + if (result.text === "\n" && containsBreak(node)) { + return { text: "", marks: [] }; + } return result; } +function containsBreak(node: DOMNode): boolean { + return ( + node.tagName === "br" || + (node.children ?? []).some((child) => containsBreak(child)) + ); +} + function walkInline( node: DOMNode, result: InlineResult, diff --git a/spec/rules/empty-blocks.md b/spec/rules/empty-blocks.md index 5483a2d0..f2595f59 100644 --- a/spec/rules/empty-blocks.md +++ b/spec/rules/empty-blocks.md @@ -10,7 +10,7 @@ An empty block stores the empty string. `\u200B` has no reserved meaning anywher - EM5. Seam deletions: the summary builder's sentinel insert/remove cancellation seam is deleted with nothing replacing it, because with no sentinel there is nothing to cancel. Since stored length equals logical length, the remaining DOM offset helpers are identity clamps; do not reintroduce a logical-to-DOM translation seam to hold them. - EM6. Caret and geometry: `caretRect` for offset 0 of an empty block measures the placeholder's line box, so the overlay caret and native caret placement agree. `pointAt` inside an empty block resolves to offset 0, and the projector projects a collapsed selection there onto the position before the placeholder. - EM7. Typing into empty: the first `splice-text` into an empty block inserts at offset 0 of `""`, and reconciliation replaces the placeholder with the text node in the same flush's write phase; deleting the last character restores the placeholder in the same flush. There is no intermediate commit, no sentinel round-trip, and no special-cased backend path — both input backends see a genuinely empty text buffer. -- EM8. Corpus: the durability corpus carries a stamp-2 fixture that must load to stamp 3 with the expected strip counts. The interop corpus asserts that no export, clipboard payload, or selected-text result contains a lone-sentinel empty block or cell. It does not ban the character: a user-typed or pasted `\u200B` inside longer text is preserved, and the escape stays hostile input for the security and AI-boundary rules. +- EM8. Corpus: the durability corpus carries a stamp-2 fixture that must load to stamp 3 with the expected strip counts. The interop corpus asserts that no export, clipboard payload, or selected-text result contains a lone-sentinel empty block or cell. HTML import treats a block's sole `
    ` as its empty placeholder and stores `""`; a `
    ` between inline content remains a stored `"\n"`. It does not ban the character: a user-typed or pasted `\u200B` inside longer text is preserved, and the escape stays hostile input for the security and AI-boundary rules. ## Retired From 94f8e9473bc0d94a5b08c0c98ff302423591ac63 Mon Sep 17 00:00:00 2001 From: janglad Date: Thu, 24 Sep 2026 15:28:19 +0200 Subject: [PATCH 5/5] Refactor clipboard importer parsing test for improved readability This commit enhances the formatting of the clipboard importer parsing test by restructuring the test data for better clarity. The changes include consistent indentation and formatting of the test cases, ensuring that the structure is more readable and maintainable. This refactor aims to improve the overall quality of the test suite. --- .../clipboard.importerParsing.test.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/rendering/react/src/__tests__/clipboard.importerParsing.test.ts b/packages/rendering/react/src/__tests__/clipboard.importerParsing.test.ts index 2d416d48..4733f5d5 100644 --- a/packages/rendering/react/src/__tests__/clipboard.importerParsing.test.ts +++ b/packages/rendering/react/src/__tests__/clipboard.importerParsing.test.ts @@ -140,11 +140,27 @@ describe("@input/pen-react clipboard: importer parsing", () => { text: "normal, bold, italic, underline", textAlignment: "center", }, - { type: "numberedListItem", text: "numbered", textAlignment: undefined }, - { type: "numberedListItem", text: "bullets", textAlignment: undefined }, - { type: "paragraph", text: "\n", textAlignment: undefined }, - { type: "bulletListItem", text: "dotted", textAlignment: undefined }, - { type: "bulletListItem", text: "Bullets", textAlignment: undefined }, + { + type: "numberedListItem", + text: "numbered", + textAlignment: undefined, + }, + { + type: "numberedListItem", + text: "bullets", + textAlignment: undefined, + }, + { type: "paragraph", text: "", textAlignment: undefined }, + { + type: "bulletListItem", + text: "dotted", + textAlignment: undefined, + }, + { + type: "bulletListItem", + text: "Bullets", + textAlignment: undefined, + }, ]); expect(blocks[0]?.textDeltas()).toEqual([ { insert: "normal, " },