@lotics/docx 0.1.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/package.json +40 -0
- package/src/fixtures/.gitkeep +0 -0
- package/src/fixtures/lotics_generated_contract.docx +0 -0
- package/src/fonts/bundled.ts +123 -0
- package/src/fonts/registry.test.ts +233 -0
- package/src/fonts/registry.ts +219 -0
- package/src/fonts/types.ts +83 -0
- package/src/index.ts +16 -0
- package/src/layout/engine.test.ts +430 -0
- package/src/layout/engine.ts +566 -0
- package/src/layout/page_geometry.ts +43 -0
- package/src/layout/types.ts +159 -0
- package/src/load.test.ts +144 -0
- package/src/load.ts +142 -0
- package/src/model/default_numbering.ts +101 -0
- package/src/model/default_styles.ts +201 -0
- package/src/model/numbering_table.ts +52 -0
- package/src/model/properties.ts +328 -0
- package/src/model/sections.ts +94 -0
- package/src/model/style_resolution.test.ts +219 -0
- package/src/model/style_resolution.ts +113 -0
- package/src/model/style_table.ts +22 -0
- package/src/model/theme.ts +156 -0
- package/src/model/types.ts +55 -0
- package/src/parse/drawing.ts +157 -0
- package/src/parse/font_table.ts +132 -0
- package/src/parse/footnotes.ts +60 -0
- package/src/parse/header_footer.test.ts +264 -0
- package/src/parse/header_footer.ts +66 -0
- package/src/parse/numbering.ts +187 -0
- package/src/parse/parser.ts +184 -0
- package/src/parse/relationships.ts +83 -0
- package/src/parse/sections.test.ts +192 -0
- package/src/parse/sections.ts +182 -0
- package/src/parse/styles.ts +149 -0
- package/src/parse/theme.test.ts +86 -0
- package/src/parse/theme.ts +112 -0
- package/src/pm/bubble_menu.ts +117 -0
- package/src/pm/commands.test.ts +185 -0
- package/src/pm/commands.ts +697 -0
- package/src/pm/commands_insert.test.ts +183 -0
- package/src/pm/docx_to_pm.test.ts +330 -0
- package/src/pm/docx_to_pm.ts +643 -0
- package/src/pm/drag_handle.ts +166 -0
- package/src/pm/format_painter.test.ts +91 -0
- package/src/pm/format_painter.ts +109 -0
- package/src/pm/header_footer_doc.ts +24 -0
- package/src/pm/hyperlinks.test.ts +234 -0
- package/src/pm/image_registry.test.ts +81 -0
- package/src/pm/image_registry.ts +100 -0
- package/src/pm/images.test.ts +257 -0
- package/src/pm/link_popover.ts +159 -0
- package/src/pm/mark_commands.ts +60 -0
- package/src/pm/marks.ts +169 -0
- package/src/pm/nodes.ts +258 -0
- package/src/pm/numbering.test.ts +210 -0
- package/src/pm/numbering_plugin.test.ts +71 -0
- package/src/pm/numbering_plugin.ts +96 -0
- package/src/pm/outline.ts +41 -0
- package/src/pm/page_break.test.ts +80 -0
- package/src/pm/page_layout.test.ts +87 -0
- package/src/pm/pagination_plugin.test.ts +155 -0
- package/src/pm/pagination_plugin.ts +590 -0
- package/src/pm/phase5.test.ts +271 -0
- package/src/pm/phase6.test.ts +215 -0
- package/src/pm/placeholder_plugin.ts +24 -0
- package/src/pm/plugins.ts +91 -0
- package/src/pm/pm_to_docx.ts +0 -0
- package/src/pm/roundtrip.test.ts +332 -0
- package/src/pm/schema.test.ts +188 -0
- package/src/pm/schema.ts +79 -0
- package/src/pm/search.ts +46 -0
- package/src/pm/table_attrs.ts +48 -0
- package/src/pm/table_borders.test.ts +117 -0
- package/src/pm/table_borders.ts +130 -0
- package/src/pm/table_convert.test.ts +221 -0
- package/src/pm/table_convert.ts +541 -0
- package/src/pm/table_decorations.ts +132 -0
- package/src/pm/table_handles.ts +163 -0
- package/src/pm/template_marker.ts +47 -0
- package/src/pm/template_plugin.ts +65 -0
- package/src/pm/templates.test.ts +162 -0
- package/src/render/clipboard.test.ts +115 -0
- package/src/render/clipboard.ts +200 -0
- package/src/render/editable_view.test.ts +173 -0
- package/src/render/footnotes_view.ts +94 -0
- package/src/render/header_footer_view.ts +95 -0
- package/src/render/link_mark_view.ts +26 -0
- package/src/render/media_resolver.ts +61 -0
- package/src/render/node_views.ts +296 -0
- package/src/render/numbering_counter.ts +149 -0
- package/src/render/page_chrome.test.ts +262 -0
- package/src/render/page_chrome.ts +343 -0
- package/src/render/page_styles.ts +234 -0
- package/src/render/paragraph_view.test.ts +162 -0
- package/src/render/paragraph_view.ts +141 -0
- package/src/render/ruler.ts +110 -0
- package/src/render/style_registry.ts +33 -0
- package/src/render/table_dom.test.ts +171 -0
- package/src/render/table_dom.ts +288 -0
- package/src/render/units.ts +18 -0
- package/src/render/view.test.ts +165 -0
- package/src/render/view.ts +607 -0
- package/src/roundtrip.test.ts +179 -0
- package/src/serialize/default_parts.ts +128 -0
- package/src/serialize/header_footer_pm.ts +82 -0
- package/src/serialize/serializer.ts +114 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import { Plugin, PluginKey } from "prosemirror-state";
|
|
2
|
+
import type { EditorState, Transaction } from "prosemirror-state";
|
|
3
|
+
import type { EditorView } from "prosemirror-view";
|
|
4
|
+
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
5
|
+
import type { Node as PMNode } from "prosemirror-model";
|
|
6
|
+
|
|
7
|
+
import type { ParagraphProperties } from "../model/properties";
|
|
8
|
+
import type { Section } from "../model/sections";
|
|
9
|
+
import type {
|
|
10
|
+
LayoutResult,
|
|
11
|
+
MeasuredBlock,
|
|
12
|
+
SectionGeometry,
|
|
13
|
+
} from "../layout/types";
|
|
14
|
+
import { runLayout } from "../layout/engine";
|
|
15
|
+
|
|
16
|
+
export const paginationPluginKey = new PluginKey<PaginationPluginState>(
|
|
17
|
+
"docx-pagination",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export type PaginationPluginOptions = {
|
|
21
|
+
/** Sections parsed from the document. The plugin maps each PM block to a section. */
|
|
22
|
+
sections: readonly Section[];
|
|
23
|
+
/** Pre-computed per-section geometries (pageHeight, content area, etc.). */
|
|
24
|
+
sectionGeometries: readonly SectionGeometry[];
|
|
25
|
+
/**
|
|
26
|
+
* Visual gap between page rectangles in CSS pixels. Used by the layout
|
|
27
|
+
* engine to compute each page's `gapHeightBefore` (which the renderer
|
|
28
|
+
* emits as a widget decoration so flow content aligns with chrome).
|
|
29
|
+
*/
|
|
30
|
+
pageGapPx: number;
|
|
31
|
+
/** Notified whenever the layout result changes (page count, page boundaries). */
|
|
32
|
+
onLayoutChange?: (layout: LayoutResult) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type PaginationPluginState = {
|
|
36
|
+
layout: LayoutResult | null;
|
|
37
|
+
decorations: DecorationSet;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pagination coordinator.
|
|
42
|
+
*
|
|
43
|
+
* On every doc change, schedules a rAF in which it measures each top-level
|
|
44
|
+
* block's offsetHeight via the editor view, runs {@link runLayout}, and
|
|
45
|
+
* emits widget decorations at page boundaries. The {@link PaginationPluginState}
|
|
46
|
+
* exposes the most recent {@link LayoutResult} so other components (status
|
|
47
|
+
* bar, PageChromeManager) can subscribe via {@link paginationPluginKey}.
|
|
48
|
+
*
|
|
49
|
+
* Decorations are visual only — they never mutate the doc model. Page-gap
|
|
50
|
+
* widgets are inserted via {@link Decoration.widget} immediately before the
|
|
51
|
+
* first block of each subsequent page and produce a transparent spacer
|
|
52
|
+
* matching the chrome layer's gap height. This keeps the editor's flowing
|
|
53
|
+
* content aligned with the absolutely-positioned page rectangles behind it.
|
|
54
|
+
*
|
|
55
|
+
* The plugin does not assume a particular DOM layout; it only requires that
|
|
56
|
+
* the editor's DOM contains one direct child per top-level PM block. This
|
|
57
|
+
* matches PM's default rendering for block nodes.
|
|
58
|
+
*/
|
|
59
|
+
export function paginationPlugin(
|
|
60
|
+
options: PaginationPluginOptions,
|
|
61
|
+
): Plugin<PaginationPluginState> {
|
|
62
|
+
return new Plugin<PaginationPluginState>({
|
|
63
|
+
key: paginationPluginKey,
|
|
64
|
+
state: {
|
|
65
|
+
init: () => ({ layout: null, decorations: DecorationSet.empty }),
|
|
66
|
+
apply: (tr, value) => {
|
|
67
|
+
const meta = tr.getMeta(paginationPluginKey) as
|
|
68
|
+
| { layout: LayoutResult; decorations: DecorationSet }
|
|
69
|
+
| undefined;
|
|
70
|
+
if (meta) return meta;
|
|
71
|
+
if (!tr.docChanged) return value;
|
|
72
|
+
// Map existing decorations through the transaction's mapping so
|
|
73
|
+
// they survive position-preserving changes. They'll be replaced
|
|
74
|
+
// on the next layout pass.
|
|
75
|
+
return {
|
|
76
|
+
layout: value.layout,
|
|
77
|
+
decorations: value.decorations.map(tr.mapping, tr.doc),
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
props: {
|
|
82
|
+
decorations(state) {
|
|
83
|
+
return paginationPluginKey.getState(state)?.decorations ?? null;
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
view(editorView) {
|
|
87
|
+
let rafHandle: number | null = null;
|
|
88
|
+
let disposed = false;
|
|
89
|
+
|
|
90
|
+
const recompute = () => {
|
|
91
|
+
rafHandle = null;
|
|
92
|
+
if (disposed) return;
|
|
93
|
+
const state = editorView.state;
|
|
94
|
+
const blocks = measureBlocks(editorView, state, options);
|
|
95
|
+
const layout = runLayout(blocks, options.sectionGeometries, {
|
|
96
|
+
minLinesBeforeBreak: 2,
|
|
97
|
+
minLinesAfterBreak: 2,
|
|
98
|
+
visualPageGapPx: options.pageGapPx,
|
|
99
|
+
});
|
|
100
|
+
const decorations = buildDecorations(state.doc, layout, editorView);
|
|
101
|
+
const prev = paginationPluginKey.getState(state);
|
|
102
|
+
if (prev && layoutsEqual(prev.layout, layout)) return;
|
|
103
|
+
editorView.dispatch(
|
|
104
|
+
state.tr.setMeta(paginationPluginKey, { layout, decorations }),
|
|
105
|
+
);
|
|
106
|
+
options.onLayoutChange?.(layout);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const schedule = () => {
|
|
110
|
+
if (disposed) return;
|
|
111
|
+
if (rafHandle !== null) return;
|
|
112
|
+
rafHandle = window.requestAnimationFrame(recompute);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Initial pass — deferred to a microtask so the EditorView
|
|
116
|
+
// constructor finishes before the plugin dispatches the first
|
|
117
|
+
// pagination transaction. PM forbids `view.dispatch` from inside
|
|
118
|
+
// a plugin's `view()` callback because the EditorView is not yet
|
|
119
|
+
// fully constructed. queueMicrotask gives us the earliest legal
|
|
120
|
+
// dispatch slot (before paint) so users still see chrome on first
|
|
121
|
+
// paint instead of after an rAF tick.
|
|
122
|
+
queueMicrotask(() => {
|
|
123
|
+
if (!disposed) recompute();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ResizeObserver catches font-load and container-resize cases that
|
|
127
|
+
// PM transactions don't trigger.
|
|
128
|
+
const ro =
|
|
129
|
+
typeof ResizeObserver !== "undefined"
|
|
130
|
+
? new ResizeObserver(schedule)
|
|
131
|
+
: null;
|
|
132
|
+
ro?.observe(editorView.dom);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
update: (_view, prevState) => {
|
|
136
|
+
if (editorView.state.doc !== prevState.doc) {
|
|
137
|
+
schedule();
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
destroy: () => {
|
|
141
|
+
disposed = true;
|
|
142
|
+
if (rafHandle !== null) cancelAnimationFrame(rafHandle);
|
|
143
|
+
ro?.disconnect();
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// =========================================================================
|
|
151
|
+
// Measurement
|
|
152
|
+
// =========================================================================
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Walk the top-level blocks in the PM doc, look up each block's DOM node,
|
|
156
|
+
* and produce {@link MeasuredBlock}s. Blocks that are paragraphs also
|
|
157
|
+
* receive a line-height array reconstructed from the rendered DOM so the
|
|
158
|
+
* engine can split them at line boundaries.
|
|
159
|
+
*
|
|
160
|
+
* The mapping from block to section uses the section blockStart/blockEnd
|
|
161
|
+
* ranges. Section breaks inside the doc (as `section_break` PM nodes)
|
|
162
|
+
* advance the active section.
|
|
163
|
+
*/
|
|
164
|
+
function measureBlocks(
|
|
165
|
+
view: EditorView,
|
|
166
|
+
state: EditorState,
|
|
167
|
+
options: PaginationPluginOptions,
|
|
168
|
+
): MeasuredBlock[] {
|
|
169
|
+
const blocks: MeasuredBlock[] = [];
|
|
170
|
+
const doc = state.doc;
|
|
171
|
+
let sectionIndex = 0;
|
|
172
|
+
let blockIndex = 0;
|
|
173
|
+
let hasWarnedSectionMismatch = false;
|
|
174
|
+
|
|
175
|
+
doc.forEach((node, _offset, index) => {
|
|
176
|
+
const dom = view.nodeDOM(positionOfChild(doc, index));
|
|
177
|
+
const el = dom instanceof HTMLElement ? dom : null;
|
|
178
|
+
// Use the flow extent (offsetHeight + margin to next sibling) rather
|
|
179
|
+
// than offsetHeight alone. Block-level CSS margins between paragraphs
|
|
180
|
+
// contribute to the actual flow position of subsequent content; if
|
|
181
|
+
// the engine ignores them, heightUsed undercounts and the gap widget
|
|
182
|
+
// ends up too short by ~10 px per inter-paragraph margin on each
|
|
183
|
+
// page, drifting content above the chrome's body slot over time.
|
|
184
|
+
const height = el ? effectiveFlowHeight(el) : 0;
|
|
185
|
+
const lineHeights =
|
|
186
|
+
node.type.name === "paragraph" && el
|
|
187
|
+
? measureParagraphLineHeights(el)
|
|
188
|
+
: null;
|
|
189
|
+
const rowHeights =
|
|
190
|
+
isTableNode(node) && el ? measureTableRowHeights(el) : null;
|
|
191
|
+
|
|
192
|
+
blocks.push({
|
|
193
|
+
index: blockIndex,
|
|
194
|
+
sectionIndex,
|
|
195
|
+
kind: classifyBlockKind(node),
|
|
196
|
+
height,
|
|
197
|
+
paragraphProperties: node.attrs.properties as ParagraphProperties | null,
|
|
198
|
+
lineHeights,
|
|
199
|
+
rowHeights,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (node.type.name === "section_break") {
|
|
203
|
+
const next = sectionIndex + 1;
|
|
204
|
+
if (next >= options.sections.length) {
|
|
205
|
+
// The doc has more section_break nodes than the parsed
|
|
206
|
+
// sections array describes. Throwing here would crash the
|
|
207
|
+
// editor mid-render; clamping silently produces wrong page
|
|
208
|
+
// geometry. Compromise: clamp and warn so developers see the
|
|
209
|
+
// mismatch in the console without losing the surface.
|
|
210
|
+
if (!hasWarnedSectionMismatch) {
|
|
211
|
+
// eslint-disable-next-line no-console
|
|
212
|
+
console.warn(
|
|
213
|
+
`[docx-editor] section_break at block ${blockIndex} exceeds sections.length (${options.sections.length}). ` +
|
|
214
|
+
"Blocks past the last section will reuse its geometry. Caller should keep `sections` in sync with the document's section_break count.",
|
|
215
|
+
);
|
|
216
|
+
hasWarnedSectionMismatch = true;
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
sectionIndex = next;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
blockIndex += 1;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return blocks;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Effective flow height of a block: `offsetHeight` plus the whitespace
|
|
230
|
+
* between this element's bottom and the next sibling's top (i.e. the
|
|
231
|
+
* resolved block margin after CSS margin-collapse).
|
|
232
|
+
*
|
|
233
|
+
* Why this matters for pagination: the editor content's flow extent is
|
|
234
|
+
* what determines where the next page's content lands. CSS `<p>` margins
|
|
235
|
+
* are visible in the layout but not in `offsetHeight`. Without
|
|
236
|
+
* accounting for them the engine undercounts heightUsed per page, the
|
|
237
|
+
* gap widget ends up too short, and content drifts above each page's
|
|
238
|
+
* body slot.
|
|
239
|
+
*
|
|
240
|
+
* For the last child of the editor (no next sibling) we return
|
|
241
|
+
* `offsetHeight` — the trailing margin has no in-flow consumer.
|
|
242
|
+
*
|
|
243
|
+
* Page-gap widget siblings are deliberately included in the diff:
|
|
244
|
+
* a paragraph that sits immediately before a gap widget contributes its
|
|
245
|
+
* own margin-bottom (the widget itself has margin: 0, so the diff to its
|
|
246
|
+
* top is exactly the paragraph's margin-bottom).
|
|
247
|
+
*/
|
|
248
|
+
function effectiveFlowHeight(el: HTMLElement): number {
|
|
249
|
+
const next = el.nextElementSibling;
|
|
250
|
+
if (!(next instanceof HTMLElement)) return el.offsetHeight;
|
|
251
|
+
const myBottom = el.getBoundingClientRect().bottom;
|
|
252
|
+
const nextTop = next.getBoundingClientRect().top;
|
|
253
|
+
const margin = nextTop - myBottom;
|
|
254
|
+
return el.offsetHeight + Math.max(0, margin);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function positionOfChild(doc: PMNode, index: number): number {
|
|
258
|
+
let pos = 0;
|
|
259
|
+
for (let i = 0; i < index; i++) {
|
|
260
|
+
pos += doc.child(i).nodeSize;
|
|
261
|
+
}
|
|
262
|
+
return pos;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function classifyBlockKind(node: PMNode): MeasuredBlock["kind"] {
|
|
266
|
+
switch (node.type.name) {
|
|
267
|
+
case "paragraph":
|
|
268
|
+
return "paragraph";
|
|
269
|
+
case "table":
|
|
270
|
+
return "table";
|
|
271
|
+
case "section_break":
|
|
272
|
+
return "section_break";
|
|
273
|
+
default:
|
|
274
|
+
return "opaque_block";
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function isTableNode(node: PMNode): boolean {
|
|
279
|
+
return node.type.name === "table";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Measure per-line heights of a paragraph DOM node. The renderer uses
|
|
284
|
+
* standard `<p>` flow layout, so we approximate line heights by walking
|
|
285
|
+
* the paragraph's client rects via `Range.getClientRects` and grouping
|
|
286
|
+
* adjacent rects on the same baseline.
|
|
287
|
+
*
|
|
288
|
+
* Returns null if the paragraph has only one line (no split possible).
|
|
289
|
+
*/
|
|
290
|
+
function measureParagraphLineHeights(el: HTMLElement): readonly number[] | null {
|
|
291
|
+
// Skip when the paragraph is empty or doesn't render multiple lines.
|
|
292
|
+
const range = document.createRange();
|
|
293
|
+
try {
|
|
294
|
+
range.selectNodeContents(el);
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const rects = Array.from(range.getClientRects());
|
|
299
|
+
if (rects.length <= 1) return null;
|
|
300
|
+
|
|
301
|
+
// Each rect roughly corresponds to a line in flow. Group by approximate
|
|
302
|
+
// top position to coalesce rects belonging to the same line that PM/CSS
|
|
303
|
+
// split across child spans.
|
|
304
|
+
const lines: number[] = [];
|
|
305
|
+
let currentTop: number | null = null;
|
|
306
|
+
let currentHeight = 0;
|
|
307
|
+
const TOLERANCE = 0.5; // px — same-baseline rect tolerance.
|
|
308
|
+
for (const r of rects) {
|
|
309
|
+
if (currentTop === null || Math.abs(r.top - currentTop) > TOLERANCE) {
|
|
310
|
+
if (currentTop !== null) lines.push(currentHeight);
|
|
311
|
+
currentTop = r.top;
|
|
312
|
+
currentHeight = r.height;
|
|
313
|
+
} else {
|
|
314
|
+
currentHeight = Math.max(currentHeight, r.height);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (currentTop !== null) lines.push(currentHeight);
|
|
318
|
+
// If the line array is still singular, no split is possible.
|
|
319
|
+
if (lines.length <= 1) return null;
|
|
320
|
+
return lines;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Measure per-row heights of a table by reading offsetHeight from each
|
|
325
|
+
* `<tr>` child. Returns null when no rows are present.
|
|
326
|
+
*/
|
|
327
|
+
function measureTableRowHeights(el: HTMLElement): readonly number[] | null {
|
|
328
|
+
const rows = el.querySelectorAll("tr");
|
|
329
|
+
if (rows.length === 0) return null;
|
|
330
|
+
const heights: number[] = [];
|
|
331
|
+
rows.forEach((row) => {
|
|
332
|
+
heights.push((row as HTMLElement).offsetHeight);
|
|
333
|
+
});
|
|
334
|
+
return heights;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// =========================================================================
|
|
338
|
+
// Decorations
|
|
339
|
+
// =========================================================================
|
|
340
|
+
|
|
341
|
+
function buildDecorations(
|
|
342
|
+
doc: PMNode,
|
|
343
|
+
layout: LayoutResult,
|
|
344
|
+
view: EditorView,
|
|
345
|
+
): DecorationSet {
|
|
346
|
+
if (layout.pages.length <= 1) return DecorationSet.empty;
|
|
347
|
+
|
|
348
|
+
const decorations: Decoration[] = [];
|
|
349
|
+
// Build a list of block index → PM position for top-level blocks.
|
|
350
|
+
const positionByBlockIndex: number[] = [];
|
|
351
|
+
let pos = 0;
|
|
352
|
+
doc.forEach((node) => {
|
|
353
|
+
positionByBlockIndex.push(pos);
|
|
354
|
+
pos += node.nodeSize;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Place a gap widget at the layout-determined boundary for every page
|
|
358
|
+
// after page 1. The engine-computed `gapHeightBefore` bridges the
|
|
359
|
+
// unused body-slot space on the previous page, the previous footer
|
|
360
|
+
// band, the visual gap between rectangles, and the next header band —
|
|
361
|
+
// so the flow position immediately after the widget equals the next
|
|
362
|
+
// page's body-slot top.
|
|
363
|
+
//
|
|
364
|
+
// Real-world wrinkle: the first block on a page often carries a CSS
|
|
365
|
+
// `margin-top` (Heading 1 / Title styles have one by default). With
|
|
366
|
+
// the widget at its engine-computed height, the block's box top would
|
|
367
|
+
// sit at body-slot top, but its CSS margin pushes the box DOWN by
|
|
368
|
+
// `margin-top` from the gap widget's bottom — overshooting by that
|
|
369
|
+
// amount. To compensate, we measure the next block's resolved
|
|
370
|
+
// `margin-top` and subtract it from the widget height. The CSS margin
|
|
371
|
+
// then "fills" the same space the widget would have occupied; the
|
|
372
|
+
// block's box lands exactly at body-slot top.
|
|
373
|
+
//
|
|
374
|
+
// When a page starts with the *continuation* of a block from the
|
|
375
|
+
// previous page (long paragraph spanning pages), the widget must be
|
|
376
|
+
// inserted MID-paragraph at the line boundary, not before the block —
|
|
377
|
+
// otherwise the entire paragraph slides to the next page. We use DOM
|
|
378
|
+
// measurement to find the PM position corresponding to the start of
|
|
379
|
+
// the next page's first line; falling back to block start when the
|
|
380
|
+
// measurement isn't possible.
|
|
381
|
+
for (let i = 1; i < layout.pages.length; i++) {
|
|
382
|
+
const page = layout.pages[i];
|
|
383
|
+
let widgetPos: number;
|
|
384
|
+
let blockDom: HTMLElement | null = null;
|
|
385
|
+
if (page.startsWithContinuation) {
|
|
386
|
+
const inlinePos = resolveInlineSplitPos({
|
|
387
|
+
view,
|
|
388
|
+
doc,
|
|
389
|
+
positionByBlockIndex,
|
|
390
|
+
slice: page.startsWithContinuation,
|
|
391
|
+
});
|
|
392
|
+
widgetPos =
|
|
393
|
+
inlinePos ??
|
|
394
|
+
positionByBlockIndex[page.startsWithContinuation.blockIndex] ??
|
|
395
|
+
0;
|
|
396
|
+
const blockPos =
|
|
397
|
+
positionByBlockIndex[page.startsWithContinuation.blockIndex];
|
|
398
|
+
if (blockPos !== undefined) {
|
|
399
|
+
const dom = view.nodeDOM(blockPos);
|
|
400
|
+
if (dom instanceof HTMLElement) blockDom = dom;
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
const blockPos = positionByBlockIndex[page.firstBlockIndex];
|
|
404
|
+
widgetPos = blockPos ?? 0;
|
|
405
|
+
if (blockPos !== undefined) {
|
|
406
|
+
const dom = view.nodeDOM(blockPos);
|
|
407
|
+
if (dom instanceof HTMLElement) blockDom = dom;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const marginTopPx = blockDom ? readMarginTop(blockDom) : 0;
|
|
411
|
+
// Continuation paragraphs are visually a single block split across
|
|
412
|
+
// pages — the heading's margin-top doesn't apply mid-paragraph, so
|
|
413
|
+
// we skip the adjustment when there's a continuation.
|
|
414
|
+
const adjustedGap = page.startsWithContinuation
|
|
415
|
+
? page.gapHeightBefore
|
|
416
|
+
: Math.max(0, page.gapHeightBefore - marginTopPx);
|
|
417
|
+
decorations.push(
|
|
418
|
+
Decoration.widget(widgetPos, gapWidget(adjustedGap, page.pageNumber), {
|
|
419
|
+
side: -1,
|
|
420
|
+
key: `page-gap-${page.pageNumber}-${adjustedGap}`,
|
|
421
|
+
ignoreSelection: true,
|
|
422
|
+
}),
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return DecorationSet.create(doc, decorations);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Map a paragraph's `startsWithContinuation` slice to the PM position that
|
|
431
|
+
* starts the slice's first visual line in the rendered DOM.
|
|
432
|
+
*
|
|
433
|
+
* Strategy: measure per-line client rects of the paragraph via Range. For
|
|
434
|
+
* each rect, find the first DOM character whose bounding rect aligns with
|
|
435
|
+
* that line's top. The slice's `firstLine` index picks the target rect.
|
|
436
|
+
* `view.posAtDOM` translates the DOM offset to a PM position.
|
|
437
|
+
*
|
|
438
|
+
* Returns null when the paragraph isn't a paragraph node, the DOM isn't
|
|
439
|
+
* available (e.g., during initial mount), or measurement returns
|
|
440
|
+
* unexpected geometry (RTL, complex inline atoms). The caller falls back
|
|
441
|
+
* to block-start placement.
|
|
442
|
+
*/
|
|
443
|
+
function resolveInlineSplitPos(args: {
|
|
444
|
+
view: EditorView;
|
|
445
|
+
doc: PMNode;
|
|
446
|
+
positionByBlockIndex: readonly number[];
|
|
447
|
+
slice: import("../layout/types").PartialBlockSlice;
|
|
448
|
+
}): number | null {
|
|
449
|
+
const blockPos = args.positionByBlockIndex[args.slice.blockIndex];
|
|
450
|
+
if (blockPos === undefined) return null;
|
|
451
|
+
const blockNode = args.doc.maybeChild(args.slice.blockIndex);
|
|
452
|
+
if (!blockNode || blockNode.type.name !== "paragraph") return null;
|
|
453
|
+
|
|
454
|
+
const dom = args.view.nodeDOM(blockPos);
|
|
455
|
+
const el = dom instanceof HTMLElement ? dom : null;
|
|
456
|
+
if (!el) return null;
|
|
457
|
+
|
|
458
|
+
const range = document.createRange();
|
|
459
|
+
range.selectNodeContents(el);
|
|
460
|
+
const rects = Array.from(range.getClientRects());
|
|
461
|
+
if (rects.length === 0) return null;
|
|
462
|
+
// Coalesce rects on the same baseline to per-line records.
|
|
463
|
+
const lines: number[] = []; // top Y of each line
|
|
464
|
+
const TOL = 0.5;
|
|
465
|
+
let currentTop: number | null = null;
|
|
466
|
+
for (const r of rects) {
|
|
467
|
+
if (currentTop === null || Math.abs(r.top - currentTop) > TOL) {
|
|
468
|
+
lines.push(r.top);
|
|
469
|
+
currentTop = r.top;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (args.slice.firstLine >= lines.length) return null;
|
|
473
|
+
const targetTop = lines[args.slice.firstLine];
|
|
474
|
+
|
|
475
|
+
// Find the first text node character whose bounding rect aligns with
|
|
476
|
+
// `targetTop`. Walk text descendants in DOM order.
|
|
477
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
|
|
478
|
+
let node: Node | null = walker.nextNode();
|
|
479
|
+
while (node) {
|
|
480
|
+
const text = node.textContent ?? "";
|
|
481
|
+
for (let offset = 0; offset < text.length; offset++) {
|
|
482
|
+
const r = document.createRange();
|
|
483
|
+
r.setStart(node, offset);
|
|
484
|
+
r.setEnd(node, Math.min(offset + 1, text.length));
|
|
485
|
+
const rect = r.getBoundingClientRect();
|
|
486
|
+
if (rect.height > 0 && Math.abs(rect.top - targetTop) <= TOL) {
|
|
487
|
+
// Found a character on the target line. Convert to PM position.
|
|
488
|
+
// posAtDOM throws only for genuinely-invalid (node, offset) pairs
|
|
489
|
+
// that we never construct here — the walker gives us live text
|
|
490
|
+
// nodes from the editor's own DOM. If it throws, the doc state
|
|
491
|
+
// is inconsistent and we want a loud failure, not a silent
|
|
492
|
+
// mis-pagination.
|
|
493
|
+
return args.view.posAtDOM(node, offset);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
node = walker.nextNode();
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Read the computed `margin-top` (in CSS pixels) on a block element. We
|
|
503
|
+
* use this to compensate the gap widget for headings (and other styles)
|
|
504
|
+
* whose CSS margin would otherwise push the block past the chrome's
|
|
505
|
+
* body-slot top by that margin's width.
|
|
506
|
+
*/
|
|
507
|
+
function readMarginTop(el: HTMLElement): number {
|
|
508
|
+
const raw = getComputedStyle(el).marginTop;
|
|
509
|
+
const parsed = Number.parseFloat(raw);
|
|
510
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function gapWidget(heightPx: number, pageNumber: number): () => HTMLElement {
|
|
514
|
+
return () => {
|
|
515
|
+
const el = document.createElement("div");
|
|
516
|
+
el.className = "docx-page-gap";
|
|
517
|
+
el.setAttribute("data-page-gap-after", String(pageNumber - 1));
|
|
518
|
+
el.setAttribute("aria-hidden", "true");
|
|
519
|
+
el.style.display = "block";
|
|
520
|
+
el.style.height = `${heightPx}px`;
|
|
521
|
+
el.style.width = "100%";
|
|
522
|
+
el.style.pointerEvents = "none";
|
|
523
|
+
return el;
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// =========================================================================
|
|
528
|
+
// Helpers
|
|
529
|
+
// =========================================================================
|
|
530
|
+
|
|
531
|
+
function layoutsEqual(a: LayoutResult | null, b: LayoutResult): boolean {
|
|
532
|
+
if (a === null) return false;
|
|
533
|
+
if (a.totalPages !== b.totalPages) return false;
|
|
534
|
+
if (a.pages.length !== b.pages.length) return false;
|
|
535
|
+
for (let i = 0; i < a.pages.length; i++) {
|
|
536
|
+
const pa = a.pages[i];
|
|
537
|
+
const pb = b.pages[i];
|
|
538
|
+
if (pa.firstBlockIndex !== pb.firstBlockIndex) return false;
|
|
539
|
+
if (pa.lastBlockIndex !== pb.lastBlockIndex) return false;
|
|
540
|
+
if (pa.headerKind !== pb.headerKind) return false;
|
|
541
|
+
if (pa.sectionIndex !== pb.sectionIndex) return false;
|
|
542
|
+
// gapHeightBefore feeds the widget decoration's height; missing it
|
|
543
|
+
// here would let a font-load or measurement change leave stale gap
|
|
544
|
+
// widgets that no longer match the chrome's body-slot positions.
|
|
545
|
+
if (pa.gapHeightBefore !== pb.gapHeightBefore) return false;
|
|
546
|
+
if (
|
|
547
|
+
partialEqual(pa.startsWithContinuation, pb.startsWithContinuation) ===
|
|
548
|
+
false
|
|
549
|
+
)
|
|
550
|
+
return false;
|
|
551
|
+
if (partialEqual(pa.endsWithContinuation, pb.endsWithContinuation) === false)
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function partialEqual(
|
|
558
|
+
a: import("../layout/types").PartialBlockSlice | null,
|
|
559
|
+
b: import("../layout/types").PartialBlockSlice | null,
|
|
560
|
+
): boolean {
|
|
561
|
+
if (a === null && b === null) return true;
|
|
562
|
+
if (a === null || b === null) return false;
|
|
563
|
+
return (
|
|
564
|
+
a.blockIndex === b.blockIndex &&
|
|
565
|
+
a.firstLine === b.firstLine &&
|
|
566
|
+
a.lastLine === b.lastLine
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Read the current pagination state from an EditorState. Public for the
|
|
572
|
+
* status bar, PageChromeManager hookup, and tests.
|
|
573
|
+
*/
|
|
574
|
+
export function getPaginationState(
|
|
575
|
+
state: EditorState,
|
|
576
|
+
): PaginationPluginState | null {
|
|
577
|
+
return paginationPluginKey.getState(state) ?? null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Manually push a new layout result into the plugin state. Used by
|
|
582
|
+
* tests that bypass the rAF measurement loop.
|
|
583
|
+
*/
|
|
584
|
+
export function setPaginationLayout(
|
|
585
|
+
view: EditorView,
|
|
586
|
+
layout: LayoutResult,
|
|
587
|
+
): Transaction {
|
|
588
|
+
const decorations = buildDecorations(view.state.doc, layout, view);
|
|
589
|
+
return view.state.tr.setMeta(paginationPluginKey, { layout, decorations });
|
|
590
|
+
}
|