perf: the document's fields answer a pass in one round
Every field resolving against the settled layout — each index's page numbers, every cross-reference — registers with scheduleFieldRound instead of taking a frame of its own. A round measures for all of them before any of them writes, since a write between two measurements lays the whole document out again, and their cached results ride one transaction: a dispatch of its own costs a view update over the document, 55 ms per index on a 460-page file. That file settles about 220 ms earlier, with every spacer, page number and reference exactly where it was on five documents.
This commit is contained in:
1 parent
50c7ec5236
commit
7e687b6ccb
5 files changed
+82
-44
No files matched your search
@@ -11,4 +11,4 @@ list markers, paragraph boxes, date/time fields and cross-references.
|
||||
A **run of tabs wider than the line** continues on the next one with the grid starting over, as LibreOffice lays it out; Chromium instead hangs the leftover tabs and starts the next line at the margin. `runBreaks` walks the run analytically from the pen *before* it — the one thing the fix can't move, so the answer is the same whether or not the break is already in place — and emits a `<br>` widget where the line runs out. Only runs of two or more are considered: reading a block's geometry forces a reflow, and doing it for every tab in a 48-page document slowed pagination enough to skew the parity harness.
|
||||
- **`listMarker.ts`** (`ListMarker`) — the marker's **family, weight, slant, size and color**, taken from the item's **first text portion** (`markerFormat`): a mark lives inside the item's paragraph and never reaches `::marker`, which only inherits the `li`'s own font, so a bold numbered heading would keep a light number. A **character style** on that portion is resolved through the registry (`charStyleProps`, handed in by the caller: the plugin from its `sheet` option, the exporters from their `exportSheet`); what a **named paragraph style** provides reaches the marker through `styleCss`'s `li:has(> …)` rule instead. First portion, not the whole line, is LibreOffice's own numbering rule (verified by probe: bold-then-plain gets a bold number, plain-then-bold does not; Word instead takes the paragraph mark). **Rendering**: a node decoration puts `--marker-family/-weight/-style/-size/-color` on **every** `listItem` — they inherit, so an item without its own format resets them to `initial` (the guaranteed-invalid value, so the `var(…, inherit)` in `editor.css` falls back; `inherit` would pull the parent item's value in), and `editor.css` has one rule reading them on the item block's `::before` — which is what **draws** the marker: `::marker` cannot be positioned, and Chromium sets it flush against the text, ~4mm right of the level's hanging indent (`LIST_HANGING_CM` 0.635cm, what both exports write). The pseudo is **floated** into that indent (`min-width` the indent, negative `margin-left`): in flow it grew every list line by a pixel, and absolutely positioned a label wider than the indent — `1.1.1.`, `viii.` — printed over the text instead of pushing it. Word and LibreOffice push too, on to the next tab stop; the float stops flush after the label. `list-style: none` drops only the marker box, so the `list-item` counter and `<ol start>` keep numbering, and `content: counter(list-item, …)`/`counters(list-item, '.')` per `data-eff-list-style` replaces `list-style-type`; a `bulletChar` rides `--bullet` (an `attr()` inside the pseudo would read the block, not the list). `export/pdf.ts` switches the pseudo off and injects the same marker as a real span, since html2canvas cannot paint counters. The bullet shim keeps the symbol font first and takes `--marker-family` as its fallback. The pass also re-runs on `FORCE_PAGE_RECALC`, so editing a character style repaints the markers. **Export**: both formats carry marker formatting **per level**, so `listMarkerFormat` returns one only when *every* item of the list agrees — ODF mints a **named** `style:family="text"` style **in styles.xml** and points the level definition at it via `text:style-name` (`collectListMarkerFormats`/`applyListMarkerFormats`) — LibreOffice ignores the reference when it resolves to an automatic style in content.xml (probed), DOCX writes the level's `w:rPr` (`markerRunProps` → `ILevelsOptions.style.run`). A **mixed** list gets no level format on purpose: LibreOffice then applies its first-portion rule and matches the editor, Word falls back to the paragraph mark (which only carries the size). Import needs nothing — the runs already carry the formatting. Nested lists that odf-kit restyles (`applyNestedListTypes`) and in-cell lists keep a plain marker. A character style **inside a list item** is handled by an ODF pre-pass (`bakeListCharStyles`) instead: odf-kit's list builder reads marks itself and knows nothing about `charStyle`, and `applyRuns` never sees a list paragraph, so the pre-pass does both of its jobs — bake the resolved props into direct marks (which is what gets the run a `<text:span>` at all) and prefix the `CST` sentinel, so `applyCharacterStyles` re-points that span at the named style like everywhere else. Formatting and name both round-trip. `tests/unit/list-marker.test.ts` covers the four LibreOffice cases, the reset, and both export legs. Both this set and `listStyle.ts`’s are rebuilt only for a transaction reaching into a list (`touchesList` — a step by its own `from`/`to` or `pos`, since a mark step’s position map is empty, and by what its map moved) or for the stylesheet’s own signal (`SHEET_CHANGED`, dispatched by `Editor.svelte`’s stylesheet effect); anything else maps the old decorations forward, so an edit anywhere in a long document no longer walks it whole. `tests/unit/list-marker-rebuild.test.ts` covers which transactions count. **Label alignment** (`markerAlign` on the list node, Word's `w:lvlJc`, ODF's `fo:text-align` on the level properties): `right` sets the label's *far* end against the hanging indent, so a wide number — the Roman numerals Word's built-in numberings use it for — grows into the margin instead of over the text. `tests/unit/marker-align.test.ts` round-trips it.
|
||||
- **`dateTimeField.ts`** (`DateTimeField`) — inline atom for an inserted date/time field (Word's "Insert Date and Time"). Attrs: `kind` (`'date'|'time'`), `format` (a catalog key), `fixed`, `value` (ISO local datetime captured at insert). A **fixed** field renders its stored `value`; an **auto** field renders the *current* moment on every load (`renderHTML`/`renderText` via `dateTimeFieldText`) — no plugin, Word/LibreOffice likewise only refresh on open. As an inline atom with no children it can't inherit the surrounding run's font from a sibling span, so it **carries that run's marks** on the node: insert adopts the cursor's stored marks, and both importers attach the field run's resolved marks (else it falls back to the editor default font). Command `insertDateTimeField({kind,format,fixed})`. Formats live in `utils/dateTime.ts` as a **token model** (one `Token[]` per format) so the three consumers stay in agreement: `renderFormat` (locale display via `Intl`), `odfNumberStyle` (`<number:date-style>`/`<number:time-style>` body), `docxPicture` (Word field switch). `DateTimePicker.svelte` (a dropdown next to `SpecialCharPicker` in `ToolbarExpanded.svelte`) lists live samples + an "update automatically" checkbox. Not in the header/footer schema (there a `<text:date>`/DATE field stays plain text). **ODF**: `replaceDateTimeFields`/`applyDateTimeFields` (sentinel U+E00A, mirrors images) emit `<text:date text:date-value text:fixed style:data-style-name>`/`<text:time text:time-value …>` + a minted per-format number style (language from the export doc language; `ensureNumberNamespace` adds `xmlns:number`); import matches the referenced number style back to a format via `parseNumberStyleTokens` + `matchFormat` (unknown ⇒ keep the shown text). **DOCX**: auto → `SimpleField` `DATE`/`TIME \@ "picture"` with a cached run; fixed → a plain text run (Word has no fixed-date field); import maps a `DATE`/`TIME` `fldSimple`/complex field's picture back to a format (`dateTimeFieldFromInstr`).
|
||||
- **`bookmark.ts`** (`Bookmark`) + **`crossReference.ts`** (`CrossReference`) — a named range of text and the references pointing at it. A bookmark is a **mark**, not a point node: both formats store a range, and only a range lets a `REF` recompute its text. It carries one `name` (`data-bookmark`), is `inclusive: false`, and names are sanitized to Word's rules on insert (`sanitizeBookmarkName`: `[A-Za-z0-9_.]`, no leading digit, 40 chars) — which also keeps the ODF sentinel pass free of XML escaping. A mark type excludes itself, so **overlapping ranges collapse to the outermost** and both importers keep the first. Word's `_GoBack` and every point bookmark (ODF `<text:bookmark/>`, a zero-length Word range) are dropped — there is no range to mark. `bookmarks(doc)`/`findBookmark`/`bookmarkNames` are the shared lookups (dialogs, node view, `Editor.svelte`'s click handler). A cross-reference is an inline atom with `name`, `format` (`'text'|'page'`) and `text`, the last being the **cached display** exactly as a Word field caches its result. Every reference of an editor resolves in one batch (`CrossRefBatch`) on each `pm-pagecount` and on a change to any of them — one bookmark scan, every page read before any text is written, one transaction for what changed; the bookmark's own text, or its page via `pageOfElement`/`readVerticalMargins` (both from `pageBreaks.ts`, shared with the TOC) — and **leaves the cached text alone when the target is gone**, which is what a reference to a deleted bookmark shows. Ctrl/Cmd+click on a reference or on an internal `#name` link jumps to the bookmark and selects it. Not in the header/footer schema. **ODF**: `replaceBookmarks` splices the `BMS`/`BME` sentinels (U+E013/U+E014) into the run text and swaps each `crossRef` for an `XRF` run (U+E015) — splicing into the text, not into a run emitter, is what makes them ride every odf-kit path, cells and custom paragraphs included; `applyBookmarks` rewrites all three to `<text:bookmark-start/>`, `<text:bookmark-end/>` and `<text:bookmark-ref text:reference-format>`. **DOCX**: the `docx` package covers both natively — `Bookmark` around the marked runs, `SimpleField` `REF`/`PAGEREF … \h` with the cached result, `InternalHyperlink` for a `#name` href. A range spanning paragraphs is emitted per paragraph (Word tolerates the repeated name). Import tracks open ranges in `Ctx` across both the block and the inline walk (Word writes a `w:bookmarkStart` beside paragraphs as well as beside runs) and reads `REF`/`PAGEREF` out of the `fldChar` state machine and `w:fldSimple` alike, including a field Word never resolved (no `w:separate`, so no cached result — the node view fills it in).
|
||||
- **`bookmark.ts`** (`Bookmark`) + **`crossReference.ts`** (`CrossReference`) — a named range of text and the references pointing at it. A bookmark is a **mark**, not a point node: both formats store a range, and only a range lets a `REF` recompute its text. It carries one `name` (`data-bookmark`), is `inclusive: false`, and names are sanitized to Word's rules on insert (`sanitizeBookmarkName`: `[A-Za-z0-9_.]`, no leading digit, 40 chars) — which also keeps the ODF sentinel pass free of XML escaping. A mark type excludes itself, so **overlapping ranges collapse to the outermost** and both importers keep the first. Word's `_GoBack` and every point bookmark (ODF `<text:bookmark/>`, a zero-length Word range) are dropped — there is no range to mark. `bookmarks(doc)`/`findBookmark`/`bookmarkNames` are the shared lookups (dialogs, node view, `Editor.svelte`'s click handler). A cross-reference is an inline atom with `name`, `format` (`'text'|'page'`) and `text`, the last being the **cached display** exactly as a Word field caches its result. Every reference of an editor resolves in one batch (`CrossRefBatch`) on each `pm-pagecount` and on a change to any of them — one bookmark scan, and every page read in the shared field round's measuring phase (`scheduleFieldRound`, `docs/architecture/pagination.md`), before any text of the round is written and on the round's own transaction; the bookmark's own text, or its page via `pageOfElement`/`readVerticalMargins` (both from `pageBreaks.ts`, shared with the TOC) — and **leaves the cached text alone when the target is gone**, which is what a reference to a deleted bookmark shows. Ctrl/Cmd+click on a reference or on an internal `#name` link jumps to the bookmark and selects it. Not in the header/footer schema. **ODF**: `replaceBookmarks` splices the `BMS`/`BME` sentinels (U+E013/U+E014) into the run text and swaps each `crossRef` for an `XRF` run (U+E015) — splicing into the text, not into a run emitter, is what makes them ride every odf-kit path, cells and custom paragraphs included; `applyBookmarks` rewrites all three to `<text:bookmark-start/>`, `<text:bookmark-end/>` and `<text:bookmark-ref text:reference-format>`. **DOCX**: the `docx` package covers both natively — `Bookmark` around the marked runs, `SimpleField` `REF`/`PAGEREF … \h` with the cached result, `InternalHyperlink` for a `#name` href. A range spanning paragraphs is emitted per paragraph (Word tolerates the repeated name). Import tracks open ranges in `Ctx` across both the block and the inline walk (Word writes a `w:bookmarkStart` beside paragraphs as well as beside runs) and reads `REF`/`PAGEREF` out of the `fldChar` state machine and `w:fldSimple` alike, including a field Word never resolved (no `w:separate`, so no cached result — the node view fills it in).
|
||||
@@ -146,6 +146,16 @@ cross-reference its number) and writes back what it found, which is a document c
|
||||
is another pass: a pass that lands what the last one did would set the round going again.
|
||||
Opening a 460-page document ran five rounds of that where four answer everything.
|
||||
|
||||
**The fields of one answer read together.** Every field that resolves against the settled
|
||||
layout — each index's page numbers, every cross-reference — registers with
|
||||
`scheduleFieldRound(view, owner, read)` (`pageBreaks.ts`) instead of scheduling a frame of
|
||||
its own. The round runs every reader's measurement first and only then their writes, since
|
||||
a write between two measurements makes the browser lay the whole document out again; a
|
||||
write may return one more phase (the index reads back where its rows now fall), run after
|
||||
every other write. Their cached results — the index `entries`, a reference's `text` — ride
|
||||
**one** transaction, a dispatch of its own costing a view update over the whole document:
|
||||
that alone was 55 ms per index on a 460-page file.
|
||||
|
||||
**Layout constants** (must stay in sync between `pageBreaks.ts`, `Editor.svelte`, and `editor.css`):
|
||||
- `PAGE_HEIGHT = 1123px` (A4 portrait), `PAGE_GAP = 20px`, `CYCLE = PAGE_HEIGHT + PAGE_GAP = 1143px`.
|
||||
- Page height/width and margins are read **live** from CSS custom properties (`--user-page-height`, `--user-page-width`, `--user-margin-*`) so orientation/margin changes don't require new constants. `getCycle()` in `Editor.svelte` reads `--user-page-height` at runtime.
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Editor } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import type { EditorView } from '@tiptap/pm/view';
|
||||
import { bookmarks, findBookmark, type BookmarkRef } from './bookmark';
|
||||
import { readVerticalMargins, pageOfElement, type PageGrid } from './pageBreaks';
|
||||
import { readVerticalMargins, pageOfElement, scheduleFieldRound, type FieldWrite, 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
|
||||
@@ -100,11 +100,10 @@ function resolveText(view: EditorView, node: PMNode, target: BookmarkRef | undef
|
||||
}
|
||||
|
||||
// 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.
|
||||
// (pm-pagecount) and on a change to any of them: one bookmark scan, and every page read
|
||||
// in the field round's measuring phase, before any text of the round is written.
|
||||
class CrossRefBatch {
|
||||
private views = new Set<CrossRefView>();
|
||||
private scheduled = false;
|
||||
private listening = false;
|
||||
|
||||
constructor(private editor: Editor) {}
|
||||
@@ -119,15 +118,11 @@ class CrossRefBatch {
|
||||
}
|
||||
|
||||
schedule(): void {
|
||||
if (this.scheduled) return;
|
||||
this.scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.scheduled = false;
|
||||
this.run();
|
||||
});
|
||||
if (this.editor.isDestroyed) return;
|
||||
scheduleFieldRound(this.editor.view, this, () => this.measure());
|
||||
}
|
||||
|
||||
private run(): void {
|
||||
private measure(): FieldWrite | void {
|
||||
const { editor } = this;
|
||||
if (editor.isDestroyed) return;
|
||||
const view = editor.view;
|
||||
@@ -146,15 +141,12 @@ class CrossRefBatch {
|
||||
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);
|
||||
return (tr) => {
|
||||
for (const j of jobs) j.ref.paint(j.node, j.text);
|
||||
for (const j of jobs) {
|
||||
if (j.text !== j.node.attrs.text) tr.setNodeAttribute(j.pos, 'text', j.text);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -329,6 +329,41 @@ export const pageBreakKey = new PluginKey<PageBreakState>('pageBreaks');
|
||||
// pagination recompute even though the document content is unchanged.
|
||||
export const FORCE_PAGE_RECALC = 'forcePageBreakRecalc';
|
||||
|
||||
// A field that reads the settled layout — an index's page numbers, a cross-reference's.
|
||||
// It measures, and what it returns writes: its own DOM, and its cached result on the
|
||||
// round's transaction. A write may return one more phase, run after every other write.
|
||||
export type FieldWrite = (tr: Transaction) => FieldWrite | void;
|
||||
export type FieldRead = () => FieldWrite | void;
|
||||
|
||||
type FieldRound = { jobs: Map<object, FieldRead>; raf: number | null };
|
||||
const fieldRounds = new WeakMap<EditorView, FieldRound>();
|
||||
|
||||
// Every field of one editor answers a pagination pass together: all measure before any
|
||||
// writes, or each write lays the whole document out again for the next reader — and one
|
||||
// transaction carries the results, a dispatch costing a view update over the document.
|
||||
export function scheduleFieldRound(view: EditorView, owner: object, read: FieldRead): void {
|
||||
let round = fieldRounds.get(view);
|
||||
if (!round) fieldRounds.set(view, (round = { jobs: new Map(), raf: null }));
|
||||
round.jobs.set(owner, read);
|
||||
if (round.raf === null) round.raf = requestAnimationFrame(() => runFieldRound(view));
|
||||
}
|
||||
|
||||
function runFieldRound(view: EditorView): void {
|
||||
const round = fieldRounds.get(view);
|
||||
if (!round) return;
|
||||
round.raf = null;
|
||||
let phase = Array.from(round.jobs.values(), (read) => read());
|
||||
round.jobs.clear();
|
||||
const tr = view.state.tr.setMeta('addToHistory', false);
|
||||
// Bounded: measure, write, and the one read-back a write can ask for.
|
||||
for (let i = 0; i < 2 && phase.some(Boolean); i++) {
|
||||
phase = phase.map((write) => (write ? write(tr) : undefined));
|
||||
}
|
||||
// A field that only moved its own rows carries no document change, and still needs the
|
||||
// pass to measure the height it just changed.
|
||||
if (tr.docChanged || tr.getMeta(FORCE_PAGE_RECALC)) view.dispatch(tr);
|
||||
}
|
||||
|
||||
// A split view's second pane (Editor.svelte marks its host). Both panes are the same
|
||||
// width, so it renders the decorations the first pane's pass produced; a layout pass of
|
||||
// its own would measure the same DOM twice and fight over the shared result.
|
||||
|
||||
@@ -9,7 +9,8 @@ import { seqCategoryOf, sequenceFieldText, type SeqCategory } from './caption';
|
||||
import { indexEntries, indexRows } from './indexEntry';
|
||||
import { bibliographyEntries, bibliographyRows } from './bibliographyEntry';
|
||||
import { isCitationStyle, type CitationStyle } from '../../utils/citationStyle';
|
||||
import { readVerticalMargins, pageOfElement, topInEditor, FORCE_PAGE_RECALC, type PageGrid } from './pageBreaks';
|
||||
import type { Transaction } from '@tiptap/pm/state';
|
||||
import { readVerticalMargins, pageOfElement, topInEditor, scheduleFieldRound, FORCE_PAGE_RECALC, type FieldWrite, type PageGrid } from './pageBreaks';
|
||||
|
||||
// A generated index: a block atom listing every source with its live page number — the
|
||||
// headings for a table of contents, the captions of one category for a list of figures
|
||||
@@ -199,13 +200,13 @@ function inCellOrItem(doc: PMNode, pos: number): boolean {
|
||||
}
|
||||
|
||||
// Node view: renders the title + one clickable row per heading. Recomputes on each
|
||||
// pagination settle (pm-pagecount, caught on the .paper ancestor) and on doc change,
|
||||
// writing entries back to the node attr — guarded by a serialized key against a loop.
|
||||
// pagination settle (pm-pagecount, caught on the .paper ancestor) and on doc change, in
|
||||
// the field round: it measures with the other fields, and writes its entries back on the
|
||||
// round's transaction — guarded by a serialized key against a loop.
|
||||
class TocView {
|
||||
dom: HTMLElement;
|
||||
private editor: Editor;
|
||||
private getPos: () => number;
|
||||
private scheduled = false;
|
||||
private lastKey = '';
|
||||
private lastLook = '';
|
||||
private wasPaginated = false;
|
||||
@@ -225,17 +226,13 @@ class TocView {
|
||||
requestAnimationFrame(() => {
|
||||
this.paper = this.dom.closest('.paper') as HTMLElement | null;
|
||||
this.paper?.addEventListener('pm-pagecount', this.onPageCount);
|
||||
this.render();
|
||||
this.schedule();
|
||||
});
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (this.scheduled) return;
|
||||
this.scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.scheduled = false;
|
||||
this.render();
|
||||
});
|
||||
if (this.editor.isDestroyed) return;
|
||||
scheduleFieldRound(this.editor.view, this, () => this.measure());
|
||||
}
|
||||
|
||||
// What the index lists, in document order: the headings down to its own level, or —
|
||||
@@ -308,7 +305,7 @@ class TocView {
|
||||
return pageOfElement(this.editor.view, el, grid);
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
private measure(): FieldWrite | void {
|
||||
if (this.editor.isDestroyed || !this.dom.isConnected) return;
|
||||
const grid = this.grid();
|
||||
let heads = this.sources();
|
||||
@@ -329,18 +326,23 @@ class TocView {
|
||||
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;
|
||||
this.paint(entries, heads);
|
||||
this.syncAttr(entries);
|
||||
}
|
||||
this.paginate();
|
||||
const stale = key !== this.lastKey;
|
||||
this.lastKey = key;
|
||||
return (tr) => {
|
||||
if (stale) {
|
||||
this.paint(entries, heads);
|
||||
this.syncAttr(entries, tr);
|
||||
}
|
||||
// The rows carry their page numbers now, so where they fall is read once every
|
||||
// field of the round has written.
|
||||
return (last) => this.paginate(last);
|
||||
};
|
||||
}
|
||||
|
||||
// The index is a block atom: it has no inner document positions for pagination to put
|
||||
// a spacer at, so one longer than a page breaks itself — the row that would cross the
|
||||
// boundary takes the gap to the next page's content top as its margin.
|
||||
private paginate(): void {
|
||||
private paginate(tr: Transaction): void {
|
||||
const rows = Array.from(this.dom.querySelectorAll<HTMLElement>('.toc-entry'));
|
||||
if (!rows.length) return;
|
||||
const view = this.editor.view;
|
||||
@@ -364,7 +366,7 @@ class TocView {
|
||||
// The index just changed height, and pagination measured the old one.
|
||||
if (moved !== this.wasPaginated) {
|
||||
this.wasPaginated = moved;
|
||||
view.dispatch(view.state.tr.setMeta('addToHistory', false).setMeta(FORCE_PAGE_RECALC, true));
|
||||
tr.setMeta(FORCE_PAGE_RECALC, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,14 +478,13 @@ class TocView {
|
||||
return typeof pos === 'number' ? this.editor.state.doc.nodeAt(pos) : null;
|
||||
}
|
||||
|
||||
private syncAttr(entries: TocEntry[]): void {
|
||||
private syncAttr(entries: TocEntry[], tr: Transaction): void {
|
||||
const pos = this.getPos();
|
||||
if (typeof pos !== 'number') return;
|
||||
const node = this.editor.state.doc.nodeAt(pos);
|
||||
if (!node || node.type.name !== 'tableOfContents') return;
|
||||
if (JSON.stringify(node.attrs.entries ?? []) === JSON.stringify(entries)) return;
|
||||
const tr = this.editor.state.tr.setNodeAttribute(pos, 'entries', entries).setMeta('addToHistory', false);
|
||||
this.editor.view.dispatch(tr);
|
||||
tr.setNodeAttribute(pos, 'entries', entries);
|
||||
}
|
||||
|
||||
update(node: PMNode): boolean {
|
||||
|
||||
Reference in new issue
Block a user