@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.
- package/dist/layout-painter/renderPage.d.ts +6 -0
- package/dist/layout-painter/renderPage.js +83 -47
- package/dist/paged-layout/editorScrollRoot.d.ts +4 -0
- package/dist/paged-layout/editorScrollRoot.js +11 -3
- package/dist/paged-layout/scrollToPmPosition.js +41 -51
- package/dist/utils/headingCollector.d.ts +7 -0
- package/dist/utils/headingCollector.js +9 -1
- package/dist/utils/outlineHeadings.d.ts +20 -0
- package/dist/utils/outlineHeadings.js +43 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1645
|
-
if (
|
|
1646
|
-
|
|
1647
|
-
|
|
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
|
|
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
|
|
1693
|
-
|
|
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:
|
|
1703
|
+
root: observerRoot,
|
|
1727
1704
|
rootMargin: `${VIRTUALIZATION_ROOT_MARGIN_PX}px 0px ${VIRTUALIZATION_ROOT_MARGIN_PX}px 0px`
|
|
1728
1705
|
});
|
|
1729
|
-
|
|
1730
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
12
|
-
if (
|
|
13
|
-
scrollEditorElementIntoView(
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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())
|
|
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.
|
|
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",
|