docxodus 9.1.0 → 9.2.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 +39 -18
- package/dist/editor-reconcile.d.ts.map +1 -1
- package/dist/editor-reconcile.js +33 -5
- package/dist/editor-reconcile.js.map +1 -1
- package/dist/editor.bundle.js +1912 -24
- package/dist/editor.d.ts +52 -3
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +261 -18
- package/dist/editor.js.map +1 -1
- package/dist/embed.bundle.js +1955 -22
- package/dist/embed.d.ts +26 -1
- package/dist/embed.d.ts.map +1 -1
- package/dist/embed.iife.js +1955 -22
- package/dist/embed.js +70 -1
- package/dist/embed.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/ribbon-chrome.d.ts +45 -0
- package/dist/ribbon-chrome.d.ts.map +1 -0
- package/dist/ribbon-chrome.js +965 -0
- package/dist/ribbon-chrome.js.map +1 -0
- package/dist/ribbon.d.ts +153 -0
- package/dist/ribbon.d.ts.map +1 -0
- package/dist/ribbon.js +830 -0
- package/dist/ribbon.js.map +1 -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/dotnet.boot.js +3 -3
- package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
- package/package.json +3 -3
package/dist/ribbon.js
ADDED
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ribbon surface — the full editor UI as a mountable module.
|
|
3
|
+
*
|
|
4
|
+
* `DocxEditor` (editor.ts) is the document engine: it owns the live `DocxSession`,
|
|
5
|
+
* the block wiring and every command. It has deliberately no chrome. This module is
|
|
6
|
+
* the chrome — the tabbed ribbon, the anchor rail, the table picker and the loading
|
|
7
|
+
* overlay — wired onto exactly that command surface and nothing else.
|
|
8
|
+
*
|
|
9
|
+
* It exists because the same UI was hand-written three times (the standalone demo,
|
|
10
|
+
* the GitHub Pages landing page, the compact iframe player) and drifted. Now the
|
|
11
|
+
* surface has one owner, and the three hosts differ only in how they obtain the
|
|
12
|
+
* WASM exports and how much of the chrome they turn on:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* // Host that already booted the .NET runtime itself:
|
|
16
|
+
* const ribbon = mountRibbon(document.querySelector("#app")!, { exports });
|
|
17
|
+
* ribbon.open(bytes, "contract.docx");
|
|
18
|
+
*
|
|
19
|
+
* // Host that wants one call and a CDN (see embed.ts):
|
|
20
|
+
* await createRibbonEditor("#app", "./contract.docx", { chrome: "auto" });
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Chrome density is measured from the ROOT ELEMENT, not the viewport: a narrow
|
|
24
|
+
* embed on a wide desktop page is narrow. See ribbon-chrome.ts for the layout.
|
|
25
|
+
*/
|
|
26
|
+
import { DocxEditor } from "./editor.js";
|
|
27
|
+
import { RIBBON_CSS, RIBBON_HINT_HTML, RIBBON_HTML, RIBBON_STYLE_ATTR, RIBBON_STYLE_VERSION, } from "./ribbon-chrome.js";
|
|
28
|
+
// Re-exported so the IIFE bundle built from this module (window.DocxodusEditor)
|
|
29
|
+
// carries both the shell and the engine it wraps.
|
|
30
|
+
export { DocxEditor } from "./editor.js";
|
|
31
|
+
const DEFAULT_STAGES = [
|
|
32
|
+
{
|
|
33
|
+
title: "Booting .NET inside your browser",
|
|
34
|
+
copy: "Streaming the trimmed WebAssembly runtime. No document bytes are sent to a server.",
|
|
35
|
+
progress: 16,
|
|
36
|
+
label: "Loading engine",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
title: "Opening the document",
|
|
40
|
+
copy: "Parsing OOXML parts, styles, numbering, tables, notes, and tracked revisions locally.",
|
|
41
|
+
progress: 54,
|
|
42
|
+
label: "Reading document",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
title: "Wiring lossless editing",
|
|
46
|
+
copy: "Connecting every editable block to its native WordprocessingML anchor.",
|
|
47
|
+
progress: 84,
|
|
48
|
+
label: "Mounting editor",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
title: "Your local editor is ready",
|
|
52
|
+
copy: "Select text, use the ribbon, switch page view, and save a real DOCX.",
|
|
53
|
+
progress: 100,
|
|
54
|
+
label: "Ready",
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
const DEFAULT_FEATURES = [
|
|
58
|
+
{ number: "01", title: "Zero-upload architecture", copy: "Your source file and edits never leave this browser session." },
|
|
59
|
+
{ number: "02", title: "Word-grade OOXML fidelity", copy: "Tables, numbering, footnotes, redlines, comments, and styles stay native." },
|
|
60
|
+
{ number: "03", title: "Surgical editing", copy: "Formatting and text edits target real document anchors instead of flattening the file." },
|
|
61
|
+
{ number: "04", title: "Lossless DOCX out", copy: "Undo, redo, edit, and save a Word document that remains a Word document." },
|
|
62
|
+
];
|
|
63
|
+
/** Every `data-dxr` name the template defines — the id-collision probe set. */
|
|
64
|
+
const CONTROL_NAMES = [
|
|
65
|
+
"docname", "new", "file", "save", "undo", "redo", "status", "ribbon",
|
|
66
|
+
"fontsize", "fontsizes", "fontfamily", "style", "delblock",
|
|
67
|
+
"table", "hr", "hrThick", "hrDouble", "rulepos", "hrClear", "footnote", "endnote",
|
|
68
|
+
"paginated", "headerfooter", "pgfmt", "pgstart", "pgclear", "pagenum", "totalpages",
|
|
69
|
+
"railAnchor", "railBlocks", "railSession", "railOp",
|
|
70
|
+
"gridpicker", "gridcells", "gridlabel", "gridalign", "gridborderless",
|
|
71
|
+
"editor",
|
|
72
|
+
"loader", "loaderEyebrow", "loaderTitle", "loaderCopy", "loaderAd", "loaderNumber",
|
|
73
|
+
"loaderAdTitle", "loaderAdCopy", "loaderBar", "loaderLabel", "loaderMeta", "loaderRetry",
|
|
74
|
+
];
|
|
75
|
+
const GRID_ROWS = 8;
|
|
76
|
+
const GRID_COLS = 10;
|
|
77
|
+
let nextIdPrefixSeed = 0;
|
|
78
|
+
/** Inject the ribbon stylesheet once per document (replacing an older version). */
|
|
79
|
+
function ensureStyles(doc) {
|
|
80
|
+
const existing = doc.querySelector(`style[${RIBBON_STYLE_ATTR}]`);
|
|
81
|
+
if (existing?.getAttribute(RIBBON_STYLE_ATTR) === RIBBON_STYLE_VERSION)
|
|
82
|
+
return;
|
|
83
|
+
existing?.remove();
|
|
84
|
+
const style = doc.createElement("style");
|
|
85
|
+
style.setAttribute(RIBBON_STYLE_ATTR, RIBBON_STYLE_VERSION);
|
|
86
|
+
style.textContent = RIBBON_CSS;
|
|
87
|
+
(doc.head ?? doc.documentElement).appendChild(style);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Pick an id prefix. An explicit one always wins. Otherwise bare ids are used when
|
|
91
|
+
* every name is free on the page — which keeps the historical ids of the standalone
|
|
92
|
+
* demo intact — and a generated prefix is used the moment any of them is taken.
|
|
93
|
+
*/
|
|
94
|
+
function resolveIdPrefix(explicit, doc) {
|
|
95
|
+
if (explicit !== undefined)
|
|
96
|
+
return explicit;
|
|
97
|
+
if (!CONTROL_NAMES.some((name) => doc.getElementById(name)))
|
|
98
|
+
return "";
|
|
99
|
+
for (;;) {
|
|
100
|
+
const candidate = `dxr${++nextIdPrefixSeed}-`;
|
|
101
|
+
if (!CONTROL_NAMES.some((name) => doc.getElementById(candidate + name)))
|
|
102
|
+
return candidate;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function resolveContainer(container) {
|
|
106
|
+
if (typeof container !== "string")
|
|
107
|
+
return container;
|
|
108
|
+
const el = document.querySelector(container);
|
|
109
|
+
if (!el)
|
|
110
|
+
throw new Error(`Docxodus ribbon: no element matches "${container}"`);
|
|
111
|
+
return el;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Mount the ribbon surface into `container`.
|
|
115
|
+
*
|
|
116
|
+
* The chrome paints synchronously — including the loading overlay — so a host can
|
|
117
|
+
* mount before its WASM runtime exists, narrate the boot through `loader`, then call
|
|
118
|
+
* `setExports` and `open`.
|
|
119
|
+
*/
|
|
120
|
+
export function mountRibbon(container, options = {}) {
|
|
121
|
+
return new RibbonSurface(resolveContainer(container), options);
|
|
122
|
+
}
|
|
123
|
+
class RibbonSurface {
|
|
124
|
+
constructor(container, options) {
|
|
125
|
+
this.live = null;
|
|
126
|
+
this.density = "full";
|
|
127
|
+
this.destroyed = false;
|
|
128
|
+
this.featureTimer = null;
|
|
129
|
+
this.featureIndex = 0;
|
|
130
|
+
this.resizeObserver = null;
|
|
131
|
+
this.lastAnchorText = "";
|
|
132
|
+
this.selectionFrame = null;
|
|
133
|
+
this.options = options;
|
|
134
|
+
this.exports = options.exports ?? null;
|
|
135
|
+
this.documentName = options.documentName ?? "untitled.docx";
|
|
136
|
+
this.chromeMode = options.chrome ?? "auto";
|
|
137
|
+
this.headerFooter = options.headerFooter ?? false;
|
|
138
|
+
// false suppresses the overlay; true/omitted takes the defaults; an object rewords it.
|
|
139
|
+
this.loaderOptions =
|
|
140
|
+
options.loader === false
|
|
141
|
+
? null
|
|
142
|
+
: typeof options.loader === "object"
|
|
143
|
+
? options.loader
|
|
144
|
+
: {};
|
|
145
|
+
this.stages = this.loaderOptions?.stages ?? DEFAULT_STAGES;
|
|
146
|
+
this.features = this.loaderOptions?.features ?? DEFAULT_FEATURES;
|
|
147
|
+
const doc = container.ownerDocument ?? document;
|
|
148
|
+
ensureStyles(doc);
|
|
149
|
+
this.idPrefix = resolveIdPrefix(options.idPrefix, doc);
|
|
150
|
+
const root = doc.createElement("div");
|
|
151
|
+
root.className = "dxr";
|
|
152
|
+
root.dataset.state = "idle";
|
|
153
|
+
root.innerHTML = RIBBON_HTML;
|
|
154
|
+
for (const el of Array.from(root.querySelectorAll("[data-dxr]"))) {
|
|
155
|
+
el.id = this.idPrefix + el.dataset.dxr;
|
|
156
|
+
}
|
|
157
|
+
for (const el of Array.from(root.querySelectorAll("[data-dxr-list]"))) {
|
|
158
|
+
el.setAttribute("list", this.idPrefix + el.dataset.dxrList);
|
|
159
|
+
}
|
|
160
|
+
container.replaceChildren(root);
|
|
161
|
+
this.element = root;
|
|
162
|
+
this.surface = this.require("editor");
|
|
163
|
+
this.applyStaticOptions();
|
|
164
|
+
this.buildGrid();
|
|
165
|
+
this.wire();
|
|
166
|
+
this.applyChrome();
|
|
167
|
+
this.loader = this.createLoaderController();
|
|
168
|
+
if (this.loaderOptions)
|
|
169
|
+
this.loader.show();
|
|
170
|
+
// Coalesced to one frame: selectionchange fires per keystroke and continuously
|
|
171
|
+
// during a drag, and the sync reads computed styles.
|
|
172
|
+
this.onSelectionChange = () => {
|
|
173
|
+
if (this.selectionFrame != null)
|
|
174
|
+
cancelAnimationFrame(this.selectionFrame);
|
|
175
|
+
this.selectionFrame = requestAnimationFrame(() => {
|
|
176
|
+
this.selectionFrame = null;
|
|
177
|
+
this.syncSelection();
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
doc.addEventListener("selectionchange", this.onSelectionChange);
|
|
181
|
+
this.onDocumentMouseDown = (event) => this.maybeClosePicker(event);
|
|
182
|
+
doc.addEventListener("mousedown", this.onDocumentMouseDown);
|
|
183
|
+
if (this.chromeMode === "auto" && typeof ResizeObserver !== "undefined") {
|
|
184
|
+
this.resizeObserver = new ResizeObserver(() => this.applyChrome());
|
|
185
|
+
this.resizeObserver.observe(root);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// ── element lookup ──────────────────────────────────────────────────────────
|
|
189
|
+
control(name) {
|
|
190
|
+
return this.element.querySelector(`[data-dxr="${name}"]`);
|
|
191
|
+
}
|
|
192
|
+
require(name) {
|
|
193
|
+
const el = this.control(name);
|
|
194
|
+
if (!el)
|
|
195
|
+
throw new Error(`Docxodus ribbon: template is missing "${name}"`);
|
|
196
|
+
return el;
|
|
197
|
+
}
|
|
198
|
+
// ── mount-time configuration ────────────────────────────────────────────────
|
|
199
|
+
applyStaticOptions() {
|
|
200
|
+
const hintEl = this.element.querySelector("[data-dxr-hint]");
|
|
201
|
+
if (hintEl) {
|
|
202
|
+
if (this.options.hint === false)
|
|
203
|
+
hintEl.remove();
|
|
204
|
+
else
|
|
205
|
+
hintEl.innerHTML = typeof this.options.hint === "string" ? this.options.hint : RIBBON_HINT_HTML;
|
|
206
|
+
}
|
|
207
|
+
if (this.options.rail === false) {
|
|
208
|
+
this.element.querySelector("[data-dxr-rail]")?.remove();
|
|
209
|
+
}
|
|
210
|
+
if (this.options.fileActions === false) {
|
|
211
|
+
this.element.querySelector("[data-dxr-files]")?.remove();
|
|
212
|
+
}
|
|
213
|
+
if (!this.loaderOptions)
|
|
214
|
+
this.control("loader")?.remove();
|
|
215
|
+
this.require("docname").textContent = this.documentName;
|
|
216
|
+
(this.require("paginated")).checked = this.options.paginated ?? false;
|
|
217
|
+
(this.require("headerfooter")).checked = this.headerFooter;
|
|
218
|
+
this.surface.dataset.view = this.options.paginated ? "paginated" : "continuous";
|
|
219
|
+
}
|
|
220
|
+
buildGrid() {
|
|
221
|
+
const cells = this.require("gridcells");
|
|
222
|
+
const fragment = document.createDocumentFragment();
|
|
223
|
+
for (let r = 0; r < GRID_ROWS; r++) {
|
|
224
|
+
for (let c = 0; c < GRID_COLS; c++) {
|
|
225
|
+
const cell = document.createElement("div");
|
|
226
|
+
cell.dataset.r = String(r);
|
|
227
|
+
cell.dataset.c = String(c);
|
|
228
|
+
fragment.appendChild(cell);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
cells.replaceChildren(fragment);
|
|
232
|
+
}
|
|
233
|
+
// ── chrome density ──────────────────────────────────────────────────────────
|
|
234
|
+
get chrome() {
|
|
235
|
+
return this.density;
|
|
236
|
+
}
|
|
237
|
+
setChrome(mode) {
|
|
238
|
+
this.chromeMode = mode;
|
|
239
|
+
if (mode === "auto" && !this.resizeObserver && typeof ResizeObserver !== "undefined") {
|
|
240
|
+
this.resizeObserver = new ResizeObserver(() => this.applyChrome());
|
|
241
|
+
this.resizeObserver.observe(this.element);
|
|
242
|
+
}
|
|
243
|
+
this.applyChrome();
|
|
244
|
+
}
|
|
245
|
+
applyChrome() {
|
|
246
|
+
const breakpoint = this.options.compactBreakpoint ?? 720;
|
|
247
|
+
const width = this.element.clientWidth || this.element.getBoundingClientRect().width;
|
|
248
|
+
const next = this.chromeMode === "auto"
|
|
249
|
+
// Width 0 means the root is not laid out yet (display:none, or measured before
|
|
250
|
+
// first paint). Assume the roomier layout and let the observer correct it.
|
|
251
|
+
? (width > 0 && width < breakpoint ? "compact" : "full")
|
|
252
|
+
: this.chromeMode;
|
|
253
|
+
if (next === this.density && this.element.dataset.chrome)
|
|
254
|
+
return;
|
|
255
|
+
this.density = next;
|
|
256
|
+
this.element.dataset.chrome = next;
|
|
257
|
+
// The picker's absolute position is meaningless after a density flip.
|
|
258
|
+
this.closePicker();
|
|
259
|
+
}
|
|
260
|
+
// ── status ──────────────────────────────────────────────────────────────────
|
|
261
|
+
/** Publish the lifecycle on the root, where CSS and host pages can key off it. */
|
|
262
|
+
setState(state) {
|
|
263
|
+
this.element.dataset.state = state;
|
|
264
|
+
}
|
|
265
|
+
setStatus(text) {
|
|
266
|
+
const el = this.control("status");
|
|
267
|
+
if (el)
|
|
268
|
+
el.textContent = text;
|
|
269
|
+
this.options.onStatus?.(text);
|
|
270
|
+
}
|
|
271
|
+
// ── document lifecycle ──────────────────────────────────────────────────────
|
|
272
|
+
get editor() {
|
|
273
|
+
return this.live;
|
|
274
|
+
}
|
|
275
|
+
setExports(exports) {
|
|
276
|
+
this.exports = exports;
|
|
277
|
+
}
|
|
278
|
+
open(bytes, name) {
|
|
279
|
+
if (!this.exports)
|
|
280
|
+
throw new Error("Docxodus ribbon: WASM exports are not set yet");
|
|
281
|
+
if (this.live) {
|
|
282
|
+
try {
|
|
283
|
+
this.live.close();
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
/* already closed */
|
|
287
|
+
}
|
|
288
|
+
this.live = null;
|
|
289
|
+
}
|
|
290
|
+
if (name)
|
|
291
|
+
this.documentName = name;
|
|
292
|
+
this.require("docname").textContent = this.documentName;
|
|
293
|
+
const paginated = this.require("paginated").checked;
|
|
294
|
+
this.surface.dataset.view = paginated ? "paginated" : "continuous";
|
|
295
|
+
this.surface.replaceChildren();
|
|
296
|
+
const started = performance.now();
|
|
297
|
+
this.live = DocxEditor.open(this.surface, bytes, this.exports, {
|
|
298
|
+
cssPrefix: this.options.cssPrefix,
|
|
299
|
+
fabricateClasses: this.options.fabricateClasses,
|
|
300
|
+
editable: this.options.editable,
|
|
301
|
+
scale: this.options.scale,
|
|
302
|
+
onEdit: this.options.onEdit,
|
|
303
|
+
paginated,
|
|
304
|
+
headerFooter: this.headerFooter,
|
|
305
|
+
});
|
|
306
|
+
this.require("save").disabled = false;
|
|
307
|
+
this.require("ribbon").setAttribute("aria-disabled", "false");
|
|
308
|
+
this.setState("ready");
|
|
309
|
+
this.setStatus(`Rendered in ${Math.round(performance.now() - started)} ms`);
|
|
310
|
+
this.syncPageNumbering();
|
|
311
|
+
this.refreshRailCounts();
|
|
312
|
+
this.refreshRailAnchor();
|
|
313
|
+
this.options.onOpen?.(this.live);
|
|
314
|
+
return this.live;
|
|
315
|
+
}
|
|
316
|
+
openBlank(name = "untitled.docx") {
|
|
317
|
+
if (!this.exports)
|
|
318
|
+
throw new Error("Docxodus ribbon: WASM exports are not set yet");
|
|
319
|
+
return this.open(this.exports.DocxSessionBridge.CreateBlankDocx(), name);
|
|
320
|
+
}
|
|
321
|
+
save() {
|
|
322
|
+
return this.live ? this.live.save() : null;
|
|
323
|
+
}
|
|
324
|
+
download(name) {
|
|
325
|
+
const bytes = this.save();
|
|
326
|
+
if (!bytes)
|
|
327
|
+
return;
|
|
328
|
+
const filename = name ?? this.documentName ?? "edited.docx";
|
|
329
|
+
if (this.options.onSave) {
|
|
330
|
+
this.options.onSave(bytes, filename);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const url = URL.createObjectURL(new Blob([bytes], {
|
|
334
|
+
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
335
|
+
}));
|
|
336
|
+
const link = document.createElement("a");
|
|
337
|
+
link.href = url;
|
|
338
|
+
link.download = filename;
|
|
339
|
+
link.click();
|
|
340
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
341
|
+
this.setStatus(`Saved ${filename}`);
|
|
342
|
+
}
|
|
343
|
+
destroy() {
|
|
344
|
+
if (this.destroyed)
|
|
345
|
+
return;
|
|
346
|
+
this.destroyed = true;
|
|
347
|
+
const doc = this.element.ownerDocument ?? document;
|
|
348
|
+
doc.removeEventListener("selectionchange", this.onSelectionChange);
|
|
349
|
+
doc.removeEventListener("mousedown", this.onDocumentMouseDown);
|
|
350
|
+
this.resizeObserver?.disconnect();
|
|
351
|
+
this.resizeObserver = null;
|
|
352
|
+
if (this.selectionFrame != null)
|
|
353
|
+
cancelAnimationFrame(this.selectionFrame);
|
|
354
|
+
this.selectionFrame = null;
|
|
355
|
+
this.stopRotation();
|
|
356
|
+
try {
|
|
357
|
+
this.live?.close();
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
/* already closed */
|
|
361
|
+
}
|
|
362
|
+
this.live = null;
|
|
363
|
+
this.element.remove();
|
|
364
|
+
}
|
|
365
|
+
// ── command plumbing ────────────────────────────────────────────────────────
|
|
366
|
+
/**
|
|
367
|
+
* Run one ribbon command and report its real cost on the rail.
|
|
368
|
+
*
|
|
369
|
+
* Every control routes through here, which is what makes the rail's "last op"
|
|
370
|
+
* an honest measurement rather than a label the surface makes up.
|
|
371
|
+
*/
|
|
372
|
+
run(label, fn) {
|
|
373
|
+
if (!this.live)
|
|
374
|
+
return;
|
|
375
|
+
const started = performance.now();
|
|
376
|
+
try {
|
|
377
|
+
fn();
|
|
378
|
+
}
|
|
379
|
+
finally {
|
|
380
|
+
const ms = performance.now() - started;
|
|
381
|
+
const el = this.control("railOp");
|
|
382
|
+
if (el)
|
|
383
|
+
el.textContent = `${label} ${ms >= 1000 ? `${(ms / 1000).toFixed(2)} s` : `${Math.round(ms)} ms`}`;
|
|
384
|
+
this.options.onCommand?.(label, ms);
|
|
385
|
+
this.refreshRailCounts();
|
|
386
|
+
this.refreshRailAnchor();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/** Format controls must not steal the document selection they are about to act on. */
|
|
390
|
+
keepSelection(el) {
|
|
391
|
+
el.addEventListener("mousedown", (event) => event.preventDefault());
|
|
392
|
+
}
|
|
393
|
+
wire() {
|
|
394
|
+
const ribbon = this.require("ribbon");
|
|
395
|
+
for (const tab of Array.from(this.element.querySelectorAll(".dxr-tab"))) {
|
|
396
|
+
this.keepSelection(tab);
|
|
397
|
+
tab.addEventListener("click", () => this.selectTab(tab.dataset.tab ?? "home"));
|
|
398
|
+
}
|
|
399
|
+
const delegate = (selector, label, fn) => {
|
|
400
|
+
for (const el of Array.from(ribbon.querySelectorAll(selector))) {
|
|
401
|
+
this.keepSelection(el);
|
|
402
|
+
el.addEventListener("click", () => this.run(label(el), () => fn(el)));
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
delegate("button[data-cmd]", (b) => b.dataset.cmd, (b) => this.live.format(b.dataset.cmd));
|
|
406
|
+
delegate("button[data-align]", (b) => `align ${b.dataset.align}`, (b) => this.live.setAlignment(b.dataset.align));
|
|
407
|
+
delegate("button[data-indent]", () => "indent", (b) => this.live.indent(parseInt(b.dataset.indent ?? "720", 10)));
|
|
408
|
+
delegate("button[data-list]", (b) => `list ${b.dataset.list}`, (b) => this.live.toggleList(b.dataset.list));
|
|
409
|
+
delegate("button[data-pagebreak]", () => "page break", () => this.live.pageBreakBefore(true));
|
|
410
|
+
delegate('.dxr-panel[data-panel="table"] button[data-tt]', (b) => b.dataset.tt, (b) => {
|
|
411
|
+
const ops = {
|
|
412
|
+
rowAbove: () => this.live.insertTableRow("above"),
|
|
413
|
+
rowBelow: () => this.live.insertTableRow("below"),
|
|
414
|
+
colLeft: () => this.live.insertTableColumn("left"),
|
|
415
|
+
colRight: () => this.live.insertTableColumn("right"),
|
|
416
|
+
delRow: () => this.live.deleteTableRow(),
|
|
417
|
+
delCol: () => this.live.deleteTableColumn(),
|
|
418
|
+
};
|
|
419
|
+
ops[b.dataset.tt ?? ""]?.();
|
|
420
|
+
});
|
|
421
|
+
const rulepos = () => (this.require("rulepos").value === "above" ? "above" : "below");
|
|
422
|
+
const simple = [
|
|
423
|
+
["undo", "undo", () => this.live.undo()],
|
|
424
|
+
["redo", "redo", () => this.live.redo()],
|
|
425
|
+
["hr", "rule", () => this.live.insertHorizontalRule(12, "single", rulepos())],
|
|
426
|
+
["hrThick", "thick rule", () => this.live.insertHorizontalRule(24, "single", rulepos())],
|
|
427
|
+
["hrDouble", "double rule", () => this.live.insertHorizontalRule(12, "double", rulepos())],
|
|
428
|
+
["hrClear", "clear border", () => this.live.clearParagraphBorders()],
|
|
429
|
+
["delblock", "delete block", () => this.live.deleteBlock()],
|
|
430
|
+
["footnote", "footnote", () => this.live.insertFootnote()],
|
|
431
|
+
["endnote", "endnote", () => this.live.insertEndnote()],
|
|
432
|
+
["pagenum", "page number", () => this.live.insertPageNumber("currentPage")],
|
|
433
|
+
["totalpages", "total pages", () => this.live.insertPageNumber("totalPages")],
|
|
434
|
+
];
|
|
435
|
+
for (const [name, label, fn] of simple) {
|
|
436
|
+
const el = this.control(name);
|
|
437
|
+
if (!el)
|
|
438
|
+
continue;
|
|
439
|
+
this.keepSelection(el);
|
|
440
|
+
el.addEventListener("click", () => this.run(label, fn));
|
|
441
|
+
}
|
|
442
|
+
// Font size: `change` only (it fires on Enter and on blur). Binding keydown as
|
|
443
|
+
// well fired twice and stacked two undo snapshots for one size change.
|
|
444
|
+
const fontsize = this.require("fontsize");
|
|
445
|
+
fontsize.addEventListener("change", () => {
|
|
446
|
+
const pts = parseFloat(fontsize.value);
|
|
447
|
+
if (this.live && pts > 0)
|
|
448
|
+
this.run("font size", () => this.live.setFontSize(pts));
|
|
449
|
+
});
|
|
450
|
+
fontsize.addEventListener("keydown", (event) => {
|
|
451
|
+
if (event.key === "Enter") {
|
|
452
|
+
event.preventDefault();
|
|
453
|
+
fontsize.blur();
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
const fontfamily = this.require("fontfamily");
|
|
457
|
+
fontfamily.addEventListener("change", () => {
|
|
458
|
+
if (this.live && fontfamily.value)
|
|
459
|
+
this.run("font family", () => this.live.setFontFamily(fontfamily.value));
|
|
460
|
+
fontfamily.value = "";
|
|
461
|
+
});
|
|
462
|
+
const style = this.require("style");
|
|
463
|
+
style.addEventListener("change", () => {
|
|
464
|
+
if (this.live && style.value)
|
|
465
|
+
this.run("style", () => this.live.setParagraphStyle(style.value));
|
|
466
|
+
style.value = "";
|
|
467
|
+
});
|
|
468
|
+
this.wireFileActions();
|
|
469
|
+
this.wireLayout();
|
|
470
|
+
this.wirePicker();
|
|
471
|
+
}
|
|
472
|
+
wireFileActions() {
|
|
473
|
+
const file = this.control("file");
|
|
474
|
+
file?.addEventListener("change", async () => {
|
|
475
|
+
const chosen = file.files?.[0];
|
|
476
|
+
if (!chosen)
|
|
477
|
+
return;
|
|
478
|
+
this.setStatus(`Loading ${chosen.name}…`);
|
|
479
|
+
this.open(new Uint8Array(await chosen.arrayBuffer()), chosen.name);
|
|
480
|
+
// Reset so re-picking the same file fires `change` again.
|
|
481
|
+
file.value = "";
|
|
482
|
+
});
|
|
483
|
+
this.control("new")?.addEventListener("click", () => {
|
|
484
|
+
if (this.exports)
|
|
485
|
+
this.openBlank("untitled.docx");
|
|
486
|
+
});
|
|
487
|
+
this.control("save")?.addEventListener("click", () => this.download());
|
|
488
|
+
}
|
|
489
|
+
wireLayout() {
|
|
490
|
+
// Pagination toggles re-render the LIVE session, so edits survive the switch.
|
|
491
|
+
const paginated = this.require("paginated");
|
|
492
|
+
paginated.addEventListener("change", () => {
|
|
493
|
+
this.surface.dataset.view = paginated.checked ? "paginated" : "continuous";
|
|
494
|
+
if (!this.live)
|
|
495
|
+
return;
|
|
496
|
+
this.run(paginated.checked ? "page view" : "continuous view", () => this.live.setPaginated(paginated.checked));
|
|
497
|
+
});
|
|
498
|
+
// The band region is chosen at open time, so toggling it re-opens the LIVE
|
|
499
|
+
// session's bytes rather than the originally loaded file — edits so far survive.
|
|
500
|
+
const headerFooter = this.require("headerfooter");
|
|
501
|
+
headerFooter.addEventListener("change", () => {
|
|
502
|
+
this.headerFooter = headerFooter.checked;
|
|
503
|
+
if (!this.live)
|
|
504
|
+
return;
|
|
505
|
+
this.open(this.live.save(), this.documentName);
|
|
506
|
+
});
|
|
507
|
+
const pgfmt = this.require("pgfmt");
|
|
508
|
+
pgfmt.addEventListener("change", () => {
|
|
509
|
+
if (!this.live || !pgfmt.value)
|
|
510
|
+
return;
|
|
511
|
+
this.run("page format", () => this.live.setPageNumbering({ format: pgfmt.value }));
|
|
512
|
+
this.syncPageNumbering();
|
|
513
|
+
});
|
|
514
|
+
const pgstart = this.require("pgstart");
|
|
515
|
+
pgstart.addEventListener("change", () => {
|
|
516
|
+
const value = parseInt(pgstart.value, 10);
|
|
517
|
+
if (!this.live || !(value > 0))
|
|
518
|
+
return;
|
|
519
|
+
this.run("page start", () => this.live.setPageNumbering({ start: value }));
|
|
520
|
+
this.syncPageNumbering();
|
|
521
|
+
});
|
|
522
|
+
this.control("pgclear")?.addEventListener("click", () => {
|
|
523
|
+
this.run("clear numbering", () => this.live.clearPageNumbering());
|
|
524
|
+
this.syncPageNumbering();
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
/** The bands own the same setting and read the live session, so both stay in step. */
|
|
528
|
+
syncPageNumbering() {
|
|
529
|
+
if (!this.live)
|
|
530
|
+
return;
|
|
531
|
+
const numbering = this.live.pageNumbering() ?? {};
|
|
532
|
+
this.require("pgfmt").value = numbering.format ?? "";
|
|
533
|
+
this.require("pgstart").value = numbering.start != null ? String(numbering.start) : "";
|
|
534
|
+
}
|
|
535
|
+
// ── table size picker ───────────────────────────────────────────────────────
|
|
536
|
+
wirePicker() {
|
|
537
|
+
const button = this.require("table");
|
|
538
|
+
const picker = this.require("gridpicker");
|
|
539
|
+
const cells = this.require("gridcells");
|
|
540
|
+
const highlight = (rows, cols) => {
|
|
541
|
+
for (const cell of Array.from(cells.children)) {
|
|
542
|
+
const on = Number(cell.dataset.r) < rows && Number(cell.dataset.c) < cols;
|
|
543
|
+
if (on)
|
|
544
|
+
cell.setAttribute("data-on", "");
|
|
545
|
+
else
|
|
546
|
+
cell.removeAttribute("data-on");
|
|
547
|
+
}
|
|
548
|
+
this.require("gridlabel").textContent = `${rows} × ${cols}`;
|
|
549
|
+
};
|
|
550
|
+
// `pointerover` covers mouse hover; touch users get the same feedback from the
|
|
551
|
+
// press itself, since a tap fires pointerover immediately before pointerdown.
|
|
552
|
+
cells.addEventListener("pointerover", (event) => {
|
|
553
|
+
const cell = event.target.closest("[data-r]");
|
|
554
|
+
if (cell)
|
|
555
|
+
highlight(Number(cell.dataset.r) + 1, Number(cell.dataset.c) + 1);
|
|
556
|
+
});
|
|
557
|
+
cells.addEventListener("mousedown", (event) => {
|
|
558
|
+
const cell = event.target.closest("[data-r]");
|
|
559
|
+
if (!cell || !this.live)
|
|
560
|
+
return;
|
|
561
|
+
event.preventDefault();
|
|
562
|
+
const rows = Number(cell.dataset.r) + 1;
|
|
563
|
+
const cols = Number(cell.dataset.c) + 1;
|
|
564
|
+
this.closePicker();
|
|
565
|
+
this.run(`table ${rows}×${cols}`, () => this.live.insertTable(rows, cols, {
|
|
566
|
+
borderless: this.require("gridborderless").checked,
|
|
567
|
+
cellAlignment: this.require("gridalign").value,
|
|
568
|
+
}));
|
|
569
|
+
});
|
|
570
|
+
this.keepSelection(button);
|
|
571
|
+
button.addEventListener("click", () => {
|
|
572
|
+
if (!this.live)
|
|
573
|
+
return;
|
|
574
|
+
if (picker.hasAttribute("data-open")) {
|
|
575
|
+
this.closePicker();
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
highlight(0, 0);
|
|
579
|
+
picker.setAttribute("data-open", "");
|
|
580
|
+
// Compact chrome docks the picker to the viewport bottom (see the stylesheet),
|
|
581
|
+
// so only the roomy layout needs it positioned under its button.
|
|
582
|
+
if (this.density === "full") {
|
|
583
|
+
const rect = button.getBoundingClientRect();
|
|
584
|
+
const host = this.element.getBoundingClientRect();
|
|
585
|
+
picker.style.left = `${rect.left - host.left}px`;
|
|
586
|
+
picker.style.top = `${rect.bottom - host.top + 5}px`;
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
closePicker() {
|
|
591
|
+
this.control("gridpicker")?.removeAttribute("data-open");
|
|
592
|
+
}
|
|
593
|
+
maybeClosePicker(event) {
|
|
594
|
+
const picker = this.control("gridpicker");
|
|
595
|
+
if (!picker?.hasAttribute("data-open"))
|
|
596
|
+
return;
|
|
597
|
+
const target = event.target;
|
|
598
|
+
if (picker.contains(target) || this.control("table")?.contains(target))
|
|
599
|
+
return;
|
|
600
|
+
this.closePicker();
|
|
601
|
+
}
|
|
602
|
+
// ── tabs ────────────────────────────────────────────────────────────────────
|
|
603
|
+
selectTab(name) {
|
|
604
|
+
for (const tab of Array.from(this.element.querySelectorAll(".dxr-tab"))) {
|
|
605
|
+
tab.setAttribute("aria-selected", String(tab.dataset.tab === name));
|
|
606
|
+
}
|
|
607
|
+
for (const panel of Array.from(this.element.querySelectorAll(".dxr-panel"))) {
|
|
608
|
+
if (panel.dataset.panel === name)
|
|
609
|
+
panel.setAttribute("data-active", "");
|
|
610
|
+
else
|
|
611
|
+
panel.removeAttribute("data-active");
|
|
612
|
+
}
|
|
613
|
+
if (name === "layout")
|
|
614
|
+
this.syncPageNumbering();
|
|
615
|
+
}
|
|
616
|
+
// ── selection-driven state ──────────────────────────────────────────────────
|
|
617
|
+
selectionElement() {
|
|
618
|
+
const selection = (this.element.ownerDocument ?? document).getSelection();
|
|
619
|
+
const node = selection?.anchorNode ?? null;
|
|
620
|
+
if (!node)
|
|
621
|
+
return null;
|
|
622
|
+
const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
623
|
+
// Ignore selections belonging to another editor (or another ribbon) on the page.
|
|
624
|
+
return el && this.surface.contains(el) ? el : null;
|
|
625
|
+
}
|
|
626
|
+
syncSelection() {
|
|
627
|
+
if (!this.live || this.destroyed)
|
|
628
|
+
return;
|
|
629
|
+
const el = this.selectionElement();
|
|
630
|
+
const state = this.live.queryFormatState();
|
|
631
|
+
for (const button of Array.from(this.element.querySelectorAll("button[data-cmd]"))) {
|
|
632
|
+
button.classList.toggle("dxr-on", !!state[button.dataset.cmd]);
|
|
633
|
+
}
|
|
634
|
+
// Reflect the caret's font size, unless the field itself is being edited.
|
|
635
|
+
const fontsize = this.control("fontsize");
|
|
636
|
+
if (el && fontsize && (this.element.ownerDocument ?? document).activeElement !== fontsize) {
|
|
637
|
+
const px = parseFloat(getComputedStyle(el).fontSize);
|
|
638
|
+
if (px)
|
|
639
|
+
fontsize.value = String(Math.round(px * 0.75 * 2) / 2);
|
|
640
|
+
}
|
|
641
|
+
// Reveal the contextual Table tab only while the caret is inside a table.
|
|
642
|
+
const inTable = !!el?.closest("table");
|
|
643
|
+
const tableTab = this.element.querySelector('.dxr-tab[data-tab="table"]');
|
|
644
|
+
if (tableTab) {
|
|
645
|
+
tableTab.hidden = !inTable;
|
|
646
|
+
if (!inTable && tableTab.getAttribute("aria-selected") === "true")
|
|
647
|
+
this.selectTab("home");
|
|
648
|
+
}
|
|
649
|
+
this.refreshRailAnchor();
|
|
650
|
+
}
|
|
651
|
+
/** Scope is read from where the block lives, which is exactly what the anchor encodes. */
|
|
652
|
+
scopeOf(el) {
|
|
653
|
+
const band = el.closest(".docx-hf-band");
|
|
654
|
+
if (band)
|
|
655
|
+
return band.getAttribute("data-hf-band") === "header" ? "hdr" : "ftr";
|
|
656
|
+
if (el.closest(".footnotes"))
|
|
657
|
+
return "fn";
|
|
658
|
+
if (el.closest(".endnotes"))
|
|
659
|
+
return "en";
|
|
660
|
+
return "body";
|
|
661
|
+
}
|
|
662
|
+
kindOf(el) {
|
|
663
|
+
if (/^H[1-6]$/.test(el.tagName))
|
|
664
|
+
return "h";
|
|
665
|
+
if (el.tagName === "LI" || el.hasAttribute("data-list-marker") || el.querySelector("[data-list-marker]")) {
|
|
666
|
+
return "li";
|
|
667
|
+
}
|
|
668
|
+
return "p";
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Block count and session handle — an O(blocks) query, so it runs only when
|
|
672
|
+
* something could have changed them (a command, or a document opening), never on
|
|
673
|
+
* the selectionchange path that fires per keystroke.
|
|
674
|
+
*/
|
|
675
|
+
refreshRailCounts() {
|
|
676
|
+
const blocks = this.control("railBlocks");
|
|
677
|
+
if (!blocks)
|
|
678
|
+
return; // rail disabled
|
|
679
|
+
blocks.textContent = this.live ? String(this.surface.querySelectorAll("[data-anchor]").length) : "—";
|
|
680
|
+
const session = this.control("railSession");
|
|
681
|
+
if (session)
|
|
682
|
+
session.textContent = this.live ? `#${this.live.sessionHandle}` : "—";
|
|
683
|
+
}
|
|
684
|
+
/** The focused anchor — cheap, and the only part the caret can change. */
|
|
685
|
+
refreshRailAnchor() {
|
|
686
|
+
const anchorEl = this.control("railAnchor");
|
|
687
|
+
if (!anchorEl)
|
|
688
|
+
return;
|
|
689
|
+
const block = this.selectionElement()?.closest("[data-anchor]") ?? null;
|
|
690
|
+
if (!block) {
|
|
691
|
+
anchorEl.textContent = this.live ? "none" : "—";
|
|
692
|
+
this.lastAnchorText = "";
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const unid = block.getAttribute("data-anchor") ?? "";
|
|
696
|
+
const text = `${this.kindOf(block)}:${this.scopeOf(block)}:${unid.slice(0, 10)}…`;
|
|
697
|
+
if (text === this.lastAnchorText)
|
|
698
|
+
return;
|
|
699
|
+
anchorEl.textContent = text;
|
|
700
|
+
this.lastAnchorText = text;
|
|
701
|
+
anchorEl.classList.remove("dxr-flash");
|
|
702
|
+
void anchorEl.offsetWidth;
|
|
703
|
+
anchorEl.classList.add("dxr-flash");
|
|
704
|
+
}
|
|
705
|
+
// ── loading overlay ─────────────────────────────────────────────────────────
|
|
706
|
+
createLoaderController() {
|
|
707
|
+
const overlay = this.control("loader");
|
|
708
|
+
if (!overlay) {
|
|
709
|
+
const noop = () => { };
|
|
710
|
+
return { show: noop, stage: noop, progress: noop, done: noop, fail: noop };
|
|
711
|
+
}
|
|
712
|
+
const options = this.loaderOptions ?? {};
|
|
713
|
+
const eyebrow = this.control("loaderEyebrow");
|
|
714
|
+
if (eyebrow && options.eyebrow)
|
|
715
|
+
eyebrow.textContent = options.eyebrow;
|
|
716
|
+
const meta = this.control("loaderMeta");
|
|
717
|
+
if (meta && options.meta)
|
|
718
|
+
meta.textContent = options.meta;
|
|
719
|
+
this.control("loaderRetry")?.addEventListener("click", () => {
|
|
720
|
+
if (options.onRetry)
|
|
721
|
+
options.onRetry();
|
|
722
|
+
else
|
|
723
|
+
location.reload();
|
|
724
|
+
});
|
|
725
|
+
if (this.features.length === 0)
|
|
726
|
+
this.control("loaderAd")?.remove();
|
|
727
|
+
const setProgress = (percent, label) => {
|
|
728
|
+
const bar = this.control("loaderBar");
|
|
729
|
+
if (bar)
|
|
730
|
+
bar.style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
|
731
|
+
if (label) {
|
|
732
|
+
const el = this.control("loaderLabel");
|
|
733
|
+
if (el)
|
|
734
|
+
el.textContent = label;
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
return {
|
|
738
|
+
show: () => {
|
|
739
|
+
overlay.hidden = false;
|
|
740
|
+
overlay.removeAttribute("data-done");
|
|
741
|
+
overlay.removeAttribute("data-error");
|
|
742
|
+
this.setState("loading");
|
|
743
|
+
this.showFeature(0);
|
|
744
|
+
this.startRotation();
|
|
745
|
+
this.loaderStage(0);
|
|
746
|
+
},
|
|
747
|
+
stage: (step) => this.loaderStage(step),
|
|
748
|
+
progress: setProgress,
|
|
749
|
+
done: () => {
|
|
750
|
+
this.stopRotation();
|
|
751
|
+
this.setState("ready");
|
|
752
|
+
// Two-step so the fade actually plays before the overlay leaves the layout.
|
|
753
|
+
setTimeout(() => overlay.setAttribute("data-done", ""), 420);
|
|
754
|
+
setTimeout(() => {
|
|
755
|
+
overlay.hidden = true;
|
|
756
|
+
}, 1050);
|
|
757
|
+
},
|
|
758
|
+
fail: (error) => {
|
|
759
|
+
this.stopRotation();
|
|
760
|
+
this.setState("error");
|
|
761
|
+
overlay.hidden = false;
|
|
762
|
+
overlay.setAttribute("data-error", "");
|
|
763
|
+
overlay.removeAttribute("data-done");
|
|
764
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
765
|
+
const title = this.control("loaderTitle");
|
|
766
|
+
if (title)
|
|
767
|
+
title.textContent = "The local engine did not start";
|
|
768
|
+
const copy = this.control("loaderCopy");
|
|
769
|
+
if (copy)
|
|
770
|
+
copy.textContent = message.slice(0, 220);
|
|
771
|
+
setProgress(100, "Load failed");
|
|
772
|
+
this.setStatus(message.slice(0, 180));
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
loaderStage(step) {
|
|
777
|
+
const stage = typeof step === "number" ? this.stages[Math.max(0, Math.min(this.stages.length - 1, step))] : step;
|
|
778
|
+
if (!stage)
|
|
779
|
+
return;
|
|
780
|
+
const title = this.control("loaderTitle");
|
|
781
|
+
if (title && stage.title)
|
|
782
|
+
title.textContent = stage.title;
|
|
783
|
+
const copy = this.control("loaderCopy");
|
|
784
|
+
if (copy && stage.copy)
|
|
785
|
+
copy.textContent = stage.copy;
|
|
786
|
+
const bar = this.control("loaderBar");
|
|
787
|
+
if (bar && stage.progress != null)
|
|
788
|
+
bar.style.width = `${stage.progress}%`;
|
|
789
|
+
const label = this.control("loaderLabel");
|
|
790
|
+
if (label && stage.label)
|
|
791
|
+
label.textContent = stage.label;
|
|
792
|
+
}
|
|
793
|
+
showFeature(index) {
|
|
794
|
+
const feature = this.features[index];
|
|
795
|
+
if (!feature)
|
|
796
|
+
return;
|
|
797
|
+
const card = this.control("loaderAd");
|
|
798
|
+
const number = this.control("loaderNumber");
|
|
799
|
+
const title = this.control("loaderAdTitle");
|
|
800
|
+
const copy = this.control("loaderAdCopy");
|
|
801
|
+
if (number)
|
|
802
|
+
number.textContent = feature.number;
|
|
803
|
+
if (title)
|
|
804
|
+
title.textContent = feature.title;
|
|
805
|
+
if (copy)
|
|
806
|
+
copy.textContent = feature.copy;
|
|
807
|
+
if (card) {
|
|
808
|
+
card.removeAttribute("data-swap");
|
|
809
|
+
void card.offsetWidth;
|
|
810
|
+
card.setAttribute("data-swap", "");
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
startRotation() {
|
|
814
|
+
this.stopRotation();
|
|
815
|
+
if (this.features.length < 2)
|
|
816
|
+
return;
|
|
817
|
+
const every = this.loaderOptions?.rotateMs ?? 1750;
|
|
818
|
+
this.featureTimer = setInterval(() => {
|
|
819
|
+
this.featureIndex = (this.featureIndex + 1) % this.features.length;
|
|
820
|
+
this.showFeature(this.featureIndex);
|
|
821
|
+
}, every);
|
|
822
|
+
}
|
|
823
|
+
stopRotation() {
|
|
824
|
+
if (this.featureTimer == null)
|
|
825
|
+
return;
|
|
826
|
+
clearInterval(this.featureTimer);
|
|
827
|
+
this.featureTimer = null;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
//# sourceMappingURL=ribbon.js.map
|