diff --git a/CHANGELOG.md b/CHANGELOG.md index 33843ce..c3436d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lib/editor/Editor.svelte b/src/lib/editor/Editor.svelte index 791cf21..6ef98ed 100644 --- a/src/lib/editor/Editor.svelte +++ b/src/lib/editor/Editor.svelte @@ -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 }) => { diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 7479636..53595dd 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -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, ]; diff --git a/src/lib/editor/pageBreaks.ts b/src/lib/editor/pageBreaks.ts new file mode 100644 index 0000000..ecba48f --- /dev/null +++ b/src/lib/editor/pageBreaks.ts @@ -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]; + }, +}); diff --git a/src/styles/editor.css b/src/styles/editor.css index 6a8d9af..de35844 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -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; + } +}