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.
- package/README.md +7 -5
- package/dist/editor-comments.d.ts +129 -0
- package/dist/editor-comments.d.ts.map +1 -0
- package/dist/editor-comments.js +805 -0
- package/dist/editor-comments.js.map +1 -0
- package/dist/editor-headerfooter.d.ts +125 -85
- package/dist/editor-headerfooter.d.ts.map +1 -1
- package/dist/editor-headerfooter.js +572 -306
- package/dist/editor-headerfooter.js.map +1 -1
- package/dist/editor-image-patch.d.ts +38 -0
- package/dist/editor-image-patch.d.ts.map +1 -0
- package/dist/editor-image-patch.js +101 -0
- package/dist/editor-image-patch.js.map +1 -0
- package/dist/editor.bundle.js +3760 -785
- package/dist/editor.d.ts +237 -5
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +1063 -112
- package/dist/editor.js.map +1 -1
- package/dist/embed.bundle.js +3784 -759
- package/dist/embed.d.ts +1 -1
- package/dist/embed.d.ts.map +1 -1
- package/dist/embed.iife.js +3772 -747
- package/dist/embed.js +10 -1
- package/dist/embed.js.map +1 -1
- package/dist/export-assets.json +14 -14
- package/dist/export-browser.bundle.js +25 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/pagination.bundle.js +25 -0
- package/dist/pagination.d.ts +6 -0
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +19 -0
- package/dist/pagination.js.map +1 -1
- package/dist/ribbon-chrome.d.ts +20 -4
- package/dist/ribbon-chrome.d.ts.map +1 -1
- package/dist/ribbon-chrome.js +550 -182
- package/dist/ribbon-chrome.js.map +1 -1
- package/dist/ribbon.d.ts +5 -4
- package/dist/ribbon.d.ts.map +1 -1
- package/dist/ribbon.js +941 -141
- package/dist/ribbon.js.map +1 -1
- package/dist/session.bundle.js +48 -3
- package/dist/session.d.ts +35 -4
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +44 -3
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +114 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/viewport.d.ts +7 -0
- package/dist/viewport.d.ts.map +1 -1
- package/dist/viewport.js +12 -0
- package/dist/viewport.js.map +1 -1
- package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm +0 -0
- package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm.br +0 -0
- package/dist/wasm/_framework/Docxodus.wasm +0 -0
- package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
- package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.Linq.wasm.br +0 -0
- package/dist/wasm/_framework/dotnet.boot.js +6 -6
- package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
- package/dist/wasm/_framework/dotnet.native.wasm +0 -0
- package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
- 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
|
|
7
|
-
* overlay — wired onto exactly that command
|
|
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 {
|
|
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", "
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
//
|
|
262
|
-
this.
|
|
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:
|
|
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:
|
|
353
|
-
revisionAuthor: this.
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
style.
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
1392
|
+
this.closePopovers();
|
|
643
1393
|
return;
|
|
644
1394
|
}
|
|
1395
|
+
this.closePopovers();
|
|
645
1396
|
highlight(0, 0);
|
|
646
1397
|
picker.setAttribute("data-open", "");
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
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
|
-
|
|
661
|
-
const picker = this.control("gridpicker");
|
|
662
|
-
if (!picker?.hasAttribute("data-open"))
|
|
663
|
-
return;
|
|
1449
|
+
maybeClosePopovers(event) {
|
|
664
1450
|
const target = event.target;
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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("
|
|
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");
|