docxodus 11.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 (68) hide show
  1. package/README.md +7 -5
  2. package/dist/editor-comments.d.ts +129 -0
  3. package/dist/editor-comments.d.ts.map +1 -0
  4. package/dist/editor-comments.js +805 -0
  5. package/dist/editor-comments.js.map +1 -0
  6. package/dist/editor-headerfooter.d.ts +125 -85
  7. package/dist/editor-headerfooter.d.ts.map +1 -1
  8. package/dist/editor-headerfooter.js +572 -306
  9. package/dist/editor-headerfooter.js.map +1 -1
  10. package/dist/editor-image-patch.d.ts +38 -0
  11. package/dist/editor-image-patch.d.ts.map +1 -0
  12. package/dist/editor-image-patch.js +101 -0
  13. package/dist/editor-image-patch.js.map +1 -0
  14. package/dist/editor.bundle.js +3760 -785
  15. package/dist/editor.d.ts +237 -5
  16. package/dist/editor.d.ts.map +1 -1
  17. package/dist/editor.js +1063 -112
  18. package/dist/editor.js.map +1 -1
  19. package/dist/embed.bundle.js +3784 -759
  20. package/dist/embed.d.ts +1 -1
  21. package/dist/embed.d.ts.map +1 -1
  22. package/dist/embed.iife.js +3772 -747
  23. package/dist/embed.js +10 -1
  24. package/dist/embed.js.map +1 -1
  25. package/dist/export-assets.json +14 -14
  26. package/dist/export-browser.bundle.js +25 -0
  27. package/dist/index.d.ts +5 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +4 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/pagination.bundle.js +25 -0
  32. package/dist/pagination.d.ts +6 -0
  33. package/dist/pagination.d.ts.map +1 -1
  34. package/dist/pagination.js +19 -0
  35. package/dist/pagination.js.map +1 -1
  36. package/dist/ribbon-chrome.d.ts +20 -4
  37. package/dist/ribbon-chrome.d.ts.map +1 -1
  38. package/dist/ribbon-chrome.js +550 -182
  39. package/dist/ribbon-chrome.js.map +1 -1
  40. package/dist/ribbon.d.ts +5 -4
  41. package/dist/ribbon.d.ts.map +1 -1
  42. package/dist/ribbon.js +941 -141
  43. package/dist/ribbon.js.map +1 -1
  44. package/dist/session.bundle.js +48 -3
  45. package/dist/session.d.ts +35 -4
  46. package/dist/session.d.ts.map +1 -1
  47. package/dist/session.js +44 -3
  48. package/dist/session.js.map +1 -1
  49. package/dist/types.d.ts +114 -5
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/types.js.map +1 -1
  52. package/dist/viewport.d.ts +7 -0
  53. package/dist/viewport.d.ts.map +1 -1
  54. package/dist/viewport.js +12 -0
  55. package/dist/viewport.js.map +1 -1
  56. package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm +0 -0
  57. package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm.br +0 -0
  58. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  59. package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
  60. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  61. package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
  62. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  63. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm.br +0 -0
  64. package/dist/wasm/_framework/dotnet.boot.js +6 -6
  65. package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
  66. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  67. package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
  68. package/package.json +2 -2
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
  *
@@ -132,11 +160,17 @@ class RibbonSurface {
132
160
  this.stripObserver = null;
133
161
  this.lastAnchorText = "";
134
162
  this.selectionFrame = null;
163
+ this.statsTimer = null;
164
+ this.findMatches = [];
165
+ this.findIndex = -1;
135
166
  this.options = options;
136
167
  this.exports = options.exports ?? null;
137
168
  this.documentName = options.documentName ?? "untitled.docx";
138
169
  this.chromeMode = options.chrome ?? "auto";
139
- 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";
140
174
  // false suppresses the overlay; true/omitted takes the defaults; an object rewords it.
141
175
  this.loaderOptions =
142
176
  options.loader === false
@@ -165,6 +199,7 @@ class RibbonSurface {
165
199
  this.surface = this.require("editor");
166
200
  this.applyStaticOptions();
167
201
  this.buildGrid();
202
+ this.populateStaticMenus();
168
203
  this.wire();
169
204
  this.wireScrollFades();
170
205
  this.applyChrome();
@@ -182,8 +217,10 @@ class RibbonSurface {
182
217
  });
183
218
  };
184
219
  doc.addEventListener("selectionchange", this.onSelectionChange);
185
- this.onDocumentMouseDown = (event) => this.maybeClosePicker(event);
220
+ this.onDocumentMouseDown = (event) => this.maybeClosePopovers(event);
186
221
  doc.addEventListener("mousedown", this.onDocumentMouseDown);
222
+ this.onKeydown = (event) => this.handleShortcut(event);
223
+ root.addEventListener("keydown", this.onKeydown);
187
224
  if (this.chromeMode === "auto" && typeof ResizeObserver !== "undefined") {
188
225
  this.resizeObserver = new ResizeObserver(() => this.applyChrome());
189
226
  this.resizeObserver.observe(root);
@@ -217,9 +254,13 @@ class RibbonSurface {
217
254
  if (!this.loaderOptions)
218
255
  this.control("loader")?.remove();
219
256
  this.require("docname").textContent = this.documentName;
220
- (this.require("paginated")).checked = this.options.paginated ?? false;
221
- (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;
222
262
  this.surface.dataset.view = this.options.paginated ? "paginated" : "continuous";
263
+ this.surface.dataset.comments = this.options.comments === false ? "off" : "on";
223
264
  }
224
265
  buildGrid() {
225
266
  const cells = this.require("gridcells");
@@ -234,6 +275,96 @@ class RibbonSurface {
234
275
  }
235
276
  cells.replaceChildren(fragment);
236
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
+ }
237
368
  // ── chrome density ──────────────────────────────────────────────────────────
238
369
  get chrome() {
239
370
  return this.density;
@@ -258,19 +389,13 @@ class RibbonSurface {
258
389
  return;
259
390
  this.density = next;
260
391
  this.element.dataset.chrome = next;
261
- // The picker's absolute position is meaningless after a density flip.
262
- this.closePicker();
392
+ // A popover's absolute position is meaningless after a density flip.
393
+ this.closePopovers();
263
394
  // A density flip relays out every strip; re-measure without waiting for
264
395
  // the observer so the fades never lag a frame behind the new layout.
265
396
  this.syncAllStripFades();
266
397
  }
267
398
  // ── scroll-edge affordances ─────────────────────────────────────────────────
268
- /**
269
- * The chrome's strips scroll horizontally instead of squashing ("no command
270
- * is removed in compact"), so each one reports which edges hide more content
271
- * via `data-fade` and the stylesheet dissolves content at exactly those
272
- * edges. Without this a strip hard-clips mid-control and reads as broken.
273
- */
274
399
  syncStripFade(strip) {
275
400
  const max = strip.scrollWidth - strip.clientWidth;
276
401
  const tokens = max <= 1 ? "" : `${strip.scrollLeft > 1 ? "l " : ""}${strip.scrollLeft < max - 1 ? "r" : ""}`.trim();
@@ -284,7 +409,7 @@ class RibbonSurface {
284
409
  this.syncStripFade(strip);
285
410
  }
286
411
  wireScrollFades() {
287
- this.strips = Array.from(this.element.querySelectorAll(".dxr-titlebar, .dxr-tabs, .dxr-panel, .dxr-rail"));
412
+ this.strips = Array.from(this.element.querySelectorAll(".dxr-titlebar, .dxr-tabs, .dxr-panel, .dxr-rail, .dxr-findbar"));
288
413
  if (typeof ResizeObserver !== "undefined") {
289
414
  // One observer for all strips. It also fires when a panel becomes the
290
415
  // active one (display flips from none), which is what keeps a freshly
@@ -336,7 +461,11 @@ class RibbonSurface {
336
461
  const paginated = this.require("paginated").checked;
337
462
  this.surface.dataset.view = paginated ? "paginated" : "continuous";
338
463
  this.surface.replaceChildren();
464
+ this.closeFindBar();
339
465
  const started = performance.now();
466
+ const tracked = this.require("trackchanges").checked
467
+ ? TrackedChangeMode.RenderInline
468
+ : (this.options.trackedChanges ?? TrackedChangeMode.Accept);
340
469
  this.live = DocxEditor.open(this.surface, bytes, this.exports, {
341
470
  cssPrefix: this.options.cssPrefix,
342
471
  fabricateClasses: this.options.fabricateClasses,
@@ -344,21 +473,34 @@ class RibbonSurface {
344
473
  scale: this.options.scale,
345
474
  columnWidth: this.options.columnWidth,
346
475
  fitToWidth: this.options.fitToWidth,
347
- onEdit: this.options.onEdit,
476
+ onEdit: (info) => {
477
+ this.options.onEdit?.(info);
478
+ this.scheduleStats();
479
+ },
348
480
  onMove: this.options.onMove,
481
+ onStoryChange: (which) => this.onStoryChange(which),
482
+ onCommentsChange: (info) => this.onCommentsChange(info),
349
483
  paginated,
350
484
  headerFooter: this.headerFooter,
351
485
  blockDrag: this.options.blockDrag ?? true,
352
- trackedChanges: this.options.trackedChanges,
353
- revisionAuthor: this.options.revisionAuthor,
486
+ trackedChanges: tracked,
487
+ revisionAuthor: this.author,
488
+ comments: this.options.comments,
489
+ commentAuthor: this.author,
354
490
  });
355
491
  this.require("save").disabled = false;
356
492
  this.require("ribbon").setAttribute("aria-disabled", "false");
357
493
  this.setState("ready");
358
494
  this.setStatus(`Rendered in ${Math.round(performance.now() - started)} ms`);
495
+ this.populateStyles();
496
+ this.populateFonts(this.documentFonts());
359
497
  this.syncPageNumbering();
498
+ this.syncLayoutState();
499
+ this.syncReviewState();
500
+ this.syncZoom();
360
501
  this.refreshRailCounts();
361
502
  this.refreshRailAnchor();
503
+ this.scheduleStats();
362
504
  this.options.onOpen?.(this.live);
363
505
  return this.live;
364
506
  }
@@ -396,6 +538,7 @@ class RibbonSurface {
396
538
  const doc = this.element.ownerDocument ?? document;
397
539
  doc.removeEventListener("selectionchange", this.onSelectionChange);
398
540
  doc.removeEventListener("mousedown", this.onDocumentMouseDown);
541
+ this.element.removeEventListener("keydown", this.onKeydown);
399
542
  this.resizeObserver?.disconnect();
400
543
  this.resizeObserver = null;
401
544
  this.stripObserver?.disconnect();
@@ -403,6 +546,8 @@ class RibbonSurface {
403
546
  if (this.selectionFrame != null)
404
547
  cancelAnimationFrame(this.selectionFrame);
405
548
  this.selectionFrame = null;
549
+ if (this.statsTimer != null)
550
+ clearTimeout(this.statsTimer);
406
551
  this.stopRotation();
407
552
  try {
408
553
  this.live?.close();
@@ -435,6 +580,7 @@ class RibbonSurface {
435
580
  this.options.onCommand?.(label, ms);
436
581
  this.refreshRailCounts();
437
582
  this.refreshRailAnchor();
583
+ this.scheduleStats();
438
584
  }
439
585
  }
440
586
  /** Format controls must not steal the document selection they are about to act on. */
@@ -466,9 +612,18 @@ class RibbonSurface {
466
612
  colRight: () => this.live.insertTableColumn("right"),
467
613
  delRow: () => this.live.deleteTableRow(),
468
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(),
469
622
  };
470
623
  ops[b.dataset.tt ?? ""]?.();
471
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 ?? ""));
472
627
  const rulepos = () => (this.require("rulepos").value === "above" ? "above" : "below");
473
628
  const simple = [
474
629
  ["undo", "undo", () => this.live.undo()],
@@ -482,6 +637,18 @@ class RibbonSurface {
482
637
  ["endnote", "endnote", () => this.live.insertEndnote()],
483
638
  ["pagenum", "page number", () => this.live.insertPageNumber("currentPage")],
484
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)],
485
652
  ];
486
653
  for (const [name, label, fn] of simple) {
487
654
  const el = this.control(name);
@@ -490,6 +657,15 @@ class RibbonSurface {
490
657
  this.keepSelection(el);
491
658
  el.addEventListener("click", () => this.run(label, fn));
492
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
+ }
493
669
  // Font size: `change` only (it fires on Enter and on blur). Binding keydown as
494
670
  // well fired twice and stacked two undo snapshots for one size change.
495
671
  const fontsize = this.require("fontsize");
@@ -504,37 +680,115 @@ class RibbonSurface {
504
680
  fontsize.blur();
505
681
  }
506
682
  });
507
- const fontfamily = this.require("fontfamily");
508
- fontfamily.addEventListener("change", () => {
509
- if (this.live && fontfamily.value)
510
- this.run("font family", () => this.live.setFontFamily(fontfamily.value));
511
- fontfamily.value = "";
512
- });
513
- const style = this.require("style");
514
- style.addEventListener("change", () => {
515
- if (this.live && style.value)
516
- this.run("style", () => this.live.setParagraphStyle(style.value));
517
- style.value = "";
518
- });
519
- // Comment authoring (issue #580): the button comments the live selection (whole block
520
- // when collapsed) with the typed text, attributed to the configured revision author.
521
- const commentButton = this.control("comment");
522
- if (commentButton) {
523
- this.keepSelection(commentButton);
524
- commentButton.addEventListener("click", () => {
525
- if (!this.live)
526
- return;
527
- const input = this.control("commenttext");
528
- const text = input?.value.trim() || "New comment.";
529
- this.run("comment", () => this.live.addComment(text, this.options.revisionAuthor ?? "Reviewer"));
530
- if (input)
531
- input.value = "";
532
- this.renderThreads();
533
- });
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")));
534
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());
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
+ });
535
774
  this.wireFileActions();
536
775
  this.wireLayout();
537
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
+ });
538
792
  }
539
793
  wireFileActions() {
540
794
  const file = this.control("file");
@@ -552,17 +806,27 @@ class RibbonSurface {
552
806
  this.openBlank("untitled.docx");
553
807
  });
554
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
+ });
555
824
  }
556
825
  wireLayout() {
557
826
  // Pagination toggles re-render the LIVE session, so edits survive the switch.
558
827
  const paginated = this.require("paginated");
559
- paginated.addEventListener("change", () => {
560
- this.surface.dataset.view = paginated.checked ? "paginated" : "continuous";
561
- if (!this.live)
562
- return;
563
- this.run(paginated.checked ? "page view" : "continuous view", () => this.live.setPaginated(paginated.checked));
564
- });
565
- // 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
566
830
  // session's bytes rather than the originally loaded file — edits so far survive.
567
831
  const headerFooter = this.require("headerfooter");
568
832
  headerFooter.addEventListener("change", () => {
@@ -590,6 +854,82 @@ class RibbonSurface {
590
854
  this.run("clear numbering", () => this.live.clearPageNumbering());
591
855
  this.syncPageNumbering();
592
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();
593
933
  }
594
934
  /** The bands own the same setting and read the live session, so both stay in step. */
595
935
  syncPageNumbering() {
@@ -599,7 +939,417 @@ class RibbonSurface {
599
939
  this.require("pgfmt").value = numbering.format ?? "";
600
940
  this.require("pgstart").value = numbering.start != null ? String(numbering.start) : "";
601
941
  }
602
- // ── 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
+ }
603
1353
  wirePicker() {
604
1354
  const button = this.require("table");
605
1355
  const picker = this.require("gridpicker");
@@ -628,7 +1378,7 @@ class RibbonSurface {
628
1378
  event.preventDefault();
629
1379
  const rows = Number(cell.dataset.r) + 1;
630
1380
  const cols = Number(cell.dataset.c) + 1;
631
- this.closePicker();
1381
+ this.closePopovers();
632
1382
  this.run(`table ${rows}×${cols}`, () => this.live.insertTable(rows, cols, {
633
1383
  borderless: this.require("gridborderless").checked,
634
1384
  cellAlignment: this.require("gridalign").value,
@@ -639,32 +1389,73 @@ class RibbonSurface {
639
1389
  if (!this.live)
640
1390
  return;
641
1391
  if (picker.hasAttribute("data-open")) {
642
- this.closePicker();
1392
+ this.closePopovers();
643
1393
  return;
644
1394
  }
1395
+ this.closePopovers();
645
1396
  highlight(0, 0);
646
1397
  picker.setAttribute("data-open", "");
647
- // Compact chrome docks the picker to the viewport bottom (see the stylesheet),
648
- // so only the roomy layout needs it positioned under its button.
649
- if (this.density === "full") {
650
- const rect = button.getBoundingClientRect();
651
- const host = this.element.getBoundingClientRect();
652
- picker.style.left = `${rect.left - host.left}px`;
653
- 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();
654
1429
  }
655
1430
  });
656
1431
  }
657
- 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() {
658
1446
  this.control("gridpicker")?.removeAttribute("data-open");
1447
+ this.control("linkpop")?.removeAttribute("data-open");
659
1448
  }
660
- maybeClosePicker(event) {
661
- const picker = this.control("gridpicker");
662
- if (!picker?.hasAttribute("data-open"))
663
- return;
1449
+ maybeClosePopovers(event) {
664
1450
  const target = event.target;
665
- if (picker.contains(target) || this.control("table")?.contains(target))
666
- return;
667
- 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
+ }
668
1459
  }
669
1460
  // ── tabs ────────────────────────────────────────────────────────────────────
670
1461
  selectTab(name) {
@@ -677,67 +1468,16 @@ class RibbonSurface {
677
1468
  else
678
1469
  panel.removeAttribute("data-active");
679
1470
  }
680
- if (name === "layout")
1471
+ if (name === "layout") {
681
1472
  this.syncPageNumbering();
682
- if (name === "review")
683
- this.renderThreads();
684
- }
685
- /** Re-render the Review tab's thread list from session truth ({@link DocxEditor.listComments}):
686
- * one row per comment, replies indented under their thread root, resolve/reopen per root. */
687
- renderThreads() {
688
- const host = this.control("threads");
689
- if (!host || !this.live)
690
- return;
691
- const doc = this.element.ownerDocument ?? document;
692
- host.textContent = "";
693
- const comments = this.live.listComments();
694
- if (comments.length === 0) {
695
- const empty = doc.createElement("span");
696
- empty.className = "dxr-note";
697
- empty.textContent = "No comments yet.";
698
- host.appendChild(empty);
699
- return;
700
- }
701
- for (const entry of comments) {
702
- const row = doc.createElement("div");
703
- row.className = "dxr-thread";
704
- row.setAttribute("data-thread", entry.anchorId);
705
- if (entry.resolved)
706
- row.setAttribute("data-resolved", "");
707
- if (entry.parentAnchorId)
708
- row.setAttribute("data-reply", "");
709
- const author = doc.createElement("span");
710
- author.className = "dxr-tauthor";
711
- author.textContent = entry.author || "—";
712
- row.appendChild(author);
713
- const text = doc.createElement("span");
714
- text.className = "dxr-ttext";
715
- text.textContent = entry.text;
716
- text.title = entry.text;
717
- row.appendChild(text);
718
- // Resolution is a THREAD state: Word keys it on the root, so replies get no toggle.
719
- if (!entry.parentAnchorId) {
720
- const toggle = doc.createElement("button");
721
- toggle.type = "button";
722
- toggle.textContent = entry.resolved ? "Reopen" : "Resolve";
723
- toggle.title = entry.resolved
724
- ? "Reopen this comment thread"
725
- : "Mark this comment thread resolved";
726
- toggle.addEventListener("click", () => {
727
- if (!this.live)
728
- return;
729
- this.run(entry.resolved ? "reopen comment" : "resolve comment", () => this.live.setCommentResolved(entry.anchorId, !entry.resolved));
730
- this.renderThreads();
731
- });
732
- row.appendChild(toggle);
733
- }
734
- host.appendChild(row);
1473
+ this.syncLayoutState();
735
1474
  }
736
- // The thread list is the one strip content that grows while its panel is
737
- // already visible, which a ResizeObserver on the panel cannot see.
738
- const panel = this.element.querySelector('.dxr-panel[data-panel="review"]');
739
- if (panel)
740
- this.syncStripFade(panel);
1475
+ if (name === "review")
1476
+ this.syncReviewState();
1477
+ if (name === "headerfooter")
1478
+ this.syncHeaderFooterState();
1479
+ if (name === "view")
1480
+ this.syncZoom();
741
1481
  }
742
1482
  // ── selection-driven state ──────────────────────────────────────────────────
743
1483
  selectionElement() {
@@ -757,12 +1497,33 @@ class RibbonSurface {
757
1497
  for (const button of Array.from(this.element.querySelectorAll("button[data-cmd]"))) {
758
1498
  button.classList.toggle("dxr-on", !!state[button.dataset.cmd]);
759
1499
  }
760
- // Reflect the caret's font size, unless the field itself is being edited.
761
- const fontsize = this.control("fontsize");
762
- if (el && fontsize && (this.element.ownerDocument ?? document).activeElement !== fontsize) {
763
- const px = parseFloat(getComputedStyle(el).fontSize);
764
- if (px)
765
- 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")));
766
1527
  }
767
1528
  // Reveal the contextual Table tab only while the caret is inside a table.
768
1529
  const inTable = !!el?.closest("table");
@@ -771,12 +1532,17 @@ class RibbonSurface {
771
1532
  tableTab.hidden = !inTable;
772
1533
  if (!inTable && tableTab.getAttribute("aria-selected") === "true")
773
1534
  this.selectTab("home");
1535
+ if (inTable) {
1536
+ const row = el?.closest("tr");
1537
+ this.require("repeatheader").checked = !!row?.classList.toString().includes("header");
1538
+ }
774
1539
  }
775
1540
  this.refreshRailAnchor();
1541
+ this.scheduleStats();
776
1542
  }
777
1543
  /** Scope is read from where the block lives, which is exactly what the anchor encodes. */
778
1544
  scopeOf(el) {
779
- const band = el.closest(".docx-hf-band");
1545
+ const band = el.closest("[data-hf-band]");
780
1546
  if (band)
781
1547
  return band.getAttribute("data-hf-band") === "header" ? "hdr" : "ftr";
782
1548
  if (el.closest(".footnotes"))
@@ -828,6 +1594,40 @@ class RibbonSurface {
828
1594
  void anchorEl.offsetWidth;
829
1595
  anchorEl.classList.add("dxr-flash");
830
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
+ }
831
1631
  // ── loading overlay ─────────────────────────────────────────────────────────
832
1632
  createLoaderController() {
833
1633
  const overlay = this.control("loader");