feat: header/footer position
This commit is contained in:
1 parent
ee217610ba
commit
7b67969774
8 files changed
+197
-26
No files matched your search
+16
-2
@@ -10,7 +10,7 @@
|
||||
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';
|
||||
import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter';
|
||||
|
||||
let editor: Editor | null = $state(null);
|
||||
let tick: number = $state(0);
|
||||
@@ -21,6 +21,7 @@
|
||||
// top toolbars target hfEditor instead of the body editor (activeEditor below).
|
||||
let headerDoc: HfDoc = $state(loadHfDoc('header'));
|
||||
let footerDoc: HfDoc = $state(loadHfDoc('footer'));
|
||||
let hfDistances: HfDistances = $state(loadHfDistances());
|
||||
let hfEditor: Editor | null = $state(null);
|
||||
let hfActive: HfZone | null = $state(null);
|
||||
let hfTick: number = $state(0);
|
||||
@@ -56,6 +57,10 @@
|
||||
saveHfDoc('footer', footerDoc);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
saveHfDistances(hfDistances);
|
||||
});
|
||||
|
||||
function setZoom(value: number) {
|
||||
zoom = Math.max(20, Math.min(300, value));
|
||||
localStorage.setItem('odf-editor-zoom', String(zoom));
|
||||
@@ -83,7 +88,10 @@
|
||||
|
||||
async function handleExport() {
|
||||
if (editor) {
|
||||
await exportToOdt(editor, pageMargins, pageOrientation, { header: headerDoc, footer: footerDoc, pageCount: numPages });
|
||||
await exportToOdt(editor, pageMargins, pageOrientation, {
|
||||
header: headerDoc, footer: footerDoc, pageCount: numPages,
|
||||
headerDistanceCm: hfDistances.header, footerDistanceCm: hfDistances.footer,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +120,10 @@
|
||||
hfActive = null;
|
||||
headerDoc = result.header;
|
||||
footerDoc = result.footer;
|
||||
hfDistances = {
|
||||
header: result.headerDistanceCm ?? DEFAULT_HF_DISTANCES.header,
|
||||
footer: result.footerDistanceCm ?? DEFAULT_HF_DISTANCES.footer,
|
||||
};
|
||||
|
||||
if (result.warnings.length) {
|
||||
console.warn('[import] Unsupported content in opened file:', result.warnings);
|
||||
@@ -248,6 +260,7 @@
|
||||
bind:showFormattingMarks
|
||||
bind:pageMargins
|
||||
bind:pageOrientation
|
||||
bind:hfDistances
|
||||
hfActive={hfActive}
|
||||
onEditZone={(zone) => (hfActive = zone)}
|
||||
/>
|
||||
@@ -262,6 +275,7 @@
|
||||
bind:hfEditor
|
||||
bind:hfActive
|
||||
bind:hfTick
|
||||
{hfDistances}
|
||||
{zoom}
|
||||
{showFormattingMarks}
|
||||
{pageMargins}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
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 { DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter';
|
||||
import { FORCE_PAGE_RECALC, type TableBreakBand } from './pageBreaks';
|
||||
import { recordTransaction, resetHistoryLog } from './historyLog.svelte';
|
||||
import '../../styles/editor.css';
|
||||
@@ -19,12 +19,12 @@
|
||||
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),
|
||||
headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES,
|
||||
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;
|
||||
headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances;
|
||||
hfEditor?: Editor | null; hfActive?: HfZone | null; hfTick?: number;
|
||||
} = $props();
|
||||
|
||||
@@ -420,6 +420,7 @@
|
||||
{currentPage}
|
||||
{pageMargins}
|
||||
{orientation}
|
||||
{hfDistances}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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 { hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter';
|
||||
import { cmToPx, type PageMargins } from '../storage/pageMargins';
|
||||
import { PAGE_W_PORTRAIT, PAGE_H_PORTRAIT, type Orientation } from '../storage/pageOrientation';
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
currentPage,
|
||||
pageMargins,
|
||||
orientation,
|
||||
hfDistances = DEFAULT_HF_DISTANCES,
|
||||
hfEditor = $bindable(),
|
||||
hfActive = $bindable(),
|
||||
hfTick = $bindable(),
|
||||
@@ -22,13 +23,13 @@
|
||||
currentPage: number;
|
||||
pageMargins: PageMargins;
|
||||
orientation: Orientation;
|
||||
hfDistances?: HfDistances;
|
||||
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();
|
||||
|
||||
@@ -42,6 +43,10 @@
|
||||
let mLeft = $derived(cmToPx(pageMargins.left));
|
||||
let mRight = $derived(cmToPx(pageMargins.right));
|
||||
let contentWidth = $derived(Math.max(0, pageWidthPx - mLeft - mRight));
|
||||
// Edge→zone distance in px, clamped below the body margin so the zone fits within
|
||||
// the margin band (matches the export's Math.min(dist, margin) clamp).
|
||||
let headerDistPx = $derived(Math.min(cmToPx(hfDistances.header), mTop));
|
||||
let footerDistPx = $derived(Math.min(cmToPx(hfDistances.footer), mBottom));
|
||||
|
||||
let pages = $derived(Array.from({ length: Math.max(1, numPages) }, (_, i) => i + 1));
|
||||
|
||||
@@ -49,11 +54,11 @@
|
||||
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 + headerDistPx;
|
||||
return { top, left, width, height: Math.max(0, mTop - headerDistPx) };
|
||||
}
|
||||
const top = (page - 1) * cycle + pageHeightPx - mBottom;
|
||||
return { top, left, width, height: Math.max(0, mBottom - HF_DISTANCE_PX) };
|
||||
return { top, left, width, height: Math.max(0, mBottom - footerDistPx) };
|
||||
}
|
||||
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;`;
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
} from './fontDetect';
|
||||
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { DEFAULT_HF_DISTANCES, clampHfDistance, type HfDistances } from '../storage/headerFooter';
|
||||
|
||||
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();
|
||||
let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), hfActive = null, onEditZone }:
|
||||
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; hfDistances?: HfDistances; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void } = $props();
|
||||
|
||||
type AlignValue = 'left' | 'center' | 'right' | 'justify';
|
||||
|
||||
@@ -606,6 +607,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Header/footer edge distances (cm) ---
|
||||
type HfAxis = 'header' | 'footer';
|
||||
const HF_DIST_FIELDS: { axis: HfAxis; label: string }[] = [
|
||||
{ axis: 'header', label: 'Header from top' },
|
||||
{ axis: 'footer', label: 'Footer from bottom' },
|
||||
];
|
||||
let hfDistInputs = $state<Record<HfAxis, string>>({ header: '', footer: '' });
|
||||
|
||||
function syncHfDistInputs() {
|
||||
hfDistInputs = { header: fmtCm(hfDistances.header), footer: fmtCm(hfDistances.footer) };
|
||||
}
|
||||
|
||||
function setHfDist(axis: HfAxis, value: number) {
|
||||
const clamped = clampHfDistance(value);
|
||||
hfDistances = { ...hfDistances, [axis]: clamped };
|
||||
hfDistInputs[axis] = fmtCm(clamped);
|
||||
}
|
||||
|
||||
function stepHfDist(axis: HfAxis, delta: number) {
|
||||
setHfDist(axis, hfDistances[axis] + delta);
|
||||
}
|
||||
|
||||
function commitHfDistInput(axis: HfAxis) {
|
||||
const n = parseFloat(hfDistInputs[axis]);
|
||||
if (Number.isNaN(n)) {
|
||||
hfDistInputs[axis] = fmtCm(hfDistances[axis]); // revert invalid input
|
||||
return;
|
||||
}
|
||||
setHfDist(axis, n);
|
||||
}
|
||||
|
||||
function onHfDistKeydown(axis: HfAxis, e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitHfDistInput(axis);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
hfDistInputs[axis] = fmtCm(hfDistances[axis]);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
stepHfDist(axis, MARGIN_STEP);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
stepHfDist(axis, -MARGIN_STEP);
|
||||
}
|
||||
}
|
||||
|
||||
function openLayout() {
|
||||
fontOpen = false;
|
||||
sizeOpen = false;
|
||||
@@ -615,7 +664,10 @@
|
||||
highlightColorOpen = false;
|
||||
alignOpen = false;
|
||||
layoutOpen = !layoutOpen;
|
||||
if (layoutOpen) syncMarginInputs();
|
||||
if (layoutOpen) {
|
||||
syncMarginInputs();
|
||||
syncHfDistInputs();
|
||||
}
|
||||
}
|
||||
|
||||
function layoutClickOutside(node: HTMLElement) {
|
||||
@@ -1072,6 +1124,38 @@
|
||||
onclick={() => onEditZone?.('footer')}
|
||||
>Edit footer</button>
|
||||
</div>
|
||||
|
||||
<div class="lh-section-label">Position (cm)</div>
|
||||
<div class="margin-grid">
|
||||
{#each HF_DIST_FIELDS as f}
|
||||
<div class="margin-field">
|
||||
<span class="margin-label">{f.label}</span>
|
||||
<div class="margin-input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
class="margin-input"
|
||||
bind:value={hfDistInputs[f.axis]}
|
||||
onkeydown={(e) => onHfDistKeydown(f.axis, e)}
|
||||
onblur={() => commitHfDistInput(f.axis)}
|
||||
inputmode="decimal"
|
||||
title="{f.label} (cm)"
|
||||
/>
|
||||
<div class="margin-steppers">
|
||||
<button class="margin-step" tabindex="-1" onclick={() => stepHfDist(f.axis, MARGIN_STEP)} title="Increase" aria-label="Increase {f.label.toLowerCase()}">
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||
<path d="M1 5.5l3-3 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="margin-step" tabindex="-1" onclick={() => stepHfDist(f.axis, -MARGIN_STEP)} title="Decrease" aria-label="Decrease {f.label.toLowerCase()}">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1570,6 +1654,9 @@
|
||||
.margin-input-wrap {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
/* Pin the input to the bottom of its grid cell so a wrapped two-line label
|
||||
(e.g. "Footer from bottom") doesn't push its box below the sibling's. */
|
||||
margin-top: auto;
|
||||
height: 1.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
|
||||
+24
-11
@@ -872,15 +872,26 @@ 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 };
|
||||
// Header/footer input for the export: one single-paragraph doc per zone (or null),
|
||||
// the page count snapshot for the stored <text:page-count> value, and the page-edge
|
||||
// distances (cm; default HF_DISTANCE_CM) for header (from top) / footer (from bottom).
|
||||
export type HfExport = {
|
||||
header: HfDoc;
|
||||
footer: HfDoc;
|
||||
pageCount: number;
|
||||
headerDistanceCm?: number;
|
||||
footerDistanceCm?: 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', hf?: HfExport): Promise<Uint8Array> {
|
||||
const raw = replaceHardBreaks(docJson);
|
||||
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
|
||||
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
|
||||
// Distance from the page edge to the header (top) / footer (bottom). Becomes the
|
||||
// ODF page margin; clamped below the body margin so the body still starts at it.
|
||||
const headerDist = Math.min(hf?.headerDistanceCm ?? HF_DISTANCE_CM, margins.top);
|
||||
const footerDist = Math.min(hf?.footerDistanceCm ?? HF_DISTANCE_CM, margins.bottom);
|
||||
let json = injectCustomTypes(raw);
|
||||
if (headerPara || footerPara) {
|
||||
json = { ...json, content: [{ type: CUST_HF }, ...(json.content ?? [])] };
|
||||
@@ -904,8 +915,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
// 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`,
|
||||
marginTop: `${headerPara ? headerDist : margins.top}cm`,
|
||||
marginBottom: `${footerPara ? footerDist : margins.bottom}cm`,
|
||||
marginLeft: `${margins.left}cm`,
|
||||
marginRight: `${margins.right}cm`,
|
||||
unknownNodeHandler(node: TiptapNode, doc: OdtDocument) {
|
||||
@@ -978,7 +989,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
const cleaned = collapseRunWhitespace(styledRows);
|
||||
const withBreaks = applyLineBreaks(cleaned);
|
||||
const withStyles = rewriteStylesXml(withBreaks);
|
||||
return applyHfPostProcess(withStyles, margins, headerPara, footerPara);
|
||||
return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist);
|
||||
}
|
||||
|
||||
function hfAlign(para: TiptapNode): AlignValue | null {
|
||||
@@ -991,7 +1002,7 @@ function hfAlign(para: TiptapNode): AlignValue | null {
|
||||
// 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 {
|
||||
function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPara: TiptapNode | null, footerPara: TiptapNode | null, headerDist: number, footerDist: number): Uint8Array {
|
||||
if (!headerPara && !footerPara) return odtBytes;
|
||||
|
||||
const files = unzipSync(odtBytes);
|
||||
@@ -1006,8 +1017,10 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa
|
||||
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));
|
||||
const zone = (kind: 'header' | 'footer', para: TiptapNode, bodyMarginCm: number, distCm: number) => {
|
||||
// min-height fills the gap between the edge→zone distance and the body margin,
|
||||
// so the body still starts at bodyMarginCm (page margin was set to distCm).
|
||||
const minH = round3(Math.max(0.2, bodyMarginCm - distCm));
|
||||
// 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(
|
||||
@@ -1023,8 +1036,8 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa
|
||||
);
|
||||
}
|
||||
};
|
||||
if (headerPara) zone('header', headerPara, margins.top);
|
||||
if (footerPara) zone('footer', footerPara, margins.bottom);
|
||||
if (headerPara) zone('header', headerPara, margins.top, headerDist);
|
||||
if (footerPara) zone('footer', footerPara, margins.bottom, footerDist);
|
||||
|
||||
files['styles.xml'] = strToU8(styles);
|
||||
return rezipOdt(files);
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface OdtImportResult {
|
||||
// Single-paragraph docs in the hfExtensions schema; null = no zone.
|
||||
header: Node | null;
|
||||
footer: Node | null;
|
||||
// Edge→zone distance (cm): header from top, footer from bottom. null = no zone.
|
||||
headerDistanceCm: number | null;
|
||||
footerDistanceCm: number | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@@ -87,12 +90,15 @@ export function importOdt(bytes: Uint8Array): OdtImportResult {
|
||||
}
|
||||
|
||||
const geometry = resolver.pageGeometry();
|
||||
const edge = resolver.edgeDistancesCm();
|
||||
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,
|
||||
headerDistanceCm: hf.header ? edge?.top ?? null : null,
|
||||
footerDistanceCm: hf.footer ? edge?.bottom ?? null : null,
|
||||
warnings: [...warnings],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -319,4 +319,16 @@ export class StyleResolver {
|
||||
const orientation: Orientation = w != null && h != null && w > h ? 'landscape' : 'portrait';
|
||||
return { margins, orientation };
|
||||
}
|
||||
|
||||
// Raw page margins (cm) = ODF's edge→zone distance, i.e. the header distance from
|
||||
// the top and footer distance from the bottom when a header/footer is present.
|
||||
edgeDistancesCm(): { top: number; bottom: number } | null {
|
||||
const props = this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null;
|
||||
if (!props) return null;
|
||||
const cm = (attr: string) => {
|
||||
const v = lengthToCm(props.getAttributeNS(NS.fo, attr));
|
||||
return v == null ? null : Math.min(10, Math.max(0, Math.round(v * 100) / 100));
|
||||
};
|
||||
return { top: cm('margin-top') ?? 1.25, bottom: cm('margin-bottom') ?? 1.25 };
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,41 @@ const KEYS: Record<HfZone, string> = {
|
||||
|
||||
// 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;
|
||||
// margin-to-header model (see export/odt.ts applyHfPostProcess).
|
||||
export const HF_DISTANCE_CM = 1.25;
|
||||
|
||||
// Per-zone distance from the page edge to the header (from top) / footer (from
|
||||
// bottom), in cm — user-configurable in the Layout panel.
|
||||
export type HfDistances = { header: number; footer: number };
|
||||
|
||||
export const DEFAULT_HF_DISTANCES: HfDistances = { header: HF_DISTANCE_CM, footer: HF_DISTANCE_CM };
|
||||
|
||||
const DIST_KEY = 'odf-editor-hf-distances';
|
||||
const DIST_MIN = 0;
|
||||
const DIST_MAX = 10;
|
||||
|
||||
export function clampHfDistance(n: number): number {
|
||||
if (!Number.isFinite(n)) return HF_DISTANCE_CM;
|
||||
return Math.min(DIST_MAX, Math.max(DIST_MIN, Math.round(n * 100) / 100));
|
||||
}
|
||||
|
||||
export function loadHfDistances(): HfDistances {
|
||||
const raw = localStorage.getItem(DIST_KEY);
|
||||
if (!raw) return { ...DEFAULT_HF_DISTANCES };
|
||||
try {
|
||||
const p = JSON.parse(raw);
|
||||
return {
|
||||
header: typeof p.header === 'number' ? clampHfDistance(p.header) : HF_DISTANCE_CM,
|
||||
footer: typeof p.footer === 'number' ? clampHfDistance(p.footer) : HF_DISTANCE_CM,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_HF_DISTANCES };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveHfDistances(d: HfDistances): void {
|
||||
localStorage.setItem(DIST_KEY, JSON.stringify(d));
|
||||
}
|
||||
|
||||
export function loadHfDoc(zone: HfZone): HfDoc {
|
||||
const raw = localStorage.getItem(KEYS[zone]);
|
||||
|
||||
Reference in new issue
Block a user