feat: page margin setting

This commit is contained in:
Steffen Becker committed 2026-06-03 18:10:45 +02:00
1 parent 7b3c44e888
commit 2088423b59
8 files changed
+420 -41

No files matched your search

+2 -2
View File
@@ -29,9 +29,10 @@
- Zoom feature
- font selection
- font size selection
- line spacing button
- line spacing + paragraph spacing
- text alignment
- show formatting marks
- margins
### Not yet implemented
- ODT import / open existing file
@@ -51,7 +52,6 @@
1 cm on paper ≈ 1 cm on screen. Possible future fix: add a user-side
calibration (DPI value or visual ruler) that scales the `zoom` factor.
Decided 2026-05-26 not worth the effort for now.
- limited font types to make sure it works on all browsers ("web safe fonts")
---
+9 -3
View File
@@ -7,6 +7,7 @@
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';
let editor: Editor | null = $state(null);
let tick: number = $state(0);
@@ -18,11 +19,16 @@
let toolbarExpanded = $state(loadToolbarExpanded());
let showFormattingMarks = $state(loadFormattingMarks());
let zoom = $state(Math.max(20, Math.min(300, parseInt(localStorage.getItem('odf-editor-zoom') ?? '100', 10))));
let pageMargins: PageMargins = $state(loadPageMargins());
$effect(() => {
saveFormattingMarks(showFormattingMarks);
});
$effect(() => {
savePageMargins(pageMargins);
});
function setZoom(value: number) {
zoom = Math.max(20, Math.min(300, value));
localStorage.setItem('odf-editor-zoom', String(zoom));
@@ -49,7 +55,7 @@
}
async function handleExport() {
if (editor) await exportToOdt(editor);
if (editor) await exportToOdt(editor, pageMargins);
}
function handleDebugDump() {
@@ -161,9 +167,9 @@
</div>
</header>
{#if toolbarExpanded}
<ToolbarExpanded {editor} {tick} bind:showFormattingMarks />
<ToolbarExpanded {editor} {tick} bind:showFormattingMarks bind:pageMargins />
{/if}
<EditorComponent bind:editor bind:tick bind:currentPage bind:numPages {zoom} {showFormattingMarks} />
<EditorComponent bind:editor bind:tick bind:currentPage bind:numPages {zoom} {showFormattingMarks} {pageMargins} />
<footer class="statusbar">
<span>Page {currentPage} of {numPages}</span>
<div class="zoom-controls">
+30 -2
View File
@@ -5,14 +5,41 @@
import type { Node as PmNode, MarkType } from 'prosemirror-model';
import { extensions } from './extensions';
import { saveDocument, loadDocument } from '../storage/autosave';
import { applyMarginVars, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import { FORCE_PAGE_RECALC } from './pageBreaks';
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 }: {
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number; showFormattingMarks?: boolean;
let { editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1), zoom = 100, showFormattingMarks = false, pageMargins = DEFAULT_MARGINS }: {
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number; showFormattingMarks?: boolean; pageMargins?: PageMargins;
} = $props();
// Apply the page margins to the :root CSS vars (visual padding + pagination
// both read these). This only touches the DOM, so it's safe inside an effect.
$effect(() => {
applyMarginVars(pageMargins);
});
// Nudge the pageBreaks plugin to recompute with the new content area. The
// dispatch bumps the `tick`/`numPages` bindings (via onTransaction/pm-pagecount),
// so it must run OUTSIDE the Svelte effect flush — otherwise the child↔parent
// binding updates re-enter and trip effect_update_depth_exceeded. requestAnimation-
// Frame defers it past the flush; the empty transaction changes no document content.
let marginRecalcRaf = 0;
$effect(() => {
// track each margin so the effect re-runs on any change
void (pageMargins.top + pageMargins.bottom + pageMargins.left + pageMargins.right);
const ed = editor;
if (!ed) return;
cancelAnimationFrame(marginRecalcRaf);
marginRecalcRaf = requestAnimationFrame(() => {
ed.view.dispatch(
ed.state.tr.setMeta('addToHistory', false).setMeta(FORCE_PAGE_RECALC, true),
);
});
});
let element: HTMLDivElement;
let editorContainer: HTMLDivElement;
@@ -154,6 +181,7 @@
onDestroy(() => {
if (zoomRaf !== null) cancelAnimationFrame(zoomRaf);
cancelAnimationFrame(marginRecalcRaf);
editor?.destroy();
element?.removeEventListener('pm-pagecount', onPageCount);
editorContainer?.removeEventListener('scroll', updateCurrentPage);
+260 -1
View File
@@ -7,8 +7,10 @@
queryLocalFontsIfAllowed,
supportsLocalFontAccess,
} from './fontDetect';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
let { editor, tick, showFormattingMarks = $bindable() }: { editor: Editor | null; tick: number; showFormattingMarks: boolean } = $props();
let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS) }:
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins } = $props();
type AlignValue = 'left' | 'center' | 'right' | 'justify';
@@ -588,6 +590,92 @@
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
// --- Page layout / margins (cm) ---
type MarginAxis = 'top' | 'bottom' | 'left' | 'right';
const MARGIN_FIELDS: { axis: MarginAxis; label: string }[] = [
{ axis: 'top', label: 'Top' },
{ axis: 'bottom', label: 'Bottom' },
{ axis: 'left', label: 'Left' },
{ axis: 'right', label: 'Right' },
];
const MARGIN_STEP = 0.1;
const MARGIN_MIN = 0;
const MARGIN_MAX = 10;
let layoutOpen = $state(false);
let marginInputs = $state<Record<MarginAxis, string>>({ top: '', bottom: '', left: '', right: '' });
function fmtCm(n: number): string {
return String(Math.round(n * 100) / 100);
}
// Refresh the editable display strings from the current margins. Called on open
// and after each change — no $effect, so there's no chance of a reactive loop
// and typing is never clobbered mid-edit.
function syncMarginInputs() {
marginInputs = {
top: fmtCm(pageMargins.top),
bottom: fmtCm(pageMargins.bottom),
left: fmtCm(pageMargins.left),
right: fmtCm(pageMargins.right),
};
}
function setMargin(axis: MarginAxis, value: number) {
const clamped = Math.min(MARGIN_MAX, Math.max(MARGIN_MIN, Math.round(value * 100) / 100));
pageMargins = { ...pageMargins, [axis]: clamped };
marginInputs[axis] = fmtCm(clamped);
}
function stepMargin(axis: MarginAxis, delta: number) {
setMargin(axis, pageMargins[axis] + delta);
}
function commitMarginInput(axis: MarginAxis) {
const n = parseFloat(marginInputs[axis]);
if (Number.isNaN(n)) {
marginInputs[axis] = fmtCm(pageMargins[axis]); // revert invalid input
return;
}
setMargin(axis, n);
}
function onMarginKeydown(axis: MarginAxis, e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
commitMarginInput(axis);
(e.target as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
marginInputs[axis] = fmtCm(pageMargins[axis]);
(e.target as HTMLInputElement).blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
stepMargin(axis, MARGIN_STEP);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
stepMargin(axis, -MARGIN_STEP);
}
}
function openLayout() {
fontOpen = false;
sizeOpen = false;
sizeInputFocused = false;
lineHeightOpen = false;
colorOpen = false;
alignOpen = false;
layoutOpen = !layoutOpen;
if (layoutOpen) syncMarginInputs();
}
function layoutClickOutside(node: HTMLElement) {
function handler(e: MouseEvent) {
if (!node.contains(e.target as Node)) layoutOpen = false;
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
function openColorPicker() {
if (!editor) return;
fontOpen = false;
@@ -1185,6 +1273,56 @@
<div class="toolbar-separator"></div>
<div class="layout-picker" use:layoutClickOutside>
<button class="layout-trigger" onclick={openLayout} title="Page layout &amp; margins" aria-pressed={layoutOpen}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="2.5" y="1.5" width="11" height="13" rx="1" stroke="currentColor" stroke-width="1.3"/>
<rect x="4.5" y="3.5" width="7" height="9" rx="0.5" stroke="currentColor" stroke-width="1" stroke-dasharray="2 1.5"/>
</svg>
<span class="layout-trigger-label">Layout</span>
<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 layoutOpen}
<div class="layout-dropdown">
<div class="lh-section-label">Page margins (cm)</div>
<div class="margin-grid">
{#each MARGIN_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={marginInputs[f.axis]}
onkeydown={(e) => onMarginKeydown(f.axis, e)}
onblur={() => commitMarginInput(f.axis)}
inputmode="decimal"
title="{f.label} margin (cm)"
/>
<div class="margin-steppers">
<button class="margin-step" tabindex="-1" onclick={() => stepMargin(f.axis, MARGIN_STEP)} title="Increase {f.label.toLowerCase()} margin" aria-label="Increase {f.label.toLowerCase()} margin">
<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={() => stepMargin(f.axis, -MARGIN_STEP)} title="Decrease {f.label.toLowerCase()} margin" aria-label="Decrease {f.label.toLowerCase()} margin">
<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>
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button
class:active={showFormattingMarks}
@@ -1529,6 +1667,127 @@
flex-direction: column;
}
.layout-picker {
position: relative;
}
.layout-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
height: 2rem;
padding: 0 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
min-width: unset;
transition: border-color 0.15s;
}
.layout-trigger:hover {
border-color: var(--color-primary);
}
.layout-trigger-label {
font-size: 0.8rem;
font-family: var(--font-sans);
}
.layout-dropdown {
position: absolute;
top: calc(100% + 3px);
left: 0;
width: 232px;
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);
z-index: 200;
padding: 2px 2px 6px;
}
.margin-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 4px 6px 2px;
}
.margin-field {
display: flex;
flex-direction: column;
gap: 3px;
/* Allow the grid track to shrink below the input's intrinsic width so the
fields stay inside the panel instead of overflowing to the right. */
min-width: 0;
}
.margin-label {
font-size: 0.72rem;
color: var(--color-text);
font-family: var(--font-sans);
}
.margin-input-wrap {
display: flex;
align-items: stretch;
height: 1.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
overflow: hidden;
transition: border-color 0.15s;
}
.margin-input-wrap:hover,
.margin-input-wrap:focus-within {
border-color: var(--color-primary);
}
.margin-input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0 0.4rem;
border: none;
background: transparent;
color: var(--color-text);
font-size: 0.8rem;
font-family: var(--font-sans);
outline: none;
}
.margin-steppers {
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-border);
}
.margin-step {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
min-width: unset;
height: 50%;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
color: var(--color-text);
cursor: pointer;
}
.margin-step:first-child {
border-bottom: 1px solid var(--color-border);
}
.margin-step:hover {
background: var(--color-btn-hover);
}
.lh-section-label {
padding: 0.4rem 0.6rem 0.2rem;
font-size: 0.65rem;
+55 -19
View File
@@ -48,27 +48,49 @@ export function getPageBreakDebug(view: EditorView): PageBreakDebugSnapshot | nu
return debugAccessors.get(view)?.() ?? null;
}
// A4 page layout constants (px at 96 dpi)
// A4 page layout constants (px at 96 dpi). The page box (height/gap/cycle) is
// fixed; only the content-area inset (top/bottom margin) is user-adjustable, so
// it's read per layout pass from the live padding instead of being a constant.
const PAGE_HEIGHT = 1123;
const PAGE_GAP = 20;
const PAGE_MARGIN_TOP = 96;
const PAGE_MARGIN_BOTTOM = 96;
const CONTENT_HEIGHT = PAGE_HEIGHT - PAGE_MARGIN_TOP - PAGE_MARGIN_BOTTOM; // 931
const DEFAULT_MARGIN_TOP = 96;
const DEFAULT_MARGIN_BOTTOM = 96;
const CYCLE = PAGE_HEIGHT + PAGE_GAP; // 1143
function pageContentStart(page: number): number {
return (page - 1) * CYCLE + PAGE_MARGIN_TOP;
type VMargins = { top: number; bottom: number; contentHeight: number };
// Reads the vertical page margins (px) from the --user-margin-* custom props the
// Layout panel writes via applyMarginVars (pageMargins.ts). These are plain px
// strings in document coordinates — unaffected by the ancestor CSS `zoom`, unlike
// getComputedStyle padding — so they pair directly with the unscaled offsetTop
// measurements below. Side margins don't affect pagination (they change line
// wrapping, which we already measure from the live DOM), so we ignore them.
function readVerticalMargins(dom: HTMLElement): VMargins {
const cs = getComputedStyle(dom);
const top = parseFloat(cs.getPropertyValue('--user-margin-top'));
const bottom = parseFloat(cs.getPropertyValue('--user-margin-bottom'));
const mt = Number.isFinite(top) ? top : DEFAULT_MARGIN_TOP;
const mb = Number.isFinite(bottom) ? bottom : DEFAULT_MARGIN_BOTTOM;
return { top: mt, bottom: mb, contentHeight: PAGE_HEIGHT - mt - mb };
}
function pageContentEnd(page: number): number {
return (page - 1) * CYCLE + PAGE_MARGIN_TOP + CONTENT_HEIGHT;
function pageContentStart(page: number, marginTop: number): number {
return (page - 1) * CYCLE + marginTop;
}
function pageContentEnd(page: number, marginTop: number, contentHeight: number): number {
return (page - 1) * CYCLE + marginTop + contentHeight;
}
function getPageForY(y: number): number {
return Math.floor(y / CYCLE) + 1;
}
const pageBreakKey = new PluginKey('pageBreaks');
const pageBreakKey = new PluginKey<number>('pageBreaks');
// Transaction meta flag: set it (e.g. when page margins change) to force a
// pagination recompute even though the document content is unchanged.
export const FORCE_PAGE_RECALC = 'forcePageBreakRecalc';
type Leaf = {
el: HTMLElement;
@@ -90,8 +112,17 @@ export const PageBreaks = Extension.create({
let rafId: number | null = null;
let lastPlacementsKey = '';
const plugin = new Plugin({
const plugin = new Plugin<number>({
key: pageBreakKey,
// A counter bumped whenever a FORCE_PAGE_RECALC transaction arrives. The
// plugin's own decoration dispatches don't carry that meta, so this never
// self-triggers. `update` reschedules a layout pass when the counter moves.
state: {
init: () => 0,
apply(tr, value) {
return tr.getMeta(FORCE_PAGE_RECALC) ? value + 1 : value;
},
},
props: {
decorations() {
return decorations;
@@ -376,6 +407,9 @@ export const PageBreaks = Extension.create({
const dom = editorView.dom;
void dom.offsetHeight; // force reflow
const vm = readVerticalMargins(dom);
const CONTENT_HEIGHT = vm.contentHeight;
const zoom = getZoomFactor();
const leaves = collectLeaves(zoom);
@@ -389,8 +423,8 @@ export const PageBreaks = Extension.create({
const effectiveTop = leaf.naturalTop + cumulativeShift;
const effectiveBottom = effectiveTop + leaf.naturalHeight;
const page = getPageForY(effectiveTop);
const contentStart = pageContentStart(page);
const contentEnd = pageContentEnd(page);
const contentStart = pageContentStart(page, vm.top);
const contentEnd = pageContentEnd(page, vm.top, CONTENT_HEIGHT);
let spacerHeight = 0;
let spacerDocPos: number | null = null;
@@ -401,13 +435,13 @@ export const PageBreaks = Extension.create({
spacerDocPos = preLeafDocPos(leaf.el);
reason = 'pre-leaf-push-to-content-start';
} else if (effectiveTop >= contentEnd) {
spacerHeight = pageContentStart(page + 1) - effectiveTop;
spacerHeight = pageContentStart(page + 1, vm.top) - effectiveTop;
spacerDocPos = preLeafDocPos(leaf.el);
reason = 'leaf-jump-to-next-page';
} else if (effectiveBottom > contentEnd) {
if (leaf.kind === 'atomic') {
if (leaf.naturalHeight <= CONTENT_HEIGHT) {
spacerHeight = pageContentStart(page + 1) - effectiveTop;
spacerHeight = pageContentStart(page + 1, vm.top) - effectiveTop;
spacerDocPos = preLeafDocPos(leaf.el);
reason = 'atomic-push-to-next-page';
} else {
@@ -417,14 +451,14 @@ export const PageBreaks = Extension.create({
const split = findLineSplit(leaf.el, contentEnd - effectiveTop, zoom);
if (split === null) {
if (leaf.naturalHeight <= CONTENT_HEIGHT) {
spacerHeight = pageContentStart(page + 1) - effectiveTop;
spacerHeight = pageContentStart(page + 1, vm.top) - effectiveTop;
spacerDocPos = preLeafDocPos(leaf.el);
reason = 'split-fallback-push-whole-leaf';
} else {
reason = 'splittable-too-tall-no-push';
}
} else {
spacerHeight = pageContentStart(page + 1) - (effectiveTop + split.naturalLineTop);
spacerHeight = pageContentStart(page + 1, vm.top) - (effectiveTop + split.naturalLineTop);
spacerDocPos = split.docPos;
reason = 'line-split';
}
@@ -509,8 +543,8 @@ export const PageBreaks = Extension.create({
layout: {
PAGE_HEIGHT,
PAGE_GAP,
PAGE_MARGIN_TOP,
PAGE_MARGIN_BOTTOM,
PAGE_MARGIN_TOP: vm.top,
PAGE_MARGIN_BOTTOM: vm.bottom,
CONTENT_HEIGHT,
CYCLE,
},
@@ -533,7 +567,9 @@ export const PageBreaks = Extension.create({
return {
update(_view, prevState) {
if (!isUpdating && prevState.doc !== editorView.state.doc) {
const forced =
pageBreakKey.getState(prevState) !== pageBreakKey.getState(editorView.state);
if (!isUpdating && (prevState.doc !== editorView.state.doc || forced)) {
schedule();
}
},
+8 -11
View File
@@ -1,6 +1,7 @@
import type { Editor } from '@tiptap/core';
import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, type ParagraphBuilder } from 'odf-kit';
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
type AlignValue = 'left' | 'center' | 'right' | 'justify';
@@ -20,12 +21,6 @@ const ODFKIT_DEFAULT_FONT = 'Liberation Serif';
const EXPORT_FONT = 'Times New Roman';
const DEFAULT_LINE_HEIGHT = 1; // must match line-height multiplier default in ToolbarExpanded.svelte
// Editor page geometry (see editor.css / pageBreaks.ts): 96px top/bottom and
// 80px side padding on a 794px (≈A4) page. Match them so the exported text
// column width — and therefore line wrapping and page flow — is identical.
const PAGE_MARGIN_V = '2.54cm'; // 96px @96dpi
const PAGE_MARGIN_H = '2.12cm'; // 80px @96dpi
// Heading sizes/margins shown in the editor (editor.css). odf-kit's built-in
// Heading_20_N styles use larger sizes (28/24/20pt), so we rewrite them on
// export. Margins are the editor's em-based values resolved against each
@@ -288,15 +283,17 @@ function applyRuns(p: ParagraphBuilder, content: TiptapNode[] = []) {
}
}
export async function exportToOdt(editor: Editor): Promise<void> {
export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT_MARGINS): Promise<void> {
const raw = editor.getJSON() as TiptapNode;
const json = injectCustomTypes(raw);
const odt = await tiptapToOdt(json, {
marginTop: PAGE_MARGIN_V,
marginBottom: PAGE_MARGIN_V,
marginLeft: PAGE_MARGIN_H,
marginRight: PAGE_MARGIN_H,
// 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`,
marginLeft: `${margins.left}cm`,
marginRight: `${margins.right}cm`,
unknownNodeHandler(node: TiptapNode, doc: OdtDocument) {
const opts: {
lineHeight?: number | string;
+49
View File
@@ -0,0 +1,49 @@
export type PageMargins = { top: number; bottom: number; left: number; right: number }; // cm
const KEY = 'odf-editor-page-margins';
// Match the editor's historical hardcoded layout: 2.54cm (96px) top/bottom,
// 2.12cm (≈80px) left/right. Keeps existing documents rendering/exporting unchanged.
export const DEFAULT_MARGINS: PageMargins = { top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 };
export const PX_PER_CM = 96 / 2.54; // 37.795 — A4 @96dpi
export const cmToPx = (cm: number) => cm * PX_PER_CM;
const MIN_CM = 0;
const MAX_CM = 10;
function clampCm(n: number): number {
if (!Number.isFinite(n)) return 0;
return Math.min(MAX_CM, Math.max(MIN_CM, n));
}
export function loadPageMargins(): PageMargins {
const raw = localStorage.getItem(KEY);
if (!raw) return { ...DEFAULT_MARGINS };
try {
const parsed = JSON.parse(raw);
return {
top: typeof parsed.top === 'number' ? clampCm(parsed.top) : DEFAULT_MARGINS.top,
bottom: typeof parsed.bottom === 'number' ? clampCm(parsed.bottom) : DEFAULT_MARGINS.bottom,
left: typeof parsed.left === 'number' ? clampCm(parsed.left) : DEFAULT_MARGINS.left,
right: typeof parsed.right === 'number' ? clampCm(parsed.right) : DEFAULT_MARGINS.right,
};
} catch {
return { ...DEFAULT_MARGINS };
}
}
export function savePageMargins(m: PageMargins): void {
localStorage.setItem(KEY, JSON.stringify(m));
}
// Sets --user-margin-{top,bottom,left,right} (in px) on the document root, where
// they inherit down to .tiptap (see editor.css). Drives both the visual padding
// and the pagination math in pageBreaks.ts.
export function applyMarginVars(m: PageMargins): void {
const root = document.documentElement.style;
root.setProperty('--user-margin-top', `${cmToPx(m.top)}px`);
root.setProperty('--user-margin-bottom', `${cmToPx(m.bottom)}px`);
root.setProperty('--user-margin-left', `${cmToPx(m.left)}px`);
root.setProperty('--user-margin-right', `${cmToPx(m.right)}px`);
}
+7 -3
View File
@@ -13,14 +13,18 @@
.paper .tiptap {
--page-height: 1123px;
--page-gap: 20px;
--page-margin-top: 96px;
--page-margin-bottom: 96px;
/* Driven by --user-margin-* set on :root from the Layout panel (pageMargins.ts).
Fallbacks preserve the original hardcoded layout when unset. */
--page-margin-top: var(--user-margin-top, 96px);
--page-margin-bottom: var(--user-margin-bottom, 96px);
--page-margin-left: var(--user-margin-left, 80px);
--page-margin-right: var(--user-margin-right, 80px);
--page-cycle: calc(var(--page-height) + var(--page-gap));
position: relative;
width: 100%;
min-height: var(--page-height);
padding: var(--page-margin-top) 80px var(--page-margin-bottom);
padding: var(--page-margin-top) var(--page-margin-right) var(--page-margin-bottom) var(--page-margin-left);
outline: none;
font-family: var(--font-serif);
/* 12pt matches odf-kit's Standard style and the toolbar's declared default. */