View ▸ Split (Ctrl+Alt+S): a second live view of the editor's state, stacked below the first and editable the same way. The panes share a width, so the second renders the pagination the first measures — pageBreaks, columnsFlow and tabStops skip the pass on a split pane rather than run it twice. Every widget decoration now builds its DOM in a function: two views render one decoration set, and a shared node is taken back and forth between the two documents until the tab locks up (measured on a two-page document).
490 lines
21 KiB
TypeScript
490 lines
21 KiB
TypeScript
import { Extension, type CommandProps } from '@tiptap/core';
|
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
|
|
import type { EditorState } from '@tiptap/pm/state';
|
|
import type { Node as PmNode } from '@tiptap/pm/model';
|
|
import { FORCE_PAGE_RECALC, isSplitPane, pageBreakKey } from './pageBreaks';
|
|
import { PX_PER_CM } from '../../storage/pageMargins';
|
|
|
|
// Per-paragraph tab stops. CSS only has the fixed `tab-size` grid, so a tab that
|
|
// resolves to a stop is measured and given the exact advance as an inline margin.
|
|
|
|
export type TabAlign = 'left' | 'center' | 'right' | 'decimal';
|
|
export type TabStop = { pos: number; align: TabAlign; leader?: string | null };
|
|
|
|
const CODE: Record<TabAlign, string> = { left: 'l', center: 'c', right: 'r', decimal: 'd' };
|
|
const ALIGN: Record<string, TabAlign> = { l: 'left', c: 'center', r: 'right', d: 'decimal' };
|
|
|
|
// The fill characters a stop may repeat across its gap. Anything else a file names is
|
|
// dropped rather than approximated, so the gap simply stays blank.
|
|
export const LEADER_CHARS = ['.', '-', '_', '·'] as const;
|
|
|
|
export function normalizeLeader(ch: unknown): string | null {
|
|
return typeof ch === 'string' && (LEADER_CHARS as readonly string[]).includes(ch) ? ch : null;
|
|
}
|
|
|
|
// Canonical attr form: one '<cm><align code><leader?>' per stop, ';'-separated
|
|
// ('6c;12r.;16d'). Positions are cm from the left text margin — Word's origin and ODF's.
|
|
export function parseTabStops(value: unknown): TabStop[] {
|
|
if (typeof value !== 'string' || !value) return [];
|
|
const out: TabStop[] = [];
|
|
for (const part of value.split(';')) {
|
|
const m = /^(-?\d*\.?\d+)([lcrd])(\S)?$/.exec(part.trim());
|
|
if (m) out.push({ pos: parseFloat(m[1]), align: ALIGN[m[2]], leader: normalizeLeader(m[3]) });
|
|
}
|
|
return out.sort((a, b) => a.pos - b.pos);
|
|
}
|
|
|
|
export function formatTabStops(stops: TabStop[]): string | null {
|
|
const byPos = new Map<number, TabStop>();
|
|
for (const s of stops) {
|
|
const pos = Math.round(s.pos * 100) / 100;
|
|
if (pos >= 0) byPos.set(pos, { ...s, pos });
|
|
}
|
|
const out = [...byPos.values()].sort((a, b) => a.pos - b.pos)
|
|
.map((s) => `${s.pos}${CODE[s.align]}${normalizeLeader(s.leader) ?? ''}`);
|
|
return out.length ? out.join(';') : null;
|
|
}
|
|
|
|
export type BlockRuler = { stops: TabStop[]; indent: number; indentRight: number; indentFirst: number };
|
|
|
|
// The stops of the block holding the cursor, plus the block's own indents (cm) —
|
|
// everything the ruler needs to draw and edit one paragraph.
|
|
export function activeTabStops(state: EditorState): BlockRuler | null {
|
|
const $from = state.selection.$from;
|
|
for (let d = $from.depth; d >= 0; d--) {
|
|
const node = $from.node(d);
|
|
if (!node.isTextblock) continue;
|
|
const cm = (v: unknown) => (typeof v === 'number' ? v : 0);
|
|
return {
|
|
stops: parseTabStops(node.attrs.tabStops),
|
|
indent: cm(node.attrs.indent),
|
|
indentRight: cm(node.attrs.indentRight),
|
|
indentFirst: cm(node.attrs.indentFirst),
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
declare module '@tiptap/core' {
|
|
interface Commands<ReturnType> {
|
|
tabStops: {
|
|
setTabStops: (stops: TabStop[]) => ReturnType;
|
|
};
|
|
}
|
|
}
|
|
|
|
const tabStopsKey = new PluginKey<DecorationSet>('tabStops');
|
|
|
|
// A stop past the end of the line is drawn at the end of the line, as LibreOffice does:
|
|
// the Math Guide's footer style puts its right stop at 18cm in a 17cm column, and honoured
|
|
// literally that hangs the page number outside the page — or wraps the footer.
|
|
function clampStops(stops: TabStop[], widthCm: number): TabStop[] {
|
|
if (!(widthCm > 0)) return stops;
|
|
return stops.map((s) => (s.pos > widthCm ? { ...s, pos: widthCm } : s));
|
|
}
|
|
|
|
// A tab's advance is decided per line, so a block only needs measuring when it has
|
|
// stops of its own; a hanging indent implies one at the text position.
|
|
function stopsOf(node: PmNode): TabStop[] {
|
|
const stops = parseTabStops(node.attrs.tabStops);
|
|
const first = typeof node.attrs.indentFirst === 'number' ? node.attrs.indentFirst : 0;
|
|
if (first < 0) {
|
|
const indent = typeof node.attrs.indent === 'number' ? node.attrs.indent : 0;
|
|
stops.push({ pos: indent, align: 'left' });
|
|
stops.sort((a, b) => a.pos - b.pos);
|
|
}
|
|
return stops;
|
|
}
|
|
|
|
// Doc positions of every tab character in a textblock.
|
|
function tabPositions(node: PmNode, blockPos: number): number[] {
|
|
const out: number[] = [];
|
|
node.forEach((child, offset) => {
|
|
const text = child.isText ? child.text ?? '' : '';
|
|
for (let i = text.indexOf('\t'); i >= 0; i = text.indexOf('\t', i + 1)) {
|
|
out.push(blockPos + 1 + offset + i);
|
|
}
|
|
});
|
|
return out;
|
|
}
|
|
|
|
type TabWidth = { pos: number; width: number; leader: string | null };
|
|
// Doc positions where a run of tabs has to start a new line.
|
|
type TabLayout = { widths: TabWidth[]; breaks: number[] };
|
|
|
|
// Where the pen lands after a tab standing at x (cm from the line start): the first stop
|
|
// right of it, else the next multiple of the default interval.
|
|
export function nextStopCm(x: number, stops: number[], interval: number): number {
|
|
const custom = stops.find((s) => s > x + 0.01);
|
|
if (custom != null) return custom;
|
|
return Math.floor(x / interval + 1e-9) * interval + interval;
|
|
}
|
|
|
|
// A run of tabs wider than the line continues on the next one, as LibreOffice lays it out
|
|
// (Chromium hangs the leftover tabs instead). Walked from the pen BEFORE the run — the one
|
|
// thing a break of ours can't move — so the answer holds with the break already in place.
|
|
function runBreaks(view: EditorView, blockEl: HTMLElement, tabs: number[], stops: TabStop[], scale: number, originX: number): number[] {
|
|
// Only a run of two or more can outgrow a line, and reading the block's geometry
|
|
// forces a reflow — so the ordinary single tab costs nothing here.
|
|
const runs: [number, number][] = [];
|
|
for (let i = 0; i < tabs.length; ) {
|
|
let end = i + 1;
|
|
while (end < tabs.length && tabs[end] === tabs[end - 1] + 1) end++;
|
|
if (end - i > 1) runs.push([i, end]);
|
|
i = end;
|
|
}
|
|
if (!runs.length) return [];
|
|
|
|
const cs = getComputedStyle(blockEl);
|
|
const interval = (parseFloat(cs.tabSize) || PX_PER_CM * 1.25) / PX_PER_CM;
|
|
const lineCm = (blockEl.clientWidth - parseFloat(cs.paddingLeft || '0') - parseFloat(cs.paddingRight || '0')) / PX_PER_CM;
|
|
if (!(interval > 0) || !(lineCm > 0)) return [];
|
|
// Stops are measured from the text margin, the grid from the line start.
|
|
const offsetCm = (blockEl.getBoundingClientRect().left - originX) / scale / PX_PER_CM
|
|
+ parseFloat(cs.paddingLeft || '0') / PX_PER_CM;
|
|
const stopCms = stops.map((s) => s.pos - offsetCm);
|
|
const out: number[] = [];
|
|
|
|
for (const [i, end] of runs) {
|
|
let x = (view.coordsAtPos(tabs[i], -1).left - originX) / scale / PX_PER_CM - offsetCm;
|
|
for (let j = i; j < end; j++) {
|
|
let next = nextStopCm(x, stopCms, interval);
|
|
// Never before the run's own first tab: that pen is what the walk starts from, so
|
|
// moving it would make the next pass decide differently.
|
|
if (next > lineCm + 0.01 && j > i) {
|
|
out.push(tabs[j]);
|
|
x = 0;
|
|
next = nextStopCm(0, stopCms, interval);
|
|
}
|
|
x = next;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function measure(view: EditorView): TabLayout {
|
|
const dom = view.dom as HTMLElement;
|
|
const tipRect = dom.getBoundingClientRect();
|
|
// From the width, not the height: offsetWidth/Height are rounded, and a one-line
|
|
// header/footer zone is short enough for that rounding to be half a percent.
|
|
const scale = dom.offsetWidth ? tipRect.width / dom.offsetWidth : 1;
|
|
if (!scale) return { widths: [], breaks: [] };
|
|
// x = 0 is the left text margin (.tiptap's padding edge), the origin of a stop.
|
|
const originX = tipRect.left + parseFloat(getComputedStyle(dom).paddingLeft || '0') * scale;
|
|
const out: TabWidth[] = [];
|
|
const breaks: number[] = [];
|
|
|
|
view.state.doc.descendants((node, pos) => {
|
|
if (!node.isTextblock) return true;
|
|
const tabs = tabPositions(node, pos);
|
|
if (!tabs.length) return false;
|
|
const blockEl = view.nodeDOM(pos);
|
|
const stops = blockEl instanceof HTMLElement
|
|
? clampStops(stopsOf(node), (blockEl.getBoundingClientRect().right - originX) / scale / PX_PER_CM - 0.03)
|
|
: stopsOf(node);
|
|
if (blockEl instanceof HTMLElement) breaks.push(...runBreaks(view, blockEl, tabs, stops, scale, originX));
|
|
if (!stops.length) return false;
|
|
const blockEnd = pos + node.nodeSize - 1;
|
|
|
|
// Where the pen stands (cm from the text margin) after the tab before this one.
|
|
// Carrying it along the line is what makes one pass self-consistent: read from the DOM
|
|
// instead and each tab measures the advance the previous pass gave the one before it.
|
|
let pen: number | null = null;
|
|
let lineTop = NaN;
|
|
for (let t = 0; t < tabs.length; t++) {
|
|
const tabPos = tabs[t];
|
|
// Side -1 measures at the end of the content BEFORE the tab. A new line — a hard
|
|
// break, or a wrap — starts the pen over at what the DOM shows there.
|
|
const start = view.coordsAtPos(tabPos, -1);
|
|
if (pen == null || Math.abs(start.top - lineTop) > 1) pen = (start.left - originX) / scale / PX_PER_CM;
|
|
lineTop = start.top;
|
|
// Custom stops replace the default grid to their left; past the last one the CSS
|
|
// grid already does the right thing, so that tab stays undecorated — and the pen
|
|
// has to be read off the DOM again at the next one.
|
|
const stop = stops.find((s) => s.pos > (pen as number) + 0.01);
|
|
if (!stop) { pen = null; continue; }
|
|
let width = (stop.pos - pen) * PX_PER_CM;
|
|
|
|
const segEnd = t + 1 < tabs.length ? tabs[t + 1] : blockEnd;
|
|
const segCm = segEnd > tabPos + 1 ? rangeWidth(view, tabPos + 1, segEnd) / scale / PX_PER_CM : 0;
|
|
if (stop.align !== 'left') {
|
|
const alignEnd = stop.align === 'decimal'
|
|
? decimalPos(node, pos, tabPos + 1, segEnd) ?? segEnd
|
|
: segEnd;
|
|
let back = alignEnd > tabPos + 1 ? rangeWidth(view, tabPos + 1, alignEnd) / scale : 0;
|
|
if (stop.align === 'center') back /= 2;
|
|
width -= back;
|
|
}
|
|
out.push({ pos: tabPos, width: Math.max(0, Math.round(width * 100) / 100), leader: normalizeLeader(stop.leader) });
|
|
// The segment sits behind a left stop, astride a centred one and ahead of the rest.
|
|
pen = stop.align === 'left' ? stop.pos + segCm : stop.align === 'center' ? stop.pos + segCm / 2 : stop.pos;
|
|
}
|
|
return false;
|
|
});
|
|
return { widths: out, breaks };
|
|
}
|
|
|
|
// Natural width of a doc range: the extent of each line it covers, added up — so a segment
|
|
// pushed onto the next line still reads its own width. Not the sum of the rects: one comes
|
|
// from an inline element's box and one from the text in it, counting that text twice.
|
|
function rangeWidth(view: EditorView, from: number, to: number): number {
|
|
// Biased outwards, or the range swallows the tab's own span at either end.
|
|
const a = view.domAtPos(from, 1);
|
|
const b = view.domAtPos(to, -1);
|
|
const range = document.createRange();
|
|
range.setStart(a.node, a.offset);
|
|
range.setEnd(b.node, b.offset);
|
|
const rows = new Map<number, { left: number; right: number }>();
|
|
for (const rect of Array.from(range.getClientRects())) {
|
|
if (!rect.width) continue;
|
|
const key = Math.round(rect.top);
|
|
const row = rows.get(key);
|
|
if (row) { row.left = Math.min(row.left, rect.left); row.right = Math.max(row.right, rect.right); }
|
|
else rows.set(key, { left: rect.left, right: rect.right });
|
|
}
|
|
let width = 0;
|
|
for (const row of rows.values()) width += row.right - row.left;
|
|
return width;
|
|
}
|
|
|
|
// Position of the segment's decimal separator ('.' or ',' — the attr carries no
|
|
// style:char, so both locales' separators are accepted).
|
|
function decimalPos(node: PmNode, blockPos: number, from: number, to: number): number | null {
|
|
let found: number | null = null;
|
|
node.forEach((child, offset) => {
|
|
if (found != null || !child.isText) return;
|
|
const base = blockPos + 1 + offset;
|
|
const text = child.text ?? '';
|
|
for (let i = 0; i < text.length; i++) {
|
|
const at = base + i;
|
|
if (at < from || at >= to) continue;
|
|
if (text[i] === '.' || text[i] === ',') { found = at; return; }
|
|
}
|
|
});
|
|
return found;
|
|
}
|
|
|
|
// A tab's own span, so it can carry an advance. Idempotent: the pass re-runs on every
|
|
// zoom or content change.
|
|
function wrapZoneTabs(para: HTMLElement): HTMLElement[] {
|
|
const walker = document.createTreeWalker(para, NodeFilter.SHOW_TEXT);
|
|
const found: Text[] = [];
|
|
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
// hasAttribute, not dataset: the marker's value is '', which reads as falsy — and a
|
|
// tab wrapped again on every pass nests a span per pass, each with its own advance.
|
|
if ((n.textContent ?? '').includes('\t') && !n.parentElement?.hasAttribute('data-zone-tab')) found.push(n as Text);
|
|
}
|
|
for (const text of found) {
|
|
let node: Text | null = text;
|
|
while (node) {
|
|
const at: number = (node.textContent ?? '').indexOf('\t');
|
|
if (at < 0) break;
|
|
const tab: Text = at ? node.splitText(at) : node;
|
|
node = tab.length > 1 ? tab.splitText(1) : null;
|
|
const span = document.createElement('span');
|
|
span.dataset.zoneTab = '';
|
|
tab.replaceWith(span);
|
|
span.append(tab);
|
|
}
|
|
}
|
|
return Array.from(para.querySelectorAll<HTMLElement>('span[data-zone-tab]'));
|
|
}
|
|
|
|
// 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';
|
|
}
|
|
// 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';
|
|
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; 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.
|
|
const boxes = Array.from(range.getClientRects()).filter((r) => r.width);
|
|
const seg = boxes.length
|
|
? Math.max(...boxes.map((r) => r.right)) - Math.min(...boxes.map((r) => r.left))
|
|
: 0;
|
|
width -= (stop.align === 'center' ? seg / 2 : seg) / scale;
|
|
}
|
|
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`);
|
|
}
|
|
}
|
|
para.style.whiteSpace = wrapping;
|
|
}
|
|
|
|
export const TabStops = Extension.create({
|
|
name: 'tabStops',
|
|
|
|
addOptions() {
|
|
return { types: ['paragraph', 'heading'] as string[] };
|
|
},
|
|
|
|
addGlobalAttributes() {
|
|
return [
|
|
{
|
|
types: this.options.types,
|
|
attributes: {
|
|
tabStops: {
|
|
default: null,
|
|
parseHTML: (element: HTMLElement) => element.getAttribute('data-tab-stops') || null,
|
|
renderHTML: (attributes: Record<string, unknown>) =>
|
|
(attributes.tabStops ? { 'data-tab-stops': String(attributes.tabStops) } : {}),
|
|
},
|
|
},
|
|
},
|
|
];
|
|
},
|
|
|
|
addCommands() {
|
|
const types = this.options.types as string[];
|
|
return {
|
|
setTabStops: (stops: TabStop[]) => ({ state, tr, dispatch }: CommandProps) => {
|
|
const { from, to } = state.selection;
|
|
const value = formatTabStops(stops);
|
|
let changed = false;
|
|
state.doc.nodesBetween(from, to, (node, pos) => {
|
|
if (!types.includes(node.type.name)) return;
|
|
if ((node.attrs.tabStops ?? null) === value) return;
|
|
tr.setNodeMarkup(pos, undefined, { ...node.attrs, tabStops: value });
|
|
changed = true;
|
|
});
|
|
if (changed && dispatch) dispatch(tr);
|
|
return changed;
|
|
},
|
|
};
|
|
},
|
|
|
|
addProseMirrorPlugins() {
|
|
let rafId: number | null = null;
|
|
let key = '';
|
|
// Each pass measures the layout the previous one produced, so a tab whose x moved
|
|
// needs one more pass to settle (a stop can also rewrap the line). Bounded against
|
|
// a two-layout ping-pong; reset per external change.
|
|
let passes = 0;
|
|
const MAX_PASSES = 6;
|
|
// Layout changes (margins, orientation, zoom, styles) arrive as FORCE_PAGE_RECALC;
|
|
// counted here because a transaction is gone by the time `update` runs. Our own
|
|
// dispatch returns above the counter, so it can't re-trigger itself.
|
|
let forced = 0;
|
|
let seenForced = 0;
|
|
|
|
return [
|
|
new Plugin<DecorationSet>({
|
|
key: tabStopsKey,
|
|
state: {
|
|
init: () => DecorationSet.empty,
|
|
apply(tr, old) {
|
|
const next = tr.getMeta(tabStopsKey) as DecorationSet | undefined;
|
|
if (next) return next;
|
|
// A pagination placement change moves lines, so the advances measured before
|
|
// it are stale — and pagination settles over many frames on a long document,
|
|
// long after two passes of ours have agreed on the layout of the moment.
|
|
if (tr.getMeta(FORCE_PAGE_RECALC) || tr.getMeta(pageBreakKey)) forced++;
|
|
return old.map(tr.mapping, tr.doc);
|
|
},
|
|
},
|
|
props: {
|
|
decorations(state) {
|
|
return tabStopsKey.getState(state);
|
|
},
|
|
},
|
|
view(view) {
|
|
if (isSplitPane(view)) return {};
|
|
const calculate = () => {
|
|
rafId = null;
|
|
let layout: TabLayout = { widths: [], breaks: [] };
|
|
// coordsAtPos throws on a position the browser hasn't rendered yet; the
|
|
// next change re-runs the pass anyway.
|
|
try { layout = measure(view); } catch { return; }
|
|
const { widths, breaks } = layout;
|
|
const next = widths.map((w) => `${w.pos}:${w.width}:${w.leader ?? ''}`).join(',') + `|${breaks.join(',')}`;
|
|
if (next === key) return;
|
|
key = next;
|
|
const decos: Decoration[] = widths.map((w) =>
|
|
// margin-LEFT: the gap is the tab's own advance, so a caret placed after
|
|
// the tab has to sit behind it. As margin-right it stayed at the old x
|
|
// until the next keystroke moved it into the following text node.
|
|
Decoration.inline(w.pos, w.pos + 1, w.leader
|
|
// The fill is a ::before clipped to the gap (editor.css), so the leader
|
|
// stays out of the document's text.
|
|
? { style: `tab-size:0;margin-left:${w.width}px;--leader-w:${w.width}px`, class: 'tab-leader', 'data-leader': w.leader }
|
|
: { style: `tab-size:0;margin-left:${w.width}px` }),
|
|
);
|
|
// The tabs the line can't hold move to the next one, where the grid starts
|
|
// over — which is what CSS does after a <br> anyway.
|
|
for (const at of breaks) {
|
|
decos.push(Decoration.widget(at, () => document.createElement('br'), { side: -1, key: 'tab-wrap' }));
|
|
}
|
|
// The advances change line breaking, so pagination has to re-measure.
|
|
view.dispatch(view.state.tr
|
|
.setMeta(tabStopsKey, DecorationSet.create(view.state.doc, decos))
|
|
.setMeta(FORCE_PAGE_RECALC, true)
|
|
.setMeta('addToHistory', false));
|
|
if (passes < MAX_PASSES) { passes++; schedule(); }
|
|
};
|
|
|
|
const schedule = () => {
|
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
rafId = requestAnimationFrame(calculate);
|
|
};
|
|
|
|
schedule();
|
|
|
|
return {
|
|
update(_v, prev) {
|
|
if (prev.doc === view.state.doc && forced === seenForced) return;
|
|
seenForced = forced;
|
|
passes = 0;
|
|
schedule();
|
|
},
|
|
destroy() {
|
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
},
|
|
};
|
|
},
|
|
}),
|
|
];
|
|
},
|
|
});
|