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:
1 parent
3af5a00fed
commit
da857fcbb8
16 files changed
+478
-230
No files matched your search
+10
-7
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user