@stll/folio-core 0.63.1 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -301,6 +301,12 @@ export type PainterPaintedDetail = {
301
301
  container: HTMLElement;
302
302
  pageCount: number;
303
303
  };
304
+ /**
305
+ * Paint `shell` and its buffer neighbours now, ahead of the observer, so a
306
+ * navigation can scroll straight onto painted content instead of passing blank
307
+ * shells. No-op for containers `renderPages` did not virtualize.
308
+ */
309
+ export declare function renderPagesAround(container: HTMLElement, shell: HTMLElement): void;
304
310
  /**
305
311
  * Force every virtualized page shell in `container` to be fully rendered.
306
312
  *
@@ -8,6 +8,7 @@ import { DEFAULT_FONT_FAMILY } from "../layout-engine/measure/measureHelpers.js"
8
8
  import { measureParagraph } from "../layout-engine/measure/measureParagraph.js";
9
9
  import { resolveTableInlineOffset } from "../layout-engine/measure/tableInlinePlacement.js";
10
10
  import { NOTE_SEPARATOR_RULE_THICKNESS, NOTE_SEPARATOR_WIDTH_FRACTION, floatingTextBoxReservesBand, floatingTextBoxWrapsText, isFloatingImageRun, isFloatingTextBoxBlock, isTextWrappingFloatingImageRun, stripFlowBlockPmAnchors } from "../layout-engine/types.js";
11
+ import { getEditorScrollRoot } from "../paged-layout/editorScrollRoot.js";
11
12
  import { cssBorderStyle } from "../utils/borderCss.js";
12
13
  import { resolveFontFamily } from "../utils/fontResolver.js";
13
14
  import { borderToStyle } from "../utils/formatToStyle.js";
@@ -1527,6 +1528,8 @@ function applyContainerStyles(container, pageGap) {
1527
1528
  */
1528
1529
  const VIRTUALIZATION_BUFFER = 1;
1529
1530
  const VIRTUALIZATION_ROOT_MARGIN_PX = 1e3;
1531
+ /** Painted pages stay painted within three viewport heights of the viewport. */
1532
+ const VIRTUALIZATION_RETAIN_MARGIN_PERCENT = 300;
1530
1533
  const INITIAL_EAGER_RENDER_PAGES = 3;
1531
1534
  /**
1532
1535
  * Minimum page count before virtualization kicks in.
@@ -1555,7 +1558,7 @@ function renderPages(pages, container, options = {}) {
1555
1558
  if (prevState && prevState.optionsHash === currentOptionsHash && useVirtualization) {
1556
1559
  const prevShells = prevState.pageStates;
1557
1560
  const prevDataMap = prevState.pageDataMap;
1558
- const observer = pc.__pageObserver;
1561
+ const observers = pc.__pageObservers;
1559
1562
  const totalPagesChanged = prevState.totalPages !== totalPages;
1560
1563
  const fieldContextChanged = prevState.fieldContextHash !== currentFieldContextHash;
1561
1564
  const commonCount = Math.min(prevShells.length, pages.length);
@@ -1619,13 +1622,16 @@ function renderPages(pages, container, options = {}) {
1619
1622
  index: i,
1620
1623
  rendered: false
1621
1624
  });
1622
- if (observer) observer.observe(pageEl);
1625
+ observers?.populate.observe(pageEl);
1626
+ observers?.retain.observe(pageEl);
1623
1627
  }
1624
1628
  }
1625
1629
  if (pages.length < prevShells.length) {
1626
1630
  for (let i = prevShells.length - 1; i >= pages.length; i--) {
1627
1631
  const shell = prevShells[i].element;
1628
- if (observer) observer.unobserve(shell);
1632
+ observers?.populate.unobserve(shell);
1633
+ observers?.retain.unobserve(shell);
1634
+ prevState.retainedShells.delete(shell);
1629
1635
  prevDataMap.delete(shell);
1630
1636
  removeRenderedPage(shell);
1631
1637
  }
@@ -1641,10 +1647,11 @@ function renderPages(pages, container, options = {}) {
1641
1647
  emitPainterPainted(container);
1642
1648
  return;
1643
1649
  }
1644
- const prevObserver = pc.__pageObserver;
1645
- if (prevObserver) {
1646
- prevObserver.disconnect();
1647
- delete pc.__pageObserver;
1650
+ const prevObservers = pc.__pageObservers;
1651
+ if (prevObservers) {
1652
+ prevObservers.populate.disconnect();
1653
+ prevObservers.retain.disconnect();
1654
+ delete pc.__pageObservers;
1648
1655
  }
1649
1656
  const pageShellClass = options.pageClassName ?? PAGE_CLASS_NAMES.page;
1650
1657
  const isPageShell = (el) => el.className.split(/\s+/u).includes(pageShellClass);
@@ -1683,52 +1690,42 @@ function renderPages(pages, container, options = {}) {
1683
1690
  index: i,
1684
1691
  rendered: false
1685
1692
  });
1686
- const observer = new IntersectionObserver((entries) => {
1693
+ const observerRoot = getEditorScrollRoot(container);
1694
+ const populate = new IntersectionObserver((entries) => {
1687
1695
  const renderState = pc.__pageRenderState;
1688
1696
  if (!renderState) return;
1689
- const { currentOptions: liveOptions, totalPages: liveTotalPages, pageDataMap: liveDataMap } = renderState;
1690
1697
  for (const entry of entries) {
1691
- if (!(entry.target instanceof HTMLElement)) continue;
1692
- const shell = entry.target;
1693
- const data = liveDataMap.get(shell);
1694
- if (!data) continue;
1695
- if (entry.isIntersecting) {
1696
- populatePageShell(shell, liveDataMap, liveTotalPages, liveOptions);
1697
- for (let offset = -1; offset <= VIRTUALIZATION_BUFFER; offset++) {
1698
- const neighborIdx = data.index + offset;
1699
- if (neighborIdx >= 0 && neighborIdx < renderState.pageStates.length && neighborIdx !== data.index) populatePageShell(renderState.pageStates[neighborIdx].element, liveDataMap, liveTotalPages, liveOptions);
1700
- }
1701
- }
1702
- }
1703
- const viewportHeight = window.innerHeight;
1704
- const nearThreshold = viewportHeight * 3;
1705
- const nearIndices = /* @__PURE__ */ new Set();
1706
- let depopulated = false;
1707
- for (const [el, data] of liveDataMap) {
1708
- if (!data.rendered) continue;
1709
- const rect = el.getBoundingClientRect();
1710
- if (rect.bottom > -nearThreshold && rect.top < viewportHeight + nearThreshold) nearIndices.add(data.index);
1711
- }
1712
- for (const [el, data] of liveDataMap) {
1713
- if (!data.rendered) continue;
1714
- let keepRendered = false;
1715
- for (const nearIdx of nearIndices) if (Math.abs(data.index - nearIdx) <= 2) {
1716
- keepRendered = true;
1717
- break;
1718
- }
1719
- if (!keepRendered && nearIndices.size > 0) {
1720
- depopulatePageShell(el, liveDataMap);
1721
- depopulated = true;
1722
- }
1698
+ if (!(entry.target instanceof HTMLElement) || !entry.isIntersecting) continue;
1699
+ const data = renderState.pageDataMap.get(entry.target);
1700
+ if (data) populatePageRange(renderState, data.index);
1723
1701
  }
1724
- if (depopulated) emitPainterPainted(container);
1725
1702
  }, {
1726
- root: null,
1703
+ root: observerRoot,
1727
1704
  rootMargin: `${VIRTUALIZATION_ROOT_MARGIN_PX}px 0px ${VIRTUALIZATION_ROOT_MARGIN_PX}px 0px`
1728
1705
  });
1729
- for (const shell of pageShells) observer.observe(shell);
1730
- pc.__pageObserver = observer;
1706
+ const retain = new IntersectionObserver((entries) => {
1707
+ const renderState = pc.__pageRenderState;
1708
+ if (!renderState) return;
1709
+ for (const entry of entries) {
1710
+ if (!(entry.target instanceof HTMLElement)) continue;
1711
+ if (entry.isIntersecting) renderState.retainedShells.add(entry.target);
1712
+ else renderState.retainedShells.delete(entry.target);
1713
+ }
1714
+ sweepUnretainedPages(container, renderState);
1715
+ }, {
1716
+ root: observerRoot,
1717
+ rootMargin: `${VIRTUALIZATION_RETAIN_MARGIN_PERCENT}% 0px ${VIRTUALIZATION_RETAIN_MARGIN_PERCENT}% 0px`
1718
+ });
1719
+ for (const shell of pageShells) {
1720
+ populate.observe(shell);
1721
+ retain.observe(shell);
1722
+ }
1723
+ pc.__pageObservers = {
1724
+ populate,
1725
+ retain
1726
+ };
1731
1727
  pc.__pageRenderState = {
1728
+ retainedShells: /* @__PURE__ */ new Set(),
1732
1729
  pageStates: pageShells.map((el) => ({
1733
1730
  element: el,
1734
1731
  fingerprint: null,
@@ -1775,6 +1772,45 @@ function populatePageShell(shell, pageDataMap, totalPages, options) {
1775
1772
  const container = shell.parentElement;
1776
1773
  if (container instanceof HTMLElement) emitPainterPainted(container);
1777
1774
  }
1775
+ /** Paint the page at `index` and its buffer neighbours. */
1776
+ function populatePageRange(state, index) {
1777
+ const first = Math.max(0, index - VIRTUALIZATION_BUFFER);
1778
+ const last = Math.min(state.pageStates.length - 1, index + VIRTUALIZATION_BUFFER);
1779
+ for (let i = first; i <= last; i++) populatePageShell(state.pageStates[i].element, state.pageDataMap, state.totalPages, state.currentOptions);
1780
+ }
1781
+ /**
1782
+ * Depopulate painted pages more than one page beyond the buffer of every
1783
+ * retained shell. Reads the retention observer's last report, so it forces no
1784
+ * layout.
1785
+ */
1786
+ function sweepUnretainedPages(container, state) {
1787
+ const retainedIndices = [];
1788
+ for (const shell of state.retainedShells) {
1789
+ const data = state.pageDataMap.get(shell);
1790
+ if (data) retainedIndices.push(data.index);
1791
+ }
1792
+ if (retainedIndices.length === 0) return;
1793
+ let depopulated = false;
1794
+ for (const [el, data] of state.pageDataMap) {
1795
+ if (!data.rendered) continue;
1796
+ if (!retainedIndices.some((retainedIndex) => Math.abs(data.index - retainedIndex) <= 2)) {
1797
+ depopulatePageShell(el, state.pageDataMap);
1798
+ depopulated = true;
1799
+ }
1800
+ }
1801
+ if (depopulated) emitPainterPainted(container);
1802
+ }
1803
+ /**
1804
+ * Paint `shell` and its buffer neighbours now, ahead of the observer, so a
1805
+ * navigation can scroll straight onto painted content instead of passing blank
1806
+ * shells. No-op for containers `renderPages` did not virtualize.
1807
+ */
1808
+ function renderPagesAround(container, shell) {
1809
+ const state = container.__pageRenderState;
1810
+ const data = state?.pageDataMap.get(shell);
1811
+ if (!state || !data) return;
1812
+ populatePageRange(state, data.index);
1813
+ }
1778
1814
  /**
1779
1815
  * Force every virtualized page shell in `container` to be fully rendered.
1780
1816
  *
@@ -1918,7 +1954,7 @@ function findPageShellForPmPos(container, pmPos) {
1918
1954
  if (fragment.pmEnd !== void 0 && fragment.pmEnd > pageEnd) pageEnd = fragment.pmEnd;
1919
1955
  }
1920
1956
  if (pageStart === Number.POSITIVE_INFINITY) continue;
1921
- if (pageStart <= pmPos && pmPos <= pageEnd) return {
1957
+ if (pageStart <= pmPos && pmPos < pageEnd) return {
1922
1958
  element: shell,
1923
1959
  isExact: true
1924
1960
  };
@@ -1947,4 +1983,4 @@ function depopulatePageShell(shell, pageDataMap) {
1947
1983
  }
1948
1984
  }
1949
1985
  //#endregion
1950
- export { PAGE_CLASS_NAMES, PAINTER_PAINTED_EVENT, applySectionHeaderFooterOptions, calculateFootnoteAreaRenderHeight, computePageFingerprint, emuToPixels, findPageShellForPmPos, floatingTableReservesBand, getDefaultPageFontFamily, isFloatingImageRun, renderAllPagesNow, renderFloatingImagesLayer, renderFootnoteArea, renderPage, renderPages, resolveAnchoredImagePosition, resolveHeaderFooterFloatLeft, resolveHeaderFooterIntrinsicFrameHorizontalPosition, selectSectionHeaderFooterRIds };
1986
+ export { PAGE_CLASS_NAMES, PAINTER_PAINTED_EVENT, applySectionHeaderFooterOptions, calculateFootnoteAreaRenderHeight, computePageFingerprint, emuToPixels, findPageShellForPmPos, floatingTableReservesBand, getDefaultPageFontFamily, isFloatingImageRun, renderAllPagesNow, renderFloatingImagesLayer, renderFootnoteArea, renderPage, renderPages, renderPagesAround, resolveAnchoredImagePosition, resolveHeaderFooterFloatLeft, resolveHeaderFooterIntrinsicFrameHorizontalPosition, selectSectionHeaderFooterRIds };
@@ -5,6 +5,10 @@ export declare const scrollEditorTo: (element: HTMLElement | null, options: Scro
5
5
  export declare const scrollEditorBy: (element: HTMLElement | null, delta: number) => void;
6
6
  type ScrollEditorElementOptions = {
7
7
  block?: "start" | "center" | "end" | "nearest";
8
+ /**
9
+ * Omitted: smooth (instant under `prefers-reduced-motion`) within two
10
+ * viewport heights, instant beyond.
11
+ */
8
12
  behavior?: ScrollBehavior;
9
13
  /** Space to leave between the target and the visible root edge. */
10
14
  margin?: number;
@@ -24,8 +24,15 @@ const nearestScrollOffset = ({ targetStart, targetEnd, viewportStart, viewportEn
24
24
  if (below && targetSize <= viewportSize || above && targetSize > viewportSize) return targetEnd - viewportEnd;
25
25
  return 0;
26
26
  };
27
+ /**
28
+ * Farthest move, in viewport heights, that the default behavior animates. A
29
+ * longer smooth scroll passes page shells that virtualization paints one by
30
+ * one before discarding them, and shows blank shells until it arrives.
31
+ */
32
+ const SMOOTH_SCROLL_MAX_VIEWPORTS = 2;
33
+ const navigationScrollBehavior = (distance, viewportHeight) => Math.abs(distance) > viewportHeight * SMOOTH_SCROLL_MAX_VIEWPORTS ? "instant" : prefersReducedMotionBehavior();
27
34
  /** Scroll only the marked root, including when the target is inside a scaled viewport. */
28
- const scrollEditorElementIntoView = (element, { block = "center", behavior = prefersReducedMotionBehavior(), margin = 0, rect } = {}) => {
35
+ const scrollEditorElementIntoView = (element, { block = "center", behavior, margin = 0, rect } = {}) => {
29
36
  const root = getEditorScrollRoot(element);
30
37
  if (!root || !element) return;
31
38
  const targetRect = rect ?? element.getBoundingClientRect();
@@ -62,10 +69,11 @@ const scrollEditorElementIntoView = (element, { block = "center", behavior = pre
62
69
  viewportEnd: visibleLeft + root.clientWidth
63
70
  });
64
71
  if (top === root.scrollTop && left === root.scrollLeft) return;
72
+ const clampedTop = Math.max(0, top);
65
73
  scrollEditorTo(root, {
66
- top: Math.max(0, top),
74
+ top: clampedTop,
67
75
  left: Math.max(0, left),
68
- behavior
76
+ behavior: behavior ?? navigationScrollBehavior(clampedTop - root.scrollTop, root.clientHeight)
69
77
  });
70
78
  };
71
79
  //#endregion
@@ -1,5 +1,5 @@
1
1
  import { findBodyPmAnchor, findBodyPmAnchors } from "../layout-bridge/dom/findBodyPmSpans.js";
2
- import { findPageShellForPmPos } from "../layout-painter/renderPage.js";
2
+ import { findPageShellForPmPos, renderPagesAround } from "../layout-painter/renderPage.js";
3
3
  import { getEditorScrollRoot, scrollEditorElementIntoView } from "./editorScrollRoot.js";
4
4
  import { isValidPmScrollPosition } from "./scrollNavigation.js";
5
5
  //#region src/paged-layout/scrollToPmPosition.ts
@@ -8,61 +8,51 @@ const PAGES_CONTAINER_CLASS = "paged-editor__pages";
8
8
  /** Scroll visible pages to a ProseMirror position. */
9
9
  const scrollPagesToPmPosition = (pageContainer, pmPos) => {
10
10
  if (!isValidPmScrollPosition(pmPos)) return;
11
- const exact = findBodyPmAnchor(pageContainer, pmPos);
12
- if (exact) {
13
- scrollEditorElementIntoView(exact);
14
- return;
15
- }
16
- let runMatch = null;
17
- for (const el of findBodyPmAnchors(pageContainer)) {
18
- const start = Number(el.dataset["pmStart"]);
19
- if (Number.isNaN(start)) continue;
20
- const endAttr = el.dataset["pmEnd"];
21
- const end = endAttr === void 0 ? start : Number(endAttr);
22
- if (start <= pmPos && pmPos <= end) {
23
- runMatch = el;
24
- break;
25
- }
26
- }
27
- if (runMatch) {
28
- scrollEditorElementIntoView(runMatch);
11
+ const rendered = findBodyPmAnchor(pageContainer, pmPos) ?? findAnchorContaining(pageContainer, pmPos);
12
+ if (rendered) {
13
+ scrollEditorElementIntoView(rendered);
29
14
  return;
30
15
  }
31
16
  const shellHit = findPageShellForPmPos(pageContainer, pmPos);
32
17
  if (!shellHit) return;
33
18
  const { element: shell } = shellHit;
34
- scrollEditorElementIntoView(shell);
35
- let attempts = 0;
36
- const refine = () => {
37
- attempts++;
38
- const exactInShell = findBodyPmAnchor(shell, pmPos);
39
- if (exactInShell) {
40
- scrollEditorElementIntoView(exactInShell);
41
- return;
42
- }
43
- let bestEl = null;
44
- let bestStart = Number.NEGATIVE_INFINITY;
45
- for (const el of findBodyPmAnchors(shell)) {
46
- const start = Number(el.dataset["pmStart"]);
47
- if (Number.isNaN(start)) continue;
48
- const endAttr = el.dataset["pmEnd"];
49
- const end = endAttr === void 0 ? start : Number(endAttr);
50
- if (start <= pmPos && pmPos <= end) {
51
- bestEl = el;
52
- break;
53
- }
54
- if (start <= pmPos && start > bestStart) {
55
- bestStart = start;
56
- bestEl = el;
57
- }
58
- }
59
- if (bestEl) {
60
- scrollEditorElementIntoView(bestEl);
61
- return;
62
- }
63
- if (attempts < 20) requestAnimationFrame(refine);
64
- };
65
- requestAnimationFrame(refine);
19
+ renderPagesAround(pageContainer, shell);
20
+ scrollEditorElementIntoView(findBodyPmAnchor(shell, pmPos) ?? findAnchorContaining(shell, pmPos) ?? findAnchorBefore(shell, pmPos) ?? shell);
21
+ };
22
+ const anchorRanges = (root) => {
23
+ const ranges = [];
24
+ for (const element of findBodyPmAnchors(root)) {
25
+ const start = Number(element.dataset["pmStart"]);
26
+ if (Number.isNaN(start)) continue;
27
+ const endAttr = element.dataset["pmEnd"];
28
+ ranges.push({
29
+ element,
30
+ start,
31
+ end: endAttr === void 0 ? start : Number(endAttr)
32
+ });
33
+ }
34
+ return ranges;
35
+ };
36
+ /**
37
+ * First painted run whose range holds `pmPos` (block positions rarely match a
38
+ * run start). Ranges are half-open, as page lookups are: a run ending where a
39
+ * block starts belongs before it, so the end of a painted page never stands in
40
+ * for the unpainted page that block opens. A run without an end holds only its
41
+ * start.
42
+ */
43
+ const findAnchorContaining = (root, pmPos) => {
44
+ for (const { element, start, end } of anchorRanges(root)) if (start <= pmPos && (pmPos < end || pmPos === start)) return element;
45
+ return null;
46
+ };
47
+ /** The painted run starting closest before `pmPos`. */
48
+ const findAnchorBefore = (root, pmPos) => {
49
+ let best = null;
50
+ let bestStart = Number.NEGATIVE_INFINITY;
51
+ for (const { element, start } of anchorRanges(root)) if (start <= pmPos && start > bestStart) {
52
+ bestStart = start;
53
+ best = element;
54
+ }
55
+ return best;
66
56
  };
67
57
  /**
68
58
  * Scroll the paged layout that mirrors `view` so `pmPos` is centered
@@ -5,6 +5,13 @@ import { Node } from "prosemirror-model";
5
5
  * Information about a heading found in the document.
6
6
  */
7
7
  export type HeadingInfo = {
8
+ /**
9
+ * Outline identity within this editing session: the paragraph's `paraId`,
10
+ * which the editor allocates for every body paragraph and which survives
11
+ * edits elsewhere in the document. A paragraph without one, or whose
12
+ * `paraId` an earlier heading already took, is identified by position.
13
+ */
14
+ id: string;
8
15
  /** The text content of the heading */
9
16
  text: string;
10
17
  /** Outline level (0 = Heading 1, 1 = Heading 2, etc.) */
@@ -1,6 +1,8 @@
1
1
  import { resolveHeadingLevel } from "../docx/builtInStyles.js";
2
2
  import { readOutlineLevelAttr } from "../prosemirror/outlineLevelAttr.js";
3
3
  //#region src/utils/headingCollector.ts
4
+ /** Not hex, so it cannot collide with a `paraId` Word or the editor writes. */
5
+ const POSITIONAL_HEADING_ID_PREFIX = "pos-";
4
6
  const RUN_IN_TITLE_MARKS = /* @__PURE__ */ new Set([
5
7
  "bold",
6
8
  "italic",
@@ -56,6 +58,7 @@ const headingText = (paragraph) => {
56
58
  */
57
59
  function collectHeadings(doc, styles) {
58
60
  const headings = [];
61
+ const takenIds = /* @__PURE__ */ new Set();
59
62
  doc.descendants((node, pos) => {
60
63
  if (node.type.name !== "paragraph") return;
61
64
  const level = resolveHeadingLevel({
@@ -64,7 +67,12 @@ function collectHeadings(doc, styles) {
64
67
  }, styles);
65
68
  if (level === void 0) return;
66
69
  const text = headingText(node);
67
- if (text.trim()) headings.push({
70
+ if (!text.trim()) return;
71
+ const paraId = node.attrs["paraId"];
72
+ const id = typeof paraId === "string" && paraId !== "" && !takenIds.has(paraId) ? paraId : `${POSITIONAL_HEADING_ID_PREFIX}${String(pos)}`;
73
+ takenIds.add(id);
74
+ headings.push({
75
+ id,
68
76
  text: text.trim(),
69
77
  level,
70
78
  pmPos: pos
@@ -0,0 +1,20 @@
1
+ import { HeadingInfo } from "./headingCollector.js";
2
+ import { Layout } from "../layout-engine/types.js";
3
+ //#region src/utils/outlineHeadings.d.ts
4
+ type ResolveOutlineHeadingsOptions = {
5
+ /** Headings collected from the document `layout` was computed from. */
6
+ collected: readonly HeadingInfo[];
7
+ layout: Layout | null;
8
+ previous: HeadingInfo[];
9
+ };
10
+ /**
11
+ * The outline's headings with page numbers from `layout`.
12
+ *
13
+ * A heading the layout does not place keeps the page number it last had under
14
+ * the same id, so the outline never shows a transient blank while a layout is
15
+ * pending. An unchanged entry is returned as its previous object and an
16
+ * unchanged list as the previous array, so consumers can skip work by identity.
17
+ */
18
+ export declare const resolveOutlineHeadings: ({ collected, layout, previous }: ResolveOutlineHeadingsOptions) => HeadingInfo[];
19
+ //#endregion
20
+ export {};
@@ -0,0 +1,43 @@
1
+ import { findPageIndexContainingPmPos } from "../layout-engine/pmPageIndex.js";
2
+ //#region src/utils/outlineHeadings.ts
3
+ const sameHeading = (a, b) => Object.values({
4
+ id: a.id === b.id,
5
+ text: a.text === b.text,
6
+ level: a.level === b.level,
7
+ pmPos: a.pmPos === b.pmPos,
8
+ pageNumber: (a.pageNumber ?? null) === (b.pageNumber ?? null)
9
+ }).every(Boolean);
10
+ const layoutPageNumber = (layout, pmPos) => {
11
+ const pageIndex = findPageIndexContainingPmPos(layout, pmPos);
12
+ return pageIndex === null ? null : layout.pages[pageIndex]?.number ?? null;
13
+ };
14
+ /**
15
+ * The outline's headings with page numbers from `layout`.
16
+ *
17
+ * A heading the layout does not place keeps the page number it last had under
18
+ * the same id, so the outline never shows a transient blank while a layout is
19
+ * pending. An unchanged entry is returned as its previous object and an
20
+ * unchanged list as the previous array, so consumers can skip work by identity.
21
+ */
22
+ const resolveOutlineHeadings = ({ collected, layout, previous }) => {
23
+ const previousById = new Map(previous.map((heading) => [heading.id, heading]));
24
+ let changed = collected.length !== previous.length;
25
+ const next = [];
26
+ for (const [index, heading] of collected.entries()) {
27
+ const before = previousById.get(heading.id);
28
+ const pageNumber = (layout && layoutPageNumber(layout, heading.pmPos)) ?? before?.pageNumber ?? null;
29
+ const resolved = {
30
+ id: heading.id,
31
+ text: heading.text,
32
+ level: heading.level,
33
+ pmPos: heading.pmPos,
34
+ pageNumber
35
+ };
36
+ const kept = before && sameHeading(before, resolved) ? before : resolved;
37
+ if (kept !== previous[index]) changed = true;
38
+ next.push(kept);
39
+ }
40
+ return changed ? next : previous;
41
+ };
42
+ //#endregion
43
+ export { resolveOutlineHeadings };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.63.1",
3
+ "version": "0.64.0",
4
4
  "description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
5
5
  "keywords": [
6
6
  "document-model",