feat: add image width height adaption and rotation

This commit is contained in:
Steffen Becker committed 2026-06-24 21:49:59 +02:00
1 parent 20eb94d7c0
commit f79be768f9
4 files changed
+289 -74

No files matched your search

+197 -60
View File
@@ -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<ReturnType> {
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: <img> 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 <img>, 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<string, unknown>): 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 <img> 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');
}
}
+21 -5
View File
@@ -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<ArrayBuffer>; 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<ArrayBuffer>; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number };
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
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 <draw:frame> 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 <draw:frame> 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 ? `<svg:title>${escapeXml(img.alt)}</svg:title>` : '';
return (
`<draw:frame draw:name="Image${index + 1}" text:anchor-type="as-char" draw:z-index="${index}"${dims}>` +
`<draw:frame draw:name="Image${index + 1}" text:anchor-type="as-char" draw:z-index="${index}"${dims}${imageTransform(img)}>` +
`<draw:image xlink:href="${img.path}"/>${title}</draw:frame>`
);
}
+7
View File
@@ -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 };
}
+64 -9
View File
@@ -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 <img>, 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 {