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(
+ "",
+ );
+ 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, "- one
- 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, "");
+ 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, "");
+ 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, "");
+ 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 `` / `` being read. */
+ readonly list?: ListSpec;
+}
+
+const SKIPPED = new Set([
+ "SCRIPT",
+ "STYLE",
+ "HEAD",
+ "TITLE",
+ "META",
+ "LINK",
+ "TEMPLATE",
+ "NOSCRIPT",
+]);
+const BLOCKS = new Set([
+ "P",
+ "DIV",
+ "LI",
+ "BLOCKQUOTE",
+ "PRE",
+ "SECTION",
+ "ARTICLE",
+ "HEADER",
+ "FOOTER",
+ "ASIDE",
+ "NAV",
+ "MAIN",
+ "FIGURE",
+ "FIGCAPTION",
+ "ADDRESS",
+ "DT",
+ "DD",
+ "H1",
+ "H2",
+ "H3",
+ "H4",
+ "H5",
+ "H6",
+]);
+const HEADING = /^H([1-6])$/;
+// Word writes a list paragraph as `mso-list:l0 level2 lfo1`, its marker in a
+// span styled `mso-list:Ignore`.
+const MSO_LIST_LEVEL = /mso-list:\s*l\d+\s+level(\d+)/i;
+const MSO_LIST_IGNORE = /mso-list:\s*ignore/i;
+// A numbered marker ends in a delimiter ("1.", "a)", "(iv)") or is a circled
+// number ("①"); a bare symbol or letter ("•", "o", "§") is a bullet.
+const NUMBERED_MARKER = /^\(?[\p{L}\p{N}]+[.)]$|^\p{No}$/u;
+const ZWSP = //g;
+const MAX_LIST_LEVEL = 8;
+
+export function parseClipboardHtml(html: string): PastedBlock[] {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ return readBlocks(doc.body, { format: {}, pre: false });
+}
+
+/** The blocks of a container: the clipboard body or a table cell. */
+function readBlocks(root: Element, ctx: Context): PastedBlock[] {
+ const out: PastedBlock[] = [];
+ let draft: Draft | undefined;
+
+ const flush = (): void => {
+ if (!draft) return;
+ const runs = trimParagraph(draft.runs);
+ if (runs.length > 0 || draft.list || draft.heading !== undefined) {
+ out.push({
+ kind: "paragraph",
+ runs,
+ ...(draft.heading !== undefined ? { heading: draft.heading } : {}),
+ ...(draft.list ? { list: draft.list } : {}),
+ });
+ }
+ draft = undefined;
+ };
+
+ /** Start a paragraph; an opened one with nothing in it yet takes the props instead. */
+ const open = (props: Omit): void => {
+ if (draft && draft.runs.length === 0) {
+ Object.assign(draft, props);
+ return;
+ }
+ flush();
+ draft = { runs: [], ...props };
+ };
+
+ const text = (value: string, format: PastedRunFormat): void => {
+ draft ??= { runs: [] };
+ const last = draft.runs.at(-1);
+ if (last && sameFormat(last.format, format)) {
+ draft.runs[draft.runs.length - 1] = { text: last.text + value, format };
+ } else {
+ draft.runs.push({ text: value, format });
+ }
+ };
+
+ const walk = (node: Node, ctx: Context): void => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const raw = (node.textContent ?? "").replace(ZWSP, "");
+ // HTML collapses ASCII whitespace only; a no-break space stays a space.
+ const value = ctx.pre ? raw.replace(/\r\n?/g, "\n") : raw.replace(/[ \t\n\r\f]+/g, " ");
+ if (value) text(value, ctx.format);
+ return;
+ }
+ if (!(node instanceof HTMLElement) || SKIPPED.has(node.tagName) || isIgnored(node)) return;
+ const tag = node.tagName;
+ const style = node.getAttribute("style") ?? "";
+ const inner: Context = {
+ ...ctx,
+ format: formatOf(node, ctx.format),
+ pre: ctx.pre || tag === "PRE" || /white-space:\s*pre/i.test(style),
+ };
+ if (tag === "BR") {
+ text("\n", ctx.format);
+ return;
+ }
+ if (node.classList.contains("wk-tab")) {
+ text("\t", ctx.format);
+ return;
+ }
+ if (tag === "TABLE") {
+ flush();
+ out.push({ kind: "table", rows: readTable(node, inner) });
+ return;
+ }
+ if (tag === "UL" || tag === "OL") {
+ flush();
+ const level = ctx.list ? Math.min(ctx.list.level + 1, MAX_LIST_LEVEL) : 0;
+ const list: ListSpec = { kind: tag === "OL" ? "numbered" : "bullet", level };
+ for (const child of Array.from(node.childNodes)) walk(child, { ...inner, list });
+ flush();
+ return;
+ }
+ if (BLOCKS.has(tag)) {
+ const heading = HEADING.exec(tag)?.[1];
+ const list = tag === "LI" ? ctx.list : (msoList(node, style) ?? editorList(node));
+ open({
+ ...(heading ? { heading: Number(heading) } : {}),
+ ...(list ? { list } : {}),
+ });
+ for (const child of Array.from(node.childNodes)) walk(child, inner);
+ flush();
+ return;
+ }
+ for (const child of Array.from(node.childNodes)) walk(child, inner);
+ };
+
+ for (const child of Array.from(root.childNodes)) walk(child, ctx);
+ flush();
+ return out;
+}
+
+function readTable(table: HTMLElement, ctx: Context): PastedParagraph[][][] {
+ // Rows of this table only: a nested table's rows belong to its cell.
+ const rows = Array.from(table.querySelectorAll("tr")).filter(
+ (tr) => tr.closest("table") === table,
+ );
+ return rows.map((tr) =>
+ Array.from(tr.children)
+ .filter(
+ (cell): cell is HTMLElement =>
+ cell instanceof HTMLElement && (cell.tagName === "TD" || cell.tagName === "TH"),
+ )
+ .map((cell) =>
+ // A cell holds paragraphs; a table nested in it is read as its text.
+ readBlocks(cell, { format: formatOf(cell, ctx.format), pre: ctx.pre }).flatMap((b) =>
+ b.kind === "paragraph" ? [b] : b.rows.flat(2),
+ ),
+ ),
+ );
+}
+
+/** Word's list marker spans, and generated text in this editor's own copy. */
+function isIgnored(el: HTMLElement): boolean {
+ return (
+ MSO_LIST_IGNORE.test(el.getAttribute("style") ?? "") ||
+ el.classList.contains("wk-list-label") ||
+ el.classList.contains("wk-fchar") ||
+ el.classList.contains("wk-fcode")
+ );
+}
+
+/** A Word list paragraph's level and kind (read off its marker). */
+function msoList(el: HTMLElement, style: string): ListSpec | undefined {
+ const level = MSO_LIST_LEVEL.exec(style)?.[1];
+ if (!level) return undefined;
+ const marker = Array.from(el.querySelectorAll("span")).find((s) =>
+ MSO_LIST_IGNORE.test(s.getAttribute("style") ?? ""),
+ );
+ return {
+ kind: markerKind(marker?.textContent ?? ""),
+ level: Math.min(Math.max(Number(level) - 1, 0), MAX_LIST_LEVEL),
+ };
+}
+
+/** A list paragraph copied from this editor: it carries its rendered label. */
+function editorList(el: HTMLElement): ListSpec | undefined {
+ const label = el.querySelector(":scope > .wk-list-label");
+ return label ? { kind: markerKind(label.textContent ?? ""), level: 0 } : undefined;
+}
+
+function markerKind(marker: string): ListSpec["kind"] {
+ return NUMBERED_MARKER.test(marker.replace(/\s/g, "")) ? "numbered" : "bullet";
+}
+
+/** The formatting inside `el`: what its tag implies, then its inline style. */
+function formatOf(el: HTMLElement, outer: PastedRunFormat): PastedRunFormat {
+ const f: { -readonly [K in keyof PastedRunFormat]: PastedRunFormat[K] } = { ...outer };
+ switch (el.tagName) {
+ case "B":
+ case "STRONG":
+ case "TH":
+ f.bold = true;
+ break;
+ case "I":
+ case "EM":
+ case "CITE":
+ case "VAR":
+ f.italic = true;
+ break;
+ case "U":
+ case "INS":
+ f.underline = true;
+ break;
+ case "S":
+ case "STRIKE":
+ case "DEL":
+ f.strike = true;
+ break;
+ case "SUP":
+ f.verticalAlign = "superscript";
+ break;
+ case "SUB":
+ f.verticalAlign = "subscript";
+ break;
+ }
+ const attrColor = el.tagName === "FONT" ? el.getAttribute("color") : null;
+ const color = hexColor(el.style.color || attrColor || "");
+ if (color !== undefined) {
+ if (color === BLACK) delete f.color;
+ else f.color = color;
+ }
+ const weight = el.style.fontWeight;
+ if (weight) f.bold = weight === "bold" || weight === "bolder" || Number(weight) >= BOLD_WEIGHT;
+ const fontStyle = el.style.fontStyle;
+ if (fontStyle) f.italic = fontStyle === "italic" || fontStyle === "oblique";
+ const decoration = `${el.style.textDecorationLine} ${el.style.textDecoration}`;
+ if (/underline/.test(decoration)) f.underline = true;
+ if (/line-through/.test(decoration)) f.strike = true;
+ if (/\bnone\b/.test(decoration) && !/underline|line-through/.test(decoration)) {
+ delete f.underline;
+ delete f.strike;
+ }
+ const align = el.style.verticalAlign;
+ if (align === "super") f.verticalAlign = "superscript";
+ else if (align === "sub") f.verticalAlign = "subscript";
+ else if (align === "baseline") delete f.verticalAlign;
+ return f;
+}
+
+const BOLD_WEIGHT = 600;
+// Black is every document's default text color; keeping it would pin the
+// pasted text black even in a style that colors it.
+const BLACK = "000000";
+
+/** `#rgb`, `#rrggbb` or `rgb(r, g, b)` as six hex digits; `undefined` otherwise. */
+function hexColor(css: string): string | undefined {
+ const value = css.trim().toLowerCase();
+ const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(value);
+ if (short)
+ return `${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`.toUpperCase();
+ const long = /^#([0-9a-f]{6})$/.exec(value);
+ if (long?.[1]) return long[1].toUpperCase();
+ const rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(value);
+ if (!rgb || rgb[4] === "0") return undefined;
+ return [rgb[1], rgb[2], rgb[3]]
+ .map((n) => Math.min(Number(n), 255).toString(16).padStart(2, "0"))
+ .join("")
+ .toUpperCase();
+}
+
+function sameFormat(a: PastedRunFormat, b: PastedRunFormat): boolean {
+ return (
+ !!a.bold === !!b.bold &&
+ !!a.italic === !!b.italic &&
+ !!a.underline === !!b.underline &&
+ !!a.strike === !!b.strike &&
+ a.verticalAlign === b.verticalAlign &&
+ a.color === b.color
+ );
+}
+
+/**
+ * HTML's whitespace rules at a paragraph's edges: collapsed spaces there do
+ * not show, and neither does the `
` a browser leaves at the end of a block.
+ */
+function trimParagraph(runs: readonly PastedRun[]): PastedRun[] {
+ const out = [...runs];
+ const first = out[0];
+ if (first) out[0] = { text: first.text.replace(/^ +/, ""), format: first.format };
+ const last = out.at(-1);
+ if (last) {
+ out[out.length - 1] = {
+ text: last.text.replace(/ +$/, "").replace(/\n$/, ""),
+ format: last.format,
+ };
+ }
+ return out
+ .filter((r) => r.text !== "")
+ .map((r) => ({ text: r.text.replace(NBSP, " "), format: r.format }));
+}
+
+const NBSP = /\u00a0/g;
diff --git a/packages/editor/src/commands/list-util.ts b/packages/editor/src/commands/list-util.ts
new file mode 100644
index 0000000..bb68f1b
--- /dev/null
+++ b/packages/editor/src/commands/list-util.ts
@@ -0,0 +1,17 @@
+/** List helpers shared by the list and paste commands (not public API). */
+
+import { addBulletList, addNumberedList, type Docx, getParagraphNumbering } from "@office-kit/docx";
+
+/**
+ * A new list instance of the given kind, as its numId. The library creates
+ * definitions only together with list paragraphs, so one is seeded and the
+ * seed paragraph dropped again.
+ */
+export function newListId(doc: Docx, kind: "bullet" | "numbered"): number | undefined {
+ const [seed] = kind === "bullet" ? addBulletList(doc, [""]) : addNumberedList(doc, [""]);
+ if (!seed) return undefined;
+ const blocks = doc.document.body.blocks;
+ const idx = blocks.indexOf(seed);
+ if (idx >= 0) blocks.splice(idx, 1);
+ return getParagraphNumbering(seed)?.numId;
+}
diff --git a/packages/editor/src/commands/list.ts b/packages/editor/src/commands/list.ts
index 6ba031e..6875b06 100644
--- a/packages/editor/src/commands/list.ts
+++ b/packages/editor/src/commands/list.ts
@@ -29,6 +29,7 @@ import type { EditorModel } from "../model.js";
import { createStyleResolver } from "../resolve.js";
import { orderSelection } from "../selection.js";
import { caretBlockIndex, moveLastBlockAfter } from "./insert-util.js";
+import { newListId } from "./list-util.js";
import type { Command } from "./types.js";
/** Insert a new bullet/numbered list built from lines, after the caret. */
@@ -71,16 +72,7 @@ export const applyListCommand: Command<{ kind: "bullet" | "numbered" }> = {
if (!sel) return;
const paras = paragraphsInRange(model.doc, orderSelection(sel));
if (paras.length === 0) return;
- // Seed a list definition of the requested kind, read the numId off the
- // seeded paragraph, bind the real selection to it, then drop the seed.
- const seed =
- kind === "bullet" ? addBulletList(model.doc, [""]) : addNumberedList(model.doc, [""]);
- const seededBlock = seed[0];
- const numId = seededBlock ? getParagraphNumbering(seededBlock)?.numId : undefined;
- if (seededBlock) {
- const idx = model.doc.document.body.blocks.indexOf(seededBlock);
- if (idx >= 0) model.doc.document.body.blocks.splice(idx, 1);
- }
+ const numId = newListId(model.doc, kind);
if (numId === undefined) return;
for (const p of paras) applyListToParagraph(model.doc, p, numId);
},
diff --git a/packages/editor/src/commands/structure.ts b/packages/editor/src/commands/structure.ts
index 2d75962..a7b2ae7 100644
--- a/packages/editor/src/commands/structure.ts
+++ b/packages/editor/src/commands/structure.ts
@@ -18,6 +18,10 @@ import {
removeParagraph,
removeTable,
splitParagraphAt,
+ applyListToParagraph,
+ setParagraphStyle,
+ setRunFormat,
+ setRunValProp,
type WmlInline,
type WmlParagraph,
type WmlRun,
@@ -32,6 +36,7 @@ import { caretAt, type DocPosition, orderSelection } from "../selection.js";
import { WML_NS } from "../wml-ns.js";
import {
isEmptyParagraph,
+ isEmptyRun,
isPlainTextRun,
plainRunText,
runAtPath,
@@ -45,6 +50,8 @@ import {
trackedMergeBack,
} from "../track-changes.js";
import { caretBlockIndex, moveLastBlockAfter } from "./insert-util.js";
+import { newListId } from "./list-util.js";
+import { insertTableCommand } from "./table.js";
import type { Command } from "./types.js";
/**
@@ -456,27 +463,221 @@ export const insertTextCommand: Command<{ text: string }> = {
if (!start) throw new Error("Insert text needs a caret position.");
const [first = "", ...rest] = text.split(/\r\n|\r|\n/);
let caret = insertIntoRun(model, start, first);
- for (const line of rest) {
- let next: DocPosition;
- if (caret.cell) {
- next = splitCellParagraph(model, caret);
- } else {
- const block = splitParagraphAt(
- model.doc,
- caret.block,
- caret.inline ?? 0,
- caret.offset ?? 0,
- );
- if (block < 0) throw new Error("The caret is not on a top-level paragraph.");
- next = { block, inline: 0, offset: 0 };
+ for (const line of rest) caret = insertIntoRun(model, splitAt(model, caret), line);
+ model.setSelection(caretAt(caret));
+ },
+ isEnabled: (model) => !!model.selection,
+};
+
+/** Enter at `caret`: split its paragraph (in a cell, within the cell); the start of the second half. */
+function splitAt(model: EditorModel, caret: DocPosition): DocPosition {
+ if (caret.cell) return splitCellParagraph(model, caret);
+ const block = splitParagraphAt(model.doc, caret.block, caret.inline ?? 0, caret.offset ?? 0);
+ if (block < 0) throw new Error("The caret is not on a top-level paragraph.");
+ return { block, inline: 0, offset: 0 };
+}
+
+/** The character formatting a pasted run keeps from its source. */
+export interface PastedRunFormat {
+ readonly bold?: boolean;
+ readonly italic?: boolean;
+ readonly underline?: boolean;
+ readonly strike?: boolean;
+ readonly verticalAlign?: "superscript" | "subscript";
+ /** Six hex digits without `#`. */
+ readonly color?: string;
+}
+
+/** Pasted text in one formatting; `\t` is a tab and `\n` a line break. */
+export interface PastedRun {
+ readonly text: string;
+ readonly format: PastedRunFormat;
+}
+
+export interface PastedParagraph {
+ readonly kind: "paragraph";
+ readonly runs: readonly PastedRun[];
+ /** Heading 1–9, as the built-in heading style of that level. */
+ readonly heading?: number;
+ /** A list item; consecutive items of one kind form one list. */
+ readonly list?: { readonly kind: "bullet" | "numbered"; readonly level: number };
+}
+
+export interface PastedTable {
+ readonly kind: "table";
+ /** Rows of cells, each cell its paragraphs. */
+ readonly rows: ReadonlyArray>;
+}
+
+/** Formatted content from the clipboard (see `parseClipboardHtml`). */
+export type PastedBlock = PastedParagraph | PastedTable;
+
+/** The list the previous pasted paragraph went into, which the next item of its kind continues. */
+type OpenList = { readonly kind: "bullet" | "numbered"; readonly numId: number } | undefined;
+
+/**
+ * Paste formatted content at the caret, replacing the selection, as one undo
+ * step. As in Word, the first pasted paragraph continues the caret's
+ * paragraph and the rest of that paragraph follows the last one; a pasted
+ * table goes between the two halves. Inside a table cell, pasted tables
+ * become their cells' paragraphs (nested tables are not supported).
+ */
+export const insertFragmentCommand: Command<{ blocks: readonly PastedBlock[] }> = {
+ id: "structure.insertFragment",
+ group: "structure",
+ label: "Paste",
+ run(model, { blocks }) {
+ if (isTrackingRevisions(model)) {
+ // A tracked paste is recorded as inserted text; its formatting would
+ // need tracked property changes of its own.
+ trackedInsertText(model, fragmentText(blocks));
+ return;
+ }
+ let caret = caretReplacingSelection(model);
+ if (!caret) throw new Error("Paste needs a caret position.");
+ let merge = true;
+ let list: OpenList;
+ const pasted = caret.cell ? flattenTables(blocks) : blocks;
+ // Text pasted within one paragraph takes the destination's paragraph
+ // formatting; pasted paragraphs bring their own (in Word, their marks).
+ const inline = pasted.length === 1 && pasted[0]?.kind === "paragraph";
+ for (const block of pasted) {
+ if (block.kind === "table") {
+ caret = insertPastedTable(model, caret, block);
+ merge = true;
+ list = undefined;
+ continue;
}
- caret = insertIntoRun(model, next, line);
+ if (!merge) caret = splitAt(model, caret);
+ const para = paragraphAt(model.doc, caret);
+ if (!para) throw new Error("The caret is not inside a paragraph.");
+ const fresh = !inline || isEmptyParagraph(para);
+ caret = insertPastedRuns(para, caret, block.runs);
+ if (fresh) list = formatPastedParagraph(model, para, block, list);
+ merge = false;
}
model.setSelection(caretAt(caret));
},
isEnabled: (model) => !!model.selection,
};
+function pastedText(p: PastedParagraph): string {
+ return p.runs.map((r) => r.text).join("");
+}
+
+function fragmentText(blocks: readonly PastedBlock[]): string {
+ return blocks
+ .map((b) =>
+ b.kind === "paragraph"
+ ? pastedText(b)
+ : b.rows
+ .map((row) => row.map((cell) => cell.map(pastedText).join(" ")).join("\t"))
+ .join("\n"),
+ )
+ .join("\n");
+}
+
+function flattenTables(blocks: readonly PastedBlock[]): PastedParagraph[] {
+ return blocks.flatMap((b) => (b.kind === "paragraph" ? [b] : b.rows.flat(2)));
+}
+
+function buildPastedRun({ text, format }: PastedRun): WmlRun {
+ const run: WmlRun = { kind: "run", pieces: [], extras: [] };
+ for (const [i, line] of text.split("\n").entries()) {
+ if (i > 0) run.pieces.push({ kind: "break" });
+ const part: WmlRun = { kind: "run", pieces: [], extras: [] };
+ setSimpleRunText(part, line);
+ run.pieces.push(...part.pieces);
+ }
+ setRunFormat(run, {
+ ...(format.bold ? { bold: true } : {}),
+ ...(format.italic ? { italic: true } : {}),
+ ...(format.strike ? { strike: true } : {}),
+ ...(format.underline ? { underline: "single" } : {}),
+ ...(format.color ? { color: format.color } : {}),
+ });
+ if (format.verticalAlign) setRunValProp(run, "vertAlign", format.verticalAlign);
+ return run;
+}
+
+/** Put the runs in at the caret; the caret after them. */
+function insertPastedRuns(
+ para: WmlParagraph,
+ caret: DocPosition,
+ runs: readonly PastedRun[],
+): DocPosition {
+ const built = runs.filter((r) => r.text !== "").map(buildPastedRun);
+ const last = built.at(-1);
+ if (!last) return caret;
+ // The empty runs an empty paragraph or a split leaves would linger next to
+ // the pasted ones.
+ const at = absoluteOffset(para, caret);
+ para.children = para.children.filter((c) => !(c.kind === "run" && isEmptyRun(c)));
+ para.children.splice(childIndexAtCaret(para, positionAtOffset(para, at, caret)), 0, ...built);
+ const inline = para.children.filter((c) => c.kind === "run").indexOf(last);
+ return { ...caret, inline, offset: runTextLength(last) };
+}
+
+/** Give a pasted paragraph its heading style or list; returns the list it is in. */
+function formatPastedParagraph(
+ model: EditorModel,
+ para: WmlParagraph,
+ pasted: PastedParagraph,
+ open: OpenList,
+): OpenList {
+ if (pasted.heading !== undefined) {
+ ensureHeadingStyles(model.doc, pasted.heading);
+ setParagraphStyle(para, `Heading${pasted.heading}`);
+ }
+ if (!pasted.list) return undefined;
+ const { kind, level } = pasted.list;
+ const numId = open?.kind === kind ? open.numId : newListId(model.doc, kind);
+ if (numId === undefined) return undefined;
+ applyListToParagraph(model.doc, para, numId, level);
+ return { kind, numId };
+}
+
+/**
+ * A pasted table between the halves of the caret's paragraph (a caret at its
+ * start leaves no empty half before it); the caret moves to the second half.
+ */
+function insertPastedTable(
+ model: EditorModel,
+ caret: DocPosition,
+ pasted: PastedTable,
+): DocPosition {
+ const rows = pasted.rows.length;
+ const cols = Math.max(0, ...pasted.rows.map((row) => row.length));
+ if (rows === 0 || cols === 0) return caret;
+ splitAt(model, caret);
+ model.setSelection(caretAt({ ...caret, inline: 0, offset: 0 }));
+ insertTableCommand.run(model, { rows, cols });
+ const blocks = model.doc.document.body.blocks;
+ const table = blocks[caret.block + 1];
+ if (table?.kind !== "table") throw new Error("The pasted table was not inserted.");
+ let list: OpenList;
+ table.rows.forEach((row, r) =>
+ row.cells.forEach((cell, c) => {
+ const paras = pasted.rows[r]?.[c] ?? [];
+ if (paras.length === 0) return;
+ cell.paragraphs = paras.map((p) => {
+ const para: WmlParagraph = { kind: "paragraph", children: [], extras: [] };
+ insertPastedRuns(para, { block: 0, inline: 0, offset: 0 }, p.runs);
+ para.children = ensureRun(para.children);
+ list = formatPastedParagraph(model, para, p, list);
+ return para;
+ });
+ }),
+ );
+ const head = blocks[caret.block];
+ let tail = caret.block + 2;
+ if (head?.kind === "paragraph" && isEmptyParagraph(head) && !sectPrOf(head)) {
+ blocks.splice(caret.block, 1);
+ tail--;
+ }
+ return { ...(caret.story ? { story: caret.story } : {}), block: tail, inline: 0, offset: 0 };
+}
+
export const insertParagraphCommand: Command<{ text?: string; options?: AppendParagraphOptions }> =
{
id: "structure.insertParagraph",
@@ -583,6 +784,7 @@ function countKind(model: EditorModel, kind: "table" | "paragraph", blockIndex:
export const structureCommands = [
insertTextCommand,
+ insertFragmentCommand,
insertParagraphCommand,
splitParagraphCommand,
mergeBackCommand,
diff --git a/packages/editor/src/commands/text-selection.test.ts b/packages/editor/src/commands/text-selection.test.ts
index 8344ecf..16ced04 100644
--- a/packages/editor/src/commands/text-selection.test.ts
+++ b/packages/editor/src/commands/text-selection.test.ts
@@ -14,7 +14,7 @@ import {
type WmlRun,
} from "@office-kit/docx";
import { describe, expect, it } from "vitest";
-import { editorFor } from "../index.js";
+import { editorFor, releasePendingFormat } from "../index.js";
import type { EditorModel } from "../model.js";
import { insertTextCommand } from "./structure.js";
import { toggleBoldCommand, toggleItalicCommand } from "./text.js";
@@ -181,4 +181,41 @@ describe("character formatting at a caret (Word)", () => {
["", false],
]);
});
+
+ it("drops the pending run when the caret moves on without typing", () => {
+ const model = editorFor(createDocx({ paragraphs: ["Hello world"] }));
+ caret(model, 0, 5);
+ runCommand(model, toggleBoldCommand, undefined);
+ expect(runProfile(model)).toEqual([
+ ["Hello", false],
+ ["", true],
+ [" world", false],
+ ]);
+ // The caret moves into " world" (run 2 before the drop, run 1 after).
+ const moved = releasePendingFormat(model, {
+ anchor: { block: 0, inline: 2, offset: 3 },
+ focus: { block: 0, inline: 2, offset: 3 },
+ });
+ expect(moved?.focus).toEqual({ block: 0, inline: 1, offset: 3 });
+ expect(runProfile(model)).toEqual([
+ ["Hello", false],
+ [" world", false],
+ ]);
+ });
+
+ it("keeps the pending run while the caret stays on it, and once text is typed", () => {
+ const model = editorFor(createDocx({ paragraphs: ["Hello"] }));
+ caret(model, 0, 5);
+ runCommand(model, toggleBoldCommand, undefined);
+ const here = model.selection!;
+ expect(releasePendingFormat(model, here)).toBeUndefined();
+ runCommand(model, insertTextCommand, { text: "!" });
+ expect(
+ releasePendingFormat(model, { anchor: { block: 0 }, focus: { block: 0 } }),
+ ).toBeUndefined();
+ expect(runProfile(model)).toEqual([
+ ["Hello", false],
+ ["!", true],
+ ]);
+ });
});
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index 59e95e1..83e4f8f 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -16,6 +16,14 @@ import { EditorModel } from "./model.js";
export { EditorModel, type ChangeListener, type EditorSnapshot } from "./model.js";
export { type Command, type FeatureGroup, runCommand } from "./commands/types.js";
export type { SectionTarget } from "./commands/section.js";
+export type {
+ PastedBlock,
+ PastedParagraph,
+ PastedRun,
+ PastedRunFormat,
+ PastedTable,
+} from "./commands/structure.js";
+export { parseClipboardHtml } from "./clipboard-html.js";
export { ALL_COMMANDS, COMMAND_IDS, commandsInGroup, getCommand } from "./commands/registry.js";
// Named command objects + group arrays for typed UI wiring.
export * as commands from "./commands/index.js";
@@ -103,7 +111,7 @@ export { AUTHOR_COLORS } from "./render-revisions.js";
export { isEditingLocked, PROTECTED_MESSAGE, protectionRefusal } from "./protection-guard.js";
export { type RawNode, rawTrees, allRawElements, xmlParts, partRawTree } from "./raw-tree.js";
export { blocks, bodyOf, paragraphAt, paragraphsInRange } from "./doc-access.js";
-export { type RunRef, runsInRange } from "./selection-runs.js";
+export { releasePendingFormat, type RunRef, runsInRange } from "./selection-runs.js";
export {
type PageBackground,
type ResolvedSection,
diff --git a/packages/editor/src/selection-runs.ts b/packages/editor/src/selection-runs.ts
index d7682b8..fca6042 100644
--- a/packages/editor/src/selection-runs.ts
+++ b/packages/editor/src/selection-runs.ts
@@ -26,6 +26,7 @@ import {
caretAt,
type OrderedSelection,
orderSelection,
+ type Selection,
} from "./selection.js";
import { positionAtOffset } from "./char-offset.js";
@@ -194,6 +195,7 @@ function applyAtCaret(
withRunFormatTracking(model, own, () => applyFn(own, para));
return;
}
+ PENDING.delete(model);
const at = absoluteChar(runs, caret.inline ?? 0, caret.offset ?? 0);
const word = wordAround(paragraphCharText(runs), at);
if (word) {
@@ -211,11 +213,45 @@ function applyAtCaret(
return;
}
withRunFormatTracking(model, pending, () => applyFn(pending, para));
+ PENDING.set(model, { run: pending, para });
const inline = paragraphRuns(para).indexOf(pending);
const pos = { ...caret, inline, offset: 0 };
model.setSelection({ anchor: pos, focus: pos });
}
+// The empty run caret formatting made for the text typed next, per editor.
+const PENDING = new WeakMap();
+
+/**
+ * The caret is moving to `selection`. If it leaves the run caret formatting
+ * made for the text typed next (Bold at a caret, say) and nothing was typed
+ * there, the run goes, as Word drops pending formatting once the caret moves
+ * on. Returns `selection` with the run indices after the removed run shifted,
+ * or `undefined` when the document did not change. Call it outside an edit,
+ * with positions read before the change.
+ */
+export function releasePendingFormat(
+ model: EditorModel,
+ selection: Selection,
+): Selection | undefined {
+ const held = PENDING.get(model);
+ if (!held) return undefined;
+ const runs = paragraphRuns(held.para);
+ const index = runs.indexOf(held.run);
+ const inHeld = (pos: DocPosition): boolean => paragraphAt(model.doc, pos) === held.para;
+ if (index < 0 || runTextLength(held.run) > 0) {
+ PENDING.delete(model);
+ return undefined;
+ }
+ const onRun = (pos: DocPosition): boolean => inHeld(pos) && (pos.inline ?? 0) === index;
+ if (onRun(selection.anchor) || onRun(selection.focus)) return undefined;
+ PENDING.delete(model);
+ held.para.children = held.para.children.filter((c) => c !== held.run);
+ const shift = (pos: DocPosition): DocPosition =>
+ inHeld(pos) && (pos.inline ?? 0) > index ? { ...pos, inline: (pos.inline ?? 0) - 1 } : pos;
+ return { anchor: shift(selection.anchor), focus: shift(selection.focus) };
+}
+
/** A paragraph's run text, one character per unit `runTextLength` counts. */
function paragraphCharText(runs: WmlRun[]): string {
return runs
diff --git a/packages/editor/src/text-edit.ts b/packages/editor/src/text-edit.ts
index 1675d1c..fb2d3c8 100644
--- a/packages/editor/src/text-edit.ts
+++ b/packages/editor/src/text-edit.ts
@@ -50,7 +50,10 @@ export function setSimpleRunText(run: WmlRun, text: string): boolean {
* picture, field or other inline that would be lost with the paragraph.
*/
export function isEmptyParagraph(para: WmlParagraph): boolean {
- return para.children.every(
- (c) => c.kind === "run" && c.pieces.every((p) => p.kind === "text" && p.value === ""),
- );
+ return para.children.every((c) => c.kind === "run" && isEmptyRun(c));
+}
+
+/** A run with no characters and nothing else (no tab, break, picture or field). */
+export function isEmptyRun(run: WmlRun): boolean {
+ return run.pieces.every((p) => p.kind === "text" && p.value === "");
}
diff --git a/site/src/lib/editor/EditorCanvas.svelte b/site/src/lib/editor/EditorCanvas.svelte
index 8883228..a63c585 100644
--- a/site/src/lib/editor/EditorCanvas.svelte
+++ b/site/src/lib/editor/EditorCanvas.svelte
@@ -11,6 +11,8 @@
paragraphAt,
positionFromDom,
readDomSelection,
+ parseClipboardHtml,
+ releasePendingFormat,
isEmptyParagraph,
runAtPath,
setSimpleRunText,
@@ -309,7 +311,12 @@
if (sel) {
// A selection never spans two stories (body and a footnote, say):
// keep the end that moved.
- model.setSelection(sameStory(sel.anchor, sel.focus) ? sel : caretAt(sel.focus));
+ const next = sameStory(sel.anchor, sel.focus) ? sel : caretAt(sel.focus);
+ // Leaving pending formatting untyped removes its run, which shifts the
+ // run indices the DOM still carries: re-render after it.
+ const released = imeRange || composing ? undefined : releasePendingFormat(model, next);
+ model.setSelection(released ?? next);
+ if (released) rerender();
if (sel.focus.story && editingHeaderFooter) headerFooterPage = focusPage();
if (canvas) decorateTableSelection(canvas, model);
}
@@ -581,8 +588,10 @@
case 'historyRedo':
history('redo');
return;
- case 'insertReplacementText':
case 'insertFromDrop':
+ if (e.dataTransfer && selectTargetRange(e)) insertTransfer(e.dataTransfer);
+ return;
+ case 'insertReplacementText':
case 'insertFromYank': {
const text = e.dataTransfer?.getData('text/plain') ?? e.data;
if (text && selectTargetRange(e)) exec(commands.insertTextCommand, { text });
@@ -662,6 +671,8 @@
}
function onCut(e: ClipboardEvent): void {
+ // A menu Cut or Paste comes without a keydown to sync the selection.
+ onSelChange();
if (!hasRangeSelection() || !e.clipboardData) return;
e.preventDefault();
e.clipboardData.setData('text/plain', window.getSelection()?.toString() ?? '');
@@ -670,10 +681,21 @@
function onPaste(e: ClipboardEvent): void {
flushImeFinalize();
- const text = e.clipboardData?.getData('text/plain');
- if (text === undefined) return;
+ onSelChange();
+ if (!e.clipboardData) return;
e.preventDefault();
- exec(commands.insertTextCommand, { text });
+ insertTransfer(e.clipboardData);
+ }
+
+ /** Paste or drop: formatted when the data has HTML, else its plain text. */
+ function insertTransfer(data: DataTransfer): void {
+ const html = data.getData('text/html');
+ const blocks = html ? parseClipboardHtml(html) : [];
+ if (blocks.length > 0) exec(commands.insertFragmentCommand, { blocks });
+ else {
+ const text = data.getData('text/plain');
+ if (text) exec(commands.insertTextCommand, { text });
+ }
}
/** Header or footer zone of the page under a point, by Word's margins. */