feat: insert images

This commit is contained in:
Steffen Becker committed 2026-06-21 12:14:10 +02:00
1 parent 8e8b93bccf
commit 64ae976ecf
10 files changed
+595 -11

No files matched your search

+1 -1
View File
@@ -38,10 +38,10 @@
- undo history
- ODT import / open existing file
- header/footer
- Images
- ...
### Not yet implemented
- Images
- Custom right-click context menu (formatting, cut/copy)
- Multi-document management
- PWA / offline support
+64 -1
View File
@@ -11,7 +11,7 @@
import SpellContextMenu from './SpellContextMenu.svelte';
import HeaderFooterLayer from './HeaderFooterLayer.svelte';
import { saveDocument, loadDocument } from '../storage/autosave';
import { applyMarginVars, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import { applyMarginVars, cmToPx, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import { applyOrientationVars, type Orientation } from '../storage/pageOrientation';
import { DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, type TableBreakBand } from './pageBreaks';
@@ -368,6 +368,52 @@
return Fragment.fromArray(nodes);
}
// --- Image insertion (drag-drop / paste); the toolbar button lives in
// ToolbarExpanded.svelte. Shared sizing mirrors the export content-width math. ---
function imageContentBoxPx(): { maxW: number; maxH: number } {
const land = orientation === 'landscape';
const wCm = (land ? 29.7 : 21) - pageMargins.left - pageMargins.right;
const hCm = (land ? 21 : 29.7) - pageMargins.top - pageMargins.bottom;
return { maxW: Math.round(cmToPx(wCm)), maxH: Math.round(cmToPx(hCm)) };
}
function imageFilesFrom(dt: DataTransfer | null): File[] {
if (!dt) return [];
const out: File[] = [];
for (const f of Array.from(dt.files)) if (f.type.startsWith('image/')) out.push(f);
if (!out.length && dt.items) {
for (const it of Array.from(dt.items)) {
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) out.push(f);
}
}
}
return out;
}
function insertImageFile(file: File, pos: number | null): void {
const ed = editor;
if (!ed) return;
const reader = new FileReader();
reader.onload = () => {
const src = reader.result as string;
const probe = document.createElement('img');
probe.onload = () => {
let w = probe.naturalWidth || 1;
let h = probe.naturalHeight || 1;
const { maxW, maxH } = imageContentBoxPx();
if (w > maxW) { h = (h * maxW) / w; w = maxW; }
if (h > maxH) { w = (w * maxH) / h; h = maxH; }
const attrs = { src, alt: file.name, width: Math.round(w), height: Math.round(h) };
if (pos == null) ed.chain().focus().setImage(attrs).run();
else ed.chain().focus().insertContentAt(pos, { type: 'image', attrs }).run();
};
probe.src = src;
};
reader.readAsDataURL(file);
}
onMount(() => {
// Start with empty history lists — loaded content is not an undoable edit.
resetHistoryLog();
@@ -383,6 +429,23 @@
attributes: { spellcheck: 'false' },
handleDOMEvents: {
contextmenu: (view, event) => openSpellMenu(view, event),
drop: (view, event) => {
const e = event as DragEvent;
const files = imageFilesFrom(e.dataTransfer);
if (!files.length) return false;
e.preventDefault();
const at = view.posAtCoords({ left: e.clientX, top: e.clientY });
for (const f of files) insertImageFile(f, at?.pos ?? null);
return true;
},
paste: (view, event) => {
const e = event as ClipboardEvent;
const files = imageFilesFrom(e.clipboardData);
if (!files.length) return false;
e.preventDefault();
for (const f of files) insertImageFile(f, null);
return true;
},
},
transformPasted(slice, view) {
const textStyleType = view.state.schema.marks.textStyle;
+54 -1
View File
@@ -9,7 +9,7 @@
queryLocalFontsIfAllowed,
supportsLocalFontAccess,
} from './fontDetect';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import { DEFAULT_MARGINS, cmToPx, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { DEFAULT_HF_DISTANCES, clampHfDistance, type HfDistances } from '../storage/headerFooter';
import { listContext } from './indent';
@@ -650,6 +650,40 @@
editor.chain().focus().setTextSelection(range).insertTable({ rows, cols, withHeaderRow: false }).run();
}
// --- Image insertion (image.ts) ---
let imageInput = $state<HTMLInputElement | null>(null);
// The page text box in px, so an inserted image never starts wider/taller than
// one page (mirrors export's content-width math; A4 dims swap with orientation).
function contentBoxPx(): { maxW: number; maxH: number } {
const land = pageOrientation === 'landscape';
const wCm = (land ? 29.7 : 21) - pageMargins.left - pageMargins.right;
const hCm = (land ? 21 : 29.7) - pageMargins.top - pageMargins.bottom;
return { maxW: Math.round(cmToPx(wCm)), maxH: Math.round(cmToPx(hCm)) };
}
function onImageFile(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = ''; // allow re-selecting the same file
if (!file || !editor || hfActive) return;
const reader = new FileReader();
reader.onload = () => {
const src = reader.result as string;
const probe = document.createElement('img');
probe.onload = () => {
let w = probe.naturalWidth || 1;
let h = probe.naturalHeight || 1;
const { maxW, maxH } = contentBoxPx();
if (w > maxW) { h = (h * maxW) / w; w = maxW; }
if (h > maxH) { w = (w * maxH) / h; h = maxH; }
editor!.chain().focus().setImage({ src, alt: file.name, width: Math.round(w), height: Math.round(h) }).run();
};
probe.src = src;
};
reader.readAsDataURL(file);
}
// --- Indent (Einzug) ---
// In a list, step the list point one level via indentListForward/Backward (the same
// commands the Tab keymap uses — see indent.ts); outside a list, step the paragraph's
@@ -1080,6 +1114,25 @@
onOpen={onTablePickerOpen}
onInsert={insertTable}
/>
<button
onclick={() => imageInput?.click()}
disabled={!!hfActive}
title={hfActive ? 'Images are not available in headers/footers' : 'Insert image'}
aria-label="Insert image"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" stroke="currentColor" />
<circle cx="5.3" cy="6" r="1.2" fill="currentColor" />
<path d="M2 12l3.7-3.6 2.4 2.4 2.8-2.8L14 11.5" stroke="currentColor" stroke-linejoin="round" />
</svg>
</button>
<input
bind:this={imageInput}
type="file"
accept="image/*"
style="display:none"
onchange={onImageFile}
/>
</div>
<div class="toolbar-separator"></div>
+4
View File
@@ -31,6 +31,7 @@ import { TableView } from './tableView';
import { TableColumnResize } from './tableColumnResize';
import { TableRowResize } from './tableRowResize';
import { TrailingNode } from './trailingNode';
import { Image } from './image';
export const extensions = [
Document,
@@ -54,6 +55,9 @@ export const extensions = [
// Shift+Enter line breaks; round-trips to ODF <text:line-break/> (export/odt.ts
// replaceHardBreaks, import/odt.ts).
HardBreak,
// Inline, as-character image; round-trips to ODF <draw:frame>/<draw:image>
// (export/odt.ts replaceImages/applyImages, import/odt.ts).
Image,
LineHeight,
ParagraphSpacing,
Indent,
+211
View File
@@ -0,0 +1,211 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { dropCursor } from '@tiptap/pm/dropcursor';
// Inline, as-character image (Word's "in line with text"). width/height are
// unscaled document px @96dpi (like tableRow.ts rowHeight); export converts them
// to cm and import back, so size round-trips exactly. Position is fully determined
// by the paragraph + text offset + the paragraph's alignment — no free coordinates.
// The node view adds corner resize handles (aspect-locked, zoom-aware).
declare module '@tiptap/core' {
interface Commands<ReturnType> {
image: {
setImage: (attrs: { src: string; alt?: string; width?: number | null; height?: number | null }) => ReturnType;
};
}
}
const MIN_SIZE_PX = 24;
function parsePx(value: string | null): number | null {
if (!value) return null;
const n = parseFloat(value);
return Number.isFinite(n) ? Math.round(n) : null;
}
export const Image = Node.create({
name: 'image',
group: 'inline',
inline: true,
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
src: { default: null },
alt: { default: '' },
// px @96dpi; rendered as inline style (not width/height attrs) so the
// value stays in one place for the node view to drive.
width: {
default: null,
parseHTML: el => parsePx(el.getAttribute('width') ?? (el as HTMLElement).style.width),
renderHTML: () => ({}),
},
height: {
default: null,
parseHTML: el => parsePx(el.getAttribute('height') ?? (el as HTMLElement).style.height),
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [{ tag: 'img[src]' }];
},
renderHTML({ HTMLAttributes, node }) {
const w = node.attrs.width as number | null;
const h = node.attrs.height as number | null;
const style = [w ? `width:${w}px` : '', h ? `height:${h}px` : ''].filter(Boolean).join(';');
return ['img', mergeAttributes(HTMLAttributes, style ? { style } : {})];
},
addCommands() {
return {
setImage:
attrs =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs }),
};
},
addNodeView() {
return ({ node, editor, getPos }) => new ImageView(node as PMNode, editor, getPos as () => number);
},
// Dragging the image runs ProseMirror's native node move; the drop cursor paints
// a caret at the would-be drop position so the user sees where it lands (like Word).
addProseMirrorPlugins() {
return [dropCursor({ color: '#3b82f6', width: 2 })];
},
});
// Node view: <img> wrapped in an inline-block span with four corner handles shown
// while the node is selected. Mirrors the zoom trick from tableRowResize.ts
// (offset size is unscaled, getBoundingClientRect is scaled → ratio is the zoom).
class ImageView {
dom: HTMLElement;
private img: HTMLImageElement;
private node: PMNode;
private editor: Editor;
private getPos: () => number;
constructor(node: PMNode, editor: Editor, getPos: () => number) {
this.node = node;
this.editor = editor;
this.getPos = getPos;
this.dom = document.createElement('span');
this.dom.className = 'image-node';
this.img = document.createElement('img');
this.img.src = (node.attrs.src as string) ?? '';
this.img.alt = (node.attrs.alt as string) ?? '';
this.applySize();
this.dom.appendChild(this.img);
for (const corner of ['nw', 'ne', 'sw', 'se'] as const) {
const handle = document.createElement('span');
handle.className = `image-resize-handle image-resize-${corner}`;
handle.addEventListener('mousedown', e => this.startResize(e as MouseEvent, corner));
this.dom.appendChild(handle);
}
}
private applySize(): void {
const w = this.node.attrs.width as number | null;
const h = this.node.attrs.height as number | null;
this.img.style.width = w ? `${w}px` : '';
this.img.style.height = h ? `${h}px` : '';
}
private startResize(event: MouseEvent, corner: 'nw' | 'ne' | 'sw' | 'se'): void {
event.preventDefault();
event.stopPropagation();
if (!this.editor.isEditable) return;
const startX = event.clientX;
const startW = this.img.offsetWidth;
const startH = this.img.offsetHeight;
if (!startW || !startH) return;
const aspect = startW / startH;
const zoom = this.img.getBoundingClientRect().width / startW || 1;
// Left-side corners grow when dragged left; right-side corners when dragged right.
const dirX = corner === 'ne' || corner === 'se' ? 1 : -1;
// Never wider than the containing cell, or the page text column outside tables.
// clientWidth/padding are unscaled layout px, matching our doc-px sizing.
const box = (this.dom.closest('td,th') ?? this.dom.closest('.tiptap')) as HTMLElement | null;
let maxW = startW;
if (box) {
const cs = getComputedStyle(box);
maxW = box.clientWidth - parseFloat(cs.paddingLeft || '0') - parseFloat(cs.paddingRight || '0');
}
maxW = Math.max(MIN_SIZE_PX, maxW || startW);
const win = this.dom.ownerDocument.defaultView ?? window;
let lastW = startW;
let lastH = startH;
let moved = false;
const move = (e: MouseEvent): void => {
if (!e.buttons) {
finish();
return;
}
const dx = ((e.clientX - startX) / zoom) * dirX;
lastW = Math.max(MIN_SIZE_PX, Math.min(maxW, Math.round(startW + dx)));
lastH = Math.max(MIN_SIZE_PX, Math.round(lastW / aspect));
moved = true;
this.img.style.width = `${lastW}px`;
this.img.style.height = `${lastH}px`;
};
const finish = (): void => {
win.removeEventListener('mousemove', move);
win.removeEventListener('mouseup', finish);
if (!moved) return;
const pos = this.getPos();
if (typeof pos !== 'number') return;
this.editor.view.dispatch(
this.editor.state.tr.setNodeMarkup(pos, undefined, { ...this.node.attrs, width: lastW, height: lastH }),
);
};
win.addEventListener('mousemove', move);
win.addEventListener('mouseup', finish);
}
update(node: PMNode): boolean {
if (node.type !== this.node.type) return false;
this.node = node;
const src = (node.attrs.src as string) ?? '';
if (this.img.getAttribute('src') !== src) this.img.src = src;
this.img.alt = (node.attrs.alt as string) ?? '';
this.applySize();
return true;
}
selectNode(): void {
this.dom.classList.add('image-selected');
}
deselectNode(): void {
this.dom.classList.remove('image-selected');
}
// Atom node: no ProseMirror-managed content. Ignore the <img> load and our own
// size/handle pokes so PM doesn't try to re-read the DOM.
ignoreMutation(): boolean {
return true;
}
// Keep ProseMirror out of resize-handle interactions so the drag isn't hijacked
// into a node drag or selection change.
stopEvent(event: Event): boolean {
const t = event.target as HTMLElement | null;
return event.type.startsWith('mouse') && !!t?.classList?.contains('image-resize-handle');
}
}
+131 -2
View File
@@ -53,6 +53,21 @@ const PGC = '';
// each tab for this before odf-kit serializes; applyInlineSentinels → <text:tab/>. U+E003.
const TAB = '';
// Sentinel wrapping an image's index (IMG{i}IMG) emitted as plain run text by
// replaceImages, so it rides every odf-kit path (body paragraphs and table cells);
// applyImages rewrites it to a <draw:frame> in content.xml. U+E004.
const IMG = '';
const EXT_BY_MIME: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/gif': 'gif',
'image/svg+xml': 'svg',
'image/webp': 'webp',
'image/bmp': 'bmp',
};
// Automatic list styles minted for in-cell lists (see applyCellBlocks). odf-kit's
// CellBuilder is run-based, so a list inside a cell can't reference a list style it
// generates for top-level lists — we inject our own, mirroring its buildListStyle.
@@ -137,6 +152,71 @@ function replaceTabs(node: TiptapNode): TiptapNode {
return { ...node, content: node.content.map(replaceTabs) };
}
// One embedded picture, collected by replaceImages and emitted by applyImages.
// bytes is ArrayBuffer-backed to match fflate's zip entry map.
type ImageExport = { path: string; bytes: Uint8Array<ArrayBuffer>; mimeType: string; widthCm: number; heightCm: number; alt: string };
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
// Decode an `image` node's data-URI src into bytes + geometry. width/height are
// px @96dpi → cm via round3 (sub-pixel, matches table column widths), so an
// integer-px image round-trips exactly. Returns null for a non-data/empty src.
function imageDescriptor(node: TiptapNode, index: number): ImageExport | null {
const src = node.attrs?.src;
if (typeof src !== 'string' || !src.startsWith('data:')) return null;
const m = /^data:([^;,]+)(;[^,]*)?,([\s\S]*)$/.exec(src);
if (!m) return null;
const mimeType = m[1] || 'image/png';
const isB64 = (m[2] ?? '').includes('base64');
let bytes: Uint8Array<ArrayBuffer>;
try {
bytes = isB64 ? base64ToBytes(m[3]) : new Uint8Array(new TextEncoder().encode(decodeURIComponent(m[3])));
} catch {
return null;
}
if (!bytes.length) return null;
const round3 = (v: number) => Math.round(v * 1000) / 1000;
const w = typeof node.attrs?.width === 'number' ? node.attrs.width : 0;
const h = typeof node.attrs?.height === 'number' ? node.attrs.height : 0;
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,
alt: typeof node.attrs?.alt === 'string' ? node.attrs.alt : '',
};
}
// Replace every inline `image` node with an IMG-sentinel text node and collect its
// bytes/geometry. Runs before odf-kit serialization so the sentinel rides through
// both the native paragraph path and our cell path; applyImages resolves it later.
function replaceImages(node: TiptapNode, images: ImageExport[]): TiptapNode {
if (!node.content?.length) return node;
const content: TiptapNode[] = [];
for (const child of node.content) {
if (child.type === 'image') {
const desc = imageDescriptor(child, images.length);
if (desc) {
images.push(desc);
content.push({ type: 'text', text: `${IMG}${images.length - 1}${IMG}` });
}
continue; // invalid image → dropped
}
content.push(replaceImages(child, images));
}
return { ...node, content };
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Mirror odf-kit's normalizeLineHeight (content.js): a number is a multiplier
// (1.5 → "150%"); a string with a unit passes through ("18pt" → "18pt").
function normalizeLineHeight(lh: number | string): string {
@@ -940,6 +1020,51 @@ function applyInlineSentinels(odtBytes: Uint8Array): Uint8Array {
return rezipOdt(files);
}
// One <draw:frame> for an as-character image. Position is purely the text-flow
// anchor; size is the exact svg geometry, so it round-trips unchanged.
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>` : '';
return (
`<draw:frame draw:name="Image${index + 1}" text:anchor-type="as-char" draw:z-index="${index}"${dims}>` +
`<draw:image xlink:href="${img.path}"/>${title}</draw:frame>`
);
}
// 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 (odf-kit), and rezipOdt re-zips
// arbitrary binary entries, so no other plumbing is needed.
function applyImages(odtBytes: Uint8Array, images: ImageExport[]): Uint8Array {
if (!images.length) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
content = content.replace(new RegExp(`${IMG}(\\d+)${IMG}`, 'g'), (_m, idx: string) => {
const i = Number(idx);
const img = images[i];
return img ? imageFrameXml(img, i) : '';
});
files['content.xml'] = strToU8(content);
for (const img of images) files[img.path] = img.bytes;
const manifestBytes = files['META-INF/manifest.xml'];
if (manifestBytes) {
const entries = images
.map(img => `<manifest:file-entry manifest:full-path="${img.path}" manifest:media-type="${img.mimeType}"/>`)
.join('');
const manifest = strFromU8(manifestBytes).replace('</manifest:manifest>', `${entries}</manifest:manifest>`);
files['META-INF/manifest.xml'] = strToU8(manifest);
}
return rezipOdt(files);
}
// Header/footer input for the export: one single-paragraph doc per zone (or null),
// the page count snapshot for the stored <text:page-count> value, and the page-edge
// distances (cm; default HF_DISTANCE_CM) for header (from top) / footer (from bottom).
@@ -953,7 +1078,10 @@ export type HfExport = {
// The full document → .odt pipeline, DOM-free; returns the .odt bytes.
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null): Promise<Uint8Array> {
const raw = replaceTabs(replaceHardBreaks(docJson));
// Collect embedded images and swap them for IMG sentinels before serialization;
// applyImages resolves the sentinels and writes the Pictures/ + manifest entries.
const images: ImageExport[] = [];
const raw = replaceImages(replaceTabs(replaceHardBreaks(docJson)), images);
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
// Distance from the page edge to the header (top) / footer (bottom). Becomes the
@@ -1066,7 +1194,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const cleaned = collapseRunWhitespace(styledRows);
const withBreaks = applyInlineSentinels(cleaned);
const withStyles = rewriteStylesXml(withBreaks, language ?? null);
const withImages = applyImages(withBreaks, images);
const withStyles = rewriteStylesXml(withImages, language ?? null);
return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist);
}
+78 -5
View File
@@ -2,7 +2,7 @@ import { unzipSync, strFromU8 } from 'fflate';
import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { DEFAULT_ORDERED_TYPE, orderedTypeFromFormat } from '../editor/orderedListTypes';
import { PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
@@ -40,7 +40,71 @@ export interface OdtImportResult {
// match the editor); body/cell paragraphs default to Standard's 0.212cm.
type BlockKind = 'body' | 'list' | 'cell';
type Ctx = { resolver: StyleResolver; warnings: Set<string> };
// `files` is the full unzipped archive so image converters can read Pictures/
// binaries; imageCache dedupes repeated hrefs into one data-URI.
type Ctx = {
resolver: StyleResolver;
warnings: Set<string>;
files: Record<string, Uint8Array>;
imageCache: Map<string, string>;
};
const MIME_BY_EXT: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
webp: 'image/webp',
bmp: 'image/bmp',
};
function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
const chunk = 0x8000; // chunk so String.fromCharCode doesn't blow the call stack
for (let i = 0; i < bytes.length; i += chunk) {
bin += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(bin);
}
// Read a Pictures/ entry from the archive into a base64 data-URI (mime by extension).
function loadImageDataUrl(href: string, ctx: Ctx): string | null {
const cached = ctx.imageCache.get(href);
if (cached) return cached;
const bytes = ctx.files[href];
if (!bytes) return null;
const ext = href.split('.').pop()?.toLowerCase() ?? '';
const url = `data:${MIME_BY_EXT[ext] ?? 'image/png'};base64,${bytesToBase64(bytes)}`;
ctx.imageCache.set(href, url);
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.
function convertFrame(frame: Element, ctx: Ctx): Node | null {
const image = frame.getElementsByTagNameNS(NS.draw, 'image')[0];
if (!image) return null;
const href = (image.getAttributeNS(NS.xlink, 'href') ?? '').replace(/^\.\//, '');
if (!href) return null;
const src = loadImageDataUrl(href, ctx);
if (!src) {
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'));
if (wCm != null) attrs.width = Math.round(cmToPx(wCm));
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
const title = frame.getElementsByTagNameNS(NS.svg, 'title')[0]?.textContent;
if (title) attrs.alt = title;
return { type: 'image', attrs };
}
// ---- editor defaults to suppress on import -----------------------------------
@@ -89,7 +153,7 @@ export function importOdt(bytes: Uint8Array): OdtImportResult {
const body = contentDoc.getElementsByTagNameNS(NS.office, 'text')[0];
if (!body) throw new Error('Not a text document (no office:text body).');
const ctx: Ctx = { resolver, warnings };
const ctx: Ctx = { resolver, warnings, files, imageCache: new Map() };
const blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
@@ -199,8 +263,12 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind): Node[] {
ctx.warnings.add('Nested tables were flattened to paragraphs');
out.push(...flattenTable(el, ctx));
}
} else if (el.namespaceURI === NS.draw && el.localName === 'frame') {
// A frame at block level (rare) → wrap the inline image in a paragraph.
const img = convertFrame(el, ctx);
if (img) out.push({ type: 'paragraph', content: [img] });
} else if (el.namespaceURI === NS.draw) {
ctx.warnings.add('Images and drawings were removed');
ctx.warnings.add('Drawings were removed');
}
}
return out;
@@ -359,8 +427,13 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel
continue;
}
}
if (e.namespaceURI === NS.draw && e.localName === 'frame') {
const img = convertFrame(e, ctx);
if (img) out.push(img);
continue;
}
if (e.namespaceURI === NS.draw) {
ctx.warnings.add('Images and drawings were removed');
ctx.warnings.add('Drawings were removed');
continue;
}
if (e.namespaceURI === NS.office && e.localName === 'annotation') {
+1
View File
@@ -14,6 +14,7 @@ export const NS = {
fo: 'urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0',
svg: 'urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0',
draw: 'urn:oasis:names:tc:opendocument:xmlns:drawing:1.0',
xlink: 'http://www.w3.org/1999/xlink',
} as const;
// Property keys are stored as "alias:localName" for the namespaces we care
+16 -1
View File
@@ -2,11 +2,26 @@ const STORAGE_KEY = 'odf-editor-doc';
const DEBOUNCE_MS = 1000;
let timeout: ReturnType<typeof setTimeout> | null = null;
// localStorage has a ~5 MB quota; embedded images (data-URIs) can exceed it. Warn
// the user once so a failed autosave isn't silent, and swallow the throw so the
// debounced timer doesn't surface an unhandled error.
let quotaWarned = false;
export function saveDocument(json: object): void {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(json));
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(json));
} catch (err) {
console.error('[autosave] Could not save the document:', err);
if (!quotaWarned) {
quotaWarned = true;
alert(
'The document is too large to save automatically (browser storage limit reached). ' +
'This usually happens with large embedded images. Save it as an .odt file to keep your work.',
);
}
}
}, DEBOUNCE_MS);
}
+35
View File
@@ -147,6 +147,41 @@
margin-bottom: 0.212cm;
}
/* Inline, as-character images (image.ts). The span is the node view wrapper; it
carries selection chrome and positions the corner resize handles. */
.paper .tiptap .image-node {
position: relative;
display: inline-block;
/* sit on the text baseline like a tall character, no descender gap */
vertical-align: bottom;
line-height: 0;
}
.paper .tiptap .image-node img {
max-width: 100%;
height: auto;
}
.paper .tiptap .image-node.image-selected img {
outline: 2px solid var(--accent, #3b82f6);
outline-offset: 1px;
}
.paper .tiptap .image-resize-handle {
display: none;
position: absolute;
width: 10px;
height: 10px;
background: var(--accent, #3b82f6);
border: 1px solid #fff;
border-radius: 50%;
z-index: 30;
}
.paper .tiptap .image-node.image-selected .image-resize-handle {
display: block;
}
.paper .tiptap .image-resize-nw { top: -5px; left: -5px; cursor: nwse-resize; }
.paper .tiptap .image-resize-ne { top: -5px; right: -5px; cursor: nesw-resize; }
.paper .tiptap .image-resize-sw { bottom: -5px; left: -5px; cursor: nesw-resize; }
.paper .tiptap .image-resize-se { bottom: -5px; right: -5px; cursor: nwse-resize; }
/* Tab stops every 1.25cm, matching ODF's default tab interval. */
.paper .tiptap {
tab-size: 1.25cm;