Files
edentext/src/lib/editor/extensions/textBox.ts
T
Steffen Becker 4cda289272 feat: a shape can be a triangle, a star or a block arrow
Twelve presets beside the three rectangular ones. Each outline is one
polygon in utils/shapes.ts, given once: the node view draws it as an SVG
path, the ODF export scales the same points into draw:enhanced-path, and
Word gets the preset's name and draws its own geometry.

A gallery in the box toolbar and the ribbon renders its tiles from that
same table, so a new shape needs no icon. Verified through LibreOffice
from both formats; a preset we can't draw still imports as a warning.
2026-08-14 08:02:08 +02:00

700 lines
28 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 { NodeSelection, TextSelection, Plugin } from '@tiptap/pm/state';
import type { EditorState } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { EditorView } from '@tiptap/pm/view';
import { HANDLES, MIN_SIZE_PX, clamp, parsePx, frameMargins, pageContentHeightPx, type WrapMode } from './image';
import { SHAPES, shapePath, isShapeKind, type ShapeKind } from '../../utils/shapes';
// cm attribute value → number, for the frame offsets (px ones use parsePx).
const parseCmAttr = (v: string | null): number | null => {
const n = v == null ? NaN : parseFloat(v);
return Number.isFinite(n) ? n : null;
};
// A text box / basic shape: a block-level frame with editable block content, a fill, a
// stroke, and the image's wrap model (inline = in-flow; left/right/topBottom float).
// Round-trips to ODF <draw:frame>/<draw:text-box>/<draw:custom-shape> and DOCX <wps:wsp>.
export type { ShapeKind };
// Fixed text inset inside the frame; exported as fo:padding / wps:bodyPr insets.
export const TEXTBOX_PADDING_CM = 0.15;
const paddingPx = (cm: unknown) => ((typeof cm === 'number' ? cm : TEXTBOX_PADDING_CM) * 96) / 2.54;
// Extra per-axis inset that fits the text area into an ellipse's inscribed
// rectangle (half-axis / √2), so text follows the oval instead of the bbox.
const ELLIPSE_INSET_RATIO = (1 - 1 / Math.SQRT2) / 2;
const DEFAULT_WIDTH_PX = 280;
const DEFAULT_HEIGHT_PX = 96;
const PX_PER_PT = 96 / 72;
export interface TextBoxAttrs {
width: number | null;
height: number | null;
rotation: number;
wrap: WrapMode;
wrapOffset: number | null; // cm from the text column's left edge
wrapOffsetY: number | null; // cm below the anchor paragraph
wrapDist: number | null; // cm of gap to the text beside it
wrapAlign: string | null; // 'center'/'right' = set against the middle/far end
paddingCm: number; // inset ring around the text (ODF fo:padding)
shapeKind: ShapeKind;
fillColor: string | null;
strokeColor: string | null;
strokeWidthPt: number;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
textBox: {
insertTextBox: () => ReturnType;
setTextBoxAttrs: (attrs: Partial<TextBoxAttrs>) => ReturnType;
};
}
}
// The text box enclosing the selection: a NodeSelection on one, or any ancestor of
// the cursor. Used by the commands and by the floating toolbar's visibility check.
export function findTextBox(state: EditorState): { pos: number; node: PMNode } | null {
const sel = state.selection;
if (sel instanceof NodeSelection && sel.node.type.name === 'textBox') {
return { pos: sel.from, node: sel.node };
}
const { $from } = sel;
for (let d = $from.depth; d > 0; d--) {
const n = $from.node(d);
if (n.type.name === 'textBox') return { pos: $from.before(d), node: n };
}
return null;
}
function shapeRadius(kind: ShapeKind): string {
return kind === 'ellipse' ? '50%' : kind === 'roundRect' ? '8px' : '0';
}
export const TextBox = Node.create({
name: 'textBox',
// Own group (not `block`): only the doc admits it, so it can't nest in table
// cells, list items, or other text boxes.
group: 'textBox',
content: '(paragraph | heading | bulletList | orderedList)+',
isolating: true,
defining: true,
draggable: true,
selectable: true,
addAttributes() {
return {
// px @96dpi like the image; height is a min-height (content never clips).
width: {
default: null,
parseHTML: el => parsePx((el as HTMLElement).style.width),
renderHTML: () => ({}),
},
height: {
default: null,
parseHTML: el => parsePx((el as HTMLElement).style.minHeight),
renderHTML: () => ({}),
},
rotation: {
default: 0,
parseHTML: el => parsePx((el as HTMLElement).getAttribute('data-rotation')) ?? 0,
renderHTML: () => ({}),
},
wrap: {
default: 'inline',
parseHTML: el => (el as HTMLElement).getAttribute('data-wrap') ?? 'inline',
renderHTML: () => ({}),
},
// Where the frame sits relative to its anchor, in cm — as on an image.
wrapOffset: {
default: null,
parseHTML: el => parseCmAttr((el as HTMLElement).getAttribute('data-wrap-offset')),
renderHTML: () => ({}),
},
wrapOffsetY: {
default: null,
parseHTML: el => parseCmAttr((el as HTMLElement).getAttribute('data-wrap-offset-y')),
renderHTML: () => ({}),
},
// The gap to the text beside it, in cm — as on an image.
wrapDist: {
default: null,
parseHTML: el => parseCmAttr((el as HTMLElement).getAttribute('data-wrap-dist')),
renderHTML: () => ({}),
},
// 'center' = set against the middle of the column (ODF style:horizontal-pos): a
// figure frame keeps it after the importer lifts it out of its anchor paragraph.
wrapAlign: {
default: null,
parseHTML: el => (el as HTMLElement).getAttribute('data-wrap-align') || null,
renderHTML: () => ({}),
},
// The file's own fo:padding (cm). A figure frame declares 0, and the default
// ring would make every such box a little taller and its text a little inset.
paddingCm: {
default: TEXTBOX_PADDING_CM,
parseHTML: el => parseCmAttr((el as HTMLElement).getAttribute('data-padding')) ?? TEXTBOX_PADDING_CM,
renderHTML: () => ({}),
},
shapeKind: {
default: 'textbox',
parseHTML: el => {
const k = (el as HTMLElement).getAttribute('data-shape');
return isShapeKind(k) ? k : 'textbox';
},
renderHTML: () => ({}),
},
fillColor: {
default: '#FFFFFF',
parseHTML: el => (el as HTMLElement).getAttribute('data-fill') || null,
renderHTML: () => ({}),
},
strokeColor: {
default: '#000000',
parseHTML: el => (el as HTMLElement).getAttribute('data-stroke') || null,
renderHTML: () => ({}),
},
strokeWidthPt: {
default: 1,
parseHTML: el => {
const n = parseFloat((el as HTMLElement).getAttribute('data-stroke-width') ?? '');
return Number.isFinite(n) ? n : 1;
},
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-textbox]' }];
},
renderHTML({ HTMLAttributes, node }) {
const a = node.attrs as TextBoxAttrs;
const style = [
a.width ? `width:${a.width}px` : '',
a.height ? `min-height:${a.height}px` : '',
// A polygon shape paints itself, so the box behind it stays bare.
a.fillColor && !SHAPES[a.shapeKind]?.points ? `background:${a.fillColor}` : '',
a.strokeColor && !SHAPES[a.shapeKind]?.points
? `border:${a.strokeWidthPt * PX_PER_PT}px solid ${a.strokeColor}` : '',
a.shapeKind !== 'textbox' ? `border-radius:${shapeRadius(a.shapeKind)}` : '',
a.rotation ? `transform:rotate(${a.rotation}deg)` : '',
`padding:${paddingPx(a.paddingCm).toFixed(2)}px`,
].filter(Boolean).join(';');
return ['div', mergeAttributes(HTMLAttributes, {
'data-textbox': '',
style,
...(a.rotation ? { 'data-rotation': String(a.rotation) } : {}),
...(a.wrap !== 'inline' ? { 'data-wrap': a.wrap } : {}),
...(a.shapeKind !== 'textbox' ? { 'data-shape': a.shapeKind } : {}),
...(a.fillColor ? { 'data-fill': a.fillColor } : {}),
...(a.strokeColor ? { 'data-stroke': a.strokeColor } : {}),
...(a.strokeWidthPt !== 1 ? { 'data-stroke-width': String(a.strokeWidthPt) } : {}),
...(a.paddingCm !== TEXTBOX_PADDING_CM ? { 'data-padding': String(a.paddingCm) } : {}),
}), 0];
},
addCommands() {
return {
// Insert a fresh text box after the current top-level block, cursor inside.
insertTextBox:
() =>
({ state, dispatch }) => {
const type = state.schema.nodes.textBox;
const para = state.schema.nodes.paragraph?.createAndFill();
if (!type || !para) return false;
const { $from } = state.selection;
const pos = $from.depth >= 1 ? $from.after(1) : state.selection.from;
if (dispatch) {
const box = type.create({ width: DEFAULT_WIDTH_PX, height: DEFAULT_HEIGHT_PX }, para);
const tr = state.tr.insert(pos, box);
tr.setSelection(TextSelection.create(tr.doc, pos + 2));
dispatch(tr.scrollIntoView());
}
return true;
},
// Update attrs of the selected box or the box enclosing the cursor.
setTextBoxAttrs:
(attrs: Partial<TextBoxAttrs>) =>
({ state, dispatch }) => {
const found = findTextBox(state);
if (!found) return false;
if (dispatch) {
dispatch(state.tr.setNodeMarkup(found.pos, undefined, { ...found.node.attrs, ...attrs }));
}
return true;
},
};
},
addNodeView() {
return ({ node, editor, getPos }) => new TextBoxView(node as PMNode, editor, getPos as () => number);
},
// Show the resize frame + handles (via .textbox-active) whenever the caret sits
// inside a box, so a plain click into it reveals the editing frame like an image.
addProseMirrorPlugins() {
return [
new Plugin({
props: {
decorations(state) {
const { from, to } = state.selection;
const decos: Decoration[] = [];
state.doc.descendants((node, pos) => {
if (node.type.name !== 'textBox') return false;
if (from >= pos && to <= pos + node.nodeSize) {
decos.push(Decoration.node(pos, pos + node.nodeSize, { class: 'textbox-active' }));
}
return false;
});
return DecorationSet.create(state.doc, decos);
},
},
}),
];
},
});
// Node view: like ImageView, an axis-aligned wrapper reserves the rotated bounding box and
// a centered rotor carries fill/stroke/rotation plus the handles — but the rotor holds an
// editable contentDOM and auto-grows, so a ResizeObserver re-fits the wrapper.
class TextBoxView {
dom: HTMLElement;
contentDOM: HTMLElement;
private rotor: HTMLElement;
private badge: HTMLElement;
private node: PMNode;
private editor: Editor;
private getPos: () => number;
private observer: ResizeObserver | null = null;
private resizing = false;
// The polygon outline, for a shape CSS cannot draw; null for the three it can.
private outline: SVGPathElement | null = null;
// Last text-area inset applied; guards the ResizeObserver feedback loop.
private lastInset = '';
constructor(node: PMNode, editor: Editor, getPos: () => number) {
this.node = node;
this.editor = editor;
this.getPos = getPos;
this.dom = document.createElement('div');
this.dom.className = 'textbox-node';
this.rotor = document.createElement('div');
this.rotor.className = 'textbox-rotor';
// Padding lives on the rotor, so the inset ring around the text is frame
// (click-to-select) area rather than content.
this.rotor.style.padding = `${paddingPx(this.attrs().paddingCm).toFixed(2)}px`;
this.contentDOM = document.createElement('div');
this.contentDOM.className = 'textbox-content';
this.rotor.appendChild(this.contentDOM);
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);
this.badge = document.createElement('span');
this.badge.className = 'image-size-badge';
this.dom.appendChild(this.badge);
// A mousedown on the frame ring (border, not the text area) object-selects the box
// so it can be dragged/moved; clicks further inside place the caret and edit.
this.dom.addEventListener('mousedown', e => this.onFrameMouseDown(e));
// The rotor is out of flow and grows with its content; refit the wrapper to the
// rotated bounding box whenever the rendered size changes (typing, resizing), and
// re-fit an ellipse's text area to its now-current height.
this.observer = new ResizeObserver(() => {
this.applyShapeInset();
this.fitWrapper();
});
this.observer.observe(this.rotor);
this.applyAll();
}
private attrs(): TextBoxAttrs {
return this.node.attrs as TextBoxAttrs;
}
private applyAll(): void {
const a = this.attrs();
this.rotor.style.width = a.width ? `${a.width}px` : `${DEFAULT_WIDTH_PX}px`;
this.rotor.style.minHeight = a.height ? `${a.height}px` : '';
// A polygon shape paints its own fill and stroke, so the box behind it stays bare.
const poly = !!SHAPES[a.shapeKind]?.points;
// The outline covers the padding box, so the frame's own ring moves to the text
// where a polygon draws it — applyShapeInset adds it back in.
this.rotor.style.padding = poly ? '0' : `${paddingPx(a.paddingCm).toFixed(2)}px`;
this.rotor.style.background = !poly && a.fillColor ? a.fillColor : 'transparent';
this.rotor.style.border = !poly && a.strokeColor
? `${a.strokeWidthPt * PX_PER_PT}px solid ${a.strokeColor}`
: 'none';
this.rotor.style.borderRadius = shapeRadius(a.shapeKind);
this.rotor.style.transform = `translate(-50%, -50%) rotate(${a.rotation}deg)`;
this.applyOutline();
this.applyWrap();
this.applyShapeInset();
this.fitWrapper();
}
// The outline of a shape CSS can't draw, behind the text. preserveAspectRatio="none"
// stretches the 0…100 box to the frame, and non-scaling-stroke keeps the line even
// width under that distortion.
private applyOutline(): void {
const a = this.attrs();
const d = shapePath(a.shapeKind);
if (!d) {
this.outline?.parentElement?.remove();
this.outline = null;
return;
}
if (!this.outline) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'textbox-outline');
svg.setAttribute('viewBox', '0 0 100 100');
svg.setAttribute('preserveAspectRatio', 'none');
this.outline = document.createElementNS('http://www.w3.org/2000/svg', 'path');
this.outline.setAttribute('vector-effect', 'non-scaling-stroke');
svg.appendChild(this.outline);
this.rotor.insertBefore(svg, this.rotor.firstChild);
}
this.outline.setAttribute('d', d);
this.outline.setAttribute('fill', a.fillColor ?? 'none');
this.outline.setAttribute('stroke', a.strokeColor ?? 'none');
this.outline.setAttribute('stroke-width', String(a.strokeWidthPt * PX_PER_PT));
}
// Pad the content into the shape's own text area — the ellipse's inscribed rectangle,
// a polygon's `textArea` — so text stays inside the outline. The vertical inset feeds
// back into the auto-grown height, so rewrite it only past a threshold; the
// ResizeObserver then settles (every ratio below 0.5 converges).
private applyShapeInset(): void {
const kind = this.attrs().shapeKind;
const area = SHAPES[kind]?.textArea;
if (kind !== 'ellipse' && !area) {
if (this.contentDOM.style.padding) this.contentDOM.style.padding = '';
this.lastInset = '';
return;
}
const w = this.rotor.offsetWidth;
const h = this.rotor.offsetHeight;
if (!w || !h) return;
const [l, t, r, b] = area
? [area[0] / 100, area[1] / 100, 1 - area[2] / 100, 1 - area[3] / 100]
: [ELLIPSE_INSET_RATIO, ELLIPSE_INSET_RATIO, ELLIPSE_INSET_RATIO, ELLIPSE_INSET_RATIO];
// Whole px: the inset feeds back into the height, and rounding is what settles it.
// A polygon carries the frame's own ring here, its rotor having none.
const ring = area ? paddingPx(this.attrs().paddingCm) : 0;
const px = (v: number, base: number) => `${Math.round(v * base + ring)}px`;
const inset = `${px(t, h)} ${px(r, w)} ${px(b, h)} ${px(l, w)}`;
if (inset === this.lastInset) return;
this.lastInset = inset;
this.contentDOM.style.padding = inset;
}
// Size the wrapper to the rotor's rotated bounding box so surrounding text
// reserves the right space (same math as ImageView.applyLayout).
private fitWrapper(): void {
const w = this.rotor.offsetWidth;
const h = this.rotor.offsetHeight;
if (!w || !h) return;
const rad = (this.attrs().rotation * 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`;
}
// Float per wrap mode, exactly like ImageView.applyWrap. 'inline' and 'topBottom'
// both render in-flow for a block-level box (they differ only in export anchoring).
private applyWrap(): void {
const d = this.dom;
const a = this.attrs();
d.style.float = '';
d.style.clear = '';
d.style.margin = frameMargins('topBottom', a.wrap === 'topBottom' ? a.wrapOffset : null, 0);
if (a.wrap === 'left' || a.wrap === 'right') {
d.style.float = a.wrap;
d.style.margin = frameMargins(a.wrap, a.wrapOffset, parseFloat(d.style.width) || 0, null, a.wrapDist);
} else if (a.wrap === 'topBottom') {
d.style.clear = 'both';
}
// The anchor paragraph's spacing, which a lifted box stands in for: space above as
// padding so it adds to the block above (editor.css), space below as the margin the
// frame distance already is.
const before = this.node.attrs.spaceBefore as number | null;
const after = this.node.attrs.spaceAfter as number | null;
// --space-top is the same value the rotor can inherit (--space-before is registered
// non-inheriting), and it is what pageBreaks.ts zeroes at a page top.
for (const prop of ['--space-before', '--space-top']) {
if (before != null) d.style.setProperty(prop, `${before}pt`);
else d.style.removeProperty(prop);
}
if (after != null) d.style.marginBottom = `${after}pt`;
// Set against the middle or the far end of the column, unless the file placed the
// box by coordinate.
if ((a.wrapAlign === 'center' || a.wrapAlign === 'right')
&& a.wrapOffset == null && a.wrap !== 'left' && a.wrap !== 'right') {
d.style.marginLeft = 'auto';
if (a.wrapAlign === 'center') d.style.marginRight = 'auto';
}
}
// The resize/rotate handles and the badge — always ours, never ProseMirror's.
private isOwnUi(t: EventTarget | null): boolean {
return (
t instanceof HTMLElement &&
(t.classList.contains('image-resize-handle') ||
t.classList.contains('image-rotate-handle') ||
t === this.badge)
);
}
// Frame hit test: the border plus a few px of the inner padding ring
// select the box; anywhere further inside is text area. Rotation-aware (the point
// is un-rotated into the rotor's own axes) and zoom-aware.
private isFrameHit(e: MouseEvent): boolean {
const r = this.rotor.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const zoom = this.dom.getBoundingClientRect().width / this.dom.offsetWidth || 1;
const rad = (-this.attrs().rotation * Math.PI) / 180;
const dx = (e.clientX - cx) / zoom;
const dy = (e.clientY - cy) / zoom;
const lx = dx * Math.cos(rad) - dy * Math.sin(rad);
const ly = dx * Math.sin(rad) + dy * Math.cos(rad);
const hw = this.rotor.offsetWidth / 2;
const hh = this.rotor.offsetHeight / 2;
const RING = 6;
const inside = Math.abs(lx) <= hw + 2 && Math.abs(ly) <= hh + 2;
const nearEdge = Math.abs(lx) >= hw - RING || Math.abs(ly) >= hh - RING;
return inside && nearEdge;
}
private onFrameMouseDown(e: MouseEvent): void {
if (this.isOwnUi(e.target)) return;
const pos = this.getPos();
if (typeof pos !== 'number') return;
const view = this.editor.view;
const already = view.state.selection instanceof NodeSelection && view.state.selection.from === pos;
if (this.isFrameHit(e)) {
// First ring click: block the native caret so the NodeSelection sticks (then
// refocus, since preventDefault also drops focus). Once selected the box is
// draggable, so a later ring drag still moves it natively.
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)));
if (!already) { e.preventDefault(); view.focus(); }
} else if (already) {
// Body click on an object-selected (draggable) box: the browser won't place a
// caret, so do it ourselves at the click point to enter text editing.
e.preventDefault();
const from = pos + 1, to = pos + this.node.nodeSize - 1;
const hit = view.posAtCoords({ left: e.clientX, top: e.clientY })?.pos;
const target = hit != null && hit >= from && hit <= to ? hit : from;
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, target)));
view.focus();
}
}
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 commit(attrs: Partial<TextBoxAttrs>): 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 }));
}
// Largest width the box may take: the page text column.
private boxMaxWidth(): number {
const box = 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);
}
// Drag math mirrors ImageView.startResize (zoom-aware, deltas un-rotated onto the
// box's own axes); the committed height is a min-height, so content never clips.
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.attrs().rotation;
const th = (deg * Math.PI) / 180;
const cosT = Math.cos(th);
const sinT = Math.sin(th);
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;
this.resizing = true;
const move = (e: MouseEvent): void => {
if (!e.buttons) { finish(); return; }
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.rotor.style.width = `${lastW}px`;
this.rotor.style.minHeight = `${lastH}px`;
this.applyShapeInset();
this.fitWrapper();
this.showBadge(lastW, lastH);
};
const finish = (): void => {
win.removeEventListener('mousemove', move);
win.removeEventListener('mouseup', finish);
this.badge.style.display = 'none';
this.resizing = false;
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 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.attrs().rotation;
let moved = false;
const move = (e: MouseEvent): void => {
if (!e.buttons) { finish(); return; }
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.rotor.style.transform = `translate(-50%, -50%) rotate(${lastDeg}deg)`;
this.fitWrapper();
};
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;
// Never touch contentDOM children — ProseMirror owns them.
if (!this.resizing) this.applyAll();
return true;
}
selectNode(): void {
this.dom.classList.add('textbox-selected');
// Draggable only while node-selected, so selecting text inside the box never
// starts a native drag.
this.dom.draggable = true;
}
deselectNode(): void {
this.dom.classList.remove('textbox-selected');
this.dom.draggable = false;
}
ignoreMutation(m: MutationRecord | { type: 'selection'; target: globalThis.Node }): boolean {
// The ellipse inset padding we write to contentDOM is our own styling, not a content
// edit — without this PM rebuilds the node view mid-resize and the wrapper collapses.
if (m.type === 'attributes' && m.target === this.contentDOM) return true;
return !this.contentDOM.contains(m.target);
}
// Mouse events on the frame ring/handles are ours (selection, resize, rotate);
// clicks further inside pass through so ProseMirror places the caret and edits.
stopEvent(event: Event): boolean {
if (!event.type.startsWith('mouse')) return false;
if (this.isOwnUi(event.target)) return true;
return this.isFrameHit(event as MouseEvent);
}
destroy(): void {
this.observer?.disconnect();
this.observer = null;
}
}
export type TextBoxDebugEntry = {
pos: number;
attrs: TextBoxAttrs;
textPreview: string;
// What the node view actually rendered — to compare against the attrs when a
// fill/stroke round-trips wrong (e.g. a stray blue background on import).
rendered: { background: string; border: string } | null;
};
// Every textBox node's attrs plus its live rendered fill/stroke, for the dev Debug
// dump. Reads the .textbox-rotor's computed style via the view's DOM lookup.
export function getTextBoxDebug(view: EditorView): TextBoxDebugEntry[] {
const out: TextBoxDebugEntry[] = [];
view.state.doc.descendants((node, pos) => {
if (node.type.name !== 'textBox') return;
let rendered: TextBoxDebugEntry['rendered'] = null;
const dom = view.nodeDOM(pos);
const rotor = dom instanceof HTMLElement ? dom.querySelector('.textbox-rotor') : null;
if (rotor && typeof getComputedStyle === 'function') {
const cs = getComputedStyle(rotor);
rendered = { background: cs.backgroundColor, border: `${cs.borderTopWidth} ${cs.borderTopStyle} ${cs.borderTopColor}` };
}
out.push({
pos,
attrs: node.attrs as TextBoxAttrs,
textPreview: (node.textContent ?? '').slice(0, 80),
rendered,
});
});
return out;
}