diff --git a/.changeset/web-4052-preserve-pasted-formatting.md b/.changeset/web-4052-preserve-pasted-formatting.md new file mode 100644 index 00000000..79471254 --- /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, blank-line spacing, inline marks, lists, and text alignment when pasting formatted HTML. diff --git a/.size-limit.baseline.json b/.size-limit.baseline.json index 8ebcd639..7799a064 100644 --- a/.size-limit.baseline.json +++ b/.size-limit.baseline.json @@ -89,9 +89,9 @@ { "name": "@input/pen-interop", "path": "packages/extensions/interop/dist/*.mjs", - "baselineBytes": 104761, - "limitBytes": 104761, - "note": "First baseline 2026-08-24 (SF2). Budgeted as the whole shipped ESM closure, not dist/index.mjs: the four format subpaths code-split into content-hashed chunks, so the root entry is 1722 B of re-exports while the chunks carry 100335 B \u2014 an index.mjs budget would guard 1.6% of the package. Replaces seven budgets totalling 100361 B (30302 import-html + 16369 export-xml + 15551 export-json + 14228 import-json + 10107 export-html + 9197 import-markdown + 4607 export-markdown). 100361 \u2192 104761, +4.4% for per-format barrel wiring. Byte total reproduced across three rebuilds." + "baselineBytes": 118975, + "limitBytes": 118975, + "note": "First baseline 2026-08-24 (SF2). Budgeted as the whole shipped ESM closure, not dist/index.mjs: the four format subpaths code-split into content-hashed chunks, so the root entry is 1722 B of re-exports while the chunks carry 100335 B \u2014 an index.mjs budget would guard 1.6% of the package. Replaces seven budgets totalling 100361 B (30302 import-html + 16369 export-xml + 15551 export-json + 14228 import-json + 10107 export-html + 9197 import-markdown + 4607 export-markdown). 100361 \u2192 104761, +4.4% for per-format barrel wiring. Re-recorded 2026-09-24 for WEB-4052: 104761 \u2192 118831, +14070 B (+13.4%) for preserving safe stylesheet class declarations, inline marks, lists, and text alignment on HTML paste; 118831 \u2192 118975, +144 B to preserve the first safe rule after braceless at-rules. Byte total reproduced from a clean @input/pen-interop build." }, { "name": "@input/pen-snapshots", 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

'); @@ -111,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({ @@ -207,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__/importHtml.test.ts b/packages/extensions/interop/src/html/import/__tests__/importHtml.test.ts index 644ab529..a55c0593 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,52 @@ 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: keeps the first rule after braceless at-rules", () => { + const result = sanitizeHTML( + '

underlined

', + ); + 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 +102,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 +266,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..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 @@ -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": "" }, + { "type": "paragraph", "text": "" }, { "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": "" }, + { "type": "numberedListItem", "indent": 0, "text": "numbered" }, + { "type": "numberedListItem", "indent": 0, "text": "bullets" }, + { "type": "paragraph", "text": "" }, + { "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..f742bd16 100644 --- a/packages/extensions/interop/src/html/import/domAdapter.ts +++ b/packages/extensions/interop/src/html/import/domAdapter.ts @@ -10,9 +10,15 @@ export interface DOMNode { } export function parseHTML(html: string): DOMNode { - if (typeof globalThis.DOMParser !== "undefined") { + if ( + typeof Object.hasOwn !== "function" && + typeof globalThis.DOMParser !== "undefined" + ) { const doc = new globalThis.DOMParser().parseFromString(html, "text/html"); - return domNodeToDOMNode(doc.body); + return { + type: "root", + children: Array.from(doc.body.childNodes).map(domNodeToDOMNode), + }; } const doc = parseDocument(html); 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..83ec31eb 100644 --- a/packages/extensions/interop/src/html/import/inlineParser.ts +++ b/packages/extensions/interop/src/html/import/inlineParser.ts @@ -1,102 +1,203 @@ 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()); + 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, + 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..e841f55a 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,40 +165,350 @@ 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 === ";" && + depth === 0 && + css.slice(selectorStart, index).trimStart().startsWith("@") + ) { + selectorStart = index + 1; + continue; + } + 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; + } + if ( + typeof Object.hasOwn !== "function" && + typeof globalThis.DOMParser !== "undefined" + ) { + return inlineBrowserStylesheetDeclarations(html); + } + + const document = parseDocument(html); + const rules = collectSafeStylesheetRules(document); + if (rules.length === 0) { + return html; + } + const rulesByClass = indexSafeStylesheetRules(rules); + + function visit(node: Document | ChildNode): void { + if (node.type === "tag") { + const element = node as Element; + if (element.attribs.class) { + const style = resolveStylesheetDeclarations( + element.name, + element.attribs.class, + element.attribs.style ?? "", + rulesByClass, + ); + if (style !== undefined) { + element.attribs.style = style; + } } - 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 ("children" in node) { + for (const child of node.children) { + visit(child); } - const admitted = admitStylePropertyValue(property, propertyValue); - if (admitted === null) { - return null; + } + } + + visit(document); + return DomUtils.getOuterHTML(document); +} + +function inlineBrowserStylesheetDeclarations(html: string): string { + const document = new globalThis.DOMParser().parseFromString( + html, + "text/html", + ); + const rules = [...document.querySelectorAll("style")].flatMap((element) => + parseSafeStylesheetRules(element.textContent ?? ""), + ); + if (rules.length === 0) { + return html; + } + const rulesByClass = indexSafeStylesheetRules(rules); + for (const element of document.body.querySelectorAll("[class]")) { + const style = resolveStylesheetDeclarations( + element.localName, + element.getAttribute("class") ?? "", + element.getAttribute("style") ?? "", + rulesByClass, + ); + if (style !== undefined) { + element.setAttribute("style", style); + } + } + return document.body.innerHTML; +} + +function indexSafeStylesheetRules( + rules: SafeStylesheetRule[], +): Map { + const rulesByClass = new Map(); + for (const [order, rule] of rules.entries()) { + 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, + }); + } + if (rule.tagName) { + classRules.tagDeclarations.set(rule.tagName, declarations); + } + rulesByClass.set(rule.className, classRules); + } + return rulesByClass; +} + +function resolveStylesheetDeclarations( + tagName: string, + classValue: string, + inlineStyle: string, + rulesByClass: Map, +): string | undefined { + const declarations = new Map(); + for (const className of new Set(classValue.split(/\s+/).filter(Boolean))) { + const classRules = rulesByClass.get(className); + if (!classRules) { + continue; + } + for (const candidates of [ + classRules.classDeclarations, + classRules.tagDeclarations.get(tagName), + ]) { + if (candidates) { + mergeCascadedDeclarations(declarations, candidates); } - return `${property}: ${admitted}`; - }) - .filter((declaration): declaration is string => declaration !== null) + } + } + if (declarations.size === 0) { + return undefined; + } + for (const declaration of parseSafeStyleDeclarations(inlineStyle)) { + declarations.set(declaration.property, { + ...declaration, + specificity: Number.POSITIVE_INFINITY, + order: Number.POSITIVE_INFINITY, + }); + } + return [...declarations.values()] + .map(({ property, value }) => `${property}: ${value}`) .join("; "); } +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( _node: Node, data: SanitizeAttributeHookEvent, @@ -198,7 +541,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..b1f80b6e --- /dev/null +++ b/packages/rendering/dom/src/utils/blockTextAlignment.ts @@ -0,0 +1,30 @@ +import type { BlockHandle } from "@input/pen-types"; + +/** Text alignment values supported by Pen's block renderers. */ +export type BlockTextAlignment = + | "left" + | "right" + | "center" + | "justify" + | "start" + | "end"; + +/** + * Returns a block's text alignment when it is a supported value. + * + * @param block - Block whose `textAlignment` prop should be resolved. + * @returns The supported alignment, or `undefined` for an absent or invalid prop. + */ +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..4733f5d5 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,74 @@ 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: "", 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/packages/tooling/conformance/scenarios/host4-degradation.spec.ts b/packages/tooling/conformance/scenarios/host4-degradation.spec.ts index 9b77c02f..622bb6f0 100644 --- a/packages/tooling/conformance/scenarios/host4-degradation.spec.ts +++ b/packages/tooling/conformance/scenarios/host4-degradation.spec.ts @@ -329,11 +329,27 @@ scenario( await s.load("hello-world"); await s.importHtml( - '

    Imported bold link

    ', + '

    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/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 `