From 20706c80cb28364e588cd39cdcdd9beba6013f84 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Mon, 22 Jun 2026 23:17:07 +0200 Subject: [PATCH] feat: image image text wrapping --- src/lib/editor/Editor.svelte | 33 ++++++++ src/lib/editor/ImageToolbar.svelte | 118 +++++++++++++++++++++++++++++ src/lib/editor/image.ts | 74 ++++++++++++++++-- src/lib/export/odt.ts | 62 +++++++++++++-- src/lib/import/odt.ts | 25 ++++-- src/lib/import/styleResolver.ts | 6 ++ src/styles/editor.css | 5 ++ 7 files changed, 301 insertions(+), 22 deletions(-) create mode 100644 src/lib/editor/ImageToolbar.svelte diff --git a/src/lib/editor/Editor.svelte b/src/lib/editor/Editor.svelte index 106be4b..d75e9bc 100644 --- a/src/lib/editor/Editor.svelte +++ b/src/lib/editor/Editor.svelte @@ -8,6 +8,9 @@ import { spellErrorAt } from './spellCheck'; import { spellController } from '../spell/controller'; import TableToolbar from './TableToolbar.svelte'; + import ImageToolbar from './ImageToolbar.svelte'; + import type { WrapMode } from './image'; + import { NodeSelection } from '@tiptap/pm/state'; import SpellContextMenu from './SpellContextMenu.svelte'; import HeaderFooterLayer from './HeaderFooterLayer.svelte'; import { saveDocument, loadDocument } from '../storage/autosave'; @@ -209,6 +212,32 @@ tableUi = { visible: true, top, left }; } + // --- Floating image wrap toolbar --- + // Shown when a single image node is selected; positioned just above it. + let imageUi = $state<{ visible: boolean; top: number; left: number; wrap: WrapMode }>({ visible: false, top: 0, left: 0, wrap: 'inline' }); + + function recomputeImageUi() { + const ed = editor; + if (!ed || !editorContainer) { + if (imageUi.visible) imageUi = { ...imageUi, visible: false }; + return; + } + const sel = ed.state.selection; + const dom = sel instanceof NodeSelection && sel.node.type.name === 'image' ? ed.view.nodeDOM(sel.from) : null; + if (!(dom instanceof HTMLElement)) { + if (imageUi.visible) imageUi = { ...imageUi, visible: false }; + return; + } + const r = dom.getBoundingClientRect(); + const cRect = editorContainer.getBoundingClientRect(); + imageUi = { + visible: true, + top: r.top - cRect.top + editorContainer.scrollTop, + left: r.left - cRect.left + editorContainer.scrollLeft, + wrap: ((sel as NodeSelection).node.attrs.wrap as WrapMode) || 'inline', + }; + } + // 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. @@ -259,6 +288,7 @@ cancelAnimationFrame(tableUiRaf); tableUiRaf = requestAnimationFrame(() => { recomputeTableUi(); + recomputeImageUi(); recomputeBands(); }); } @@ -553,6 +583,9 @@ {#if tableUi.visible} {/if} + {#if imageUi.visible} + + {/if} {#if spellMenu.visible} + import type { Editor } from '@tiptap/core'; + import type { WrapMode } from './image'; + + let { + editor, + top, + left, + wrap, + }: { + editor: Editor | null; + top: number; + left: number; + wrap: WrapMode; + } = $props(); + + // preventDefault on mousedown keeps the image node-selected; .focus() re-anchors + // to it so setImageWrap targets the right node. + function set(mode: WrapMode) { + editor?.chain().focus().setImageWrap(mode).run(); + } + + const modes: { mode: WrapMode; title: string }[] = [ + { mode: 'inline', title: 'In line with text' }, + { mode: 'left', title: 'Wrap text — image left' }, + { mode: 'right', title: 'Wrap text — image right' }, + { mode: 'topBottom', title: 'Top and bottom' }, + ]; + + + + + diff --git a/src/lib/editor/image.ts b/src/lib/editor/image.ts index 8155bc3..8e8fc52 100644 --- a/src/lib/editor/image.ts +++ b/src/lib/editor/image.ts @@ -1,16 +1,22 @@ import { Node, mergeAttributes } from '@tiptap/core'; import type { Editor } from '@tiptap/core'; import type { Node as PMNode } from '@tiptap/pm/model'; +import { NodeSelection } from '@tiptap/pm/state'; import { dropCursor } from '@tiptap/pm/dropcursor'; -// Inline, as-character image (Word's "in line with text"). width/height are -// unscaled doc px @96dpi (like tableRow.ts rowHeight); rotation is CW degrees. -// Export converts to cm + an ODF draw:transform, so size/rotation round-trip exactly. +// Inline, as-character image, or a floating text-wrapped frame (wrap = flow mode); +// width/height are doc px @96dpi, rotation CW degrees. Export → cm + ODF +// draw:transform/style:wrap. A floating image floats at its anchor; drag re-anchors it. + +// 'inline' = as-character; 'left'/'right' = square wrap (text on the open side); +// 'topBottom' = no side wrap (text only above/below). +export type WrapMode = 'inline' | 'left' | 'right' | 'topBottom'; declare module '@tiptap/core' { interface Commands { image: { - setImage: (attrs: { src: string; alt?: string; width?: number | null; height?: number | null; rotation?: number }) => ReturnType; + setImage: (attrs: { src: string; alt?: string; width?: number | null; height?: number | null; rotation?: number; wrap?: WrapMode }) => ReturnType; + setImageWrap: (wrap: WrapMode) => ReturnType; }; } } @@ -77,6 +83,11 @@ export const Image = Node.create({ parseHTML: el => parsePx((el as HTMLElement).getAttribute('data-rotation')) ?? 0, renderHTML: () => ({}), }, + wrap: { + default: 'inline', + parseHTML: el => (el as HTMLElement).getAttribute('data-wrap') ?? 'inline', + renderHTML: () => ({}), + }, }; }, @@ -88,12 +99,17 @@ export const Image = Node.create({ const w = node.attrs.width as number | null; const h = node.attrs.height as number | null; const rot = (node.attrs.rotation as number) || 0; + const wrap = (node.attrs.wrap as WrapMode) || 'inline'; const style = [ w ? `width:${w}px` : '', h ? `height:${h}px` : '', rot ? `transform:rotate(${rot}deg)` : '', ].filter(Boolean).join(';'); - return ['img', mergeAttributes(HTMLAttributes, { ...(style ? { style } : {}), ...(rot ? { 'data-rotation': String(rot) } : {}) })]; + return ['img', mergeAttributes(HTMLAttributes, { + ...(style ? { style } : {}), + ...(rot ? { 'data-rotation': String(rot) } : {}), + ...(wrap !== 'inline' ? { 'data-wrap': wrap } : {}), + })]; }, addCommands() { @@ -102,6 +118,16 @@ export const Image = Node.create({ attrs => ({ commands }) => commands.insertContent({ type: this.name, attrs }), + + // Set the wrap mode on the selected image. + setImageWrap: + (wrap: WrapMode) => + ({ state, dispatch }) => { + const sel = state.selection; + if (!(sel instanceof NodeSelection) || sel.node.type.name !== this.name) return false; + if (dispatch) dispatch(state.tr.setNodeMarkup(sel.from, undefined, { ...sel.node.attrs, wrap })); + return true; + }, }; }, @@ -167,6 +193,7 @@ class ImageView { this.dom.appendChild(this.badge); this.applyLayout(this.attrW(), this.attrH(), this.attrRot()); + this.applyWrap(); } private showBadge(w: number, h: number): void { @@ -178,6 +205,7 @@ class ImageView { private attrW(): number | null { const w = this.node.attrs.width; return typeof w === 'number' ? w : null; } private attrH(): number | null { const h = this.node.attrs.height; return typeof h === 'number' ? h : null; } private attrRot(): number { return (this.node.attrs.rotation as number) || 0; } + private attrWrap(): WrapMode { const w = this.node.attrs.wrap; return w === 'left' || w === 'right' || w === 'topBottom' ? w : 'inline'; } // Size the rotor to w×h, rotate it about its centre, and grow the axis-aligned // wrapper to the rotated bounding box so the line reserves the right space. @@ -199,6 +227,33 @@ class ImageView { this.rotor.style.transform = `translate(-50%, -50%) rotate(${deg}deg)`; } + // Float the wrapper per wrap mode so text flows beside it at its anchor paragraph + // (left/right) or only above/below it (topBottom). Dragging re-anchors the image + // via ProseMirror's native node move, so it always floats where the text actually is. + private applyWrap(): void { + const d = this.dom; + const wrap = this.attrWrap(); + d.style.float = ''; + d.style.display = ''; + d.style.clear = ''; + d.style.margin = ''; + if (wrap === 'left') { + d.style.float = 'left'; + d.style.margin = '0 14px 6px 0'; + } else if (wrap === 'right') { + d.style.float = 'right'; + d.style.margin = '0 0 6px 14px'; + } else if (wrap === 'topBottom') { + // A full-width float (not display:block — which on an inline atom node view + // disrupts ProseMirror's view + page-break spacer widgets): text can only flow + // above/below it. applyLayout reset the width to the box; widen to the column here. + d.style.float = 'left'; + d.style.clear = 'both'; + d.style.width = '100%'; + d.style.margin = '6px 0'; + } + } + private adoptNaturalSize(): void { if (this.attrW() != null && this.attrH() != null) return; const nw = this.img.naturalWidth; @@ -270,6 +325,7 @@ class ImageView { } moved = true; this.applyLayout(lastW, lastH, deg); + this.applyWrap(); this.showBadge(lastW, lastH); }; const finish = (): void => { @@ -304,6 +360,7 @@ class ImageView { lastDeg = ((Math.round(ang) % 360) + 360) % 360; moved = true; this.applyLayout(w, h, lastDeg); + this.applyWrap(); }; const finish = (): void => { win.removeEventListener('mousemove', move); @@ -321,6 +378,7 @@ class ImageView { if (this.img.getAttribute('src') !== src) this.img.src = src; this.img.alt = (node.attrs.alt as string) ?? ''; this.applyLayout(this.attrW(), this.attrH(), this.attrRot()); + this.applyWrap(); return true; } @@ -336,11 +394,11 @@ class ImageView { return true; } - // Keep ProseMirror out of handle interactions so a drag isn't hijacked into a - // node drag or selection change. + // Keep ProseMirror out of handle interactions only; dragging the image itself runs + // PM's native node move (which re-anchors a floating image to the drop paragraph). stopEvent(event: Event): boolean { - const t = event.target as HTMLElement | null; if (!event.type.startsWith('mouse')) return false; + const t = event.target as HTMLElement | null; return !!t?.classList?.contains('image-resize-handle') || !!t?.classList?.contains('image-rotate-handle'); } } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 0c67d95..55ae651 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -152,8 +152,10 @@ function replaceTabs(node: TiptapNode): TiptapNode { } // One embedded picture, collected by replaceImages and emitted by applyImages. -// bytes is ArrayBuffer-backed to match fflate's zip entry map. rotationDeg is CW. -type ImageExport = { path: string; bytes: Uint8Array; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number }; +// bytes is ArrayBuffer-backed to match fflate's zip entry map. rotationDeg is CW; +// wrap floats the frame at its anchor paragraph (left/right/top-bottom). +type WrapMode = 'inline' | 'left' | 'right' | 'topBottom'; +type ImageExport = { path: string; bytes: Uint8Array; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode }; function base64ToBytes(b64: string): Uint8Array { const bin = atob(b64); @@ -180,16 +182,20 @@ function imageDescriptor(node: TiptapNode, index: number): ImageExport | null { } if (!bytes.length) return null; const round3 = (v: number) => Math.round(v * 1000) / 1000; + const pxToCm = (px: number) => round3((px * 2.54) / 96); const w = typeof node.attrs?.width === 'number' ? node.attrs.width : 0; const h = typeof node.attrs?.height === 'number' ? node.attrs.height : 0; + const wrapAttr = node.attrs?.wrap; + const wrap: WrapMode = wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline'; return { path: `Pictures/image${index + 1}.${EXT_BY_MIME[mimeType] ?? 'png'}`, bytes, mimeType, - widthCm: w > 0 ? round3((w * 2.54) / 96) : 0, - heightCm: h > 0 ? round3((h * 2.54) / 96) : 0, + widthCm: w > 0 ? pxToCm(w) : 0, + heightCm: h > 0 ? pxToCm(h) : 0, alt: typeof node.attrs?.alt === 'string' ? node.attrs.alt : '', rotationDeg: typeof node.attrs?.rotation === 'number' ? node.attrs.rotation : 0, + wrap, }; } @@ -1032,19 +1038,57 @@ function imageTransform(img: ImageExport): string { return ` draw:transform="rotate (${a.toFixed(6)}) translate (${tx}cm ${ty}cm)"`; } -// One for an as-character image. Position is the text-flow anchor; size -// is the exact svg geometry and rotation the draw:transform, so both round-trip. +// ODF style:wrap is the side TEXT flows on (inverse of the image side); horizontal-pos +// places the frame on that side. topBottom ⇒ no wrap, centred. +function imageWrapProps(wrap: WrapMode): string { + if (wrap === 'left') return 'style:wrap="right" style:horizontal-pos="left"'; + if (wrap === 'right') return 'style:wrap="left" style:horizontal-pos="right"'; + return 'style:wrap="none" style:horizontal-pos="center"'; +} + +// Graphic style for a floating frame (wrap + side, anchored to the paragraph top). +// Inline images need none. Injected into content.xml automatic-styles by applyImages. +function imageGraphicStyle(img: ImageExport, index: number): string { + if (img.wrap === 'inline') return ''; + return ( + `` + + `` + + `` + ); +} + +// One . Inline = as-character (text-flow anchor). Floating = paragraph +// anchor + a graphic style (wrap + side). Size is the exact svg geometry and rotation +// the draw:transform, so all of it round-trips. function imageFrameXml(img: ImageExport, index: number): string { const dims = (img.widthCm ? ` svg:width="${img.widthCm}cm"` : '') + (img.heightCm ? ` svg:height="${img.heightCm}cm"` : ''); const title = img.alt ? `${escapeXml(img.alt)}` : ''; + const inner = `${title}`; + const anchor = img.wrap === 'inline' ? 'as-char' : 'paragraph'; + const styleName = img.wrap === 'inline' ? '' : ` draw:style-name="ImgFr${index + 1}"`; return ( - `` + - `${title}` + `` + + `${inner}` ); } +// Inject automatic styles, tolerating an empty/self-closed or absent section. +function injectAutomaticStyles(content: string, styles: string): string { + if (!styles) return content; + if (content.includes('')) { + return content.replace('', `${styles}`); + } + if (content.includes('')) { + return content.replace('', `${styles}`); + } + return content.replace('${styles}, add the binary // picture files, and register each in META-INF/manifest.xml. content.xml already // declares the draw/svg/xlink namespaces and rezipOdt handles binary entries. @@ -1060,6 +1104,8 @@ function applyImages(odtBytes: Uint8Array, images: ImageExport[]): Uint8Array { const img = images[i]; return img ? imageFrameXml(img, i) : ''; }); + // Graphic styles for the floating frames. + content = injectAutomaticStyles(content, images.map((img, i) => imageGraphicStyle(img, i)).join('')); files['content.xml'] = strToU8(content); for (const img of images) files[img.path] = img.bytes; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 2982676..00b801e 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -79,9 +79,18 @@ function loadImageDataUrl(href: string, ctx: Ctx): string | null { return url; } -// A → an inline image node. Size comes from the frame's -// svg geometry (cm → px), so it round-trips unchanged; page-anchored (floating) -// frames are flattened to inline with a warning. +// ODF style:wrap names the side TEXT flows on (inverse of the image side); pick a +// side from horizontal-pos when the value doesn't name one (parallel/dynamic/…). +function wrapModeFromOdf(wrapVal: string | undefined, hpos: string | undefined): 'left' | 'right' | 'topBottom' { + if (wrapVal === 'none') return 'topBottom'; + if (wrapVal === 'left') return 'right'; // text on left ⇒ image on the right + if (wrapVal === 'right') return 'left'; // text on right ⇒ image on the left + return hpos && /right/.test(hpos) ? 'right' : 'left'; +} + +// A → an image node. Size comes from the frame's svg +// geometry (cm → px). as-char frames stay inline; paragraph/page-anchored frames +// with a wrap become floating (wrap mode + svg:x/svg:y position). function convertFrame(frame: Element, ctx: Ctx): Node | null { const image = frame.getElementsByTagNameNS(NS.draw, 'image')[0]; if (!image) return null; @@ -92,9 +101,6 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null { ctx.warnings.add('Some images could not be read and were skipped'); return null; } - if (frame.getAttributeNS(NS.text, 'anchor-type') === 'page') { - ctx.warnings.add('Floating images were placed inline (position may differ)'); - } const attrs: Record = { src }; const wCm = lengthToCm(frame.getAttributeNS(NS.svg, 'width')); const hCm = lengthToCm(frame.getAttributeNS(NS.svg, 'height')); @@ -109,6 +115,13 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null { const deg = ((Math.round((-parseFloat(rot[1]) * 180) / Math.PI) % 360) + 360) % 360; if (deg) attrs.rotation = deg; } + // Floating frame → wrap mode (side). (as-char stays inline.) + const anchor = frame.getAttributeNS(NS.text, 'anchor-type'); + const gp = ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name')); + const wrapVal = gp['style:wrap']; + if ((anchor && anchor !== 'as-char') || wrapVal) { + attrs.wrap = wrapModeFromOdf(wrapVal, gp['style:horizontal-pos']); + } return { type: 'image', attrs }; } diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 790ff80..6ed376b 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -90,6 +90,7 @@ function entryFromStyleElement(el: Element): StyleEntry { collectProps(el, 'table-column-properties', entry.misc); collectProps(el, 'table-row-properties', entry.misc); collectProps(el, 'table-cell-properties', entry.misc); + collectProps(el, 'graphic-properties', entry.misc); return entry; } @@ -197,6 +198,11 @@ export class StyleResolver { return styleName ? this.merged('text', styleName).text : {}; } + // Graphic-properties of a draw:frame's style (style:wrap, positioning, …). + graphicProps(styleName: string | null): PropMap { + return styleName ? this.merged('graphic', styleName).misc : {}; + } + // The document's default spell-check language, read from the base Standard // paragraph style (falls back to the paragraph default-style). null when unset. documentLanguage(): { language: string; country: string } | null { diff --git a/src/styles/editor.css b/src/styles/editor.css index 8ae6665..46bc5c1 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -155,6 +155,11 @@ vertical-align: bottom; line-height: 0; } +/* While selected, lift the node (and its handles) above the header/footer layer + (z-index 22) so a rotate/resize handle poking into a margin stays grabbable. */ +.paper .tiptap .image-node.image-selected { + z-index: 25; +} .paper .tiptap .image-rotor { position: absolute; left: 50%;