fix: text field frame

This commit is contained in:
Steffen Becker committed 2026-07-10 16:25:20 +02:00
1 parent 74ee5b7d23
commit 0526579f2b
5 files changed
+128 -19

No files matched your search

+2
View File
@@ -14,6 +14,7 @@
import { convertUnsupportedImages } from './lib/import/imageFormats';
import { getPageBreakDebug } from './lib/editor/extensions/pageBreaks';
import { getColumnsFlowDebug } from './lib/editor/extensions/columnsFlow';
import { getTextBoxDebug } from './lib/editor/extensions/textBox';
import { getColorDebug } from './lib/utils/colorDebug';
import { countText, type TextStats } from './lib/utils/wordCount';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, type ThemeMode } from './lib/storage/theme';
@@ -578,6 +579,7 @@
doc: editor.getJSON(),
pageBreaks: snapshot,
columnsFlow: getColumnsFlowDebug(editor.view),
textBoxes: getTextBoxDebug(editor.view),
colors: getColorDebug(editor),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
+6 -1
View File
@@ -312,9 +312,14 @@
const r = dom.getBoundingClientRect();
const cRect = editorContainer.getBoundingClientRect();
const a = found.node.attrs;
// Anchor above the rotate grip (it protrudes above the box) so the toolbar never
// covers it; fall back to the box top when the grip isn't rendered.
const grip = dom.querySelector('.image-rotate-handle');
const gr = grip instanceof HTMLElement ? grip.getBoundingClientRect() : null;
const anchorTop = gr && gr.height > 0 ? Math.min(r.top, gr.top) : r.top;
textBoxUi = {
visible: true,
top: r.top - cRect.top + editorContainer.scrollTop,
top: anchorTop - cRect.top + editorContainer.scrollTop,
left: r.left - cRect.left + editorContainer.scrollLeft,
wrap: (a.wrap as WrapMode) || 'inline',
shapeKind: (a.shapeKind as ShapeKind) || 'textbox',
+78 -6
View File
@@ -1,8 +1,10 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { NodeSelection, TextSelection } from '@tiptap/pm/state';
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, pageContentHeightPx, type WrapMode } from './image';
// A text box / basic shape: a block-level frame with editable block content, a fill,
@@ -183,6 +185,29 @@ export const TextBox = Node.create({
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
@@ -235,7 +260,8 @@ class TextBoxView {
this.badge.className = 'image-size-badge';
this.dom.appendChild(this.badge);
// A mousedown on the frame (border, not the text area) selects the box whole.
// 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
@@ -328,13 +354,27 @@ class TextBoxView {
}
private onFrameMouseDown(e: MouseEvent): void {
if (this.isOwnUi(e.target) || !this.isFrameHit(e)) return;
if (this.isOwnUi(e.target)) return;
const pos = this.getPos();
if (typeof pos !== 'number') return;
// No preventDefault: the browser must still be able to start a native drag of
// the (now selected, draggable) box.
const view = this.editor.view;
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)));
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 {
@@ -484,3 +524,35 @@ class TextBoxView {
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;
}
+35 -9
View File
@@ -137,21 +137,47 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null {
return { type: 'image', attrs };
}
// A shape's fill color. fo:background-color (LibreOffice's per-shape fill) wins over
// draw:fill when set — the two coexist because the default graphic style carries a
// draw:fill that a "no fill" shape overrides only via fo:background-color="transparent".
function shapeFill(gp: PropMap): string | null {
const bg = gp['fo:background-color'];
if (bg !== undefined) return bg === 'transparent' || bg === 'none' ? null : normalizeColor(bg) ?? null;
return gp['draw:fill'] === 'solid' ? normalizeColor(gp['draw:fill-color'] ?? '') ?? null : null;
}
// A shape's stroke as { color, widthPt }. fo:border ("<w> <style> <color>", LibreOffice's
// per-shape border) wins over draw:stroke for the same reason as shapeFill.
function shapeStroke(gp: PropMap): { color: string | null; widthPt: number | null } {
const border = gp['fo:border'];
if (border !== undefined) {
let widthPt: number | null = null, color: string | null = null, styleTok: string | null = null;
for (const part of border.trim().split(/\s+/)) {
if (/^-?[\d.]+\s*(pt|cm|mm|in|px|pc)?$/.test(part)) widthPt = lengthToPt(part);
else if (part.startsWith('#')) color = normalizeColor(part) ?? part;
else styleTok = part;
}
if (styleTok === 'none' || styleTok === 'hidden' || (widthPt != null && widthPt <= 0)) {
return { color: null, widthPt: null };
}
return { color: color ?? '#000000', widthPt };
}
const color = gp['draw:stroke'] && gp['draw:stroke'] !== 'none'
? normalizeColor(gp['svg:stroke-color'] ?? '#000000') ?? '#000000'
: null;
return { color, widthPt: color ? lengthToPt(gp['svg:stroke-width']) : null };
}
// Fill/stroke attrs from a shape's graphic style, suppressing the editor defaults
// (white fill, 1pt black stroke). Absent/none → explicit null (transparent/no border),
// since omitting the attr would re-apply the default.
function shapeStyleAttrs(gp: PropMap, attrs: Record<string, unknown>): void {
const fillColor = gp['draw:fill'] === 'solid' ? normalizeColor(gp['draw:fill-color'] ?? '') ?? null : null;
const fillColor = shapeFill(gp);
if (fillColor !== '#FFFFFF') attrs.fillColor = fillColor;
const strokeColor = gp['draw:stroke'] && gp['draw:stroke'] !== 'none'
? normalizeColor(gp['svg:stroke-color'] ?? '#000000') ?? '#000000'
: null;
const { color: strokeColor, widthPt } = shapeStroke(gp);
if (strokeColor !== '#000000') attrs.strokeColor = strokeColor;
if (strokeColor) {
const widthPt = lengthToPt(gp['svg:stroke-width']);
if (widthPt != null && Math.abs(widthPt - 1) > 0.1) {
attrs.strokeWidthPt = Math.round(widthPt * 100) / 100;
}
if (strokeColor && widthPt != null && Math.abs(widthPt - 1) > 0.1) {
attrs.strokeWidthPt = Math.round(widthPt * 100) / 100;
}
}
+7 -3
View File
@@ -252,7 +252,9 @@
.paper .tiptap .image-node.image-selected .image-resize-handle,
.paper .tiptap .image-node.image-selected .image-rotate-handle,
.paper .tiptap .textbox-node.textbox-selected .image-resize-handle,
.paper .tiptap .textbox-node.textbox-selected .image-rotate-handle {
.paper .tiptap .textbox-node.textbox-selected .image-rotate-handle,
.paper .tiptap .textbox-node.textbox-active .image-resize-handle,
.paper .tiptap .textbox-node.textbox-active .image-rotate-handle {
display: block;
}
.paper .tiptap .image-resize-nw { top: -5px; left: -5px; cursor: nwse-resize; }
@@ -314,7 +316,8 @@
.paper .tiptap .textbox-node {
position: relative;
}
.paper .tiptap .textbox-node.textbox-selected {
.paper .tiptap .textbox-node.textbox-selected,
.paper .tiptap .textbox-node.textbox-active {
z-index: 25;
}
.paper .tiptap .textbox-rotor {
@@ -324,7 +327,8 @@
box-sizing: border-box;
transform: translate(-50%, -50%);
}
.paper .tiptap .textbox-node.textbox-selected .textbox-rotor {
.paper .tiptap .textbox-node.textbox-selected .textbox-rotor,
.paper .tiptap .textbox-node.textbox-active .textbox-rotor {
outline: 2px solid var(--accent, #3b82f6);
outline-offset: 1px;
}