feat: file-menue icons

This commit is contained in:
Steffen Becker committed 2026-06-15 16:38:13 +02:00
1 parent 7b67969774
commit 460a7d5dcb
4 files changed
+378 -86

No files matched your search

+276 -60
View File
@@ -1,16 +1,18 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Editor } from '@tiptap/core';
import EditorComponent from './lib/editor/Editor.svelte';
import Toolbar from './lib/editor/Toolbar.svelte';
import ToolbarExpanded from './lib/editor/ToolbarExpanded.svelte';
import { exportToOdt } from './lib/export/odt';
import { buildOdt, deriveFilename } from './lib/export/odt';
import { supportsFsAccess, saveOdt, saveAsOdt, openOdt } from './lib/export/saveFile';
import { importOdt } from './lib/import/odt';
import { getPageBreakDebug } from './lib/editor/pageBreaks';
import { getColorDebug } from './lib/editor/colorDebug';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, type ThemeMode } from './lib/storage/theme';
import { loadPageMargins, savePageMargins, type PageMargins } from './lib/storage/pageMargins';
import { loadPageMargins, savePageMargins, DEFAULT_MARGINS, type PageMargins } from './lib/storage/pageMargins';
import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation';
import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter';
import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter';
let editor: Editor | null = $state(null);
let tick: number = $state(0);
@@ -86,25 +88,48 @@
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
async function handleExport() {
if (editor) {
await exportToOdt(editor, pageMargins, pageOrientation, {
header: headerDoc, footer: footerDoc, pageCount: numPages,
headerDistanceCm: hfDistances.header, footerDistanceCm: hfDistances.footer,
});
}
}
// The file the document is saved to (File System Access API). Session-only: a
// reload restores the doc from localStorage but the first Save re-prompts.
let fileHandle: FileSystemFileHandle | null = $state(null);
const fsSupported = supportsFsAccess();
let saveMenuOpen = $state(false);
let fileInput: HTMLInputElement | null = $state(null);
async function handleImportFile(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = ''; // allow re-selecting the same file
if (!file || !editor) return;
// odf-kit export options for the current header/footer + page geometry.
function hfOpts() {
return {
header: headerDoc, footer: footerDoc, pageCount: numPages,
headerDistanceCm: hfDistances.header, footerDistanceCm: hfDistances.footer,
};
}
function isDocNonEmpty(): boolean {
if (!editor) return false;
const body = editor.state.doc.textContent.length > 0 || editor.state.doc.childCount > 1;
return body || !hfIsEmpty(headerDoc) || !hfIsEmpty(footerDoc);
}
function handleNew() {
if (!editor) return;
if (isDocNonEmpty() && !confirm('Start a new document? Any unsaved changes will be lost.')) return;
editor.commands.setContent('<p></p>'); // onUpdate fires → autosave
// Reset everything to defaults; the $effects persist these.
hfActive = null;
headerDoc = null;
footerDoc = null;
pageMargins = { ...DEFAULT_MARGINS };
pageOrientation = 'portrait';
hfDistances = { ...DEFAULT_HF_DISTANCES };
fileHandle = null;
editor.commands.focus();
}
// Replace the document with a parsed .odt; adopt its geometry/header/footer and
// track the source handle (null for the fallback file input) so Save overwrites it.
function applyImport(bytes: Uint8Array, handle: FileSystemFileHandle | null) {
if (!editor) return;
try {
const result = importOdt(new Uint8Array(await file.arrayBuffer()));
const result = importOdt(bytes);
const hasContent = editor.state.doc.textContent.length > 0 || editor.state.doc.childCount > 1;
if (hasContent && !confirm('Opening this file will replace the current document. Continue?')) {
@@ -124,6 +149,7 @@
header: result.headerDistanceCm ?? DEFAULT_HF_DISTANCES.header,
footer: result.footerDistanceCm ?? DEFAULT_HF_DISTANCES.footer,
};
fileHandle = handle;
if (result.warnings.length) {
console.warn('[import] Unsupported content in opened file:', result.warnings);
@@ -135,6 +161,81 @@
}
}
async function handleOpen() {
if (!editor) return;
if (fsSupported) {
try {
const r = await openOdt();
if (r) applyImport(r.bytes, r.handle);
} catch (err) {
if ((err as DOMException)?.name !== 'AbortError') {
console.error('[open] Failed to open file:', err);
alert('Could not open this file.');
}
}
} else {
fileInput?.click();
}
}
async function handleImportFile(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = ''; // allow re-selecting the same file
if (!file) return;
applyImport(new Uint8Array(await file.arrayBuffer()), null);
}
async function handleSave() {
if (!editor) return;
saveMenuOpen = false;
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
try {
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts());
fileHandle = await saveOdt(bytes, deriveFilename(json), fileHandle);
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
// A stored handle may have lost permission; re-prompt via Save As.
if (fileHandle) { fileHandle = null; await handleSaveAs(); return; }
console.error('[save] Failed to save file:', err);
alert('Could not save this file.');
}
}
async function handleSaveAs() {
if (!editor) return;
saveMenuOpen = false;
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
try {
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts());
fileHandle = await saveAsOdt(bytes, deriveFilename(json));
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
console.error('[save] Failed to save file:', err);
alert('Could not save this file.');
}
}
function saveMenuClickOutside(node: HTMLElement) {
function handler(e: MouseEvent) {
if (!node.contains(e.target as Node)) saveMenuOpen = false;
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
onMount(() => {
function onKeydown(e: KeyboardEvent) {
// Ctrl/Cmd+S → Save (suppress the browser's save-page dialog).
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
handleSave();
}
}
window.addEventListener('keydown', onKeydown);
return () => window.removeEventListener('keydown', onKeydown);
});
function handleDebugDump() {
if (!editor) return;
const snapshot = getPageBreakDebug(editor.view);
@@ -163,6 +264,59 @@
<header>
<Toolbar editor={activeEditor} tick={activeTick} />
<div class="header-actions">
{#snippet saveIcon()}
<!-- Floppy disk -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2.75 2.5h7.65L13.5 5.6V12.75a.75.75 0 0 1-.75.75H3.25a.75.75 0 0 1-.75-.75V3.25a.75.75 0 0 1 .25-.75z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
<path d="M5 2.5v3h4.5v-3" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
<rect x="4.75" y="8.75" width="6.5" height="4.75" rx="0.5" stroke="currentColor" stroke-width="1.3"/>
</svg>
{/snippet}
<div class="file-actions">
<button class="file-action-btn" onclick={handleNew} disabled={!editor} title="New document">
<!-- Page with folded corner + plus -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M9 1.75H4.5A1.25 1.25 0 0 0 3.25 3v10A1.25 1.25 0 0 0 4.5 14.25h7A1.25 1.25 0 0 0 12.75 13V5.5L9 1.75z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
<path d="M9 1.75V5.5h3.75" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
<path d="M8 8v3.5M6.25 9.75h3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</button>
<button class="file-action-btn" onclick={handleOpen} disabled={!editor} title="Open .odt…">
<!-- Folder -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M1.75 12.5V4a1 1 0 0 1 1-1h3.2a1 1 0 0 1 .8.4l.7.95a1 1 0 0 0 .8.4h4.2a1 1 0 0 1 1 1v6.75a1 1 0 0 1-1 1H2.75a1 1 0 0 1-1-1z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
</svg>
</button>
{#if fsSupported}
<div class="save-split" use:saveMenuClickOutside>
<button class="file-action-btn save-main" onclick={handleSave} disabled={!editor} title="Save (Ctrl+S)">
{@render saveIcon()}
</button>
<button
class="save-chevron"
onclick={() => (saveMenuOpen = !saveMenuOpen)}
disabled={!editor}
title="Save options"
aria-haspopup="menu"
aria-expanded={saveMenuOpen}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
{#if saveMenuOpen}
<div class="save-dropdown" role="menu">
<button class="save-option" onclick={handleSaveAs} role="menuitem">Save As…</button>
</div>
{/if}
</div>
{:else}
<button class="file-action-btn" onclick={handleSave} disabled={!editor} title="Save .odt">
{@render saveIcon()}
</button>
{/if}
</div>
<div class="action-separator"></div>
<button
class="toolbar-toggle-btn"
class:active={toolbarExpanded}
@@ -245,12 +399,6 @@
class="file-input"
onchange={handleImportFile}
/>
<button class="export-btn open-btn" onclick={() => fileInput?.click()} disabled={!editor}>
Open .odt
</button>
<button class="export-btn" onclick={handleExport} disabled={!editor}>
Download .odt
</button>
</div>
</header>
{#if toolbarExpanded}
@@ -324,6 +472,110 @@
margin-right: 1rem;
}
.file-actions {
display: flex;
align-items: center;
gap: 2px;
}
.action-separator {
width: 1px;
height: 1.5rem;
background: var(--color-border);
}
.file-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
cursor: pointer;
transition: background 0.15s;
}
.file-action-btn:hover:not(:disabled) {
background: var(--color-btn-hover);
}
.file-action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Save split button: floppy + chevron that opens the Save As menu. */
.save-split {
position: relative;
display: inline-flex;
align-items: center;
}
.save-main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.save-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 2rem;
padding: 0;
border: none;
border-radius: var(--radius);
border-top-left-radius: 0;
border-bottom-left-radius: 0;
background: transparent;
color: var(--color-text);
cursor: pointer;
transition: background 0.15s;
}
.save-chevron:hover:not(:disabled) {
background: var(--color-btn-hover);
}
.save-chevron:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.save-dropdown {
position: absolute;
top: calc(100% + 0.4rem);
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
min-width: 120px;
z-index: 100;
overflow: hidden;
}
.save-option {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
color: var(--color-text);
font-size: 0.85rem;
font-family: var(--font-sans);
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.save-option:hover {
background: var(--color-btn-hover);
}
.toolbar-toggle-btn {
display: inline-flex;
align-items: center;
@@ -419,33 +671,6 @@
display: none;
}
/* Compound selector: must outweigh the later .export-btn rules. */
.export-btn.open-btn {
background: transparent;
color: var(--color-text);
border: 1px solid var(--color-border);
}
.export-btn.open-btn:hover:not(:disabled) {
background: var(--color-btn-hover);
}
.export-btn {
margin-left: 0;
margin-right: 0;
padding: 0.4rem 1rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius);
font-size: 0.85rem;
font-family: var(--font-sans);
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.debug-btn {
padding: 0.35rem 0.7rem;
background: transparent;
@@ -469,15 +694,6 @@
cursor: not-allowed;
}
.export-btn:hover:not(:disabled) {
background: var(--color-btn-hover);
}
.export-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.statusbar {
position: fixed;
bottom: 0;
+22 -9
View File
@@ -197,10 +197,11 @@
{@const box = zoneBox(hfActive, editingPage)}
<div class="hf-zone hf-{hfActive} hf-active" style={boxStyle(box)} bind:this={liveMount}></div>
<div class="hf-bar" style="top: {box.top}px; left: {box.left + box.width}px;">
<button class="hf-bar-btn" title="Insert current page number" onmousedown={(e) => e.preventDefault()} onclick={() => insertField('pageNumber')}>Page #</button>
<button class="hf-bar-btn" title="Insert total page count" onmousedown={(e) => e.preventDefault()} onclick={() => insertField('pageCount')}>Count</button>
<span class="hf-bar-label">Insert</span>
<button class="hf-bar-btn" title="Insert the current page number" onmousedown={(e) => e.preventDefault()} onclick={() => insertField('pageNumber')}>Page number</button>
<button class="hf-bar-btn" title="Insert the total page count" onmousedown={(e) => e.preventDefault()} onclick={() => insertField('pageCount')}>Page count</button>
<span class="hf-bar-sep"></span>
<button class="hf-bar-btn hf-bar-done" title="Finish editing" onmousedown={(e) => e.preventDefault()} onclick={() => (hfActive = null)}>Done ✓</button>
<button class="hf-bar-btn hf-bar-done" title="Finish editing the header/footer" onmousedown={(e) => e.preventDefault()} onclick={() => (hfActive = null)}>Done</button>
</div>
{/if}
</div>
@@ -295,28 +296,40 @@
transform: translate(calc(-100% - 4px), -100%);
z-index: 151;
display: flex;
gap: 1px;
padding: 2px;
align-items: center;
gap: 2px;
padding: 3px 4px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.14);
pointer-events: auto;
white-space: nowrap;
}
.hf-bar-label {
padding: 0 4px 0 2px;
color: var(--color-text-muted);
font-family: var(--font-sans);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
user-select: none;
}
.hf-bar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
height: 1.6rem;
padding: 0 4px;
height: 1.8rem;
padding: 0 0.55rem;
border: none;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--color-text);
font-family: var(--font-sans);
font-size: 0.8rem;
white-space: nowrap;
cursor: pointer;
transition: background 0.12s;
}
@@ -331,7 +344,7 @@
.hf-bar-sep {
width: 1px;
align-self: stretch;
margin: 2px 2px;
margin: 3px 2px;
background: var(--color-border);
}
</style>
+6 -17
View File
@@ -1,4 +1,3 @@
import type { Editor } from '@tiptap/core';
import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, type ParagraphBuilder, type TableBuilder, type RowBuilder, type CellBuilder, type HeaderFooterBuilder } from 'odf-kit';
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
@@ -883,7 +882,7 @@ export type HfExport = {
footerDistanceCm?: number;
};
// The full document → .odt pipeline, DOM-free (exportToOdt adds the download).
// 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): Promise<Uint8Array> {
const raw = replaceHardBreaks(docJson);
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
@@ -1043,22 +1042,12 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa
return rezipOdt(files);
}
export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport): Promise<void> {
const finalBytes = await buildOdt(editor.getJSON() as TiptapNode, margins, orientation, hf);
const blob = new Blob([finalBytes as Uint8Array<ArrayBuffer>], { type: 'application/vnd.oasis.opendocument.text' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = getFilename(editor);
a.click();
URL.revokeObjectURL(url);
}
function getFilename(editor: Editor): string {
// Document filename derived from the first non-empty heading (sanitized, max 50
// chars), falling back to document.odt.
export function deriveFilename(json: TiptapNode): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const json = editor.getJSON() as any;
const heading = json.content?.find(
const content = (json as any).content as any[] | undefined;
const heading = content?.find(
(node: any) => node.type === 'heading' && node.content?.length
);
const firstText: string | undefined = heading?.content?.[0]?.text;
+74
View File
@@ -0,0 +1,74 @@
// File System Access API helpers for saving/opening .odt files. Falls back to a
// plain browser download / no-op where the API is unavailable (Firefox/Safari).
const ODT_MIME = 'application/vnd.oasis.opendocument.text';
const PICKER_TYPES = [
{ description: 'OpenDocument Text', accept: { [ODT_MIME]: ['.odt'] } },
];
// showSaveFilePicker/showOpenFilePicker are not in lib.dom yet; reach them via casts.
type WinFs = Window & {
showSaveFilePicker?: (opts: unknown) => Promise<FileSystemFileHandle>;
showOpenFilePicker?: (opts: unknown) => Promise<FileSystemFileHandle[]>;
};
export function supportsFsAccess(): boolean {
return typeof (window as WinFs).showSaveFilePicker === 'function';
}
function download(bytes: Uint8Array, name: string): void {
const blob = new Blob([bytes as Uint8Array<ArrayBuffer>], { type: ODT_MIME });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
async function writeHandle(handle: FileSystemFileHandle, bytes: Uint8Array): Promise<void> {
const writable = await handle.createWritable();
await writable.write(bytes as Uint8Array<ArrayBuffer>);
await writable.close();
}
// Save to the given handle if we have one; otherwise prompt for a location (i.e.
// the first save acts as Save As). Returns the handle written to, or null when
// falling back to a plain download. Throws AbortError if the user cancels.
export async function saveOdt(
bytes: Uint8Array,
suggestedName: string,
handle: FileSystemFileHandle | null,
): Promise<FileSystemFileHandle | null> {
if (!supportsFsAccess()) {
download(bytes, suggestedName);
return null;
}
const target = handle ?? (await (window as WinFs).showSaveFilePicker!({ suggestedName, types: PICKER_TYPES }));
await writeHandle(target, bytes);
return target;
}
// Always prompt for a location. Returns the new handle, or null on fallback.
export async function saveAsOdt(
bytes: Uint8Array,
suggestedName: string,
): Promise<FileSystemFileHandle | null> {
if (!supportsFsAccess()) {
download(bytes, suggestedName);
return null;
}
const handle = await (window as WinFs).showSaveFilePicker!({ suggestedName, types: PICKER_TYPES });
await writeHandle(handle, bytes);
return handle;
}
// Prompt for an .odt to open, capturing its handle so a later save can overwrite
// the same file. Returns null if cancelled. Only call when supportsFsAccess().
export async function openOdt(): Promise<{ bytes: Uint8Array; handle: FileSystemFileHandle; name: string } | null> {
const [handle] = await (window as WinFs).showOpenFilePicker!({ types: PICKER_TYPES, multiple: false });
if (!handle) return null;
const file = await handle.getFile();
return { bytes: new Uint8Array(await file.arrayBuffer()), handle, name: file.name };
}