feat: header/footer
This commit is contained in:
1 parent
3088da0ac9
commit
ee217610ba
11 files changed
+856
-71
No files matched your search
@@ -37,6 +37,7 @@
|
||||
- tables
|
||||
- undo history
|
||||
- ODT import / open existing file
|
||||
- header/footer
|
||||
- ...
|
||||
|
||||
### Not yet implemented
|
||||
|
||||
+52
-4
@@ -10,12 +10,24 @@
|
||||
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, type ThemeMode } from './lib/storage/theme';
|
||||
import { loadPageMargins, savePageMargins, type PageMargins } from './lib/storage/pageMargins';
|
||||
import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation';
|
||||
import { loadHfDoc, saveHfDoc, type HfDoc, type HfZone } from './lib/storage/headerFooter';
|
||||
|
||||
let editor: Editor | null = $state(null);
|
||||
let tick: number = $state(0);
|
||||
let currentPage: number = $state(1);
|
||||
let numPages: number = $state(1);
|
||||
|
||||
// Header/footer content + live-edit state. While a zone is being edited, the
|
||||
// top toolbars target hfEditor instead of the body editor (activeEditor below).
|
||||
let headerDoc: HfDoc = $state(loadHfDoc('header'));
|
||||
let footerDoc: HfDoc = $state(loadHfDoc('footer'));
|
||||
let hfEditor: Editor | null = $state(null);
|
||||
let hfActive: HfZone | null = $state(null);
|
||||
let hfTick: number = $state(0);
|
||||
|
||||
let activeEditor = $derived(hfActive ? hfEditor : editor);
|
||||
let activeTick = $derived(hfActive ? hfTick : tick);
|
||||
|
||||
let themeMode: ThemeMode = $state(loadTheme());
|
||||
let themeOpen = $state(false);
|
||||
let toolbarExpanded = $state(loadToolbarExpanded());
|
||||
@@ -36,6 +48,14 @@
|
||||
saveOrientation(pageOrientation);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
saveHfDoc('header', headerDoc);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
saveHfDoc('footer', footerDoc);
|
||||
});
|
||||
|
||||
function setZoom(value: number) {
|
||||
zoom = Math.max(20, Math.min(300, value));
|
||||
localStorage.setItem('odf-editor-zoom', String(zoom));
|
||||
@@ -62,7 +82,9 @@
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (editor) await exportToOdt(editor, pageMargins, pageOrientation);
|
||||
if (editor) {
|
||||
await exportToOdt(editor, pageMargins, pageOrientation, { header: headerDoc, footer: footerDoc, pageCount: numPages });
|
||||
}
|
||||
}
|
||||
|
||||
let fileInput: HTMLInputElement | null = $state(null);
|
||||
@@ -86,6 +108,10 @@
|
||||
// Editor.svelte re-paginates.
|
||||
if (result.margins) pageMargins = result.margins;
|
||||
if (result.orientation) pageOrientation = result.orientation;
|
||||
// Adopt header/footer (null clears the zone); end any active edit.
|
||||
hfActive = null;
|
||||
headerDoc = result.header;
|
||||
footerDoc = result.footer;
|
||||
|
||||
if (result.warnings.length) {
|
||||
console.warn('[import] Unsupported content in opened file:', result.warnings);
|
||||
@@ -123,7 +149,7 @@
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<Toolbar {editor} {tick} />
|
||||
<Toolbar editor={activeEditor} tick={activeTick} />
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="toolbar-toggle-btn"
|
||||
@@ -216,9 +242,31 @@
|
||||
</div>
|
||||
</header>
|
||||
{#if toolbarExpanded}
|
||||
<ToolbarExpanded {editor} {tick} bind:showFormattingMarks bind:pageMargins bind:pageOrientation />
|
||||
<ToolbarExpanded
|
||||
editor={activeEditor}
|
||||
tick={activeTick}
|
||||
bind:showFormattingMarks
|
||||
bind:pageMargins
|
||||
bind:pageOrientation
|
||||
hfActive={hfActive}
|
||||
onEditZone={(zone) => (hfActive = zone)}
|
||||
/>
|
||||
{/if}
|
||||
<EditorComponent bind:editor bind:tick bind:currentPage bind:numPages {zoom} {showFormattingMarks} {pageMargins} orientation={pageOrientation} />
|
||||
<EditorComponent
|
||||
bind:editor
|
||||
bind:tick
|
||||
bind:currentPage
|
||||
bind:numPages
|
||||
bind:headerDoc
|
||||
bind:footerDoc
|
||||
bind:hfEditor
|
||||
bind:hfActive
|
||||
bind:hfTick
|
||||
{zoom}
|
||||
{showFormattingMarks}
|
||||
{pageMargins}
|
||||
orientation={pageOrientation}
|
||||
/>
|
||||
<footer class="statusbar">
|
||||
<span>Page {currentPage} of {numPages}</span>
|
||||
<div class="zoom-controls">
|
||||
|
||||
@@ -5,17 +5,27 @@
|
||||
import type { Node as PmNode, MarkType } from 'prosemirror-model';
|
||||
import { extensions } from './extensions';
|
||||
import TableToolbar from './TableToolbar.svelte';
|
||||
import HeaderFooterLayer from './HeaderFooterLayer.svelte';
|
||||
import { saveDocument, loadDocument } from '../storage/autosave';
|
||||
import { applyMarginVars, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
|
||||
import { applyOrientationVars, type Orientation } from '../storage/pageOrientation';
|
||||
import type { HfDoc, HfZone } from '../storage/headerFooter';
|
||||
import { FORCE_PAGE_RECALC, type TableBreakBand } from './pageBreaks';
|
||||
import { recordTransaction, resetHistoryLog } from './historyLog.svelte';
|
||||
import '../../styles/editor.css';
|
||||
|
||||
const DEFAULT_EDITOR_FONT = 'Georgia'; // must match ToolbarExpanded.svelte
|
||||
|
||||
let { editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1), zoom = 100, showFormattingMarks = false, pageMargins = DEFAULT_MARGINS, orientation = 'portrait' }: {
|
||||
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number; showFormattingMarks?: boolean; pageMargins?: PageMargins; orientation?: Orientation;
|
||||
let {
|
||||
editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1),
|
||||
zoom = 100, showFormattingMarks = false, pageMargins = DEFAULT_MARGINS, orientation = 'portrait',
|
||||
headerDoc = $bindable(null), footerDoc = $bindable(null),
|
||||
hfEditor = $bindable(null), hfActive = $bindable(null), hfTick = $bindable(0),
|
||||
}: {
|
||||
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number;
|
||||
showFormattingMarks?: boolean; pageMargins?: PageMargins; orientation?: Orientation;
|
||||
headerDoc?: HfDoc; footerDoc?: HfDoc;
|
||||
hfEditor?: Editor | null; hfActive?: HfZone | null; hfTick?: number;
|
||||
} = $props();
|
||||
|
||||
// Apply the page margins + orientation to the :root CSS vars (visual padding,
|
||||
@@ -345,6 +355,10 @@
|
||||
onUpdate: ({ editor: e }) => {
|
||||
saveDocument(e.getJSON());
|
||||
},
|
||||
onFocus: () => {
|
||||
// Clicking back into the body ends header/footer editing (Word behaviour).
|
||||
hfActive = null;
|
||||
},
|
||||
});
|
||||
|
||||
element.addEventListener('pm-pagecount', onPageCount);
|
||||
@@ -376,7 +390,7 @@
|
||||
<!-- Reserves the scaled scroll footprint; the transform on .paper reserves none.
|
||||
Before the first measure (size 0) it's left unsized so .paper isn't clipped. -->
|
||||
<div class="paper-scaler" style={scaledWidth ? `width: ${scaledWidth}px; height: ${scaledHeight}px;` : ''}>
|
||||
<div bind:this={paperEl} class="paper" class:show-formatting-marks={showFormattingMarks} style="transform: scale({appliedZoom / 100});">
|
||||
<div bind:this={paperEl} class="paper" class:show-formatting-marks={showFormattingMarks} class:hf-editing={hfActive} style="transform: scale({appliedZoom / 100});">
|
||||
<!-- Dedicated mount point that TipTap fully owns — keeping it free of Svelte
|
||||
content avoids Svelte and ProseMirror fighting over the same parent's DOM. -->
|
||||
<div bind:this={element} class="tiptap-host"></div>
|
||||
@@ -396,6 +410,17 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<HeaderFooterLayer
|
||||
bind:headerDoc
|
||||
bind:footerDoc
|
||||
bind:hfEditor
|
||||
bind:hfActive
|
||||
bind:hfTick
|
||||
{numPages}
|
||||
{currentPage}
|
||||
{pageMargins}
|
||||
{orientation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if tableUi.visible}
|
||||
@@ -404,6 +429,12 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* While editing a header/footer, dim the body so focus is on the margin zone. */
|
||||
.paper.hf-editing :global(.tiptap) {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
/* Overlay for the table page-break bands, inside .paper (inset:0) so it scales with
|
||||
the page background. pointer-events:none keeps the editor clickable. z-index clears
|
||||
the resize handles (20) so the band also masks a too-tall cell's handle in the gap. */
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
<script lang="ts">
|
||||
import { Editor, generateHTML } from '@tiptap/core';
|
||||
import { hfExtensions } from './hfExtensions';
|
||||
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfZone } from '../storage/headerFooter';
|
||||
import { cmToPx, type PageMargins } from '../storage/pageMargins';
|
||||
import { PAGE_W_PORTRAIT, PAGE_H_PORTRAIT, type Orientation } from '../storage/pageOrientation';
|
||||
|
||||
let {
|
||||
headerDoc = $bindable(),
|
||||
footerDoc = $bindable(),
|
||||
numPages,
|
||||
currentPage,
|
||||
pageMargins,
|
||||
orientation,
|
||||
hfEditor = $bindable(),
|
||||
hfActive = $bindable(),
|
||||
hfTick = $bindable(),
|
||||
}: {
|
||||
headerDoc: HfDoc;
|
||||
footerDoc: HfDoc;
|
||||
numPages: number;
|
||||
currentPage: number;
|
||||
pageMargins: PageMargins;
|
||||
orientation: Orientation;
|
||||
hfEditor: Editor | null;
|
||||
hfActive: HfZone | null;
|
||||
hfTick: number;
|
||||
} = $props();
|
||||
|
||||
const PAGE_GAP = 20;
|
||||
const HF_DISTANCE_PX = cmToPx(HF_DISTANCE_CM);
|
||||
// Schema for static (read-only) rendering of the inactive zones.
|
||||
const renderExts = hfExtensions();
|
||||
|
||||
// All geometry is in unscaled document px — the layer lives inside the scaled
|
||||
// .paper, so the zoom transform applies to it identically to the page background.
|
||||
let pageWidthPx = $derived(orientation === 'landscape' ? PAGE_H_PORTRAIT : PAGE_W_PORTRAIT);
|
||||
let pageHeightPx = $derived(orientation === 'landscape' ? PAGE_W_PORTRAIT : PAGE_H_PORTRAIT);
|
||||
let cycle = $derived(pageHeightPx + PAGE_GAP);
|
||||
let mTop = $derived(cmToPx(pageMargins.top));
|
||||
let mBottom = $derived(cmToPx(pageMargins.bottom));
|
||||
let mLeft = $derived(cmToPx(pageMargins.left));
|
||||
let mRight = $derived(cmToPx(pageMargins.right));
|
||||
let contentWidth = $derived(Math.max(0, pageWidthPx - mLeft - mRight));
|
||||
|
||||
let pages = $derived(Array.from({ length: Math.max(1, numPages) }, (_, i) => i + 1));
|
||||
|
||||
function zoneBox(zone: HfZone, page: number) {
|
||||
const left = mLeft;
|
||||
const width = contentWidth;
|
||||
if (zone === 'header') {
|
||||
const top = (page - 1) * cycle + HF_DISTANCE_PX;
|
||||
return { top, left, width, height: Math.max(0, mTop - HF_DISTANCE_PX) };
|
||||
}
|
||||
const top = (page - 1) * cycle + pageHeightPx - mBottom;
|
||||
return { top, left, width, height: Math.max(0, mBottom - HF_DISTANCE_PX) };
|
||||
}
|
||||
const boxStyle = (b: { top: number; left: number; width: number; height: number }) =>
|
||||
`top: ${b.top}px; left: ${b.left}px; width: ${b.width}px; height: ${b.height}px;`;
|
||||
|
||||
function staticHtml(doc: HfDoc): string {
|
||||
if (hfIsEmpty(doc)) return '';
|
||||
try {
|
||||
return generateHTML(doc as Parameters<typeof generateHTML>[0], renderExts);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
let headerHtml = $derived(staticHtml(headerDoc));
|
||||
let footerHtml = $derived(staticHtml(footerDoc));
|
||||
|
||||
// Replace the placeholder text in every page-field span with the real value:
|
||||
// current page number, or the total page count. Re-runs when its param changes.
|
||||
function patchFields(node: HTMLElement, params: [number, number, string]) {
|
||||
const apply = ([page, total]: [number, number, string]) => {
|
||||
for (const el of Array.from(node.querySelectorAll('[data-page-field]'))) {
|
||||
el.textContent = String(el.getAttribute('data-page-field') === 'count' ? total : page);
|
||||
}
|
||||
};
|
||||
apply(params);
|
||||
return { update: apply };
|
||||
}
|
||||
|
||||
// --- live editing of one zone ---
|
||||
let liveMount = $state<HTMLDivElement | null>(null);
|
||||
let editingPage = $state(1);
|
||||
// Set by a double-click (that page); null when editing is triggered externally
|
||||
// (Layout-panel buttons), where the current page is used instead.
|
||||
let pendingPage: number | null = null;
|
||||
let liveZone: HfZone | null = null;
|
||||
|
||||
function zoneDoc(zone: HfZone): HfDoc {
|
||||
return zone === 'header' ? headerDoc : footerDoc;
|
||||
}
|
||||
function emptyDoc(): HfDoc {
|
||||
return { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
}
|
||||
|
||||
function startEdit(zone: HfZone, page: number) {
|
||||
pendingPage = page;
|
||||
hfActive = zone; // the $effect below mounts the live editor
|
||||
}
|
||||
|
||||
function destroyLive() {
|
||||
hfEditor?.destroy();
|
||||
hfEditor = null;
|
||||
liveZone = null;
|
||||
}
|
||||
|
||||
// Mount / swap / unmount the single live editor as hfActive changes. Driven from
|
||||
// double-click (sets editingPage) or the Layout-panel buttons (use currentPage).
|
||||
$effect(() => {
|
||||
const zone = hfActive;
|
||||
const mount = liveMount; // read unconditionally so it's always a tracked dep
|
||||
if (!zone) {
|
||||
if (hfEditor) destroyLive();
|
||||
return;
|
||||
}
|
||||
if (!mount) return;
|
||||
if (hfEditor && liveZone === zone) return; // already editing this zone
|
||||
if (hfEditor) destroyLive();
|
||||
|
||||
editingPage = pendingPage ?? currentPage;
|
||||
pendingPage = null;
|
||||
liveZone = zone;
|
||||
const ed = new Editor({
|
||||
element: mount,
|
||||
extensions: hfExtensions(zone === 'header' ? 'Header…' : 'Footer…'),
|
||||
content: zoneDoc(zone) ?? emptyDoc(),
|
||||
// No autofocus: its scrollIntoView nudges the page so the just-clicked zone
|
||||
// appears to jump. Focus the zone explicitly without scrolling instead.
|
||||
onTransaction: () => {
|
||||
hfTick++;
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
const json = editor.getJSON() as HfDoc;
|
||||
if (zone === 'header') headerDoc = json;
|
||||
else footerDoc = json;
|
||||
},
|
||||
editorProps: {
|
||||
handleKeyDown: (_view, event) => {
|
||||
if (event.key === 'Escape') {
|
||||
hfActive = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
ed.commands.focus('end', { scrollIntoView: false });
|
||||
hfEditor = ed;
|
||||
});
|
||||
|
||||
// Keep the live editor's own page-field spans showing the edited page / total.
|
||||
$effect(() => {
|
||||
void hfTick;
|
||||
void numPages;
|
||||
void editingPage;
|
||||
if (!liveMount) return;
|
||||
for (const el of Array.from(liveMount.querySelectorAll('[data-page-field]'))) {
|
||||
el.textContent = String(el.getAttribute('data-page-field') === 'count' ? numPages : editingPage);
|
||||
}
|
||||
});
|
||||
|
||||
function insertField(kind: 'pageNumber' | 'pageCount') {
|
||||
hfEditor?.chain().focus().insertContent({ type: kind }).run();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="hf-layer">
|
||||
{#each pages as p}
|
||||
{#each ['header', 'footer'] as const as zone}
|
||||
{#if !(hfActive === zone && editingPage === p)}
|
||||
{@const html = zone === 'header' ? headerHtml : footerHtml}
|
||||
<div
|
||||
class="hf-zone hf-{zone}"
|
||||
class:hf-empty={!html}
|
||||
data-hf-label={zone === 'header' ? 'Header' : 'Footer'}
|
||||
style={boxStyle(zoneBox(zone, p))}
|
||||
ondblclick={() => startEdit(zone, p)}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
use:patchFields={[p, numPages, html]}
|
||||
>
|
||||
{@html html}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
{#if hfActive}
|
||||
{@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-sep"></span>
|
||||
<button class="hf-bar-btn hf-bar-done" title="Finish editing" onmousedown={(e) => e.preventDefault()} onclick={() => (hfActive = null)}>Done ✓</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Sits inside the scaled .paper, like .band-layer. Zones opt back into pointer
|
||||
events so the margin areas are double-clickable to edit (Word behaviour). */
|
||||
.hf-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 22;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hf-zone {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: auto;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 12pt;
|
||||
color: var(--color-text);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.hf-header {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.hf-footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Inactive zones render slightly muted, like Word's greyed header/footer. */
|
||||
.hf-zone:not(.hf-active) {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* Empty zone: invisible until hovered, then show a faint label hint. */
|
||||
.hf-empty::before {
|
||||
content: attr(data-hf-label);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-sans);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.hf-footer.hf-empty::before {
|
||||
margin-top: auto;
|
||||
}
|
||||
.hf-empty:hover::before {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.hf-active {
|
||||
outline: 1px dashed var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Strip the page margins from the rendered header/footer paragraph. */
|
||||
.hf-zone :global(p) {
|
||||
margin: 0;
|
||||
line-height: 1.15;
|
||||
}
|
||||
/* The live editor's editable root is also a `.tiptap`, so the global
|
||||
`.paper .tiptap` rules (96px padding, 1123px min-height, page-background
|
||||
gradient) leak in and push the text out of the clipped zone. Reset them —
|
||||
higher specificity than `.paper .tiptap`, plus !important for the gradient. */
|
||||
.hf-layer .hf-zone :global(.tiptap) {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
background: none !important;
|
||||
font-size: 12pt;
|
||||
line-height: 1.15;
|
||||
outline: none;
|
||||
}
|
||||
/* The live editor's paragraph also matches `.paper .tiptap p` (margin-bottom
|
||||
0.212cm), which the static `<p>` doesn't — with flex-end alignment that gap
|
||||
shifts the text up on activation. Match the static zero margin (higher
|
||||
specificity than `.paper .tiptap p`). */
|
||||
.hf-layer .hf-zone :global(.tiptap p) {
|
||||
margin: 0;
|
||||
}
|
||||
.hf-zone :global([data-page-field]) {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.hf-bar {
|
||||
position: absolute;
|
||||
transform: translate(calc(-100% - 4px), -100%);
|
||||
z-index: 151;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
padding: 2px;
|
||||
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;
|
||||
}
|
||||
|
||||
.hf-bar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.hf-bar-btn:hover {
|
||||
background: var(--color-btn-hover);
|
||||
}
|
||||
.hf-bar-done {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hf-bar-sep {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
margin: 2px 2px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -12,8 +12,8 @@
|
||||
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
|
||||
let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait') }:
|
||||
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation } = $props();
|
||||
let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), hfActive = null, onEditZone }:
|
||||
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void } = $props();
|
||||
|
||||
type AlignValue = 'left' | 'center' | 'right' | 'justify';
|
||||
|
||||
@@ -1058,6 +1058,20 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="lh-section-label">Header & footer</div>
|
||||
<div class="hf-edit-row">
|
||||
<button
|
||||
class="hf-edit-btn"
|
||||
class:active={hfActive === 'header'}
|
||||
onclick={() => onEditZone?.('header')}
|
||||
>Edit header</button>
|
||||
<button
|
||||
class="hf-edit-btn"
|
||||
class:active={hfActive === 'footer'}
|
||||
onclick={() => onEditZone?.('footer')}
|
||||
>Edit footer</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1509,6 +1523,35 @@
|
||||
padding: 4px 6px 2px;
|
||||
}
|
||||
|
||||
.hf-edit-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 2px 6px 4px;
|
||||
}
|
||||
|
||||
.hf-edit-btn {
|
||||
flex: 1;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.hf-edit-btn:hover {
|
||||
background: var(--color-btn-hover);
|
||||
}
|
||||
|
||||
.hf-edit-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.margin-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Document from '@tiptap/extension-document';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import Text from '@tiptap/extension-text';
|
||||
import Bold from '@tiptap/extension-bold';
|
||||
import Italic from '@tiptap/extension-italic';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Strike from '@tiptap/extension-strike';
|
||||
import Subscript from '@tiptap/extension-subscript';
|
||||
import Superscript from '@tiptap/extension-superscript';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import { TextStyle, FontFamily, FontSize } from '@tiptap/extension-text-style';
|
||||
import HardBreak from '@tiptap/extension-hard-break';
|
||||
import History from '@tiptap/extension-history';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import { FontWeight } from './fontWeight';
|
||||
import { FontColor } from './fontColor';
|
||||
import { PageNumber, PageCount } from './pageField';
|
||||
|
||||
// Schema for the header/footer mini editors: exactly one paragraph of formatted
|
||||
// runs, line breaks, and page fields — what odf-kit's HeaderFooterBuilder (and
|
||||
// thus the ODT export) can express. Marks mirror the main editor's.
|
||||
|
||||
const HfDocument = Document.extend({ content: 'paragraph' });
|
||||
|
||||
export function hfExtensions(placeholder = '') {
|
||||
return [
|
||||
HfDocument,
|
||||
Paragraph,
|
||||
Text,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strike,
|
||||
Subscript,
|
||||
Superscript,
|
||||
TextStyle,
|
||||
FontFamily,
|
||||
FontSize,
|
||||
FontWeight,
|
||||
FontColor,
|
||||
Highlight.configure({ multicolor: true }),
|
||||
HardBreak,
|
||||
PageNumber,
|
||||
PageCount,
|
||||
History,
|
||||
Placeholder.configure({ placeholder }),
|
||||
TextAlign.configure({
|
||||
types: ['paragraph'],
|
||||
alignments: ['left', 'center', 'right', 'justify'],
|
||||
defaultAlignment: 'left',
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Node } from '@tiptap/core';
|
||||
|
||||
// Inline atom fields for header/footer content: current page number and total
|
||||
// page count. The DOM text is a placeholder — HeaderFooterLayer.svelte patches
|
||||
// every [data-page-field] span with the real per-page value after rendering.
|
||||
// Round-trips to ODF <text:page-number>/<text:page-count> (export/import odt.ts).
|
||||
|
||||
function pageFieldNode(name: string, kind: 'number' | 'count') {
|
||||
return Node.create({
|
||||
name,
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: `span[data-page-field="${kind}"]` }];
|
||||
},
|
||||
|
||||
renderHTML() {
|
||||
return ['span', { 'data-page-field': kind }, '1'];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const PageNumber = pageFieldNode('pageNumber', 'number');
|
||||
export const PageCount = pageFieldNode('pageCount', 'count');
|
||||
+137
-43
@@ -1,8 +1,9 @@
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, type ParagraphBuilder, type TableBuilder, type RowBuilder, type CellBuilder } from 'odf-kit';
|
||||
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';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc } from '../storage/headerFooter';
|
||||
import { DEFAULT_ORDERED_TYPE, orderedTypeDef } from '../editor/orderedListTypes';
|
||||
|
||||
type AlignValue = 'left' | 'center' | 'right' | 'justify';
|
||||
@@ -17,6 +18,9 @@ const CUST_H = '__cust_h__';
|
||||
// native path emits cells with no border (invisible in LibreOffice/Word), so we
|
||||
// build the table ourselves with an explicit border.
|
||||
const CUST_TABLE = '__cust_table__';
|
||||
// Synthetic first node: routes header/footer emission through unknownNodeHandler,
|
||||
// which is the only hook with access to the OdtDocument (setHeader/setFooter).
|
||||
const CUST_HF = '__cust_hf__';
|
||||
|
||||
// Table export styling. Values mirror the editor's table CSS (src/styles/editor.css)
|
||||
// so the .odt matches the on-screen preview.
|
||||
@@ -42,6 +46,10 @@ const SEG = '';
|
||||
// swaps the char for <text:line-break/> in content.xml. U+E001 (SEG is U+E000).
|
||||
const LBR = '';
|
||||
|
||||
// Sentinel wrapping the page-count digits in header/footer runs; applyHfPostProcess
|
||||
// rewrites it to <text:page-count> in styles.xml. U+E002 (SEG/LBR are E000/E001).
|
||||
const PGC = '';
|
||||
|
||||
// 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.
|
||||
@@ -665,48 +673,67 @@ export function normalizeColor(input: string): string | undefined {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Emit each text node as an odf-kit run, translating its TipTap marks/attrs into
|
||||
// TextFormatting (bold/italic/underline, font family/size, colour, highlight).
|
||||
// Translate a TipTap mark set into odf-kit TextFormatting (bold/italic/underline,
|
||||
// font family/size, colour, highlight). Shared by body runs and header/footer runs.
|
||||
function formattingFromMarks(marks: TiptapNode['marks'] = []): TextFormatting {
|
||||
const tsm = marks.find(m => m.type === 'textStyle');
|
||||
const fmt: TextFormatting = {};
|
||||
if (marks.some(m => m.type === 'bold')) fmt.bold = true;
|
||||
// Explicit weight (e.g. un-bolding heading text) overrides the bold shortcut.
|
||||
if (tsm?.attrs?.fontWeight) {
|
||||
const fw = String(tsm.attrs.fontWeight);
|
||||
fmt.fontWeight = (/^\d+$/.test(fw) ? Number(fw) : fw) as TextFormatting['fontWeight'];
|
||||
}
|
||||
if (marks.some(m => m.type === 'italic')) fmt.italic = true;
|
||||
if (marks.some(m => m.type === 'underline')) fmt.underline = true;
|
||||
if (marks.some(m => m.type === 'strike')) fmt.strikethrough = true;
|
||||
if (marks.some(m => m.type === 'superscript')) fmt.superscript = true;
|
||||
else if (marks.some(m => m.type === 'subscript')) fmt.subscript = true;
|
||||
if (tsm?.attrs?.fontFamily) {
|
||||
const ff = String(tsm.attrs.fontFamily);
|
||||
// Explicitly choosing the editor default should match the untagged
|
||||
// default, which resolves to EXPORT_FONT via the Standard style.
|
||||
fmt.fontFamily = ff === ODFKIT_DEFAULT_FONT ? EXPORT_FONT : ff;
|
||||
}
|
||||
if (tsm?.attrs?.fontSize) fmt.fontSize = String(tsm.attrs.fontSize);
|
||||
if (tsm?.attrs?.color) {
|
||||
const c = normalizeColor(String(tsm.attrs.color));
|
||||
if (c) fmt.color = c;
|
||||
}
|
||||
// Text highlight (background). odf-kit maps highlightColor → fo:background-color
|
||||
// natively for normal paragraphs; this covers the custom-attr-paragraph path
|
||||
// (CUST_P/CUST_H), which bypasses odf-kit's own mark handling.
|
||||
const hl = marks.find(m => m.type === 'highlight');
|
||||
if (hl?.attrs?.color) {
|
||||
const c = normalizeColor(String(hl.attrs.color));
|
||||
if (c) fmt.highlightColor = c;
|
||||
}
|
||||
return fmt;
|
||||
}
|
||||
|
||||
// Emit each text node as an odf-kit run.
|
||||
function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = []) {
|
||||
for (const node of content) {
|
||||
if (node.type !== 'text' || !node.text) continue;
|
||||
const marks = node.marks ?? [];
|
||||
const tsm = marks.find(m => m.type === 'textStyle');
|
||||
const fmt: TextFormatting = {};
|
||||
if (marks.some(m => m.type === 'bold')) fmt.bold = true;
|
||||
// Explicit weight (e.g. un-bolding heading text) overrides the bold shortcut.
|
||||
if (tsm?.attrs?.fontWeight) {
|
||||
const fw = String(tsm.attrs.fontWeight);
|
||||
fmt.fontWeight = (/^\d+$/.test(fw) ? Number(fw) : fw) as TextFormatting['fontWeight'];
|
||||
}
|
||||
if (marks.some(m => m.type === 'italic')) fmt.italic = true;
|
||||
if (marks.some(m => m.type === 'underline')) fmt.underline = true;
|
||||
if (marks.some(m => m.type === 'strike')) fmt.strikethrough = true;
|
||||
if (marks.some(m => m.type === 'superscript')) fmt.superscript = true;
|
||||
else if (marks.some(m => m.type === 'subscript')) fmt.subscript = true;
|
||||
if (tsm?.attrs?.fontFamily) {
|
||||
const ff = String(tsm.attrs.fontFamily);
|
||||
// Explicitly choosing the editor default should match the untagged
|
||||
// default, which resolves to EXPORT_FONT via the Standard style.
|
||||
fmt.fontFamily = ff === ODFKIT_DEFAULT_FONT ? EXPORT_FONT : ff;
|
||||
}
|
||||
if (tsm?.attrs?.fontSize) fmt.fontSize = String(tsm.attrs.fontSize);
|
||||
if (tsm?.attrs?.color) {
|
||||
const c = normalizeColor(String(tsm.attrs.color));
|
||||
if (c) fmt.color = c;
|
||||
}
|
||||
// Text highlight (background). odf-kit maps highlightColor → fo:background-color
|
||||
// natively for normal paragraphs; this covers the custom-attr-paragraph path
|
||||
// (CUST_P/CUST_H), which bypasses odf-kit's own mark handling.
|
||||
const hl = marks.find(m => m.type === 'highlight');
|
||||
if (hl?.attrs?.color) {
|
||||
const c = normalizeColor(String(hl.attrs.color));
|
||||
if (c) fmt.highlightColor = c;
|
||||
}
|
||||
const fmt = formattingFromMarks(node.marks);
|
||||
p.addText(node.text, Object.keys(fmt).length ? fmt : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit the header/footer paragraph into odf-kit's HeaderFooterBuilder. hardBreak
|
||||
// and pageCount ride as sentinels (LBR / PGC-wrapped digits) and are rewritten to
|
||||
// <text:line-break/> / <text:page-count> in applyHfPostProcess (styles.xml).
|
||||
function applyHfRuns(b: HeaderFooterBuilder, para: TiptapNode, pageCount: number): void {
|
||||
for (const node of para.content ?? []) {
|
||||
const fmt = formattingFromMarks(node.marks);
|
||||
const f = Object.keys(fmt).length ? fmt : undefined;
|
||||
if (node.type === 'text' && node.text) b.addText(node.text, f);
|
||||
else if (node.type === 'hardBreak') b.addText(LBR);
|
||||
else if (node.type === 'pageNumber') b.addPageNumber(f);
|
||||
else if (node.type === 'pageCount') b.addText(`${PGC}${pageCount}${PGC}`, f);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit a cell's inline content into odf-kit's run-based CellBuilder and, in lockstep,
|
||||
// return a CellBlock[] descriptor. odf-kit serializes the runs into one <text:p>;
|
||||
// applyCellBlocks splits on SEG and rebuilds real <text:h>/<text:p>/<text:list>.
|
||||
@@ -845,10 +872,19 @@ function applyLineBreaks(odtBytes: Uint8Array): Uint8Array {
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// Header/footer input for the export: one single-paragraph doc per zone (or null)
|
||||
// plus the page count snapshot used as the stored <text:page-count> value.
|
||||
export type HfExport = { header: HfDoc; footer: HfDoc; pageCount: number };
|
||||
|
||||
// The full document → .odt pipeline, DOM-free (exportToOdt adds the download).
|
||||
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait'): Promise<Uint8Array> {
|
||||
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport): Promise<Uint8Array> {
|
||||
const raw = replaceHardBreaks(docJson);
|
||||
const json = injectCustomTypes(raw);
|
||||
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;
|
||||
let json = injectCustomTypes(raw);
|
||||
if (headerPara || footerPara) {
|
||||
json = { ...json, content: [{ type: CUST_HF }, ...(json.content ?? [])] };
|
||||
}
|
||||
|
||||
// Text width = A4 page width (portrait 21cm / landscape 29.7cm) minus the L/R
|
||||
// margins. Table column widths are scaled to fill exactly this width.
|
||||
@@ -865,11 +901,19 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
orientation,
|
||||
// Margins (cm) come from the Layout panel via App state. They match the
|
||||
// editor's on-screen padding so exported line wrapping / page flow is identical.
|
||||
marginTop: `${margins.top}cm`,
|
||||
marginBottom: `${margins.bottom}cm`,
|
||||
// With a header/footer, ODF's vertical margin is the page-edge→header distance;
|
||||
// applyHfPostProcess sizes the header box so the body still starts at the
|
||||
// editor's margin (Word-style mapping).
|
||||
marginTop: `${headerPara ? HF_DISTANCE_CM : margins.top}cm`,
|
||||
marginBottom: `${footerPara ? HF_DISTANCE_CM : margins.bottom}cm`,
|
||||
marginLeft: `${margins.left}cm`,
|
||||
marginRight: `${margins.right}cm`,
|
||||
unknownNodeHandler(node: TiptapNode, doc: OdtDocument) {
|
||||
if (node.type === CUST_HF) {
|
||||
if (headerPara) doc.setHeader((b: HeaderFooterBuilder) => applyHfRuns(b, headerPara, hf!.pageCount));
|
||||
if (footerPara) doc.setFooter((b: HeaderFooterBuilder) => applyHfRuns(b, footerPara, hf!.pageCount));
|
||||
return;
|
||||
}
|
||||
if (node.type === CUST_TABLE) {
|
||||
exportTable(node, doc, contentWidthCm, cellBlocks);
|
||||
return;
|
||||
@@ -933,11 +977,61 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
|
||||
const cleaned = collapseRunWhitespace(styledRows);
|
||||
const withBreaks = applyLineBreaks(cleaned);
|
||||
return rewriteStylesXml(withBreaks);
|
||||
const withStyles = rewriteStylesXml(withBreaks);
|
||||
return applyHfPostProcess(withStyles, margins, headerPara, footerPara);
|
||||
}
|
||||
|
||||
export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait'): Promise<void> {
|
||||
const finalBytes = await buildOdt(editor.getJSON() as TiptapNode, margins, orientation);
|
||||
function hfAlign(para: TiptapNode): AlignValue | null {
|
||||
const ta = para.attrs?.textAlign;
|
||||
return ta === 'center' || ta === 'right' || ta === 'justify' ? ta : null;
|
||||
}
|
||||
|
||||
// Header/footer post-processing on styles.xml: resolve the LBR/PGC sentinels in the
|
||||
// emitted runs, rewrite odf-kit's fixed header/footer geometry (0.6/0.5cm) to the
|
||||
// Word-style mapping (page margin = HF distance, min-height fills up to the body
|
||||
// margin so the body starts exactly at the editor's margin), and apply the
|
||||
// paragraph alignment to the named Header/Footer styles.
|
||||
function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPara: TiptapNode | null, footerPara: TiptapNode | null): Uint8Array {
|
||||
if (!headerPara && !footerPara) return odtBytes;
|
||||
|
||||
const files = unzipSync(odtBytes);
|
||||
const stylesBytes = files['styles.xml'];
|
||||
if (!stylesBytes) return odtBytes;
|
||||
let styles = strFromU8(stylesBytes);
|
||||
|
||||
// Same fix as collapseRunWhitespace: odf-kit joins runs with "\n", which would
|
||||
// collapse into spurious spaces. styles.xml only has text:p inside header/footer.
|
||||
styles = styles.replace(/<text:p\b[^>]*>[\s\S]*?<\/text:p>/g, (block) => block.replace(/\n/g, ''));
|
||||
styles = styles.split(LBR).join('<text:line-break/>');
|
||||
styles = styles.replace(new RegExp(`${PGC}(\\d*)${PGC}`, 'g'), '<text:page-count>$1</text:page-count>');
|
||||
|
||||
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
||||
const zone = (kind: 'header' | 'footer', para: TiptapNode, bodyMarginCm: number) => {
|
||||
const minH = round3(Math.max(0.2, bodyMarginCm - HF_DISTANCE_CM));
|
||||
// The spacing attr sits on the body-facing side: below the header, above the footer.
|
||||
const spacingAttr = kind === 'header' ? 'fo:margin-bottom' : 'fo:margin-top';
|
||||
styles = styles.replace(
|
||||
new RegExp(`<style:${kind}-style>[\\s\\S]*?</style:${kind}-style>`),
|
||||
`<style:${kind}-style><style:header-footer-properties fo:min-height="${minH}cm" ${spacingAttr}="0cm" style:dynamic-spacing="false"/></style:${kind}-style>`,
|
||||
);
|
||||
const align = hfAlign(para);
|
||||
if (align) {
|
||||
const styleName = kind === 'header' ? 'Header' : 'Footer';
|
||||
styles = styles.replace(
|
||||
new RegExp(`(<style:style style:name="${styleName}"[^>]*?)/>`),
|
||||
`$1><style:paragraph-properties fo:text-align="${align}"/></style:style>`,
|
||||
);
|
||||
}
|
||||
};
|
||||
if (headerPara) zone('header', headerPara, margins.top);
|
||||
if (footerPara) zone('footer', footerPara, margins.bottom);
|
||||
|
||||
files['styles.xml'] = strToU8(styles);
|
||||
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);
|
||||
|
||||
+65
-4
@@ -23,6 +23,9 @@ export interface OdtImportResult {
|
||||
content: Node; // { type: 'doc', … }
|
||||
margins: PageMargins | null;
|
||||
orientation: Orientation | null;
|
||||
// Single-paragraph docs in the hfExtensions schema; null = no zone.
|
||||
header: Node | null;
|
||||
footer: Node | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@@ -78,15 +81,58 @@ export function importOdt(bytes: Uint8Array): OdtImportResult {
|
||||
const blocks = convertBlocks(Array.from(body.children), ctx, 'body');
|
||||
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
|
||||
|
||||
const hf = resolver.masterPageHF();
|
||||
if (hf.hasVariants) {
|
||||
warnings.add('Per-page header/footer variants (first/even pages) are not supported — the default one was used');
|
||||
}
|
||||
|
||||
const geometry = resolver.pageGeometry();
|
||||
return {
|
||||
content: { type: 'doc', content: blocks },
|
||||
margins: geometry?.margins ?? null,
|
||||
orientation: geometry?.orientation ?? null,
|
||||
header: hf.header ? convertHfZone(hf.header, ctx) : null,
|
||||
footer: hf.footer ? convertHfZone(hf.footer, ctx) : null,
|
||||
warnings: [...warnings],
|
||||
};
|
||||
}
|
||||
|
||||
// A header/footer zone → one single-paragraph doc (hfExtensions schema). Multiple
|
||||
// paragraphs collapse to hard line breaks; block structures flatten to their text.
|
||||
function convertHfZone(zoneEl: Element, ctx: Ctx): Node | null {
|
||||
const inline: Node[] = [];
|
||||
let textAlign: string | null = null;
|
||||
|
||||
const addPara = (p: Element) => {
|
||||
if (inline.length) inline.push({ type: 'hardBreak' });
|
||||
const styleName = p.getAttributeNS(NS.text, 'style-name');
|
||||
if (textAlign === null) {
|
||||
const ta = ctx.resolver.paraProps(styleName)['fo:text-align'] ?? '';
|
||||
textAlign = ta === 'center' || ta === 'justify' ? ta : ta === 'right' || ta === 'end' ? 'right' : null;
|
||||
if (textAlign === null) textAlign = ''; // only the first paragraph decides
|
||||
}
|
||||
inline.push(...convertInline(p, ctx, ctx.resolver.paraTextProps(styleName), null, true));
|
||||
};
|
||||
|
||||
for (const child of Array.from(zoneEl.children)) {
|
||||
if (child.namespaceURI === NS.text && (child.localName === 'p' || child.localName === 'h')) {
|
||||
addPara(child);
|
||||
} else if (child.namespaceURI === NS.text || child.namespaceURI === NS.table) {
|
||||
// Lists/tables in headers are beyond the one-paragraph model — keep their text.
|
||||
ctx.warnings.add('Lists/tables in headers or footers were flattened to text');
|
||||
for (const p of Array.from(child.getElementsByTagNameNS(NS.text, 'p'))) addPara(p);
|
||||
}
|
||||
}
|
||||
// Trim leading/trailing breaks from empty source paragraphs.
|
||||
while (inline[0]?.type === 'hardBreak') inline.shift();
|
||||
while (inline[inline.length - 1]?.type === 'hardBreak') inline.pop();
|
||||
if (inline.length === 0) return null;
|
||||
|
||||
const para: Node = { type: 'paragraph', content: inline };
|
||||
if (textAlign) para.attrs = { textAlign };
|
||||
return { type: 'doc', content: [para] };
|
||||
}
|
||||
|
||||
function parseXml(xml: string): Document {
|
||||
const doc = new DOMParser().parseFromString(xml, 'application/xml');
|
||||
if (doc.getElementsByTagName('parsererror').length) {
|
||||
@@ -201,12 +247,18 @@ function snapPt(v: number): number {
|
||||
|
||||
// ---- inline conversion --------------------------------------------------------
|
||||
|
||||
function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel: number | null): Node[] {
|
||||
function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel: number | null, hfFields = false): Node[] {
|
||||
const out: Node[] = [];
|
||||
|
||||
const pushText = (text: string, props: PropMap) => {
|
||||
// Strip our export sentinels (SEG/LBR) defensively — never legitimate text.
|
||||
const clean = text.replace(/[]/g, '');
|
||||
let clean = text.replace(/[-]/g, '');
|
||||
if (clean.includes('\n')) {
|
||||
// Newlines in ODF text content are formatting whitespace (real breaks
|
||||
// are text:line-break): drop whitespace-only nodes, collapse the rest.
|
||||
if (!clean.trim()) return;
|
||||
clean = clean.replace(/[ \t]*\n[ \t]*/g, ' ');
|
||||
}
|
||||
if (!clean) return;
|
||||
const marks = marksFor(props, ctx.resolver, headingLevel);
|
||||
const node: Node = { type: 'text', text: clean };
|
||||
@@ -255,8 +307,17 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel
|
||||
case 'change-end':
|
||||
continue;
|
||||
default:
|
||||
// Text fields (date, page number, title, …) store their evaluated
|
||||
// value as element text — keep what the source document showed.
|
||||
// In headers/footers, page fields stay live fields (pageField.ts).
|
||||
if (hfFields && e.localName === 'page-number') {
|
||||
out.push({ type: 'pageNumber' });
|
||||
continue;
|
||||
}
|
||||
if (hfFields && e.localName === 'page-count') {
|
||||
out.push({ type: 'pageCount' });
|
||||
continue;
|
||||
}
|
||||
// Other text fields (date, title, …) store their evaluated value as
|
||||
// element text — keep what the source document showed.
|
||||
if (e.textContent) pushText(e.textContent, props);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -230,31 +230,87 @@ export class StyleResolver {
|
||||
return name ? this.listStyles.get(name) ?? null : null;
|
||||
}
|
||||
|
||||
// Page margins + orientation from the first master page's page layout.
|
||||
// Page *size* is intentionally ignored — the editor is A4-only.
|
||||
pageGeometry(): { margins: PageMargins; orientation: Orientation } | null {
|
||||
// The master page governing the document (prefer "Standard", else the first).
|
||||
private masterPageEl(): Element | null {
|
||||
const doc = this.stylesDoc;
|
||||
if (!doc) return null;
|
||||
const masterPage = doc.getElementsByTagNameNS(NS.style, 'master-page')[0];
|
||||
const layoutName = masterPage?.getAttributeNS(NS.style, 'page-layout-name');
|
||||
if (!layoutName) return null;
|
||||
let props: Element | null = null;
|
||||
const pages = Array.from(doc.getElementsByTagNameNS(NS.style, 'master-page'));
|
||||
return pages.find(p => p.getAttributeNS(NS.style, 'name') === 'Standard') ?? pages[0] ?? null;
|
||||
}
|
||||
|
||||
private pageLayoutEl(): Element | null {
|
||||
const doc = this.stylesDoc;
|
||||
const layoutName = this.masterPageEl()?.getAttributeNS(NS.style, 'page-layout-name');
|
||||
if (!doc || !layoutName) return null;
|
||||
for (const layout of Array.from(doc.getElementsByTagNameNS(NS.style, 'page-layout'))) {
|
||||
if (layout.getAttributeNS(NS.style, 'name') === layoutName) {
|
||||
props = layout.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null;
|
||||
break;
|
||||
}
|
||||
if (layout.getAttributeNS(NS.style, 'name') === layoutName) return layout;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Header/footer of the master page: the content elements, the vertical space the
|
||||
// zone occupies below/above the page margin (height + body-side spacing, for the
|
||||
// Word-style body-margin reconstruction), and whether per-page variants exist.
|
||||
masterPageHF(): {
|
||||
header: Element | null;
|
||||
footer: Element | null;
|
||||
headerExtraCm: number;
|
||||
footerExtraCm: number;
|
||||
hasVariants: boolean;
|
||||
} {
|
||||
const mp = this.masterPageEl();
|
||||
const layout = this.pageLayoutEl();
|
||||
|
||||
const zone = (local: string): Element | null => {
|
||||
if (!mp) return null;
|
||||
for (const child of Array.from(mp.children)) {
|
||||
if (child.namespaceURI === NS.style && child.localName === local) return child;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const extraCm = (local: 'header-style' | 'footer-style', spacingAttr: 'margin-bottom' | 'margin-top'): number => {
|
||||
if (!layout) return 0;
|
||||
let props: Element | null = null;
|
||||
for (const child of Array.from(layout.children)) {
|
||||
if (child.namespaceURI === NS.style && child.localName === local) {
|
||||
props = child.getElementsByTagNameNS(NS.style, 'header-footer-properties')[0] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!props) return 0;
|
||||
const height = lengthToCm(props.getAttributeNS(NS.svg, 'height'))
|
||||
?? lengthToCm(props.getAttributeNS(NS.fo, 'min-height'))
|
||||
?? 0;
|
||||
const spacing = lengthToCm(props.getAttributeNS(NS.fo, spacingAttr)) ?? 0;
|
||||
return height + spacing;
|
||||
};
|
||||
|
||||
return {
|
||||
header: zone('header'),
|
||||
footer: zone('footer'),
|
||||
headerExtraCm: extraCm('header-style', 'margin-bottom'),
|
||||
footerExtraCm: extraCm('footer-style', 'margin-top'),
|
||||
hasVariants: ['header-first', 'footer-first', 'header-left', 'footer-left'].some(l => zone(l) != null),
|
||||
};
|
||||
}
|
||||
|
||||
// Page margins + orientation from the master page's layout. With a header/footer,
|
||||
// ODF's vertical margin ends at the zone — the editor's body margin is margin +
|
||||
// zone height + spacing (inverse of the export's Word-style mapping). Page *size*
|
||||
// is intentionally ignored — the editor is A4-only.
|
||||
pageGeometry(): { margins: PageMargins; orientation: Orientation } | null {
|
||||
const props = this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null;
|
||||
if (!props) return null;
|
||||
|
||||
const cm = (attr: string, fallback: number) => {
|
||||
const hf = this.masterPageHF();
|
||||
const cm = (attr: string, fallback: number, extra = 0) => {
|
||||
const v = lengthToCm(props.getAttributeNS(NS.fo, attr));
|
||||
if (v == null) return fallback;
|
||||
return Math.min(10, Math.max(0, Math.round(v * 100) / 100));
|
||||
return Math.min(10, Math.max(0, Math.round((v + extra) * 100) / 100));
|
||||
};
|
||||
const margins: PageMargins = {
|
||||
top: cm('margin-top', 2.54),
|
||||
bottom: cm('margin-bottom', 2.54),
|
||||
top: cm('margin-top', 2.54, hf.header ? hf.headerExtraCm : 0),
|
||||
bottom: cm('margin-bottom', 2.54, hf.footer ? hf.footerExtraCm : 0),
|
||||
left: cm('margin-left', 2.12),
|
||||
right: cm('margin-right', 2.12),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Header/footer content: one single-paragraph TipTap doc per zone (hfExtensions
|
||||
// schema), identical on every page. null = zone empty → not exported.
|
||||
|
||||
export type HfZone = 'header' | 'footer';
|
||||
export type HfDoc = { type: 'doc'; content?: unknown[] } | null;
|
||||
|
||||
const KEYS: Record<HfZone, string> = {
|
||||
header: 'odf-editor-header',
|
||||
footer: 'odf-editor-footer',
|
||||
};
|
||||
|
||||
// Word's default distance from the page edge to the header/footer text. The body
|
||||
// margin stays the body margin (Word semantics); export/import convert to ODF's
|
||||
// margin-to-header model (see export/odt.ts applyHeaderFooterGeometry).
|
||||
export const HF_DISTANCE_CM = 1.27;
|
||||
|
||||
export function loadHfDoc(zone: HfZone): HfDoc {
|
||||
const raw = localStorage.getItem(KEYS[zone]);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && parsed.type === 'doc' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveHfDoc(zone: HfZone, doc: HfDoc): void {
|
||||
if (hfIsEmpty(doc)) localStorage.removeItem(KEYS[zone]);
|
||||
else localStorage.setItem(KEYS[zone], JSON.stringify(doc));
|
||||
}
|
||||
|
||||
// Empty = null or a single paragraph without inline content. Empty zones render
|
||||
// nothing and are skipped on export.
|
||||
export function hfIsEmpty(doc: HfDoc): boolean {
|
||||
if (!doc?.content?.length) return true;
|
||||
const para = doc.content[0] as { content?: unknown[] } | undefined;
|
||||
return !para?.content?.length;
|
||||
}
|
||||
Reference in new issue
Block a user