@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,607 @@
|
|
|
1
|
+
import { EditorState, type Plugin } from "prosemirror-state";
|
|
2
|
+
import { EditorView, type NodeView } from "prosemirror-view";
|
|
3
|
+
import type { Node as PMNode } from "prosemirror-model";
|
|
4
|
+
import type { FontRegistry } from "../fonts/types";
|
|
5
|
+
import type { Section } from "../model/sections";
|
|
6
|
+
import type { RelationshipMap } from "../parse/relationships";
|
|
7
|
+
import type { HeaderFooterRegistry } from "../parse/header_footer";
|
|
8
|
+
import type { FootnoteRegistry } from "../parse/footnotes";
|
|
9
|
+
import { renderParagraph } from "./table_dom";
|
|
10
|
+
import { docxSchema } from "../pm/schema";
|
|
11
|
+
import { buildEditorPlugins, buildReadOnlyPlugins } from "../pm/plugins";
|
|
12
|
+
import type { NumberingTable } from "../model/numbering_table";
|
|
13
|
+
import {
|
|
14
|
+
imageRegistryPlugin,
|
|
15
|
+
type ImageRegistry,
|
|
16
|
+
} from "../pm/image_registry";
|
|
17
|
+
import { buildLinkMarkView } from "./link_mark_view";
|
|
18
|
+
import {
|
|
19
|
+
buildImageInlineView,
|
|
20
|
+
fieldInlineView,
|
|
21
|
+
opaqueBlockView,
|
|
22
|
+
opaqueInlineView,
|
|
23
|
+
sectionBreakView,
|
|
24
|
+
} from "./node_views";
|
|
25
|
+
import { buildParagraphView } from "./paragraph_view";
|
|
26
|
+
import type { ThemeTable } from "../model/theme";
|
|
27
|
+
import {
|
|
28
|
+
NULL_MEDIA_RESOLVER,
|
|
29
|
+
type MediaResolver,
|
|
30
|
+
} from "./media_resolver";
|
|
31
|
+
import { buildClipboardHooks } from "./clipboard";
|
|
32
|
+
import {
|
|
33
|
+
EDITOR_BASE_CSS,
|
|
34
|
+
fontFaceStylesheet,
|
|
35
|
+
} from "./page_styles";
|
|
36
|
+
import { buildRuler, RULER_CSS } from "./ruler";
|
|
37
|
+
import {
|
|
38
|
+
createPageChromeManager,
|
|
39
|
+
DEFAULT_PAGE_GAP_PX,
|
|
40
|
+
type HeaderFooterFactory,
|
|
41
|
+
type PageChromeManager,
|
|
42
|
+
} from "./page_chrome";
|
|
43
|
+
import {
|
|
44
|
+
getPaginationState,
|
|
45
|
+
paginationPlugin,
|
|
46
|
+
} from "../pm/pagination_plugin";
|
|
47
|
+
import { computeSectionGeometries } from "../layout/page_geometry";
|
|
48
|
+
import type { LayoutResult } from "../layout/types";
|
|
49
|
+
import { dxaToPx } from "./units";
|
|
50
|
+
import { placeholderPlugin } from "../pm/placeholder_plugin";
|
|
51
|
+
import { resolveInternalPartPath } from "../parse/relationships";
|
|
52
|
+
import { headerFooterFragmentToPm } from "../pm/header_footer_doc";
|
|
53
|
+
import { pmDocToHeaderFooterXml } from "../serialize/header_footer_pm";
|
|
54
|
+
import { keymap } from "prosemirror-keymap";
|
|
55
|
+
import { history, redo, undo } from "prosemirror-history";
|
|
56
|
+
import { baseKeymap, toggleMark } from "prosemirror-commands";
|
|
57
|
+
import { dropCursor } from "prosemirror-dropcursor";
|
|
58
|
+
import { gapCursor } from "prosemirror-gapcursor";
|
|
59
|
+
|
|
60
|
+
export type ViewMode = "read-only" | "editable";
|
|
61
|
+
|
|
62
|
+
export type DocxViewOptions = {
|
|
63
|
+
fontRegistry: FontRegistry;
|
|
64
|
+
sections: readonly Section[];
|
|
65
|
+
relationships?: RelationshipMap;
|
|
66
|
+
mediaResolver?: MediaResolver;
|
|
67
|
+
imageRegistry?: ImageRegistry;
|
|
68
|
+
numberingTable?: NumberingTable | null;
|
|
69
|
+
themeTable?: ThemeTable | null;
|
|
70
|
+
headerFooterRegistry?: HeaderFooterRegistry;
|
|
71
|
+
footnoteRegistry?: FootnoteRegistry;
|
|
72
|
+
mode?: ViewMode;
|
|
73
|
+
onChange?: (doc: PMNode) => void;
|
|
74
|
+
onPageCountChange?: (pageCount: number) => void;
|
|
75
|
+
plugins?: readonly Plugin[];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type ReadOnlyViewOptions = DocxViewOptions;
|
|
79
|
+
|
|
80
|
+
export type PaginationHandle = {
|
|
81
|
+
/**
|
|
82
|
+
* Most recent page count from the layout engine. Returns 1 until the
|
|
83
|
+
* first layout pass completes (one rAF after mount).
|
|
84
|
+
*/
|
|
85
|
+
getPageCount(): number;
|
|
86
|
+
/**
|
|
87
|
+
* Most recent layout result. Null until the first layout pass completes.
|
|
88
|
+
*/
|
|
89
|
+
getLayout(): LayoutResult | null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type ReadOnlyView = {
|
|
93
|
+
destroy(): void;
|
|
94
|
+
editorView: EditorView;
|
|
95
|
+
canvasElement: HTMLElement;
|
|
96
|
+
canvasInner: HTMLElement;
|
|
97
|
+
styleElement: HTMLStyleElement;
|
|
98
|
+
pagination: PaginationHandle;
|
|
99
|
+
getDocument(): PMNode;
|
|
100
|
+
setZoom(zoom: number): void;
|
|
101
|
+
/**
|
|
102
|
+
* Apply any in-place header/footer edits made through the editable
|
|
103
|
+
* header/footer DOMs to a clone of the parts map. Caller owns the
|
|
104
|
+
* resulting map and writes it through pmToDocx → serializeDocx.
|
|
105
|
+
*/
|
|
106
|
+
commitHeaderFooterEdits(parts: ReadonlyMap<string, Uint8Array>): Map<string, Uint8Array>;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export function createReadOnlyView(
|
|
110
|
+
host: HTMLElement,
|
|
111
|
+
pmDoc: PMNode,
|
|
112
|
+
options: DocxViewOptions,
|
|
113
|
+
): ReadOnlyView {
|
|
114
|
+
const styleElement = document.createElement("style");
|
|
115
|
+
styleElement.setAttribute("data-docx-editor-styles", "true");
|
|
116
|
+
styleElement.textContent =
|
|
117
|
+
EDITOR_BASE_CSS +
|
|
118
|
+
"\n" +
|
|
119
|
+
RULER_CSS +
|
|
120
|
+
"\n" +
|
|
121
|
+
fontFaceStylesheet(options.fontRegistry);
|
|
122
|
+
host.appendChild(styleElement);
|
|
123
|
+
|
|
124
|
+
const firstSection = options.sections[0]?.properties;
|
|
125
|
+
if (!firstSection) {
|
|
126
|
+
throw new Error("createReadOnlyView requires at least one section");
|
|
127
|
+
}
|
|
128
|
+
const sectionGeometries = computeSectionGeometries(options.sections);
|
|
129
|
+
const firstGeometry = sectionGeometries[0];
|
|
130
|
+
|
|
131
|
+
const canvasElement = document.createElement("div");
|
|
132
|
+
canvasElement.className = "docx-canvas";
|
|
133
|
+
canvasElement.setAttribute("data-docx-canvas", "true");
|
|
134
|
+
host.appendChild(canvasElement);
|
|
135
|
+
|
|
136
|
+
const canvasInner = document.createElement("div");
|
|
137
|
+
canvasInner.className = "docx-canvas-inner";
|
|
138
|
+
canvasInner.style.position = "relative";
|
|
139
|
+
canvasInner.style.width = `${firstGeometry.pageWidth}px`;
|
|
140
|
+
canvasElement.appendChild(canvasInner);
|
|
141
|
+
|
|
142
|
+
const ruler = buildRuler(firstSection);
|
|
143
|
+
canvasInner.appendChild(ruler.element);
|
|
144
|
+
|
|
145
|
+
// Each page rectangle in the chrome layer aligns vertically with a region
|
|
146
|
+
// of the editor's flowing content. We position the editor content
|
|
147
|
+
// absolutely *over* the layer and use widget decorations between pages
|
|
148
|
+
// to push subsequent paragraphs into the next page's body slot.
|
|
149
|
+
const stackRoot = document.createElement("div");
|
|
150
|
+
stackRoot.className = "docx-page-stack";
|
|
151
|
+
stackRoot.style.position = "relative";
|
|
152
|
+
stackRoot.style.width = `${firstGeometry.pageWidth}px`;
|
|
153
|
+
canvasInner.appendChild(stackRoot);
|
|
154
|
+
|
|
155
|
+
// When a header/footer sub-view takes focus, the body editor must yield
|
|
156
|
+
// both pointer interaction *and* keyboard editability. CSS pointer-events
|
|
157
|
+
// alone doesn't block keypresses — we toggle this flag and the body's
|
|
158
|
+
// `editable()` callback reads it.
|
|
159
|
+
let subViewFocused = false;
|
|
160
|
+
const setBodyDimmed = (dimmed: boolean) => {
|
|
161
|
+
stackRoot.classList.toggle("docx-page-stack--body-dimmed", dimmed);
|
|
162
|
+
subViewFocused = dimmed;
|
|
163
|
+
};
|
|
164
|
+
type EditableHeaderFooter = {
|
|
165
|
+
view: EditorView;
|
|
166
|
+
kind: "hdr" | "ftr";
|
|
167
|
+
partPath: string;
|
|
168
|
+
};
|
|
169
|
+
// Keyed by partPath, not relationshipId — two relationship ids in the
|
|
170
|
+
// same section can target the same part (rare but legal). The first
|
|
171
|
+
// mounted view wins; duplicates are torn down.
|
|
172
|
+
const editableHeaderFooters = new Map<string, EditableHeaderFooter>();
|
|
173
|
+
const mode: ViewMode = options.mode ?? "read-only";
|
|
174
|
+
const isEditable = mode === "editable";
|
|
175
|
+
|
|
176
|
+
// Editable header/footer mounter — used only for page 1 in editable mode.
|
|
177
|
+
// Pages 2..N render the same fragment as read-only DOM via the default
|
|
178
|
+
// chrome path. This mirrors Word's UX: the user edits "the header," not
|
|
179
|
+
// "header on page 3."
|
|
180
|
+
const mountHeaderFooterPm = (
|
|
181
|
+
fragment: import("../parse/header_footer").HeaderFooterFragment,
|
|
182
|
+
relationshipId: string | null,
|
|
183
|
+
kind: "hdr" | "ftr",
|
|
184
|
+
): HTMLElement => {
|
|
185
|
+
const wrapper = document.createElement("div");
|
|
186
|
+
wrapper.className = "docx-header-footer docx-header-footer--editable";
|
|
187
|
+
wrapper.setAttribute(`data-docx-${kind === "hdr" ? "header" : "footer"}`, "true");
|
|
188
|
+
const headerPmDoc = headerFooterFragmentToPm(fragment, {
|
|
189
|
+
styleTable: null,
|
|
190
|
+
numberingTable: options.numberingTable ?? null,
|
|
191
|
+
});
|
|
192
|
+
const state = EditorState.create({
|
|
193
|
+
schema: docxSchema,
|
|
194
|
+
doc: headerPmDoc,
|
|
195
|
+
plugins: [
|
|
196
|
+
history(),
|
|
197
|
+
keymap({ "Mod-z": undo, "Shift-Mod-z": redo, "Mod-y": redo }),
|
|
198
|
+
keymap({
|
|
199
|
+
"Mod-b": toggleMark(docxSchema.marks.bold),
|
|
200
|
+
"Mod-i": toggleMark(docxSchema.marks.italic),
|
|
201
|
+
"Mod-u": toggleMark(docxSchema.marks.underline),
|
|
202
|
+
}),
|
|
203
|
+
keymap(baseKeymap),
|
|
204
|
+
dropCursor(),
|
|
205
|
+
gapCursor(),
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
const subView = new EditorView(wrapper, {
|
|
209
|
+
state,
|
|
210
|
+
editable: () => true,
|
|
211
|
+
attributes: {
|
|
212
|
+
class: "docx-header-footer-content",
|
|
213
|
+
spellcheck: "true",
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
wrapper.addEventListener("focusin", () => setBodyDimmed(true));
|
|
217
|
+
wrapper.addEventListener("focusout", (e) => {
|
|
218
|
+
const next = e.relatedTarget as HTMLElement | null;
|
|
219
|
+
if (!next || !wrapper.contains(next)) setBodyDimmed(false);
|
|
220
|
+
});
|
|
221
|
+
if (relationshipId && options.relationships) {
|
|
222
|
+
const rel = options.relationships.get(relationshipId);
|
|
223
|
+
if (rel) {
|
|
224
|
+
const partPath = resolveInternalPartPath(rel.target);
|
|
225
|
+
if (editableHeaderFooters.has(partPath)) {
|
|
226
|
+
subView.destroy();
|
|
227
|
+
wrapper.remove();
|
|
228
|
+
} else {
|
|
229
|
+
editableHeaderFooters.set(partPath, {
|
|
230
|
+
view: subView,
|
|
231
|
+
kind,
|
|
232
|
+
partPath,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return wrapper;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const headerFooterFactory: HeaderFooterFactory = ({
|
|
241
|
+
pageNumber,
|
|
242
|
+
kind,
|
|
243
|
+
fragment,
|
|
244
|
+
relationshipId,
|
|
245
|
+
}) => {
|
|
246
|
+
// Only the first page gets an editable PM mini-view in editable mode.
|
|
247
|
+
// Pages 2..N fall through to the chrome manager's default read-only
|
|
248
|
+
// DOM rendering, which avoids the multi-view selection/coordination
|
|
249
|
+
// problem.
|
|
250
|
+
if (!isEditable) return null;
|
|
251
|
+
if (pageNumber !== 1) return null;
|
|
252
|
+
return mountHeaderFooterPm(fragment, relationshipId, kind);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const pageChrome: PageChromeManager = createPageChromeManager({
|
|
256
|
+
host: stackRoot,
|
|
257
|
+
sections: options.sections,
|
|
258
|
+
sectionGeometries,
|
|
259
|
+
headerFooterRegistry: options.headerFooterRegistry ?? null,
|
|
260
|
+
pageGapPx: DEFAULT_PAGE_GAP_PX,
|
|
261
|
+
renderHeaderFooter: headerFooterFactory,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const numberingTable = options.numberingTable ?? null;
|
|
265
|
+
const basePlugins =
|
|
266
|
+
options.plugins ??
|
|
267
|
+
(isEditable
|
|
268
|
+
? buildEditorPlugins({ numberingTable })
|
|
269
|
+
: buildReadOnlyPlugins({ numberingTable }));
|
|
270
|
+
|
|
271
|
+
const footnoteRegistry = options.footnoteRegistry ?? null;
|
|
272
|
+
|
|
273
|
+
const paginationPluginInstance = paginationPlugin({
|
|
274
|
+
sections: options.sections,
|
|
275
|
+
sectionGeometries,
|
|
276
|
+
pageGapPx: DEFAULT_PAGE_GAP_PX,
|
|
277
|
+
onLayoutChange: (layout) => {
|
|
278
|
+
pageChrome.setLayout(layout);
|
|
279
|
+
if (footnoteRegistry) {
|
|
280
|
+
const perPage = buildPerPageFootnotes(
|
|
281
|
+
footnoteRegistry,
|
|
282
|
+
editorView.state.doc,
|
|
283
|
+
layout,
|
|
284
|
+
);
|
|
285
|
+
pageChrome.setFootnotes(perPage);
|
|
286
|
+
}
|
|
287
|
+
options.onPageCountChange?.(layout.totalPages);
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const allPlugins = [
|
|
292
|
+
...basePlugins,
|
|
293
|
+
...(options.imageRegistry ? [imageRegistryPlugin(options.imageRegistry)] : []),
|
|
294
|
+
...(isEditable ? [placeholderPlugin("Type / for commands…")] : []),
|
|
295
|
+
paginationPluginInstance,
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
const state = EditorState.create({
|
|
299
|
+
schema: docxSchema,
|
|
300
|
+
doc: pmDoc,
|
|
301
|
+
plugins: allPlugins,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const relationships = options.relationships ?? new Map();
|
|
305
|
+
const media = options.mediaResolver ?? NULL_MEDIA_RESOLVER;
|
|
306
|
+
const imageView = buildImageInlineView(media);
|
|
307
|
+
|
|
308
|
+
// Editor content overlays the page chrome stack. Padding-top/bottom
|
|
309
|
+
// matches the first page's header/footer bands so the first paragraph
|
|
310
|
+
// lands inside page 1's body slot. Left/right padding matches the page
|
|
311
|
+
// margins so content sits in the body slot horizontally.
|
|
312
|
+
const editorMount = document.createElement("div");
|
|
313
|
+
editorMount.className = "docx-editor-mount";
|
|
314
|
+
editorMount.style.position = "absolute";
|
|
315
|
+
editorMount.style.top = "0";
|
|
316
|
+
editorMount.style.left = "0";
|
|
317
|
+
editorMount.style.right = "0";
|
|
318
|
+
// Padding mirrors page 1's geometry — first-section header band on top,
|
|
319
|
+
// first-section side margins horizontally. Multi-section documents
|
|
320
|
+
// whose later sections use different left/right margins will misalign
|
|
321
|
+
// their body content against the chrome layer's body slot. Tracked as
|
|
322
|
+
// a Phase 5 known gap in docs/docx.md.
|
|
323
|
+
editorMount.style.paddingTop = `${firstGeometry.headerBand}px`;
|
|
324
|
+
editorMount.style.paddingLeft = `${(firstGeometry.pageWidth - bodyWidth(firstSection)) / 2}px`;
|
|
325
|
+
editorMount.style.paddingRight = `${(firstGeometry.pageWidth - bodyWidth(firstSection)) / 2}px`;
|
|
326
|
+
editorMount.style.pointerEvents = "none";
|
|
327
|
+
|
|
328
|
+
// Multi-column body slots. When every section in the document declares
|
|
329
|
+
// the same column count > 1, apply CSS multi-column to the editor
|
|
330
|
+
// content. The reader still sees content flowing top-to-bottom; the
|
|
331
|
+
// browser splits it into N balanced columns. For mixed-section column
|
|
332
|
+
// counts (e.g., section 1 single-column, section 2 two-column), this
|
|
333
|
+
// is deferred — multi-section column awareness requires per-section
|
|
334
|
+
// wrappers, which is a Phase 5 design itself.
|
|
335
|
+
const columnCounts = new Set(
|
|
336
|
+
options.sections.map((s) => s.properties.columns.count),
|
|
337
|
+
);
|
|
338
|
+
if (columnCounts.size === 1) {
|
|
339
|
+
const count = options.sections[0]?.properties.columns.count ?? 1;
|
|
340
|
+
if (count > 1) {
|
|
341
|
+
editorMount.style.columnCount = String(count);
|
|
342
|
+
const spaceDxa =
|
|
343
|
+
options.sections[0]?.properties.columns.space ?? 720;
|
|
344
|
+
editorMount.style.columnGap = `${dxaToPx(spaceDxa)}px`;
|
|
345
|
+
editorMount.style.columnFill = "auto";
|
|
346
|
+
}
|
|
347
|
+
} else if (columnCounts.size > 1) {
|
|
348
|
+
// eslint-disable-next-line no-console
|
|
349
|
+
console.warn(
|
|
350
|
+
"[docx-editor] Document mixes section column counts; multi-column rendering disabled. Sections must share a column count for the Phase 5 multi-column path to apply.",
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
stackRoot.appendChild(editorMount);
|
|
355
|
+
|
|
356
|
+
const onChange = options.onChange;
|
|
357
|
+
const clipboardHooks = isEditable
|
|
358
|
+
? buildClipboardHooks({
|
|
359
|
+
imageRegistry: options.imageRegistry,
|
|
360
|
+
mediaResolver: media,
|
|
361
|
+
})
|
|
362
|
+
: null;
|
|
363
|
+
const editorView = new EditorView(editorMount, {
|
|
364
|
+
state,
|
|
365
|
+
editable: () => isEditable && !subViewFocused,
|
|
366
|
+
dispatchTransaction(transaction) {
|
|
367
|
+
const newState = editorView.state.apply(transaction);
|
|
368
|
+
editorView.updateState(newState);
|
|
369
|
+
if (transaction.docChanged && onChange) {
|
|
370
|
+
onChange(newState.doc);
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
transformPastedHTML: clipboardHooks?.transformPastedHTML,
|
|
374
|
+
handlePaste: clipboardHooks?.handlePaste,
|
|
375
|
+
handleDrop: clipboardHooks?.handleDrop,
|
|
376
|
+
nodeViews: {
|
|
377
|
+
paragraph: buildParagraphView(options.themeTable ?? null),
|
|
378
|
+
section_break: (node: PMNode): NodeView => sectionBreakView(node),
|
|
379
|
+
opaque_block: (node: PMNode): NodeView => opaqueBlockView(node),
|
|
380
|
+
opaque_inline: (): NodeView => opaqueInlineView(),
|
|
381
|
+
image_inline: (node: PMNode, v, getPos): NodeView => imageView(node, v, getPos),
|
|
382
|
+
field_inline: (node: PMNode, v, getPos): NodeView =>
|
|
383
|
+
fieldInlineView(node, v, getPos as () => number | undefined),
|
|
384
|
+
},
|
|
385
|
+
markViews: {
|
|
386
|
+
link: buildLinkMarkView(relationships),
|
|
387
|
+
},
|
|
388
|
+
attributes: {
|
|
389
|
+
class: "docx-editor-content",
|
|
390
|
+
"data-docx-editor": mode,
|
|
391
|
+
spellcheck: isEditable ? "true" : "false",
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Re-enable pointer events on the editor's DOM (we disabled them on the
|
|
396
|
+
// mount wrapper so that clicks in gap regions fall through to the chrome
|
|
397
|
+
// layer if needed, but the content area itself must remain interactive).
|
|
398
|
+
(editorView.dom as HTMLElement).style.pointerEvents = "auto";
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
const setZoom = (zoom: number) => {
|
|
402
|
+
canvasInner.style.transform = `scale(${zoom})`;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const pagination: PaginationHandle = {
|
|
406
|
+
getPageCount: () => {
|
|
407
|
+
const layout = getPaginationState(editorView.state)?.layout;
|
|
408
|
+
return layout?.totalPages ?? 1;
|
|
409
|
+
},
|
|
410
|
+
getLayout: () => getPaginationState(editorView.state)?.layout ?? null,
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const commitHeaderFooterEdits = (
|
|
414
|
+
parts: ReadonlyMap<string, Uint8Array>,
|
|
415
|
+
): Map<string, Uint8Array> => {
|
|
416
|
+
const out = new Map(parts);
|
|
417
|
+
if (editableHeaderFooters.size === 0) return out;
|
|
418
|
+
const encoder = new TextEncoder();
|
|
419
|
+
for (const { view: hfView, kind, partPath } of editableHeaderFooters.values()) {
|
|
420
|
+
const xml = pmDocToHeaderFooterXml(hfView.state.doc, kind);
|
|
421
|
+
out.set(partPath, encoder.encode(xml));
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
editorView,
|
|
428
|
+
canvasElement,
|
|
429
|
+
canvasInner,
|
|
430
|
+
styleElement,
|
|
431
|
+
pagination,
|
|
432
|
+
getDocument: () => editorView.state.doc,
|
|
433
|
+
setZoom,
|
|
434
|
+
commitHeaderFooterEdits,
|
|
435
|
+
destroy: () => {
|
|
436
|
+
for (const { view: hfView } of editableHeaderFooters.values()) {
|
|
437
|
+
hfView.destroy();
|
|
438
|
+
}
|
|
439
|
+
pageChrome.destroy();
|
|
440
|
+
editorView.destroy();
|
|
441
|
+
ruler.destroy();
|
|
442
|
+
styleElement.remove();
|
|
443
|
+
canvasElement.remove();
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Body width = pageWidth minus left/right margins. Renderer-local helper
|
|
450
|
+
* because the section properties carry margins in dxa.
|
|
451
|
+
*/
|
|
452
|
+
function bodyWidth(props: import("../model/sections").SectionProperties): number {
|
|
453
|
+
return (
|
|
454
|
+
dxaToPx(props.pageSize.width) -
|
|
455
|
+
dxaToPx(props.margins.left) -
|
|
456
|
+
dxaToPx(props.margins.right)
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Group the document's footnote references by the page they appear on,
|
|
462
|
+
* then build per-page footnote DOM fragments. The chrome manager mounts
|
|
463
|
+
* the fragment above the section footer band of each page.
|
|
464
|
+
*
|
|
465
|
+
* A footnote is mounted on the page that contains the first reference to
|
|
466
|
+
* it. Subsequent references on later pages do not re-mount the body — they
|
|
467
|
+
* still display the small superscript marker via the footnote_ref NodeView,
|
|
468
|
+
* pointing at the canonical body on the earlier page. This matches Word's
|
|
469
|
+
* default "Reference Mark" behavior for repeated references.
|
|
470
|
+
*/
|
|
471
|
+
function buildPerPageFootnotes(
|
|
472
|
+
registry: import("../parse/footnotes").FootnoteRegistry,
|
|
473
|
+
doc: import("prosemirror-model").Node,
|
|
474
|
+
layout: LayoutResult,
|
|
475
|
+
): ReadonlyMap<number, HTMLElement> {
|
|
476
|
+
// Walk every footnote_ref in the doc and record (id, blockIndex).
|
|
477
|
+
type Ref = { id: string; blockIndex: number; number: number };
|
|
478
|
+
const refs: Ref[] = [];
|
|
479
|
+
const seen = new Set<string>();
|
|
480
|
+
let blockIndex = -1;
|
|
481
|
+
doc.descendants((node, _pos, parent) => {
|
|
482
|
+
if (parent === null) return true;
|
|
483
|
+
if (parent === doc) {
|
|
484
|
+
// The `descendants` callback fires for the top-level children too —
|
|
485
|
+
// for those, advance the block index.
|
|
486
|
+
blockIndex += 1;
|
|
487
|
+
}
|
|
488
|
+
if (node.type.name === "footnote_ref") {
|
|
489
|
+
const id = node.attrs.footnoteId as string;
|
|
490
|
+
if (!id || seen.has(id)) return false;
|
|
491
|
+
seen.add(id);
|
|
492
|
+
refs.push({
|
|
493
|
+
id,
|
|
494
|
+
blockIndex,
|
|
495
|
+
number: node.attrs.number as number,
|
|
496
|
+
});
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
return true;
|
|
500
|
+
});
|
|
501
|
+
if (refs.length === 0) return new Map();
|
|
502
|
+
|
|
503
|
+
// Map blockIndex → pageNumber via the layout.
|
|
504
|
+
const pageOfBlock = (idx: number): number => {
|
|
505
|
+
for (const page of layout.pages) {
|
|
506
|
+
if (idx >= page.firstBlockIndex && idx <= page.lastBlockIndex) {
|
|
507
|
+
return page.pageNumber;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return 1;
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// Bucket refs by page.
|
|
514
|
+
const byPage = new Map<number, Ref[]>();
|
|
515
|
+
for (const ref of refs) {
|
|
516
|
+
const page = pageOfBlock(ref.blockIndex);
|
|
517
|
+
const bucket = byPage.get(page) ?? [];
|
|
518
|
+
bucket.push(ref);
|
|
519
|
+
byPage.set(page, bucket);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Render each page's footnote DOM.
|
|
523
|
+
const out = new Map<number, HTMLElement>();
|
|
524
|
+
for (const [pageNumber, pageRefs] of byPage) {
|
|
525
|
+
const wrapper = document.createElement("aside");
|
|
526
|
+
wrapper.className = "docx-footnotes";
|
|
527
|
+
const ol = document.createElement("ol");
|
|
528
|
+
ol.className = "docx-footnotes__list";
|
|
529
|
+
for (const ref of pageRefs) {
|
|
530
|
+
const entry = registry.byId.get(ref.id);
|
|
531
|
+
if (!entry) continue;
|
|
532
|
+
const li = document.createElement("li");
|
|
533
|
+
li.className = "docx-footnote";
|
|
534
|
+
li.setAttribute("data-footnote-id", entry.id);
|
|
535
|
+
li.setAttribute("value", String(ref.number));
|
|
536
|
+
// Reuse the existing block-rendering helpers via the registry-only
|
|
537
|
+
// `renderFootnotesSection` builds a complete OL; for per-page we
|
|
538
|
+
// need the individual entry. The helper isn't exported, so this
|
|
539
|
+
// duplication is intentional and small.
|
|
540
|
+
for (const block of entry.blocks) {
|
|
541
|
+
if (block.kind === "paragraph") {
|
|
542
|
+
const xml = paragraphBlockForFootnote(block);
|
|
543
|
+
li.appendChild(
|
|
544
|
+
renderFootnoteParagraph(xml),
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
ol.appendChild(li);
|
|
549
|
+
}
|
|
550
|
+
wrapper.appendChild(ol);
|
|
551
|
+
out.set(pageNumber, wrapper);
|
|
552
|
+
}
|
|
553
|
+
return out;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// TODO(phase-5): Unify with `footnotes_view.ts`. The OOXML→XmlElement
|
|
557
|
+
// conversion is duplicated here so per-page rendering can produce a
|
|
558
|
+
// single <li>, while `footnotes_view.ts` produces the whole <aside><ol>.
|
|
559
|
+
// When editable footnotes land, this duplication should fold into a
|
|
560
|
+
// shared helper exported from `footnotes_view.ts`.
|
|
561
|
+
function paragraphBlockForFootnote(
|
|
562
|
+
block: Extract<import("../model/types").Block, { kind: "paragraph" }>,
|
|
563
|
+
): import("@lotics/ooxml/xml").XmlElement {
|
|
564
|
+
const children: import("@lotics/ooxml/xml").XmlElement[] = [];
|
|
565
|
+
if (block.properties !== null) {
|
|
566
|
+
children.push({ "w:pPr": [...block.properties] });
|
|
567
|
+
}
|
|
568
|
+
for (const inline of block.content) {
|
|
569
|
+
if (inline.kind === "run") {
|
|
570
|
+
const runChildren: import("@lotics/ooxml/xml").XmlElement[] = [];
|
|
571
|
+
if (inline.properties !== null) {
|
|
572
|
+
runChildren.push({ "w:rPr": [...inline.properties] });
|
|
573
|
+
}
|
|
574
|
+
for (const c of inline.content) {
|
|
575
|
+
if (c.kind === "text") {
|
|
576
|
+
const t: import("@lotics/ooxml/xml").XmlElement = {
|
|
577
|
+
"w:t": c.value === "" ? [] : [{ "#text": c.value }],
|
|
578
|
+
};
|
|
579
|
+
if (c.preserveSpace) t[":@"] = { "@_xml:space": "preserve" };
|
|
580
|
+
runChildren.push(t);
|
|
581
|
+
} else {
|
|
582
|
+
runChildren.push(c.xml);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
children.push({ "w:r": runChildren });
|
|
586
|
+
} else {
|
|
587
|
+
children.push(inline.xml);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return { "w:p": children };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function renderFootnoteParagraph(
|
|
594
|
+
xml: import("@lotics/ooxml/xml").XmlElement,
|
|
595
|
+
): HTMLElement {
|
|
596
|
+
// Delegate to the table_dom paragraph renderer so footnote text is
|
|
597
|
+
// styled identically to body paragraphs.
|
|
598
|
+
return renderParagraph(xml);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function createEditableView(
|
|
602
|
+
host: HTMLElement,
|
|
603
|
+
pmDoc: PMNode,
|
|
604
|
+
options: Omit<DocxViewOptions, "mode">,
|
|
605
|
+
): ReadOnlyView {
|
|
606
|
+
return createReadOnlyView(host, pmDoc, { ...options, mode: "editable" });
|
|
607
|
+
}
|