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