fix: a frame keeps its place whatever kind of frame it is
Text boxes and shapes had no offsets at all, and for images the wrap side overrode the x: a file that names the side its text flows on lost the coordinate, our own export included. Side and place are read separately now, and one helper turns both into the float's margins for every frame kind.
This commit is contained in:
1 parent
f8c818c203
commit
5968b6a04b
7 files changed
+99
-54
No files matched your search
@@ -65,6 +65,19 @@ export function fitInlineImage(attrs: Record<string, unknown>, maxWidthPx: numbe
|
||||
attrs.width = maxWidthPx;
|
||||
}
|
||||
|
||||
// A floating frame's margins from its offsets: wrapOffset is its left edge in the text
|
||||
// column, measured against the live column vars an indented anchor cannot skew (a right
|
||||
// float is placed from the far side), wrapOffsetY how far below its anchor it sits.
|
||||
export function frameMargins(wrap: WrapMode, offsetCm: unknown, offsetYCm: unknown, boxWidthPx: number): string {
|
||||
const px = (v: unknown) => (typeof v === 'number' ? `${Math.round(cmToPx(v))}px` : null);
|
||||
const down = px(offsetYCm);
|
||||
if (wrap === 'topBottom') return `${down ?? '6px'} 0 6px`;
|
||||
const near = px(offsetCm);
|
||||
if (wrap === 'left') return `${down ?? '0'} 14px 6px ${near ?? '0'}`;
|
||||
const far = near == null ? '0' : `calc(${COLUMN_WIDTH_CSS} - ${near} - ${boxWidthPx}px)`;
|
||||
return `${down ?? '0'} ${far} 6px 14px`;
|
||||
}
|
||||
|
||||
// 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).
|
||||
export function pageContentHeightPx(): number {
|
||||
@@ -274,8 +287,8 @@ class ImageView {
|
||||
}
|
||||
|
||||
// Float the wrapper per wrap mode so text flows beside it at its anchor paragraph
|
||||
// (left/right) or only above/below it (topBottom). wrapOffset moves it inside the
|
||||
// column via the outer margin. The live re-anchor drag keeps it where the text is.
|
||||
// (left/right) or only above/below it (topBottom). The live re-anchor drag keeps it
|
||||
// where the text is.
|
||||
private applyWrap(): void {
|
||||
const d = this.dom;
|
||||
const wrap = this.attrWrap();
|
||||
@@ -283,23 +296,10 @@ class ImageView {
|
||||
d.style.display = '';
|
||||
d.style.clear = '';
|
||||
d.style.margin = '';
|
||||
const offset = this.node.attrs.wrapOffset as number | null;
|
||||
// The offset is the frame's left edge in the text column. A right float is placed
|
||||
// from the other side, so its margin is what the column has left over — measured
|
||||
// against the live column vars, which an indented anchor paragraph cannot skew.
|
||||
const near = offset == null ? '0' : `${Math.round(cmToPx(offset))}px`;
|
||||
const far = offset == null ? '0'
|
||||
: `calc(${COLUMN_WIDTH_CSS} - ${Math.round(cmToPx(offset))}px - ${this.boxWidth()}px)`;
|
||||
// Down from the anchor paragraph. Lines that fit entirely above the frame's top
|
||||
// edge still flow there, which is what a word processor does with the same offset.
|
||||
const offsetY = this.node.attrs.wrapOffsetY as number | null;
|
||||
const down = offsetY == null ? null : `${Math.round(cmToPx(offsetY))}px`;
|
||||
if (wrap === 'left') {
|
||||
d.style.float = 'left';
|
||||
d.style.margin = `${down ?? '0'} 14px 6px ${near}`;
|
||||
} else if (wrap === 'right') {
|
||||
d.style.float = 'right';
|
||||
d.style.margin = `${down ?? '0'} ${far} 6px 14px`;
|
||||
const a = this.node.attrs;
|
||||
if (wrap === 'left' || wrap === 'right') {
|
||||
d.style.float = wrap;
|
||||
d.style.margin = frameMargins(wrap, a.wrapOffset, a.wrapOffsetY, this.boxWidth());
|
||||
} else if (wrap === 'topBottom') {
|
||||
// A full-width float (not display:block — which on an inline atom node view
|
||||
// disrupts ProseMirror's view + page-break spacer widgets): text can only flow
|
||||
@@ -307,7 +307,7 @@ class ImageView {
|
||||
d.style.float = 'left';
|
||||
d.style.clear = 'both';
|
||||
d.style.width = '100%';
|
||||
d.style.margin = `${down ?? '6px'} 0 6px`;
|
||||
d.style.margin = frameMargins(wrap, a.wrapOffset, a.wrapOffsetY, this.boxWidth());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,13 @@ 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';
|
||||
import { HANDLES, MIN_SIZE_PX, clamp, parsePx, frameMargins, pageContentHeightPx, type WrapMode } from './image';
|
||||
|
||||
// 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).
|
||||
@@ -30,6 +36,8 @@ export interface TextBoxAttrs {
|
||||
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
|
||||
shapeKind: ShapeKind;
|
||||
fillColor: string | null;
|
||||
strokeColor: string | null;
|
||||
@@ -98,6 +106,17 @@ export const TextBox = Node.create({
|
||||
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: () => ({}),
|
||||
},
|
||||
shapeKind: {
|
||||
default: 'textbox',
|
||||
parseHTML: el => (el as HTMLElement).getAttribute('data-shape') ?? 'textbox',
|
||||
@@ -337,17 +356,14 @@ class TextBoxView {
|
||||
// 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 = '6px 0';
|
||||
const wrap = this.attrs().wrap;
|
||||
if (wrap === 'left') {
|
||||
d.style.float = 'left';
|
||||
d.style.margin = '0 14px 6px 0';
|
||||
} else if (wrap === 'right') {
|
||||
d.style.float = 'right';
|
||||
d.style.margin = '0 0 6px 14px';
|
||||
} else if (wrap === 'topBottom') {
|
||||
d.style.margin = frameMargins('topBottom', null, a.wrapOffsetY, 0);
|
||||
if (a.wrap === 'left' || a.wrap === 'right') {
|
||||
d.style.float = a.wrap;
|
||||
d.style.margin = frameMargins(a.wrap, a.wrapOffset, a.wrapOffsetY, parseFloat(d.style.width) || 0);
|
||||
} else if (a.wrap === 'topBottom') {
|
||||
d.style.clear = 'both';
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -390,6 +390,8 @@ type TextBoxDocx = {
|
||||
heightPx: number;
|
||||
rotationDeg: number;
|
||||
wrap: 'inline' | 'left' | 'right' | 'topBottom';
|
||||
offsetCm: number | null;
|
||||
offsetYCm: number | null;
|
||||
shapeKind: 'textbox' | 'roundRect' | 'ellipse';
|
||||
fill: string | null;
|
||||
stroke: string | null;
|
||||
@@ -406,6 +408,8 @@ function textBoxDocxDescriptor(node: TiptapNode): TextBoxDocx {
|
||||
heightPx: typeof a.height === 'number' && a.height > 0 ? Math.round(a.height) : 96,
|
||||
rotationDeg: typeof a.rotation === 'number' ? a.rotation : 0,
|
||||
wrap: wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline',
|
||||
offsetCm: typeof a.wrapOffset === 'number' ? a.wrapOffset : null,
|
||||
offsetYCm: typeof a.wrapOffsetY === 'number' ? a.wrapOffsetY : null,
|
||||
shapeKind: kind === 'roundRect' || kind === 'ellipse' ? kind : 'textbox',
|
||||
fill: typeof a.fillColor === 'string' && a.fillColor ? a.fillColor : null,
|
||||
stroke: typeof a.strokeColor === 'string' && a.strokeColor ? a.strokeColor : null,
|
||||
@@ -582,12 +586,16 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number): string {
|
||||
? '<wp:wrapTopAndBottom/>'
|
||||
: `<wp:wrapSquare wrapText="${box.wrap === 'right' ? 'left' : 'right'}"/>`;
|
||||
const align = box.wrap === 'right' ? 'right' : 'left';
|
||||
const emu = (cm: number) => Math.round(cm * 360000);
|
||||
const posH = box.offsetCm != null && box.wrap !== 'topBottom'
|
||||
? `<wp:posOffset>${emu(box.offsetCm)}</wp:posOffset>`
|
||||
: `<wp:align>${align}</wp:align>`;
|
||||
return (
|
||||
`<w:drawing><wp:anchor ${wpNs} distT="0" distB="0" distL="114300" distR="114300"` +
|
||||
` simplePos="0" relativeHeight="${251658240 + index}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="0">` +
|
||||
`<wp:simplePos x="0" y="0"/>` +
|
||||
`<wp:positionH relativeFrom="margin"><wp:align>${align}</wp:align></wp:positionH>` +
|
||||
`<wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>` +
|
||||
`<wp:positionH relativeFrom="margin">${posH}</wp:positionH>` +
|
||||
`<wp:positionV relativeFrom="paragraph"><wp:posOffset>${emu(box.offsetYCm ?? 0)}</wp:posOffset></wp:positionV>` +
|
||||
`${extent}${wrapEl}${docPr}${graphic}</wp:anchor></w:drawing>`
|
||||
);
|
||||
}
|
||||
|
||||
+11
-3
@@ -451,6 +451,8 @@ type TextBoxExport = {
|
||||
heightCm: number;
|
||||
rotationDeg: number;
|
||||
wrap: WrapMode;
|
||||
wrapOffsetCm: number | null;
|
||||
wrapOffsetYCm: number | null;
|
||||
shapeKind: ShapeKind;
|
||||
fill: string | null;
|
||||
stroke: string | null;
|
||||
@@ -468,6 +470,8 @@ function textBoxDescriptor(node: TiptapNode): TextBoxExport {
|
||||
heightCm: pxToCm(typeof a.height === 'number' && a.height > 0 ? a.height : 96),
|
||||
rotationDeg: typeof a.rotation === 'number' ? a.rotation : 0,
|
||||
wrap: wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline',
|
||||
wrapOffsetCm: typeof a.wrapOffset === 'number' ? round3(a.wrapOffset) : null,
|
||||
wrapOffsetYCm: typeof a.wrapOffsetY === 'number' ? round3(a.wrapOffsetY) : null,
|
||||
shapeKind: kind === 'roundRect' || kind === 'ellipse' ? kind : 'textbox',
|
||||
fill: typeof a.fillColor === 'string' && a.fillColor ? a.fillColor : null,
|
||||
stroke: typeof a.strokeColor === 'string' && a.strokeColor ? a.strokeColor : null,
|
||||
@@ -2756,8 +2760,9 @@ function textBoxGraphicStyle(box: TextBoxExport, index: number): string {
|
||||
: 'draw:stroke="none"';
|
||||
const wrap = box.wrap === 'inline'
|
||||
? ''
|
||||
: ` ${imageWrapProps(box.wrap, null)} style:number-wrapped-paragraphs="no-limit"` +
|
||||
` style:horizontal-rel="paragraph-content" style:vertical-pos="top" style:vertical-rel="paragraph"`;
|
||||
: ` ${imageWrapProps(box.wrap, box.wrapOffsetCm)} style:number-wrapped-paragraphs="no-limit"` +
|
||||
` style:horizontal-rel="paragraph-content"` +
|
||||
` style:vertical-pos="${box.wrapOffsetYCm != null ? 'from-top' : 'top'}" style:vertical-rel="paragraph"`;
|
||||
// auto-grow only for plain text boxes; a custom-shape needs both explicitly
|
||||
// false, or LibreOffice's shape autofit shrinks it to its text.
|
||||
const grow = box.shapeKind === 'textbox'
|
||||
@@ -2778,9 +2783,12 @@ function textBoxXml(box: TextBoxExport, inner: string, index: number): string {
|
||||
const n = index + 1;
|
||||
const anchor = box.wrap === 'inline' ? 'as-char' : 'paragraph';
|
||||
const transform = frameTransform(box.rotationDeg, box.widthCm, box.heightCm);
|
||||
const at = box.wrap === 'inline' ? ''
|
||||
: (box.wrapOffsetCm != null && box.wrap !== 'topBottom' ? ` svg:x="${box.wrapOffsetCm}cm"` : '') +
|
||||
(box.wrapOffsetYCm != null ? ` svg:y="${box.wrapOffsetYCm}cm"` : '');
|
||||
const common =
|
||||
` draw:style-name="TbxFr${n}" text:anchor-type="${anchor}" draw:z-index="${index}"` +
|
||||
` svg:width="${box.widthCm}cm"`;
|
||||
` svg:width="${box.widthCm}cm"${at}`;
|
||||
if (box.shapeKind === 'textbox') {
|
||||
// svg:height for consumers without auto-grow; fo:min-height is the real semantic
|
||||
// (height = minimum, content grows the box) and wins on our own re-import.
|
||||
|
||||
+28
-17
@@ -1228,9 +1228,6 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
|
||||
return { type: 'image', attrs };
|
||||
}
|
||||
|
||||
// The wrap mode, plus where the frame sits in the text column (cm from its left edge,
|
||||
// null when the file only names a side). Text can only flow on one side of a CSS float,
|
||||
// so the side is the half of the column the frame does *not* fill.
|
||||
// An undrawable frame as an image node of the same size, wrapped like the real one.
|
||||
function placeholderNode(box: { w: number; h: number }, label: string, anchor: Element | undefined, ctx: Ctx): Node {
|
||||
const attrs: Record<string, unknown> = { src: placeholderImage(label, box.w, box.h), width: box.w, height: box.h, alt: label };
|
||||
@@ -1256,27 +1253,36 @@ function anchorOffsetY(anchor: Element): number | null {
|
||||
return Number.isFinite(off) && off > 0 ? round2(off / 360000) : null;
|
||||
}
|
||||
|
||||
// The frame's own x in the text column, cm from its left edge. null where the file
|
||||
// names an alignment instead of a coordinate. A page-relative offset counts from the
|
||||
// sheet edge — shift it to the column so one number serves both.
|
||||
function anchorOffsetX(anchor: Element, ctx: Ctx): number | null {
|
||||
const posH = anchor.getElementsByTagNameNS(WP, 'positionH')[0];
|
||||
if (posH?.getElementsByTagNameNS(WP, 'align')[0]) return null;
|
||||
const off = parseInt(posH?.getElementsByTagNameNS(WP, 'posOffset')[0]?.textContent ?? '', 10);
|
||||
if (!Number.isFinite(off)) return null;
|
||||
const base = posH?.getAttribute('relativeFrom') === 'page' ? -cmToEmu(ctx.leftMarginCm) : 0;
|
||||
return round2((off + base) / 360000);
|
||||
}
|
||||
|
||||
// Wrap mode and place are independent: the mode is what the file's wrap element says,
|
||||
// the place its position offsets. Only where neither names a side does the frame's own
|
||||
// x decide which half of the column it fills (text flows on one side of a CSS float).
|
||||
function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom'; offsetCm: number | null; offsetYCm: number | null } {
|
||||
const offsetYCm = anchorOffsetY(anchor);
|
||||
const at = (wrap: 'left' | 'right' | 'topBottom', offsetCm: number | null = null) => ({ wrap, offsetCm, offsetYCm });
|
||||
const offsetCm = anchorOffsetX(anchor, ctx);
|
||||
const at = (wrap: 'left' | 'right' | 'topBottom') => ({ wrap, offsetCm: wrap === 'topBottom' ? null : offsetCm, offsetYCm });
|
||||
if (anchor.getElementsByTagNameNS(WP, 'wrapTopAndBottom')[0]) return at('topBottom');
|
||||
const sq = anchor.getElementsByTagNameNS(WP, 'wrapSquare')[0];
|
||||
const wt = sq?.getAttribute('wrapText');
|
||||
const wt = anchor.getElementsByTagNameNS(WP, 'wrapSquare')[0]?.getAttribute('wrapText');
|
||||
if (wt === 'right') return at('left'); // text on right ⇒ image on left
|
||||
if (wt === 'left') return at('right');
|
||||
const posH = anchor.getElementsByTagNameNS(WP, 'positionH')[0];
|
||||
const align = posH?.getElementsByTagNameNS(WP, 'align')[0]?.textContent?.trim();
|
||||
const align = anchor.getElementsByTagNameNS(WP, 'positionH')[0]
|
||||
?.getElementsByTagNameNS(WP, 'align')[0]?.textContent?.trim();
|
||||
if (align === 'right' || align === 'outside') return at('right');
|
||||
if (align) return at('left');
|
||||
const off = parseInt(posH?.getElementsByTagNameNS(WP, 'posOffset')[0]?.textContent ?? '', 10);
|
||||
if (!Number.isFinite(off)) return at('left');
|
||||
if (offsetCm == null) return at('left');
|
||||
const cx = intAttr(anchor.getElementsByTagNameNS(WP, 'extent')[0], '', 'cx') ?? 0;
|
||||
// A page-relative offset counts from the sheet edge, everything else from the text
|
||||
// column — shift it there so one comparison serves both.
|
||||
const base = posH?.getAttribute('relativeFrom') === 'page' ? -cmToEmu(ctx.leftMarginCm) : 0;
|
||||
const left = off + base;
|
||||
const wrap = left + cx / 2 > cmToEmu(ctx.contentWidthCm) / 2 ? 'right' : 'left';
|
||||
return at(wrap, round2(left / 360000));
|
||||
return at(cmToEmu(offsetCm) + cx / 2 > cmToEmu(ctx.contentWidthCm) / 2 ? 'right' : 'left');
|
||||
}
|
||||
|
||||
// ---- text boxes / shapes ------------------------------------------------------
|
||||
@@ -1321,7 +1327,12 @@ function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ct
|
||||
if (cy) attrs.height = Math.round(emuToPx(cy));
|
||||
const rot = intAttr(nsChild(spPr, A, 'xfrm'), '', 'rot');
|
||||
if (rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360;
|
||||
if (isAnchor) attrs.wrap = anchorWrap(root, ctx).wrap;
|
||||
if (isAnchor) {
|
||||
const { wrap, offsetCm, offsetYCm } = anchorWrap(root, ctx);
|
||||
attrs.wrap = wrap;
|
||||
if (offsetCm != null) attrs.wrapOffset = offsetCm;
|
||||
if (offsetYCm != null) attrs.wrapOffsetY = offsetYCm;
|
||||
}
|
||||
|
||||
const fillClr = nsChild(nsChild(spPr, A, 'solidFill'), A, 'srgbClr')?.getAttribute('val');
|
||||
const fill = fillClr ? hexColor(fillClr) ?? null : null;
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('DOCX export → import round trip', () => {
|
||||
para('box text'),
|
||||
para([text('bold in box', [{ type: 'bold' }])]),
|
||||
] },
|
||||
{ type: 'textBox', attrs: { width: 192, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('ellipse text')] },
|
||||
{ type: 'textBox', attrs: { width: 192, height: 96, wrap: 'right', wrapOffset: 6, wrapOffsetY: 1.5, shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, content: [para('ellipse text')] },
|
||||
{ type: 'columns', attrs: { count: 2, gapCm: 0.8 }, content: [para('newspaper one'), para('newspaper two')] },
|
||||
{ type: 'table', content: [
|
||||
{ type: 'tableRow', content: [headerCell('Name', { colwidth: [6] }), headerCell('Qty', { colwidth: [3] })] },
|
||||
@@ -317,6 +317,8 @@ describe('DOCX export → import round trip', () => {
|
||||
expect(hasMark(walk(plain, 'text')[1], 'bold')).toBe(true);
|
||||
expect(ellipse.attrs.shapeKind).toBe('ellipse');
|
||||
expect(ellipse.attrs.wrap).toBe('right');
|
||||
expect(ellipse.attrs.wrapOffset).toBeCloseTo(6, 1);
|
||||
expect(ellipse.attrs.wrapOffsetY).toBeCloseTo(1.5, 1);
|
||||
expect(ellipse.attrs.rotation).toBe(30);
|
||||
expect(ellipse.attrs.fillColor).toBe('#FFEE00');
|
||||
expect(ellipse.attrs.strokeColor).toBe('#FF0000');
|
||||
|
||||
@@ -87,7 +87,7 @@ const fixture: N = {
|
||||
P(null, T('wrapped left '), IMGN(90, 60, 'Float', 0, 'left', 2.5), T(' text flows beside it')),
|
||||
P(null, T('top/bottom '), IMGN(70, 50, 'Banner', 0, 'topBottom')),
|
||||
TBX({ width: 288, height: 96 }, P(null, T('box para one')), P(null, T('box '), T('bold', { type: 'bold' }))),
|
||||
TBX({ width: 192, height: 80, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, P(null, T('in ellipse'))),
|
||||
TBX({ width: 192, height: 80, wrap: 'right', wrapOffset: 6, wrapOffsetY: 1.5, shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, P(null, T('in ellipse'))),
|
||||
COLS({ count: 2, gapCm: 0.5 }, P(null, T('newspaper column text one')), P(null, T('newspaper column text two'))),
|
||||
{ type: 'bulletList', content: [
|
||||
LI(P(null, T('bullet one'))),
|
||||
|
||||
Reference in new issue
Block a user