feat: page orientation

This commit is contained in:
Steffen Becker committed 2026-06-04 14:05:14 +02:00
1 parent 2088423b59
commit 61400d1eed
8 files changed
+176 -46

No files matched your search

+1
View File
@@ -33,6 +33,7 @@
- text alignment
- show formatting marks
- margins
- page orientation (odt-kit bug still open => export not working)
### Not yet implemented
- ODT import / open existing file
+9 -3
View File
@@ -8,6 +8,7 @@
import { getColorDebug } from './lib/editor/colorDebug';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, type ThemeMode } from './lib/storage/theme';
import { loadPageMargins, savePageMargins, type PageMargins } from './lib/storage/pageMargins';
import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation';
let editor: Editor | null = $state(null);
let tick: number = $state(0);
@@ -20,6 +21,7 @@
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());
let pageOrientation: Orientation = $state(loadOrientation());
$effect(() => {
saveFormattingMarks(showFormattingMarks);
@@ -29,6 +31,10 @@
savePageMargins(pageMargins);
});
$effect(() => {
saveOrientation(pageOrientation);
});
function setZoom(value: number) {
zoom = Math.max(20, Math.min(300, value));
localStorage.setItem('odf-editor-zoom', String(zoom));
@@ -55,7 +61,7 @@
}
async function handleExport() {
if (editor) await exportToOdt(editor, pageMargins);
if (editor) await exportToOdt(editor, pageMargins, pageOrientation);
}
function handleDebugDump() {
@@ -167,9 +173,9 @@
</div>
</header>
{#if toolbarExpanded}
<ToolbarExpanded {editor} {tick} bind:showFormattingMarks bind:pageMargins />
<ToolbarExpanded {editor} {tick} bind:showFormattingMarks bind:pageMargins bind:pageOrientation />
{/if}
<EditorComponent bind:editor bind:tick bind:currentPage bind:numPages {zoom} {showFormattingMarks} {pageMargins} />
<EditorComponent bind:editor bind:tick bind:currentPage bind:numPages {zoom} {showFormattingMarks} {pageMargins} orientation={pageOrientation} />
<footer class="statusbar">
<span>Page {currentPage} of {numPages}</span>
<div class="zoom-controls">
+22 -10
View File
@@ -6,20 +6,24 @@
import { extensions } from './extensions';
import { saveDocument, loadDocument } from '../storage/autosave';
import { applyMarginVars, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import { applyOrientationVars, type Orientation } from '../storage/pageOrientation';
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, pageMargins = DEFAULT_MARGINS }: {
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number; showFormattingMarks?: boolean; pageMargins?: PageMargins;
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;
} = $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.
// Apply the page margins + orientation to the :root CSS vars (visual padding,
// page dimensions, and pagination all read these). DOM-only, safe in effects.
$effect(() => {
applyMarginVars(pageMargins);
});
$effect(() => {
applyOrientationVars(orientation);
});
// Nudge the pageBreaks plugin to recompute with the new content area. The
// dispatch bumps the `tick`/`numPages` bindings (via onTransaction/pm-pagecount),
@@ -28,8 +32,9 @@
// 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
// track each margin + orientation so the effect re-runs on any change
void (pageMargins.top + pageMargins.bottom + pageMargins.left + pageMargins.right);
void orientation;
const ed = editor;
if (!ed) return;
cancelAnimationFrame(marginRecalcRaf);
@@ -43,7 +48,13 @@
let element: HTMLDivElement;
let editorContainer: HTMLDivElement;
const CYCLE = 1143; // PAGE_HEIGHT + PAGE_GAP, must match pageBreaks.ts
// Page cycle (page height + 20px gap) in document px. Orientation-dependent, so
// read live from --user-page-height (set by applyOrientationVars). Must match
// pageBreaks.ts. Fallback = A4 portrait (1123 + 20).
function getCycle(): number {
const ph = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--user-page-height'));
return (Number.isFinite(ph) ? ph : 1123) + 20;
}
// Throttle the value actually written to the DOM to one update per animation frame,
// so rapid slider events don't trigger 50+ layout/paint cycles per second.
@@ -93,8 +104,9 @@
const editorRect = editorContainer.getBoundingClientRect();
const tiptapRect = tiptap.getBoundingClientRect();
// getBoundingClientRect and coordsAtPos return zoomed viewport pixels;
// divide by zoom factor to convert to document coordinates before comparing with CYCLE.
// divide by zoom factor to convert to document coordinates before comparing with the cycle.
const zoomFactor = appliedZoom / 100;
const cycle = getCycle();
if (editor) {
try {
@@ -102,7 +114,7 @@
const cursorMidY = (coords.top + coords.bottom) / 2;
if (cursorMidY >= editorRect.top && cursorMidY <= editorRect.bottom) {
const cursorInDoc = (cursorMidY - tiptapRect.top) / zoomFactor;
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInDoc) / CYCLE) + 1));
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInDoc) / cycle) + 1));
return;
}
} catch {
@@ -111,7 +123,7 @@
}
const visibleTopInDoc = (editorRect.top - tiptapRect.top) / zoomFactor;
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, visibleTopInDoc) / CYCLE) + 1));
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, visibleTopInDoc) / cycle) + 1));
}
function onPageCount(e: Event) {
@@ -167,7 +179,7 @@
try {
const coords = e.view.coordsAtPos(e.state.selection.head);
const cursorInDoc = ((coords.top + coords.bottom) / 2 - tiptap.getBoundingClientRect().top) / (appliedZoom / 100);
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInDoc) / CYCLE) + 1));
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInDoc) / getCycle()) + 1));
} catch { /* ignore */ }
},
onUpdate: ({ editor: e }) => {
+63 -2
View File
@@ -8,9 +8,10 @@
supportsLocalFontAccess,
} from './fontDetect';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS) }:
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins } = $props();
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();
type AlignValue = 'left' | 'center' | 'right' | 'justify';
@@ -1286,6 +1287,33 @@
</button>
{#if layoutOpen}
<div class="layout-dropdown">
<div class="lh-section-label">Orientation</div>
<div class="orientation-row">
<button
class="orientation-btn"
class:active={pageOrientation === 'portrait'}
aria-pressed={pageOrientation === 'portrait'}
onclick={() => (pageOrientation = 'portrait')}
title="Portrait"
>
<svg width="14" height="16" viewBox="0 0 14 16" fill="none" aria-hidden="true">
<rect x="2.5" y="1.5" width="9" height="13" rx="1" stroke="currentColor" stroke-width="1.3"/>
</svg>
<span>Portrait</span>
</button>
<button
class="orientation-btn"
class:active={pageOrientation === 'landscape'}
aria-pressed={pageOrientation === 'landscape'}
onclick={() => (pageOrientation = 'landscape')}
title="Landscape"
>
<svg width="16" height="14" viewBox="0 0 16 14" fill="none" aria-hidden="true">
<rect x="1.5" y="2.5" width="13" height="9" rx="1" stroke="currentColor" stroke-width="1.3"/>
</svg>
<span>Landscape</span>
</button>
</div>
<div class="lh-section-label">Page margins (cm)</div>
<div class="margin-grid">
{#each MARGIN_FIELDS as f}
@@ -1708,6 +1736,39 @@
padding: 2px 2px 6px;
}
.orientation-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 4px 6px 2px;
}
.orientation-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
font-family: var(--font-sans);
font-size: 0.72rem;
cursor: pointer;
}
.orientation-btn:hover {
background: var(--color-btn-hover);
}
.orientation-btn.active {
border-color: var(--color-primary);
background: var(--color-primary);
color: white;
}
.margin-grid {
display: grid;
grid-template-columns: 1fr 1fr;
+43 -27
View File
@@ -48,20 +48,27 @@ export function getPageBreakDebug(view: EditorView): PageBreakDebugSnapshot | nu
return debugAccessors.get(view)?.() ?? null;
}
// 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.
// A4 page layout constants (px at 96 dpi). The page height (and thus cycle) is
// orientation-dependent and the content-area inset (top/bottom margin) is
// user-adjustable, so both are read per layout pass from live CSS vars instead
// of being constants — the values below are only portrait fallbacks.
const PAGE_HEIGHT = 1123;
const PAGE_GAP = 20;
const DEFAULT_MARGIN_TOP = 96;
const DEFAULT_MARGIN_BOTTOM = 96;
const CYCLE = PAGE_HEIGHT + PAGE_GAP; // 1143
type VMargins = { top: number; bottom: number; contentHeight: number };
type VMargins = {
top: number;
bottom: number;
contentHeight: number;
pageHeight: number;
cycle: 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
// Reads the vertical page margins + page height (px) from the --user-* custom
// props the Layout panel writes via applyMarginVars (pageMargins.ts) and
// applyOrientationVars (pageOrientation.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.
@@ -69,21 +76,29 @@ 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 ph = parseFloat(cs.getPropertyValue('--user-page-height'));
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 };
const pageHeight = Number.isFinite(ph) ? ph : PAGE_HEIGHT;
return {
top: mt,
bottom: mb,
contentHeight: pageHeight - mt - mb,
pageHeight,
cycle: pageHeight + PAGE_GAP,
};
}
function pageContentStart(page: number, marginTop: number): number {
return (page - 1) * CYCLE + marginTop;
function pageContentStart(page: number, marginTop: number, cycle: number): number {
return (page - 1) * cycle + marginTop;
}
function pageContentEnd(page: number, marginTop: number, contentHeight: number): number {
return (page - 1) * CYCLE + marginTop + contentHeight;
function pageContentEnd(page: number, marginTop: number, contentHeight: number, cycle: number): number {
return (page - 1) * cycle + marginTop + contentHeight;
}
function getPageForY(y: number): number {
return Math.floor(y / CYCLE) + 1;
function getPageForY(y: number, cycle: number): number {
return Math.floor(y / cycle) + 1;
}
const pageBreakKey = new PluginKey<number>('pageBreaks');
@@ -409,6 +424,7 @@ export const PageBreaks = Extension.create({
const vm = readVerticalMargins(dom);
const CONTENT_HEIGHT = vm.contentHeight;
const CYCLE_PX = vm.cycle;
const zoom = getZoomFactor();
const leaves = collectLeaves(zoom);
@@ -422,9 +438,9 @@ export const PageBreaks = Extension.create({
const leaf = leaves[i];
const effectiveTop = leaf.naturalTop + cumulativeShift;
const effectiveBottom = effectiveTop + leaf.naturalHeight;
const page = getPageForY(effectiveTop);
const contentStart = pageContentStart(page, vm.top);
const contentEnd = pageContentEnd(page, vm.top, CONTENT_HEIGHT);
const page = getPageForY(effectiveTop, CYCLE_PX);
const contentStart = pageContentStart(page, vm.top, CYCLE_PX);
const contentEnd = pageContentEnd(page, vm.top, CONTENT_HEIGHT, CYCLE_PX);
let spacerHeight = 0;
let spacerDocPos: number | null = null;
@@ -435,13 +451,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, vm.top) - effectiveTop;
spacerHeight = pageContentStart(page + 1, vm.top, CYCLE_PX) - 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, vm.top) - effectiveTop;
spacerHeight = pageContentStart(page + 1, vm.top, CYCLE_PX) - effectiveTop;
spacerDocPos = preLeafDocPos(leaf.el);
reason = 'atomic-push-to-next-page';
} else {
@@ -451,14 +467,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, vm.top) - effectiveTop;
spacerHeight = pageContentStart(page + 1, vm.top, CYCLE_PX) - effectiveTop;
spacerDocPos = preLeafDocPos(leaf.el);
reason = 'split-fallback-push-whole-leaf';
} else {
reason = 'splittable-too-tall-no-push';
}
} else {
spacerHeight = pageContentStart(page + 1, vm.top) - (effectiveTop + split.naturalLineTop);
spacerHeight = pageContentStart(page + 1, vm.top, CYCLE_PX) - (effectiveTop + split.naturalLineTop);
spacerDocPos = split.docPos;
reason = 'line-split';
}
@@ -478,7 +494,7 @@ export const PageBreaks = Extension.create({
effectiveTop,
effectiveBottom,
pageOfTop: page,
pageOfBottom: getPageForY(effectiveBottom),
pageOfBottom: getPageForY(effectiveBottom, CYCLE_PX),
contentStart,
contentEnd,
overflowsPageEnd: effectiveBottom > contentEnd,
@@ -531,9 +547,9 @@ export const PageBreaks = Extension.create({
if (leaves.length > 0) {
const lastLeaf = leaves[leaves.length - 1];
const effectiveBottom = lastLeaf.naturalTop + cumulativeShift + lastLeaf.naturalHeight;
numPages = Math.max(1, getPageForY(effectiveBottom));
numPages = Math.max(1, getPageForY(effectiveBottom, CYCLE_PX));
}
const targetHeight = numPages * CYCLE - PAGE_GAP;
const targetHeight = numPages * CYCLE_PX - PAGE_GAP;
dom.style.minHeight = `${targetHeight}px`;
dom.dispatchEvent(new CustomEvent('pm-pagecount', { bubbles: true, detail: { numPages } }));
@@ -541,12 +557,12 @@ export const PageBreaks = Extension.create({
lastSnapshot = {
timestamp: new Date().toISOString(),
layout: {
PAGE_HEIGHT,
PAGE_HEIGHT: vm.pageHeight,
PAGE_GAP,
PAGE_MARGIN_TOP: vm.top,
PAGE_MARGIN_BOTTOM: vm.bottom,
CONTENT_HEIGHT,
CYCLE,
CYCLE: CYCLE_PX,
},
numPages,
leaves: leavesDebug,
+5 -1
View File
@@ -2,6 +2,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';
import type { Orientation } from '../storage/pageOrientation';
type AlignValue = 'left' | 'center' | 'right' | 'justify';
@@ -283,11 +284,14 @@ function applyRuns(p: ParagraphBuilder, content: TiptapNode[] = []) {
}
}
export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT_MARGINS): Promise<void> {
export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait'): Promise<void> {
const raw = editor.getJSON() as TiptapNode;
const json = injectCustomTypes(raw);
const odt = await tiptapToOdt(json, {
// Orientation comes from the Layout panel; odf-kit swaps the A4 dimensions
// automatically (29.7×21cm) and writes style:print-orientation accordingly.
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`,
+28
View File
@@ -0,0 +1,28 @@
export type Orientation = 'portrait' | 'landscape';
const KEY = 'odf-editor-page-orientation';
// A4 page box in px @96dpi. Portrait is the editor's historical fixed layout;
// landscape swaps the two dimensions (matches odf-kit's automatic A4 swap).
export const PAGE_W_PORTRAIT = 794; // 210mm
export const PAGE_H_PORTRAIT = 1123; // 297mm
export function loadOrientation(): Orientation {
return localStorage.getItem(KEY) === 'landscape' ? 'landscape' : 'portrait';
}
export function saveOrientation(o: Orientation): void {
localStorage.setItem(KEY, o);
}
// Sets --user-page-{width,height} (in px) on the document root, where they
// inherit down to .paper / .tiptap (see editor.css) and are read per layout
// pass by pageBreaks.ts. Landscape swaps the portrait dimensions.
export function applyOrientationVars(o: Orientation): void {
const landscape = o === 'landscape';
const w = landscape ? PAGE_H_PORTRAIT : PAGE_W_PORTRAIT;
const h = landscape ? PAGE_W_PORTRAIT : PAGE_H_PORTRAIT;
const root = document.documentElement.style;
root.setProperty('--user-page-width', `${w}px`);
root.setProperty('--user-page-height', `${h}px`);
}
+5 -3
View File
@@ -4,14 +4,16 @@
overflow: auto;
}
/* Fixed A4 paper container (794px = 210mm at 96dpi) */
/* A4 paper container (794px = 210mm at 96dpi by default; --user-page-width is
set from the Layout panel and swaps to 1123px in landscape — pageOrientation.ts) */
.paper {
width: 794px;
width: var(--user-page-width, 794px);
margin: 0 auto;
}
.paper .tiptap {
--page-height: 1123px;
/* Driven by --user-page-height (pageOrientation.ts); fallback = A4 portrait. */
--page-height: var(--user-page-height, 1123px);
--page-gap: 20px;
/* Driven by --user-margin-* set on :root from the Layout panel (pageMargins.ts).
Fallbacks preserve the original hardcoded layout when unset. */