docxodus 10.0.0 → 12.0.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/README.md +7 -5
- package/dist/docxodus.worker.js +6 -38
- package/dist/docxodus.worker.js.map +1 -1
- package/dist/editor-comments.d.ts +129 -0
- package/dist/editor-comments.d.ts.map +1 -0
- package/dist/editor-comments.js +805 -0
- package/dist/editor-comments.js.map +1 -0
- package/dist/editor-headerfooter.d.ts +125 -85
- package/dist/editor-headerfooter.d.ts.map +1 -1
- package/dist/editor-headerfooter.js +572 -306
- package/dist/editor-headerfooter.js.map +1 -1
- package/dist/editor-image-patch.d.ts +38 -0
- package/dist/editor-image-patch.d.ts.map +1 -0
- package/dist/editor-image-patch.js +101 -0
- package/dist/editor-image-patch.js.map +1 -0
- package/dist/editor.bundle.js +3815 -778
- package/dist/editor.d.ts +237 -5
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +1063 -112
- package/dist/editor.js.map +1 -1
- package/dist/embed.bundle.js +3911 -849
- package/dist/embed.d.ts +1 -1
- package/dist/embed.d.ts.map +1 -1
- package/dist/embed.iife.js +3899 -837
- package/dist/embed.js +10 -1
- package/dist/embed.js.map +1 -1
- package/dist/export-assets.json +40 -46
- package/dist/export-browser.bundle.js +27 -3
- package/dist/index.d.ts +28 -64
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +68 -134
- package/dist/index.js.map +1 -1
- package/dist/pagination.bundle.js +25 -0
- package/dist/pagination.d.ts +6 -0
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +19 -0
- package/dist/pagination.js.map +1 -1
- package/dist/react.d.ts +4 -4
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js.map +1 -1
- package/dist/ribbon-chrome.d.ts +20 -4
- package/dist/ribbon-chrome.d.ts.map +1 -1
- package/dist/ribbon-chrome.js +575 -184
- package/dist/ribbon-chrome.js.map +1 -1
- package/dist/ribbon.d.ts +5 -4
- package/dist/ribbon.d.ts.map +1 -1
- package/dist/ribbon.js +978 -129
- package/dist/ribbon.js.map +1 -1
- package/dist/session.bundle.js +100 -3
- package/dist/session.d.ts +60 -4
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +75 -3
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +194 -227
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -75
- package/dist/types.js.map +1 -1
- package/dist/viewport.d.ts +7 -0
- package/dist/viewport.d.ts.map +1 -1
- package/dist/viewport.js +12 -0
- package/dist/viewport.js.map +1 -1
- package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm +0 -0
- package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm.br +0 -0
- package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm +0 -0
- package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm.br +0 -0
- package/dist/wasm/_framework/Docxodus.wasm +0 -0
- package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
- package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Concurrent.wasm.br +0 -0
- package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm.br +0 -0
- package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Compression.wasm.br +0 -0
- package/dist/wasm/_framework/System.IO.Packaging.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Packaging.wasm.br +0 -0
- package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Pipelines.wasm.br +0 -0
- package/dist/wasm/_framework/System.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.wasm.br +0 -0
- package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
- package/dist/wasm/_framework/System.Private.CoreLib.wasm.br +0 -0
- package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Uri.wasm.br +0 -0
- package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.Linq.wasm.br +0 -0
- package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.wasm.br +0 -0
- package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
- package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm.br +0 -0
- package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
- package/dist/wasm/_framework/System.Security.Cryptography.wasm.br +0 -0
- package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Encodings.Web.wasm.br +0 -0
- package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Json.wasm.br +0 -0
- package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
- package/dist/wasm/_framework/System.Text.RegularExpressions.wasm.br +0 -0
- package/dist/wasm/_framework/dotnet.boot.js +31 -37
- package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
- package/dist/wasm/_framework/dotnet.native.js +39 -3
- package/dist/wasm/_framework/dotnet.native.js.br +0 -0
- package/dist/wasm/_framework/dotnet.native.wasm +0 -0
- package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
- package/dist/worker-proxy.bundle.js +2 -3
- package/dist/worker-proxy.d.ts +2 -2
- package/dist/worker-proxy.d.ts.map +1 -1
- package/dist/worker-proxy.js +1 -2
- package/dist/worker-proxy.js.map +1 -1
- package/package.json +3 -3
- package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
- package/dist/wasm/_framework/System.Diagnostics.Process.wasm.br +0 -0
|
@@ -1,79 +1,67 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HeaderFooterRegion — the DocxEditor's
|
|
2
|
+
* HeaderFooterRegion — the DocxEditor's header/footer editing surface.
|
|
3
3
|
*
|
|
4
4
|
* Header and footer stories live in their own OOXML parts (HeaderPart/FooterPart) *outside*
|
|
5
|
-
* the body, so they cannot be another block in the body flow. This module
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* the body, so they cannot be another block in the body flow. This module presents them two
|
|
6
|
+
* ways, both composed PER STORY PARAGRAPH via the editor's block renderer, which resolves
|
|
7
|
+
* `hdr`/`ftr` anchors natively:
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* - **Continuous view** — two bands, one docked above the body and one below, drawn as the
|
|
10
|
+
* top and bottom margin of the sheet (a dashed rule and a small "Header"/"Footer" tag, the
|
|
11
|
+
* look Word has while a header is being edited).
|
|
12
|
+
* - **Page view** — no bands. The paginator clones each story onto every page as inert
|
|
13
|
+
* presentation; clicking a page's header or footer area swaps THAT page's clone for the
|
|
14
|
+
* real, editable story paragraphs. A commit re-renders the paragraph and re-clones the story
|
|
15
|
+
* onto every other page that shows it, so all pages update live, and page-number fields on
|
|
16
|
+
* each clone are substituted per page just as the paginator does.
|
|
16
17
|
*
|
|
17
18
|
* Once rendered, a story paragraph is an ORDINARY editable block: DocxSession's anchor
|
|
18
19
|
* resolution indexes every scope and routes by part, so ReplaceText/ApplyFormat/SetParagraphFormat
|
|
19
|
-
* all accept a `p:hdr1:<unid>` anchor. The editor wires
|
|
20
|
-
* uses for the body, which is why the whole ribbon works inside a
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{ value: "decimal", label: "1, 2, 3" },
|
|
28
|
-
{ value: "lowerLetter", label: "a, b, c" },
|
|
29
|
-
{ value: "upperLetter", label: "A, B, C" },
|
|
30
|
-
{ value: "lowerRoman", label: "i, ii, iii" },
|
|
31
|
-
{ value: "upperRoman", label: "I, II, III" },
|
|
32
|
-
];
|
|
33
|
-
const KIND_LABELS = [
|
|
34
|
-
{ value: "default", label: "Default" },
|
|
35
|
-
{ value: "first", label: "First page" },
|
|
36
|
-
{ value: "even", label: "Even pages" },
|
|
37
|
-
];
|
|
38
|
-
/**
|
|
39
|
-
* Verbatim from docs/architecture/docx_mutation_api.md. `w:evenAndOddHeaders` is set on the
|
|
40
|
-
* SETTINGS part, so it is document-global and governs footers as well as headers: once set,
|
|
41
|
-
* even pages stop inheriting the Default stories entirely. A demo user who creates only an
|
|
42
|
-
* even header hits this immediately and sees their footer vanish on page 2.
|
|
43
|
-
*/
|
|
44
|
-
const EVEN_WARNING = "w:evenAndOddHeaders is document-global and governs footers too — even pages stop " +
|
|
45
|
-
"inheriting the Default stories, so a section with only a Default footer shows no footer " +
|
|
46
|
-
"at all on even pages.";
|
|
47
|
-
/**
|
|
48
|
-
* `w:titlePg` is per-section but has the same shape of surprise: once set, page 1 uses its OWN
|
|
49
|
-
* header AND footer, so an empty first-page footer means page 1 shows no footer even though the
|
|
50
|
-
* Default one is populated.
|
|
20
|
+
* all accept a `p:hdr1:<unid>` anchor. The editor wires story blocks with the same `wireBlock` it
|
|
21
|
+
* uses for the body, which is why the whole ribbon works inside a story with no new command code.
|
|
22
|
+
*
|
|
23
|
+
* "Different first page" and "Different odd & even pages" are Word's two checkboxes, backed by
|
|
24
|
+
* `w:titlePg` (per section) and `w:evenAndOddHeaders` (document-global). Enabling one seeds BOTH
|
|
25
|
+
* the header and the footer story of that kind, exactly as Word does — page 1 stops using the
|
|
26
|
+
* default stories the moment `w:titlePg` is set, so seeding only one side would silently leave
|
|
27
|
+
* page 1 with no footer.
|
|
51
28
|
*/
|
|
52
|
-
|
|
53
|
-
"ones, so an empty first-page footer leaves page 1 with no footer.";
|
|
29
|
+
import { formatPageNumber } from "./page-number-format.js";
|
|
54
30
|
/** Kinds/scopes the markdown projection addresses as editable text blocks. */
|
|
55
31
|
const STORY_BLOCK_KINDS = new Set(["p", "h", "li"]);
|
|
32
|
+
const KIND_LABELS = {
|
|
33
|
+
default: { header: "Header", footer: "Footer" },
|
|
34
|
+
first: { header: "First Page Header", footer: "First Page Footer" },
|
|
35
|
+
even: { header: "Even Page Header", footer: "Even Page Footer" },
|
|
36
|
+
};
|
|
37
|
+
/** Attributes the editor stamps on live story blocks and that a page clone must not carry. */
|
|
38
|
+
const LIVE_BLOCK_ATTRS = [
|
|
39
|
+
"contenteditable", "data-anchor", "data-hf-anchor", "data-hf-adopted", "data-hf-focused",
|
|
40
|
+
"data-committed-text", "data-render-sig", "tabindex",
|
|
41
|
+
];
|
|
56
42
|
export class HeaderFooterRegion {
|
|
57
|
-
constructor(bridge, handle,
|
|
58
|
-
/** The body anchor whose section the
|
|
43
|
+
constructor(bridge, handle, callbacks) {
|
|
44
|
+
/** The body anchor whose section the region currently describes (needed to seed a story). */
|
|
59
45
|
this.bodyAnchorId = null;
|
|
60
46
|
this.sectionInfo = null;
|
|
61
47
|
/** Memoized body anchor → governing sectionUnid, so repeat focus costs no bridge call. */
|
|
62
48
|
this.sectionOfAnchor = new Map();
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
49
|
+
/** Which story kind each continuous band shows. */
|
|
50
|
+
this.kinds = { header: "default", footer: "default" };
|
|
51
|
+
/** Page view: the page stack being edited in place, and the hosts currently holding live blocks. */
|
|
52
|
+
this.pageRoot = null;
|
|
53
|
+
this.pageHosts = new Set();
|
|
54
|
+
this.activeHost = null;
|
|
55
|
+
this.pageListeners = new Map();
|
|
67
56
|
this.bridge = bridge;
|
|
68
57
|
this.handle = handle;
|
|
69
|
-
this.options = options;
|
|
70
58
|
this.callbacks = callbacks;
|
|
71
59
|
this.headerBand = this.buildBand("header");
|
|
72
60
|
this.footerBand = this.buildBand("footer");
|
|
73
61
|
}
|
|
74
62
|
// ─── public surface ──────────────────────────────────────────────────
|
|
75
63
|
/**
|
|
76
|
-
* Point the
|
|
64
|
+
* Point the region at the section governing `bodyAnchorId` and repaint if it changed.
|
|
77
65
|
* Cheap to call on every focus change: the anchor → section lookup is memoized, and an
|
|
78
66
|
* unchanged `sectionUnid` returns without touching the DOM (so it never clobbers the
|
|
79
67
|
* user's kind selection).
|
|
@@ -88,50 +76,124 @@ export class HeaderFooterRegion {
|
|
|
88
76
|
}
|
|
89
77
|
const info = this.readSectionInfo(bodyAnchorId);
|
|
90
78
|
if (!info)
|
|
91
|
-
return; // not a body anchor (or no sectPr) — leave
|
|
79
|
+
return; // not a body anchor (or no sectPr) — leave things as they are
|
|
92
80
|
this.sectionOfAnchor.set(bodyAnchorId, info.sectionUnid);
|
|
93
81
|
const changed = info.sectionUnid !== this.sectionInfo?.sectionUnid;
|
|
94
82
|
this.bodyAnchorId = bodyAnchorId;
|
|
95
83
|
this.sectionInfo = info;
|
|
96
|
-
if (changed)
|
|
84
|
+
if (changed && !this.pageRoot) {
|
|
85
|
+
// A kind the new section cannot show falls back to its default story.
|
|
86
|
+
for (const which of ["header", "footer"]) {
|
|
87
|
+
if (!this.partUriFor(which, this.kinds[which]))
|
|
88
|
+
this.kinds[which] = "default";
|
|
89
|
+
}
|
|
97
90
|
this.refreshAll();
|
|
91
|
+
}
|
|
98
92
|
}
|
|
99
|
-
/**
|
|
93
|
+
/** Re-read the section and repaint one story (band, or the active page host and its clones). */
|
|
100
94
|
refresh(which) {
|
|
101
95
|
this.sectionInfo = this.bodyAnchorId ? this.readSectionInfo(this.bodyAnchorId) : null;
|
|
102
|
-
this.
|
|
96
|
+
if (this.pageRoot)
|
|
97
|
+
this.repaintPageStory(which);
|
|
98
|
+
else
|
|
99
|
+
this.renderBand(which);
|
|
103
100
|
}
|
|
104
|
-
/** Repaint both
|
|
101
|
+
/** Repaint both stories from the live session (after a remount, undo/redo, or section change). */
|
|
105
102
|
refreshAll() {
|
|
103
|
+
if (this.pageRoot) {
|
|
104
|
+
this.repaintPageStory("header");
|
|
105
|
+
this.repaintPageStory("footer");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
106
108
|
this.renderBand("header");
|
|
107
109
|
this.renderBand("footer");
|
|
108
110
|
}
|
|
109
|
-
/** Select (and, if absent, create) the story kind a band
|
|
111
|
+
/** Select (and, if absent, create) the story kind a continuous band shows. */
|
|
110
112
|
setKind(which, kind) {
|
|
111
113
|
this.kinds[which] = kind;
|
|
112
|
-
const select = this.bandFor(which).querySelector("[data-hf-kind]");
|
|
113
|
-
if (select && select.value !== kind)
|
|
114
|
-
select.value = kind;
|
|
115
114
|
if (!this.partUriFor(which, kind))
|
|
116
115
|
this.seedStory(which, kind);
|
|
117
|
-
// Selecting first/even IS the user saying "use a different first/even page", so the
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// flag; without this the typed story is saved but never rendered.
|
|
116
|
+
// Selecting first/even IS the user saying "use a different first/even page", so the
|
|
117
|
+
// section's flag must be set even when the part already exists: Word leaves first/even parts
|
|
118
|
+
// behind when those options are switched off, so a real document commonly has the reference
|
|
119
|
+
// WITHOUT the flag; without this the typed story is saved but never rendered.
|
|
122
120
|
if (this.bodyAnchorId && kind !== "default") {
|
|
123
121
|
this.bridge.EnsureHeaderFooterVisible(this.handle, this.bodyAnchorId, kind);
|
|
124
122
|
}
|
|
125
123
|
this.refresh(which);
|
|
126
124
|
}
|
|
127
|
-
/** The kind a band is currently editing. */
|
|
125
|
+
/** The kind a band (or the active page host) is currently editing. */
|
|
128
126
|
kindOf(which) {
|
|
127
|
+
if (this.pageRoot && this.activeHost && this.whichOf(this.activeHost) === which) {
|
|
128
|
+
return this.activeHost.dataset.hfType ?? "default";
|
|
129
|
+
}
|
|
129
130
|
return this.kinds[which];
|
|
130
131
|
}
|
|
132
|
+
/** Word's checkbox state: whether `w:titlePg` (first) / `w:evenAndOddHeaders` (even) is set. */
|
|
133
|
+
kindEnabled(kind) {
|
|
134
|
+
const info = this.sectionInfo;
|
|
135
|
+
if (!info)
|
|
136
|
+
return false;
|
|
137
|
+
if (kind === "first")
|
|
138
|
+
return info.titlePage ?? this.refsFor("header").some((r) => r.kind === "first" && !r.inherited);
|
|
139
|
+
return info.evenAndOddHeaders ?? false;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Word's "Different first page" / "Different odd & even pages". Enabling seeds BOTH stories of
|
|
143
|
+
* that kind when absent (page 1 uses its own header AND footer once `w:titlePg` is set, so a
|
|
144
|
+
* missing first-page footer would leave page 1 footer-less) and sets the flag; disabling clears
|
|
145
|
+
* the flag and leaves the parts in place, as Word does. Returns false when the bundle predates
|
|
146
|
+
* the disable op and `enabled` is false.
|
|
147
|
+
*/
|
|
148
|
+
setKindEnabled(kind, enabled) {
|
|
149
|
+
if (!this.bodyAnchorId)
|
|
150
|
+
return false;
|
|
151
|
+
if (enabled) {
|
|
152
|
+
for (const which of ["header", "footer"]) {
|
|
153
|
+
// The odd/all-pages story too: once the option is on, its pages need somewhere to
|
|
154
|
+
// type as much as the first/even ones do (Word shows an empty area for both).
|
|
155
|
+
if (!this.partUriFor(which, "default"))
|
|
156
|
+
this.seedStory(which, "default");
|
|
157
|
+
if (!this.partUriFor(which, kind))
|
|
158
|
+
this.seedStory(which, kind);
|
|
159
|
+
}
|
|
160
|
+
const res = parseResult(this.bridge.EnsureHeaderFooterVisible(this.handle, this.bodyAnchorId, kind));
|
|
161
|
+
if (!res.success)
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
if (!this.bridge.SetHeaderFooterKindEnabled)
|
|
166
|
+
return false;
|
|
167
|
+
const res = parseResult(this.bridge.SetHeaderFooterKindEnabled(this.handle, this.bodyAnchorId, kind, false));
|
|
168
|
+
if (!res.success)
|
|
169
|
+
return false;
|
|
170
|
+
for (const which of ["header", "footer"]) {
|
|
171
|
+
if (this.kinds[which] === kind)
|
|
172
|
+
this.kinds[which] = "default";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
this.reloadSection();
|
|
176
|
+
// Page view selects stories per page from the flags, so the pages themselves must rebuild.
|
|
177
|
+
if (this.pageRoot)
|
|
178
|
+
this.callbacks.remount();
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
/** Human label for the story a band (or the active host) shows — "First Page Header". */
|
|
182
|
+
storyLabel(which) {
|
|
183
|
+
return KIND_LABELS[this.kindOf(which)][which];
|
|
184
|
+
}
|
|
185
|
+
/** The kinds a band could show right now (those with a story or an enabled flag). */
|
|
186
|
+
availableKinds(which) {
|
|
187
|
+
const kinds = ["default"];
|
|
188
|
+
if (this.kindEnabled("first") || this.partUriFor(which, "first"))
|
|
189
|
+
kinds.push("first");
|
|
190
|
+
if (this.kindEnabled("even") || this.partUriFor(which, "even"))
|
|
191
|
+
kinds.push("even");
|
|
192
|
+
return kinds;
|
|
193
|
+
}
|
|
131
194
|
/** Append a PAGE / NUMPAGES field to the story paragraph addressed by `anchorId`.
|
|
132
195
|
* Deliberately a PLAIN field (no `\*` switch), exactly as Word inserts one, so it follows the
|
|
133
|
-
* section's page-number format — see {@link setPageNumbering}.
|
|
134
|
-
* format as a switch here would silently WIN over any later format change. */
|
|
196
|
+
* section's page-number format — see {@link setPageNumbering}. */
|
|
135
197
|
insertPageNumber(which, anchorId, field) {
|
|
136
198
|
const res = parseResult(this.bridge.InsertPageNumberField(this.handle, anchorId, field, ""));
|
|
137
199
|
if (!res.success)
|
|
@@ -139,15 +201,25 @@ export class HeaderFooterRegion {
|
|
|
139
201
|
this.refresh(which);
|
|
140
202
|
return true;
|
|
141
203
|
}
|
|
142
|
-
/**
|
|
143
|
-
*
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
204
|
+
/** Insert a page number into a story's own target paragraph (focused, else last). Seeds the
|
|
205
|
+
* story first if the selected kind has none, so the command is never a silent no-op. */
|
|
206
|
+
insertPageNumberInBand(which, field) {
|
|
207
|
+
// Page view: the field goes into a live story, so open the story on the current page
|
|
208
|
+
// first (Word does the same — inserting a page number takes you into the footer).
|
|
209
|
+
if (this.pageRoot && !(this.activeHost && this.whichOf(this.activeHost) === which)) {
|
|
210
|
+
const near = this.activeHost ?? null;
|
|
211
|
+
if (!this.focusStory(which, near))
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
const kind = this.kindOf(which);
|
|
215
|
+
if (!this.partUriFor(which, kind)) {
|
|
216
|
+
this.seedStory(which, kind);
|
|
217
|
+
this.refresh(which);
|
|
218
|
+
}
|
|
219
|
+
const target = this.pageNumberTarget(which);
|
|
220
|
+
return target ? this.insertPageNumber(which, target, field) : false;
|
|
221
|
+
}
|
|
222
|
+
/** Set this section's page numbering (`w:pgNumType`) — Word's *Format Page Numbers…*. */
|
|
151
223
|
setPageNumbering(op) {
|
|
152
224
|
if (!this.bodyAnchorId)
|
|
153
225
|
return false;
|
|
@@ -157,16 +229,11 @@ export class HeaderFooterRegion {
|
|
|
157
229
|
this.reloadSection();
|
|
158
230
|
return true;
|
|
159
231
|
}
|
|
160
|
-
/** This section's page numbering as the live document states it
|
|
161
|
-
* defaulted — "continues the previous section" is not the same claim as "starts at 1". */
|
|
232
|
+
/** This section's page numbering as the live document states it (fields absent, not defaulted). */
|
|
162
233
|
pageNumbering() {
|
|
163
|
-
return {
|
|
164
|
-
start: this.sectionInfo?.pageNumberStart,
|
|
165
|
-
format: this.sectionInfo?.pageNumberFormat,
|
|
166
|
-
};
|
|
234
|
+
return { start: this.sectionInfo?.pageNumberStart, format: this.sectionInfo?.pageNumberFormat };
|
|
167
235
|
}
|
|
168
|
-
/** Remove this section's page-numbering start/format
|
|
169
|
-
* section's numbering in Word's default `1, 2, 3`. */
|
|
236
|
+
/** Remove this section's page-numbering start/format. */
|
|
170
237
|
clearPageNumbering() {
|
|
171
238
|
if (!this.bodyAnchorId)
|
|
172
239
|
return false;
|
|
@@ -176,69 +243,346 @@ export class HeaderFooterRegion {
|
|
|
176
243
|
this.reloadSection();
|
|
177
244
|
return true;
|
|
178
245
|
}
|
|
179
|
-
/**
|
|
180
|
-
* Re-read the section from the live document and repaint BOTH bands.
|
|
181
|
-
*
|
|
182
|
-
* `refreshAll` only repaints — it deliberately does not re-read, because its callers (remount,
|
|
183
|
-
* undo/redo) already refreshed the section. A section-property write has no such caller, and
|
|
184
|
-
* repainting from the stale snapshot would leave the chrome reporting the value the document had
|
|
185
|
-
* before the edit.
|
|
186
|
-
*/
|
|
187
|
-
reloadSection() {
|
|
188
|
-
this.sectionInfo = this.bodyAnchorId ? this.readSectionInfo(this.bodyAnchorId) : null;
|
|
189
|
-
this.refreshAll();
|
|
190
|
-
}
|
|
191
|
-
/** Insert a page number into a band's own target paragraph (focused, else last). Seeds the
|
|
192
|
-
* story first if the band's selected kind has none, so the command is never a silent no-op. */
|
|
193
|
-
insertPageNumberInBand(which, field) {
|
|
194
|
-
if (!this.partUriFor(which, this.kinds[which])) {
|
|
195
|
-
this.seedStory(which, this.kinds[which]);
|
|
196
|
-
this.renderBand(which);
|
|
197
|
-
}
|
|
198
|
-
const target = this.pageNumberTarget(which);
|
|
199
|
-
return target ? this.insertPageNumber(which, target, field) : false;
|
|
200
|
-
}
|
|
201
|
-
/** The band element containing `node`, or null. */
|
|
246
|
+
/** The story host (continuous band or active page area) containing `node`, or null. */
|
|
202
247
|
bandOf(node) {
|
|
203
248
|
const el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
|
|
204
249
|
const band = el?.closest("[data-hf-band]") ?? null;
|
|
205
|
-
|
|
250
|
+
if (!band)
|
|
251
|
+
return null;
|
|
252
|
+
return band === this.headerBand || band === this.footerBand || this.pageHosts.has(band) ? band : null;
|
|
206
253
|
}
|
|
207
|
-
/** The block-list root (a
|
|
254
|
+
/** The block-list root (a story's container) owning `node`, or null. */
|
|
208
255
|
blockRootOf(node) {
|
|
209
256
|
const band = this.bandOf(node);
|
|
210
257
|
return band ? band.querySelector("[data-hf-body]") : null;
|
|
211
258
|
}
|
|
212
|
-
/** True when `node` is inside
|
|
259
|
+
/** True when `node` is inside a story host (band or page area). */
|
|
213
260
|
contains(node) {
|
|
214
261
|
return this.bandOf(node) !== null;
|
|
215
262
|
}
|
|
216
|
-
/** `"header"` / `"footer"` for a
|
|
263
|
+
/** `"header"` / `"footer"` for a story host produced by this region. */
|
|
217
264
|
whichOf(band) {
|
|
218
|
-
return band ===
|
|
265
|
+
return band.getAttribute("data-hf-band") === "footer" ? "footer" : "header";
|
|
266
|
+
}
|
|
267
|
+
/** The story host currently being edited (page view), or null. */
|
|
268
|
+
get active() {
|
|
269
|
+
return this.activeHost;
|
|
270
|
+
}
|
|
271
|
+
/** True while the caret is in a story (either view) — drives the contextual ribbon tab. */
|
|
272
|
+
isStoryActive() {
|
|
273
|
+
return this.activeHost !== null;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Track focus: the editor calls this whenever any block takes focus. A body block leaving a
|
|
277
|
+
* page story deactivates it (and re-paginates if the story grew past its band); a story block
|
|
278
|
+
* activates its host.
|
|
279
|
+
*/
|
|
280
|
+
noteFocus(el) {
|
|
281
|
+
const host = el ? this.bandOf(el) : null;
|
|
282
|
+
if (host === this.activeHost)
|
|
283
|
+
return;
|
|
284
|
+
const previous = this.activeHost;
|
|
285
|
+
this.activeHost = host;
|
|
286
|
+
if (previous)
|
|
287
|
+
this.deactivateHost(previous);
|
|
288
|
+
if (host) {
|
|
289
|
+
host.setAttribute("data-hf-active", "");
|
|
290
|
+
if (this.pageHosts.has(host))
|
|
291
|
+
host.style.overflow = "visible";
|
|
292
|
+
}
|
|
293
|
+
this.callbacks.onActiveChange?.(host, host ? this.whichOf(host) : null);
|
|
294
|
+
}
|
|
295
|
+
/** Leave story editing: the caret goes back to the body (Word's "Close Header and Footer"). */
|
|
296
|
+
close() {
|
|
297
|
+
const previous = this.activeHost;
|
|
298
|
+
this.activeHost = null;
|
|
299
|
+
if (previous)
|
|
300
|
+
this.deactivateHost(previous);
|
|
301
|
+
this.callbacks.onActiveChange?.(null, null);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Move the caret into a story (Word's "Go to Header / Go to Footer"). Page view activates the
|
|
305
|
+
* area on the page the caret is on (else the first page); continuous view focuses the band.
|
|
306
|
+
*/
|
|
307
|
+
focusStory(which, near) {
|
|
308
|
+
if (this.pageRoot) {
|
|
309
|
+
const pages = Array.from(this.pageRoot.querySelectorAll(".page-box"));
|
|
310
|
+
let page = near?.closest(".page-box") ?? pages[0] ?? null;
|
|
311
|
+
if (!page)
|
|
312
|
+
return false;
|
|
313
|
+
let area = page.querySelector(`.page-${which}`);
|
|
314
|
+
if (!area) {
|
|
315
|
+
// No story of this kind exists, so the paginator drew no area to click. Seed it and
|
|
316
|
+
// re-paginate; the page then carries an (empty) area for the caret to land in.
|
|
317
|
+
const pageIndex = pages.indexOf(page);
|
|
318
|
+
const bodyAnchor = this.bodyAnchorForPage(page);
|
|
319
|
+
if (!bodyAnchor)
|
|
320
|
+
return false;
|
|
321
|
+
this.syncToBody(bodyAnchor);
|
|
322
|
+
const kind = page.querySelector(`.page-${which === "header" ? "footer" : "header"}`)?.dataset.hfType ?? "default";
|
|
323
|
+
if (!this.partUriFor(which, kind))
|
|
324
|
+
this.seedStory(which, kind);
|
|
325
|
+
this.callbacks.remount();
|
|
326
|
+
if (!this.pageRoot)
|
|
327
|
+
return false;
|
|
328
|
+
page = this.pageRoot.querySelectorAll(".page-box")[Math.max(0, pageIndex)] ?? null;
|
|
329
|
+
area = page?.querySelector(`.page-${which}`) ?? null;
|
|
330
|
+
if (!area)
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
this.editInPage(area, "end");
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
const band = which === "header" ? this.headerBand : this.footerBand;
|
|
337
|
+
const block = band.querySelector('[data-anchor][contenteditable="true"]');
|
|
338
|
+
if (!block)
|
|
339
|
+
return false;
|
|
340
|
+
placeCaretAtEnd(block);
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
/** After the editor swapped/split/merged a story block in place: show this page's own numbers
|
|
344
|
+
* in the fresh render, then mirror it to the page clones. */
|
|
345
|
+
afterStoryEdit(el) {
|
|
346
|
+
const host = this.bandOf(el);
|
|
347
|
+
if (!host || !this.pageRoot)
|
|
348
|
+
return;
|
|
349
|
+
this.substituteFields(host);
|
|
350
|
+
this.propagate(host);
|
|
351
|
+
}
|
|
352
|
+
// ─── page view: edit in place ────────────────────────────────────────
|
|
353
|
+
/**
|
|
354
|
+
* Adopt a page stack: every page's header/footer area becomes click-to-edit. The paginator's
|
|
355
|
+
* clones stay as they are until a click swaps one for the live story.
|
|
356
|
+
*/
|
|
357
|
+
attachPages(pageRoot) {
|
|
358
|
+
this.detachPages();
|
|
359
|
+
this.pageRoot = pageRoot;
|
|
360
|
+
for (const area of Array.from(pageRoot.querySelectorAll(".page-header, .page-footer"))) {
|
|
361
|
+
area.setAttribute("data-hf-page", area.classList.contains("page-footer") ? "footer" : "header");
|
|
362
|
+
area.setAttribute("data-hf-inert", "");
|
|
363
|
+
area.title = `Click to edit the ${area.classList.contains("page-footer") ? "footer" : "header"}`;
|
|
364
|
+
const listener = (event) => {
|
|
365
|
+
if (this.pageHosts.has(area))
|
|
366
|
+
return; // already live — the click lands in a block
|
|
367
|
+
event.preventDefault();
|
|
368
|
+
this.editInPage(area, "point", event);
|
|
369
|
+
};
|
|
370
|
+
area.addEventListener("mousedown", listener);
|
|
371
|
+
this.pageListeners.set(area, listener);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/** Release a page stack (before a remount replaces it). */
|
|
375
|
+
detachPages() {
|
|
376
|
+
for (const [area, listener] of this.pageListeners)
|
|
377
|
+
area.removeEventListener("mousedown", listener);
|
|
378
|
+
this.pageListeners.clear();
|
|
379
|
+
this.pageHosts.clear();
|
|
380
|
+
const hadActive = this.activeHost !== null;
|
|
381
|
+
this.activeHost = null;
|
|
382
|
+
this.pageRoot = null;
|
|
383
|
+
// The pages are being thrown away, so there is no host to deactivate — but the caret WAS
|
|
384
|
+
// in a story, and the ribbon's contextual tab and story label follow this callback. Without
|
|
385
|
+
// it, the next body focus compared null to null and never published "back in the body".
|
|
386
|
+
if (hadActive)
|
|
387
|
+
this.callbacks.onActiveChange?.(null, null);
|
|
388
|
+
}
|
|
389
|
+
/** True when the region is presenting stories inside page boxes rather than as bands. */
|
|
390
|
+
get inPageMode() {
|
|
391
|
+
return this.pageRoot !== null;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Swap a page's cloned story for the live, editable one and put the caret in it. The page
|
|
395
|
+
* advertises which story it shows (`data-hf-type`, stamped by the paginator) and which
|
|
396
|
+
* section it belongs to (`data-section-index`); the section's body anchor comes from the
|
|
397
|
+
* first anchored block on the page (or an earlier page of the same section).
|
|
398
|
+
*/
|
|
399
|
+
editInPage(area, caret, event) {
|
|
400
|
+
if (!this.pageRoot)
|
|
401
|
+
return;
|
|
402
|
+
const which = area.getAttribute("data-hf-page") === "footer" ? "footer" : "header";
|
|
403
|
+
const kind = area.dataset.hfType ?? "default";
|
|
404
|
+
const page = area.closest(".page-box");
|
|
405
|
+
const bodyAnchor = page ? this.bodyAnchorForPage(page) : null;
|
|
406
|
+
if (!bodyAnchor)
|
|
407
|
+
return;
|
|
408
|
+
this.syncToBody(bodyAnchor);
|
|
409
|
+
this.kinds[which] = kind;
|
|
410
|
+
if (!this.pageHosts.has(area)) {
|
|
411
|
+
area.setAttribute("data-hf-band", which);
|
|
412
|
+
area.removeAttribute("data-hf-inert");
|
|
413
|
+
area.dataset.hfLabel = KIND_LABELS[kind][which];
|
|
414
|
+
this.pageHosts.add(area);
|
|
415
|
+
this.renderHostStory(area, which, kind);
|
|
416
|
+
}
|
|
417
|
+
const blocks = Array.from(area.querySelectorAll('[data-anchor][contenteditable="true"]'));
|
|
418
|
+
if (blocks.length === 0)
|
|
419
|
+
return;
|
|
420
|
+
let target = blocks[blocks.length - 1];
|
|
421
|
+
if (caret === "point" && event) {
|
|
422
|
+
const hit = event.target?.closest('[data-anchor][contenteditable="true"]');
|
|
423
|
+
if (hit && area.contains(hit))
|
|
424
|
+
target = hit;
|
|
425
|
+
const doc = area.ownerDocument;
|
|
426
|
+
const point = caretFromPoint(doc, event.clientX, event.clientY);
|
|
427
|
+
if (point && target.contains(point.node)) {
|
|
428
|
+
const sel = doc.getSelection();
|
|
429
|
+
const range = doc.createRange();
|
|
430
|
+
range.setStart(point.node, point.offset);
|
|
431
|
+
range.collapse(true);
|
|
432
|
+
sel?.removeAllRanges();
|
|
433
|
+
sel?.addRange(range);
|
|
434
|
+
target.focus({ preventScroll: true });
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
placeCaretAtEnd(target);
|
|
439
|
+
}
|
|
440
|
+
/** The full body anchor of the first anchored block on `page`, walking back to earlier pages. */
|
|
441
|
+
bodyAnchorForPage(page) {
|
|
442
|
+
let box = page;
|
|
443
|
+
while (box) {
|
|
444
|
+
const block = box.querySelector(".page-content [data-anchor]");
|
|
445
|
+
const unid = block?.getAttribute("data-anchor");
|
|
446
|
+
const id = unid ? this.callbacks.bodyAnchorIdOf(unid) : undefined;
|
|
447
|
+
if (id)
|
|
448
|
+
return id;
|
|
449
|
+
box = box.previousElementSibling;
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
/** Render the live story into a page host (replacing the paginator's clone). */
|
|
454
|
+
renderHostStory(host, which, kind) {
|
|
455
|
+
let body = host.querySelector(":scope > [data-hf-body]");
|
|
456
|
+
if (!body) {
|
|
457
|
+
host.replaceChildren();
|
|
458
|
+
body = host.ownerDocument.createElement("div");
|
|
459
|
+
body.className = "docx-hf-body";
|
|
460
|
+
body.setAttribute("data-hf-body", "");
|
|
461
|
+
host.appendChild(body);
|
|
462
|
+
}
|
|
463
|
+
const partUri = this.partUriFor(which, kind);
|
|
464
|
+
if (!partUri) {
|
|
465
|
+
// The page shows nothing because the story does not exist; Word still lets you click into
|
|
466
|
+
// the margin and type. Seed it and render the empty paragraph.
|
|
467
|
+
this.seedStory(which, kind);
|
|
468
|
+
}
|
|
469
|
+
this.fillStoryBody(body, which, kind);
|
|
470
|
+
this.substituteFields(host);
|
|
471
|
+
}
|
|
472
|
+
/** Repaint the page story `which` (active host first, then every clone of the same story). */
|
|
473
|
+
repaintPageStory(which) {
|
|
474
|
+
const host = this.activeHost && this.whichOf(this.activeHost) === which ? this.activeHost : null;
|
|
475
|
+
if (host) {
|
|
476
|
+
const body = host.querySelector(":scope > [data-hf-body]");
|
|
477
|
+
if (body)
|
|
478
|
+
this.fillStoryBody(body, which, this.kindOf(which));
|
|
479
|
+
this.substituteFields(host);
|
|
480
|
+
this.propagate(host);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
// No live host for this story — page clones carry the paginator's render; nothing to do
|
|
484
|
+
// until the next remount picks the change up.
|
|
485
|
+
}
|
|
486
|
+
/** Mirror a live host's story onto every other page area showing the same story. */
|
|
487
|
+
propagate(host) {
|
|
488
|
+
if (!this.pageRoot)
|
|
489
|
+
return;
|
|
490
|
+
const which = this.whichOf(host);
|
|
491
|
+
const kind = host.dataset.hfType ?? "default";
|
|
492
|
+
const partUri = this.partUriFor(which, kind);
|
|
493
|
+
const sourceBody = host.querySelector(":scope > [data-hf-body]");
|
|
494
|
+
if (!sourceBody)
|
|
495
|
+
return;
|
|
496
|
+
const sectionParts = new Map();
|
|
497
|
+
for (const area of Array.from(this.pageRoot.querySelectorAll(`.page-${which}`))) {
|
|
498
|
+
if (area === host)
|
|
499
|
+
continue;
|
|
500
|
+
if ((area.dataset.hfType ?? "default") !== kind)
|
|
501
|
+
continue;
|
|
502
|
+
const page = area.closest(".page-box");
|
|
503
|
+
if (!page)
|
|
504
|
+
continue;
|
|
505
|
+
const sectionIndex = page.dataset.sectionIndex ?? "0";
|
|
506
|
+
let part = sectionParts.get(sectionIndex);
|
|
507
|
+
if (part === undefined) {
|
|
508
|
+
const anchor = this.bodyAnchorForPage(page);
|
|
509
|
+
const info = anchor ? this.readSectionInfo(anchor) : null;
|
|
510
|
+
const refs = (which === "header" ? info?.headerRefs : info?.footerRefs) ?? [];
|
|
511
|
+
part = refs.find((r) => r.kind === kind)?.partUri ?? null;
|
|
512
|
+
sectionParts.set(sectionIndex, part);
|
|
513
|
+
}
|
|
514
|
+
if (!partUri || part !== partUri)
|
|
515
|
+
continue;
|
|
516
|
+
if (this.pageHosts.has(area)) {
|
|
517
|
+
// Another page already holds a live copy of this story: re-render it from truth too.
|
|
518
|
+
const body = area.querySelector(":scope > [data-hf-body]");
|
|
519
|
+
if (body)
|
|
520
|
+
this.fillStoryBody(body, which, kind);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
area.replaceChildren();
|
|
524
|
+
for (const child of Array.from(sourceBody.children))
|
|
525
|
+
area.appendChild(inertClone(child));
|
|
526
|
+
}
|
|
527
|
+
this.substituteFields(area);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/** Substitute PAGE / NUMPAGES markers in a page area from the page's own numbering. */
|
|
531
|
+
substituteFields(area) {
|
|
532
|
+
const page = area.closest(".page-box");
|
|
533
|
+
if (!page || !this.pageRoot)
|
|
534
|
+
return;
|
|
535
|
+
const total = this.pageRoot.querySelectorAll(".page-box:not([data-section-filler])").length;
|
|
536
|
+
const displayed = parseInt(page.dataset.displayedPageNumber || page.dataset.pageNumber || "1", 10);
|
|
537
|
+
const format = this.sectionInfo?.pageNumberFormat;
|
|
538
|
+
for (const marker of Array.from(area.querySelectorAll("[data-field]"))) {
|
|
539
|
+
const kind = marker.dataset.field;
|
|
540
|
+
if (kind !== "PAGE" && kind !== "NUMPAGES")
|
|
541
|
+
continue;
|
|
542
|
+
// Keep the session's cached result on the element: the editor's offset space counts THAT,
|
|
543
|
+
// not the per-page number shown here, so a later edit diffs against the right text.
|
|
544
|
+
if (marker.dataset.fieldCached === undefined)
|
|
545
|
+
marker.dataset.fieldCached = marker.textContent ?? "";
|
|
546
|
+
marker.textContent = formatPageNumber(kind === "PAGE" ? displayed : total, marker.dataset.fieldFormat ?? format);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
deactivateHost(host) {
|
|
550
|
+
host.removeAttribute("data-hf-active");
|
|
551
|
+
if (!this.pageHosts.has(host))
|
|
552
|
+
return;
|
|
553
|
+
host.style.overflow = "hidden";
|
|
554
|
+
// The story may have grown past the band the paginator reserved for it; re-flow the pages
|
|
555
|
+
// rather than clip it (Word pushes the body down).
|
|
556
|
+
const body = host.querySelector(":scope > [data-hf-body]");
|
|
557
|
+
// A couple of pixels of slack: the paginator measured the story in a detached box, and
|
|
558
|
+
// sub-pixel rounding between that and the live host must not read as growth.
|
|
559
|
+
if (body && body.scrollHeight > host.clientHeight + 3) {
|
|
560
|
+
this.callbacks.remount();
|
|
561
|
+
}
|
|
219
562
|
}
|
|
220
563
|
// ─── section + anchor resolution ─────────────────────────────────────
|
|
221
564
|
readSectionInfo(bodyAnchorId) {
|
|
222
565
|
try {
|
|
223
|
-
const
|
|
224
|
-
const parsed = JSON.parse(raw);
|
|
566
|
+
const parsed = JSON.parse(this.bridge.GetSectionInfo(this.handle, bodyAnchorId));
|
|
225
567
|
return parsed && typeof parsed.sectionUnid === "string" ? parsed : null;
|
|
226
568
|
}
|
|
227
569
|
catch {
|
|
228
570
|
return null;
|
|
229
571
|
}
|
|
230
572
|
}
|
|
573
|
+
reloadSection() {
|
|
574
|
+
this.sectionInfo = this.bodyAnchorId ? this.readSectionInfo(this.bodyAnchorId) : null;
|
|
575
|
+
this.refreshAll();
|
|
576
|
+
}
|
|
231
577
|
refsFor(which) {
|
|
232
578
|
const info = this.sectionInfo;
|
|
233
579
|
if (!info)
|
|
234
580
|
return [];
|
|
235
581
|
return (which === "header" ? info.headerRefs : info.footerRefs) ?? [];
|
|
236
582
|
}
|
|
237
|
-
/** The reference supplying `kind` for this band — possibly inherited from an earlier section. */
|
|
238
583
|
refFor(which, kind) {
|
|
239
584
|
return this.refsFor(which).find((r) => r.kind === kind) ?? null;
|
|
240
585
|
}
|
|
241
|
-
/** URI of the part supplying `kind` for this band, or null when the story doesn't exist. */
|
|
242
586
|
partUriFor(which, kind) {
|
|
243
587
|
return this.refFor(which, kind)?.partUri ?? null;
|
|
244
588
|
}
|
|
@@ -246,7 +590,8 @@ export class HeaderFooterRegion {
|
|
|
246
590
|
storyAnchors(partUri) {
|
|
247
591
|
let index;
|
|
248
592
|
try {
|
|
249
|
-
|
|
593
|
+
const raw = this.bridge.ListAnchors ? this.bridge.ListAnchors(this.handle) : this.bridge.Project(this.handle);
|
|
594
|
+
index = JSON.parse(raw).anchorIndex;
|
|
250
595
|
}
|
|
251
596
|
catch {
|
|
252
597
|
return [];
|
|
@@ -255,11 +600,7 @@ export class HeaderFooterRegion {
|
|
|
255
600
|
.filter(([, t]) => t.partUri === partUri && STORY_BLOCK_KINDS.has(t.kind))
|
|
256
601
|
.map(([id]) => id);
|
|
257
602
|
}
|
|
258
|
-
/**
|
|
259
|
-
* Create an empty story for `kind`. Seeding happens the moment a kind is selected rather than
|
|
260
|
-
* lazily on first keystroke: an absent story renders no contenteditable element, so there
|
|
261
|
-
* would be nothing to click into.
|
|
262
|
-
*/
|
|
603
|
+
/** Create an empty story for `kind` so there is a paragraph to click into. */
|
|
263
604
|
seedStory(which, kind) {
|
|
264
605
|
if (!this.bodyAnchorId)
|
|
265
606
|
return;
|
|
@@ -267,178 +608,106 @@ export class HeaderFooterRegion {
|
|
|
267
608
|
const res = parseResult(set(this.handle, this.bodyAnchorId, kind, ""));
|
|
268
609
|
if (!res.success)
|
|
269
610
|
return;
|
|
270
|
-
// A new part changes the projection (fresh hdr{N}/ftr{N} scope) and the section's refs.
|
|
271
611
|
this.callbacks.refreshAnchorMap();
|
|
272
612
|
this.sectionInfo = this.readSectionInfo(this.bodyAnchorId);
|
|
273
613
|
}
|
|
274
|
-
// ───
|
|
275
|
-
bandFor(which) {
|
|
276
|
-
return which === "header" ? this.headerBand : this.footerBand;
|
|
277
|
-
}
|
|
614
|
+
// ─── continuous view: bands ──────────────────────────────────────────
|
|
278
615
|
buildBand(which) {
|
|
279
616
|
const band = document.createElement("div");
|
|
280
617
|
band.className = "docx-hf-band";
|
|
281
618
|
band.setAttribute("data-hf-band", which);
|
|
282
|
-
const
|
|
283
|
-
|
|
619
|
+
const tag = document.createElement("div");
|
|
620
|
+
tag.className = "docx-hf-tag";
|
|
284
621
|
const label = document.createElement("span");
|
|
285
622
|
label.className = "docx-hf-label";
|
|
286
|
-
label.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
pageNum.setAttribute("data-hf-pagenum", "");
|
|
301
|
-
pageNum.title = "Insert a page-number field into the focused story paragraph";
|
|
302
|
-
for (const o of [
|
|
303
|
-
{ value: "", label: "Page №…" },
|
|
304
|
-
{ value: "currentPage", label: "Page number" },
|
|
305
|
-
{ value: "totalPages", label: "Total pages" },
|
|
306
|
-
]) {
|
|
307
|
-
const opt = document.createElement("option");
|
|
308
|
-
opt.value = o.value;
|
|
309
|
-
opt.textContent = o.label;
|
|
310
|
-
pageNum.appendChild(opt);
|
|
311
|
-
}
|
|
312
|
-
// The <select> steals focus from the story paragraph, collapsing the caret, so the target
|
|
313
|
-
// is resolved from the band's own blocks rather than from the editor's live selection.
|
|
314
|
-
pageNum.addEventListener("change", () => {
|
|
315
|
-
const field = pageNum.value;
|
|
316
|
-
pageNum.value = "";
|
|
317
|
-
if (field !== "currentPage" && field !== "totalPages")
|
|
318
|
-
return;
|
|
319
|
-
const target = this.pageNumberTarget(which);
|
|
320
|
-
if (target)
|
|
321
|
-
this.insertPageNumber(which, target, field);
|
|
322
|
-
});
|
|
323
|
-
chrome.appendChild(pageNum);
|
|
324
|
-
// Word's "Format Page Numbers…": these are SECTION properties (w:pgNumType), not properties of
|
|
325
|
-
// this band's story, so both bands show the same values and either can set them. An inserted
|
|
326
|
-
// page-number field is plain, so it renders through whatever is chosen here.
|
|
327
|
-
const pageFmt = document.createElement("select");
|
|
328
|
-
pageFmt.setAttribute("data-hf-pagefmt", "");
|
|
329
|
-
pageFmt.title = "Page-number format for this section";
|
|
330
|
-
for (const o of PAGE_FORMAT_LABELS) {
|
|
331
|
-
const opt = document.createElement("option");
|
|
332
|
-
opt.value = o.value;
|
|
333
|
-
opt.textContent = o.label;
|
|
334
|
-
pageFmt.appendChild(opt);
|
|
335
|
-
}
|
|
336
|
-
pageFmt.addEventListener("change", () => {
|
|
337
|
-
if (pageFmt.value === "")
|
|
338
|
-
return;
|
|
339
|
-
this.setPageNumbering({ format: pageFmt.value });
|
|
340
|
-
});
|
|
341
|
-
chrome.appendChild(pageFmt);
|
|
342
|
-
const pageStart = document.createElement("input");
|
|
343
|
-
pageStart.setAttribute("data-hf-pagestart", "");
|
|
344
|
-
pageStart.type = "number";
|
|
345
|
-
pageStart.min = "0";
|
|
346
|
-
pageStart.placeholder = "Start at";
|
|
347
|
-
pageStart.title = "Restart this section's page numbering at this number";
|
|
348
|
-
// Commit on blur/Enter, not on every keystroke: typing "12" would otherwise apply a start of 1
|
|
349
|
-
// first, and each apply is an undo step.
|
|
350
|
-
const commitStart = () => {
|
|
351
|
-
const raw = pageStart.value.trim();
|
|
352
|
-
if (raw === "")
|
|
353
|
-
return;
|
|
354
|
-
const start = Number(raw);
|
|
355
|
-
if (!Number.isInteger(start) || start < 0)
|
|
356
|
-
return;
|
|
357
|
-
this.setPageNumbering({ start });
|
|
358
|
-
};
|
|
359
|
-
pageStart.addEventListener("blur", commitStart);
|
|
360
|
-
pageStart.addEventListener("keydown", (e) => {
|
|
361
|
-
if (e.key === "Enter") {
|
|
362
|
-
e.preventDefault();
|
|
363
|
-
commitStart();
|
|
364
|
-
}
|
|
365
|
-
});
|
|
366
|
-
chrome.appendChild(pageStart);
|
|
367
|
-
const inheritedNote = document.createElement("span");
|
|
368
|
-
inheritedNote.className = "docx-hf-inherited";
|
|
369
|
-
inheritedNote.setAttribute("data-hf-inherited-note", "");
|
|
370
|
-
inheritedNote.hidden = true;
|
|
371
|
-
chrome.appendChild(inheritedNote);
|
|
372
|
-
band.appendChild(chrome);
|
|
373
|
-
const warning = document.createElement("div");
|
|
374
|
-
warning.className = "docx-hf-warning";
|
|
375
|
-
warning.setAttribute("data-hf-warning", "");
|
|
376
|
-
warning.hidden = true;
|
|
377
|
-
band.appendChild(warning);
|
|
623
|
+
label.setAttribute("data-hf-label", "");
|
|
624
|
+
label.textContent = KIND_LABELS.default[which];
|
|
625
|
+
tag.appendChild(label);
|
|
626
|
+
const kinds = document.createElement("span");
|
|
627
|
+
kinds.className = "docx-hf-kinds";
|
|
628
|
+
kinds.setAttribute("data-hf-kinds", "");
|
|
629
|
+
kinds.hidden = true;
|
|
630
|
+
tag.appendChild(kinds);
|
|
631
|
+
const note = document.createElement("span");
|
|
632
|
+
note.className = "docx-hf-inherited";
|
|
633
|
+
note.setAttribute("data-hf-inherited-note", "");
|
|
634
|
+
note.hidden = true;
|
|
635
|
+
tag.appendChild(note);
|
|
636
|
+
band.appendChild(tag);
|
|
378
637
|
const body = document.createElement("div");
|
|
379
638
|
body.className = "docx-hf-body";
|
|
380
639
|
body.setAttribute("data-hf-body", "");
|
|
381
640
|
band.appendChild(body);
|
|
382
641
|
return band;
|
|
383
642
|
}
|
|
384
|
-
/**
|
|
385
|
-
* Which story paragraph a page-number insert targets: the last one the user focused in this
|
|
386
|
-
* band if it is still attached, else the band's last paragraph (Word's convention — the page
|
|
387
|
-
* number goes at the end of the footer line).
|
|
388
|
-
*/
|
|
643
|
+
/** Which story paragraph a page-number insert targets: the focused one, else the last. */
|
|
389
644
|
pageNumberTarget(which) {
|
|
390
|
-
const
|
|
645
|
+
const host = this.pageRoot
|
|
646
|
+
? (this.activeHost && this.whichOf(this.activeHost) === which ? this.activeHost : null)
|
|
647
|
+
: (which === "header" ? this.headerBand : this.footerBand);
|
|
648
|
+
const body = host?.querySelector("[data-hf-body]");
|
|
391
649
|
if (!body)
|
|
392
650
|
return null;
|
|
393
651
|
const focused = body.querySelector('[data-hf-focused="true"][data-hf-anchor]');
|
|
394
652
|
const blocks = Array.from(body.querySelectorAll("[data-hf-anchor]"));
|
|
395
|
-
|
|
396
|
-
return target?.getAttribute("data-hf-anchor") ?? null;
|
|
653
|
+
return (focused ?? blocks[blocks.length - 1])?.getAttribute("data-hf-anchor") ?? null;
|
|
397
654
|
}
|
|
398
655
|
renderBand(which) {
|
|
399
|
-
const band = this.
|
|
656
|
+
const band = which === "header" ? this.headerBand : this.footerBand;
|
|
400
657
|
const body = band.querySelector("[data-hf-body]");
|
|
401
658
|
if (!body)
|
|
402
659
|
return;
|
|
403
660
|
const kind = this.kinds[which];
|
|
404
|
-
const
|
|
405
|
-
if (
|
|
406
|
-
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
661
|
+
const label = band.querySelector("[data-hf-label]");
|
|
662
|
+
if (label)
|
|
663
|
+
label.textContent = KIND_LABELS[kind][which];
|
|
664
|
+
// The story switcher shows only when there is more than one story to switch between.
|
|
665
|
+
const kinds = band.querySelector("[data-hf-kinds]");
|
|
666
|
+
if (kinds) {
|
|
667
|
+
const available = this.availableKinds(which);
|
|
668
|
+
kinds.hidden = available.length < 2;
|
|
669
|
+
kinds.replaceChildren();
|
|
670
|
+
for (const k of available) {
|
|
671
|
+
const button = document.createElement("button");
|
|
672
|
+
button.type = "button";
|
|
673
|
+
button.setAttribute("data-hf-kind", k);
|
|
674
|
+
button.textContent =
|
|
675
|
+
k === "default"
|
|
676
|
+
? (this.kindEnabled("even") ? "Odd pages" : "All pages")
|
|
677
|
+
: k === "first" ? "First page" : "Even pages";
|
|
678
|
+
button.toggleAttribute("data-on", k === kind);
|
|
679
|
+
button.addEventListener("mousedown", (e) => e.preventDefault());
|
|
680
|
+
button.addEventListener("click", () => this.setKind(which, k));
|
|
681
|
+
kinds.appendChild(button);
|
|
682
|
+
}
|
|
416
683
|
}
|
|
417
|
-
// A section that declares no reference of this kind shows the story it inherits from an
|
|
418
|
-
// earlier section. Say so: editing it changes BOTH sections, because they share one part.
|
|
419
684
|
const inherited = this.refFor(which, kind)?.inherited === true;
|
|
420
685
|
band.toggleAttribute("data-hf-inherited", inherited);
|
|
421
686
|
const note = band.querySelector("[data-hf-inherited-note]");
|
|
422
687
|
if (note) {
|
|
423
688
|
note.hidden = !inherited;
|
|
424
|
-
note.textContent = inherited ? "
|
|
689
|
+
note.textContent = inherited ? "Same as previous section" : "";
|
|
425
690
|
}
|
|
426
|
-
this.
|
|
691
|
+
this.fillStoryBody(body, which, kind);
|
|
692
|
+
}
|
|
693
|
+
/** Render the story's paragraphs into `body` (a placeholder when there is no story yet). */
|
|
694
|
+
fillStoryBody(body, which, kind) {
|
|
695
|
+
// A repaint replaces the story's nodes; if the caret was in one of them, put it back at the
|
|
696
|
+
// end of the story (where a page-number field just landed) instead of dropping it.
|
|
697
|
+
const hadFocus = body.contains(body.ownerDocument.activeElement);
|
|
427
698
|
body.innerHTML = "";
|
|
699
|
+
const band = body.closest("[data-hf-band]");
|
|
428
700
|
const partUri = this.partUriFor(which, kind);
|
|
429
|
-
|
|
430
|
-
body.appendChild(this.placeholder(which, kind));
|
|
431
|
-
band.setAttribute("data-hf-empty", "true");
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
band.removeAttribute("data-hf-empty");
|
|
435
|
-
const anchors = this.storyAnchors(partUri);
|
|
701
|
+
const anchors = partUri ? this.storyAnchors(partUri) : [];
|
|
436
702
|
if (anchors.length === 0) {
|
|
437
703
|
body.appendChild(this.placeholder(which, kind));
|
|
704
|
+
band?.setAttribute("data-hf-empty", "true");
|
|
438
705
|
return;
|
|
439
706
|
}
|
|
707
|
+
band?.removeAttribute("data-hf-empty");
|
|
708
|
+
let last = null;
|
|
440
709
|
for (const anchorId of anchors) {
|
|
441
|
-
const el = this.
|
|
710
|
+
const el = this.callbacks.renderBlock(anchorId);
|
|
442
711
|
if (!el)
|
|
443
712
|
continue;
|
|
444
713
|
body.appendChild(el);
|
|
@@ -446,13 +715,15 @@ export class HeaderFooterRegion {
|
|
|
446
715
|
// because a story paragraph's content-addressed unid can collide with another part's.
|
|
447
716
|
this.adoptBlock(el, anchorId);
|
|
448
717
|
this.callbacks.wireBlock(el);
|
|
718
|
+
last = el;
|
|
449
719
|
}
|
|
720
|
+
if (hadFocus && last)
|
|
721
|
+
placeCaretAtEnd(last);
|
|
450
722
|
}
|
|
451
723
|
/**
|
|
452
724
|
* Mark a story paragraph as belonging to this region. The full anchor id is stamped alongside
|
|
453
|
-
* `data-anchor` (which carries only the bare unid
|
|
454
|
-
*
|
|
455
|
-
* render AND after an incremental swap, which replaces the DOM node.
|
|
725
|
+
* `data-anchor` (which carries only the bare unid) so chrome can address the block without
|
|
726
|
+
* going through the editor's unid map. Called on first render AND after an incremental swap.
|
|
456
727
|
*/
|
|
457
728
|
adoptBlock(el, anchorId) {
|
|
458
729
|
const body = this.blockRootOf(el);
|
|
@@ -463,62 +734,57 @@ export class HeaderFooterRegion {
|
|
|
463
734
|
return;
|
|
464
735
|
el.dataset.hfAdopted = "true";
|
|
465
736
|
el.addEventListener("focus", () => {
|
|
466
|
-
body
|
|
467
|
-
.querySelectorAll("[data-hf-focused]")
|
|
468
|
-
.forEach((sib) => sib.removeAttribute("data-hf-focused"));
|
|
737
|
+
body.querySelectorAll("[data-hf-focused]").forEach((sib) => sib.removeAttribute("data-hf-focused"));
|
|
469
738
|
el.setAttribute("data-hf-focused", "true");
|
|
470
739
|
});
|
|
471
740
|
}
|
|
472
|
-
renderStoryBlock(anchorId) {
|
|
473
|
-
const html = this.bridge.RenderBlockHtml(this.handle, anchorId, this.options.cssPrefix, this.options.fabricateClasses);
|
|
474
|
-
if (html.charCodeAt(0) === 0x7b /* error object */)
|
|
475
|
-
return null;
|
|
476
|
-
return new DOMParser().parseFromString(html, "text/html").body
|
|
477
|
-
.firstElementChild;
|
|
478
|
-
}
|
|
479
741
|
placeholder(which, kind) {
|
|
480
742
|
const el = document.createElement("div");
|
|
481
743
|
el.className = "docx-hf-placeholder";
|
|
482
744
|
el.setAttribute("data-hf-placeholder", "");
|
|
483
|
-
|
|
484
|
-
el.
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
* when the counterpart story is missing entirely, which is what a user almost always wants next.
|
|
494
|
-
*/
|
|
495
|
-
renderKindWarning(which) {
|
|
496
|
-
const band = this.bandFor(which);
|
|
497
|
-
const warning = band.querySelector("[data-hf-warning]");
|
|
498
|
-
if (!warning)
|
|
499
|
-
return;
|
|
500
|
-
const kind = this.kinds[which];
|
|
501
|
-
if (kind === "default") {
|
|
502
|
-
warning.hidden = true;
|
|
503
|
-
warning.textContent = "";
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
const other = which === "header" ? "footer" : "header";
|
|
507
|
-
warning.hidden = false;
|
|
508
|
-
warning.textContent = `${kind === "even" ? EVEN_WARNING : FIRST_WARNING} `;
|
|
509
|
-
if (this.partUriFor(other, kind))
|
|
510
|
-
return; // counterpart exists — nothing to offer
|
|
511
|
-
const fix = document.createElement("button");
|
|
512
|
-
fix.type = "button";
|
|
513
|
-
fix.setAttribute("data-hf-fix-counterpart", "");
|
|
514
|
-
fix.textContent = `Also create a ${kind === "even" ? "matching even" : "first-page"} ${other}`;
|
|
515
|
-
fix.addEventListener("click", () => {
|
|
516
|
-
this.setKind(other, kind);
|
|
517
|
-
this.renderKindWarning(which);
|
|
745
|
+
el.textContent = `No ${KIND_LABELS[kind][which].toLowerCase()} yet — click here to add one.`;
|
|
746
|
+
el.addEventListener("mousedown", (event) => {
|
|
747
|
+
event.preventDefault();
|
|
748
|
+
if (!this.bodyAnchorId)
|
|
749
|
+
return;
|
|
750
|
+
this.seedStory(which, kind);
|
|
751
|
+
this.refresh(which);
|
|
752
|
+
const block = el.parentElement?.querySelector('[data-anchor][contenteditable="true"]');
|
|
753
|
+
if (block)
|
|
754
|
+
placeCaretAtEnd(block);
|
|
518
755
|
});
|
|
519
|
-
|
|
756
|
+
return el;
|
|
520
757
|
}
|
|
521
758
|
}
|
|
759
|
+
/** A presentation-only copy of a live story block for another page. */
|
|
760
|
+
function inertClone(el) {
|
|
761
|
+
const clone = el.cloneNode(true);
|
|
762
|
+
for (const node of [clone, ...Array.from(clone.querySelectorAll("*"))]) {
|
|
763
|
+
for (const attr of LIVE_BLOCK_ATTRS)
|
|
764
|
+
node.removeAttribute(attr);
|
|
765
|
+
}
|
|
766
|
+
return clone;
|
|
767
|
+
}
|
|
768
|
+
function placeCaretAtEnd(el) {
|
|
769
|
+
const doc = el.ownerDocument;
|
|
770
|
+
el.focus({ preventScroll: true });
|
|
771
|
+
const sel = doc.getSelection();
|
|
772
|
+
if (!sel)
|
|
773
|
+
return;
|
|
774
|
+
const range = doc.createRange();
|
|
775
|
+
range.selectNodeContents(el);
|
|
776
|
+
range.collapse(false);
|
|
777
|
+
sel.removeAllRanges();
|
|
778
|
+
sel.addRange(range);
|
|
779
|
+
}
|
|
780
|
+
function caretFromPoint(doc, x, y) {
|
|
781
|
+
const d = doc;
|
|
782
|
+
const position = d.caretPositionFromPoint?.(x, y);
|
|
783
|
+
if (position)
|
|
784
|
+
return { node: position.offsetNode, offset: position.offset };
|
|
785
|
+
const range = d.caretRangeFromPoint?.(x, y);
|
|
786
|
+
return range ? { node: range.startContainer, offset: range.startOffset } : null;
|
|
787
|
+
}
|
|
522
788
|
function parseResult(json) {
|
|
523
789
|
try {
|
|
524
790
|
return JSON.parse(json);
|