feat: insert images
This commit is contained in:
1 parent
8e8b93bccf
commit
64ae976ecf
10 files changed
+595
-11
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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, '&').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 <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
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user