feat: page margin setting
This commit is contained in:
1 parent
7b3c44e888
commit
2088423b59
8 files changed
+420
-41
No files matched your search
+2
-2
@@ -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
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 & 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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user