feat: DIN A4 print layout with visual page breaks

This commit is contained in:
Steffen Becker committed 2026-05-25 20:50:08 +02:00
1 parent c8cd22a03d
commit 2d2489ca4f
5 files changed
+281 -9

No files matched your search

+8 -1
View File
@@ -23,12 +23,19 @@
- Favicon (hand-coded SVG, no external assets)
- Lists (ordered / unordered)
- Light / Dark / Auto appearance mode (settings menu in toolbar)
- expandable extended toolbar
- DIN A4 print layout with visual page breaks
- Repeating multi-page background (page surface + gap between pages)
- Shadow edges at page boundaries for depth effect
- Dynamic page snapping (editor always shows full pages)
- Print-ready @media print styles with @page A4 sizing
### Not yet implemented
- ODT import / open existing file
- font size
- Images, tables
- DIN A4 print layout / page breaks
- Zoom feature
- Custom right-click context menu (formatting, cut/copy)
- Multi-document management
- PWA / offline support
- i18n
-2
View File
@@ -17,8 +17,6 @@
extensions,
content: saved || undefined,
onTransaction: () => {
// Increment tick to signal Svelte that editor state changed.
// (editor = editor is a no-op in Svelte 5 for same references)
tick++;
},
onUpdate: ({ editor: e }) => {
+2
View File
@@ -10,6 +10,7 @@ import OrderedList from '@tiptap/extension-ordered-list';
import ListItem from '@tiptap/extension-list-item';
import History from '@tiptap/extension-history';
import Placeholder from '@tiptap/extension-placeholder';
import { PageBreaks } from './pageBreaks';
export const extensions = [
Document,
@@ -24,4 +25,5 @@ export const extensions = [
ListItem,
History,
Placeholder.configure({ placeholder: 'Start typing…' }),
PageBreaks,
];
+165
View File
@@ -0,0 +1,165 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view';
// A4 page layout constants (px at 96 dpi)
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 CYCLE = PAGE_HEIGHT + PAGE_GAP; // 1143
function pageContentStart(page: number): number {
return (page - 1) * CYCLE + PAGE_MARGIN_TOP;
}
function pageContentEnd(page: number): number {
return (page - 1) * CYCLE + PAGE_MARGIN_TOP + CONTENT_HEIGHT;
}
function getPageForY(y: number): number {
return Math.floor(y / CYCLE) + 1;
}
const pageBreakKey = new PluginKey('pageBreaks');
export const PageBreaks = Extension.create({
name: 'pageBreaks',
addProseMirrorPlugins() {
let decorations = DecorationSet.empty;
let isUpdating = false;
let rafId: number | null = null;
const plugin = new Plugin({
key: pageBreakKey,
props: {
decorations() {
return decorations;
},
},
view(editorView) {
function calculate() {
rafId = null;
if (isUpdating || !editorView.dom.isConnected) return;
isUpdating = true;
const dom = editorView.dom;
void dom.offsetHeight; // force reflow
// Walk DOM children: measure content blocks, track spacer heights
const blocks: { top: number; height: number }[] = [];
let spacerHeightAbove = 0;
for (const child of Array.from(dom.children)) {
const el = child as HTMLElement;
if (el.dataset?.pageBreakSpacer) {
spacerHeightAbove += el.offsetHeight;
} else {
blocks.push({
// Subtract spacer heights to get "natural" position
top: el.offsetTop - spacerHeightAbove,
height: el.offsetHeight,
});
}
}
// Calculate which blocks need page break spacers
let cumulativeShift = 0;
const spacers: { beforeIndex: number; height: number }[] = [];
for (let i = 0; i < blocks.length; i++) {
const m = blocks[i];
const effectiveTop = m.top + cumulativeShift;
const effectiveBottom = effectiveTop + m.height;
const page = getPageForY(effectiveTop);
const contentStart = pageContentStart(page);
const contentEnd = pageContentEnd(page);
let spacerHeight = 0;
if (effectiveTop < contentStart && i > 0) {
spacerHeight = contentStart - effectiveTop;
} else if (effectiveTop >= contentEnd) {
const nextStart = pageContentStart(page + 1);
spacerHeight = nextStart - effectiveTop;
} else if (effectiveBottom > contentEnd && m.height <= CONTENT_HEIGHT) {
const nextStart = pageContentStart(page + 1);
spacerHeight = nextStart - effectiveTop;
}
if (spacerHeight > 0) {
spacers.push({ beforeIndex: i, height: spacerHeight });
cumulativeShift += spacerHeight;
}
}
// Build widget decorations at correct document positions
const doc = editorView.state.doc;
const decoArray: Decoration[] = [];
const spacerMap = new Map(spacers.map((s) => [s.beforeIndex, s.height]));
let nodeIndex = 0;
doc.forEach((_node, offset) => {
const height = spacerMap.get(nodeIndex);
if (height !== undefined) {
const spacerEl = document.createElement('div');
spacerEl.dataset.pageBreakSpacer = 'true';
spacerEl.style.height = `${height}px`;
spacerEl.style.pointerEvents = 'none';
spacerEl.style.userSelect = 'none';
spacerEl.setAttribute('contenteditable', 'false');
decoArray.push(Decoration.widget(offset, spacerEl, { side: -1 }));
}
nodeIndex++;
});
// Update decorations
const newDecorations = decoArray.length > 0
? DecorationSet.create(doc, decoArray)
: DecorationSet.empty;
// Only dispatch if something actually changed
const oldCount = decorations === DecorationSet.empty ? 0 : -1; // force update comparison
decorations = newDecorations;
// Trigger view update with a no-op transaction
const tr = editorView.state.tr.setMeta('addToHistory', false).setMeta(pageBreakKey, true);
editorView.dispatch(tr);
// Update min-height for full pages
void dom.offsetHeight;
const totalHeight = dom.scrollHeight;
const numPages = Math.max(1, Math.ceil((totalHeight + PAGE_GAP) / CYCLE));
const targetHeight = numPages * PAGE_HEIGHT + (numPages - 1) * PAGE_GAP;
dom.style.minHeight = `${targetHeight}px`;
isUpdating = false;
}
function schedule() {
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(calculate);
}
// Initial calculation
schedule();
return {
update(_view, prevState) {
if (!isUpdating && prevState.doc !== editorView.state.doc) {
schedule();
}
},
destroy() {
if (rafId !== null) cancelAnimationFrame(rafId);
},
};
},
});
return [plugin];
},
});
+106 -6
View File
@@ -14,17 +14,86 @@
}
.paper .tiptap {
--page-height: 1123px;
--page-gap: 20px;
--page-margin-top: 96px;
--page-margin-bottom: 96px;
--page-cycle: calc(var(--page-height) + var(--page-gap));
position: relative;
width: 100%;
min-height: 1123px; /* 297mm at 96dpi */
padding: 2.5rem 3rem;
background: var(--color-surface);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
border: 1px solid var(--color-border);
min-height: var(--page-height);
padding: var(--page-margin-top) 80px var(--page-margin-bottom);
outline: none;
font-family: var(--font-serif);
font-size: 1.05rem;
line-height: 1.0;
line-height: 1.6;
color: var(--color-text);
/* Multi-page background: page surfaces separated by editor background */
background:
/* Left border per page */
repeating-linear-gradient(
to bottom,
var(--color-border) 0px,
var(--color-border) var(--page-height),
transparent var(--page-height),
transparent var(--page-cycle)
),
/* Right border per page */
repeating-linear-gradient(
to bottom,
var(--color-border) 0px,
var(--color-border) var(--page-height),
transparent var(--page-height),
transparent var(--page-cycle)
),
/* Top/bottom border lines of each page */
repeating-linear-gradient(
to bottom,
var(--color-border) 0px,
var(--color-border) 1px,
transparent 1px,
transparent calc(var(--page-height) - 1px),
var(--color-border) calc(var(--page-height) - 1px),
var(--color-border) var(--page-height),
transparent var(--page-height),
transparent var(--page-cycle)
),
/* Page surface (white) alternating with gap (transparent = editor bg shows) */
repeating-linear-gradient(
to bottom,
var(--color-surface) 0px,
var(--color-surface) var(--page-height),
transparent var(--page-height),
transparent var(--page-cycle)
);
background-size:
1px var(--page-cycle),
1px var(--page-cycle),
100% var(--page-cycle),
100% var(--page-cycle);
background-position:
left top,
right top,
left top,
left top;
background-repeat: repeat-y;
border: none;
/* Subtle overall shadow for depth */
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
/* Page break spacer widgets (inserted by ProseMirror decorations) */
.paper .tiptap [data-page-break-spacer] {
display: block;
width: 100%;
margin: 0;
padding: 0;
border: none;
background: none;
}
.paper .tiptap p {
@@ -70,3 +139,34 @@
pointer-events: none;
height: 0;
}
/* Print styles: proper A4 pagination */
@media print {
.editor {
padding: 0;
overflow: visible;
}
.paper {
width: 100%;
max-width: none;
}
.paper .tiptap {
background: white !important;
box-shadow: none;
border: none;
padding: 0;
min-height: auto;
}
.paper .tiptap > *[data-page-break] {
margin-top: 0 !important;
page-break-before: always;
}
@page {
size: A4;
margin: 25.4mm;
}
}