+
Orientation
+
+
+
+
Page margins (cm)
{#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;
diff --git a/src/lib/editor/pageBreaks.ts b/src/lib/editor/pageBreaks.ts
index a6ba0fb..488591d 100644
--- a/src/lib/editor/pageBreaks.ts
+++ b/src/lib/editor/pageBreaks.ts
@@ -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('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,
diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts
index 01c49d6..53a4ed9 100644
--- a/src/lib/export/odt.ts
+++ b/src/lib/export/odt.ts
@@ -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 {
+export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait'): Promise {
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`,
diff --git a/src/lib/storage/pageOrientation.ts b/src/lib/storage/pageOrientation.ts
new file mode 100644
index 0000000..1f2ab7a
--- /dev/null
+++ b/src/lib/storage/pageOrientation.ts
@@ -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`);
+}
diff --git a/src/styles/editor.css b/src/styles/editor.css
index 0007115..5fe3299 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -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. */