dx: add debug feature for analysis of page break

This commit is contained in:
Steffen Becker committed 2026-05-27 19:00:34 +02:00
1 parent d7cd9b885c
commit 6305e58307
3 files changed
+177 -1

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
node_modules
dist
.vite
debug
+51
View File
@@ -4,6 +4,7 @@
import Toolbar from './lib/editor/Toolbar.svelte';
import ToolbarExpanded from './lib/editor/ToolbarExpanded.svelte';
import { exportToOdt } from './lib/export/odt';
import { getPageBreakDebug } from './lib/editor/pageBreaks';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, type ThemeMode } from './lib/storage/theme';
let editor: Editor | null = $state(null);
@@ -44,6 +45,28 @@
async function handleExport() {
if (editor) await exportToOdt(editor);
}
function handleDebugDump() {
if (!editor) return;
const snapshot = getPageBreakDebug(editor.view);
if (!snapshot) {
console.warn('[debug] No page-break snapshot yet — try again after the editor has rendered.');
return;
}
const payload = {
capturedAt: new Date().toISOString(),
zoom,
doc: editor.getJSON(),
pageBreaks: snapshot,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pagebreak-debug-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
a.click();
URL.revokeObjectURL(url);
}
</script>
<main>
@@ -111,6 +134,11 @@
</div>
{/if}
</div>
{#if import.meta.env.DEV}
<button class="debug-btn" onclick={handleDebugDump} disabled={!editor} title="Download page-break debug snapshot">
Debug
</button>
{/if}
<button class="export-btn" onclick={handleExport} disabled={!editor}>
Download .odt
</button>
@@ -260,6 +288,29 @@
transition: background 0.15s;
}
.debug-btn {
padding: 0.35rem 0.7rem;
background: transparent;
color: var(--color-text-muted);
border: 1px dashed var(--color-border);
border-radius: var(--radius);
font-size: 0.75rem;
font-family: var(--font-sans);
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.debug-btn:hover:not(:disabled) {
background: var(--color-btn-hover);
color: var(--color-text);
}
.debug-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.export-btn:hover:not(:disabled) {
background: var(--color-primary-hover);
}
+125 -1
View File
@@ -1,6 +1,52 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
export type PageBreakDebugSnapshot = {
timestamp: string;
layout: {
PAGE_HEIGHT: number;
PAGE_GAP: number;
PAGE_MARGIN_TOP: number;
PAGE_MARGIN_BOTTOM: number;
CONTENT_HEIGHT: number;
CYCLE: number;
};
numPages: number;
leaves: Array<{
index: number;
tag: string;
kind: 'atomic' | 'splittable';
naturalTop: number;
naturalHeight: number;
textPreview: string;
intraSpacerHeights: number[];
effectiveTop: number;
effectiveBottom: number;
pageOfTop: number;
pageOfBottom: number;
contentStart: number;
contentEnd: number;
overflowsPageEnd: boolean;
}>;
placements: Array<{
leafIndex: number;
docPos: number | null;
height: number;
reason: string;
}>;
renderedSpacers: Array<{
height: number;
offsetTopInDoc: number;
viewportTop: number;
}>;
};
const debugAccessors = new WeakMap<EditorView, () => PageBreakDebugSnapshot | null>();
export function getPageBreakDebug(view: EditorView): PageBreakDebugSnapshot | null {
return debugAccessors.get(view)?.() ?? null;
}
// A4 page layout constants (px at 96 dpi)
const PAGE_HEIGHT = 1123;
@@ -51,6 +97,33 @@ export const PageBreaks = Extension.create({
},
},
view(editorView) {
let lastSnapshot: PageBreakDebugSnapshot | null = null;
debugAccessors.set(editorView, (): PageBreakDebugSnapshot | null => {
const snap: PageBreakDebugSnapshot | null = lastSnapshot;
if (snap === null) return null;
const dom = editorView.dom;
const tipRect = dom.getBoundingClientRect();
const renderedSpacers = Array.from(
dom.querySelectorAll<HTMLElement>('[data-page-break-spacer]'),
).map((sp) => {
const r = sp.getBoundingClientRect();
return {
height: sp.offsetHeight,
offsetTopInDoc: r.top - tipRect.top,
viewportTop: r.top,
};
});
return {
timestamp: snap.timestamp,
layout: snap.layout,
numPages: snap.numPages,
leaves: snap.leaves,
placements: snap.placements,
renderedSpacers,
};
});
function docPosBeforeElement(el: HTMLElement): number | null {
const parent = el.parentNode;
if (!parent) return null;
@@ -247,6 +320,8 @@ export const PageBreaks = Extension.create({
let cumulativeShift = 0;
const placements: { docPos: number; height: number }[] = [];
const leavesDebug: PageBreakDebugSnapshot['leaves'] = [];
const placementsDebug: PageBreakDebugSnapshot['placements'] = [];
for (let i = 0; i < leaves.length; i++) {
const leaf = leaves[i];
@@ -258,18 +333,24 @@ export const PageBreaks = Extension.create({
let spacerHeight = 0;
let spacerDocPos: number | null = null;
let reason = 'fits';
if (effectiveTop < contentStart && i > 0) {
spacerHeight = contentStart - effectiveTop;
spacerDocPos = preLeafDocPos(leaf.el);
reason = 'pre-leaf-push-to-content-start';
} else if (effectiveTop >= contentEnd) {
spacerHeight = pageContentStart(page + 1) - 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;
spacerDocPos = preLeafDocPos(leaf.el);
reason = 'atomic-push-to-next-page';
} else {
reason = 'atomic-too-tall-no-push';
}
} else {
const split = findLineSplit(leaf.el, contentEnd - effectiveTop);
@@ -277,17 +358,43 @@ export const PageBreaks = Extension.create({
if (leaf.naturalHeight <= CONTENT_HEIGHT) {
spacerHeight = pageContentStart(page + 1) - 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);
spacerDocPos = split.docPos;
reason = 'line-split';
}
}
}
leavesDebug.push({
index: i,
tag: leaf.el.tagName,
kind: leaf.kind,
naturalTop: leaf.naturalTop,
naturalHeight: leaf.naturalHeight,
textPreview: (leaf.el.textContent ?? '').slice(0, 120),
intraSpacerHeights: Array.from(
leaf.el.querySelectorAll<HTMLElement>('[data-page-break-spacer]'),
).map((sp) => sp.offsetHeight),
effectiveTop,
effectiveBottom,
pageOfTop: page,
pageOfBottom: getPageForY(effectiveBottom),
contentStart,
contentEnd,
overflowsPageEnd: effectiveBottom > contentEnd,
});
if (spacerHeight > 0 && spacerDocPos !== null) {
placements.push({ docPos: spacerDocPos, height: spacerHeight });
placementsDebug.push({ leafIndex: i, docPos: spacerDocPos, height: spacerHeight, reason });
cumulativeShift += spacerHeight;
} else if (reason !== 'fits') {
placementsDebug.push({ leafIndex: i, docPos: spacerDocPos, height: spacerHeight, reason });
}
}
@@ -320,6 +427,22 @@ export const PageBreaks = Extension.create({
dom.dispatchEvent(new CustomEvent('pm-pagecount', { bubbles: true, detail: { numPages } }));
lastSnapshot = {
timestamp: new Date().toISOString(),
layout: {
PAGE_HEIGHT,
PAGE_GAP,
PAGE_MARGIN_TOP,
PAGE_MARGIN_BOTTOM,
CONTENT_HEIGHT,
CYCLE,
},
numPages,
leaves: leavesDebug,
placements: placementsDebug,
renderedSpacers: [],
};
isUpdating = false;
}
@@ -339,6 +462,7 @@ export const PageBreaks = Extension.create({
},
destroy() {
if (rafId !== null) cancelAnimationFrame(rafId);
debugAccessors.delete(editorView);
},
};
},