feat: image image text wrapping

This commit is contained in:
Steffen Becker committed 2026-06-24 21:49:59 +02:00
1 parent f79be768f9
commit 20706c80cb
7 files changed
+301 -22

No files matched your search

+33
View File
@@ -8,6 +8,9 @@
import { spellErrorAt } from './spellCheck';
import { spellController } from '../spell/controller';
import TableToolbar from './TableToolbar.svelte';
import ImageToolbar from './ImageToolbar.svelte';
import type { WrapMode } from './image';
import { NodeSelection } from '@tiptap/pm/state';
import SpellContextMenu from './SpellContextMenu.svelte';
import HeaderFooterLayer from './HeaderFooterLayer.svelte';
import { saveDocument, loadDocument } from '../storage/autosave';
@@ -209,6 +212,32 @@
tableUi = { visible: true, top, left };
}
// --- Floating image wrap toolbar ---
// Shown when a single image node is selected; positioned just above it.
let imageUi = $state<{ visible: boolean; top: number; left: number; wrap: WrapMode }>({ visible: false, top: 0, left: 0, wrap: 'inline' });
function recomputeImageUi() {
const ed = editor;
if (!ed || !editorContainer) {
if (imageUi.visible) imageUi = { ...imageUi, visible: false };
return;
}
const sel = ed.state.selection;
const dom = sel instanceof NodeSelection && sel.node.type.name === 'image' ? ed.view.nodeDOM(sel.from) : null;
if (!(dom instanceof HTMLElement)) {
if (imageUi.visible) imageUi = { ...imageUi, visible: false };
return;
}
const r = dom.getBoundingClientRect();
const cRect = editorContainer.getBoundingClientRect();
imageUi = {
visible: true,
top: r.top - cRect.top + editorContainer.scrollTop,
left: r.left - cRect.left + editorContainer.scrollLeft,
wrap: ((sel as NodeSelection).node.attrs.wrap as WrapMode) || 'inline',
};
}
// Table page-break overlay: pageBreaks.ts reports (via pm-pagecount) where a continuous
// table box crosses a page boundary, each as a band in doc px. Rendered in .band-layer
// inside the scaled .paper — a mask hides borders in the margins, a stripe is the gap.
@@ -259,6 +288,7 @@
cancelAnimationFrame(tableUiRaf);
tableUiRaf = requestAnimationFrame(() => {
recomputeTableUi();
recomputeImageUi();
recomputeBands();
});
}
@@ -553,6 +583,9 @@
{#if tableUi.visible}
<TableToolbar {editor} top={tableUi.top} left={tableUi.left} />
{/if}
{#if imageUi.visible}
<ImageToolbar {editor} top={imageUi.top} left={imageUi.left} wrap={imageUi.wrap} />
{/if}
{#if spellMenu.visible}
<SpellContextMenu
top={spellMenu.top}
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
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' },
];
</script>
<div
class="image-toolbar"
style="top: {top}px; left: {left}px;"
role="toolbar"
tabindex="-1"
aria-label="Image text wrap"
onmousedown={(e) => e.preventDefault()}
>
{#each modes as m}
<button
class="it-btn"
class:active={wrap === m.mode}
title={m.title}
aria-label={m.title}
aria-pressed={wrap === m.mode}
onclick={() => set(m.mode)}
>
{#if m.mode === 'inline'}
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="2.5" y="6" width="5" height="6" rx="1" fill="currentColor" />
<line x1="9" y1="7.5" x2="15.5" y2="7.5" stroke="currentColor" stroke-width="1.2" />
<line x1="9" y1="10.5" x2="15.5" y2="10.5" stroke="currentColor" stroke-width="1.2" />
</svg>
{:else if m.mode === 'left'}
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="2.5" y="4" width="6" height="6" rx="1" fill="currentColor" />
<line x1="10" y1="5" x2="15.5" y2="5" stroke="currentColor" stroke-width="1.2" />
<line x1="10" y1="8" x2="15.5" y2="8" stroke="currentColor" stroke-width="1.2" />
<line x1="2.5" y1="13" x2="15.5" y2="13" stroke="currentColor" stroke-width="1.2" />
</svg>
{:else if m.mode === 'right'}
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="9.5" y="4" width="6" height="6" rx="1" fill="currentColor" />
<line x1="2.5" y1="5" x2="8" y2="5" stroke="currentColor" stroke-width="1.2" />
<line x1="2.5" y1="8" x2="8" y2="8" stroke="currentColor" stroke-width="1.2" />
<line x1="2.5" y1="13" x2="15.5" y2="13" stroke="currentColor" stroke-width="1.2" />
</svg>
{:else}
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<line x1="2.5" y1="3" x2="15.5" y2="3" stroke="currentColor" stroke-width="1.2" />
<rect x="6" y="6" width="6" height="6" rx="1" fill="currentColor" />
<line x1="2.5" y1="15" x2="15.5" y2="15" stroke="currentColor" stroke-width="1.2" />
</svg>
{/if}
</button>
{/each}
</div>
<style>
.image-toolbar {
position: absolute;
transform: translateY(calc(-100% - 6px));
z-index: 150;
display: flex;
align-items: center;
gap: 1px;
padding: 3px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.14);
}
.it-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.7rem;
height: 1.7rem;
min-width: unset;
padding: 0;
border: none;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--color-text);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.it-btn:hover {
background: var(--color-btn-hover);
}
.it-btn.active {
background: var(--color-btn-active, var(--color-btn-hover));
color: var(--color-accent, #3b82f6);
}
</style>
+66 -8
View File
@@ -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<ReturnType> {
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');
}
}
+54 -8
View File
@@ -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<ArrayBuffer>; 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<ArrayBuffer>; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number; wrap: WrapMode };
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
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 <draw:frame> 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 (
`<style:style style:name="ImgFr${index + 1}" style:family="graphic">` +
`<style:graphic-properties ${imageWrapProps(img.wrap)}` +
` style:number-wrapped-paragraphs="no-limit"` +
` style:horizontal-rel="paragraph-content"` +
` style:vertical-pos="top" style:vertical-rel="paragraph"/>` +
`</style:style>`
);
}
// One <draw:frame>. 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 ? `<svg:title>${escapeXml(img.alt)}</svg:title>` : '';
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}"`;
return (
`<draw:frame draw:name="Image${index + 1}" text:anchor-type="as-char" draw:z-index="${index}"${dims}${imageTransform(img)}>` +
`<draw:image xlink:href="${img.path}"/>${title}</draw:frame>`
`<draw:frame draw:name="Image${index + 1}"${styleName} text:anchor-type="${anchor}" draw:z-index="${index}"${dims}${imageTransform(img)}>` +
`${inner}</draw:frame>`
);
}
// 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('</office:automatic-styles>')) {
return content.replace('</office:automatic-styles>', `${styles}</office:automatic-styles>`);
}
if (content.includes('<office:automatic-styles/>')) {
return content.replace('<office:automatic-styles/>', `<office:automatic-styles>${styles}</office:automatic-styles>`);
}
return content.replace('<office:body', `<office:automatic-styles>${styles}</office:automatic-styles><office:body`);
}
// Resolve image sentinels: swap each IMG{i}IMG for its <draw:frame>, 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;
+19 -6
View File
@@ -79,9 +79,18 @@ function loadImageDataUrl(href: string, ctx: Ctx): string | null {
return url;
}
// A <draw:frame><draw:image> → 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 <draw:frame><draw:image> → 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<string, unknown> = { 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 };
}
+6
View File
@@ -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 {
+5
View File
@@ -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%;