From f8199238aed5396ea4bf632b2b886c9fe1e65cc3 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Tue, 6 Oct 2026 11:21:02 +0900 Subject: [PATCH] feat(editor): formatted paste; drop untyped pending formatting - Pasting HTML keeps character formatting, headings, lists, line breaks, tabs and tables (insertFragmentCommand + parseClipboardHtml), from Word, web pages, Google Docs and the editor's own copy. Text drops too. - Formatting set at a caret and left without typing is dropped when the caret moves on (releasePendingFormat), instead of an empty run that swallowed an arrow-key press. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UAiVj14HzKtRDqK12BXTr6 --- .changeset/editor-rich-paste.md | 8 + packages/editor/src/clipboard-html.test.ts | 238 ++++++++++++ packages/editor/src/clipboard-html.ts | 340 ++++++++++++++++++ packages/editor/src/commands/list-util.ts | 17 + packages/editor/src/commands/list.ts | 12 +- packages/editor/src/commands/structure.ts | 230 +++++++++++- .../src/commands/text-selection.test.ts | 39 +- packages/editor/src/index.ts | 10 +- packages/editor/src/selection-runs.ts | 36 ++ packages/editor/src/text-edit.ts | 9 +- site/src/lib/editor/EditorCanvas.svelte | 32 +- 11 files changed, 937 insertions(+), 34 deletions(-) create mode 100644 .changeset/editor-rich-paste.md create mode 100644 packages/editor/src/clipboard-html.test.ts create mode 100644 packages/editor/src/clipboard-html.ts create mode 100644 packages/editor/src/commands/list-util.ts 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

+
  • one
    more
  • two
    1. deep
+

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

  • C
  • D
"); + 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 `
    ` / `
      ` 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. */