From f79be768f91019329691094610c6484d896dc19f Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sun, 21 Jun 2026 22:15:21 +0200 Subject: [PATCH] feat: add image width height adaption and rotation --- src/lib/editor/image.ts | 257 ++++++++++++++++++++++++++++++---------- src/lib/export/odt.ts | 26 +++- src/lib/import/odt.ts | 7 ++ src/styles/editor.css | 73 ++++++++++-- 4 files changed, 289 insertions(+), 74 deletions(-) diff --git a/src/lib/editor/image.ts b/src/lib/editor/image.ts index 4182068..8155bc3 100644 --- a/src/lib/editor/image.ts +++ b/src/lib/editor/image.ts @@ -4,25 +4,50 @@ import type { Node as PMNode } from '@tiptap/pm/model'; 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); export converts to cm and -// back, so size round-trips exactly. The node view adds aspect-locked resize handles. +// 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. declare module '@tiptap/core' { interface Commands { image: { - setImage: (attrs: { src: string; alt?: string; width?: number | null; height?: number | null }) => ReturnType; + setImage: (attrs: { src: string; alt?: string; width?: number | null; height?: number | null; rotation?: number }) => ReturnType; }; } } const MIN_SIZE_PX = 24; +// Corners keep aspect (Word-style); edges (n/s/e/w) change one dimension only. +const HANDLES: { k: string; x: -1 | 0 | 1; y: -1 | 0 | 1; aspect: boolean }[] = [ + { k: 'nw', x: -1, y: -1, aspect: true }, + { k: 'n', x: 0, y: -1, aspect: false }, + { k: 'ne', x: 1, y: -1, aspect: true }, + { k: 'e', x: 1, y: 0, aspect: false }, + { k: 'se', x: 1, y: 1, aspect: true }, + { k: 's', x: 0, y: 1, aspect: false }, + { k: 'sw', x: -1, y: 1, aspect: true }, + { k: 'w', x: -1, y: 0, aspect: false }, +]; + function parsePx(value: string | null): number | null { if (!value) return null; const n = parseFloat(value); return Number.isFinite(n) ? Math.round(n) : null; } +const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); + +// The page text height in px, capping how tall an image can be stretched. Read live +// from the :root vars the editor maintains (orientation/margins change them). +function pageContentHeightPx(): number { + const cs = getComputedStyle(document.documentElement); + const h = + parseFloat(cs.getPropertyValue('--user-page-height')) - + parseFloat(cs.getPropertyValue('--user-margin-top')) - + parseFloat(cs.getPropertyValue('--user-margin-bottom')); + return h > 0 ? h : 4000; +} + export const Image = Node.create({ name: 'image', group: 'inline', @@ -35,8 +60,8 @@ export const Image = Node.create({ return { src: { default: null }, alt: { default: '' }, - // px @96dpi; rendered as inline style (not width/height attrs) so the - // value stays in one place for the node view to drive. + // px @96dpi and CW degrees; kept off the width/height attrs (rendered via + // inline style / data-rotation) so one place drives the node view. width: { default: null, parseHTML: el => parsePx(el.getAttribute('width') ?? (el as HTMLElement).style.width), @@ -47,6 +72,11 @@ export const Image = Node.create({ parseHTML: el => parsePx(el.getAttribute('height') ?? (el as HTMLElement).style.height), renderHTML: () => ({}), }, + rotation: { + default: 0, + parseHTML: el => parsePx((el as HTMLElement).getAttribute('data-rotation')) ?? 0, + renderHTML: () => ({}), + }, }; }, @@ -57,8 +87,13 @@ export const Image = Node.create({ renderHTML({ HTMLAttributes, node }) { const w = node.attrs.width as number | null; const h = node.attrs.height as number | null; - const style = [w ? `width:${w}px` : '', h ? `height:${h}px` : ''].filter(Boolean).join(';'); - return ['img', mergeAttributes(HTMLAttributes, style ? { style } : {})]; + const rot = (node.attrs.rotation as number) || 0; + 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) } : {}) })]; }, addCommands() { @@ -81,12 +116,14 @@ export const Image = Node.create({ }, }); -// Node view: wrapped in an inline-block span with four corner handles shown -// while the node is selected. Mirrors the zoom trick from tableRowResize.ts -// (offset size is unscaled, getBoundingClientRect is scaled → ratio is the zoom). +// Node view: a bounding-box wrapper (reserves the rotated footprint for text flow) +// holds a centered, rotated "rotor" with the , eight resize handles (corners +// aspect-locked, edges single-axis) and a rotate grip — all rotating with the image. class ImageView { dom: HTMLElement; + private rotor: HTMLElement; private img: HTMLImageElement; + private badge: HTMLElement; private node: PMNode; private editor: Editor; private getPos: () => number; @@ -99,79 +136,180 @@ class ImageView { this.dom = document.createElement('span'); this.dom.className = 'image-node'; + this.rotor = document.createElement('span'); + this.rotor.className = 'image-rotor'; + this.img = document.createElement('img'); this.img.src = (node.attrs.src as string) ?? ''; this.img.alt = (node.attrs.alt as string) ?? ''; - this.applySize(); - this.dom.appendChild(this.img); + // Pasted/HTML images may arrive without dimensions; adopt their natural size + // (capped to the text column) so every image is explicitly sized. + this.img.onload = () => this.adoptNaturalSize(); + this.rotor.appendChild(this.img); - for (const corner of ['nw', 'ne', 'sw', 'se'] as const) { - const handle = document.createElement('span'); - handle.className = `image-resize-handle image-resize-${corner}`; - handle.addEventListener('mousedown', e => this.startResize(e as MouseEvent, corner)); - this.dom.appendChild(handle); + for (const cfg of HANDLES) { + const h = document.createElement('span'); + h.className = `image-resize-handle image-resize-${cfg.k}`; + h.addEventListener('mousedown', e => this.startResize(e as MouseEvent, cfg)); + this.rotor.appendChild(h); } + const rot = document.createElement('span'); + rot.className = 'image-rotate-handle'; + rot.addEventListener('mousedown', e => this.startRotate(e as MouseEvent)); + this.rotor.appendChild(rot); + + this.dom.appendChild(this.rotor); + + // Live "Width … × Height …" readout shown while resizing (on the axis-aligned + // wrapper so it stays upright even when the image is rotated). + this.badge = document.createElement('span'); + this.badge.className = 'image-size-badge'; + this.dom.appendChild(this.badge); + + this.applyLayout(this.attrW(), this.attrH(), this.attrRot()); } - private applySize(): void { - const w = this.node.attrs.width as number | null; - const h = this.node.attrs.height as number | null; - this.img.style.width = w ? `${w}px` : ''; - this.img.style.height = h ? `${h}px` : ''; + private showBadge(w: number, h: number): void { + const cm = (px: number) => ((px * 2.54) / 96).toFixed(2); + this.badge.textContent = `Width ${cm(w)} cm × Height ${cm(h)} cm`; + this.badge.style.display = 'block'; } - private startResize(event: MouseEvent, corner: 'nw' | 'ne' | 'sw' | 'se'): void { + 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; } + + // 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. + private applyLayout(w: number | null, h: number | null, deg: number): void { + if (w && h) { + this.rotor.style.width = `${w}px`; + this.rotor.style.height = `${h}px`; + const rad = (deg * Math.PI) / 180; + const bw = Math.abs(w * Math.cos(rad)) + Math.abs(h * Math.sin(rad)); + const bh = Math.abs(w * Math.sin(rad)) + Math.abs(h * Math.cos(rad)); + this.dom.style.width = `${bw}px`; + this.dom.style.height = `${bh}px`; + } else { + this.rotor.style.width = ''; + this.rotor.style.height = ''; + this.dom.style.width = ''; + this.dom.style.height = ''; + } + this.rotor.style.transform = `translate(-50%, -50%) rotate(${deg}deg)`; + } + + private adoptNaturalSize(): void { + if (this.attrW() != null && this.attrH() != null) return; + const nw = this.img.naturalWidth; + const nh = this.img.naturalHeight; + if (!nw || !nh) return; + const maxW = this.boxMaxWidth(); + let w = nw; + let h = nh; + if (w > maxW) { h = Math.round((h * maxW) / w); w = maxW; } + this.commit({ width: Math.round(w), height: Math.round(h) }); + } + + // Largest width an image may take: the containing cell, else the page text column. + private boxMaxWidth(): number { + const box = (this.dom.closest('td,th') ?? this.dom.closest('.tiptap')) as HTMLElement | null; + if (!box) return 10000; + const cs = getComputedStyle(box); + const w = box.clientWidth - parseFloat(cs.paddingLeft || '0') - parseFloat(cs.paddingRight || '0'); + return Math.max(MIN_SIZE_PX, w || 10000); + } + + private commit(attrs: Record): void { + const pos = this.getPos(); + if (typeof pos !== 'number') return; + this.editor.view.dispatch(this.editor.state.tr.setNodeMarkup(pos, undefined, { ...this.node.attrs, ...attrs })); + } + + private startResize(event: MouseEvent, cfg: typeof HANDLES[number]): void { event.preventDefault(); event.stopPropagation(); if (!this.editor.isEditable) return; - const startX = event.clientX; - const startW = this.img.offsetWidth; - const startH = this.img.offsetHeight; + const startW = this.rotor.offsetWidth; + const startH = this.rotor.offsetHeight; if (!startW || !startH) return; const aspect = startW / startH; - const zoom = this.img.getBoundingClientRect().width / startW || 1; - // Left-side corners grow when dragged left; right-side corners when dragged right. - const dirX = corner === 'ne' || corner === 'se' ? 1 : -1; - // Never wider than the containing cell, or the page text column outside tables. - // clientWidth/padding are unscaled layout px, matching our doc-px sizing. - const box = (this.dom.closest('td,th') ?? this.dom.closest('.tiptap')) as HTMLElement | null; - let maxW = startW; - if (box) { - const cs = getComputedStyle(box); - maxW = box.clientWidth - parseFloat(cs.paddingLeft || '0') - parseFloat(cs.paddingRight || '0'); - } - maxW = Math.max(MIN_SIZE_PX, maxW || startW); + const deg = this.attrRot(); + const th = (deg * Math.PI) / 180; + const cosT = Math.cos(th); + const sinT = Math.sin(th); + // The wrapper is axis-aligned, so its scaled/unscaled width ratio is the zoom. + const zoom = this.dom.getBoundingClientRect().width / this.dom.offsetWidth || 1; + const maxW = this.boxMaxWidth(); + const maxH = pageContentHeightPx(); + const sx = event.clientX; + const sy = event.clientY; const win = this.dom.ownerDocument.defaultView ?? window; - let lastW = startW; let lastH = startH; let moved = false; const move = (e: MouseEvent): void => { - if (!e.buttons) { - finish(); - return; + if (!e.buttons) { finish(); return; } + // Un-rotate the screen delta onto the image's own axes so handles track the + // pointer at any rotation. + const dx = (e.clientX - sx) / zoom; + const dy = (e.clientY - sy) / zoom; + const lx = dx * cosT + dy * sinT; + const ly = -dx * sinT + dy * cosT; + if (cfg.aspect) { + let w = clamp(startW + cfg.x * lx, MIN_SIZE_PX, maxW); + let h = w / aspect; + if (h > maxH) { h = maxH; w = h * aspect; } + lastW = Math.round(w); + lastH = Math.round(h); + } else { + if (cfg.x) lastW = Math.round(clamp(startW + cfg.x * lx, MIN_SIZE_PX, maxW)); + if (cfg.y) lastH = Math.round(clamp(startH + cfg.y * ly, MIN_SIZE_PX, maxH)); } - const dx = ((e.clientX - startX) / zoom) * dirX; - lastW = Math.max(MIN_SIZE_PX, Math.min(maxW, Math.round(startW + dx))); - lastH = Math.max(MIN_SIZE_PX, Math.round(lastW / aspect)); moved = true; - this.img.style.width = `${lastW}px`; - this.img.style.height = `${lastH}px`; + this.applyLayout(lastW, lastH, deg); + this.showBadge(lastW, lastH); }; - const finish = (): void => { win.removeEventListener('mousemove', move); win.removeEventListener('mouseup', finish); - if (!moved) return; - const pos = this.getPos(); - if (typeof pos !== 'number') return; - this.editor.view.dispatch( - this.editor.state.tr.setNodeMarkup(pos, undefined, { ...this.node.attrs, width: lastW, height: lastH }), - ); + this.badge.style.display = 'none'; + if (moved) this.commit({ width: lastW, height: lastH }); }; + win.addEventListener('mousemove', move); + win.addEventListener('mouseup', finish); + } + private startRotate(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + if (!this.editor.isEditable) return; + + const w = this.rotor.offsetWidth; + const h = this.rotor.offsetHeight; + const rect = this.dom.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const win = this.dom.ownerDocument.defaultView ?? window; + let lastDeg = this.attrRot(); + let moved = false; + + const move = (e: MouseEvent): void => { + if (!e.buttons) { finish(); return; } + // Handle sits above centre, so 0° is straight up; Shift snaps to 15°. + let ang = (Math.atan2(e.clientY - cy, e.clientX - cx) * 180) / Math.PI + 90; + if (e.shiftKey) ang = Math.round(ang / 15) * 15; + lastDeg = ((Math.round(ang) % 360) + 360) % 360; + moved = true; + this.applyLayout(w, h, lastDeg); + }; + const finish = (): void => { + win.removeEventListener('mousemove', move); + win.removeEventListener('mouseup', finish); + if (moved) this.commit({ rotation: lastDeg }); + }; win.addEventListener('mousemove', move); win.addEventListener('mouseup', finish); } @@ -182,7 +320,7 @@ class ImageView { const src = (node.attrs.src as string) ?? ''; if (this.img.getAttribute('src') !== src) this.img.src = src; this.img.alt = (node.attrs.alt as string) ?? ''; - this.applySize(); + this.applyLayout(this.attrW(), this.attrH(), this.attrRot()); return true; } @@ -194,16 +332,15 @@ class ImageView { this.dom.classList.remove('image-selected'); } - // Atom node: no ProseMirror-managed content. Ignore the load and our own - // size/handle pokes so PM doesn't try to re-read the DOM. ignoreMutation(): boolean { return true; } - // Keep ProseMirror out of resize-handle interactions so the drag isn't hijacked - // into a node drag or selection change. + // Keep ProseMirror out of handle interactions so a drag isn't hijacked into a + // node drag or selection change. stopEvent(event: Event): boolean { const t = event.target as HTMLElement | null; - return event.type.startsWith('mouse') && !!t?.classList?.contains('image-resize-handle'); + if (!event.type.startsWith('mouse')) return false; + 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 8d67085..0c67d95 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -152,8 +152,8 @@ 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. -type ImageExport = { path: string; bytes: Uint8Array; mimeType: string; widthCm: number; heightCm: number; alt: string }; +// 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 }; function base64ToBytes(b64: string): Uint8Array { const bin = atob(b64); @@ -189,6 +189,7 @@ function imageDescriptor(node: TiptapNode, index: number): ImageExport | null { widthCm: w > 0 ? round3((w * 2.54) / 96) : 0, heightCm: h > 0 ? round3((h * 2.54) / 96) : 0, alt: typeof node.attrs?.alt === 'string' ? node.attrs.alt : '', + rotationDeg: typeof node.attrs?.rotation === 'number' ? node.attrs.rotation : 0, }; } @@ -1016,15 +1017,30 @@ function applyInlineSentinels(odtBytes: Uint8Array): Uint8Array { return rezipOdt(files); } -// One for an as-character image. Position is purely the text-flow -// anchor; size is the exact svg geometry, so it round-trips unchanged. +// ODF draw:transform for a rotated frame. ODF rotate() is CCW radians (ours is CW +// degrees) about the origin, so the translate re-centres it on the unrotated box. +function imageTransform(img: ImageExport): string { + if (!img.rotationDeg || !img.widthCm || !img.heightCm) return ''; + const a = (-img.rotationDeg * Math.PI) / 180; + const cw = img.widthCm / 2; + const ch = img.heightCm / 2; + const cos = Math.cos(a); + const sin = Math.sin(a); + const r3 = (v: number) => Math.round(v * 1000) / 1000; + const tx = r3(cos * cw - sin * ch - cw); + const ty = r3(sin * cw + cos * ch - ch); + 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. 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)}` : ''; return ( - `` + + `` + `${title}` ); } diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index d962e56..2982676 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -102,6 +102,13 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null { if (hCm != null) attrs.height = Math.round(cmToPx(hCm)); const title = frame.getElementsByTagNameNS(NS.svg, 'title')[0]?.textContent; if (title) attrs.alt = title; + // draw:transform rotate() is CCW radians; the editor stores CW degrees. + const transform = frame.getAttributeNS(NS.draw, 'transform'); + const rot = transform && /rotate\s*\(\s*(-?[\d.eE+]+)\s*\)/.exec(transform); + if (rot) { + const deg = ((Math.round((-parseFloat(rot[1]) * 180) / Math.PI) % 360) + 360) % 360; + if (deg) attrs.rotation = deg; + } return { type: 'image', attrs }; } diff --git a/src/styles/editor.css b/src/styles/editor.css index e26b7cc..8ae6665 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -147,20 +147,26 @@ margin-bottom: 0.212cm; } -/* Inline, as-character images (image.ts). The span is the node view wrapper; it - carries selection chrome and positions the corner resize handles. */ +/* Inline, as-character images (image.ts). The wrapper reserves the rotated bounding + box; the rotor holds the , resize handles and rotate grip, rotating together. */ .paper .tiptap .image-node { position: relative; display: inline-block; - /* sit on the text baseline like a tall character, no descender gap */ vertical-align: bottom; line-height: 0; } -.paper .tiptap .image-node img { - max-width: 100%; - height: auto; +.paper .tiptap .image-rotor { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); } -.paper .tiptap .image-node.image-selected img { +.paper .tiptap .image-rotor img { + width: 100%; + height: 100%; + display: block; +} +.paper .tiptap .image-node.image-selected .image-rotor img { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; } @@ -174,13 +180,62 @@ border-radius: 50%; z-index: 30; } -.paper .tiptap .image-node.image-selected .image-resize-handle { +.paper .tiptap .image-node.image-selected .image-resize-handle, +.paper .tiptap .image-node.image-selected .image-rotate-handle { display: block; } .paper .tiptap .image-resize-nw { top: -5px; left: -5px; cursor: nwse-resize; } +.paper .tiptap .image-resize-n { top: -5px; left: 50%; margin-left: -5px; cursor: ns-resize; } .paper .tiptap .image-resize-ne { top: -5px; right: -5px; cursor: nesw-resize; } -.paper .tiptap .image-resize-sw { bottom: -5px; left: -5px; cursor: nesw-resize; } +.paper .tiptap .image-resize-e { top: 50%; right: -5px; margin-top: -5px; cursor: ew-resize; } .paper .tiptap .image-resize-se { bottom: -5px; right: -5px; cursor: nwse-resize; } +.paper .tiptap .image-resize-s { bottom: -5px; left: 50%; margin-left: -5px; cursor: ns-resize; } +.paper .tiptap .image-resize-sw { bottom: -5px; left: -5px; cursor: nesw-resize; } +.paper .tiptap .image-resize-w { top: 50%; left: -5px; margin-top: -5px; cursor: ew-resize; } +/* Rotate grip floats above top-centre with a short connector line; the icon is a + circular rotation arrow (like Word). */ +.paper .tiptap .image-rotate-handle { + display: none; + position: absolute; + top: -28px; + left: 50%; + width: 16px; + height: 16px; + margin-left: -8px; + background: #fff center / 11px 11px no-repeat + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233b82f6' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 1 1-2.6-6.4'/%3E%3Cpath d='M21 4v5h-5'/%3E%3C/svg%3E"); + border: 1px solid var(--accent, #3b82f6); + border-radius: 50%; + cursor: grab; + z-index: 31; +} +.paper .tiptap .image-rotate-handle::before { + content: ''; + position: absolute; + top: 15px; + left: 50%; + width: 1px; + height: 13px; + margin-left: -0.5px; + background: var(--accent, #3b82f6); +} +/* Live size readout while resizing. */ +.paper .tiptap .image-size-badge { + display: none; + position: absolute; + top: 6px; + left: 50%; + transform: translateX(-50%); + padding: 2px 7px; + background: rgba(0, 0, 0, 0.75); + color: #fff; + font-size: 11px; + line-height: 1.4; + white-space: nowrap; + border-radius: 4px; + pointer-events: none; + z-index: 32; +} /* Tab stops every 1.25cm, matching ODF's default tab interval. */ .paper .tiptap {