From 0aa9399f936378a07b5f6ff99ef59003afac9464 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 3 Jul 2026 22:53:38 +0200 Subject: [PATCH] feat: textbox and shapes --- CHANGELOG.md | 12 +- src/lib/components/Editor.svelte | 47 +++ src/lib/components/TextBoxToolbar.svelte | 246 +++++++++++ src/lib/components/ToolbarExpanded.svelte | 11 + src/lib/editor/extensions.ts | 7 +- src/lib/editor/extensions/image.ts | 11 +- src/lib/editor/extensions/pageBreaks.ts | 12 + src/lib/editor/extensions/textBox.ts | 486 ++++++++++++++++++++++ src/lib/editor/extensions/trailingNode.ts | 2 +- src/lib/export/docx.ts | 237 ++++++++++- src/lib/export/odt.ts | 171 +++++++- src/lib/import/docx.ts | 159 ++++++- src/lib/import/docxStyles.ts | 5 + src/lib/import/odt.ts | 189 +++++++-- src/styles/editor.css | 39 +- tests/docx-roundtrip.test.ts | 127 ++++++ tests/lo-roundtrip.test.ts | 35 +- tests/roundtrip.test.ts | 107 +++++ tests/unit/docx-export.test.ts | 10 + 19 files changed, 1856 insertions(+), 57 deletions(-) create mode 100644 src/lib/components/TextBoxToolbar.svelte create mode 100644 src/lib/editor/extensions/textBox.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f2e26..c35373c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ - Table of contents: generated from headings (H1–H3) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field - Hyperlinks: create / edit / remove (toolbar + Ctrl+K), Ctrl/Cmd+click to open, hover hint showing the URL; ODF `text:a` round-trip - Manual page break (Ctrl+Enter); round-trips to ODF `fo:break-before` +- Text boxes and basic shapes (rectangle / rounded rectangle / ellipse): editable block content, fill and border colors, border width, resize/rotate handles, text wrap (inline / left / right / top-bottom) like images; floating toolbar for wrap, shape kind and colors. Round-trips to ODF `draw:frame`/`draw:text-box` + `draw:custom-shape` and DOCX DrawingML `wps:wsp`/`wps:txbx` (imports Word's `mc:AlternateContent` and legacy VML text boxes too) **Page & layout** - Page margins (cm) and page orientation (portrait / landscape) @@ -77,7 +78,7 @@ Planned word-processor features (to match Word/LibreOffice): - Heading levels H4–H6 (currently clamped to H3) - Paragraph borders / shading - Track changes (revisions) -- Text boxes / shapes / drawings (currently dropped on import) +- Lines / arrows / connectors / freeform and other shape presets (currently dropped on import with a warning; text boxes + rect/round-rect/ellipse are supported) - Equations / formulas - Cross-references / bookmarks - Named paragraph / character styles (style gallery) @@ -105,6 +106,15 @@ Other: `2.`…), nested lists indented. Content is preserved and readable, but loses list/heading semantics (no auto-numbering, not recognised as a heading). Noted 2026-06-06. +- Text boxes in the DOCX export: lists inside a box are flattened to + literal-marker paragraphs (`•` / `1.`), and images inside a box are dropped — + the box XML is injected by a post-pack string pass (`export/docx.ts` + `applyTextBoxesDocx`) that cannot mint numbering.xml or media/rels entries. + ODT export has neither limitation. Noted 2026-07-03. +- A plain text box's height is a *minimum* (content grows the box, like the + editor). LibreOffice recomputes auto-grow heights on open, so a re-saved box + keeps its content but sheds excess empty height. Shapes (rect/ellipse) export + with fixed geometry instead. Noted 2026-07-03. --- diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 5494034..f6b6916 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -10,7 +10,9 @@ import TableToolbar from './TableToolbar.svelte'; import TableSplitDialog from './TableSplitDialog.svelte'; import ImageToolbar from './ImageToolbar.svelte'; + import TextBoxToolbar from './TextBoxToolbar.svelte'; import type { WrapMode } from '../editor/extensions/image'; + import { findTextBox, type ShapeKind } from '../editor/extensions/textBox'; import { NodeSelection } from '@tiptap/pm/state'; import SpellContextMenu from './SpellContextMenu.svelte'; import HeaderFooterLayer from './HeaderFooterLayer.svelte'; @@ -287,6 +289,38 @@ }; } + // --- Floating text-box toolbar --- + // Shown when a text box is node-selected or the cursor is inside one; positioned + // just above it. Hidden while the image toolbar shows (an image inside a box). + let textBoxUi = $state<{ + visible: boolean; top: number; left: number; + wrap: WrapMode; shapeKind: ShapeKind; fillColor: string | null; + strokeColor: string | null; strokeWidthPt: number; + }>({ visible: false, top: 0, left: 0, wrap: 'inline', shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 }); + + function recomputeTextBoxUi() { + const ed = editor; + const found = ed && editorContainer && !imageUi.visible ? findTextBox(ed.state) : null; + const dom = found ? ed!.view.nodeDOM(found.pos) : null; + if (!found || !(dom instanceof HTMLElement)) { + if (textBoxUi.visible) textBoxUi = { ...textBoxUi, visible: false }; + return; + } + const r = dom.getBoundingClientRect(); + const cRect = editorContainer.getBoundingClientRect(); + const a = found.node.attrs; + textBoxUi = { + visible: true, + top: r.top - cRect.top + editorContainer.scrollTop, + left: r.left - cRect.left + editorContainer.scrollLeft, + wrap: (a.wrap as WrapMode) || 'inline', + shapeKind: (a.shapeKind as ShapeKind) || 'textbox', + fillColor: (a.fillColor as string | null) ?? null, + strokeColor: (a.strokeColor as string | null) ?? null, + strokeWidthPt: (a.strokeWidthPt as number) ?? 1, + }; + } + // Table page-break overlay: pageBreaks.ts reports (via pm-pagecount) where a continuous // table box crosses a page boundary, each as a band in doc px. Rendered in .band-layer // inside the scaled .paper — a mask hides borders in the margins, a stripe is the gap. @@ -340,6 +374,7 @@ tableUiRaf = requestAnimationFrame(() => { recomputeTableUi(); recomputeImageUi(); + recomputeTextBoxUi(); recomputeBands(); }); } @@ -672,6 +707,18 @@ {#if imageUi.visible} {/if} + {#if textBoxUi.visible} + + {/if} {#if spellMenu.visible} + import type { Editor } from '@tiptap/core'; + import type { WrapMode } from '../editor/extensions/image'; + import type { ShapeKind, TextBoxAttrs } from '../editor/extensions/textBox'; + import ColorPicker from './ColorPicker.svelte'; + + let { + editor, + top, + left, + wrap, + shapeKind, + fillColor, + strokeColor, + strokeWidthPt, + }: { + editor: Editor | null; + top: number; + left: number; + wrap: WrapMode; + shapeKind: ShapeKind; + fillColor: string | null; + strokeColor: string | null; + strokeWidthPt: number; + } = $props(); + + let fillOpen = $state(false); + let strokeOpen = $state(false); + + function set(attrs: Partial) { + editor?.chain().focus().setTextBoxAttrs(attrs).run(); + } + + const wrapModes: { mode: WrapMode; title: string }[] = [ + { mode: 'inline', title: 'In line with text' }, + { mode: 'left', title: 'Wrap text — box left' }, + { mode: 'right', title: 'Wrap text — box right' }, + { mode: 'topBottom', title: 'Top and bottom' }, + ]; + + const shapes: { kind: ShapeKind; title: string }[] = [ + { kind: 'textbox', title: 'Rectangle' }, + { kind: 'roundRect', title: 'Rounded rectangle' }, + { kind: 'ellipse', title: 'Ellipse' }, + ]; + + const strokeWidths = [0.5, 1, 2.25]; + + + + + diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte index 0253823..e7ea6b3 100644 --- a/src/lib/components/ToolbarExpanded.svelte +++ b/src/lib/components/ToolbarExpanded.svelte @@ -1231,6 +1231,17 @@ style="display:none" onchange={onImageFile} /> +