+ import type { Editor } from '@tiptap/core';
+ import type { WrapMode } from './image';
+
+ let {
+ editor,
+ top,
+ left,
+ wrap,
+ }: {
+ editor: Editor | null;
+ top: number;
+ left: number;
+ wrap: WrapMode;
+ } = $props();
+
+ // preventDefault on mousedown keeps the image node-selected; .focus() re-anchors
+ // to it so setImageWrap targets the right node.
+ function set(mode: WrapMode) {
+ editor?.chain().focus().setImageWrap(mode).run();
+ }
+
+ const modes: { mode: WrapMode; title: string }[] = [
+ { mode: 'inline', title: 'In line with text' },
+ { mode: 'left', title: 'Wrap text — image left' },
+ { mode: 'right', title: 'Wrap text — image right' },
+ { mode: 'topBottom', title: 'Top and bottom' },
+ ];
+
+
+ e.preventDefault()}
+>
+ {#each modes as m}
+
+ {/each}
+
+
+
diff --git a/src/lib/editor/image.ts b/src/lib/editor/image.ts
index 8155bc3..8e8fc52 100644
--- a/src/lib/editor/image.ts
+++ b/src/lib/editor/image.ts
@@ -1,16 +1,22 @@
import { Node, mergeAttributes } from '@tiptap/core';
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';
-// Inline, as-character image (Word's "in line with text"). width/height are
-// unscaled doc px @96dpi (like tableRow.ts rowHeight); rotation is CW degrees.
-// Export converts to cm + an ODF draw:transform, so size/rotation round-trip exactly.
+// 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
+// draw:transform/style:wrap. A floating image floats at its anchor; drag re-anchors it.
+
+// 'inline' = as-character; 'left'/'right' = square wrap (text on the open side);
+// 'topBottom' = no side wrap (text only above/below).
+export type WrapMode = 'inline' | 'left' | 'right' | 'topBottom';
declare module '@tiptap/core' {
interface Commands {
image: {
- setImage: (attrs: { src: string; alt?: string; width?: number | null; height?: number | null; rotation?: number }) => ReturnType;
+ setImage: (attrs: { src: string; alt?: string; width?: number | null; height?: number | null; rotation?: number; wrap?: WrapMode }) => ReturnType;
+ setImageWrap: (wrap: WrapMode) => ReturnType;
};
}
}
@@ -77,6 +83,11 @@ export const Image = Node.create({
parseHTML: el => parsePx((el as HTMLElement).getAttribute('data-rotation')) ?? 0,
renderHTML: () => ({}),
},
+ wrap: {
+ default: 'inline',
+ parseHTML: el => (el as HTMLElement).getAttribute('data-wrap') ?? 'inline',
+ renderHTML: () => ({}),
+ },
};
},
@@ -88,12 +99,17 @@ export const Image = Node.create({
const w = node.attrs.width as number | null;
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 style = [
w ? `width:${w}px` : '',
h ? `height:${h}px` : '',
rot ? `transform:rotate(${rot}deg)` : '',
].filter(Boolean).join(';');
- return ['img', mergeAttributes(HTMLAttributes, { ...(style ? { style } : {}), ...(rot ? { 'data-rotation': String(rot) } : {}) })];
+ return ['img', mergeAttributes(HTMLAttributes, {
+ ...(style ? { style } : {}),
+ ...(rot ? { 'data-rotation': String(rot) } : {}),
+ ...(wrap !== 'inline' ? { 'data-wrap': wrap } : {}),
+ })];
},
addCommands() {
@@ -102,6 +118,16 @@ export const Image = Node.create({
attrs =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs }),
+
+ // Set the wrap mode on the selected image.
+ setImageWrap:
+ (wrap: WrapMode) =>
+ ({ 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 }));
+ return true;
+ },
};
},
@@ -167,6 +193,7 @@ class ImageView {
this.dom.appendChild(this.badge);
this.applyLayout(this.attrW(), this.attrH(), this.attrRot());
+ this.applyWrap();
}
private showBadge(w: number, h: number): void {
@@ -178,6 +205,7 @@ class ImageView {
private attrW(): number | null { const w = this.node.attrs.width; return typeof w === 'number' ? w : null; }
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'; }
// 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.
@@ -199,6 +227,33 @@ class ImageView {
this.rotor.style.transform = `translate(-50%, -50%) rotate(${deg}deg)`;
}
+ // Float the wrapper per wrap mode so text flows beside it at its anchor paragraph
+ // (left/right) or only above/below it (topBottom). Dragging re-anchors the image
+ // via ProseMirror's native node move, so it always floats where the text actually is.
+ private applyWrap(): void {
+ const d = this.dom;
+ const wrap = this.attrWrap();
+ d.style.float = '';
+ d.style.display = '';
+ d.style.clear = '';
+ d.style.margin = '';
+ 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') {
+ // 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
+ // above/below it. applyLayout reset the width to the box; widen to the column here.
+ d.style.float = 'left';
+ d.style.clear = 'both';
+ d.style.width = '100%';
+ d.style.margin = '6px 0';
+ }
+ }
+
private adoptNaturalSize(): void {
if (this.attrW() != null && this.attrH() != null) return;
const nw = this.img.naturalWidth;
@@ -270,6 +325,7 @@ class ImageView {
}
moved = true;
this.applyLayout(lastW, lastH, deg);
+ this.applyWrap();
this.showBadge(lastW, lastH);
};
const finish = (): void => {
@@ -304,6 +360,7 @@ class ImageView {
lastDeg = ((Math.round(ang) % 360) + 360) % 360;
moved = true;
this.applyLayout(w, h, lastDeg);
+ this.applyWrap();
};
const finish = (): void => {
win.removeEventListener('mousemove', move);
@@ -321,6 +378,7 @@ class ImageView {
if (this.img.getAttribute('src') !== src) this.img.src = src;
this.img.alt = (node.attrs.alt as string) ?? '';
this.applyLayout(this.attrW(), this.attrH(), this.attrRot());
+ this.applyWrap();
return true;
}
@@ -336,11 +394,11 @@ class ImageView {
return true;
}
- // Keep ProseMirror out of handle interactions so a drag isn't hijacked into a
- // node drag or selection change.
+ // Keep ProseMirror out of handle interactions only; dragging the image itself runs
+ // PM's native node move (which re-anchors a floating image to the drop paragraph).
stopEvent(event: Event): boolean {
- const t = event.target as HTMLElement | null;
if (!event.type.startsWith('mouse')) return false;
+ const t = event.target as HTMLElement | null;
return !!t?.classList?.contains('image-resize-handle') || !!t?.classList?.contains('image-rotate-handle');
}
}
diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts
index 0c67d95..55ae651 100644
--- a/src/lib/export/odt.ts
+++ b/src/lib/export/odt.ts
@@ -152,8 +152,10 @@ function replaceTabs(node: TiptapNode): TiptapNode {
}
// One embedded picture, collected by replaceImages and emitted by applyImages.
-// bytes is ArrayBuffer-backed to match fflate's zip entry map. rotationDeg is CW.
-type ImageExport = { path: string; bytes: Uint8Array; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number };
+// 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; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode };
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
@@ -180,16 +182,20 @@ function imageDescriptor(node: TiptapNode, index: number): ImageExport | null {
}
if (!bytes.length) return null;
const round3 = (v: number) => Math.round(v * 1000) / 1000;
+ const pxToCm = (px: number) => round3((px * 2.54) / 96);
const w = typeof node.attrs?.width === 'number' ? node.attrs.width : 0;
const h = typeof node.attrs?.height === 'number' ? node.attrs.height : 0;
+ const wrapAttr = node.attrs?.wrap;
+ const wrap: WrapMode = wrapAttr === 'left' || wrapAttr === 'right' || wrapAttr === 'topBottom' ? wrapAttr : 'inline';
return {
path: `Pictures/image${index + 1}.${EXT_BY_MIME[mimeType] ?? 'png'}`,
bytes,
mimeType,
- widthCm: w > 0 ? round3((w * 2.54) / 96) : 0,
- heightCm: h > 0 ? round3((h * 2.54) / 96) : 0,
+ widthCm: w > 0 ? pxToCm(w) : 0,
+ heightCm: h > 0 ? pxToCm(h) : 0,
alt: typeof node.attrs?.alt === 'string' ? node.attrs.alt : '',
rotationDeg: typeof node.attrs?.rotation === 'number' ? node.attrs.rotation : 0,
+ wrap,
};
}
@@ -1032,19 +1038,57 @@ function imageTransform(img: ImageExport): string {
return ` draw:transform="rotate (${a.toFixed(6)}) translate (${tx}cm ${ty}cm)"`;
}
-// One for an as-character image. Position is the text-flow anchor; size
-// is the exact svg geometry and rotation the draw:transform, so both round-trip.
+// 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"';
+ return 'style:wrap="none" style:horizontal-pos="center"';
+}
+
+// Graphic style for a floating frame (wrap + side, anchored to the paragraph top).
+// Inline images need none. Injected into content.xml automatic-styles by applyImages.
+function imageGraphicStyle(img: ImageExport, index: number): string {
+ if (img.wrap === 'inline') return '';
+ return (
+ `` +
+ `` +
+ ``
+ );
+}
+
+// One . Inline = as-character (text-flow anchor). Floating = paragraph
+// anchor + a graphic style (wrap + side). Size is the exact svg geometry and rotation
+// the draw:transform, so all of it round-trips.
function imageFrameXml(img: ImageExport, index: number): string {
const dims =
(img.widthCm ? ` svg:width="${img.widthCm}cm"` : '') +
(img.heightCm ? ` svg:height="${img.heightCm}cm"` : '');
const title = img.alt ? `${escapeXml(img.alt)}` : '';
+ const inner = `${title}`;
+ const anchor = img.wrap === 'inline' ? 'as-char' : 'paragraph';
+ const styleName = img.wrap === 'inline' ? '' : ` draw:style-name="ImgFr${index + 1}"`;
return (
- `` +
- `${title}`
+ `` +
+ `${inner}`
);
}
+// Inject automatic styles, tolerating an empty/self-closed or absent section.
+function injectAutomaticStyles(content: string, styles: string): string {
+ if (!styles) return content;
+ if (content.includes('')) {
+ return content.replace('', `${styles}`);
+ }
+ if (content.includes('')) {
+ return content.replace('', `${styles}`);
+ }
+ return content.replace('${styles}, add the binary
// picture files, and register each in META-INF/manifest.xml. content.xml already
// declares the draw/svg/xlink namespaces and rezipOdt handles binary entries.
@@ -1060,6 +1104,8 @@ function applyImages(odtBytes: Uint8Array, images: ImageExport[]): Uint8Array {
const img = images[i];
return img ? imageFrameXml(img, i) : '';
});
+ // Graphic styles for the floating frames.
+ content = injectAutomaticStyles(content, images.map((img, i) => imageGraphicStyle(img, i)).join(''));
files['content.xml'] = strToU8(content);
for (const img of images) files[img.path] = img.bytes;
diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts
index 2982676..00b801e 100644
--- a/src/lib/import/odt.ts
+++ b/src/lib/import/odt.ts
@@ -79,9 +79,18 @@ function loadImageDataUrl(href: string, ctx: Ctx): string | null {
return url;
}
-// A → an inline image node. Size comes from the frame's
-// svg geometry (cm → px), so it round-trips unchanged; page-anchored (floating)
-// frames are flattened to inline with a warning.
+// ODF style:wrap names the side TEXT flows on (inverse of the image side); pick a
+// side from horizontal-pos when the value doesn't name one (parallel/dynamic/…).
+function wrapModeFromOdf(wrapVal: string | undefined, hpos: string | undefined): 'left' | 'right' | 'topBottom' {
+ if (wrapVal === 'none') return 'topBottom';
+ if (wrapVal === 'left') return 'right'; // text on left ⇒ image on the right
+ if (wrapVal === 'right') return 'left'; // text on right ⇒ image on the left
+ return hpos && /right/.test(hpos) ? 'right' : 'left';
+}
+
+// A → an image node. Size comes from the frame's svg
+// geometry (cm → px). as-char frames stay inline; paragraph/page-anchored frames
+// with a wrap become floating (wrap mode + svg:x/svg:y position).
function convertFrame(frame: Element, ctx: Ctx): Node | null {
const image = frame.getElementsByTagNameNS(NS.draw, 'image')[0];
if (!image) return null;
@@ -92,9 +101,6 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null {
ctx.warnings.add('Some images could not be read and were skipped');
return null;
}
- if (frame.getAttributeNS(NS.text, 'anchor-type') === 'page') {
- ctx.warnings.add('Floating images were placed inline (position may differ)');
- }
const attrs: Record = { src };
const wCm = lengthToCm(frame.getAttributeNS(NS.svg, 'width'));
const hCm = lengthToCm(frame.getAttributeNS(NS.svg, 'height'));
@@ -109,6 +115,13 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null {
const deg = ((Math.round((-parseFloat(rot[1]) * 180) / Math.PI) % 360) + 360) % 360;
if (deg) attrs.rotation = deg;
}
+ // Floating frame → wrap mode (side). (as-char stays inline.)
+ const anchor = frame.getAttributeNS(NS.text, 'anchor-type');
+ const gp = ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name'));
+ const wrapVal = gp['style:wrap'];
+ if ((anchor && anchor !== 'as-char') || wrapVal) {
+ attrs.wrap = wrapModeFromOdf(wrapVal, gp['style:horizontal-pos']);
+ }
return { type: 'image', attrs };
}
diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts
index 790ff80..6ed376b 100644
--- a/src/lib/import/styleResolver.ts
+++ b/src/lib/import/styleResolver.ts
@@ -90,6 +90,7 @@ function entryFromStyleElement(el: Element): StyleEntry {
collectProps(el, 'table-column-properties', entry.misc);
collectProps(el, 'table-row-properties', entry.misc);
collectProps(el, 'table-cell-properties', entry.misc);
+ collectProps(el, 'graphic-properties', entry.misc);
return entry;
}
@@ -197,6 +198,11 @@ export class StyleResolver {
return styleName ? this.merged('text', styleName).text : {};
}
+ // Graphic-properties of a draw:frame's style (style:wrap, positioning, …).
+ graphicProps(styleName: string | null): PropMap {
+ return styleName ? this.merged('graphic', styleName).misc : {};
+ }
+
// The document's default spell-check language, read from the base Standard
// paragraph style (falls back to the paragraph default-style). null when unset.
documentLanguage(): { language: string; country: string } | null {
diff --git a/src/styles/editor.css b/src/styles/editor.css
index 8ae6665..46bc5c1 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -155,6 +155,11 @@
vertical-align: bottom;
line-height: 0;
}
+/* While selected, lift the node (and its handles) above the header/footer layer
+ (z-index 22) so a rotate/resize handle poking into a margin stays grabbable. */
+.paper .tiptap .image-node.image-selected {
+ z-index: 25;
+}
.paper .tiptap .image-rotor {
position: absolute;
left: 50%;