feat: place a floating image at its x in the text column
Word anchors a wrapped frame at an offset; the model only knew a side, so a frame the file puts 91mm in snapped to the margin and the text beside it came out 20mm too narrow. wrapOffset carries that x (cm from the column's left edge, Word's posOffset / ODF svg:x) and the float's outer margin renders it — against the column vars, so an indented anchor paragraph cannot skew it. Picking a wrap side in the toolbar clears the offset: that gesture means "put it there".
This commit is contained in:
1 parent
273654640c
commit
d2701d38ae
5 files changed
+81
-27
No files matched your search
@@ -3,6 +3,7 @@ import type { Editor } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { NodeSelection } from '@tiptap/pm/state';
|
||||
import { dropCursor } from '@tiptap/pm/dropcursor';
|
||||
import { cmToPx } from '../../storage/pageMargins';
|
||||
|
||||
// Inline, as-character image, or a floating text-wrapped frame (wrap = flow mode);
|
||||
// width/height are doc px @96dpi, rotation CW degrees. Export → cm + ODF
|
||||
@@ -42,8 +43,18 @@ export function parsePx(value: string | null): number | null {
|
||||
return Number.isFinite(n) ? Math.round(n) : null;
|
||||
}
|
||||
|
||||
function parseCm(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const n = parseFloat(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
// The page text width, live from the vars the editor maintains (margins/orientation).
|
||||
const COLUMN_WIDTH_CSS =
|
||||
'calc(var(--user-page-width) - var(--user-margin-left) - var(--user-margin-right))';
|
||||
|
||||
// An as-character image wider than the text column takes a line of its own and leaves an
|
||||
// empty one above it. Fitting it to the column is where an image the file sizes to the
|
||||
// column lands anyway, once its cm/EMU width is rounded to whole px (importers).
|
||||
@@ -99,6 +110,13 @@ export const Image = Node.create({
|
||||
parseHTML: el => (el as HTMLElement).getAttribute('data-wrap') ?? 'inline',
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
// Where a floating frame sits in the text column: cm from the column's left edge
|
||||
// to the frame's left edge (Word's posOffset, ODF svg:x). null = flush to its side.
|
||||
wrapOffset: {
|
||||
default: null,
|
||||
parseHTML: el => parseCm((el as HTMLElement).getAttribute('data-wrap-offset')),
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -111,6 +129,7 @@ export const Image = Node.create({
|
||||
const h = node.attrs.height as number | null;
|
||||
const rot = (node.attrs.rotation as number) || 0;
|
||||
const wrap = (node.attrs.wrap as WrapMode) || 'inline';
|
||||
const offset = node.attrs.wrapOffset as number | null;
|
||||
const style = [
|
||||
w ? `width:${w}px` : '',
|
||||
h ? `height:${h}px` : '',
|
||||
@@ -120,6 +139,7 @@ export const Image = Node.create({
|
||||
...(style ? { style } : {}),
|
||||
...(rot ? { 'data-rotation': String(rot) } : {}),
|
||||
...(wrap !== 'inline' ? { 'data-wrap': wrap } : {}),
|
||||
...(offset != null ? { 'data-wrap-offset': String(offset) } : {}),
|
||||
})];
|
||||
},
|
||||
|
||||
@@ -136,7 +156,8 @@ export const Image = Node.create({
|
||||
({ state, dispatch }) => {
|
||||
const sel = state.selection;
|
||||
if (!(sel instanceof NodeSelection) || sel.node.type.name !== this.name) return false;
|
||||
if (dispatch) dispatch(state.tr.setNodeMarkup(sel.from, undefined, { ...sel.node.attrs, wrap }));
|
||||
// Picking a side means "put it there", so the imported offset goes with it.
|
||||
if (dispatch) dispatch(state.tr.setNodeMarkup(sel.from, undefined, { ...sel.node.attrs, wrap, wrapOffset: null }));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -220,6 +241,8 @@ class ImageView {
|
||||
private attrH(): number | null { const h = this.node.attrs.height; return typeof h === 'number' ? h : null; }
|
||||
private attrRot(): number { return (this.node.attrs.rotation as number) || 0; }
|
||||
private attrWrap(): WrapMode { const w = this.node.attrs.wrap; return w === 'left' || w === 'right' || w === 'topBottom' ? w : 'inline'; }
|
||||
// The wrapper's reserved (rotated) width, which applyLayout has just written.
|
||||
private boxWidth(): number { return parseFloat(this.dom.style.width) || this.attrW() || 0; }
|
||||
|
||||
// Size the rotor to w×h, rotate it about its centre, and grow the axis-aligned
|
||||
// wrapper to the rotated bounding box so the line reserves the right space.
|
||||
@@ -242,8 +265,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). The live re-anchor drag keeps it
|
||||
// floating where the text actually is.
|
||||
// (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.
|
||||
private applyWrap(): void {
|
||||
const d = this.dom;
|
||||
const wrap = this.attrWrap();
|
||||
@@ -251,12 +274,19 @@ 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)`;
|
||||
if (wrap === 'left') {
|
||||
d.style.float = 'left';
|
||||
d.style.margin = '0 14px 6px 0';
|
||||
d.style.margin = `0 14px 6px ${near}`;
|
||||
} else if (wrap === 'right') {
|
||||
d.style.float = 'right';
|
||||
d.style.margin = '0 0 6px 14px';
|
||||
d.style.margin = `0 ${far} 6px 14px`;
|
||||
} 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
|
||||
|
||||
@@ -313,7 +313,9 @@ function decodeDataUri(src: string): { bytes: Uint8Array; type: 'png' | 'jpg' |
|
||||
}
|
||||
}
|
||||
|
||||
function floatingFor(wrap: string): IFloating | undefined {
|
||||
// offsetCm places the frame in the text column (Word's posOffset); without one it is
|
||||
// flush to its side.
|
||||
function floatingFor(wrap: string, offsetCm: number | null): IFloating | undefined {
|
||||
if (wrap === 'inline') return undefined;
|
||||
const verticalPosition = { relative: VerticalPositionRelativeFrom.PARAGRAPH, offset: 0 };
|
||||
if (wrap === 'topBottom') {
|
||||
@@ -327,8 +329,11 @@ function floatingFor(wrap: string): IFloating | undefined {
|
||||
// left: image at left, text on the right; right: mirror.
|
||||
const align = wrap === 'right' ? HorizontalPositionAlign.RIGHT : HorizontalPositionAlign.LEFT;
|
||||
const side = wrap === 'right' ? TextWrappingSide.LEFT : TextWrappingSide.RIGHT;
|
||||
const horizontalPosition = offsetCm != null
|
||||
? { relative: HorizontalPositionRelativeFrom.MARGIN, offset: Math.round(offsetCm * 360000) }
|
||||
: { relative: HorizontalPositionRelativeFrom.MARGIN, align };
|
||||
return {
|
||||
horizontalPosition: { relative: HorizontalPositionRelativeFrom.MARGIN, align },
|
||||
horizontalPosition,
|
||||
verticalPosition,
|
||||
wrap: { type: TextWrappingType.SQUARE, side },
|
||||
allowOverlap: false,
|
||||
@@ -344,12 +349,13 @@ function imageRun(node: TiptapNode): ImageRun | null {
|
||||
const height = typeof node.attrs?.height === 'number' && node.attrs.height > 0 ? Math.round(node.attrs.height) : 150;
|
||||
const rotation = typeof node.attrs?.rotation === 'number' ? node.attrs.rotation : 0;
|
||||
const wrap = String(node.attrs?.wrap ?? 'inline');
|
||||
const offsetCm = typeof node.attrs?.wrapOffset === 'number' ? node.attrs.wrapOffset : null;
|
||||
return new ImageRun({
|
||||
type: decoded.type,
|
||||
data: decoded.bytes,
|
||||
altText: typeof node.attrs?.alt === 'string' && node.attrs.alt ? { name: node.attrs.alt, title: node.attrs.alt, description: node.attrs.alt } : undefined,
|
||||
transformation: { width, height, rotation: rotation || undefined },
|
||||
floating: floatingFor(wrap),
|
||||
floating: floatingFor(wrap, offsetCm),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+12
-7
@@ -322,7 +322,7 @@ function replacePageBreaks(doc: TiptapNode): TiptapNode {
|
||||
// bytes is ArrayBuffer-backed to match fflate's zip entry map. rotationDeg is CW;
|
||||
// wrap floats the frame at its anchor paragraph (left/right/top-bottom).
|
||||
type WrapMode = 'inline' | 'left' | 'right' | 'topBottom';
|
||||
type ImageExport = { path: string; bytes: Uint8Array<ArrayBuffer>; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode };
|
||||
type ImageExport = { path: string; bytes: Uint8Array<ArrayBuffer>; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode; wrapOffsetCm: number | null };
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
|
||||
const bin = atob(b64);
|
||||
@@ -363,6 +363,7 @@ function imageDescriptor(node: TiptapNode, index: number, namePrefix = 'image'):
|
||||
alt: typeof node.attrs?.alt === 'string' ? node.attrs.alt : '',
|
||||
rotationDeg: typeof node.attrs?.rotation === 'number' ? node.attrs.rotation : 0,
|
||||
wrap,
|
||||
wrapOffsetCm: typeof node.attrs?.wrapOffset === 'number' ? round3(node.attrs.wrapOffset) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2437,9 +2438,11 @@ function imageTransform(img: ImageExport): string {
|
||||
|
||||
// ODF style:wrap is the side TEXT flows on (inverse of the image side); horizontal-pos
|
||||
// places the frame on that side. topBottom ⇒ no wrap, centred.
|
||||
function imageWrapProps(wrap: WrapMode): string {
|
||||
if (wrap === 'left') return 'style:wrap="right" style:horizontal-pos="left"';
|
||||
if (wrap === 'right') return 'style:wrap="left" style:horizontal-pos="right"';
|
||||
// An offset frame is placed by coordinate instead (svg:x on the frame).
|
||||
function imageWrapProps(wrap: WrapMode, offset: number | null): string {
|
||||
const pos = offset != null && wrap !== 'topBottom' ? 'from-left' : null;
|
||||
if (wrap === 'left') return `style:wrap="right" style:horizontal-pos="${pos ?? 'left'}"`;
|
||||
if (wrap === 'right') return `style:wrap="left" style:horizontal-pos="${pos ?? 'right'}"`;
|
||||
return 'style:wrap="none" style:horizontal-pos="center"';
|
||||
}
|
||||
|
||||
@@ -2449,7 +2452,7 @@ function imageGraphicStyle(img: ImageExport, index: number): string {
|
||||
if (img.wrap === 'inline') return '';
|
||||
return (
|
||||
`<style:style style:name="ImgFr${index + 1}" style:family="graphic">` +
|
||||
`<style:graphic-properties ${imageWrapProps(img.wrap)}` +
|
||||
`<style:graphic-properties ${imageWrapProps(img.wrap, img.wrapOffsetCm)}` +
|
||||
` style:number-wrapped-paragraphs="no-limit"` +
|
||||
` style:horizontal-rel="paragraph-content"` +
|
||||
` style:vertical-pos="top" style:vertical-rel="paragraph"/>` +
|
||||
@@ -2468,8 +2471,10 @@ function imageFrameXml(img: ImageExport, index: number): string {
|
||||
const inner = `<draw:image xlink:href="${img.path}"/>${title}`;
|
||||
const anchor = img.wrap === 'inline' ? 'as-char' : 'paragraph';
|
||||
const styleName = img.wrap === 'inline' ? '' : ` draw:style-name="ImgFr${index + 1}"`;
|
||||
const x = img.wrapOffsetCm != null && img.wrap !== 'inline' && img.wrap !== 'topBottom'
|
||||
? ` svg:x="${img.wrapOffsetCm}cm"` : '';
|
||||
return (
|
||||
`<draw:frame draw:name="Image${index + 1}"${styleName} text:anchor-type="${anchor}" draw:z-index="${index}"${dims}${imageTransform(img)}>` +
|
||||
`<draw:frame draw:name="Image${index + 1}"${styleName} text:anchor-type="${anchor}" draw:z-index="${index}"${dims}${x}${imageTransform(img)}>` +
|
||||
`${inner}</draw:frame>`
|
||||
);
|
||||
}
|
||||
@@ -2611,7 +2616,7 @@ function textBoxGraphicStyle(box: TextBoxExport, index: number): string {
|
||||
: 'draw:stroke="none"';
|
||||
const wrap = box.wrap === 'inline'
|
||||
? ''
|
||||
: ` ${imageWrapProps(box.wrap)} style:number-wrapped-paragraphs="no-limit"` +
|
||||
: ` ${imageWrapProps(box.wrap, null)} style:number-wrapped-paragraphs="no-limit"` +
|
||||
` style:horizontal-rel="paragraph-content" style:vertical-pos="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.
|
||||
|
||||
+21
-12
@@ -1142,29 +1142,38 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
|
||||
const rot = xfrm ? parseInt(xfrm.getAttribute('rot') ?? '', 10) : NaN;
|
||||
if (Number.isFinite(rot) && rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360;
|
||||
|
||||
if (anchor) attrs.wrap = anchorWrap(anchor, ctx);
|
||||
else fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm)));
|
||||
if (anchor) {
|
||||
const { wrap, offsetCm } = anchorWrap(anchor, ctx);
|
||||
attrs.wrap = wrap;
|
||||
if (offsetCm != null) attrs.wrapOffset = offsetCm;
|
||||
} else {
|
||||
fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm)));
|
||||
}
|
||||
return { type: 'image', attrs };
|
||||
}
|
||||
|
||||
function anchorWrap(anchor: Element, ctx: Ctx): 'left' | 'right' | 'topBottom' {
|
||||
if (anchor.getElementsByTagNameNS(WP, 'wrapTopAndBottom')[0]) return 'topBottom';
|
||||
// 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.
|
||||
function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom'; offsetCm: number | null } {
|
||||
if (anchor.getElementsByTagNameNS(WP, 'wrapTopAndBottom')[0]) return { wrap: 'topBottom', offsetCm: null };
|
||||
const sq = anchor.getElementsByTagNameNS(WP, 'wrapSquare')[0];
|
||||
const wt = sq?.getAttribute('wrapText');
|
||||
if (wt === 'right') return 'left'; // text on right ⇒ image on left
|
||||
if (wt === 'left') return 'right';
|
||||
// wrapText="bothSides" says nothing about the side, so the frame's own x decides it:
|
||||
// the editor has no free position, only a side, and the nearer one is that side.
|
||||
if (wt === 'right') return { wrap: 'left', offsetCm: null }; // text on right ⇒ image on left
|
||||
if (wt === 'left') return { wrap: 'right', offsetCm: null };
|
||||
const posH = anchor.getElementsByTagNameNS(WP, 'positionH')[0];
|
||||
const align = posH?.getElementsByTagNameNS(WP, 'align')[0]?.textContent?.trim();
|
||||
if (align) return align === 'right' || align === 'outside' ? 'right' : 'left';
|
||||
if (align === 'right' || align === 'outside') return { wrap: 'right', offsetCm: null };
|
||||
if (align) return { wrap: 'left', offsetCm: null };
|
||||
const off = parseInt(posH?.getElementsByTagNameNS(WP, 'posOffset')[0]?.textContent ?? '', 10);
|
||||
if (!Number.isFinite(off)) return 'left';
|
||||
if (!Number.isFinite(off)) return { wrap: 'left', offsetCm: null };
|
||||
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;
|
||||
return off + base + cx / 2 > cmToEmu(ctx.contentWidthCm) / 2 ? 'right' : 'left';
|
||||
const left = off + base;
|
||||
const wrap = left + cx / 2 > cmToEmu(ctx.contentWidthCm) / 2 ? 'right' : 'left';
|
||||
return { wrap, offsetCm: round2(left / 360000) };
|
||||
}
|
||||
|
||||
// ---- text boxes / shapes ------------------------------------------------------
|
||||
@@ -1209,7 +1218,7 @@ 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);
|
||||
if (isAnchor) attrs.wrap = anchorWrap(root, ctx).wrap;
|
||||
|
||||
const fillClr = nsChild(nsChild(spPr, A, 'solidFill'), A, 'srgbClr')?.getAttribute('val');
|
||||
const fill = fillClr ? hexColor(fillClr) ?? null : null;
|
||||
|
||||
@@ -131,6 +131,10 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record<string, unknown>,
|
||||
if (anchor || wrapVal) {
|
||||
attrs.wrap = wrapModeFromOdf(wrapVal, gp['style:horizontal-pos']);
|
||||
}
|
||||
// A frame placed by coordinate (style:horizontal-pos="from-left") keeps its x in the
|
||||
// column; the wrap mode alone would snap it to a side.
|
||||
const x = gp['style:horizontal-pos'] === 'from-left' ? lengthToCm(el.getAttributeNS(NS.svg, 'x')) : null;
|
||||
if (x != null && attrs.wrap && attrs.wrap !== 'topBottom') attrs.wrapOffset = Math.round(x * 100) / 100;
|
||||
}
|
||||
|
||||
// A <draw:frame><draw:image> → an image node. Size comes from the frame's svg
|
||||
|
||||
Reference in new issue
Block a user