Files
edentext/src/lib/editor/image.ts
T

347 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Node, mergeAttributes } from '@tiptap/core';
import type { Editor } from '@tiptap/core';
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); 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; 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',
inline: true,
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
src: { default: null },
alt: { default: '' },
// 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),
renderHTML: () => ({}),
},
height: {
default: null,
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: () => ({}),
},
};
},
parseHTML() {
return [{ tag: 'img[src]' }];
},
renderHTML({ HTMLAttributes, node }) {
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 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() {
return {
setImage:
attrs =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs }),
};
},
addNodeView() {
return ({ node, editor, getPos }) => new ImageView(node as PMNode, editor, getPos as () => number);
},
// Dragging the image runs ProseMirror's native node move; the drop cursor paints
// a caret at the would-be drop position so the user sees where it lands (like Word).
addProseMirrorPlugins() {
return [dropCursor({ color: '#3b82f6', width: 2 })];
},
});
// 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;
constructor(node: PMNode, editor: Editor, getPos: () => number) {
this.node = node;
this.editor = editor;
this.getPos = getPos;
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) ?? '';
// 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 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 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 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 startW = this.rotor.offsetWidth;
const startH = this.rotor.offsetHeight;
if (!startW || !startH) return;
const aspect = startW / startH;
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; }
// 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));
}
moved = true;
this.applyLayout(lastW, lastH, deg);
this.showBadge(lastW, lastH);
};
const finish = (): void => {
win.removeEventListener('mousemove', move);
win.removeEventListener('mouseup', finish);
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);
}
update(node: PMNode): boolean {
if (node.type !== this.node.type) return false;
this.node = node;
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.applyLayout(this.attrW(), this.attrH(), this.attrRot());
return true;
}
selectNode(): void {
this.dom.classList.add('image-selected');
}
deselectNode(): void {
this.dom.classList.remove('image-selected');
}
ignoreMutation(): boolean {
return true;
}
// 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;
if (!event.type.startsWith('mouse')) return false;
return !!t?.classList?.contains('image-resize-handle') || !!t?.classList?.contains('image-rotate-handle');
}
}