diff --git a/src/lib/editor/Editor.svelte b/src/lib/editor/Editor.svelte
index 794c4ce..1dfcab3 100644
--- a/src/lib/editor/Editor.svelte
+++ b/src/lib/editor/Editor.svelte
@@ -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);
diff --git a/src/lib/editor/ToolbarExpanded.svelte b/src/lib/editor/ToolbarExpanded.svelte
index e1c980a..e834b8b 100644
--- a/src/lib/editor/ToolbarExpanded.svelte
+++ b/src/lib/editor/ToolbarExpanded.svelte
@@ -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
>({ 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 @@
+
+
+ {#if layoutOpen}
+
+
Page margins (cm)
+
+ {#each MARGIN_FIELDS as f}
+
+ {/each}
+
+
+ {/if}
+
+
+
+