fix: text field frame
This commit is contained in:
1 parent
74ee5b7d23
commit
0526579f2b
5 files changed
+128
-19
No files matched your search
@@ -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' });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user