feat: image image text wrapping
This commit is contained in:
1 parent
f79be768f9
commit
20706c80cb
7 files changed
+301
-22
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in new issue
Block a user