feat: file-menue icons
This commit is contained in:
1 parent
7b67969774
commit
460a7d5dcb
4 files changed
+378
-86
No files matched your search
+276
-60
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user