diff --git a/src/lib/editor/extensions/textBox.ts b/src/lib/editor/extensions/textBox.ts index 2c1362a..de7c707 100644 --- a/src/lib/editor/extensions/textBox.ts +++ b/src/lib/editor/extensions/textBox.ts @@ -18,6 +18,10 @@ export type ShapeKind = 'textbox' | 'roundRect' | 'ellipse'; export const TEXTBOX_PADDING_CM = 0.15; const PADDING_PX = (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; @@ -224,6 +228,9 @@ class TextBoxView { private getPos: () => number; private observer: ResizeObserver | null = null; private resizing = false; + // Last ellipse text-area inset applied; guards the ResizeObserver feedback loop. + private lastInsetX = -1; + private lastInsetY = -1; constructor(node: PMNode, editor: Editor, getPos: () => number) { this.node = node; @@ -265,8 +272,12 @@ class TextBoxView { 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). - this.observer = new ResizeObserver(() => this.fitWrapper()); + // 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(); @@ -287,9 +298,30 @@ class TextBoxView { this.rotor.style.borderRadius = shapeRadius(a.shapeKind); this.rotor.style.transform = `translate(-50%, -50%) rotate(${a.rotation}deg)`; this.applyWrap(); + this.applyShapeInset(); this.fitWrapper(); } + // For an ellipse, pad the content into the inscribed rectangle so text stays inside + // the oval. The vertical inset feeds back into the auto-grown height, so rewrite it + // only past a threshold — the ResizeObserver then settles (ratio < 0.5 converges). + private applyShapeInset(): void { + if (this.attrs().shapeKind !== 'ellipse') { + if (this.contentDOM.style.padding) this.contentDOM.style.padding = ''; + this.lastInsetX = this.lastInsetY = -1; + return; + } + const w = this.rotor.offsetWidth; + const h = this.rotor.offsetHeight; + if (!w || !h) return; + const insetX = w * ELLIPSE_INSET_RATIO; + const insetY = h * ELLIPSE_INSET_RATIO; + if (Math.abs(insetX - this.lastInsetX) < 0.5 && Math.abs(insetY - this.lastInsetY) < 0.5) return; + this.lastInsetX = insetX; + this.lastInsetY = insetY; + this.contentDOM.style.padding = `${insetY.toFixed(2)}px ${insetX.toFixed(2)}px`; + } + // 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 { @@ -443,6 +475,7 @@ class TextBoxView { moved = true; this.rotor.style.width = `${lastW}px`; this.rotor.style.minHeight = `${lastH}px`; + this.applyShapeInset(); this.fitWrapper(); this.showBadge(lastW, lastH); }; @@ -508,6 +541,9 @@ class TextBoxView { } 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); } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 6437c5b..dc6b121 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -1787,6 +1787,7 @@ function applyDateTimeFields(odtBytes: Uint8Array, fields: DateTimeFieldExport[] const ENHANCED_GEOMETRY: Record<'roundRect' | 'ellipse', string> = { ellipse: '', roundRect: '