perf: the load passes batch their field work and flow a section whole

Cross-references resolve in one batch per pass, the index reads every leader gap
before filling one, and a zone's tabs lay out with the other zones' — and only
where a field asks for the page count or the chapter map.
List marker and list style decorations are rebuilt only for a transaction
reaching into a list; the unsaved dot compares documents structurally, and the
autosave serializes when it writes rather than per keystroke.
The columns flow applies every op it can judge in a pass, so a section pages in
one pass. Two long files that took 33 s and 20 s now settle in 9 s and 2 s.
This commit is contained in:
Steffen Becker committed 2026-09-08 22:31:01 +02:00
1 parent 3af5a00fed
commit da857fcbb8
16 files changed
+478 -230

No files matched your search

+10 -7
View File
@@ -164,7 +164,10 @@
// Everything the file holds counts: the text, and the page setup, styles and zones
// beside it, which is what the exporter is handed. Same beat as the word count.
let dirty = $state(false);
let cleanSum: number | null = null;
// The text as saved, compared as a tree: every unchanged node is the very object the
// saved tree holds, so the comparison is cheap however many pictures ride along.
let cleanDoc: PmNode | null = null;
let cleanRest = 0;
let sumTimer: ReturnType<typeof setTimeout> | undefined;
// Whether there is a file to lose those changes from — a saved file makes the dot,
// and the unload warning, follow the changes rather than the document's existence.
@@ -172,15 +175,15 @@
// The page count rides in with the zones but is a layout result, not an edit: a
// late-loading font repaginating the document must not mark it changed.
function documentSum(): number {
if (!editor) return 0;
function restSum(): number {
const [margins, orientation, hf, ...rest] = exportArgs();
const { pageCount: _pages, ...zones } = hf;
return fnv1a(JSON.stringify([editor.getJSON(), documentName, margins, orientation, zones, ...rest]));
return fnv1a(JSON.stringify([documentName, margins, orientation, zones, ...rest]));
}
function markSaved(): void {
cleanSum = documentSum();
cleanDoc = editor?.state.doc ?? null;
cleanRest = restSum();
dirty = false;
}
@@ -191,13 +194,13 @@
// The document the editor comes up with is the file's, taken here and not in the
// timer: under a repagination the timer can be pushed out past the first edit,
// which would make that edit the baseline.
if (cleanSum === null) {
if (cleanDoc === null) {
markSaved();
return;
}
sumTimer ??= setTimeout(() => {
sumTimer = undefined;
dirty = documentSum() !== cleanSum;
dirty = !editor || !cleanDoc || !editor.state.doc.eq(cleanDoc) || restSum() !== cleanRest;
}, 300);
});
+15 -4
View File
@@ -50,6 +50,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
import { MAX_PAGE_COLUMNS } from '../storage/theme';
import { DEFAULT_HF_DISTANCES, HF_ZONE_KEYS, hfIsEmpty, hfUsesChapterField, type HfDoc, type HfZone, type HfDistances, type HfSet, type HfZoneKey } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, PAGE_GAP, pageOfElement, readVerticalMargins, topInEditor, type TableBreakBand } from '../editor/extensions/pageBreaks';
import { SHEET_CHANGED } from '../editor/extensions/listMarker';
import { findBookmark } from '../editor/extensions/bookmark';
import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte';
import { fitPagesZoom, wheelZoomFactor } from '../utils/zoom';
@@ -130,10 +131,20 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
return out;
}
// Kept by value: a fresh array per pass would re-lay every zone's fields and tabs.
let chapterKey = '';
function refreshChapterStarts(): void {
const next = wantsChapters ? collectChapterStarts() : [];
const key = JSON.stringify(next);
if (key === chapterKey) return;
chapterKey = key;
chapterStarts = next;
}
// Pagination refreshes the starts; inserting a chapter field into a zone isn't a
// pagination event, so collect them as soon as a zone asks for them.
$effect(() => {
chapterStarts = wantsChapters ? collectChapterStarts() : [];
refreshChapterStarts();
});
// The page border wraps the header/footer band too (both word processors draw it
@@ -409,7 +420,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// A table style's fill/borders live in cell attrs, so an edited registry has to be
// painted into the document — this effect is the choke point every change passes.
ed.commands.refreshTableStyles();
ed.view.dispatch(ed.state.tr.setMeta('addToHistory', false).setMeta(FORCE_PAGE_RECALC, true));
ed.view.dispatch(ed.state.tr.setMeta('addToHistory', false).setMeta(FORCE_PAGE_RECALC, true).setMeta(SHEET_CHANGED, true));
});
});
@@ -1011,7 +1022,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
if (typeof detail.docHeight === 'number') docHeightDoc = detail.docHeight;
tableBandsDoc = detail.tableBreakBands ?? [];
sectionStartPages = detail.sectionStartPages ?? [];
chapterStarts = wantsChapters ? collectChapterStarts() : [];
refreshChapterStarts();
// The document height changed → resize the scaled scroll footprint.
recomputeScaledSize();
updateCurrentPage();
@@ -1291,7 +1302,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
} catch { /* ignore */ }
},
onUpdate: ({ editor: e, transaction }) => {
saveDocument(e.getJSON());
saveDocument(() => e.getJSON());
const ui = transaction.getMeta('uiEvent');
if (ui === 'paste' || ui === 'drop') void inlineRemoteImages(e);
},
+19 -6
View File
@@ -258,14 +258,25 @@
return v === 'first' ? b.footerFirst : v === 'even' ? b.footerEven : b.footer;
}
// Page, page count, the zone's HTML, the chapter map and the zone: everything the two
// actions below re-run on (the map's identity changes with each pagination pass).
// Page, the page count and chapter map (only where the zone shows them), its HTML and
// the zone: everything the two actions below re-run on.
type ZoneParams = [number, number, string, unknown, HfZone];
// The zone's tabs: static HTML no ProseMirror plugin reaches. The advances are layout
// px, so only a content change invalidates them — not the zoom transform.
// px, so only a content change invalidates them — not the zoom transform. The zones of
// one flush lay out together, after their fields are patched: one layout, not one each.
const tabQueue = new Set<HTMLElement>();
function layOutTabs(node: HTMLElement, _params: ZoneParams) {
const apply = () => layOutZoneTabs(node);
const apply = () => {
if (!tabQueue.size) {
queueMicrotask(() => {
const zones = [...tabQueue].filter((z) => z.isConnected);
tabQueue.clear();
layOutZoneTabs(zones);
});
}
tabQueue.add(node);
};
apply();
return { update: apply };
}
@@ -452,6 +463,8 @@
{#each ['header', 'footer'] as const as zone}
{#if !(interactive && hfActive === zone && editingPage === p)}
{@const html = zoneHtml(zone, p)}
{@const total = html.includes('data-page-field="count"') ? numPages : 0}
{@const chapters = html.includes('data-page-field="chapter"') ? chapterStarts : null}
<div
class="hf-zone hf-{zone}"
class:hf-empty={!html}
@@ -460,8 +473,8 @@
ondblclick={() => interactive && startEdit(zone, p)}
role="button"
tabindex="-1"
use:patchFields={[p, numPages, html, chapterStarts, zone]}
use:layOutTabs={[p, numPages, html, chapterStarts, zone]}
use:patchFields={[p, total, html, chapters, zone]}
use:layOutTabs={[p, total, html, chapters, zone]}
>
{@html html}
</div>
+69 -39
View File
@@ -29,9 +29,11 @@ const OPEN_HEIGHT_SLACK_PX = 40;
// Below this many lines of leftover room, a boundary block splits at the block
// boundary instead of pulling a line or two over (not worth a paragraph split).
const MIN_TAIL_LINES = 2;
// Pass budget per external change: each pass performs one split/join, so a paste
// spanning many pages needs several; bounded against estimate-driven oscillation.
// Pass budget per external change: a pass fixes what it can judge, the next measures
// the geometry it changed; bounded against estimate-driven oscillation. The op budget
// within a pass pages a whole section in one go — a split per page.
const MAX_FLOW_PASSES = 48;
const MAX_FLOW_OPS = 64;
type Fragment = { pos: number; node: PMNode };
export type LineRect = { left: number; right: number; top: number; height: number };
@@ -71,20 +73,31 @@ function blockHeightPx(el: Element, scale: number): number {
return h / scale;
}
// Total content height of a fragment's blocks as poured into one column flow:
// block rects plus the collapsed inter-block margins (computed style, unscaled).
function contentHeightPx(children: Element[], scale: number): number {
// Where each block starts and ends in the flow as poured into one column: block rects
// plus the collapsed inter-block margins (computed style, unscaled). The split counts
// blocks by the same measure the overflow test uses, or it leaves one block too many.
function flowSpans(children: Element[], scale: number): { start: number[]; end: number[] } {
const start: number[] = [];
const end: number[] = [];
let sum = 0;
let prevMb = 0;
for (let i = 0; i < children.length; i++) {
const cs = getComputedStyle(children[i]);
const mt = parseFloat(cs.marginTop) || 0;
const mb = parseFloat(cs.marginBottom) || 0;
sum += blockHeightPx(children[i], scale);
if (i > 0) sum += Math.max(prevMb, mt);
start.push(sum);
sum += blockHeightPx(children[i], scale);
end.push(sum);
prevMb = mb;
}
return sum;
return { start, end };
}
// Total content height of a fragment's blocks as poured into one column flow.
function contentHeightPx(children: Element[], scale: number): number {
const { end } = flowSpans(children, scale);
return end.length ? end[end.length - 1] : 0;
}
// The first rect of each rendered line, in document order — column 1's lines
@@ -274,18 +287,26 @@ export const ColumnsFlow = Extension.create({
return true;
}
// One reflow step: fix the first out-of-sync boundary (merge a stray
// joinPrev part, split an overflowing fragment, or pull a continuation
// back) and dispatch. Convergence comes from re-running until quiescent.
// One reflow pass: fix each out-of-sync boundary (merge a stray joinPrev part,
// split an overflowing fragment, or pull a continuation back) and dispatch.
// Convergence comes from re-running until quiescent.
function reflow(): boolean {
if (editorView.composing || !editorView.dom.isConnected) return false;
const frags = fragments();
if (!frags.length) return false;
const vm = readVerticalMargins(editorView.dom);
const scale = getScale();
let ops = 0;
let fixedAt = -1;
let i = 0;
let frags = fragments();
// An op changes the fragments; `again` keeps the index on the one it changed.
const done = (again: boolean) => { ops++; frags = fragments(); if (again) { fixedAt = i; i--; } };
for (let i = 0; i < frags.length; i++) {
pass: for (; i < frags.length && ops < MAX_FLOW_OPS; i++) {
// After an op, only its own fragment and chained continuations are judged: a
// continuation lands at a page top (a full page of room wherever it renders now),
// anything else still sits where the layout before the op had left it.
const continued = i > 0 && hasNextInChain(frags, i - 1);
if (ops && !continued && i !== fixedAt) break;
const { pos, node } = frags[i];
// A joinPrev paragraph inside a fragment (left over after a fragment
@@ -299,7 +320,8 @@ export const ColumnsFlow = Extension.create({
canJoin(editorView.state.doc, childPos)
) {
dispatchFlow(editorView.state.tr.join(childPos));
return true;
done(true);
continue pass;
}
childPos += child.nodeSize;
}
@@ -307,13 +329,16 @@ export const ColumnsFlow = Extension.create({
const el = editorView.nodeDOM(pos) as HTMLElement | null;
if (!el || !el.classList.contains('columns-node')) continue;
const top = topWithin(el);
const page = Math.floor(top / vm.cycle) + 1;
const contentStart = (page - 1) * vm.cycle + vm.top;
const contentEnd = contentStart + vm.contentHeight;
// Mid-move (pageBreaks hasn't repositioned it yet) — measure next pass.
if (top < contentStart - 0.5 || top >= contentEnd) continue;
const available = contentEnd - top;
let available = vm.contentHeight;
if (!continued) {
const top = topWithin(el);
const page = Math.floor(top / vm.cycle) + 1;
const contentStart = (page - 1) * vm.cycle + vm.top;
const contentEnd = contentStart + vm.contentHeight;
// Mid-move (pageBreaks hasn't repositioned it yet) — measure next pass.
if (top < contentStart - 0.5 || top >= contentEnd) continue;
available = contentEnd - top;
}
const count = Math.max(1, node.attrs.count as number);
const children = Array.from(el.children).filter(
(c) => !(c as HTMLElement).dataset?.pageBreakSpacer,
@@ -321,7 +346,8 @@ export const ColumnsFlow = Extension.create({
if (children.length !== node.childCount) continue;
// The rendered box height is our own decoration, so overflow and room
// are judged from the content itself (per-column share of the flow).
const usedPerColumn = contentHeightPx(children, scale) / count;
const spans = flowSpans(children, scale);
const usedPerColumn = (spans.end[spans.end.length - 1] ?? 0) / count;
// The estimate averages the flow over the columns, but each column ends on
// a whole line, so a fragment measuring exactly its slot still spills once
@@ -338,31 +364,30 @@ export const ColumnsFlow = Extension.create({
available >= vm.contentHeight - 1 &&
splitParagraphInBlock(frags[i], children, 0, count * (available - SAFETY_PX), scale, true)
) {
return true;
done(false);
}
continue;
}
if (node.childCount < 2) continue;
let sum = 0;
let k = 0;
for (let j = 0; j < children.length - 1; j++) {
const h = blockHeightPx(children[j], scale);
if ((sum + h) / count > available - SAFETY_PX && j > 0) break;
sum += h;
if (spans.end[j] / count > available - SAFETY_PX && j > 0) break;
k = j + 1;
}
// Leftover room after k whole blocks: fill it with the boundary
// block's first lines (line split) rather than leaving it empty.
const tailBudget = count * (available - SAFETY_PX) - sum;
const tailBudget = count * (available - SAFETY_PX) - spans.start[k];
if (k < node.childCount && splitParagraphInBlock(frags[i], children, k, tailBudget, scale, false)) {
return true;
done(false);
continue;
}
let boundary = pos + 1;
for (let j = 0; j < k; j++) boundary += node.child(j).nodeSize;
const typesAfter = [{ type: node.type, attrs: node.attrs }];
if (!canSplit(editorView.state.doc, boundary, 1, typesAfter)) continue;
dispatchFlow(editorView.state.tr.split(boundary, 1, typesAfter));
return true;
done(false);
continue;
}
// Fits: pull the adjacent continuation back when its head would
@@ -396,11 +421,12 @@ export const ColumnsFlow = Extension.create({
continue;
}
dispatchFlow(tr);
return true;
done(true);
continue;
}
}
}
return false;
return ops > 0;
}
// Sequential-fill heights: a fragment with a continuation spans its page slot;
@@ -416,12 +442,16 @@ export const ColumnsFlow = Extension.create({
const { pos, node } = frags[i];
const el = editorView.nodeDOM(pos) as HTMLElement | null;
if (!el || !el.classList.contains('columns-node')) continue;
const top = topWithin(el);
const page = Math.floor(top / vm.cycle) + 1;
const contentStart = (page - 1) * vm.cycle + vm.top;
const contentEnd = contentStart + vm.contentHeight;
if (top < contentStart - 0.5 || top >= contentEnd) continue;
const available = contentEnd - top;
// A chained continuation spans a full page wherever it renders right now.
let available = vm.contentHeight;
if (!(i > 0 && hasNextInChain(frags, i - 1))) {
const top = topWithin(el);
const page = Math.floor(top / vm.cycle) + 1;
const contentStart = (page - 1) * vm.cycle + vm.top;
const contentEnd = contentStart + vm.contentHeight;
if (top < contentStart - 0.5 || top >= contentEnd) continue;
available = contentEnd - top;
}
const count = Math.max(1, node.attrs.count as number);
const children = Array.from(el.children).filter(
(c) => !(c as HTMLElement).dataset?.pageBreakSpacer,
+94 -53
View File
@@ -1,8 +1,9 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { findBookmark } from './bookmark';
import { readVerticalMargins, pageOfElement } from './pageBreaks';
import type { EditorView } from '@tiptap/pm/view';
import { bookmarks, findBookmark, type BookmarkRef } from './bookmark';
import { readVerticalMargins, pageOfElement, type PageGrid } from './pageBreaks';
// A cross-reference: an inline atom showing either the text of a bookmark or the page it
// sits on, kept live by the node view the way the TOC keeps its page numbers. Round-trips
@@ -86,16 +87,91 @@ export const CrossReference = Node.create({
},
});
// Node view: repaints from the live bookmark on each pagination settle (pm-pagecount on
// the .paper ancestor) and on doc change, writing the resolved text back to the attr —
// guarded against a loop by comparing with what the node already holds.
// The reference's text now: the bookmark's own text, or the page it renders on. A
// bookmark that no longer exists leaves the cached text alone (Word shows its cached
// result too until the field is updated).
function resolveText(view: EditorView, node: PMNode, target: BookmarkRef | undefined, grid: () => PageGrid): string {
const cached = String(node.attrs.text ?? '');
if (!target) return cached;
if (node.attrs.format !== 'page') return target.text;
const at = view.domAtPos(target.from).node;
const el = (at.nodeType === 1 ? at : at.parentElement) as HTMLElement | null;
return el ? String(pageOfElement(view, el, grid())) : cached;
}
// Every reference of one editor resolves in one go, on each pagination settle
// (pm-pagecount) and on a change to any of them: one bookmark scan, every page read
// before any text is written, and one transaction for the texts that changed.
class CrossRefBatch {
private views = new Set<CrossRefView>();
private scheduled = false;
private listening = false;
constructor(private editor: Editor) {}
add(view: CrossRefView): void {
this.views.add(view);
this.schedule();
}
remove(view: CrossRefView): void {
this.views.delete(view);
}
schedule(): void {
if (this.scheduled) return;
this.scheduled = true;
requestAnimationFrame(() => {
this.scheduled = false;
this.run();
});
}
private run(): void {
const { editor } = this;
if (editor.isDestroyed) return;
const view = editor.view;
if (!this.listening) {
this.listening = true;
view.dom.addEventListener('pm-pagecount', () => this.schedule());
}
const targets = new Map<string, BookmarkRef>();
for (const b of bookmarks(editor.state.doc)) if (!targets.has(b.name)) targets.set(b.name, b);
let grid: PageGrid | null = null;
const gridOf = () => (grid ??= readVerticalMargins(view.dom as HTMLElement).grid);
const jobs: { ref: CrossRefView; node: PMNode; pos: number; text: string }[] = [];
for (const ref of this.views) {
const pos = ref.pos();
const node = pos === null ? null : editor.state.doc.nodeAt(pos);
if (pos === null || !node || node.type.name !== 'crossRef' || !ref.dom.isConnected) continue;
jobs.push({ ref, node, pos, text: resolveText(view, node, targets.get(String(node.attrs.name ?? '')), gridOf) });
}
for (const j of jobs) j.ref.paint(j.node, j.text);
const tr = editor.state.tr.setMeta('addToHistory', false);
let changed = false;
for (const j of jobs) {
if (j.text === j.node.attrs.text) continue;
tr.setNodeAttribute(j.pos, 'text', j.text);
changed = true;
}
if (changed) view.dispatch(tr);
}
}
const batches = new WeakMap<Editor, CrossRefBatch>();
function batchFor(editor: Editor): CrossRefBatch {
let batch = batches.get(editor);
if (!batch) batches.set(editor, (batch = new CrossRefBatch(editor)));
return batch;
}
// Node view: shows what the batch resolves; a modifier-click jumps to the bookmark.
class CrossRefView {
dom: HTMLElement;
private editor: Editor;
private getPos: () => number;
private scheduled = false;
private paper: HTMLElement | null = null;
private onPageCount = () => this.schedule();
private batch: CrossRefBatch;
constructor(editor: Editor, getPos: () => number) {
this.editor = editor;
@@ -112,64 +188,29 @@ class CrossRefView {
this.goTo();
});
requestAnimationFrame(() => {
this.paper = this.dom.closest('.paper') as HTMLElement | null;
this.paper?.addEventListener('pm-pagecount', this.onPageCount);
this.render();
});
this.batch = batchFor(editor);
this.batch.add(this);
}
private schedule(): void {
if (this.scheduled) return;
this.scheduled = true;
requestAnimationFrame(() => {
this.scheduled = false;
this.render();
});
pos(): number | null {
const pos = this.getPos();
return typeof pos === 'number' ? pos : null;
}
private node(): PMNode | null {
const pos = this.getPos();
const node = typeof pos === 'number' ? this.editor.state.doc.nodeAt(pos) : null;
const pos = this.pos();
const node = pos === null ? null : this.editor.state.doc.nodeAt(pos);
return node?.type.name === 'crossRef' ? node : null;
}
// The reference's text now: the bookmark's own text, or the page it renders on. A
// bookmark that no longer exists leaves the cached text alone (Word shows its cached
// result too until the field is updated).
private resolve(node: PMNode): string {
const found = findBookmark(this.editor.state.doc, String(node.attrs.name ?? ''));
if (!found) return String(node.attrs.text ?? '');
if (node.attrs.format !== 'page') return found.text;
const at = this.editor.view.domAtPos(found.from).node;
const el = (at.nodeType === 1 ? at : at.parentElement) as HTMLElement | null;
if (!el) return String(node.attrs.text ?? '');
const { grid } = readVerticalMargins(this.editor.view.dom as HTMLElement);
return String(pageOfElement(this.editor.view, el, grid));
}
// Mirror what renderHTML would emit, so the DOM reads the same with or without the view.
private paint(node: PMNode | null, text = String(node?.attrs?.text ?? '')): void {
paint(node: PMNode | null, text = String(node?.attrs?.text ?? '')): void {
this.dom.dataset.crossRef = String(node?.attrs?.name ?? '');
this.dom.dataset.format = node?.attrs?.format === 'page' ? 'page' : 'text';
this.dom.dataset.text = text;
this.dom.textContent = text;
}
private render(): void {
if (this.editor.isDestroyed || !this.dom.isConnected) return;
const node = this.node();
if (!node) return;
const text = this.resolve(node);
this.paint(node, text);
if (text === node.attrs.text) return;
const pos = this.getPos();
if (typeof pos !== 'number') return;
this.editor.view.dispatch(
this.editor.state.tr.setNodeAttribute(pos, 'text', text).setMeta('addToHistory', false),
);
}
// Scroll the bookmark into view and drop the cursor into it.
private goTo(): void {
const node = this.node();
@@ -183,7 +224,7 @@ class CrossRefView {
update(node: PMNode): boolean {
if (node.type.name !== 'crossRef') return false;
this.schedule();
this.batch.schedule();
return true;
}
@@ -199,6 +240,6 @@ class CrossRefView {
}
destroy(): void {
this.paper?.removeEventListener('pm-pagecount', this.onPageCount);
this.batch.remove(this);
}
}
+40 -5
View File
@@ -1,9 +1,8 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import { cssFontFamily, resolveStyle, type StyleSheet, type TextProps } from '../../styles/styleSheet';
import { FORCE_PAGE_RECALC } from './pageBreaks';
// A list marker's direct formatting. CSS values, since that is what two of the three
// consumers want; the DOCX exporter converts. The family is the plain name, not a CSS
@@ -90,6 +89,43 @@ export function listMarkerDecos(doc: ProseMirrorNode, charProps?: CharStyleProps
return DecorationSet.create(doc, decos);
}
// Set on the transaction Editor.svelte's stylesheet effect dispatches: an edited registry
// changes what a character or list style means, which no document change shows.
export const SHEET_CHANGED = 'sheetChanged';
const LIST_TYPES = new Set(['bulletList', 'orderedList', 'listItem']);
// Whether a transaction's changes reach into a list. A step is taken by its own range
// (`from`/`to` or `pos`, which is all a mark step has — its position map is empty) and
// by what its map moved, in the document before and after it. Anything else leaves every
// marker as it was, so the old decorations are mapped instead of rebuilt.
export function touchesList(tr: Transaction): boolean {
for (let i = 0; i < tr.steps.length; i++) {
const step = tr.steps[i] as unknown as { from?: number; to?: number; pos?: number };
const before = tr.docs[i];
const after = tr.docs[i + 1] ?? tr.doc;
if (typeof step.from === 'number' && inList(before, step.from, step.to ?? step.from)) return true;
if (typeof step.pos === 'number' && inList(before, step.pos, step.pos + 1)) return true;
let hit = false;
tr.steps[i].getMap().forEach((oldStart, oldEnd, newStart, newEnd) => {
hit ||= inList(before, oldStart, oldEnd) || inList(after, newStart, newEnd);
});
if (hit) return true;
}
return false;
}
// A list node in the range, or one around it — a marker's format is read from the item's
// first text portion, so a change inside an item counts as much as one to the list.
function inList(doc: ProseMirrorNode, from: number, to: number): boolean {
if (from > doc.content.size) return true; // out of this document: rebuild rather than miss
const $from = doc.resolve(from);
for (let d = $from.depth; d > 0; d--) if (LIST_TYPES.has($from.node(d).type.name)) return true;
let hit = false;
doc.nodesBetween(from, Math.min(to, doc.content.size), (node) => { hit ||= LIST_TYPES.has(node.type.name); return !hit; });
return hit;
}
function itemMarkerFormat(item: ProseMirrorNode, charProps?: CharStyleProps): MarkerFormat | null {
const block = item.firstChild;
const first = block?.firstChild;
@@ -132,10 +168,9 @@ export const ListMarker = Extension.create<{ sheet: () => StyleSheet }>({
key: listMarkerKey,
state: {
init: (_, state) => listMarkerDecos(state.doc, props()),
// An edited registry can change what a character style means, and that
// arrives as FORCE_PAGE_RECALC (Editor.svelte's stylesheet effect).
apply: (tr, old) =>
tr.docChanged || tr.getMeta(FORCE_PAGE_RECALC) ? listMarkerDecos(tr.doc, props()) : old,
tr.getMeta(SHEET_CHANGED) || (tr.docChanged && touchesList(tr)) ? listMarkerDecos(tr.doc, props())
: tr.docChanged ? old.map(tr.mapping, tr.doc) : old,
},
props: {
decorations(state) {
+3 -4
View File
@@ -6,7 +6,7 @@ import { childCycle, defaultOrderedTypeAt, ROOT_ORDERED_CYCLE, type OrderedCycle
import { effectiveListLevel } from '../../styles/listStyles';
import type { ListStyle as ListStyleDef } from '../../styles/listStyles';
import type { StyleSheet } from '../../styles/styleSheet';
import { FORCE_PAGE_RECALC } from './pageBreaks';
import { SHEET_CHANGED, touchesList } from './listMarker';
const EMPTY_SHEET: StyleSheet = { paragraph: {}, character: {}, table: {}, list: {} };
@@ -175,10 +175,9 @@ export const ListStyle = Extension.create<{ sheet: () => StyleSheet }>({
key: listStyleKey,
state: {
init: (_, state) => listStyleDecos(state.doc, sheet()),
// An edited registry changes what a list style means; that arrives as
// FORCE_PAGE_RECALC (Editor.svelte's stylesheet effect), as in listMarker.ts.
apply: (tr, old) =>
tr.docChanged || tr.getMeta(FORCE_PAGE_RECALC) ? listStyleDecos(tr.doc, sheet()) : old,
tr.getMeta(SHEET_CHANGED) || (tr.docChanged && touchesList(tr)) ? listStyleDecos(tr.doc, sheet())
: tr.docChanged ? old.map(tr.mapping, tr.doc) : old,
},
props: {
decorations(state) {
+102 -77
View File
@@ -291,90 +291,115 @@ function wrapZoneTabs(para: HTMLElement): HTMLElement[] {
return Array.from(para.querySelectorAll<HTMLElement>('span[data-zone-tab]'));
}
type ZoneJob = {
para: HTMLElement; tabs: HTMLElement[]; wrapping: string; align: string;
scale: number; originX: number; stops: TabStop[];
};
// An inactive header/footer zone is generateHTML output no ProseMirror plugin reaches,
// so its tabs are measured straight on the DOM — same rule, left to right, each advance
// applied before the next is read.
export function layOutZoneTabs(zone: HTMLElement): void {
const para = zone.querySelector<HTMLElement>('[data-tab-stops]');
if (!para || !parseTabStops(para.getAttribute('data-tab-stops')).length) return;
const tabs = wrapZoneTabs(para);
for (const t of tabs) {
t.className = '';
t.removeAttribute('data-leader');
t.style.cssText = 'tab-size:0';
// applied before the next is read. Zones go together: a round's reads before its writes.
export function layOutZoneTabs(zones: HTMLElement[]): void {
const jobs: ZoneJob[] = [];
for (const zone of zones) {
const para = zone.querySelector<HTMLElement>('[data-tab-stops]');
if (!para || !parseTabStops(para.getAttribute('data-tab-stops')).length) continue;
const tabs = wrapZoneTabs(para);
for (const t of tabs) {
t.className = '';
t.removeAttribute('data-leader');
t.style.cssText = 'tab-size:0';
}
// Measured with wrapping off: a segment that has already wrapped reads short by the
// space its break swallowed, so the advance computed from it keeps it wrapped — a
// footer stayed two lines over three pixels.
const wrapping = para.style.whiteSpace;
para.style.whiteSpace = 'pre';
// Laid out from the left, whatever the paragraph's alignment: LibreOffice positions
// the tabbed segments on the stops and only then shifts the whole line by what is
// left over (probed — a right-aligned head with two stops starts at the left edge).
const align = para.style.textAlign;
para.style.textAlign = 'left';
jobs.push({ para, tabs, wrapping, align, scale: 1, originX: 0, stops: [] });
}
// Measured with wrapping off: a segment that has already wrapped reads short by the
// space its break swallowed, so the advance computed from it keeps it wrapped — the
// Math Guide's footer stayed two lines over three pixels.
const wrapping = para.style.whiteSpace;
para.style.whiteSpace = 'pre';
// Laid out from the left, whatever the paragraph's alignment: LibreOffice positions
// the tabbed segments on the stops and only then shifts the whole line by what is
// left over (probed — a right-aligned head with two stops starts at the left edge,
// and moves right by exactly the gap when its last stop sits inside the column).
const align = para.style.textAlign;
para.style.textAlign = 'left';
const rect = para.getBoundingClientRect();
const scale = para.offsetWidth ? rect.width / para.offsetWidth : 1;
const cs = getComputedStyle(para);
const padLeft = parseFloat(cs.paddingLeft || '0');
// The line's own width, not clientWidth: that is rounded up to whole px, and half a pixel
// is enough to wrap the run a right-aligned stop puts at the end. One px of slack, since
// a run ending exactly on the boundary wraps and a pixel short of it shows on nothing.
const lineCm = (rect.width / (scale || 1) - padLeft - parseFloat(cs.paddingRight || '0') - 1) / PX_PER_CM;
const stops = clampStops(parseTabStops(para.getAttribute('data-tab-stops')), lineCm);
if (!scale) { para.style.whiteSpace = wrapping; para.style.textAlign = align; return; }
const originX = rect.left + padLeft * scale;
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i];
const xCm = (tab.getBoundingClientRect().left - originX) / scale / PX_PER_CM;
const stop = stops.find((s) => s.pos > xCm + 0.01);
if (!stop) continue;
let width = (stop.pos - xCm) * PX_PER_CM;
// A decimal stop takes the whole segment back, i.e. behaves as right — a zone is one
// paragraph of running text, where a separator to align on is not a case that arises.
if (stop.align !== 'left') {
const range = document.createRange();
range.setStartAfter(tab);
if (i + 1 < tabs.length) range.setEndBefore(tabs[i + 1]);
else range.setEnd(para, para.childNodes.length);
// The extent, not the sum: a range crossing inline elements yields a rect for the
// element's box as well as for the text inside it. Its own line only — a zone
// ending in a hard break has a rect on the next one, and that starts at the
// paragraph's left edge, which would inflate the segment by its own offset.
const segment = () => {
const rects = Array.from(range.getClientRects()).filter((r) => r.width);
if (!rects.length) return 0;
const top = Math.min(...rects.map((r) => r.top));
const own = rects.filter((r) => r.top < top + 1);
return Math.max(...own.map((r) => r.right)) - Math.min(...own.map((r) => r.left));
};
const take = (seg: number) => (stop.align === 'center' ? seg / 2 : seg) / scale;
width -= take(segment());
// A segment too long to reach the stop wraps, and what the stop aligns is the part
// that stays on the line — measured with the wrapping the zone really has, and
// from this tab at zero, so the break falls where the layout will put it.
if (width < 0) {
const own = tab.style.marginLeft;
tab.style.marginLeft = '0px';
para.style.whiteSpace = wrapping;
width = (stop.pos - xCm) * PX_PER_CM - take(segment());
para.style.whiteSpace = 'pre';
tab.style.marginLeft = own;
for (const job of jobs) {
const { para } = job;
const rect = para.getBoundingClientRect();
job.scale = para.offsetWidth ? rect.width / para.offsetWidth : 1;
const cs = getComputedStyle(para);
const padLeft = parseFloat(cs.paddingLeft || '0');
// The line's own width, not clientWidth: that is rounded up to whole px, and half a pixel
// is enough to wrap the run a right-aligned stop puts at the end. One px of slack, since
// a run ending exactly on the boundary wraps and a pixel short of it shows on nothing.
const lineCm = (rect.width / (job.scale || 1) - padLeft - parseFloat(cs.paddingRight || '0') - 1) / PX_PER_CM;
job.stops = clampStops(parseTabStops(para.getAttribute('data-tab-stops')), lineCm);
job.originX = rect.left + padLeft * job.scale;
}
for (let i = 0; jobs.some((j) => i < j.tabs.length); i++) {
const round: ZoneAdvance[] = [];
for (const job of jobs) {
if (i >= job.tabs.length || !job.scale) continue;
const advance = zoneTabAdvance(job, i);
if (advance) round.push(advance);
}
for (const { tab, width, leader } of round) {
tab.style.marginLeft = `${width}px`;
if (leader) {
tab.className = 'tab-leader';
tab.dataset.leader = leader;
tab.style.setProperty('--leader-w', `${width}px`);
}
}
width = Math.max(0, Math.round(width * 100) / 100);
tab.style.marginLeft = `${width}px`;
const leader = normalizeLeader(stop.leader);
if (leader) {
tab.className = 'tab-leader';
tab.dataset.leader = leader;
tab.style.setProperty('--leader-w', `${width}px`);
}
for (const { para, wrapping, align } of jobs) {
para.style.whiteSpace = wrapping;
para.style.textAlign = align;
}
}
type ZoneAdvance = { tab: HTMLElement; width: number; leader: ReturnType<typeof normalizeLeader> };
// One tab's advance to its stop, read off the layout of the moment (the tabs before it
// already carry theirs); null where no stop is left on the line.
function zoneTabAdvance(job: ZoneJob, i: number): ZoneAdvance | null {
const { para, tabs, stops, scale, originX, wrapping } = job;
const tab = tabs[i];
const xCm = (tab.getBoundingClientRect().left - originX) / scale / PX_PER_CM;
const stop = stops.find((s) => s.pos > xCm + 0.01);
if (!stop) return null;
let width = (stop.pos - xCm) * PX_PER_CM;
// A decimal stop takes the whole segment back, i.e. behaves as right — a zone is one
// paragraph of running text, where a separator to align on is not a case that arises.
if (stop.align !== 'left') {
const range = document.createRange();
range.setStartAfter(tab);
if (i + 1 < tabs.length) range.setEndBefore(tabs[i + 1]);
else range.setEnd(para, para.childNodes.length);
// The extent, not the sum: a range crossing inline elements yields a rect for the
// element's box as well as for the text inside it. Its own line only — a zone ending
// in a hard break has a rect on the next line, starting at the paragraph's left edge.
const segment = () => {
const rects = Array.from(range.getClientRects()).filter((r) => r.width);
if (!rects.length) return 0;
const top = Math.min(...rects.map((r) => r.top));
const own = rects.filter((r) => r.top < top + 1);
return Math.max(...own.map((r) => r.right)) - Math.min(...own.map((r) => r.left));
};
const take = (seg: number) => (stop.align === 'center' ? seg / 2 : seg) / scale;
width -= take(segment());
// A segment too long to reach the stop wraps, and what the stop aligns is the part
// that stays on the line — measured with the wrapping the zone really has, and
// from this tab at zero, so the break falls where the layout will put it.
if (width < 0) {
const own = tab.style.marginLeft;
tab.style.marginLeft = '0px';
para.style.whiteSpace = wrapping;
width = (stop.pos - xCm) * PX_PER_CM - take(segment());
para.style.whiteSpace = 'pre';
tab.style.marginLeft = own;
}
}
para.style.whiteSpace = wrapping;
para.style.textAlign = align;
return { tab, width: Math.max(0, Math.round(width * 100) / 100), leader: normalizeLeader(stop.leader) };
}
export const TabStops = Extension.create({
+28 -21
View File
@@ -207,6 +207,7 @@ class TocView {
private getPos: () => number;
private scheduled = false;
private lastKey = '';
private lastLook = '';
private wasPaginated = false;
private paper: HTMLElement | null = null;
private onPageCount = () => this.schedule();
@@ -326,6 +327,7 @@ class TocView {
// (leader, page numbers, title, level styles). Its cached entries are what syncAttr
// writes back, so keying on them too would chase this pass's own result.
const { entries: _cached, ...look } = this.node()?.attrs ?? {};
this.lastLook = JSON.stringify(look);
const key = JSON.stringify([entries, look]);
if (key !== this.lastKey) {
this.lastKey = key;
@@ -344,15 +346,15 @@ class TocView {
const view = this.editor.view;
const vm = readVerticalMargins(view.dom as HTMLElement);
for (const row of rows) row.style.marginTop = '';
// Read every natural top first: applying a gap moves each row below it, and one
// reflow for the whole index beats one per row.
const tops = rows.map(row => topInEditor(view, row));
// Read every natural top and height first: applying a gap moves each row below it,
// and one reflow for the whole index beats one per row.
const boxes = rows.map(row => [topInEditor(view, row), row.offsetHeight]);
let shift = 0;
let moved = false;
rows.forEach((row, i) => {
const top = tops[i] + shift;
const top = boxes[i][0] + shift;
const page = Math.max(1, Math.floor(top / vm.cycle) + 1);
if (top + row.offsetHeight <= (page - 1) * vm.cycle + vm.top + vm.contentHeight) return;
if (top + boxes[i][1] <= (page - 1) * vm.cycle + vm.top + vm.contentHeight) return;
const gap = page * vm.cycle + vm.top - top;
if (gap <= 0) return;
row.style.marginTop = `${gap}px`;
@@ -439,24 +441,27 @@ class TocView {
// As many leader dots as the gap holds. The row clips the rest on screen, but nothing
// else does: 200 of them reach the PDF, the clipboard and every measurement as text.
private fillLeaders(): void {
const range = document.createRange();
const leaders = Array.from(this.dom.querySelectorAll<HTMLElement>('.toc-leader')).filter(el => el.textContent);
// One dot's advance, probed once per font: the levels have styles of their own, and
// measuring level 1's dot for a smaller level 3 leaves its row short of the number.
const advances = new Map<string, number>();
for (const el of this.dom.querySelectorAll<HTMLElement>('.toc-leader')) {
const fill = el.textContent?.[0];
if (!fill) continue;
const keys = leaders.map((el) => {
const cs = getComputedStyle(el);
const key = `${fill}|${cs.fontSize}|${cs.fontFamily}|${cs.fontWeight}|${cs.fontStyle}`;
let one = advances.get(key);
if (one == null) {
el.textContent = fill.repeat(LEADER_PROBE);
range.selectNodeContents(el);
one = range.getBoundingClientRect().width / LEADER_PROBE;
advances.set(key, one);
}
el.textContent = one > 0 ? fill.repeat(Math.max(0, Math.floor(el.getBoundingClientRect().width / one))) : '';
}
return `${el.textContent![0]}|${cs.fontSize}|${cs.fontFamily}|${cs.fontWeight}|${cs.fontStyle}`;
});
// Every gap is read before any row is written: a write between two reads is a layout.
const widths = leaders.map(el => el.getBoundingClientRect().width);
const advances = new Map<string, number>();
const range = document.createRange();
leaders.forEach((el, i) => {
if (advances.has(keys[i])) return;
el.textContent = keys[i][0].repeat(LEADER_PROBE);
range.selectNodeContents(el);
advances.set(keys[i], range.getBoundingClientRect().width / LEADER_PROBE);
});
leaders.forEach((el, i) => {
const one = advances.get(keys[i]) ?? 0;
el.textContent = one > 0 ? keys[i][0].repeat(Math.max(0, Math.floor(widths[i] / one))) : '';
});
}
// Scroll the heading into view and drop the cursor into it.
@@ -483,7 +488,9 @@ class TocView {
update(node: PMNode): boolean {
if (node.type.name !== 'tableOfContents') return false;
this.schedule();
// The entries are what syncAttr just wrote back; only a changed look is news.
const { entries: _cached, ...look } = node.attrs;
if (JSON.stringify(look) !== this.lastLook) this.schedule();
return true;
}
+1 -1
View File
@@ -52,7 +52,7 @@ copy lands on the same document — the one case still shared. The app's own key
zoom, chrome, AutoCorrect, AutoText, word completion, recent files/fonts/chars, markup
view, user dictionary, number recognition) stay unscoped.
- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`); hiding the page (`visibilitychange`/`pagehide`) writes what is pending at once, and writes queue behind each other so a slow picture round trip never stores an older document over a newer one. Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor, or a reload cut its start short: the user is asked whether to try again; declined, it moves to `edentext-doc-broken` and the kept versions are offered before the app starts empty, so a freeze is escaped instead of repeated. **Pictures do not live in it:** `imageStore.ts` swaps every data-URI past 4 KB for an `idb:<key>` before the write and keeps the bytes in the `edentext-images` IndexedDB store (a handful of photos is the whole localStorage quota). The key is a content hash of the data-URI's head, tail and length — sampled, because this runs on every autosave — so a picture used twice is stored once, across documents too — which is why the store is the one thing **not** scoped per document. Each save sweeps the keys no stored document names any more, found by scanning localStorage for `idb:` references rather than by a per-document list, which would go stale for every document no tab has opened since. Where IndexedDB is unavailable the data-URIs stay inline, exactly as before; a key the store has lost keeps its `idb:` src and is reported, rather than quietly dropping the reader's content. `loadDocument` is therefore **async**, and `Editor.svelte` awaits it before building the editor.
- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` and serialized only when the write runs (`autosave.ts`); hiding the page (`visibilitychange`/`pagehide`) writes what is pending at once, and writes queue behind each other so a slow picture round trip never stores an older document over a newer one. Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor, or a reload cut its start short: the user is asked whether to try again; declined, it moves to `edentext-doc-broken` and the kept versions are offered before the app starts empty, so a freeze is escaped instead of repeated. **Pictures do not live in it:** `imageStore.ts` swaps every data-URI past 4 KB for an `idb:<key>` before the write and keeps the bytes in the `edentext-images` IndexedDB store (a handful of photos is the whole localStorage quota). The key is a content hash of the data-URI's head, tail and length — sampled, because this runs on every autosave — so a picture used twice is stored once, across documents too — which is why the store is the one thing **not** scoped per document. Each save sweeps the keys no stored document names any more, found by scanning localStorage for `idb:` references rather than by a per-document list, which would go stale for every document no tab has opened since. Where IndexedDB is unavailable the data-URIs stay inline, exactly as before; a key the store has lost keeps its `idb:` src and is reported, rather than quietly dropping the reader's content. `loadDocument` is therefore **async**, and `Editor.svelte` awaits it before building the editor.
- **Earlier versions:** the `edentext-snapshots` IndexedDB database (`snapshots.ts`, one per document via `docKey`, as `edentext-fonts` is) — up to three earlier autosaves, one taken at most every five minutes, keyed by their timestamp. They hold the document **whole**, pictures inline: the image store is swept against the stored documents, and a snapshot is none of them. **Nothing in the UI points at them**: they are offered only where the browser copy has failed the reader — `loadDocument`'s recovery question, newest first, one confirm each (the newest may be the one that broke). A version taken up starts under the boot flag like any other document, so one that freezes the editor again brings the question back.
- **Recent files:** `edentext-recent-files` — `{id, name, at}` per entry, newest first, eight kept (`recentFiles.ts`). Only the label lives here: the `FileSystemFileHandle` that reopens one is structured-cloneable but not JSON, so it sits in the `edentext-recent` IndexedDB store under the same id, and each write sweeps the handles no entry names. Two handles compare only through `isSameEntry`, which is async — hence the walk when recording a file already in the list. A file without a handle is never recorded (nothing could reopen it — no File System Access API, or a template), so the list simply stays empty in Firefox/Safari/Brave; a startup prune drops entries whose handle is gone. Reopening asks for `read` permission again (the API drops it on reload) — a declined prompt keeps the entry for the next try, only a moved or deleted file drops it (with a message). Save re-prompts for write on its own (Save-As fallback in `App.svelte`).
- **Document protected:** `edentext-doc-protected` — `'1'` while the open document is password-protected; the password itself is never stored, so after a reload the first save asks for it again or lets the protection go (`documentName.ts`).
+9 -7
View File
@@ -14,8 +14,9 @@ const BROKEN_KEY = docKey('edentext-doc-broken');
const DEBOUNCE_MS = 1000;
let timeout: ReturnType<typeof setTimeout> | null = null;
// The document as last handed in, until it is written.
let pending: object | null = null;
// Hands out the document as last handed in, until it is written. A function, so a
// long document is serialized once per write, not once per keystroke.
let pending: (() => object) | null = null;
// Writes queue behind each other: an older write finishing after a newer one would
// store the older document and sweep the pictures the newer one just added.
let chain: Promise<void> = Promise.resolve();
@@ -24,15 +25,16 @@ let chain: Promise<void> = Promise.resolve();
// debounced timer doesn't surface an unhandled error.
let quotaWarned = false;
export function saveDocument(json: object): void {
export function saveDocument(json: () => object): void {
pending = json;
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => { chain = chain.then(write); }, DEBOUNCE_MS);
}
async function write(): Promise<void> {
const json = pending;
if (!json) return;
const take = pending;
if (!take) return;
const json = take();
// Pictures go to IndexedDB and the JSON keeps a key; where that fails they stay
// inline, which is the only thing localStorage ever held.
const { json: slim, blobs } = stashImages(json);
@@ -43,7 +45,7 @@ async function write(): Promise<void> {
void keepSnapshot(json);
// Pending until stored, so a flush during the round trip still has it; a newer
// document handed in meanwhile stays pending for the write queued behind.
if (pending === json) pending = null;
if (pending === take) pending = null;
}
// The tab is going away: no time for the IndexedDB round trip, so the JSON goes out
@@ -51,7 +53,7 @@ async function write(): Promise<void> {
// Timer and pending stay, so a page revived from the back/forward cache writes again.
export function flushDocument(): void {
if (!pending) return;
const { json: slim, blobs } = stashImages(pending);
const { json: slim, blobs } = stashImages(pending());
void putImages(blobs);
store(slim);
}