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.
Files changed (113) hide show
  1. package/README.md +7 -5
  2. package/dist/docxodus.worker.js +6 -38
  3. package/dist/docxodus.worker.js.map +1 -1
  4. package/dist/editor-comments.d.ts +129 -0
  5. package/dist/editor-comments.d.ts.map +1 -0
  6. package/dist/editor-comments.js +805 -0
  7. package/dist/editor-comments.js.map +1 -0
  8. package/dist/editor-headerfooter.d.ts +125 -85
  9. package/dist/editor-headerfooter.d.ts.map +1 -1
  10. package/dist/editor-headerfooter.js +572 -306
  11. package/dist/editor-headerfooter.js.map +1 -1
  12. package/dist/editor-image-patch.d.ts +38 -0
  13. package/dist/editor-image-patch.d.ts.map +1 -0
  14. package/dist/editor-image-patch.js +101 -0
  15. package/dist/editor-image-patch.js.map +1 -0
  16. package/dist/editor.bundle.js +3815 -778
  17. package/dist/editor.d.ts +237 -5
  18. package/dist/editor.d.ts.map +1 -1
  19. package/dist/editor.js +1063 -112
  20. package/dist/editor.js.map +1 -1
  21. package/dist/embed.bundle.js +3911 -849
  22. package/dist/embed.d.ts +1 -1
  23. package/dist/embed.d.ts.map +1 -1
  24. package/dist/embed.iife.js +3899 -837
  25. package/dist/embed.js +10 -1
  26. package/dist/embed.js.map +1 -1
  27. package/dist/export-assets.json +40 -46
  28. package/dist/export-browser.bundle.js +27 -3
  29. package/dist/index.d.ts +28 -64
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +68 -134
  32. package/dist/index.js.map +1 -1
  33. package/dist/pagination.bundle.js +25 -0
  34. package/dist/pagination.d.ts +6 -0
  35. package/dist/pagination.d.ts.map +1 -1
  36. package/dist/pagination.js +19 -0
  37. package/dist/pagination.js.map +1 -1
  38. package/dist/react.d.ts +4 -4
  39. package/dist/react.d.ts.map +1 -1
  40. package/dist/react.js.map +1 -1
  41. package/dist/ribbon-chrome.d.ts +20 -4
  42. package/dist/ribbon-chrome.d.ts.map +1 -1
  43. package/dist/ribbon-chrome.js +575 -184
  44. package/dist/ribbon-chrome.js.map +1 -1
  45. package/dist/ribbon.d.ts +5 -4
  46. package/dist/ribbon.d.ts.map +1 -1
  47. package/dist/ribbon.js +978 -129
  48. package/dist/ribbon.js.map +1 -1
  49. package/dist/session.bundle.js +100 -3
  50. package/dist/session.d.ts +60 -4
  51. package/dist/session.d.ts.map +1 -1
  52. package/dist/session.js +75 -3
  53. package/dist/session.js.map +1 -1
  54. package/dist/types.d.ts +194 -227
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js +9 -75
  57. package/dist/types.js.map +1 -1
  58. package/dist/viewport.d.ts +7 -0
  59. package/dist/viewport.d.ts.map +1 -1
  60. package/dist/viewport.js +12 -0
  61. package/dist/viewport.js.map +1 -1
  62. package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm +0 -0
  63. package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm.br +0 -0
  64. package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm +0 -0
  65. package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm.br +0 -0
  66. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  67. package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
  68. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  69. package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
  70. package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
  71. package/dist/wasm/_framework/System.Collections.Concurrent.wasm.br +0 -0
  72. package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
  73. package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm.br +0 -0
  74. package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
  75. package/dist/wasm/_framework/System.IO.Compression.wasm.br +0 -0
  76. package/dist/wasm/_framework/System.IO.Packaging.wasm +0 -0
  77. package/dist/wasm/_framework/System.IO.Packaging.wasm.br +0 -0
  78. package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
  79. package/dist/wasm/_framework/System.IO.Pipelines.wasm.br +0 -0
  80. package/dist/wasm/_framework/System.Linq.wasm +0 -0
  81. package/dist/wasm/_framework/System.Linq.wasm.br +0 -0
  82. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  83. package/dist/wasm/_framework/System.Private.CoreLib.wasm.br +0 -0
  84. package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
  85. package/dist/wasm/_framework/System.Private.Uri.wasm.br +0 -0
  86. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  87. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm.br +0 -0
  88. package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
  89. package/dist/wasm/_framework/System.Private.Xml.wasm.br +0 -0
  90. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
  91. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm.br +0 -0
  92. package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
  93. package/dist/wasm/_framework/System.Security.Cryptography.wasm.br +0 -0
  94. package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
  95. package/dist/wasm/_framework/System.Text.Encodings.Web.wasm.br +0 -0
  96. package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
  97. package/dist/wasm/_framework/System.Text.Json.wasm.br +0 -0
  98. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
  99. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm.br +0 -0
  100. package/dist/wasm/_framework/dotnet.boot.js +31 -37
  101. package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
  102. package/dist/wasm/_framework/dotnet.native.js +39 -3
  103. package/dist/wasm/_framework/dotnet.native.js.br +0 -0
  104. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  105. package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
  106. package/dist/worker-proxy.bundle.js +2 -3
  107. package/dist/worker-proxy.d.ts +2 -2
  108. package/dist/worker-proxy.d.ts.map +1 -1
  109. package/dist/worker-proxy.js +1 -2
  110. package/dist/worker-proxy.js.map +1 -1
  111. package/package.json +3 -3
  112. package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
  113. package/dist/wasm/_framework/System.Diagnostics.Process.wasm.br +0 -0
@@ -1,79 +1,67 @@
1
1
  /**
2
- * HeaderFooterRegion — the DocxEditor's docked header/footer editing bands.
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 renders them as two
6
- * docked bands — one above the body, one below — each composed PER STORY PARAGRAPH via the
7
- * session-attached `RenderBlockHtml`, which resolves `hdr`/`ftr` anchors natively.
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
- * Using the same renderer for first paint and for the post-edit incremental swap means there is
10
- * no fidelity drift between them. It is also the only option that works in continuous mode at
11
- * all: the editor's full-document render only asks for headers/footers in paginated mode, and it
12
- * never stamps `data-anchor` inside header/footer parts (Unids are assigned to the main document
13
- * part only). In paginated mode the page boxes additionally clone one header node onto every
14
- * page, so page-margin nodes could never be uniquely addressable — bands keep exactly one DOM
15
- * node per story paragraph.
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 band blocks with the same `wireBlock` it
20
- * uses for the body, which is why the whole ribbon works inside a band with no new command code.
21
- */
22
- /** The page-number formats Word's *Format Page Numbers…* dialog offers, in its order. The blank
23
- * entry means "leave the section's format alone" — not "decimal", which would write an attribute
24
- * the document may never have had. */
25
- const PAGE_FORMAT_LABELS = [
26
- { value: "", label: "Format…" },
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
- const FIRST_WARNING = "w:titlePg makes page 1 use its own first-page header AND footer instead of the Default " +
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, options, callbacks) {
58
- /** The body anchor whose section the bands currently describe (needed to seed a story). */
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
- this.kinds = {
64
- header: "default",
65
- footer: "default",
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 bands at the section governing `bodyAnchorId` and repaint if it changed.
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 the bands as they are
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
- /** Repaint one band from the live session. */
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.renderBand(which);
96
+ if (this.pageRoot)
97
+ this.repaintPageStory(which);
98
+ else
99
+ this.renderBand(which);
103
100
  }
104
- /** Repaint both bands from the live session (after a remount, undo/redo, or section change). */
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 edits, and make it render. */
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 section's
118
- // visibility flag must be set even when the part already exists — seedStory (which sets it as
119
- // a side effect of writing content) doesn't run then. Word leaves first/even parts behind when
120
- // those options are switched off, so a real document commonly has the reference WITHOUT the
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}. Stamping the section's current
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
- * Set this section's page numbering (`w:pgNumType`) — Word's *Format Page Numbers…*. Omitted
144
- * fields are left alone, so the format and the start are independently settable. Both bands then
145
- * repaint, because the values belong to the section rather than to either story.
146
- *
147
- * The rendered page numbers in the editor do not change: a page-number field's cached result is
148
- * what the browser shows, and Word recomputes it on open. Paginated mode substitutes the real
149
- * per-page number, so it does reflect the format immediately.
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. Fields are absent, not
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 — it reverts to continuing the previous
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
- return band === this.headerBand || band === this.footerBand ? band : null;
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 band's story container) owning `node`, or null. */
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 either band (including its chrome). */
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 band element produced by this region. */
263
+ /** `"header"` / `"footer"` for a story host produced by this region. */
217
264
  whichOf(band) {
218
- return band === this.footerBand ? "footer" : "header";
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 raw = this.bridge.GetSectionInfo(this.handle, bodyAnchorId);
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
- index = JSON.parse(this.bridge.Project(this.handle)).anchorIndex;
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
- // ─── rendering ───────────────────────────────────────────────────────
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 chrome = document.createElement("div");
283
- chrome.className = "docx-hf-chrome";
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.textContent = which === "header" ? "Header" : "Footer";
287
- chrome.appendChild(label);
288
- const kindSelect = document.createElement("select");
289
- kindSelect.setAttribute("data-hf-kind", "");
290
- kindSelect.title = "Which header/footer story to edit";
291
- for (const k of KIND_LABELS) {
292
- const opt = document.createElement("option");
293
- opt.value = k.value;
294
- opt.textContent = k.label;
295
- kindSelect.appendChild(opt);
296
- }
297
- kindSelect.addEventListener("change", () => this.setKind(which, kindSelect.value));
298
- chrome.appendChild(kindSelect);
299
- const pageNum = document.createElement("select");
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 body = this.bandFor(which).querySelector("[data-hf-body]");
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
- const target = focused ?? blocks[blocks.length - 1];
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.bandFor(which);
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 select = band.querySelector("[data-hf-kind]");
405
- if (select && select.value !== kind)
406
- select.value = kind;
407
- // Section-level page numbering — the same values on both bands, seeded from the live sectPr so
408
- // the chrome reports what the document says rather than what was last clicked.
409
- const pageFmt = band.querySelector("[data-hf-pagefmt]");
410
- if (pageFmt)
411
- pageFmt.value = this.sectionInfo?.pageNumberFormat ?? "";
412
- const pageStart = band.querySelector("[data-hf-pagestart]");
413
- if (pageStart && document.activeElement !== pageStart) {
414
- const start = this.sectionInfo?.pageNumberStart;
415
- pageStart.value = start === undefined ? "" : String(start);
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 ? "inherited from an earlier section" : "";
689
+ note.textContent = inherited ? "Same as previous section" : "";
425
690
  }
426
- this.renderKindWarning(which);
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
- if (!partUri) {
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.renderStoryBlock(anchorId);
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, matching the body's convention) so band
454
- * chrome can address the block without going through the editor's unid map. Called on first
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
- const label = KIND_LABELS.find((k) => k.value === kind)?.label ?? kind;
484
- el.textContent = `No ${label.toLowerCase()} ${which} — pick a kind to create one.`;
485
- return el;
486
- }
487
- /**
488
- * Surface the caveat that comes with the selected kind. Turning on first/even means those pages
489
- * stop using the Default stories entirely, which bites hardest on the OTHER band: enabling an
490
- * even header with no even footer leaves even pages footer-less, and enabling a first-page
491
- * header with an empty first-page footer leaves page 1 footer-less. The note is shown whenever
492
- * first/even is selected (the behavior change is real either way); the fix button appears only
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
- warning.appendChild(fix);
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);