452 lines
20 KiB
Svelte
452 lines
20 KiB
Svelte
<script lang="ts">
|
||
import { onMount, onDestroy, untrack } from 'svelte';
|
||
import { Editor } from '@tiptap/core';
|
||
import { Slice, Fragment } from 'prosemirror-model';
|
||
import type { Node as PmNode, MarkType } from 'prosemirror-model';
|
||
import { extensions } from './extensions';
|
||
import TableToolbar from './TableToolbar.svelte';
|
||
import { saveDocument, loadDocument } from '../storage/autosave';
|
||
import { applyMarginVars, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
|
||
import { applyOrientationVars, type Orientation } from '../storage/pageOrientation';
|
||
import { FORCE_PAGE_RECALC, type TableBreakBand } from './pageBreaks';
|
||
import { recordTransaction, resetHistoryLog } from './historyLog.svelte';
|
||
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, pageMargins = DEFAULT_MARGINS, orientation = 'portrait' }: {
|
||
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number; showFormattingMarks?: boolean; pageMargins?: PageMargins; orientation?: Orientation;
|
||
} = $props();
|
||
|
||
// Apply the page margins + orientation to the :root CSS vars (visual padding,
|
||
// page dimensions, and pagination all read these). DOM-only, safe in effects.
|
||
$effect(() => {
|
||
applyMarginVars(pageMargins);
|
||
});
|
||
$effect(() => {
|
||
applyOrientationVars(orientation);
|
||
});
|
||
|
||
// 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 + orientation so the effect re-runs on any change
|
||
void (pageMargins.top + pageMargins.bottom + pageMargins.left + pageMargins.right);
|
||
void orientation;
|
||
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;
|
||
|
||
// Zoom is a CSS `transform: scale()` on .paper (so layout and pagination stay at
|
||
// 100%). A transform reserves no layout space, so .paper-scaler reserves the scaled
|
||
// footprint to drive the scrollbars and horizontal centering.
|
||
let paperEl: HTMLDivElement;
|
||
let docHeightDoc = $state(0); // document height, from pm-pagecount
|
||
let scaledWidth = $state(0);
|
||
let scaledHeight = $state(0);
|
||
|
||
function recomputeScaledSize() {
|
||
if (!paperEl) return;
|
||
const z = appliedZoom / 100;
|
||
const w = paperEl.offsetWidth; // unscaled page width (= --user-page-width)
|
||
const h = docHeightDoc || paperEl.offsetHeight; // unscaled document height
|
||
scaledWidth = Math.round(w * z);
|
||
scaledHeight = Math.round(h * z);
|
||
}
|
||
|
||
// Page cycle (page height + 20px gap) in document px. Orientation-dependent, so
|
||
// read live from --user-page-height (set by applyOrientationVars). Must match
|
||
// pageBreaks.ts. Fallback = A4 portrait (1123 + 20).
|
||
function getCycle(): number {
|
||
const ph = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--user-page-height'));
|
||
return (Number.isFinite(ph) ? ph : 1123) + 20;
|
||
}
|
||
|
||
// --- Floating table-editing toolbar ---
|
||
// Shown when the selection is inside a table; positioned just above that table.
|
||
let tableUi = $state<{ visible: boolean; top: number; left: number }>({ visible: false, top: 0, left: 0 });
|
||
let tableUiRaf = 0;
|
||
|
||
// The DOM element of the table containing the current selection, or null.
|
||
// nodeDOM(before(table)) returns the wrapper div the table node view renders.
|
||
function activeTableDOM(ed: Editor): HTMLElement | null {
|
||
const resolved = ed.state.selection.$from;
|
||
for (let d = resolved.depth; d > 0; d--) {
|
||
if (resolved.node(d).type.name === 'table') {
|
||
try {
|
||
const dom = ed.view.nodeDOM(resolved.before(d));
|
||
return dom instanceof HTMLElement ? dom : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function recomputeTableUi() {
|
||
const ed = editor;
|
||
if (!ed || !editorContainer) {
|
||
if (tableUi.visible) tableUi = { ...tableUi, visible: false };
|
||
return;
|
||
}
|
||
const dom = activeTableDOM(ed);
|
||
if (!dom) {
|
||
if (tableUi.visible) tableUi = { ...tableUi, visible: false };
|
||
return;
|
||
}
|
||
// Position in the editor container's content space: viewport delta + scroll.
|
||
// getBoundingClientRect and scrollTop share the same (zoom-affected) scale,
|
||
// so this stays aligned across zoom; the toolbar itself is a non-zoomed
|
||
// sibling of .paper, so it renders at a constant size.
|
||
const tRect = dom.getBoundingClientRect();
|
||
const cRect = editorContainer.getBoundingClientRect();
|
||
tableUi = {
|
||
visible: true,
|
||
top: tRect.top - cRect.top + editorContainer.scrollTop,
|
||
left: tRect.left - cRect.left + editorContainer.scrollLeft,
|
||
};
|
||
}
|
||
|
||
// --- Table page-break overlay ---
|
||
// pageBreaks.ts reports (via pm-pagecount) where a single continuous table box crosses
|
||
// a page boundary (a too-tall cell's content flowing across pages, or a between-rows
|
||
// push), each as one band in document px relative to .tiptap's top. We render the
|
||
// overlay in a .band-layer inside .paper
|
||
// (in document px) so the same `transform: scale()` scales it identically to the
|
||
// .tiptap page background. Two pieces per break:
|
||
// • band — a solid page-coloured mask (content width) hiding the table's borders
|
||
// bleeding through the page margins, plus the close/open lines.
|
||
// • stripe — the dark page gap, drawn as ONE full-page-width element on top. Using
|
||
// a single rectangle for the whole gap avoids a left/right edge where two
|
||
// separately-rasterised gradients disagree by a sub-pixel (the seam).
|
||
type BandStyle = { top: number; left: number; width: number; height: number };
|
||
type GapStripeStyle = { top: number; width: number; height: number; background: string };
|
||
let tableBandsDoc = $state<TableBreakBand[]>([]);
|
||
let bandStyles = $state<BandStyle[]>([]);
|
||
let gapStripeStyles = $state<GapStripeStyle[]>([]);
|
||
|
||
function recomputeBands() {
|
||
const tiptap = element?.querySelector('.tiptap') as HTMLElement | null;
|
||
if (!tiptap || tableBandsDoc.length === 0) {
|
||
if (bandStyles.length) bandStyles = [];
|
||
if (gapStripeStyles.length) gapStripeStyles = [];
|
||
return;
|
||
}
|
||
const pageWidth = tiptap.offsetWidth; // unscaled doc px = full page width
|
||
// Everything below is in document px (relative to .tiptap's top). The .band-layer
|
||
// lives inside the scaled .paper, so the transform applies the scaling — no `* z`.
|
||
const border = 1; // 1px page-edge line, like the CSS .tiptap background
|
||
bandStyles = tableBandsDoc.map((b) => {
|
||
// Use pure page geometry: the band spans exactly the inter-page region — from the
|
||
// closing page's content-bottom (closeY) down through margin/gap/margin to the next
|
||
// page's content-top. This is precisely where the table must show no vertical
|
||
// borders, and pagination guarantees no real content ever sits there, so a solid
|
||
// mask here can never eat content. (Anchoring the band to the break's spacers
|
||
// instead under-covered atomic-push / leaf-jump breaks, where the spacer renders
|
||
// below content-bottom — after the empty gap left by the pushed block — leaving the
|
||
// top of the page's bottom margin unmasked and the borders bleeding through it.)
|
||
// Match the band exactly to the table's content box (b.left / b.width): the black
|
||
// close (top) + open (bottom) lines are this element's top/bottom borders, so they
|
||
// must line up with the table's own start/end borders, which span the content width.
|
||
// The 1px overhang to also mask the outer L/R borders' half-pixel slivers is done by
|
||
// a horizontal-only box-shadow (.table-break-band CSS) so the mask widens without
|
||
// lengthening those close/open lines.
|
||
return { top: b.closeY, left: b.left, width: b.width, height: b.height };
|
||
});
|
||
// Full-page-width gap stripe: the dark page gap + its two page-edge lines, at the
|
||
// true surface bottom (closeY + marginBottom = N·cycle − gap). Covers the entire
|
||
// natural gap (margins + table) with ONE element → no seam. Painted on top of the
|
||
// bands (later in DOM) so it overrides their white fill in the gap region.
|
||
gapStripeStyles = tableBandsDoc.map((b) => {
|
||
const gapStart = b.closeY + b.marginBottom;
|
||
return {
|
||
top: gapStart - border,
|
||
width: pageWidth,
|
||
height: b.gap + 2 * border,
|
||
background:
|
||
`linear-gradient(to bottom,`
|
||
+ ` var(--color-page-border) 0, var(--color-page-border) ${border}px,`
|
||
+ ` var(--color-bg) ${border}px, var(--color-bg) ${border + b.gap}px,`
|
||
+ ` var(--color-page-border) ${border + b.gap}px, var(--color-page-border) 100%)`,
|
||
};
|
||
});
|
||
}
|
||
|
||
// Defer to the next frame so the DOM reflects the latest transaction /
|
||
// pagination spacers before we measure (mirrors the page-break code).
|
||
function scheduleTableUi() {
|
||
cancelAnimationFrame(tableUiRaf);
|
||
tableUiRaf = requestAnimationFrame(() => {
|
||
recomputeTableUi();
|
||
recomputeBands();
|
||
});
|
||
}
|
||
|
||
// Throttle the value actually written to the DOM to one update per animation frame,
|
||
// so rapid slider events don't trigger 50+ layout/paint cycles per second.
|
||
let appliedZoom = $state(untrack(() => zoom));
|
||
let zoomRaf: number | null = null;
|
||
|
||
$effect(() => {
|
||
zoom; // track
|
||
if (zoomRaf !== null) return;
|
||
zoomRaf = requestAnimationFrame(() => {
|
||
zoomRaf = null;
|
||
appliedZoom = zoom; // pick up the latest value, not a captured snapshot
|
||
});
|
||
});
|
||
|
||
// Preserve the top-of-viewport anchor across zoom changes.
|
||
let prevZoom = -1;
|
||
let pendingAnchorDocY: number | null = null;
|
||
|
||
$effect.pre(() => {
|
||
const z = appliedZoom;
|
||
if (prevZoom < 0 || !editorContainer || !element || z === prevZoom) {
|
||
prevZoom = z;
|
||
return;
|
||
}
|
||
const editorRect = editorContainer.getBoundingClientRect();
|
||
const paperRect = element.getBoundingClientRect();
|
||
pendingAnchorDocY = (editorRect.top - paperRect.top) / (prevZoom / 100);
|
||
prevZoom = z;
|
||
});
|
||
|
||
$effect(() => {
|
||
appliedZoom; // track to fire after the pre-effect / DOM update
|
||
// Re-reserve the scaled scroll footprint for the new zoom (layout itself is frozen).
|
||
recomputeScaledSize();
|
||
// Zoom changes the table's rendered position — re-place the floating toolbar.
|
||
scheduleTableUi();
|
||
if (pendingAnchorDocY === null || !editorContainer || !element) return;
|
||
const docY = pendingAnchorDocY;
|
||
pendingAnchorDocY = null;
|
||
const editorRect = editorContainer.getBoundingClientRect();
|
||
const paperRect = element.getBoundingClientRect();
|
||
const targetScreenY = paperRect.top + docY * (appliedZoom / 100);
|
||
editorContainer.scrollTop += targetScreenY - editorRect.top;
|
||
});
|
||
|
||
function updateCurrentPage() {
|
||
const tiptap = element?.querySelector('.tiptap') as HTMLElement | null;
|
||
if (!tiptap || !editorContainer) return;
|
||
|
||
const editorRect = editorContainer.getBoundingClientRect();
|
||
const tiptapRect = tiptap.getBoundingClientRect();
|
||
// getBoundingClientRect and coordsAtPos return zoomed viewport pixels;
|
||
// divide by zoom factor to convert to document coordinates before comparing with the cycle.
|
||
const zoomFactor = appliedZoom / 100;
|
||
const cycle = getCycle();
|
||
|
||
if (editor) {
|
||
try {
|
||
const coords = editor.view.coordsAtPos(editor.state.selection.head);
|
||
const cursorMidY = (coords.top + coords.bottom) / 2;
|
||
if (cursorMidY >= editorRect.top && cursorMidY <= editorRect.bottom) {
|
||
const cursorInDoc = (cursorMidY - tiptapRect.top) / zoomFactor;
|
||
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInDoc) / cycle) + 1));
|
||
return;
|
||
}
|
||
} catch {
|
||
// coordsAtPos can fail during editor teardown — fall through
|
||
}
|
||
}
|
||
|
||
const visibleTopInDoc = (editorRect.top - tiptapRect.top) / zoomFactor;
|
||
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, visibleTopInDoc) / cycle) + 1));
|
||
}
|
||
|
||
function onPageCount(e: Event) {
|
||
const detail = (e as CustomEvent<{ numPages: number; docHeight?: number; tableBreakBands?: TableBreakBand[] }>).detail;
|
||
numPages = detail.numPages;
|
||
if (typeof detail.docHeight === 'number') docHeightDoc = detail.docHeight;
|
||
tableBandsDoc = detail.tableBreakBands ?? [];
|
||
// The document height changed → resize the scaled scroll footprint.
|
||
recomputeScaledSize();
|
||
updateCurrentPage();
|
||
// Pagination spacers can shift a table's position — re-place the toolbar/bands.
|
||
scheduleTableUi();
|
||
}
|
||
|
||
function applyFontToFragment(frag: Fragment, textStyleType: MarkType, font: string): Fragment {
|
||
const nodes: PmNode[] = [];
|
||
frag.forEach((node: PmNode) => {
|
||
if (node.isText) {
|
||
const existingTS = node.marks.find(m => m.type === textStyleType);
|
||
if (existingTS?.attrs.fontFamily) {
|
||
nodes.push(node);
|
||
} else {
|
||
const newAttrs = { ...(existingTS?.attrs ?? {}), fontFamily: font };
|
||
const otherMarks = node.marks.filter(m => m.type !== textStyleType);
|
||
nodes.push(node.mark([...otherMarks, textStyleType.create(newAttrs)]));
|
||
}
|
||
} else {
|
||
nodes.push(node.copy(applyFontToFragment(node.content, textStyleType, font)));
|
||
}
|
||
});
|
||
return Fragment.fromArray(nodes);
|
||
}
|
||
|
||
onMount(() => {
|
||
// Start with empty history lists — loaded content is not an undoable edit.
|
||
resetHistoryLog();
|
||
const saved = loadDocument();
|
||
|
||
editor = new Editor({
|
||
element,
|
||
extensions,
|
||
content: saved || undefined,
|
||
editorProps: {
|
||
transformPasted(slice, view) {
|
||
const textStyleType = view.state.schema.marks.textStyle;
|
||
if (!textStyleType) return slice;
|
||
const cursorMarks = view.state.storedMarks ?? view.state.selection.$head.marks();
|
||
const cursorFont = cursorMarks.find(m => m.type === textStyleType)?.attrs.fontFamily as string | undefined;
|
||
const font = cursorFont ?? DEFAULT_EDITOR_FONT;
|
||
return new Slice(applyFontToFragment(slice.content, textStyleType, font), slice.openStart, slice.openEnd);
|
||
},
|
||
},
|
||
onTransaction: ({ editor: e, transaction }) => {
|
||
tick++;
|
||
// Mirror this transaction into the labelled undo/redo log for the toolbar's
|
||
// history dropdowns. e.state is the POST-transaction state, so the history
|
||
// depths recordTransaction reads already reflect this transaction.
|
||
recordTransaction(e.state, transaction);
|
||
// Covers both doc and selection changes (entering/leaving a table,
|
||
// adding/removing rows or columns).
|
||
scheduleTableUi();
|
||
},
|
||
onSelectionUpdate: ({ editor: e }) => {
|
||
// Use the editor instance passed by TipTap directly — avoids any Svelte
|
||
// prop-reactivity timing issues. TipTap always auto-scrolls the cursor
|
||
// into view before firing this, so no visibility check is needed here.
|
||
const tiptap = element?.querySelector('.tiptap') as HTMLElement | null;
|
||
if (!tiptap) return;
|
||
try {
|
||
const coords = e.view.coordsAtPos(e.state.selection.head);
|
||
const cursorInDoc = ((coords.top + coords.bottom) / 2 - tiptap.getBoundingClientRect().top) / (appliedZoom / 100);
|
||
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInDoc) / getCycle()) + 1));
|
||
} catch { /* ignore */ }
|
||
},
|
||
onUpdate: ({ editor: e }) => {
|
||
saveDocument(e.getJSON());
|
||
},
|
||
});
|
||
|
||
element.addEventListener('pm-pagecount', onPageCount);
|
||
editorContainer.addEventListener('scroll', onEditorScroll);
|
||
// Seed the scaled footprint before the first paint (so the page is centered, not
|
||
// briefly left-aligned), then refine it once layout settles. The pageBreaks plugin
|
||
// also fires pm-pagecount shortly after with the precise document height.
|
||
recomputeScaledSize();
|
||
requestAnimationFrame(recomputeScaledSize);
|
||
});
|
||
|
||
function onEditorScroll() {
|
||
updateCurrentPage();
|
||
scheduleTableUi();
|
||
}
|
||
|
||
onDestroy(() => {
|
||
if (zoomRaf !== null) cancelAnimationFrame(zoomRaf);
|
||
cancelAnimationFrame(marginRecalcRaf);
|
||
cancelAnimationFrame(tableUiRaf);
|
||
editor?.destroy();
|
||
resetHistoryLog();
|
||
element?.removeEventListener('pm-pagecount', onPageCount);
|
||
editorContainer?.removeEventListener('scroll', onEditorScroll);
|
||
});
|
||
</script>
|
||
|
||
<div class="editor" bind:this={editorContainer}>
|
||
<!-- Reserves the scaled scroll footprint; the transform on .paper reserves none.
|
||
Before the first measure (size 0) it's left unsized so .paper isn't clipped. -->
|
||
<div class="paper-scaler" style={scaledWidth ? `width: ${scaledWidth}px; height: ${scaledHeight}px;` : ''}>
|
||
<div bind:this={paperEl} class="paper" class:show-formatting-marks={showFormattingMarks} style="transform: scale({appliedZoom / 100});">
|
||
<!-- Dedicated mount point that TipTap fully owns — keeping it free of Svelte
|
||
content avoids Svelte and ProseMirror fighting over the same parent's DOM. -->
|
||
<div bind:this={element} class="tiptap-host"></div>
|
||
{#if bandStyles.length}
|
||
<div class="band-layer">
|
||
{#each bandStyles as b}
|
||
<div
|
||
class="table-break-band"
|
||
style="top: {b.top}px; left: {b.left}px; width: {b.width}px; height: {b.height}px;"
|
||
></div>
|
||
{/each}
|
||
{#each gapStripeStyles as s}
|
||
<div
|
||
class="page-gap-stripe"
|
||
style="top: {s.top}px; width: {s.width}px; height: {s.height}px; background: {s.background};"
|
||
></div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{#if tableUi.visible}
|
||
<TableToolbar {editor} top={tableUi.top} left={tableUi.left} />
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
/* Overlay layer for the table page-break bands. It lives inside .paper (filling it
|
||
via inset:0) so the bands are scaled by the same transform as the .tiptap page
|
||
background — no sub-pixel seam at fractional zoom. pointer-events:none keeps the
|
||
editor clickable. The z-index must clear the column-/row-resize handles
|
||
(z-index:20 in editor.css): a too-tall cell is one continuous DOM box, so its resize
|
||
handle spans every page gap it crosses; the band masks that handle in the gap just
|
||
like it masks the table's borders bleeding through the page margins. */
|
||
.band-layer {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 21;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Table page-break mask: a solid page-coloured fill over the table's margin band
|
||
that hides the table's vertical borders bleeding through the page margins, plus
|
||
the black close (top) + open (bottom) line. Document px inside .band-layer. The
|
||
dark gap itself is drawn by .page-gap-stripe on top. */
|
||
.table-break-band {
|
||
position: absolute;
|
||
pointer-events: none;
|
||
background: var(--color-page-bg);
|
||
border-top: 1px solid #000;
|
||
border-bottom: 1px solid #000;
|
||
/* The fill matches the table's content box exactly so the close/open lines line up
|
||
with the table's start/end borders. The table's outer L/R borders are centred on
|
||
the content edges, though, so their outer halves sit 0.5px into the margins. Two
|
||
horizontal-only box-shadows (0 blur/spread, ±1px x-offset) paint the page colour
|
||
1px beyond each side for the band's full height — swallowing those slivers without
|
||
extending the top/bottom borders (the close/open lines stay the table's width). */
|
||
box-shadow:
|
||
1px 0 0 0 var(--color-page-bg),
|
||
-1px 0 0 0 var(--color-page-bg);
|
||
}
|
||
|
||
/* The page gap at a table break, drawn as ONE full-page-width element on top of the
|
||
masks so the dark gap line is a single rasterised rectangle — no left/right edge
|
||
where it could disagree (sub-pixel, at fractional zoom) with the .tiptap
|
||
background gap in the side margins. */
|
||
.page-gap-stripe {
|
||
position: absolute;
|
||
left: 0;
|
||
pointer-events: none;
|
||
}
|
||
</style>
|