diff --git a/.changeset/editor-rich-paste.md b/.changeset/editor-rich-paste.md new file mode 100644 index 0000000..4ba0b7a --- /dev/null +++ b/.changeset/editor-rich-paste.md @@ -0,0 +1,8 @@ +--- +"@office-kit/docx-editor": patch +--- + +Formatted paste and pending formatting: + +- New `insertFragmentCommand` and `parseClipboardHtml`: pasting HTML (from Word, a web page, Google Docs or the editor itself) keeps bold, italic, underline, strikethrough, superscript / subscript, text color, headings, bullet and numbered lists with their levels, line breaks, tabs and tables. As in Word, the first pasted paragraph continues the caret's paragraph and a pasted table goes between its halves; inside a table cell a pasted table becomes paragraphs. +- New `releasePendingFormat`: formatting set at a caret (Bold with no selection, say) and left without typing is dropped once the caret moves on, as in Word, instead of leaving an empty run behind. diff --git a/packages/editor/src/clipboard-html.test.ts b/packages/editor/src/clipboard-html.test.ts new file mode 100644 index 0000000..3b11782 --- /dev/null +++ b/packages/editor/src/clipboard-html.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom +/** + * Clipboard HTML → pasted blocks, on the HTML that Word, Google Docs, web + * pages and this editor's canvas put on the clipboard; and pasting those + * blocks into a document. + */ + +import { + createDocx, + getParagraphNumbering, + getParagraphStyle, + getRunFormat, + openDocx, + paragraphs, + paragraphText, + toUint8Array, + validate, + type WmlParagraph, + type WmlRun, +} from "@office-kit/docx"; +import { describe, expect, it } from "vitest"; +import { commands, editorFor, type EditorModel, parseClipboardHtml, runCommand } from "./index.js"; + +function runsOf(p: WmlParagraph): Array<[string, string]> { + return p.children + .filter((c): c is WmlRun => c.kind === "run") + .map((r) => { + const f = getRunFormat(r); + const flags = `${f.bold ? "B" : ""}${f.italic ? "I" : ""}${f.underline ? "U" : ""}`; + return [ + r.pieces + .map((p) => (p.kind === "text" ? p.value : p.kind === "break" ? "\n" : "")) + .join(""), + flags, + ]; + }); +} + +function caretModel(texts: string[], block: number, offset: number): EditorModel { + const model = editorFor(createDocx({ paragraphs: texts })); + const pos = { block, inline: 0, offset }; + model.setSelection({ anchor: pos, focus: pos }); + return model; +} + +function paste(model: EditorModel, html: string): void { + runCommand(model, commands.insertFragmentCommand, { blocks: parseClipboardHtml(html) }); +} + +describe("parseClipboardHtml", () => { + it("reads inline formatting from tags and styles", () => { + expect( + parseClipboardHtml( + '

a bold red u2

', + ), + ).toEqual([ + { + kind: "paragraph", + runs: [ + { text: "a ", format: {} }, + { text: "bold", format: { bold: true } }, + { text: " ", format: {} }, + { text: "red", format: { italic: true, color: "CC0000" } }, + { text: " ", format: {} }, + { text: "u", format: { underline: true } }, + { text: "2", format: { verticalAlign: "superscript" } }, + ], + }, + ]); + }); + + it("lets Google Docs' font-weight:normal wrapper cancel ", () => { + const [p] = parseClipboardHtml( + '

plain bold

', + ); + expect(p).toEqual({ + kind: "paragraph", + runs: [ + { text: "plain ", format: { bold: false } }, + { text: "bold", format: { bold: true } }, + ], + }); + }); + + it("reads headings, nested lists, line breaks and collapses whitespace", () => { + const blocks = parseClipboardHtml(` +

Title

+ +

spaced + out

`); + expect(blocks).toEqual([ + { kind: "paragraph", heading: 2, runs: [{ text: "Title", format: {} }] }, + { + kind: "paragraph", + list: { kind: "bullet", level: 0 }, + runs: [{ text: "one\nmore", format: {} }], + }, + { + kind: "paragraph", + list: { kind: "bullet", level: 0 }, + runs: [{ text: "two", format: {} }], + }, + { + kind: "paragraph", + list: { kind: "numbered", level: 1 }, + runs: [{ text: "deep", format: {} }], + }, + { kind: "paragraph", runs: [{ text: "spaced out", format: {} }] }, + ]); + }); + + it("reads Word's list paragraphs and drops their typed markers", () => { + const blocks = parseClipboardHtml( + `

1.  First

+

o Sub

`, + ); + expect(blocks).toEqual([ + { + kind: "paragraph", + list: { kind: "numbered", level: 0 }, + runs: [{ text: "First", format: {} }], + }, + { + kind: "paragraph", + list: { kind: "bullet", level: 1 }, + runs: [{ text: "Sub", format: {} }], + }, + ]); + }); + + it("reads tables cell by cell", () => { + const [table] = parseClipboardHtml( + "
h

a

b

c
", + ); + expect(table).toEqual({ + kind: "table", + rows: [ + [ + [{ kind: "paragraph", runs: [{ text: "h", format: { bold: true } }] }], + [ + { kind: "paragraph", runs: [{ text: "a", format: {} }] }, + { kind: "paragraph", runs: [{ text: "b", format: {} }] }, + ], + ], + [[{ kind: "paragraph", runs: [{ text: "c", format: {} }] }]], + ], + }); + }); + + it("reads this editor's own copy: no list label, tabs kept", () => { + const blocks = parseClipboardHtml( + '

•\ta\tb​

', + ); + expect(blocks).toEqual([ + { + kind: "paragraph", + list: { kind: "bullet", level: 0 }, + runs: [{ text: "a\tb", format: {} }], + }, + ]); + }); +}); + +describe("insertFragmentCommand", () => { + it("pastes formatted text inside a paragraph as one undo step", () => { + const model = caretModel(["Hello world"], 0, 6); + paste(model, "big "); + expect(runsOf(paragraphs(model.doc)[0]!)).toEqual([ + ["Hello ", ""], + ["big", "B"], + [" ", ""], + ["world", ""], + ]); + expect(model.selection?.focus).toMatchObject({ inline: 2, offset: 1 }); + model.undo(); + expect(paragraphText(paragraphs(model.doc)[0]!)).toBe("Hello world"); + }); + + it("splits the caret paragraph around several pasted paragraphs (Word)", () => { + const model = caretModel(["xyz"], 0, 2); + paste(model, "

A

B

"); + const ps = paragraphs(model.doc); + expect(ps.map(paragraphText)).toEqual(["xyA", "B", "C", "Dz"]); + expect(getParagraphStyle(ps[1]!)).toBe("Heading1"); + const lists = ps.slice(2).map((p) => getParagraphNumbering(p)?.numId); + expect(lists[0]).toBeDefined(); + expect(lists[1]).toBe(lists[0]); + expect(validate(openDocx(toUint8Array(model.doc)))).toHaveLength(0); + }); + + it("gives the first of several pasted paragraphs their list too, leaving no empty runs", () => { + const model = caretModel(["x", ""], 1, 0); + paste(model, "
  1. one
  2. two
"); + const [, first, second] = paragraphs(model.doc); + const numIds = [first, second].map((p) => getParagraphNumbering(p!)?.numId); + expect(numIds[0]).toBeDefined(); + expect(numIds[1]).toBe(numIds[0]); + expect([runsOf(first!), runsOf(second!)]).toEqual([[["one", ""]], [["two", ""]]]); + }); + + it("keeps the destination's paragraph formatting for text pasted within a paragraph", () => { + const model = caretModel(["x"], 0, 1); + paste(model, "

big

"); + expect(getParagraphStyle(paragraphs(model.doc)[0]!)).toBeUndefined(); + expect(paragraphText(paragraphs(model.doc)[0]!)).toBe("xbig"); + }); + + it("puts a pasted table between the halves of the paragraph", () => { + const model = caretModel(["before", "after"], 0, 3); + paste(model, "
ab
"); + const kinds = model.doc.document.body.blocks.map((b) => b.kind); + expect(kinds).toEqual(["paragraph", "table", "paragraph", "paragraph"]); + expect(paragraphs(model.doc).map(paragraphText)).toEqual(["bef", "ore", "after"]); + const table = model.doc.document.body.blocks[1]; + if (table?.kind !== "table") throw new Error("expected a table"); + expect(runsOf(table.rows[0]!.cells[0]!.paragraphs[0]!)).toEqual([["a", "I"]]); + expect(model.selection?.focus).toMatchObject({ block: 2, offset: 0 }); + const reopened = openDocx(toUint8Array(model.doc)); + expect(validate(reopened)).toHaveLength(0); + expect(reopened.document.body.blocks.map((b) => b.kind)).toEqual(kinds); + }); + + it("puts a table pasted at a paragraph start before it, with no empty paragraph", () => { + const model = caretModel(["text"], 0, 0); + paste(model, "
a
"); + expect(model.doc.document.body.blocks.map((b) => b.kind)).toEqual(["table", "paragraph"]); + expect(paragraphs(model.doc).map(paragraphText)).toEqual(["text"]); + }); + + it("pastes a table's cells as paragraphs inside a table cell", () => { + const model = caretModel(["x"], 0, 1); + runCommand(model, commands.insertTableCommand, { rows: 1, cols: 1 }); + paste(model, "
ab
"); + const table = model.doc.document.body.blocks[1]; + if (table?.kind !== "table") throw new Error("expected a table"); + expect(table.rows[0]!.cells[0]!.paragraphs.map(paragraphText)).toEqual(["a", "b"]); + }); +}); diff --git a/packages/editor/src/clipboard-html.ts b/packages/editor/src/clipboard-html.ts new file mode 100644 index 0000000..cfa43b7 --- /dev/null +++ b/packages/editor/src/clipboard-html.ts @@ -0,0 +1,340 @@ +/** + * Read clipboard HTML — from Word, a web page, Google Docs, or this editor's + * own canvas — into {@link PastedBlock}s for `insertFragmentCommand`. + * Browser-only: it parses with `DOMParser`. + * + * What survives: paragraphs, line breaks and tabs; bold, italic, underline, + * strikethrough, superscript / subscript and text color; headings; bullet and + * numbered lists with their levels; and tables. Fonts, sizes, spacing and + * other layout are left to the destination, as in Word's "Merge Formatting". + */ + +import type { + PastedBlock, + PastedParagraph, + PastedRun, + PastedRunFormat, +} from "./commands/structure.js"; + +type ListSpec = NonNullable; + +interface Draft { + runs: PastedRun[]; + heading?: number; + list?: ListSpec; +} + +interface Context { + readonly format: PastedRunFormat; + readonly pre: boolean; + /** The kind and depth of the `