diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a864dc..c8e97f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lib/editor/Editor.svelte b/src/lib/editor/Editor.svelte index 4fbb261..106be4b 100644 --- a/src/lib/editor/Editor.svelte +++ b/src/lib/editor/Editor.svelte @@ -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; diff --git a/src/lib/editor/ToolbarExpanded.svelte b/src/lib/editor/ToolbarExpanded.svelte index b7850ba..5739629 100644 --- a/src/lib/editor/ToolbarExpanded.svelte +++ b/src/lib/editor/ToolbarExpanded.svelte @@ -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(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} /> + +
diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 3076244..31df79f 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -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 (export/odt.ts // replaceHardBreaks, import/odt.ts). HardBreak, + // Inline, as-character image; round-trips to ODF / + // (export/odt.ts replaceImages/applyImages, import/odt.ts). + Image, LineHeight, ParagraphSpacing, Indent, diff --git a/src/lib/editor/image.ts b/src/lib/editor/image.ts new file mode 100644 index 0000000..9055d38 --- /dev/null +++ b/src/lib/editor/image.ts @@ -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 { + 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: 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 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'); + } +} diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 4f2fde9..a60d764 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -53,6 +53,21 @@ const PGC = ''; // each tab for this before odf-kit serializes; applyInlineSentinels → . 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 in content.xml. U+E004. +const IMG = ''; + +const EXT_BY_MIME: Record = { + '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; mimeType: string; widthCm: number; heightCm: number; alt: string }; + +function base64ToBytes(b64: string): Uint8Array { + 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; + 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, '&').replace(//g, '>').replace(/"/g, '"'); +} + // 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 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 ? `${escapeXml(img.alt)}` : ''; + return ( + `` + + `${title}` + ); +} + +// Resolve image sentinels: swap each IMG{i}IMG for its , 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 => ``) + .join(''); + const manifest = strFromU8(manifestBytes).replace('', `${entries}`); + 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 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 { - 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); } diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index c147982..9b4e1f7 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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 }; +// `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; + files: Record; + imageCache: Map; +}; + +const MIME_BY_EXT: Record = { + 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 → 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 = { 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') { diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 3b4a0a8..9910362 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -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 diff --git a/src/lib/storage/autosave.ts b/src/lib/storage/autosave.ts index 79522f7..7a9f170 100644 --- a/src/lib/storage/autosave.ts +++ b/src/lib/storage/autosave.ts @@ -2,11 +2,26 @@ const STORAGE_KEY = 'odf-editor-doc'; const DEBOUNCE_MS = 1000; let timeout: ReturnType | 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); } diff --git a/src/styles/editor.css b/src/styles/editor.css index b90bef2..e26b7cc 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -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;