feat: DIN A4 print layout with visual page breaks
This commit is contained in:
1 parent
c8cd22a03d
commit
2d2489ca4f
5 files changed
+281
-9
No files matched your search
+8
-1
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user