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
package/dist/ribbon.js CHANGED
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * `DocxEditor` (editor.ts) is the document engine: it owns the live `DocxSession`,
5
5
  * the block wiring and every command. It has deliberately no chrome. This module is
6
- * the chrome — the tabbed ribbon, the anchor rail, the table picker and the loading
7
- * overlay — wired onto exactly that command surface and nothing else.
6
+ * the chrome — the tabbed ribbon, the status bar (with the anchor rail), the table
7
+ * picker, the find bar and the loading overlay — wired onto exactly that command
8
+ * surface and nothing else.
8
9
  *
9
10
  * It exists because the same UI was hand-written three times (the standalone demo,
10
11
  * the GitHub Pages landing page, the compact iframe player) and drifted. Now the
@@ -24,7 +25,9 @@
24
25
  * embed on a wide desktop page is narrow. See ribbon-chrome.ts for the layout.
25
26
  */
26
27
  import { DocxEditor } from "./editor.js";
27
- import { RIBBON_CSS, RIBBON_HINT_HTML, RIBBON_HTML, RIBBON_STYLE_ATTR, RIBBON_STYLE_VERSION, } from "./ribbon-chrome.js";
28
+ import { threadMembers } from "./editor-comments.js";
29
+ import { TrackedChangeMode } from "./types.js";
30
+ import { COMMON_FONTS, HIGHLIGHT_COLORS, RIBBON_CSS, RIBBON_HINT_HTML, RIBBON_HTML, RIBBON_STYLE_ATTR, RIBBON_STYLE_VERSION, } from "./ribbon-chrome.js";
28
31
  // Re-exported so the IIFE bundle built from this module (window.DocxodusEditor)
29
32
  // carries both the shell and the engine it wraps.
30
33
  export { DocxEditor } from "./editor.js";
@@ -63,17 +66,32 @@ const DEFAULT_FEATURES = [
63
66
  /** Every `data-dxr` name the template defines — the id-collision probe set. */
64
67
  const CONTROL_NAMES = [
65
68
  "docname", "new", "file", "save", "undo", "redo", "status", "ribbon",
66
- "fontsize", "fontsizes", "fontfamily", "style", "delblock",
67
- "table", "hr", "hrThick", "hrDouble", "rulepos", "hrClear", "footnote", "endnote",
68
- "paginated", "headerfooter", "pgfmt", "pgstart", "pgclear", "pagenum", "totalpages",
69
- "railAnchor", "railBlocks", "railSession", "railOp",
69
+ "fontsize", "fontsizes", "fontfamily", "fontgrow", "fontshrink", "clearformat", "smallcaps",
70
+ "fontcolorbtn", "fontcolorbar", "fontcolor", "highlight", "listmenu", "linespacing",
71
+ "style", "delblock", "findtoggle", "replacetoggle",
72
+ "table", "picturefile", "link", "unlink", "insertcomment", "gotoheader", "gotofooter", "pagenummenu",
73
+ "hr", "hrThick", "hrDouble", "rulepos", "hrClear",
74
+ "margins", "orientation", "pagesize", "pagesetupnote", "spacebefore", "spaceafter",
75
+ "specialindent", "specialindentval", "pgfmt", "pgstart", "pgclear", "paginated", "headerfooter",
76
+ "toc", "footnote", "endnote", "pagenum", "totalpages",
77
+ "comment", "commentdelete", "commentprev", "commentnext", "commentresolve", "showcomments",
78
+ "commentcount", "trackchanges", "markup", "author", "revcount",
79
+ "viewpage", "viewweb", "zoom", "zoomfit", "showrail", "showhint",
80
+ "shadebtn", "shadebar", "shade", "repeatheader",
81
+ "hfFirst", "hfEven", "hfstory",
82
+ "findbar", "findtext", "findprev", "findnext", "findcount", "findcase", "replacegroup",
83
+ "replacetext", "replaceone", "replaceall", "findclose",
84
+ "railAnchor", "railBlocks", "railSession", "railOp", "pageinfo", "wordcount",
85
+ "zoomout", "zoomlevel", "zoomin",
70
86
  "gridpicker", "gridcells", "gridlabel", "gridalign", "gridborderless",
87
+ "linkpop", "linkform", "linkurl", "linkcancel",
71
88
  "editor",
72
89
  "loader", "loaderEyebrow", "loaderTitle", "loaderCopy", "loaderAd", "loaderNumber",
73
90
  "loaderAdTitle", "loaderAdCopy", "loaderBar", "loaderLabel", "loaderMeta", "loaderRetry",
74
91
  ];
75
92
  const GRID_ROWS = 8;
76
93
  const GRID_COLS = 10;
94
+ const ZOOM_STEPS = [0.5, 0.75, 0.9, 1, 1.25, 1.5, 2];
77
95
  let nextIdPrefixSeed = 0;
78
96
  /** Inject the ribbon stylesheet once per document (replacing an older version). */
79
97
  function ensureStyles(doc) {
@@ -110,6 +128,16 @@ function resolveContainer(container) {
110
128
  throw new Error(`Docxodus ribbon: no element matches "${container}"`);
111
129
  return el;
112
130
  }
131
+ /** CSS colour → "#rrggbb" (for the colour inputs), or null when it cannot be expressed. */
132
+ function toHex(color) {
133
+ const m = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(color);
134
+ if (m) {
135
+ return "#" + [m[1], m[2], m[3]].map((n) => parseInt(n, 10).toString(16).padStart(2, "0")).join("");
136
+ }
137
+ if (/^#[0-9a-f]{6}$/i.test(color))
138
+ return color.toLowerCase();
139
+ return null;
140
+ }
113
141
  /**
114
142
  * Mount the ribbon surface into `container`.
115
143
  *
@@ -128,13 +156,21 @@ class RibbonSurface {
128
156
  this.featureTimer = null;
129
157
  this.featureIndex = 0;
130
158
  this.resizeObserver = null;
159
+ this.strips = [];
160
+ this.stripObserver = null;
131
161
  this.lastAnchorText = "";
132
162
  this.selectionFrame = null;
163
+ this.statsTimer = null;
164
+ this.findMatches = [];
165
+ this.findIndex = -1;
133
166
  this.options = options;
134
167
  this.exports = options.exports ?? null;
135
168
  this.documentName = options.documentName ?? "untitled.docx";
136
169
  this.chromeMode = options.chrome ?? "auto";
137
- this.headerFooter = options.headerFooter ?? false;
170
+ // Headers and footers are document content, so the surface edits them by default; the
171
+ // engine's own default stays off for consumers that mount `DocxEditor` bare.
172
+ this.headerFooter = options.headerFooter ?? true;
173
+ this.author = options.commentAuthor ?? options.revisionAuthor ?? "Reviewer";
138
174
  // false suppresses the overlay; true/omitted takes the defaults; an object rewords it.
139
175
  this.loaderOptions =
140
176
  options.loader === false
@@ -163,7 +199,9 @@ class RibbonSurface {
163
199
  this.surface = this.require("editor");
164
200
  this.applyStaticOptions();
165
201
  this.buildGrid();
202
+ this.populateStaticMenus();
166
203
  this.wire();
204
+ this.wireScrollFades();
167
205
  this.applyChrome();
168
206
  this.loader = this.createLoaderController();
169
207
  if (this.loaderOptions)
@@ -179,8 +217,10 @@ class RibbonSurface {
179
217
  });
180
218
  };
181
219
  doc.addEventListener("selectionchange", this.onSelectionChange);
182
- this.onDocumentMouseDown = (event) => this.maybeClosePicker(event);
220
+ this.onDocumentMouseDown = (event) => this.maybeClosePopovers(event);
183
221
  doc.addEventListener("mousedown", this.onDocumentMouseDown);
222
+ this.onKeydown = (event) => this.handleShortcut(event);
223
+ root.addEventListener("keydown", this.onKeydown);
184
224
  if (this.chromeMode === "auto" && typeof ResizeObserver !== "undefined") {
185
225
  this.resizeObserver = new ResizeObserver(() => this.applyChrome());
186
226
  this.resizeObserver.observe(root);
@@ -214,9 +254,13 @@ class RibbonSurface {
214
254
  if (!this.loaderOptions)
215
255
  this.control("loader")?.remove();
216
256
  this.require("docname").textContent = this.documentName;
217
- (this.require("paginated")).checked = this.options.paginated ?? false;
218
- (this.require("headerfooter")).checked = this.headerFooter;
257
+ this.require("paginated").checked = this.options.paginated ?? false;
258
+ this.require("headerfooter").checked = this.headerFooter;
259
+ this.require("trackchanges").checked =
260
+ this.options.trackedChanges === TrackedChangeMode.RenderInline;
261
+ this.require("author").value = this.author;
219
262
  this.surface.dataset.view = this.options.paginated ? "paginated" : "continuous";
263
+ this.surface.dataset.comments = this.options.comments === false ? "off" : "on";
220
264
  }
221
265
  buildGrid() {
222
266
  const cells = this.require("gridcells");
@@ -231,6 +275,96 @@ class RibbonSurface {
231
275
  }
232
276
  cells.replaceChildren(fragment);
233
277
  }
278
+ /** Menus whose entries do not depend on the document. */
279
+ populateStaticMenus() {
280
+ const highlight = this.require("highlight");
281
+ for (const color of HIGHLIGHT_COLORS) {
282
+ const opt = document.createElement("option");
283
+ opt.value = color.value;
284
+ opt.textContent = `■ ${color.label}`;
285
+ opt.style.color = color.css;
286
+ highlight.appendChild(opt);
287
+ }
288
+ this.populateFonts([]);
289
+ }
290
+ /** The font menu: the document's own fonts first, then the common list, no duplicates. */
291
+ populateFonts(documentFonts) {
292
+ const select = this.require("fontfamily");
293
+ const seen = new Set();
294
+ select.replaceChildren();
295
+ const placeholder = document.createElement("option");
296
+ placeholder.value = "";
297
+ placeholder.textContent = "Font…";
298
+ select.appendChild(placeholder);
299
+ const add = (name, group) => {
300
+ const key = name.toLowerCase();
301
+ if (seen.has(key))
302
+ return;
303
+ seen.add(key);
304
+ const opt = document.createElement("option");
305
+ opt.value = name;
306
+ opt.textContent = name;
307
+ opt.style.fontFamily = `"${name}", sans-serif`;
308
+ group.appendChild(opt);
309
+ };
310
+ if (documentFonts.length > 0) {
311
+ const group = document.createElement("optgroup");
312
+ group.label = "In this document";
313
+ for (const name of documentFonts)
314
+ add(name, group);
315
+ select.appendChild(group);
316
+ }
317
+ const common = document.createElement("optgroup");
318
+ common.label = "Common fonts";
319
+ for (const name of COMMON_FONTS)
320
+ add(name, common);
321
+ select.appendChild(common);
322
+ }
323
+ /** The style gallery: every paragraph style the document defines, quick styles first, plus
324
+ * the built-ins Word always offers (the engine creates a missing built-in on first use). */
325
+ populateStyles() {
326
+ const select = this.require("style");
327
+ const styles = this.live?.styles() ?? [];
328
+ const paragraphStyles = styles.filter((s) => s.type === "paragraph" && !s.semiHidden);
329
+ if (paragraphStyles.length === 0)
330
+ return; // older bundle: keep the static five
331
+ const have = new Set(paragraphStyles.map((s) => s.id.toLowerCase()));
332
+ const builtIns = [
333
+ ["Normal", "Normal", 0], ["Heading1", "Heading 1", 9], ["Heading2", "Heading 2", 9],
334
+ ["Heading3", "Heading 3", 9], ["Title", "Title", 10], ["Subtitle", "Subtitle", 11],
335
+ ["Quote", "Quote", 29], ["ListParagraph", "List Paragraph", 34],
336
+ ];
337
+ for (const [id, name, priority] of builtIns) {
338
+ if (have.has(id.toLowerCase()))
339
+ continue;
340
+ paragraphStyles.push({
341
+ id, name, type: "paragraph", isDefault: false, isCustom: false, hasLatentException: false,
342
+ uiPriority: priority, quickFormat: true,
343
+ });
344
+ }
345
+ paragraphStyles.sort((a, b) => {
346
+ const qa = a.quickFormat ? 0 : 1;
347
+ const qb = b.quickFormat ? 0 : 1;
348
+ if (qa !== qb)
349
+ return qa - qb;
350
+ const pa = a.uiPriority ?? 99;
351
+ const pb = b.uiPriority ?? 99;
352
+ if (pa !== pb)
353
+ return pa - pb;
354
+ return a.name.localeCompare(b.name);
355
+ });
356
+ select.replaceChildren();
357
+ const placeholder = document.createElement("option");
358
+ placeholder.value = "";
359
+ placeholder.textContent = "Style…";
360
+ select.appendChild(placeholder);
361
+ for (const style of paragraphStyles) {
362
+ const opt = document.createElement("option");
363
+ opt.value = style.id;
364
+ opt.textContent = style.name;
365
+ select.appendChild(opt);
366
+ }
367
+ }
234
368
  // ── chrome density ──────────────────────────────────────────────────────────
235
369
  get chrome() {
236
370
  return this.density;
@@ -255,8 +389,41 @@ class RibbonSurface {
255
389
  return;
256
390
  this.density = next;
257
391
  this.element.dataset.chrome = next;
258
- // The picker's absolute position is meaningless after a density flip.
259
- this.closePicker();
392
+ // A popover's absolute position is meaningless after a density flip.
393
+ this.closePopovers();
394
+ // A density flip relays out every strip; re-measure without waiting for
395
+ // the observer so the fades never lag a frame behind the new layout.
396
+ this.syncAllStripFades();
397
+ }
398
+ // ── scroll-edge affordances ─────────────────────────────────────────────────
399
+ syncStripFade(strip) {
400
+ const max = strip.scrollWidth - strip.clientWidth;
401
+ const tokens = max <= 1 ? "" : `${strip.scrollLeft > 1 ? "l " : ""}${strip.scrollLeft < max - 1 ? "r" : ""}`.trim();
402
+ if (tokens)
403
+ strip.setAttribute("data-fade", tokens);
404
+ else
405
+ strip.removeAttribute("data-fade");
406
+ }
407
+ syncAllStripFades() {
408
+ for (const strip of this.strips)
409
+ this.syncStripFade(strip);
410
+ }
411
+ wireScrollFades() {
412
+ this.strips = Array.from(this.element.querySelectorAll(".dxr-titlebar, .dxr-tabs, .dxr-panel, .dxr-rail, .dxr-findbar"));
413
+ if (typeof ResizeObserver !== "undefined") {
414
+ // One observer for all strips. It also fires when a panel becomes the
415
+ // active one (display flips from none), which is what keeps a freshly
416
+ // selected tab's fades honest without per-tab wiring.
417
+ this.stripObserver = new ResizeObserver((entries) => {
418
+ for (const entry of entries)
419
+ this.syncStripFade(entry.target);
420
+ });
421
+ }
422
+ for (const strip of this.strips) {
423
+ strip.addEventListener("scroll", () => this.syncStripFade(strip), { passive: true });
424
+ this.stripObserver?.observe(strip);
425
+ this.syncStripFade(strip);
426
+ }
260
427
  }
261
428
  // ── status ──────────────────────────────────────────────────────────────────
262
429
  /** Publish the lifecycle on the root, where CSS and host pages can key off it. */
@@ -294,7 +461,11 @@ class RibbonSurface {
294
461
  const paginated = this.require("paginated").checked;
295
462
  this.surface.dataset.view = paginated ? "paginated" : "continuous";
296
463
  this.surface.replaceChildren();
464
+ this.closeFindBar();
297
465
  const started = performance.now();
466
+ const tracked = this.require("trackchanges").checked
467
+ ? TrackedChangeMode.RenderInline
468
+ : (this.options.trackedChanges ?? TrackedChangeMode.Accept);
298
469
  this.live = DocxEditor.open(this.surface, bytes, this.exports, {
299
470
  cssPrefix: this.options.cssPrefix,
300
471
  fabricateClasses: this.options.fabricateClasses,
@@ -302,21 +473,34 @@ class RibbonSurface {
302
473
  scale: this.options.scale,
303
474
  columnWidth: this.options.columnWidth,
304
475
  fitToWidth: this.options.fitToWidth,
305
- onEdit: this.options.onEdit,
476
+ onEdit: (info) => {
477
+ this.options.onEdit?.(info);
478
+ this.scheduleStats();
479
+ },
306
480
  onMove: this.options.onMove,
481
+ onStoryChange: (which) => this.onStoryChange(which),
482
+ onCommentsChange: (info) => this.onCommentsChange(info),
307
483
  paginated,
308
484
  headerFooter: this.headerFooter,
309
485
  blockDrag: this.options.blockDrag ?? true,
310
- trackedChanges: this.options.trackedChanges,
311
- revisionAuthor: this.options.revisionAuthor,
486
+ trackedChanges: tracked,
487
+ revisionAuthor: this.author,
488
+ comments: this.options.comments,
489
+ commentAuthor: this.author,
312
490
  });
313
491
  this.require("save").disabled = false;
314
492
  this.require("ribbon").setAttribute("aria-disabled", "false");
315
493
  this.setState("ready");
316
494
  this.setStatus(`Rendered in ${Math.round(performance.now() - started)} ms`);
495
+ this.populateStyles();
496
+ this.populateFonts(this.documentFonts());
317
497
  this.syncPageNumbering();
498
+ this.syncLayoutState();
499
+ this.syncReviewState();
500
+ this.syncZoom();
318
501
  this.refreshRailCounts();
319
502
  this.refreshRailAnchor();
503
+ this.scheduleStats();
320
504
  this.options.onOpen?.(this.live);
321
505
  return this.live;
322
506
  }
@@ -354,11 +538,16 @@ class RibbonSurface {
354
538
  const doc = this.element.ownerDocument ?? document;
355
539
  doc.removeEventListener("selectionchange", this.onSelectionChange);
356
540
  doc.removeEventListener("mousedown", this.onDocumentMouseDown);
541
+ this.element.removeEventListener("keydown", this.onKeydown);
357
542
  this.resizeObserver?.disconnect();
358
543
  this.resizeObserver = null;
544
+ this.stripObserver?.disconnect();
545
+ this.stripObserver = null;
359
546
  if (this.selectionFrame != null)
360
547
  cancelAnimationFrame(this.selectionFrame);
361
548
  this.selectionFrame = null;
549
+ if (this.statsTimer != null)
550
+ clearTimeout(this.statsTimer);
362
551
  this.stopRotation();
363
552
  try {
364
553
  this.live?.close();
@@ -391,6 +580,7 @@ class RibbonSurface {
391
580
  this.options.onCommand?.(label, ms);
392
581
  this.refreshRailCounts();
393
582
  this.refreshRailAnchor();
583
+ this.scheduleStats();
394
584
  }
395
585
  }
396
586
  /** Format controls must not steal the document selection they are about to act on. */
@@ -422,9 +612,18 @@ class RibbonSurface {
422
612
  colRight: () => this.live.insertTableColumn("right"),
423
613
  delRow: () => this.live.deleteTableRow(),
424
614
  delCol: () => this.live.deleteTableColumn(),
615
+ mergeRight: () => this.live.mergeCells(1, 2),
616
+ mergeDown: () => this.live.mergeCells(2, 1),
617
+ unmerge: () => this.live.unmergeCells(),
618
+ borders: () => this.live.setTableBorders({ scope: "all", style: "single", size: 4 }),
619
+ noborders: () => this.live.setTableBorders({ scope: "all", style: "none" }),
620
+ noshade: () => this.live.setCellShading("", "cell"),
621
+ delTable: () => this.live.deleteTable(),
425
622
  };
426
623
  ops[b.dataset.tt ?? ""]?.();
427
624
  });
625
+ delegate("button[data-rev]", (b) => `revision ${b.dataset.rev}`, (b) => this.runRevisionCommand(b.dataset.rev ?? ""));
626
+ delegate("button[data-hf]", (b) => `header/footer ${b.dataset.hf}`, (b) => this.runHeaderFooterCommand(b.dataset.hf ?? ""));
428
627
  const rulepos = () => (this.require("rulepos").value === "above" ? "above" : "below");
429
628
  const simple = [
430
629
  ["undo", "undo", () => this.live.undo()],
@@ -438,6 +637,18 @@ class RibbonSurface {
438
637
  ["endnote", "endnote", () => this.live.insertEndnote()],
439
638
  ["pagenum", "page number", () => this.live.insertPageNumber("currentPage")],
440
639
  ["totalpages", "total pages", () => this.live.insertPageNumber("totalPages")],
640
+ ["fontgrow", "grow font", () => this.live.adjustFontSize(1)],
641
+ ["fontshrink", "shrink font", () => this.live.adjustFontSize(-1)],
642
+ ["clearformat", "clear formatting", () => this.live.clearFormatting()],
643
+ ["toc", "table of contents", () => this.live.insertTableOfContents()],
644
+ ["unlink", "remove link", () => this.live.removeHyperlink()],
645
+ ["gotoheader", "go to header", () => this.live.goToHeaderFooter("header")],
646
+ ["gotofooter", "go to footer", () => this.live.goToHeaderFooter("footer")],
647
+ ["viewpage", "page view", () => this.setPaginated(true)],
648
+ ["viewweb", "continuous view", () => this.setPaginated(false)],
649
+ ["zoomfit", "fit width", () => this.setZoom(1)],
650
+ ["zoomin", "zoom in", () => this.stepZoom(1)],
651
+ ["zoomout", "zoom out", () => this.stepZoom(-1)],
441
652
  ];
442
653
  for (const [name, label, fn] of simple) {
443
654
  const el = this.control(name);
@@ -446,6 +657,15 @@ class RibbonSurface {
446
657
  this.keepSelection(el);
447
658
  el.addEventListener("click", () => this.run(label, fn));
448
659
  }
660
+ const smallcaps = this.control("smallcaps");
661
+ if (smallcaps) {
662
+ this.keepSelection(smallcaps);
663
+ smallcaps.addEventListener("click", () => {
664
+ const on = smallcaps.getAttribute("aria-pressed") !== "true";
665
+ this.run("small caps", () => this.live.setSmallCaps(on));
666
+ smallcaps.setAttribute("aria-pressed", String(on));
667
+ });
668
+ }
449
669
  // Font size: `change` only (it fires on Enter and on blur). Binding keydown as
450
670
  // well fired twice and stacked two undo snapshots for one size change.
451
671
  const fontsize = this.require("fontsize");
@@ -460,37 +680,115 @@ class RibbonSurface {
460
680
  fontsize.blur();
461
681
  }
462
682
  });
463
- const fontfamily = this.require("fontfamily");
464
- fontfamily.addEventListener("change", () => {
465
- if (this.live && fontfamily.value)
466
- this.run("font family", () => this.live.setFontFamily(fontfamily.value));
467
- fontfamily.value = "";
468
- });
469
- const style = this.require("style");
470
- style.addEventListener("change", () => {
471
- if (this.live && style.value)
472
- this.run("style", () => this.live.setParagraphStyle(style.value));
473
- style.value = "";
474
- });
475
- // Comment authoring (issue #580): the button comments the live selection (whole block
476
- // when collapsed) with the typed text, attributed to the configured revision author.
477
- const commentButton = this.control("comment");
478
- if (commentButton) {
479
- this.keepSelection(commentButton);
480
- commentButton.addEventListener("click", () => {
481
- if (!this.live)
482
- return;
483
- const input = this.control("commenttext");
484
- const text = input?.value.trim() || "New comment.";
485
- this.run("comment", () => this.live.addComment(text, this.options.revisionAuthor ?? "Reviewer"));
486
- if (input)
487
- input.value = "";
488
- this.renderThreads();
489
- });
683
+ this.wireSelect("fontfamily", "font family", (v) => this.live.setFontFamily(v));
684
+ this.wireSelect("style", "style", (v) => this.live.setParagraphStyle(v));
685
+ this.wireSelect("listmenu", "list style", (v) => this.live.setListFormat(v));
686
+ this.wireSelect("linespacing", "line spacing", (v) => this.live.setLineSpacing(parseFloat(v)));
687
+ this.wireSelect("highlight", "highlight", (v) => this.live.setHighlight(v === "none" ? "" : v));
688
+ this.wireSelect("pagenummenu", "page number", (v) => this.live.insertPageNumber(v === "pageOf" ? "pageOfTotal" : v));
689
+ // Colour pickers: the native input steals focus, so the engine's last-selection
690
+ // bookmark is what the command applies to.
691
+ const fontcolor = this.require("fontcolor");
692
+ fontcolor.addEventListener("input", () => {
693
+ this.require("fontcolorbar").style.setProperty("--dxr-swatch", fontcolor.value);
694
+ });
695
+ fontcolor.addEventListener("change", () => {
696
+ this.require("fontcolorbar").style.setProperty("--dxr-swatch", fontcolor.value);
697
+ this.run("font color", () => this.live.setFontColor(fontcolor.value));
698
+ });
699
+ const fontcolorbtn = this.control("fontcolorbtn");
700
+ if (fontcolorbtn) {
701
+ this.keepSelection(fontcolorbtn);
702
+ fontcolorbtn.addEventListener("click", () => this.run("font color", () => this.live.setFontColor(fontcolor.value)));
703
+ }
704
+ const shade = this.require("shade");
705
+ shade.addEventListener("input", () => this.require("shadebar").style.setProperty("--dxr-swatch", shade.value));
706
+ shade.addEventListener("change", () => {
707
+ this.require("shadebar").style.setProperty("--dxr-swatch", shade.value);
708
+ this.run("cell shading", () => this.live.setCellShading(shade.value, "cell"));
709
+ });
710
+ const shadebtn = this.control("shadebtn");
711
+ if (shadebtn) {
712
+ this.keepSelection(shadebtn);
713
+ shadebtn.addEventListener("click", () => this.run("cell shading", () => this.live.setCellShading(shade.value, "cell")));
714
+ }
715
+ const repeatheader = this.require("repeatheader");
716
+ repeatheader.addEventListener("change", () => this.run("repeat header row", () => this.live.setRepeatHeaderRow(repeatheader.checked)));
717
+ // Comments: New/Insert open a draft bubble in the gutter (Word's flow); the rest act on the
718
+ // active thread.
719
+ for (const name of ["comment", "insertcomment"]) {
720
+ const button = this.control(name);
721
+ if (!button)
722
+ continue;
723
+ this.keepSelection(button);
724
+ button.addEventListener("click", () => this.beginComment());
490
725
  }
726
+ this.control("commentdelete")?.addEventListener("click", () => {
727
+ const active = this.live?.activeComment;
728
+ if (!active)
729
+ return;
730
+ this.run("delete comment", () => {
731
+ for (const id of threadMembers(this.live.listComments(), active))
732
+ this.live.removeComment(id);
733
+ this.live.removeComment(active);
734
+ });
735
+ });
736
+ this.control("commentresolve")?.addEventListener("click", () => {
737
+ const active = this.live?.activeComment;
738
+ if (!active)
739
+ return;
740
+ const entry = this.live.listComments().find((c) => c.anchorId === active);
741
+ if (!entry)
742
+ return;
743
+ this.run(entry.resolved ? "reopen comment" : "resolve comment", () => this.live.setCommentResolved(active, !entry.resolved));
744
+ this.live.activateComment(active);
745
+ });
746
+ this.control("commentprev")?.addEventListener("click", () => this.live?.stepComment(-1));
747
+ this.control("commentnext")?.addEventListener("click", () => this.live?.stepComment(1));
748
+ const showcomments = this.require("showcomments");
749
+ showcomments.addEventListener("change", () => {
750
+ this.live?.showComments(showcomments.checked);
751
+ this.surface.dataset.comments = showcomments.checked && this.options.comments !== false ? "on" : "off";
752
+ });
753
+ // Tracking.
754
+ const trackchanges = this.require("trackchanges");
755
+ trackchanges.addEventListener("change", () => {
756
+ if (!this.live)
757
+ return;
758
+ this.run(trackchanges.checked ? "track changes on" : "track changes off", () => this.live.setTrackedChanges(trackchanges.checked ? TrackedChangeMode.RenderInline : TrackedChangeMode.Accept));
759
+ this.syncReviewState();
760
+ });
761
+ const markup = this.require("markup");
762
+ markup.addEventListener("change", () => {
763
+ if (!this.live)
764
+ return;
765
+ // "No markup" shows the document as if every change were accepted; edits keep recording.
766
+ this.run(`markup ${markup.value}`, () => this.live.setTrackedChanges(markup.value === "none" ? TrackedChangeMode.Accept : TrackedChangeMode.RenderInline));
767
+ trackchanges.checked = markup.value !== "none";
768
+ });
769
+ const author = this.require("author");
770
+ author.addEventListener("change", () => {
771
+ this.author = author.value.trim() || "Reviewer";
772
+ this.live?.setRevisionAuthor(this.author);
773
+ });
491
774
  this.wireFileActions();
492
775
  this.wireLayout();
493
776
  this.wirePicker();
777
+ this.wireLinkPopover();
778
+ this.wireFindBar();
779
+ this.wireHeaderFooterTab();
780
+ this.wireZoom();
781
+ this.wireViewToggles();
782
+ }
783
+ /** A `<select>` that acts on change and snaps back to its placeholder. */
784
+ wireSelect(name, label, apply) {
785
+ const select = this.require(name);
786
+ select.addEventListener("change", () => {
787
+ const value = select.value;
788
+ if (this.live && value)
789
+ this.run(label, () => apply(value));
790
+ select.value = "";
791
+ });
494
792
  }
495
793
  wireFileActions() {
496
794
  const file = this.control("file");
@@ -508,17 +806,27 @@ class RibbonSurface {
508
806
  this.openBlank("untitled.docx");
509
807
  });
510
808
  this.control("save")?.addEventListener("click", () => this.download());
809
+ const picture = this.control("picturefile");
810
+ picture?.addEventListener("change", async () => {
811
+ const chosen = picture.files?.[0];
812
+ picture.value = "";
813
+ if (!chosen || !this.live)
814
+ return;
815
+ const started = performance.now();
816
+ const ok = await this.live.insertImageFile(chosen, { altText: chosen.name });
817
+ const ms = performance.now() - started;
818
+ const el = this.control("railOp");
819
+ if (el)
820
+ el.textContent = `picture ${Math.round(ms)} ms`;
821
+ this.setStatus(ok ? `Inserted ${chosen.name}` : "Could not insert the picture here");
822
+ this.refreshRailCounts();
823
+ });
511
824
  }
512
825
  wireLayout() {
513
826
  // Pagination toggles re-render the LIVE session, so edits survive the switch.
514
827
  const paginated = this.require("paginated");
515
- paginated.addEventListener("change", () => {
516
- this.surface.dataset.view = paginated.checked ? "paginated" : "continuous";
517
- if (!this.live)
518
- return;
519
- this.run(paginated.checked ? "page view" : "continuous view", () => this.live.setPaginated(paginated.checked));
520
- });
521
- // The band region is chosen at open time, so toggling it re-opens the LIVE
828
+ paginated.addEventListener("change", () => this.setPaginated(paginated.checked));
829
+ // The region is chosen at open time, so toggling it re-opens the LIVE
522
830
  // session's bytes rather than the originally loaded file — edits so far survive.
523
831
  const headerFooter = this.require("headerfooter");
524
832
  headerFooter.addEventListener("change", () => {
@@ -546,6 +854,82 @@ class RibbonSurface {
546
854
  this.run("clear numbering", () => this.live.clearPageNumbering());
547
855
  this.syncPageNumbering();
548
856
  });
857
+ // Page setup (Word's Layout > Page Setup): margins presets, orientation, size.
858
+ const pageSetup = (op) => {
859
+ if (!this.live.setPageSetup(op))
860
+ this.setStatus("This engine build cannot change the page setup");
861
+ this.syncLayoutState();
862
+ };
863
+ this.wireSelect("margins", "margins", (v) => {
864
+ const [top, bottom, left, right] = v.split(",").map((n) => parseInt(n, 10));
865
+ pageSetup({ marginTopTwips: top, marginBottomTwips: bottom, marginLeftTwips: left, marginRightTwips: right });
866
+ });
867
+ this.wireSelect("orientation", "orientation", (v) => pageSetup({ landscape: v === "landscape" }));
868
+ this.wireSelect("pagesize", "page size", (v) => {
869
+ const [w, h] = v.split(",").map((n) => parseInt(n, 10));
870
+ const landscape = !!this.live.sectionInfo()?.landscape;
871
+ pageSetup(landscape
872
+ ? { pageWidthTwips: h, pageHeightTwips: w, landscape: true }
873
+ : { pageWidthTwips: w, pageHeightTwips: h });
874
+ });
875
+ // Paragraph spacing / special indent.
876
+ const before = this.require("spacebefore");
877
+ const after = this.require("spaceafter");
878
+ const spacing = (which, input) => {
879
+ const pts = parseFloat(input.value);
880
+ if (!this.live || !(pts >= 0))
881
+ return;
882
+ this.run(`space ${which}`, () => this.live.setParagraphSpacing(which === "before" ? { beforePt: pts } : { afterPt: pts }));
883
+ };
884
+ before.addEventListener("change", () => spacing("before", before));
885
+ after.addEventListener("change", () => spacing("after", after));
886
+ const special = this.require("specialindent");
887
+ const specialVal = this.require("specialindentval");
888
+ const applySpecial = () => {
889
+ if (!this.live || !special.value)
890
+ return;
891
+ const inches = parseFloat(specialVal.value);
892
+ const twips = Math.round((inches >= 0 ? inches : 0.5) * 1440);
893
+ this.run("special indent", () => {
894
+ if (special.value === "firstLine")
895
+ this.live.setFirstLineIndent(twips);
896
+ else if (special.value === "hanging")
897
+ this.live.setHangingIndent(twips);
898
+ else
899
+ this.live.setFirstLineIndent(0);
900
+ });
901
+ };
902
+ special.addEventListener("change", applySpecial);
903
+ specialVal.addEventListener("change", applySpecial);
904
+ }
905
+ setPaginated(on) {
906
+ const paginated = this.require("paginated");
907
+ paginated.checked = on;
908
+ this.surface.dataset.view = on ? "paginated" : "continuous";
909
+ if (!this.live)
910
+ return;
911
+ this.run(on ? "page view" : "continuous view", () => this.live.setPaginated(on));
912
+ this.syncViewButtons();
913
+ }
914
+ syncViewButtons() {
915
+ const on = this.require("paginated").checked;
916
+ this.control("viewpage")?.classList.toggle("dxr-on", on);
917
+ this.control("viewweb")?.classList.toggle("dxr-on", !on);
918
+ }
919
+ wireViewToggles() {
920
+ const showrail = this.require("showrail");
921
+ showrail.addEventListener("change", () => {
922
+ const rail = this.element.querySelector("[data-dxr-rail]");
923
+ if (rail)
924
+ rail.hidden = !showrail.checked;
925
+ });
926
+ const showhint = this.require("showhint");
927
+ showhint.addEventListener("change", () => {
928
+ const hint = this.element.querySelector("[data-dxr-hint]");
929
+ if (hint)
930
+ hint.hidden = !showhint.checked;
931
+ });
932
+ this.syncViewButtons();
549
933
  }
550
934
  /** The bands own the same setting and read the live session, so both stay in step. */
551
935
  syncPageNumbering() {
@@ -555,7 +939,417 @@ class RibbonSurface {
555
939
  this.require("pgfmt").value = numbering.format ?? "";
556
940
  this.require("pgstart").value = numbering.start != null ? String(numbering.start) : "";
557
941
  }
558
- // ── table size picker ───────────────────────────────────────────────────────
942
+ /** Layout tab: describe the section under the caret. */
943
+ syncLayoutState() {
944
+ const note = this.control("pagesetupnote");
945
+ const info = this.live?.sectionInfo() ?? null;
946
+ if (!note)
947
+ return;
948
+ if (!info) {
949
+ note.textContent = "";
950
+ return;
951
+ }
952
+ const inches = (twips) => (twips / 1440).toFixed(2).replace(/\.?0+$/, "");
953
+ note.textContent =
954
+ `${inches(info.pageWidthTwips)}″ × ${inches(info.pageHeightTwips)}″ ${info.landscape ? "landscape" : "portrait"}, ` +
955
+ `margins ${inches(info.marginTopTwips)}″ / ${inches(info.marginRightTwips)}″ / ${inches(info.marginBottomTwips)}″ / ${inches(info.marginLeftTwips)}″`;
956
+ }
957
+ // ── review ──────────────────────────────────────────────────────────────────
958
+ beginComment() {
959
+ if (!this.live)
960
+ return;
961
+ if (this.density === "compact") {
962
+ // No gutter on a phone: post directly on the selection with a prompt.
963
+ const text = window.prompt("Comment");
964
+ if (!text?.trim())
965
+ return;
966
+ this.run("comment", () => this.live.addComment(text.trim(), this.author));
967
+ return;
968
+ }
969
+ if (!this.live.beginComment())
970
+ this.setStatus("Click into a paragraph (or select text) to comment on it");
971
+ }
972
+ onCommentsChange(info) {
973
+ const count = this.control("commentcount");
974
+ if (count)
975
+ count.textContent = info.threads === 0 ? "No comments" : `${info.open} open · ${info.threads} total`;
976
+ const resolve = this.control("commentresolve");
977
+ const del = this.control("commentdelete");
978
+ const entry = info.active ? this.live?.listComments().find((c) => c.anchorId === info.active) : null;
979
+ if (resolve) {
980
+ resolve.disabled = !entry;
981
+ resolve.textContent = entry?.resolved ? "Reopen" : "Resolve";
982
+ }
983
+ if (del)
984
+ del.disabled = !entry;
985
+ }
986
+ syncReviewState() {
987
+ if (!this.live)
988
+ return;
989
+ const tracked = this.live.trackedChanges === TrackedChangeMode.RenderInline;
990
+ this.require("trackchanges").checked = tracked;
991
+ this.require("markup").value = tracked ? "all" : "none";
992
+ const revs = this.live.listRevisions();
993
+ const count = this.control("revcount");
994
+ if (count)
995
+ count.textContent = revs.length === 0 ? "No tracked changes" : `${revs.length} change${revs.length === 1 ? "" : "s"}`;
996
+ this.require("showcomments").checked = this.live.commentsVisible || this.options.comments === false;
997
+ }
998
+ runRevisionCommand(op) {
999
+ if (!this.live)
1000
+ return;
1001
+ const marks = this.live.revisionElements();
1002
+ const current = this.currentRevisionMark(marks);
1003
+ switch (op) {
1004
+ case "accept":
1005
+ case "reject": {
1006
+ const mark = current ?? marks[0];
1007
+ const entry = mark ? this.live.revisionAt(mark) : null;
1008
+ if (!entry) {
1009
+ this.setStatus("Put the caret in a tracked change first");
1010
+ return;
1011
+ }
1012
+ if (op === "accept")
1013
+ this.live.acceptRevision(entry.id);
1014
+ else
1015
+ this.live.rejectRevision(entry.id);
1016
+ break;
1017
+ }
1018
+ case "acceptall":
1019
+ this.live.acceptAllRevisions();
1020
+ break;
1021
+ case "rejectall":
1022
+ this.live.rejectAllRevisions();
1023
+ break;
1024
+ case "next":
1025
+ case "prev": {
1026
+ if (marks.length === 0) {
1027
+ this.setStatus("No tracked changes");
1028
+ return;
1029
+ }
1030
+ const index = current ? marks.indexOf(current) : -1;
1031
+ const target = marks[(index + (op === "next" ? 1 : -1) + marks.length) % marks.length];
1032
+ this.selectRevisionMark(target);
1033
+ return;
1034
+ }
1035
+ default: return;
1036
+ }
1037
+ this.syncReviewState();
1038
+ }
1039
+ currentRevisionMark(marks) {
1040
+ const sel = (this.element.ownerDocument ?? document).getSelection();
1041
+ const node = sel?.anchorNode ?? null;
1042
+ if (!node)
1043
+ return null;
1044
+ const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
1045
+ const mark = el?.closest('ins, del, tr[class*="row-"]') ?? null;
1046
+ return mark && marks.includes(mark) ? mark : null;
1047
+ }
1048
+ selectRevisionMark(mark) {
1049
+ const doc = this.element.ownerDocument ?? document;
1050
+ const block = mark.closest('[data-anchor][contenteditable="true"]');
1051
+ block?.focus({ preventScroll: true });
1052
+ const range = doc.createRange();
1053
+ range.selectNodeContents(mark);
1054
+ const sel = doc.getSelection();
1055
+ sel?.removeAllRanges();
1056
+ sel?.addRange(range);
1057
+ mark.scrollIntoView({ block: "center", behavior: "smooth" });
1058
+ }
1059
+ // ── header & footer tab ─────────────────────────────────────────────────────
1060
+ wireHeaderFooterTab() {
1061
+ for (const kind of ["first", "even"]) {
1062
+ const box = this.require(kind === "first" ? "hfFirst" : "hfEven");
1063
+ box.addEventListener("change", () => {
1064
+ if (!this.live)
1065
+ return;
1066
+ const ok = this.live.setHeaderFooterKindEnabled(kind, box.checked);
1067
+ if (!ok) {
1068
+ box.checked = !box.checked;
1069
+ this.setStatus("This engine build cannot change that option");
1070
+ }
1071
+ this.run(`${kind === "first" ? "different first page" : "different odd & even"} ${box.checked ? "on" : "off"}`, () => { });
1072
+ this.syncHeaderFooterState();
1073
+ });
1074
+ }
1075
+ }
1076
+ runHeaderFooterCommand(op) {
1077
+ if (!this.live)
1078
+ return;
1079
+ switch (op) {
1080
+ case "pagenum":
1081
+ this.live.insertPageNumber("currentPage");
1082
+ break;
1083
+ case "totalpages":
1084
+ this.live.insertPageNumber("totalPages");
1085
+ break;
1086
+ case "pageof":
1087
+ this.live.insertPageNumber("pageOfTotal");
1088
+ break;
1089
+ case "header":
1090
+ this.live.goToHeaderFooter("header");
1091
+ break;
1092
+ case "footer":
1093
+ this.live.goToHeaderFooter("footer");
1094
+ break;
1095
+ case "close":
1096
+ this.live.closeHeaderFooter();
1097
+ break;
1098
+ }
1099
+ this.syncHeaderFooterState();
1100
+ }
1101
+ onStoryChange(which) {
1102
+ const tab = this.element.querySelector('.dxr-tab[data-tab="headerfooter"]');
1103
+ if (!tab)
1104
+ return;
1105
+ tab.hidden = which === null;
1106
+ if (which) {
1107
+ this.selectTab("headerfooter");
1108
+ }
1109
+ else if (tab.getAttribute("aria-selected") === "true") {
1110
+ this.selectTab("home");
1111
+ }
1112
+ this.syncHeaderFooterState();
1113
+ }
1114
+ syncHeaderFooterState() {
1115
+ if (!this.live)
1116
+ return;
1117
+ this.require("hfFirst").checked = this.live.headerFooterKindEnabled("first");
1118
+ this.require("hfEven").checked = this.live.headerFooterKindEnabled("even");
1119
+ const story = this.control("hfstory");
1120
+ if (story) {
1121
+ const which = this.live.activeStoryKind ?? "header";
1122
+ story.textContent = this.live.headerFooterStoryLabel(which);
1123
+ }
1124
+ }
1125
+ // ── zoom ────────────────────────────────────────────────────────────────────
1126
+ wireZoom() {
1127
+ for (const name of ["zoom", "zoomlevel"]) {
1128
+ const select = this.control(name);
1129
+ select?.addEventListener("change", () => this.setZoom(parseFloat(select.value)));
1130
+ }
1131
+ }
1132
+ setZoom(scale) {
1133
+ if (!this.live || !(scale > 0))
1134
+ return;
1135
+ this.run(`zoom ${Math.round(scale * 100)}%`, () => this.live.setZoom(scale));
1136
+ this.syncZoom();
1137
+ }
1138
+ stepZoom(direction) {
1139
+ if (!this.live)
1140
+ return;
1141
+ const current = this.live.requestedZoom;
1142
+ const next = direction > 0
1143
+ ? ZOOM_STEPS.find((z) => z > current + 1e-6) ?? ZOOM_STEPS[ZOOM_STEPS.length - 1]
1144
+ : [...ZOOM_STEPS].reverse().find((z) => z < current - 1e-6) ?? ZOOM_STEPS[0];
1145
+ this.setZoom(next);
1146
+ }
1147
+ syncZoom() {
1148
+ if (!this.live)
1149
+ return;
1150
+ const requested = this.live.requestedZoom;
1151
+ const value = String(ZOOM_STEPS.find((z) => Math.abs(z - requested) < 1e-6) ?? requested);
1152
+ for (const name of ["zoom", "zoomlevel"]) {
1153
+ const select = this.control(name);
1154
+ if (!select)
1155
+ continue;
1156
+ if (!Array.from(select.options).some((o) => o.value === value)) {
1157
+ const opt = document.createElement("option");
1158
+ opt.value = value;
1159
+ opt.textContent = `${Math.round(requested * 100)}%`;
1160
+ select.appendChild(opt);
1161
+ }
1162
+ select.value = value;
1163
+ }
1164
+ }
1165
+ // ── find & replace ──────────────────────────────────────────────────────────
1166
+ wireFindBar() {
1167
+ const bar = this.require("findbar");
1168
+ const text = this.require("findtext");
1169
+ const replace = this.require("replacetext");
1170
+ const matchCase = this.require("findcase");
1171
+ this.control("findtoggle")?.addEventListener("click", () => this.openFindBar(false));
1172
+ this.control("replacetoggle")?.addEventListener("click", () => this.openFindBar(true));
1173
+ this.control("findclose")?.addEventListener("click", () => this.closeFindBar());
1174
+ text.addEventListener("input", () => this.refreshFind(true));
1175
+ matchCase.addEventListener("change", () => this.refreshFind(true));
1176
+ text.addEventListener("keydown", (event) => {
1177
+ if (event.key === "Enter") {
1178
+ event.preventDefault();
1179
+ this.stepFind(event.shiftKey ? -1 : 1);
1180
+ }
1181
+ else if (event.key === "Escape") {
1182
+ event.preventDefault();
1183
+ this.closeFindBar();
1184
+ }
1185
+ });
1186
+ replace.addEventListener("keydown", (event) => {
1187
+ if (event.key === "Enter") {
1188
+ event.preventDefault();
1189
+ this.replaceCurrent();
1190
+ }
1191
+ else if (event.key === "Escape") {
1192
+ event.preventDefault();
1193
+ this.closeFindBar();
1194
+ }
1195
+ });
1196
+ this.control("findprev")?.addEventListener("click", () => this.stepFind(-1));
1197
+ this.control("findnext")?.addEventListener("click", () => this.stepFind(1));
1198
+ this.control("replaceone")?.addEventListener("click", () => this.replaceCurrent());
1199
+ this.control("replaceall")?.addEventListener("click", () => {
1200
+ if (!this.live || !text.value)
1201
+ return;
1202
+ let count = 0;
1203
+ this.run("replace all", () => {
1204
+ count = this.live.replaceAll(text.value, replace.value, { matchCase: matchCase.checked });
1205
+ });
1206
+ this.setStatus(`Replaced ${count} occurrence${count === 1 ? "" : "s"}`);
1207
+ this.refreshFind(true);
1208
+ });
1209
+ bar.hidden = true;
1210
+ }
1211
+ openFindBar(withReplace) {
1212
+ const bar = this.require("findbar");
1213
+ bar.hidden = false;
1214
+ this.require("replacegroup").hidden = !withReplace;
1215
+ this.syncStripFade(bar);
1216
+ const text = this.require("findtext");
1217
+ text.focus();
1218
+ text.select();
1219
+ this.refreshFind(false);
1220
+ }
1221
+ closeFindBar() {
1222
+ const bar = this.control("findbar");
1223
+ if (bar)
1224
+ bar.hidden = true;
1225
+ this.findMatches = [];
1226
+ this.findIndex = -1;
1227
+ }
1228
+ refreshFind(select) {
1229
+ const text = this.require("findtext");
1230
+ const matchCase = this.require("findcase").checked;
1231
+ this.findMatches = this.live && text.value ? this.live.find(text.value, { matchCase }) : [];
1232
+ this.findIndex = this.findMatches.length > 0 ? 0 : -1;
1233
+ this.updateFindCount();
1234
+ if (select && this.findIndex >= 0)
1235
+ this.live?.selectMatch(this.findMatches[this.findIndex]);
1236
+ }
1237
+ stepFind(direction) {
1238
+ if (!this.live)
1239
+ return;
1240
+ if (this.findMatches.length === 0)
1241
+ this.refreshFind(false);
1242
+ if (this.findMatches.length === 0)
1243
+ return;
1244
+ this.findIndex = (this.findIndex + direction + this.findMatches.length) % this.findMatches.length;
1245
+ this.live.selectMatch(this.findMatches[this.findIndex]);
1246
+ this.updateFindCount();
1247
+ }
1248
+ replaceCurrent() {
1249
+ if (!this.live)
1250
+ return;
1251
+ if (this.findMatches.length === 0 || this.findIndex < 0)
1252
+ this.refreshFind(true);
1253
+ const match = this.findMatches[this.findIndex];
1254
+ if (!match)
1255
+ return;
1256
+ const replacement = this.require("replacetext").value;
1257
+ this.run("replace", () => this.live.replaceMatch(match, replacement));
1258
+ // Offsets after the replacement shifted; re-scan and continue from the same slot.
1259
+ const keep = this.findIndex;
1260
+ this.refreshFind(false);
1261
+ if (this.findMatches.length > 0) {
1262
+ this.findIndex = Math.min(keep, this.findMatches.length - 1);
1263
+ this.live.selectMatch(this.findMatches[this.findIndex]);
1264
+ this.updateFindCount();
1265
+ }
1266
+ }
1267
+ updateFindCount() {
1268
+ const el = this.control("findcount");
1269
+ if (!el)
1270
+ return;
1271
+ el.textContent = this.findMatches.length === 0
1272
+ ? (this.require("findtext").value ? "0 of 0" : "")
1273
+ : `${this.findIndex + 1} of ${this.findMatches.length}`;
1274
+ }
1275
+ // ── keyboard shortcuts the engine does not own ──────────────────────────────
1276
+ handleShortcut(event) {
1277
+ if (!this.live || !(event.ctrlKey || event.metaKey))
1278
+ return;
1279
+ const key = event.key.toLowerCase();
1280
+ const target = event.target instanceof Element ? event.target : null;
1281
+ const inDocument = this.surface.contains(event.target);
1282
+ // Ctrl+Alt+M is Word's New Comment; every other chord here is plain Ctrl.
1283
+ if (event.altKey) {
1284
+ if (key === "m" && inDocument) {
1285
+ event.preventDefault();
1286
+ this.beginComment();
1287
+ }
1288
+ return;
1289
+ }
1290
+ if (key === "f") {
1291
+ event.preventDefault();
1292
+ this.openFindBar(false);
1293
+ return;
1294
+ }
1295
+ if (key === "h") {
1296
+ event.preventDefault();
1297
+ this.openFindBar(true);
1298
+ return;
1299
+ }
1300
+ if (!inDocument)
1301
+ return;
1302
+ // Below here every chord edits the focused BLOCK, so a text box inside the surface — the
1303
+ // comment gutter's reply and edit boxes — owns its keys: Ctrl+Enter posts a reply there and
1304
+ // must not also drop a page break on the commented paragraph, nor Ctrl+E/L/R/J re-align it
1305
+ // mid-reply. Find and replace stay reachable from a comment box, as they are in Word.
1306
+ if (target?.closest(".docx-comment-gutter, textarea, input"))
1307
+ return;
1308
+ const align = (alignment) => {
1309
+ event.preventDefault();
1310
+ this.run(`align ${alignment}`, () => this.live.setAlignment(alignment));
1311
+ };
1312
+ switch (key) {
1313
+ case "k":
1314
+ event.preventDefault();
1315
+ this.openLinkPopover();
1316
+ return;
1317
+ case "e":
1318
+ align("center");
1319
+ return;
1320
+ case "l":
1321
+ align("left");
1322
+ return;
1323
+ case "r":
1324
+ align("right");
1325
+ return;
1326
+ case "j":
1327
+ align("justify");
1328
+ return;
1329
+ case "]":
1330
+ event.preventDefault();
1331
+ this.run("grow font", () => this.live.adjustFontSize(1));
1332
+ return;
1333
+ case "[":
1334
+ event.preventDefault();
1335
+ this.run("shrink font", () => this.live.adjustFontSize(-1));
1336
+ return;
1337
+ case "enter":
1338
+ event.preventDefault();
1339
+ this.run("page break", () => this.live.pageBreakBefore(true));
1340
+ return;
1341
+ default: return;
1342
+ }
1343
+ }
1344
+ // ── popovers ────────────────────────────────────────────────────────────────
1345
+ positionPopover(popover, anchor) {
1346
+ if (this.density !== "full")
1347
+ return;
1348
+ const rect = anchor.getBoundingClientRect();
1349
+ const host = this.element.getBoundingClientRect();
1350
+ popover.style.left = `${rect.left - host.left}px`;
1351
+ popover.style.top = `${rect.bottom - host.top + 5}px`;
1352
+ }
559
1353
  wirePicker() {
560
1354
  const button = this.require("table");
561
1355
  const picker = this.require("gridpicker");
@@ -584,7 +1378,7 @@ class RibbonSurface {
584
1378
  event.preventDefault();
585
1379
  const rows = Number(cell.dataset.r) + 1;
586
1380
  const cols = Number(cell.dataset.c) + 1;
587
- this.closePicker();
1381
+ this.closePopovers();
588
1382
  this.run(`table ${rows}×${cols}`, () => this.live.insertTable(rows, cols, {
589
1383
  borderless: this.require("gridborderless").checked,
590
1384
  cellAlignment: this.require("gridalign").value,
@@ -595,32 +1389,73 @@ class RibbonSurface {
595
1389
  if (!this.live)
596
1390
  return;
597
1391
  if (picker.hasAttribute("data-open")) {
598
- this.closePicker();
1392
+ this.closePopovers();
599
1393
  return;
600
1394
  }
1395
+ this.closePopovers();
601
1396
  highlight(0, 0);
602
1397
  picker.setAttribute("data-open", "");
603
- // Compact chrome docks the picker to the viewport bottom (see the stylesheet),
604
- // so only the roomy layout needs it positioned under its button.
605
- if (this.density === "full") {
606
- const rect = button.getBoundingClientRect();
607
- const host = this.element.getBoundingClientRect();
608
- picker.style.left = `${rect.left - host.left}px`;
609
- picker.style.top = `${rect.bottom - host.top + 5}px`;
1398
+ this.positionPopover(picker, button);
1399
+ });
1400
+ }
1401
+ wireLinkPopover() {
1402
+ const button = this.require("link");
1403
+ const pop = this.require("linkpop");
1404
+ const form = this.require("linkform");
1405
+ const url = this.require("linkurl");
1406
+ this.keepSelection(button);
1407
+ button.addEventListener("click", () => {
1408
+ if (pop.hasAttribute("data-open"))
1409
+ this.closePopovers();
1410
+ else
1411
+ this.openLinkPopover();
1412
+ });
1413
+ this.control("linkcancel")?.addEventListener("click", () => this.closePopovers());
1414
+ form.addEventListener("submit", (event) => {
1415
+ event.preventDefault();
1416
+ const target = url.value.trim();
1417
+ if (!target || !this.live)
1418
+ return;
1419
+ this.closePopovers();
1420
+ let ok = false;
1421
+ this.run("link", () => { ok = this.live.insertHyperlink(target); });
1422
+ if (!ok)
1423
+ this.setStatus("Select the text to link first");
1424
+ });
1425
+ url.addEventListener("keydown", (event) => {
1426
+ if (event.key === "Escape") {
1427
+ event.preventDefault();
1428
+ this.closePopovers();
610
1429
  }
611
1430
  });
612
1431
  }
613
- closePicker() {
1432
+ openLinkPopover() {
1433
+ if (!this.live)
1434
+ return;
1435
+ this.closePopovers();
1436
+ const pop = this.require("linkpop");
1437
+ const url = this.require("linkurl");
1438
+ const existing = this.live.hyperlinkAtCaret();
1439
+ url.value = existing?.target ?? "";
1440
+ pop.setAttribute("data-open", "");
1441
+ this.positionPopover(pop, this.require("link"));
1442
+ url.focus();
1443
+ url.select();
1444
+ }
1445
+ closePopovers() {
614
1446
  this.control("gridpicker")?.removeAttribute("data-open");
1447
+ this.control("linkpop")?.removeAttribute("data-open");
615
1448
  }
616
- maybeClosePicker(event) {
617
- const picker = this.control("gridpicker");
618
- if (!picker?.hasAttribute("data-open"))
619
- return;
1449
+ maybeClosePopovers(event) {
620
1450
  const target = event.target;
621
- if (picker.contains(target) || this.control("table")?.contains(target))
622
- return;
623
- this.closePicker();
1451
+ for (const [popName, buttonName] of [["gridpicker", "table"], ["linkpop", "link"]]) {
1452
+ const pop = this.control(popName);
1453
+ if (!pop?.hasAttribute("data-open"))
1454
+ continue;
1455
+ if (pop.contains(target) || this.control(buttonName)?.contains(target))
1456
+ continue;
1457
+ pop.removeAttribute("data-open");
1458
+ }
624
1459
  }
625
1460
  // ── tabs ────────────────────────────────────────────────────────────────────
626
1461
  selectTab(name) {
@@ -633,62 +1468,16 @@ class RibbonSurface {
633
1468
  else
634
1469
  panel.removeAttribute("data-active");
635
1470
  }
636
- if (name === "layout")
1471
+ if (name === "layout") {
637
1472
  this.syncPageNumbering();
638
- if (name === "review")
639
- this.renderThreads();
640
- }
641
- /** Re-render the Review tab's thread list from session truth ({@link DocxEditor.listComments}):
642
- * one row per comment, replies indented under their thread root, resolve/reopen per root. */
643
- renderThreads() {
644
- const host = this.control("threads");
645
- if (!host || !this.live)
646
- return;
647
- const doc = this.element.ownerDocument ?? document;
648
- host.textContent = "";
649
- const comments = this.live.listComments();
650
- if (comments.length === 0) {
651
- const empty = doc.createElement("span");
652
- empty.className = "dxr-note";
653
- empty.textContent = "No comments yet.";
654
- host.appendChild(empty);
655
- return;
656
- }
657
- for (const entry of comments) {
658
- const row = doc.createElement("div");
659
- row.className = "dxr-thread";
660
- row.setAttribute("data-thread", entry.anchorId);
661
- if (entry.resolved)
662
- row.setAttribute("data-resolved", "");
663
- if (entry.parentAnchorId)
664
- row.setAttribute("data-reply", "");
665
- const author = doc.createElement("span");
666
- author.className = "dxr-tauthor";
667
- author.textContent = entry.author || "—";
668
- row.appendChild(author);
669
- const text = doc.createElement("span");
670
- text.className = "dxr-ttext";
671
- text.textContent = entry.text;
672
- text.title = entry.text;
673
- row.appendChild(text);
674
- // Resolution is a THREAD state: Word keys it on the root, so replies get no toggle.
675
- if (!entry.parentAnchorId) {
676
- const toggle = doc.createElement("button");
677
- toggle.type = "button";
678
- toggle.textContent = entry.resolved ? "Reopen" : "Resolve";
679
- toggle.title = entry.resolved
680
- ? "Reopen this comment thread"
681
- : "Mark this comment thread resolved";
682
- toggle.addEventListener("click", () => {
683
- if (!this.live)
684
- return;
685
- this.run(entry.resolved ? "reopen comment" : "resolve comment", () => this.live.setCommentResolved(entry.anchorId, !entry.resolved));
686
- this.renderThreads();
687
- });
688
- row.appendChild(toggle);
689
- }
690
- host.appendChild(row);
1473
+ this.syncLayoutState();
691
1474
  }
1475
+ if (name === "review")
1476
+ this.syncReviewState();
1477
+ if (name === "headerfooter")
1478
+ this.syncHeaderFooterState();
1479
+ if (name === "view")
1480
+ this.syncZoom();
692
1481
  }
693
1482
  // ── selection-driven state ──────────────────────────────────────────────────
694
1483
  selectionElement() {
@@ -708,12 +1497,33 @@ class RibbonSurface {
708
1497
  for (const button of Array.from(this.element.querySelectorAll("button[data-cmd]"))) {
709
1498
  button.classList.toggle("dxr-on", !!state[button.dataset.cmd]);
710
1499
  }
711
- // Reflect the caret's font size, unless the field itself is being edited.
712
- const fontsize = this.control("fontsize");
713
- if (el && fontsize && (this.element.ownerDocument ?? document).activeElement !== fontsize) {
714
- const px = parseFloat(getComputedStyle(el).fontSize);
715
- if (px)
716
- fontsize.value = String(Math.round(px * 0.75 * 2) / 2);
1500
+ const doc = this.element.ownerDocument ?? document;
1501
+ if (el && typeof getComputedStyle === "function") {
1502
+ const cs = getComputedStyle(el);
1503
+ // Reflect the caret's font size, unless the field itself is being edited.
1504
+ const fontsize = this.control("fontsize");
1505
+ if (fontsize && doc.activeElement !== fontsize) {
1506
+ const px = parseFloat(cs.fontSize);
1507
+ if (px)
1508
+ fontsize.value = String(Math.round(px * 0.75 * 2) / 2);
1509
+ }
1510
+ const hex = toHex(cs.color);
1511
+ if (hex) {
1512
+ this.control("fontcolorbar")?.style.setProperty("--dxr-swatch", hex);
1513
+ const input = this.control("fontcolor");
1514
+ if (input && doc.activeElement !== input)
1515
+ input.value = hex;
1516
+ }
1517
+ this.control("smallcaps")?.setAttribute("aria-pressed", String(cs.fontVariant.includes("small-caps")));
1518
+ const align = cs.textAlign === "start" ? "left" : cs.textAlign === "end" ? "right" : cs.textAlign;
1519
+ for (const button of Array.from(this.element.querySelectorAll("button[data-align]"))) {
1520
+ button.classList.toggle("dxr-on", button.dataset.align === align);
1521
+ }
1522
+ }
1523
+ const listFormat = this.live.listFormatAtCaret();
1524
+ for (const button of Array.from(this.element.querySelectorAll("button[data-list]"))) {
1525
+ const kind = button.dataset.list;
1526
+ button.classList.toggle("dxr-on", !!listFormat && (kind === "bullet" ? listFormat.startsWith("bullet") : !listFormat.startsWith("bullet")));
717
1527
  }
718
1528
  // Reveal the contextual Table tab only while the caret is inside a table.
719
1529
  const inTable = !!el?.closest("table");
@@ -722,12 +1532,17 @@ class RibbonSurface {
722
1532
  tableTab.hidden = !inTable;
723
1533
  if (!inTable && tableTab.getAttribute("aria-selected") === "true")
724
1534
  this.selectTab("home");
1535
+ if (inTable) {
1536
+ const row = el?.closest("tr");
1537
+ this.require("repeatheader").checked = !!row?.classList.toString().includes("header");
1538
+ }
725
1539
  }
726
1540
  this.refreshRailAnchor();
1541
+ this.scheduleStats();
727
1542
  }
728
1543
  /** Scope is read from where the block lives, which is exactly what the anchor encodes. */
729
1544
  scopeOf(el) {
730
- const band = el.closest(".docx-hf-band");
1545
+ const band = el.closest("[data-hf-band]");
731
1546
  if (band)
732
1547
  return band.getAttribute("data-hf-band") === "header" ? "hdr" : "ftr";
733
1548
  if (el.closest(".footnotes"))
@@ -779,6 +1594,40 @@ class RibbonSurface {
779
1594
  void anchorEl.offsetWidth;
780
1595
  anchorEl.classList.add("dxr-flash");
781
1596
  }
1597
+ /** Page position and word count are O(document); debounced off the hot paths. */
1598
+ scheduleStats() {
1599
+ if (this.statsTimer != null)
1600
+ clearTimeout(this.statsTimer);
1601
+ this.statsTimer = setTimeout(() => {
1602
+ this.statsTimer = null;
1603
+ this.refreshStats();
1604
+ }, 150);
1605
+ }
1606
+ refreshStats() {
1607
+ if (!this.live || this.destroyed)
1608
+ return;
1609
+ const pageinfo = this.control("pageinfo");
1610
+ if (pageinfo) {
1611
+ const info = this.live.pageInfo();
1612
+ pageinfo.textContent = info ? `Page ${info.page} of ${info.total}` : "Continuous";
1613
+ }
1614
+ const words = this.control("wordcount");
1615
+ if (words) {
1616
+ const n = this.live.wordCount();
1617
+ words.textContent = `${n.toLocaleString()} word${n === 1 ? "" : "s"}`;
1618
+ }
1619
+ }
1620
+ /** Font families the document's rendered text actually uses, most common first. */
1621
+ documentFonts() {
1622
+ const counts = new Map();
1623
+ for (const el of Array.from(this.surface.querySelectorAll("[data-anchor] [style*='font-family'], [data-anchor][style*='font-family']"))) {
1624
+ const family = el.style.fontFamily.split(",")[0]?.trim().replace(/^['"]|['"]$/g, "");
1625
+ if (!family || /^(serif|sans-serif|monospace)$/i.test(family))
1626
+ continue;
1627
+ counts.set(family, (counts.get(family) ?? 0) + 1);
1628
+ }
1629
+ return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]).map(([name]) => name).slice(0, 12);
1630
+ }
782
1631
  // ── loading overlay ─────────────────────────────────────────────────────────
783
1632
  createLoaderController() {
784
1633
  const overlay = this.control("loader");