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/editor.js
CHANGED
|
@@ -19,12 +19,14 @@
|
|
|
19
19
|
import { paginateHtml } from "./pagination.js";
|
|
20
20
|
import { DocumentViewport } from "./viewport.js";
|
|
21
21
|
import { HeaderFooterRegion } from "./editor-headerfooter.js";
|
|
22
|
+
import { CommentGutter, COMMENT_GUTTER_CSS } from "./editor-comments.js";
|
|
22
23
|
import { draggable, dropTargetForElements, monitorForElements, } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
|
23
24
|
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
|
|
24
25
|
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
|
25
26
|
import { autoScrollForElements, autoScrollWindowForElements, } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
|
26
27
|
import { TrackedChangeMode } from "./types.js";
|
|
27
28
|
import { diffUnits, needsRemount, unidOf } from "./editor-reconcile.js";
|
|
29
|
+
import { imageOnlyDelta, patchImageAttributes } from "./editor-image-patch.js";
|
|
28
30
|
const EDITABLE_TAGS = new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
|
|
29
31
|
const BLOCK_DRAG_TYPE = "docxodus-block";
|
|
30
32
|
const blockDragStyledDocuments = new WeakSet();
|
|
@@ -134,6 +136,13 @@ function collectInlineSegments(node, out) {
|
|
|
134
136
|
// it isn't part of the paragraph's content and must never be committed as text.
|
|
135
137
|
if (isGeneratedChrome(child))
|
|
136
138
|
return;
|
|
139
|
+
if (isField(child)) {
|
|
140
|
+
// A field serializes as its cached result (the session's own text for it).
|
|
141
|
+
const text = fieldText(child);
|
|
142
|
+
if (text)
|
|
143
|
+
out.push({ text, bold: false, italic: false, href: null });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
137
146
|
if (child.nodeType === 3 /* TEXT_NODE */) {
|
|
138
147
|
const text = child.textContent ?? "";
|
|
139
148
|
if (!text)
|
|
@@ -206,7 +215,42 @@ export function serializeInlineMarkdown(block) {
|
|
|
206
215
|
merged.push({ ...s });
|
|
207
216
|
}
|
|
208
217
|
}
|
|
209
|
-
|
|
218
|
+
// Leading whitespace is never content (a rendered empty paragraph carries a placeholder
|
|
219
|
+
// space, and typing lands after it). Trailing whitespace usually is not either — the
|
|
220
|
+
// browser's bogus trailing <br> serializes as a hard break — EXCEPT the single space a user
|
|
221
|
+
// types before a page-number field goes in: "Page " + PAGE must not commit as "Page". The
|
|
222
|
+
// engine writes it with xml:space="preserve"; `trimBounds` counts it the same way.
|
|
223
|
+
const md = merged.map(segToMarkdown).join("").replace(/^\s+/, "").replace(/\s+$/, "");
|
|
224
|
+
return escapeLeadingBlockMarkers(md + (keepsTrailingSpace(block) ? " " : ""));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Whether a block's content text ends in a space the commit should keep: a plain or no-break
|
|
228
|
+
* space (contenteditable turns a typed trailing space into U+00A0) after some non-whitespace
|
|
229
|
+
* text. A whitespace-only block (the placeholder an empty paragraph renders as) keeps nothing.
|
|
230
|
+
*/
|
|
231
|
+
function keepsTrailingSpace(block) {
|
|
232
|
+
const text = blockContentText(block);
|
|
233
|
+
if (text.trim().length === 0)
|
|
234
|
+
return false;
|
|
235
|
+
// An ordinary trailing space can only have come from the document (the converter renders
|
|
236
|
+
// it from a preserved w:t), so it is content. A trailing NBSP is ambiguous: the browser
|
|
237
|
+
// writes one for a typed space, but the placeholder an empty paragraph renders as is one
|
|
238
|
+
// too, and typing at the start of such a paragraph leaves it dangling at the end. Only the
|
|
239
|
+
// typed one counts, which `wireBlock` records from the input event.
|
|
240
|
+
if (/ $/.test(text))
|
|
241
|
+
return true;
|
|
242
|
+
return /\u00a0$/.test(text) && block.dataset.typedTrailingSpace === "1";
|
|
243
|
+
}
|
|
244
|
+
/** Record whether the last input left a typed space at the end of the block \u2014 see
|
|
245
|
+
* {@link keepsTrailingSpace}. */
|
|
246
|
+
function noteTypedSpace(block, event) {
|
|
247
|
+
const input = event;
|
|
248
|
+
const typedSpace = input.inputType === "insertText" && typeof input.data === "string" && /[ \u00a0]$/.test(input.data);
|
|
249
|
+
const atEnd = typedSpace && caretOffsetIn(block) === blockContentText(block).length;
|
|
250
|
+
if (atEnd)
|
|
251
|
+
block.dataset.typedTrailingSpace = "1";
|
|
252
|
+
else
|
|
253
|
+
delete block.dataset.typedTrailingSpace;
|
|
210
254
|
}
|
|
211
255
|
/** True if `block` renders as a list item (has a generated marker as its first child). */
|
|
212
256
|
function isListBlock(block) {
|
|
@@ -245,10 +289,37 @@ function ensureListMarkerSeparator(block) {
|
|
|
245
289
|
* serialization and the display number gets COMMITTED as literal text (destroying the citation
|
|
246
290
|
* run); left editable and the user can delete a marker outright, orphaning the note.
|
|
247
291
|
*/
|
|
248
|
-
const GENERATED_CHROME_SELECTOR = '[data-list-marker], a.footnote-ref, a.endnote-ref, a[class$="-backref"]';
|
|
292
|
+
const GENERATED_CHROME_SELECTOR = '[data-list-marker], a.footnote-ref, a.endnote-ref, a[class$="-backref"], a.comment-marker';
|
|
249
293
|
function isGeneratedChrome(node) {
|
|
250
294
|
return node?.nodeType === 1 && !!node.matches?.(GENERATED_CHROME_SELECTOR);
|
|
251
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* A rendered field — PAGE / NUMPAGES in a running story, stamped `[data-field]` by the editor
|
|
298
|
+
* render profile. Fields are ATOMIC for editing (`contenteditable=false`, the caret lands on
|
|
299
|
+
* either side), and their content length is the field's CACHED result — the text the session
|
|
300
|
+
* holds in the result run — even after the page view substituted the per-page number into the
|
|
301
|
+
* DOM. Counting the substituted text instead would shift every offset after the field and make
|
|
302
|
+
* the commit diff rewrite the field runs.
|
|
303
|
+
*/
|
|
304
|
+
function fieldOf(node) {
|
|
305
|
+
const el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
|
|
306
|
+
return el?.closest?.("[data-field]") ?? null;
|
|
307
|
+
}
|
|
308
|
+
function isField(node) {
|
|
309
|
+
return node.nodeType === 1 && node.hasAttribute("data-field");
|
|
310
|
+
}
|
|
311
|
+
/** The text a field counts for: the cached result the session holds, else what it shows. */
|
|
312
|
+
function fieldText(field) {
|
|
313
|
+
return field.dataset.fieldCached ?? field.textContent ?? "";
|
|
314
|
+
}
|
|
315
|
+
/** Freeze a field's rendered result as its cached text (once) and keep the caret out of it. */
|
|
316
|
+
function adoptFields(block) {
|
|
317
|
+
for (const field of Array.from(block.querySelectorAll("[data-field]"))) {
|
|
318
|
+
if (field.dataset.fieldCached === undefined)
|
|
319
|
+
field.dataset.fieldCached = field.textContent ?? "";
|
|
320
|
+
field.setAttribute("contenteditable", "false");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
252
323
|
/** True if `node` is, or is inside, generated chrome (not editable content). */
|
|
253
324
|
function isInMarker(node) {
|
|
254
325
|
let el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
|
|
@@ -297,6 +368,16 @@ function contentOffsetOf(block, container, offset) {
|
|
|
297
368
|
const walk = (node) => {
|
|
298
369
|
if (done)
|
|
299
370
|
return;
|
|
371
|
+
if (isField(node)) {
|
|
372
|
+
// Atomic: a point inside a field resolves to the field's end; the field counts its
|
|
373
|
+
// cached text either way.
|
|
374
|
+
const inside = node === container || node.contains(container);
|
|
375
|
+
if (!inside || offset > 0 || node !== container)
|
|
376
|
+
count += stripBidi(fieldText(node)).length;
|
|
377
|
+
if (inside)
|
|
378
|
+
done = true;
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
300
381
|
if (node.nodeType === 3 /* TEXT_NODE */) {
|
|
301
382
|
if (node === container) {
|
|
302
383
|
if (!isInMarker(node))
|
|
@@ -337,7 +418,10 @@ function caretOffsetIn(block) {
|
|
|
337
418
|
function blockContentText(block) {
|
|
338
419
|
let out = "";
|
|
339
420
|
const walk = (node) => {
|
|
340
|
-
if (node
|
|
421
|
+
if (isField(node)) {
|
|
422
|
+
out += stripBidi(fieldText(node));
|
|
423
|
+
}
|
|
424
|
+
else if (node.nodeType === 3 /* TEXT_NODE */) {
|
|
341
425
|
if (!isInMarker(node))
|
|
342
426
|
out += stripBidi(node.textContent ?? "");
|
|
343
427
|
}
|
|
@@ -367,7 +451,9 @@ function trimBounds(block) {
|
|
|
367
451
|
const content = blockContentText(block);
|
|
368
452
|
return {
|
|
369
453
|
leading: content.length - content.replace(/^\s+/, "").length,
|
|
370
|
-
|
|
454
|
+
// Mirrors serializeInlineMarkdown: leading and trailing whitespace dropped, plus the one
|
|
455
|
+
// trailing space a commit keeps.
|
|
456
|
+
trimmedLen: content.trim().length + (keepsTrailingSpace(block) ? 1 : 0),
|
|
371
457
|
};
|
|
372
458
|
}
|
|
373
459
|
/**
|
|
@@ -402,6 +488,19 @@ function contentPositionIn(el, offset) {
|
|
|
402
488
|
const walk = (node) => {
|
|
403
489
|
if (result)
|
|
404
490
|
return;
|
|
491
|
+
if (isField(node)) {
|
|
492
|
+
// Never land inside a field: an offset at its start sits before it, anything up to its
|
|
493
|
+
// cached length sits after it.
|
|
494
|
+
const len = stripBidi(fieldText(node)).length;
|
|
495
|
+
const parent = node.parentNode;
|
|
496
|
+
if (parent && remaining <= len) {
|
|
497
|
+
const index = Array.prototype.indexOf.call(parent.childNodes, node);
|
|
498
|
+
result = { node: parent, offset: index + (remaining > 0 ? 1 : 0) };
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
remaining -= len;
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
405
504
|
if (node.nodeType === 3 /* TEXT_NODE */) {
|
|
406
505
|
if (isInMarker(node))
|
|
407
506
|
return;
|
|
@@ -470,40 +569,23 @@ function setSelectionBetween(anchor, focus) {
|
|
|
470
569
|
/** Place the caret at content offset `offset` within `el`, skipping marker text. */
|
|
471
570
|
function placeCaretAtOffset(el, offset) {
|
|
472
571
|
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
473
|
-
|
|
572
|
+
// A remount can detach the block between a caller's lookup and this call; addRange on a
|
|
573
|
+
// detached node throws "the given range isn't in document".
|
|
574
|
+
if (!sel || !el.isConnected)
|
|
474
575
|
return;
|
|
475
576
|
el.focus();
|
|
476
577
|
const range = document.createRange();
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
return; // never land the caret in the marker
|
|
485
|
-
const raw = node.textContent ?? "";
|
|
486
|
-
const len = stripBidi(raw).length; // content length excludes injected bidi marks
|
|
487
|
-
if (remaining <= len) {
|
|
488
|
-
range.setStart(node, domOffsetForContentOffset(raw, remaining));
|
|
489
|
-
placed = true;
|
|
490
|
-
}
|
|
491
|
-
else {
|
|
492
|
-
remaining -= len;
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
else {
|
|
496
|
-
node.childNodes.forEach(walk);
|
|
497
|
-
}
|
|
498
|
-
};
|
|
499
|
-
walk(el);
|
|
500
|
-
if (!placed) {
|
|
578
|
+
// contentPositionIn clamps a past-end offset to the end of the last text node, and never
|
|
579
|
+
// lands inside marker chrome or a field — the same rules every other offset helper uses.
|
|
580
|
+
const pos = contentPositionIn(el, offset);
|
|
581
|
+
try {
|
|
582
|
+
range.setStart(pos.node, pos.offset);
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
501
585
|
range.selectNodeContents(el);
|
|
502
586
|
range.collapse(false);
|
|
503
587
|
}
|
|
504
|
-
|
|
505
|
-
range.collapse(true);
|
|
506
|
-
}
|
|
588
|
+
range.collapse(true);
|
|
507
589
|
sel.removeAllRanges();
|
|
508
590
|
sel.addRange(range);
|
|
509
591
|
}
|
|
@@ -534,36 +616,17 @@ function selectRange(el, start, length) {
|
|
|
534
616
|
return;
|
|
535
617
|
el.focus();
|
|
536
618
|
const range = document.createRange();
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
if (child.nodeType === 3 /* TEXT_NODE */) {
|
|
543
|
-
if (isInMarker(child))
|
|
544
|
-
continue; // marker text isn't part of the content offset space
|
|
545
|
-
const raw = child.textContent ?? "";
|
|
546
|
-
const len = stripBidi(raw).length; // content length excludes injected bidi marks
|
|
547
|
-
if (!startSet && pos + len >= start) {
|
|
548
|
-
range.setStart(child, domOffsetForContentOffset(raw, start - pos));
|
|
549
|
-
startSet = true;
|
|
550
|
-
}
|
|
551
|
-
if (startSet && pos + len >= end) {
|
|
552
|
-
range.setEnd(child, domOffsetForContentOffset(raw, end - pos));
|
|
553
|
-
return true;
|
|
554
|
-
}
|
|
555
|
-
pos += len;
|
|
556
|
-
}
|
|
557
|
-
else if (walk(child)) {
|
|
558
|
-
return true;
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
return false;
|
|
562
|
-
};
|
|
563
|
-
if (walk(el) || startSet) {
|
|
564
|
-
sel.removeAllRanges();
|
|
565
|
-
sel.addRange(range);
|
|
619
|
+
const from = contentPositionIn(el, start);
|
|
620
|
+
const to = contentPositionIn(el, start + length);
|
|
621
|
+
try {
|
|
622
|
+
range.setStart(from.node, from.offset);
|
|
623
|
+
range.setEnd(to.node, to.offset);
|
|
566
624
|
}
|
|
625
|
+
catch {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
sel.removeAllRanges();
|
|
629
|
+
sel.addRange(range);
|
|
567
630
|
}
|
|
568
631
|
/**
|
|
569
632
|
* True when `el`'s immediate parent is a paragraph-border `<div>` the full render wrapped it in
|
|
@@ -599,9 +662,12 @@ function selectionHasFormat(key, fallback) {
|
|
|
599
662
|
}
|
|
600
663
|
}
|
|
601
664
|
/** Build the full ConvertDocxToHtmlComplete arg list (stampAnchors = last arg). */
|
|
602
|
-
function completeArgs(bytes, cssPrefix, fabricate, paginated, scale, renderTrackedChanges) {
|
|
665
|
+
function completeArgs(bytes, cssPrefix, fabricate, paginated, scale, renderTrackedChanges, comments) {
|
|
603
666
|
return [
|
|
604
|
-
|
|
667
|
+
// Comment mode 1 = Inline: the commented runs are wrapped in highlight spans and the
|
|
668
|
+
// reference becomes an (editor-hidden) marker, which is what the comment gutter positions
|
|
669
|
+
// its bubbles against. -1 renders no comment markup at all.
|
|
670
|
+
bytes, "Document", cssPrefix, fabricate, "", comments ? 1 : -1, "comment-",
|
|
605
671
|
/* paginationMode */ paginated ? 1 : 0, /* paginationScale */ scale, "page-",
|
|
606
672
|
false, 0, "annot-",
|
|
607
673
|
// Footnotes/endnotes ON: they are document content, and the editor makes the rendered note
|
|
@@ -669,6 +735,10 @@ export class DocxEditor {
|
|
|
669
735
|
this.dragSelection = null;
|
|
670
736
|
/** The docked header/footer bands, when `options.headerFooter` is on. */
|
|
671
737
|
this.region = null;
|
|
738
|
+
/** The comment gutter, when `options.comments` is on. */
|
|
739
|
+
this.gutter = null;
|
|
740
|
+
/** Story host the caret is in (page view / band), published for chrome via `onStoryChange`. */
|
|
741
|
+
this.activeStory = null;
|
|
672
742
|
/** Why the last reconcile() fell back to a full remount (null = it patched). For
|
|
673
743
|
* diagnostics/specs; not part of the public API. */
|
|
674
744
|
this.lastReconcileFallback = null;
|
|
@@ -878,8 +948,12 @@ export class DocxEditor {
|
|
|
878
948
|
blockDrag: options.blockDrag ?? false,
|
|
879
949
|
trackedChanges: options.trackedChanges ?? TrackedChangeMode.Accept,
|
|
880
950
|
revisionAuthor: options.revisionAuthor ?? "docxodus",
|
|
951
|
+
comments: options.comments ?? true,
|
|
952
|
+
commentAuthor: options.commentAuthor ?? options.revisionAuthor ?? "Reviewer",
|
|
881
953
|
onEdit: options.onEdit,
|
|
882
954
|
onMove: options.onMove,
|
|
955
|
+
onStoryChange: options.onStoryChange,
|
|
956
|
+
onCommentsChange: options.onCommentsChange,
|
|
883
957
|
};
|
|
884
958
|
// NOT persistAnchorIds: that setting applies to every Save on the session, so it put the
|
|
885
959
|
// projector's Unid bookkeeping into the bytes the USER downloads — ~6x the file size for
|
|
@@ -896,13 +970,18 @@ export class DocxEditor {
|
|
|
896
970
|
editor.refreshAnchorMap();
|
|
897
971
|
if (opts.headerFooter)
|
|
898
972
|
editor.createRegion();
|
|
899
|
-
|
|
973
|
+
// First paint goes through the session-attached editor render when the bundle has it, so
|
|
974
|
+
// the comment markup (and everything else in the profile) is exactly what a remount will
|
|
975
|
+
// produce; older bundles take the bytes path with the same profile.
|
|
976
|
+
const fullHtml = editor.renderFullHtml(bytes);
|
|
900
977
|
if (opts.paginated)
|
|
901
978
|
editor.mountPaginated(fullHtml);
|
|
902
979
|
else
|
|
903
980
|
editor.mountHtml(fullHtml);
|
|
904
981
|
editor.syncRegionToBody();
|
|
905
982
|
editor.setupBlockDrag();
|
|
983
|
+
if (opts.comments)
|
|
984
|
+
editor.createGutter();
|
|
906
985
|
return editor;
|
|
907
986
|
}
|
|
908
987
|
/**
|
|
@@ -931,6 +1010,9 @@ export class DocxEditor {
|
|
|
931
1010
|
}
|
|
932
1011
|
this.clearDragSelection();
|
|
933
1012
|
this.teardownBlockDrag();
|
|
1013
|
+
this.gutter?.dispose();
|
|
1014
|
+
this.gutter = null;
|
|
1015
|
+
this.region?.detachPages();
|
|
934
1016
|
this.viewport.dispose();
|
|
935
1017
|
this.exports.DocxSessionBridge.CloseSession(this.handle);
|
|
936
1018
|
}
|
|
@@ -1635,11 +1717,36 @@ export class DocxEditor {
|
|
|
1635
1717
|
}
|
|
1636
1718
|
/** Build the header/footer region (called once, before the first mount). */
|
|
1637
1719
|
createRegion() {
|
|
1638
|
-
this.region = new HeaderFooterRegion(this.exports.DocxSessionBridge, this.handle, {
|
|
1720
|
+
this.region = new HeaderFooterRegion(this.exports.DocxSessionBridge, this.handle, {
|
|
1639
1721
|
wireBlock: (el) => this.wireBlock(el),
|
|
1722
|
+
renderBlock: (anchorId) => this.renderInto(anchorId),
|
|
1640
1723
|
refreshAnchorMap: () => this.refreshAnchorMap(),
|
|
1724
|
+
bodyAnchorIdOf: (unid) => this.unidToFullId.get(unid),
|
|
1725
|
+
remount: () => {
|
|
1726
|
+
if (!this.closed)
|
|
1727
|
+
this.remount();
|
|
1728
|
+
},
|
|
1729
|
+
onActiveChange: (_host, which) => {
|
|
1730
|
+
this.activeStory = which;
|
|
1731
|
+
this.options.onStoryChange?.(which);
|
|
1732
|
+
},
|
|
1641
1733
|
});
|
|
1642
1734
|
}
|
|
1735
|
+
/** Build the comment gutter over the container (called once, after the first mount). */
|
|
1736
|
+
createGutter() {
|
|
1737
|
+
ensureCommentGutterStyles(this.container.ownerDocument);
|
|
1738
|
+
this.gutter = new CommentGutter({
|
|
1739
|
+
container: this.container,
|
|
1740
|
+
commentAuthor: this.options.commentAuthor,
|
|
1741
|
+
listComments: () => this.listComments(),
|
|
1742
|
+
addComment: (markdown, author, target) => this.addComment(markdown, author, target),
|
|
1743
|
+
addCommentReply: (parent, markdown, author) => this.addCommentReply(parent, markdown, author),
|
|
1744
|
+
updateComment: (anchorId, markdown) => this.updateComment(anchorId, markdown),
|
|
1745
|
+
removeComment: (anchorId) => this.removeComment(anchorId),
|
|
1746
|
+
setCommentResolved: (anchorId, resolved) => this.setCommentResolved(anchorId, resolved),
|
|
1747
|
+
commentTarget: () => this.commentTarget(),
|
|
1748
|
+
}, { onChange: (info) => this.options.onCommentsChange?.(info) });
|
|
1749
|
+
}
|
|
1643
1750
|
/**
|
|
1644
1751
|
* Point the bands at the section governing the current focus (or the first body block).
|
|
1645
1752
|
* Called after every mount, and on focus of a body block, so a multi-section document shows
|
|
@@ -1674,11 +1781,13 @@ export class DocxEditor {
|
|
|
1674
1781
|
* and the scaled page, which are different boxes.
|
|
1675
1782
|
*/
|
|
1676
1783
|
mountHtml(fullHtml) {
|
|
1784
|
+
this.region?.detachPages();
|
|
1677
1785
|
const parsed = new DOMParser().parseFromString(fullHtml, "text/html");
|
|
1678
1786
|
const styles = Array.from(parsed.querySelectorAll("style"))
|
|
1679
1787
|
.map((s) => s.outerHTML)
|
|
1680
1788
|
.join("");
|
|
1681
1789
|
this.container.innerHTML = styles;
|
|
1790
|
+
this.readoptGutter();
|
|
1682
1791
|
const flow = document.createElement("div");
|
|
1683
1792
|
flow.className = "docx-body-flow";
|
|
1684
1793
|
flow.innerHTML = parsed.body.innerHTML;
|
|
@@ -1693,15 +1802,14 @@ export class DocxEditor {
|
|
|
1693
1802
|
}
|
|
1694
1803
|
/** Paginated mount: flow blocks into page boxes via pagination.ts, wire the page clones. */
|
|
1695
1804
|
mountPaginated(fullHtml) {
|
|
1696
|
-
|
|
1697
|
-
//
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
}
|
|
1805
|
+
this.region?.detachPages();
|
|
1806
|
+
// Pagination writes into its own wrapper so the container can also hold the comment gutter
|
|
1807
|
+
// (and so pagination's innerHTML reset can never eat it).
|
|
1808
|
+
this.container.innerHTML = "";
|
|
1809
|
+
this.readoptGutter();
|
|
1810
|
+
const target = document.createElement("div");
|
|
1811
|
+
target.className = "docx-body-flow";
|
|
1812
|
+
this.container.appendChild(target);
|
|
1705
1813
|
// Fragmented paragraphs intentionally have only one addressable head and
|
|
1706
1814
|
// are therefore unsuitable for the editor's one-block editing model.
|
|
1707
1815
|
paginateHtml(fullHtml, target, {
|
|
@@ -1721,10 +1829,11 @@ export class DocxEditor {
|
|
|
1721
1829
|
this.editRoot = pageRoot;
|
|
1722
1830
|
if (this.options.editable)
|
|
1723
1831
|
this.wireBlocks(pageRoot);
|
|
1724
|
-
// The page boxes render their own
|
|
1725
|
-
//
|
|
1832
|
+
// The page boxes render their own header/footer margins as inert clones; the region turns
|
|
1833
|
+
// the clicked one into the live story in place (Word's edit-in-the-margin), so there is
|
|
1834
|
+
// still exactly one addressable node per story paragraph at any time.
|
|
1726
1835
|
if (this.region)
|
|
1727
|
-
this.
|
|
1836
|
+
this.region.attachPages(pageRoot);
|
|
1728
1837
|
// Page boxes already carry the section's dimensions, so the viewport contributes only the
|
|
1729
1838
|
// fit zoom — a full page is far wider than a phone, and clipping it is not an option.
|
|
1730
1839
|
this.viewport.attach(pageRoot, false);
|
|
@@ -1756,6 +1865,9 @@ export class DocxEditor {
|
|
|
1756
1865
|
// a citation marker can't be deleted directly (which would orphan its note definition).
|
|
1757
1866
|
el.querySelectorAll(GENERATED_CHROME_SELECTOR)
|
|
1758
1867
|
.forEach((m) => m.setAttribute("contenteditable", "false"));
|
|
1868
|
+
// Page-number fields are atomic, and remember their cached result so per-page substitution
|
|
1869
|
+
// in the page view never shifts the offset space (see `fieldText`).
|
|
1870
|
+
adoptFields(el);
|
|
1759
1871
|
// Baseline for the commit diff: CONTENT text (list markers + injected bidi marks excluded),
|
|
1760
1872
|
// matching the session's flat run-text offset space.
|
|
1761
1873
|
el.dataset.committedText = blockContentText(el);
|
|
@@ -1766,8 +1878,10 @@ export class DocxEditor {
|
|
|
1766
1878
|
// its own, and re-resolving would clobber the user's kind selection.
|
|
1767
1879
|
if (this.region && !this.isBandBlock(el))
|
|
1768
1880
|
this.syncRegionToBody(el);
|
|
1881
|
+
this.region?.noteFocus(el);
|
|
1769
1882
|
});
|
|
1770
1883
|
el.addEventListener("blur", () => this.commitBlock(el));
|
|
1884
|
+
el.addEventListener("input", (ev) => noteTypedSpace(el, ev));
|
|
1771
1885
|
el.addEventListener("keydown", (ev) => this.onKeydown(el, ev));
|
|
1772
1886
|
// A band block re-rendered by an incremental swap is a fresh DOM node; re-adopt it (with the
|
|
1773
1887
|
// anchor its caller already stamped) so the band chrome can still address it.
|
|
@@ -1856,6 +1970,8 @@ export class DocxEditor {
|
|
|
1856
1970
|
this.activeBlock = fresh; // keep ribbon target valid
|
|
1857
1971
|
// The throwaway render numbers citation markers from 1 — repair in place.
|
|
1858
1972
|
this.maybeRenumberNotes(fresh);
|
|
1973
|
+
if (inBand)
|
|
1974
|
+
this.region.afterStoryEdit(fresh);
|
|
1859
1975
|
}
|
|
1860
1976
|
}
|
|
1861
1977
|
this.options.onEdit?.({ anchorId: newAnchor, unid: newUnid });
|
|
@@ -2036,6 +2152,8 @@ export class DocxEditor {
|
|
|
2036
2152
|
}
|
|
2037
2153
|
this.wireBlock(firstEl);
|
|
2038
2154
|
this.wireBlock(secondEl);
|
|
2155
|
+
if (inBand)
|
|
2156
|
+
this.region.afterStoryEdit(secondEl);
|
|
2039
2157
|
placeCaretAtOffset(secondEl, 0);
|
|
2040
2158
|
this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
|
|
2041
2159
|
}
|
|
@@ -2087,6 +2205,8 @@ export class DocxEditor {
|
|
|
2087
2205
|
this.unidToFullId.set(merged.unid, merged.id);
|
|
2088
2206
|
}
|
|
2089
2207
|
this.wireBlock(mergedEl);
|
|
2208
|
+
if (inBand)
|
|
2209
|
+
this.region.afterStoryEdit(mergedEl);
|
|
2090
2210
|
placeCaretAtOffset(mergedEl, caret);
|
|
2091
2211
|
this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
|
|
2092
2212
|
}
|
|
@@ -2104,11 +2224,20 @@ export class DocxEditor {
|
|
|
2104
2224
|
// but wireBlock may have stored textContent before this Task 2 change, and the bidi test
|
|
2105
2225
|
// explicitly stores textContent). Using stripBidi keeps the baseline consistent with the
|
|
2106
2226
|
// session's offset space regardless of how committedText was stored.
|
|
2107
|
-
|
|
2108
|
-
|
|
2227
|
+
// contenteditable keeps a typed trailing space as U+00A0 so it stays visible; the document
|
|
2228
|
+
// wants an ordinary space there (Word's own), and the two are the same length, so the
|
|
2229
|
+
// offset space is unchanged. Both sides are normalized the same way, so an untouched
|
|
2230
|
+
// placeholder (the NBSP an empty paragraph renders as) never reads as an edit \u2014 a blur that
|
|
2231
|
+
// committed "" over a paragraph another op just filled would wipe that op's work.
|
|
2232
|
+
const old = stripBidi(el.dataset.committedText ?? "").replace(/\u00a0$/, " ");
|
|
2233
|
+
const next = blockContentText(el).replace(/\u00a0$/, " ");
|
|
2109
2234
|
if (old === next)
|
|
2110
2235
|
return null;
|
|
2111
|
-
|
|
2236
|
+
// A whitespace-only baseline means the paragraph was (or rendered as) empty, so the typed
|
|
2237
|
+
// text replaces it wholesale — unless it holds a field whose cached result is empty (a PAGE
|
|
2238
|
+
// field in a tool-generated footer): that is content, and the span path below keeps it
|
|
2239
|
+
// while inserting the typed text beside it.
|
|
2240
|
+
if (old.trim().length === 0 && !el.querySelector("[data-field]")) {
|
|
2112
2241
|
return this.parseEdit(this.exports.DocxSessionBridge.ReplaceText(this.handle, fullId, serializeInlineMarkdown(el)));
|
|
2113
2242
|
}
|
|
2114
2243
|
const minLen = Math.min(old.length, next.length);
|
|
@@ -2121,10 +2250,16 @@ export class DocxEditor {
|
|
|
2121
2250
|
let start = p;
|
|
2122
2251
|
let len = old.length - p - s;
|
|
2123
2252
|
let middle = next.slice(p, next.length - s);
|
|
2124
|
-
// A pure insertion is a zero-length span, which resolves to no runs and is rejected. Anchor a
|
|
2125
|
-
// neighbor char so the span is non-empty and the inserted text inherits an adjacent run's rPr
|
|
2126
|
-
// (the LEFT run when there is one, matching contenteditable; the first run at the very start).
|
|
2127
2253
|
if (len === 0) {
|
|
2254
|
+
// A pure insertion. The engine inserts a NEW run at a run boundary (and steps outside a
|
|
2255
|
+
// field's chrome, so typing after "Page X of Y" never lands inside the NUMPAGES result);
|
|
2256
|
+
// an offset inside a run's text is not a boundary and is refused, as is the op on an
|
|
2257
|
+
// older engine build. Then anchor a neighbour char so the span is non-empty and the
|
|
2258
|
+
// inserted text inherits an adjacent run's rPr (the LEFT run when there is one, matching
|
|
2259
|
+
// contenteditable; the first run at the very start).
|
|
2260
|
+
const inserted = this.parseEdit(this.exports.DocxSessionBridge.ReplaceTextAtSpan(this.handle, fullId, start, 0, middle));
|
|
2261
|
+
if (inserted.success)
|
|
2262
|
+
return inserted;
|
|
2128
2263
|
if (start > 0) {
|
|
2129
2264
|
start -= 1;
|
|
2130
2265
|
len = 1;
|
|
@@ -2157,6 +2292,9 @@ export class DocxEditor {
|
|
|
2157
2292
|
}
|
|
2158
2293
|
renderBlockHtml(anchorId) {
|
|
2159
2294
|
const bridge = this.exports.DocxSessionBridge;
|
|
2295
|
+
if (typeof bridge.RenderEditorBlockHtml === "function") {
|
|
2296
|
+
return bridge.RenderEditorBlockHtml(this.handle, anchorId, this.editorRenderProfile());
|
|
2297
|
+
}
|
|
2160
2298
|
if (typeof bridge.RenderBlockHtmlForReview === "function") {
|
|
2161
2299
|
return bridge.RenderBlockHtmlForReview(this.handle, anchorId, this.options.cssPrefix, this.options.fabricateClasses, this.renderTrackedChanges);
|
|
2162
2300
|
}
|
|
@@ -2168,6 +2306,18 @@ export class DocxEditor {
|
|
|
2168
2306
|
? bridge.ListRenderedBlocks(this.handle, this.renderTrackedChanges)
|
|
2169
2307
|
: bridge.ListBlocks(this.handle);
|
|
2170
2308
|
}
|
|
2309
|
+
/** Batch-render `idsJson` anchors through the richest endpoint the bundle carries; the JSON
|
|
2310
|
+
* maps each anchor to its HTML (null when it failed to resolve), or carries `error`. */
|
|
2311
|
+
renderBlocksJson(idsJson) {
|
|
2312
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
2313
|
+
if (typeof bridge.RenderEditorBlocksHtml === "function") {
|
|
2314
|
+
return bridge.RenderEditorBlocksHtml(this.handle, idsJson, this.editorRenderProfile());
|
|
2315
|
+
}
|
|
2316
|
+
if (typeof bridge.RenderBlocksHtmlForReview === "function") {
|
|
2317
|
+
return bridge.RenderBlocksHtmlForReview(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses, this.renderTrackedChanges);
|
|
2318
|
+
}
|
|
2319
|
+
return bridge.RenderBlocksHtml(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses);
|
|
2320
|
+
}
|
|
2171
2321
|
/** Render two blocks in ONE batched bridge call when the bundle carries RenderBlocksHtml —
|
|
2172
2322
|
* the per-render shell/converter setup is paid once instead of twice, which matters on the
|
|
2173
2323
|
* Enter path (split renders both halves synchronously under the keystroke). Falls back to
|
|
@@ -2178,9 +2328,7 @@ export class DocxEditor {
|
|
|
2178
2328
|
if (typeof bridge.RenderBlocksHtml === "function") {
|
|
2179
2329
|
try {
|
|
2180
2330
|
const idsJson = JSON.stringify([a, b]);
|
|
2181
|
-
const json =
|
|
2182
|
-
? bridge.RenderBlocksHtmlForReview(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses, this.renderTrackedChanges)
|
|
2183
|
-
: bridge.RenderBlocksHtml(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses);
|
|
2331
|
+
const json = this.renderBlocksJson(idsJson);
|
|
2184
2332
|
const map = JSON.parse(json);
|
|
2185
2333
|
if (!map.error) {
|
|
2186
2334
|
const parse = (h) => h
|
|
@@ -2208,8 +2356,10 @@ export class DocxEditor {
|
|
|
2208
2356
|
parseEdit(json) {
|
|
2209
2357
|
try {
|
|
2210
2358
|
const result = JSON.parse(json);
|
|
2211
|
-
if (result.success)
|
|
2359
|
+
if (result.success) {
|
|
2212
2360
|
this.invalidateBlockMoveTargets();
|
|
2361
|
+
this.gutter?.schedule();
|
|
2362
|
+
}
|
|
2213
2363
|
return result;
|
|
2214
2364
|
}
|
|
2215
2365
|
catch {
|
|
@@ -2762,24 +2912,131 @@ export class DocxEditor {
|
|
|
2762
2912
|
* annotation type legal review runs on, finally authorable from the shipped surface
|
|
2763
2913
|
* (issue #580).
|
|
2764
2914
|
*/
|
|
2765
|
-
addComment(markdown = "New comment.", author =
|
|
2766
|
-
|
|
2915
|
+
addComment(markdown = "New comment.", author = this.options.commentAuthor, target) {
|
|
2916
|
+
let block = target?.block.isConnected ? target.block : this.activeBlock;
|
|
2917
|
+
let span = target ? target.span : null;
|
|
2918
|
+
if (target && !target.block.isConnected) {
|
|
2919
|
+
// The draft's block was swapped since it was captured — the click that posts a draft
|
|
2920
|
+
// blurs and commits the paragraph. Find the same paragraph by anchor rather than taking
|
|
2921
|
+
// whatever block is active now, and keep the span only while the text it was measured
|
|
2922
|
+
// on is unchanged; otherwise comment the whole paragraph rather than the wrong characters.
|
|
2923
|
+
const live = target.anchor === undefined ? null : this.blockByAnchor(target.anchor);
|
|
2924
|
+
if (live)
|
|
2925
|
+
block = live;
|
|
2926
|
+
if (!live || target.text === undefined || blockContentText(live) !== target.text)
|
|
2927
|
+
span = null;
|
|
2928
|
+
}
|
|
2767
2929
|
if (this.closed || !block)
|
|
2768
|
-
return;
|
|
2930
|
+
return null;
|
|
2769
2931
|
const bridge = this.exports.DocxSessionBridge;
|
|
2770
2932
|
if (!bridge.AddComment)
|
|
2771
|
-
return; // bridge predates comment authoring
|
|
2933
|
+
return null; // bridge predates comment authoring
|
|
2772
2934
|
let fullId = this.anchorIdOf(block);
|
|
2773
2935
|
if (!fullId)
|
|
2774
|
-
return;
|
|
2936
|
+
return null;
|
|
2775
2937
|
const idx = this.blockIndex(block);
|
|
2776
|
-
// Span first: syncBlock re-renders the block and would drop the live selection.
|
|
2777
|
-
|
|
2938
|
+
// Span first: syncBlock re-renders the block and would drop the live selection. A target
|
|
2939
|
+
// captured earlier (the gutter's draft bubble) wins over whatever the selection is now.
|
|
2940
|
+
if (!target)
|
|
2941
|
+
span = selectionSpanIn(block);
|
|
2778
2942
|
fullId = this.syncBlock(block, fullId);
|
|
2779
2943
|
const res = this.parseEdit(bridge.AddComment(this.handle, fullId, span ? JSON.stringify(span) : "", author, "", "", markdown));
|
|
2780
2944
|
if (!res.success)
|
|
2781
|
-
return;
|
|
2782
|
-
|
|
2945
|
+
return null;
|
|
2946
|
+
const created = res.created?.find((a) => a.kind === "cmt")?.id ?? null;
|
|
2947
|
+
// Comment markup lands inside the host paragraph, so it re-renders like any text edit; the
|
|
2948
|
+
// gutter then picks the new highlight up on its next layout.
|
|
2949
|
+
if (this.isBandBlock(block))
|
|
2950
|
+
this.refreshAfter(block, idx, false);
|
|
2951
|
+
else
|
|
2952
|
+
this.reconcile(idx, false);
|
|
2953
|
+
const entry = created ? this.listComments().find((c) => c.anchorId === created) ?? null : null;
|
|
2954
|
+
return entry;
|
|
2955
|
+
}
|
|
2956
|
+
/** Reply to a thread root (or any comment) as a native Word reply (`commentsExtended`). */
|
|
2957
|
+
addCommentReply(parentAnchorId, markdown, author = this.options.commentAuthor) {
|
|
2958
|
+
if (this.closed)
|
|
2959
|
+
return false;
|
|
2960
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
2961
|
+
if (!bridge.AddCommentReply)
|
|
2962
|
+
return false;
|
|
2963
|
+
const res = this.parseEdit(bridge.AddCommentReply(this.handle, parentAnchorId, author, "", "", markdown));
|
|
2964
|
+
if (!res.success)
|
|
2965
|
+
return false;
|
|
2966
|
+
// A reply adds a reference run beside the parent's, so the host paragraph re-renders.
|
|
2967
|
+
this.reconcile();
|
|
2968
|
+
return true;
|
|
2969
|
+
}
|
|
2970
|
+
/** Replace a comment's body text; author/date are preserved. */
|
|
2971
|
+
updateComment(commentAnchorId, markdown) {
|
|
2972
|
+
if (this.closed)
|
|
2973
|
+
return false;
|
|
2974
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
2975
|
+
if (!bridge.UpdateComment)
|
|
2976
|
+
return false;
|
|
2977
|
+
return this.parseEdit(bridge.UpdateComment(this.handle, commentAnchorId, markdown)).success;
|
|
2978
|
+
}
|
|
2979
|
+
/** Delete a comment: the definition and its range markers everywhere. */
|
|
2980
|
+
removeComment(commentAnchorId) {
|
|
2981
|
+
if (this.closed)
|
|
2982
|
+
return false;
|
|
2983
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
2984
|
+
if (!bridge.RemoveComment)
|
|
2985
|
+
return false;
|
|
2986
|
+
const res = this.parseEdit(bridge.RemoveComment(this.handle, commentAnchorId));
|
|
2987
|
+
if (!res.success)
|
|
2988
|
+
return false;
|
|
2989
|
+
this.reconcile();
|
|
2990
|
+
return true;
|
|
2991
|
+
}
|
|
2992
|
+
/** What "New comment" would comment on right now: the active block and the selection in it. */
|
|
2993
|
+
commentTarget() {
|
|
2994
|
+
const block = this.activeBlock;
|
|
2995
|
+
if (this.closed || !block || !block.isConnected)
|
|
2996
|
+
return null;
|
|
2997
|
+
let span = selectionSpanIn(block);
|
|
2998
|
+
const unid = block.getAttribute("data-anchor");
|
|
2999
|
+
if (!span && this.lastSelection && this.lastSelection.unid === unid)
|
|
3000
|
+
span = this.lastSelection.span;
|
|
3001
|
+
return { block, span, anchor: this.anchorIdOf(block), text: blockContentText(block) };
|
|
3002
|
+
}
|
|
3003
|
+
/** The live block for a full anchor id, or null when no mounted block resolves to it. */
|
|
3004
|
+
blockByAnchor(anchor) {
|
|
3005
|
+
for (const el of Array.from(this.container.querySelectorAll("[data-anchor]"))) {
|
|
3006
|
+
if (this.anchorIdOf(el) === anchor)
|
|
3007
|
+
return el;
|
|
3008
|
+
}
|
|
3009
|
+
return null;
|
|
3010
|
+
}
|
|
3011
|
+
/** Open a draft comment bubble beside the selection (Word's "New Comment"). */
|
|
3012
|
+
beginComment() {
|
|
3013
|
+
return this.gutter?.beginDraft() ?? false;
|
|
3014
|
+
}
|
|
3015
|
+
cancelComment() {
|
|
3016
|
+
this.gutter?.cancelDraft();
|
|
3017
|
+
}
|
|
3018
|
+
/** Activate a thread by `cmt` anchor id or numeric comment id (null clears). */
|
|
3019
|
+
activateComment(id) {
|
|
3020
|
+
this.gutter?.setActive(id, { scrollBubble: true, scrollAnchor: true });
|
|
3021
|
+
}
|
|
3022
|
+
/** Step to the next (+1) / previous (−1) thread in document order. */
|
|
3023
|
+
stepComment(direction) {
|
|
3024
|
+
return this.gutter?.step(direction) ?? null;
|
|
3025
|
+
}
|
|
3026
|
+
/** The active thread root's anchor id, or null. */
|
|
3027
|
+
get activeComment() {
|
|
3028
|
+
return this.gutter?.active ?? null;
|
|
3029
|
+
}
|
|
3030
|
+
/** Show or hide the comment gutter (the markup stays in the document). */
|
|
3031
|
+
showComments(visible) {
|
|
3032
|
+
this.gutter?.setVisible(visible);
|
|
3033
|
+
}
|
|
3034
|
+
get commentsVisible() {
|
|
3035
|
+
return this.gutter?.isVisible ?? false;
|
|
3036
|
+
}
|
|
3037
|
+
/** Force the gutter to lay out now (tests). */
|
|
3038
|
+
layoutComments() {
|
|
3039
|
+
this.gutter?.layout();
|
|
2783
3040
|
}
|
|
2784
3041
|
/** The document's native comment threads (session truth), for review UIs — flat entries
|
|
2785
3042
|
* with `parentAnchorId` linking replies and `resolved` carrying thread state. */
|
|
@@ -2801,11 +3058,11 @@ export class DocxEditor {
|
|
|
2801
3058
|
* needed — a review UI re-reads {@link listComments} for the new state. */
|
|
2802
3059
|
setCommentResolved(commentAnchorId, resolved) {
|
|
2803
3060
|
if (this.closed)
|
|
2804
|
-
return;
|
|
3061
|
+
return false;
|
|
2805
3062
|
const bridge = this.exports.DocxSessionBridge;
|
|
2806
3063
|
if (!bridge.SetCommentResolved)
|
|
2807
|
-
return;
|
|
2808
|
-
this.parseEdit(bridge.SetCommentResolved(this.handle, commentAnchorId, resolved));
|
|
3064
|
+
return false;
|
|
3065
|
+
return this.parseEdit(bridge.SetCommentResolved(this.handle, commentAnchorId, resolved)).success;
|
|
2809
3066
|
}
|
|
2810
3067
|
applyParagraphFormat(op) {
|
|
2811
3068
|
const block = this.activeBlock;
|
|
@@ -2907,8 +3164,11 @@ export class DocxEditor {
|
|
|
2907
3164
|
const block = this.activeBlock;
|
|
2908
3165
|
const band = block ? this.region.bandOf(block) : null;
|
|
2909
3166
|
const anchorId = block?.getAttribute("data-hf-anchor");
|
|
2910
|
-
if (band && anchorId) {
|
|
2911
|
-
|
|
3167
|
+
if (block && band && anchorId) {
|
|
3168
|
+
// Flush uncommitted typing first: the field appends to the SESSION paragraph, and the
|
|
3169
|
+
// story repaint that follows would otherwise blur-commit stale DOM text over it.
|
|
3170
|
+
const synced = this.syncBlock(block, anchorId);
|
|
3171
|
+
this.region.insertPageNumber(this.region.whichOf(band), synced, field);
|
|
2912
3172
|
return;
|
|
2913
3173
|
}
|
|
2914
3174
|
// No band block focused — target the footer, where page numbers overwhelmingly live.
|
|
@@ -2939,6 +3199,629 @@ export class DocxEditor {
|
|
|
2939
3199
|
pageNumbering() {
|
|
2940
3200
|
return this.region?.pageNumbering() ?? {};
|
|
2941
3201
|
}
|
|
3202
|
+
// ─── Font group ──────────────────────────────────────────────────────
|
|
3203
|
+
/**
|
|
3204
|
+
* Apply one inline `FormatOp` to the selection: a sub-range of the active block, the whole
|
|
3205
|
+
* block when the caret is collapsed, or every block of a multi-block selection. The last real
|
|
3206
|
+
* selection is used when a focus-stealing control (a colour picker) collapsed the live one.
|
|
3207
|
+
*/
|
|
3208
|
+
applyInlineFormat(op) {
|
|
3209
|
+
const block = this.activeBlock;
|
|
3210
|
+
if (this.closed || !block)
|
|
3211
|
+
return;
|
|
3212
|
+
const blocks = this.selectedBlocks();
|
|
3213
|
+
if (blocks.length > 1 && this.applyInlineOpAcrossBlocks(blocks, op))
|
|
3214
|
+
return;
|
|
3215
|
+
const unid = block.getAttribute("data-anchor");
|
|
3216
|
+
if (!unid)
|
|
3217
|
+
return;
|
|
3218
|
+
let fullId = this.anchorIdOf(block);
|
|
3219
|
+
if (!fullId)
|
|
3220
|
+
return;
|
|
3221
|
+
let span = selectionSpanIn(block);
|
|
3222
|
+
if (!span && this.lastSelection && this.lastSelection.unid === unid)
|
|
3223
|
+
span = this.lastSelection.span;
|
|
3224
|
+
fullId = this.syncBlock(block, fullId);
|
|
3225
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyFormat(this.handle, fullId, span ? JSON.stringify(span) : "", JSON.stringify(op)));
|
|
3226
|
+
if (!res.success)
|
|
3227
|
+
return;
|
|
3228
|
+
if (this.affectsList(res)) {
|
|
3229
|
+
this.refreshAfter(block, this.blockIndex(block), false);
|
|
3230
|
+
return;
|
|
3231
|
+
}
|
|
3232
|
+
const fresh = this.swapBlock(block, unid, res.modified?.[0]);
|
|
3233
|
+
if (fresh && span)
|
|
3234
|
+
selectRange(fresh, span.start, span.length);
|
|
3235
|
+
else
|
|
3236
|
+
fresh?.focus();
|
|
3237
|
+
}
|
|
3238
|
+
/** Font colour as a hex triplet (with or without '#'); `""` clears the explicit colour. */
|
|
3239
|
+
setFontColor(hex) {
|
|
3240
|
+
this.applyInlineFormat({ color: hex.replace(/^#/, "").toUpperCase() });
|
|
3241
|
+
}
|
|
3242
|
+
/** Word highlight colour name (`"yellow"`, `"green"`, …); `""` removes the highlight. */
|
|
3243
|
+
setHighlight(name) {
|
|
3244
|
+
this.applyInlineFormat({ highlight: name });
|
|
3245
|
+
}
|
|
3246
|
+
setAllCaps(on) {
|
|
3247
|
+
this.applyInlineFormat({ caps: on });
|
|
3248
|
+
}
|
|
3249
|
+
setSmallCaps(on) {
|
|
3250
|
+
this.applyInlineFormat({ smallCaps: on });
|
|
3251
|
+
}
|
|
3252
|
+
/** Word's "Clear All Formatting": drop every direct run property on the selection. */
|
|
3253
|
+
clearFormatting() {
|
|
3254
|
+
this.applyInlineFormat({
|
|
3255
|
+
bold: false, italic: false, underline: false, strike: false, code: false, color: "",
|
|
3256
|
+
highlight: "", vertAlign: "", fontSizePts: 0, fontFamily: "", runStyle: "",
|
|
3257
|
+
caps: false, smallCaps: false,
|
|
3258
|
+
});
|
|
3259
|
+
}
|
|
3260
|
+
/** The caret's rendered font size in points (from computed style), or null. */
|
|
3261
|
+
fontSizeAtCaret() {
|
|
3262
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
3263
|
+
let el = this.activeBlock;
|
|
3264
|
+
if (sel && sel.rangeCount > 0) {
|
|
3265
|
+
const n = sel.getRangeAt(0).startContainer;
|
|
3266
|
+
const candidate = n.nodeType === 3 ? n.parentElement : n;
|
|
3267
|
+
if (candidate && this.container.contains(candidate))
|
|
3268
|
+
el = candidate;
|
|
3269
|
+
}
|
|
3270
|
+
if (!el || typeof getComputedStyle !== "function")
|
|
3271
|
+
return null;
|
|
3272
|
+
const px = parseFloat(getComputedStyle(el).fontSize);
|
|
3273
|
+
return px > 0 ? Math.round(px * 0.75 * 2) / 2 : null;
|
|
3274
|
+
}
|
|
3275
|
+
/** Grow (+) or shrink (−) the selection's font size by `delta` points (Word's A↑ / A↓). */
|
|
3276
|
+
adjustFontSize(delta) {
|
|
3277
|
+
const size = this.fontSizeAtCaret();
|
|
3278
|
+
if (size == null)
|
|
3279
|
+
return;
|
|
3280
|
+
this.setFontSize(Math.max(1, Math.round((size + delta) * 2) / 2));
|
|
3281
|
+
}
|
|
3282
|
+
// ─── Paragraph group ─────────────────────────────────────────────────
|
|
3283
|
+
/** Line spacing as a multiple of single (1, 1.15, 1.5, 2 …) — `w:spacing/@w:line` under `auto`. */
|
|
3284
|
+
setLineSpacing(multiple) {
|
|
3285
|
+
this.applyParagraphFormat({ lineSpacing: Math.round(multiple * 240), lineSpacingRule: "auto" });
|
|
3286
|
+
}
|
|
3287
|
+
/** Space before/after the paragraph, in POINTS (Word's Paragraph dialog units). */
|
|
3288
|
+
setParagraphSpacing(op) {
|
|
3289
|
+
const payload = {};
|
|
3290
|
+
if (op.beforePt != null)
|
|
3291
|
+
payload.spacingBefore = Math.max(0, Math.round(op.beforePt * 20));
|
|
3292
|
+
if (op.afterPt != null)
|
|
3293
|
+
payload.spacingAfter = Math.max(0, Math.round(op.afterPt * 20));
|
|
3294
|
+
if (Object.keys(payload).length === 0)
|
|
3295
|
+
return;
|
|
3296
|
+
this.applyParagraphFormat(payload);
|
|
3297
|
+
}
|
|
3298
|
+
/** First-line indent in twips (0 = none); removes any hanging indent. */
|
|
3299
|
+
setFirstLineIndent(twips) {
|
|
3300
|
+
this.applyParagraphFormat({ firstLineIndent: Math.max(0, twips) });
|
|
3301
|
+
}
|
|
3302
|
+
/** Hanging indent in twips (0 = none); removes any first-line indent. */
|
|
3303
|
+
setHangingIndent(twips) {
|
|
3304
|
+
this.applyParagraphFormat({ hangingIndent: Math.max(0, twips) });
|
|
3305
|
+
}
|
|
3306
|
+
/**
|
|
3307
|
+
* Make the active block (or every selected block) a list item of `kind` — the full Word
|
|
3308
|
+
* numbering gallery, not just bullets/decimal — or a plain paragraph with `"none"`.
|
|
3309
|
+
*/
|
|
3310
|
+
setListFormat(kind) {
|
|
3311
|
+
const block = this.activeBlock;
|
|
3312
|
+
if (this.closed || !block)
|
|
3313
|
+
return;
|
|
3314
|
+
const blocks = this.selectedBlocks();
|
|
3315
|
+
if (blocks.length > 1 &&
|
|
3316
|
+
this.applyParagraphOpAcrossBlocks(blocks, (id) => this.exports.DocxSessionBridge.ApplyListFormat(this.handle, id, kind), true))
|
|
3317
|
+
return;
|
|
3318
|
+
const unid = block.getAttribute("data-anchor");
|
|
3319
|
+
if (!unid)
|
|
3320
|
+
return;
|
|
3321
|
+
let fullId = this.anchorIdOf(block);
|
|
3322
|
+
if (!fullId)
|
|
3323
|
+
return;
|
|
3324
|
+
const idx = this.blockIndex(block);
|
|
3325
|
+
fullId = this.syncBlock(block, fullId);
|
|
3326
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyListFormat(this.handle, fullId, kind));
|
|
3327
|
+
if (!res.success)
|
|
3328
|
+
return;
|
|
3329
|
+
this.refreshAfter(block, idx, false, /* forceRemount */ true);
|
|
3330
|
+
}
|
|
3331
|
+
/** The active block's list format (`"bullet"`, `"decimal"`, …), or null when it is not a list item. */
|
|
3332
|
+
listFormatAtCaret() {
|
|
3333
|
+
const block = this.activeBlock;
|
|
3334
|
+
if (this.closed || !block || !isListBlock(block))
|
|
3335
|
+
return null;
|
|
3336
|
+
const fullId = this.anchorIdOf(block);
|
|
3337
|
+
if (!fullId)
|
|
3338
|
+
return null;
|
|
3339
|
+
try {
|
|
3340
|
+
const membership = JSON.parse(this.exports.DocxSessionBridge.GetListMembership(this.handle, fullId));
|
|
3341
|
+
return membership?.format ?? null;
|
|
3342
|
+
}
|
|
3343
|
+
catch {
|
|
3344
|
+
return null;
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
/** Paragraph formatting (direct + effective) of the active block, for ribbon state. */
|
|
3348
|
+
paragraphFormatting() {
|
|
3349
|
+
const block = this.activeBlock;
|
|
3350
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3351
|
+
if (this.closed || !block || typeof bridge.GetFormatting !== "function")
|
|
3352
|
+
return null;
|
|
3353
|
+
const fullId = this.anchorIdOf(block);
|
|
3354
|
+
if (!fullId)
|
|
3355
|
+
return null;
|
|
3356
|
+
try {
|
|
3357
|
+
const parsed = JSON.parse(bridge.GetFormatting(this.handle, fullId));
|
|
3358
|
+
return parsed && typeof parsed === "object" && "effectiveParagraph" in parsed ? parsed : null;
|
|
3359
|
+
}
|
|
3360
|
+
catch {
|
|
3361
|
+
return null;
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
// ─── Links, images, references ───────────────────────────────────────
|
|
3365
|
+
/** Wrap the selection in a hyperlink (external URL, or an internal bookmark name). */
|
|
3366
|
+
insertHyperlink(target, kind = "external") {
|
|
3367
|
+
const block = this.activeBlock;
|
|
3368
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3369
|
+
if (this.closed || !block || typeof bridge.AddHyperlink !== "function")
|
|
3370
|
+
return false;
|
|
3371
|
+
const unid = block.getAttribute("data-anchor");
|
|
3372
|
+
if (!unid)
|
|
3373
|
+
return false;
|
|
3374
|
+
let fullId = this.anchorIdOf(block);
|
|
3375
|
+
if (!fullId)
|
|
3376
|
+
return false;
|
|
3377
|
+
let span = selectionSpanIn(block);
|
|
3378
|
+
if (!span && this.lastSelection && this.lastSelection.unid === unid)
|
|
3379
|
+
span = this.lastSelection.span;
|
|
3380
|
+
if (!span)
|
|
3381
|
+
return false; // Word needs a range to link
|
|
3382
|
+
fullId = this.syncBlock(block, fullId);
|
|
3383
|
+
const res = this.parseEdit(bridge.AddHyperlink(this.handle, fullId, span.start, span.length, kind, target));
|
|
3384
|
+
if (!res.success)
|
|
3385
|
+
return false;
|
|
3386
|
+
const fresh = this.swapBlock(block, unid, res.modified?.[0]);
|
|
3387
|
+
if (fresh)
|
|
3388
|
+
selectRange(fresh, span.start, span.length);
|
|
3389
|
+
return true;
|
|
3390
|
+
}
|
|
3391
|
+
/** The hyperlink the caret (or selection start) sits in, or null. */
|
|
3392
|
+
hyperlinkAtCaret() {
|
|
3393
|
+
const block = this.activeBlock;
|
|
3394
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3395
|
+
if (this.closed || !block || typeof bridge.ListHyperlinks !== "function")
|
|
3396
|
+
return null;
|
|
3397
|
+
const fullId = this.anchorIdOf(block);
|
|
3398
|
+
if (!fullId)
|
|
3399
|
+
return null;
|
|
3400
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
3401
|
+
let offset = 0;
|
|
3402
|
+
if (sel && sel.rangeCount > 0 && block.contains(sel.getRangeAt(0).startContainer)) {
|
|
3403
|
+
const r = sel.getRangeAt(0);
|
|
3404
|
+
offset = contentOffsetOf(block, r.startContainer, r.startOffset);
|
|
3405
|
+
}
|
|
3406
|
+
try {
|
|
3407
|
+
const links = JSON.parse(bridge.ListHyperlinks(this.handle, 63));
|
|
3408
|
+
return (links.find((l) => l.anchorId === fullId && offset >= l.span.start && offset <= l.span.start + l.span.length) ?? null);
|
|
3409
|
+
}
|
|
3410
|
+
catch {
|
|
3411
|
+
return null;
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
/** Remove the hyperlink at the caret, keeping its text. */
|
|
3415
|
+
removeHyperlink() {
|
|
3416
|
+
const link = this.hyperlinkAtCaret();
|
|
3417
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3418
|
+
const block = this.activeBlock;
|
|
3419
|
+
if (!link || !block || typeof bridge.RemoveHyperlink !== "function")
|
|
3420
|
+
return false;
|
|
3421
|
+
const unid = block.getAttribute("data-anchor");
|
|
3422
|
+
const res = this.parseEdit(bridge.RemoveHyperlink(this.handle, link.id));
|
|
3423
|
+
if (!res.success)
|
|
3424
|
+
return false;
|
|
3425
|
+
if (unid)
|
|
3426
|
+
this.swapBlock(block, unid, res.modified?.[0]);
|
|
3427
|
+
return true;
|
|
3428
|
+
}
|
|
3429
|
+
/** Insert an inline image (base64 bytes) at the caret in the active body block. */
|
|
3430
|
+
insertImage(imageBase64, options = {}) {
|
|
3431
|
+
const block = this.activeBlock;
|
|
3432
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3433
|
+
if (this.closed || !block || typeof bridge.InsertImage !== "function")
|
|
3434
|
+
return false;
|
|
3435
|
+
let fullId = this.anchorIdOf(block);
|
|
3436
|
+
if (!fullId)
|
|
3437
|
+
return false;
|
|
3438
|
+
const idx = this.blockIndex(block);
|
|
3439
|
+
const raw = caretOffsetIn(block);
|
|
3440
|
+
fullId = this.syncBlock(block, fullId);
|
|
3441
|
+
const offset = trimmedSplitOffset(block, raw ?? (block.textContent ?? "").length);
|
|
3442
|
+
const res = this.parseEdit(bridge.InsertImage(this.handle, fullId, offset, imageBase64, JSON.stringify(options)));
|
|
3443
|
+
if (!res.success)
|
|
3444
|
+
return false;
|
|
3445
|
+
// Image parts are not in the block-render shell, so a single-block swap would drop the
|
|
3446
|
+
// picture; the full render is the only path that shows it.
|
|
3447
|
+
this.refreshAfter(block, idx, true, /* forceRemount */ true);
|
|
3448
|
+
return true;
|
|
3449
|
+
}
|
|
3450
|
+
/** Read a picked file and insert it as an inline image. */
|
|
3451
|
+
async insertImageFile(file, options = {}) {
|
|
3452
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
3453
|
+
let binary = "";
|
|
3454
|
+
const chunk = 0x8000;
|
|
3455
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
3456
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
3457
|
+
}
|
|
3458
|
+
return this.insertImage(btoa(binary), options);
|
|
3459
|
+
}
|
|
3460
|
+
/** Insert a table of contents (a real TOC field) before the active block. */
|
|
3461
|
+
insertTableOfContents(options = {}) {
|
|
3462
|
+
const block = this.activeBlock;
|
|
3463
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3464
|
+
if (this.closed || !block || typeof bridge.InsertTableOfContents !== "function")
|
|
3465
|
+
return false;
|
|
3466
|
+
if (this.isBandBlock(block) || block.closest("table, .footnotes, .endnotes"))
|
|
3467
|
+
return false;
|
|
3468
|
+
let fullId = this.anchorIdOf(block);
|
|
3469
|
+
if (!fullId)
|
|
3470
|
+
return false;
|
|
3471
|
+
const idx = this.blockIndex(block);
|
|
3472
|
+
fullId = this.syncBlock(block, fullId);
|
|
3473
|
+
const res = this.parseEdit(bridge.InsertTableOfContents(this.handle, fullId, "before", JSON.stringify(options)));
|
|
3474
|
+
if (!res.success)
|
|
3475
|
+
return false;
|
|
3476
|
+
this.refreshAfter(block, idx, false, /* forceRemount */ true);
|
|
3477
|
+
return true;
|
|
3478
|
+
}
|
|
3479
|
+
// ─── Table group (extended) ──────────────────────────────────────────
|
|
3480
|
+
/** Merge the active cell with `rowSpan`×`colSpan` neighbours (down and right). */
|
|
3481
|
+
mergeCells(rowSpan, colSpan) {
|
|
3482
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3483
|
+
if (typeof bridge.MergeCells !== "function")
|
|
3484
|
+
return;
|
|
3485
|
+
this.tableEdit((a) => bridge.MergeCells(this.handle, a, rowSpan, colSpan, ""));
|
|
3486
|
+
}
|
|
3487
|
+
/** Split a merged cell back into its grid cells. */
|
|
3488
|
+
unmergeCells() {
|
|
3489
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3490
|
+
if (typeof bridge.UnmergeCells !== "function")
|
|
3491
|
+
return;
|
|
3492
|
+
this.tableEdit((a) => bridge.UnmergeCells(this.handle, a));
|
|
3493
|
+
}
|
|
3494
|
+
/** Set (or with `style: "none"` remove) the borders of the active cell's table. */
|
|
3495
|
+
setTableBorders(spec) {
|
|
3496
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3497
|
+
if (typeof bridge.SetTableBorders !== "function")
|
|
3498
|
+
return;
|
|
3499
|
+
this.tableEdit((a) => bridge.SetTableBorders(this.handle, a, JSON.stringify(spec)));
|
|
3500
|
+
}
|
|
3501
|
+
/** Shade the active cell (or its row/column/table) with a hex fill; `""` clears. */
|
|
3502
|
+
setCellShading(fill, scope = "cell") {
|
|
3503
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3504
|
+
if (typeof bridge.SetCellShading !== "function")
|
|
3505
|
+
return;
|
|
3506
|
+
this.tableEdit((a) => bridge.SetCellShading(this.handle, a, fill.replace(/^#/, ""), scope));
|
|
3507
|
+
}
|
|
3508
|
+
/** Repeat the active cell's row at the top of every page the table spans. */
|
|
3509
|
+
setRepeatHeaderRow(repeat) {
|
|
3510
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3511
|
+
if (typeof bridge.SetRepeatHeaderRow !== "function")
|
|
3512
|
+
return;
|
|
3513
|
+
this.tableEdit((a) => bridge.SetRepeatHeaderRow(this.handle, a, repeat));
|
|
3514
|
+
}
|
|
3515
|
+
/** Delete the whole table the caret is in. */
|
|
3516
|
+
deleteTable() {
|
|
3517
|
+
const block = this.activeBlock;
|
|
3518
|
+
const table = block?.closest("table");
|
|
3519
|
+
if (this.closed || !block || !table)
|
|
3520
|
+
return;
|
|
3521
|
+
const tableId = this.anchorIdOf(table);
|
|
3522
|
+
if (!tableId)
|
|
3523
|
+
return;
|
|
3524
|
+
const idx = this.blockIndex(block);
|
|
3525
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.DeleteBlock(this.handle, tableId));
|
|
3526
|
+
if (!res.success)
|
|
3527
|
+
return;
|
|
3528
|
+
this.refreshAfter(block, Math.max(0, idx - 1), true);
|
|
3529
|
+
}
|
|
3530
|
+
// ─── Review group ────────────────────────────────────────────────────
|
|
3531
|
+
/** How edits are being recorded right now. */
|
|
3532
|
+
get trackedChanges() {
|
|
3533
|
+
return this.options.trackedChanges;
|
|
3534
|
+
}
|
|
3535
|
+
/**
|
|
3536
|
+
* Switch tracked-changes mode mid-session (Word's "Track Changes" toggle). Undo history and
|
|
3537
|
+
* edits survive; the document re-renders so revisions show (or stop showing) inline.
|
|
3538
|
+
*/
|
|
3539
|
+
setTrackedChanges(mode) {
|
|
3540
|
+
this.assertOpen();
|
|
3541
|
+
if (this.options.trackedChanges === mode)
|
|
3542
|
+
return;
|
|
3543
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3544
|
+
if (typeof bridge.SetTrackedChanges !== "function")
|
|
3545
|
+
return;
|
|
3546
|
+
bridge.SetTrackedChanges(this.handle, mode);
|
|
3547
|
+
this.options.trackedChanges = mode;
|
|
3548
|
+
this.remount();
|
|
3549
|
+
}
|
|
3550
|
+
setRevisionAuthor(author) {
|
|
3551
|
+
this.assertOpen();
|
|
3552
|
+
this.options.revisionAuthor = author;
|
|
3553
|
+
this.exports.DocxSessionBridge.SetRevisionAuthor?.(this.handle, author);
|
|
3554
|
+
}
|
|
3555
|
+
/** Every tracked revision in the document, from the session's registry. */
|
|
3556
|
+
listRevisions() {
|
|
3557
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3558
|
+
if (this.closed || typeof bridge.ListRevisions !== "function")
|
|
3559
|
+
return [];
|
|
3560
|
+
try {
|
|
3561
|
+
const parsed = JSON.parse(bridge.ListRevisions(this.handle));
|
|
3562
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
3563
|
+
}
|
|
3564
|
+
catch {
|
|
3565
|
+
return [];
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
acceptRevision(revisionId) {
|
|
3569
|
+
return this.resolveRevision("AcceptRevision", revisionId);
|
|
3570
|
+
}
|
|
3571
|
+
rejectRevision(revisionId) {
|
|
3572
|
+
return this.resolveRevision("RejectRevision", revisionId);
|
|
3573
|
+
}
|
|
3574
|
+
acceptAllRevisions() {
|
|
3575
|
+
return this.resolveRevision("AcceptAllRevisions");
|
|
3576
|
+
}
|
|
3577
|
+
rejectAllRevisions() {
|
|
3578
|
+
return this.resolveRevision("RejectAllRevisions");
|
|
3579
|
+
}
|
|
3580
|
+
resolveRevision(op, revisionId) {
|
|
3581
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3582
|
+
if (this.closed)
|
|
3583
|
+
return false;
|
|
3584
|
+
const idx = this.activeBlock ? this.blockIndex(this.activeBlock) : -1;
|
|
3585
|
+
let json;
|
|
3586
|
+
if (op === "AcceptRevision" || op === "RejectRevision") {
|
|
3587
|
+
const fn = bridge[op];
|
|
3588
|
+
if (typeof fn !== "function" || !revisionId)
|
|
3589
|
+
return false;
|
|
3590
|
+
json = fn(this.handle, revisionId);
|
|
3591
|
+
}
|
|
3592
|
+
else {
|
|
3593
|
+
const fn = bridge[op];
|
|
3594
|
+
if (typeof fn !== "function")
|
|
3595
|
+
return false;
|
|
3596
|
+
json = fn(this.handle);
|
|
3597
|
+
}
|
|
3598
|
+
const res = this.parseEdit(json);
|
|
3599
|
+
if (!res.success)
|
|
3600
|
+
return false;
|
|
3601
|
+
// Resolution rewrites markup across the document; the reconciler proves what it can.
|
|
3602
|
+
this.reconcile(idx, false);
|
|
3603
|
+
return true;
|
|
3604
|
+
}
|
|
3605
|
+
/** Rendered revision marks in document order — what Previous/Next step through. */
|
|
3606
|
+
revisionElements() {
|
|
3607
|
+
return Array.from(this.container.querySelectorAll('ins[class*="-ins"], del[class*="-del"], ins[class*="move-to"], del[class*="move-from"], ' +
|
|
3608
|
+
'tr[class*="row-ins"], tr[class*="row-del"]')).filter((el) => !el.closest("#pagination-staging, .docx-comment-gutter"));
|
|
3609
|
+
}
|
|
3610
|
+
/** The registry entry a rendered revision mark belongs to (matched by block, then by text). */
|
|
3611
|
+
revisionAt(el) {
|
|
3612
|
+
const block = el.closest("[data-anchor]");
|
|
3613
|
+
const unid = block?.getAttribute("data-anchor");
|
|
3614
|
+
const candidates = this.listRevisions().filter((r) => r.resolutionStatus === "supported");
|
|
3615
|
+
if (!unid)
|
|
3616
|
+
return candidates[0] ?? null;
|
|
3617
|
+
const inBlock = candidates.filter((r) => r.anchorId?.endsWith(unid) || r.affectedAnchors?.some((a) => a.unid === unid));
|
|
3618
|
+
if (inBlock.length === 0)
|
|
3619
|
+
return null;
|
|
3620
|
+
const text = (el.textContent ?? "").trim();
|
|
3621
|
+
return inBlock.find((r) => text && r.text.trim() === text) ?? inBlock[0];
|
|
3622
|
+
}
|
|
3623
|
+
// ─── Find & replace ──────────────────────────────────────────────────
|
|
3624
|
+
/** Every occurrence of `query` in the editable blocks, in document order. */
|
|
3625
|
+
find(query, options = {}) {
|
|
3626
|
+
if (!query)
|
|
3627
|
+
return [];
|
|
3628
|
+
const needle = options.matchCase ? query : query.toLowerCase();
|
|
3629
|
+
const blocks = this.editableList().concat(Array.from(this.container.querySelectorAll('[data-hf-band] [data-anchor][contenteditable="true"]')));
|
|
3630
|
+
const out = [];
|
|
3631
|
+
for (const block of blocks) {
|
|
3632
|
+
const text = blockContentText(block);
|
|
3633
|
+
const hay = options.matchCase ? text : text.toLowerCase();
|
|
3634
|
+
let from = 0;
|
|
3635
|
+
for (;;) {
|
|
3636
|
+
const at = hay.indexOf(needle, from);
|
|
3637
|
+
if (at < 0)
|
|
3638
|
+
break;
|
|
3639
|
+
out.push({ block, start: at, length: query.length });
|
|
3640
|
+
from = at + Math.max(1, query.length);
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
return out;
|
|
3644
|
+
}
|
|
3645
|
+
/** Select a match and scroll it into view. */
|
|
3646
|
+
selectMatch(match) {
|
|
3647
|
+
if (!match.block.isConnected)
|
|
3648
|
+
return;
|
|
3649
|
+
this.activeBlock = match.block;
|
|
3650
|
+
selectRange(match.block, match.start, match.length);
|
|
3651
|
+
match.block.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
3652
|
+
}
|
|
3653
|
+
/** Replace one match's text (formatting of the surrounding run is inherited). */
|
|
3654
|
+
replaceMatch(match, replacement) {
|
|
3655
|
+
const block = match.block;
|
|
3656
|
+
if (this.closed || !block.isConnected)
|
|
3657
|
+
return false;
|
|
3658
|
+
const unid = block.getAttribute("data-anchor");
|
|
3659
|
+
if (!unid)
|
|
3660
|
+
return false;
|
|
3661
|
+
let fullId = this.anchorIdOf(block);
|
|
3662
|
+
if (!fullId)
|
|
3663
|
+
return false;
|
|
3664
|
+
fullId = this.syncBlock(block, fullId);
|
|
3665
|
+
const span = trimmedSpan(block, { start: match.start, length: match.length });
|
|
3666
|
+
if (span.length === 0)
|
|
3667
|
+
return false;
|
|
3668
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.ReplaceTextAtSpan(this.handle, fullId, span.start, span.length, replacement));
|
|
3669
|
+
if (!res.success)
|
|
3670
|
+
return false;
|
|
3671
|
+
const fresh = this.swapBlock(block, unid, res.modified?.[0]);
|
|
3672
|
+
if (fresh)
|
|
3673
|
+
selectRange(fresh, span.start, replacement.length);
|
|
3674
|
+
return true;
|
|
3675
|
+
}
|
|
3676
|
+
/** Replace every occurrence; returns how many were replaced. */
|
|
3677
|
+
replaceAll(query, replacement, options = {}) {
|
|
3678
|
+
if (this.closed || !query)
|
|
3679
|
+
return 0;
|
|
3680
|
+
const matches = this.find(query, options);
|
|
3681
|
+
let count = 0;
|
|
3682
|
+
// Group by block and replace from the END so earlier offsets stay valid.
|
|
3683
|
+
const byBlock = new Map();
|
|
3684
|
+
for (const m of matches) {
|
|
3685
|
+
const list = byBlock.get(m.block) ?? [];
|
|
3686
|
+
list.push(m);
|
|
3687
|
+
byBlock.set(m.block, list);
|
|
3688
|
+
}
|
|
3689
|
+
for (const [block, list] of byBlock) {
|
|
3690
|
+
let current = block;
|
|
3691
|
+
for (const m of list.slice().sort((a, b) => b.start - a.start)) {
|
|
3692
|
+
if (!current.isConnected)
|
|
3693
|
+
break;
|
|
3694
|
+
const before = current;
|
|
3695
|
+
if (this.replaceMatch({ block: current, start: m.start, length: m.length }, replacement)) {
|
|
3696
|
+
count++;
|
|
3697
|
+
// swapBlock replaced the node; keep following it.
|
|
3698
|
+
current = this.activeBlock && this.activeBlock !== before ? this.activeBlock : current;
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
return count;
|
|
3703
|
+
}
|
|
3704
|
+
// ─── View ────────────────────────────────────────────────────────────
|
|
3705
|
+
/** Set the author-pinned zoom (1 = 100%). Fit-to-width still caps it on narrow hosts. */
|
|
3706
|
+
setZoom(scale) {
|
|
3707
|
+
this.assertOpen();
|
|
3708
|
+
this.viewport.setScale(scale);
|
|
3709
|
+
this.gutter?.schedule();
|
|
3710
|
+
}
|
|
3711
|
+
/** The zoom the user asked for (what a zoom control shows), before fit-to-width caps it. */
|
|
3712
|
+
get requestedZoom() {
|
|
3713
|
+
return this.viewport.requestedScale;
|
|
3714
|
+
}
|
|
3715
|
+
/** Word count over the body's editable text. */
|
|
3716
|
+
wordCount() {
|
|
3717
|
+
let count = 0;
|
|
3718
|
+
for (const block of this.editableList()) {
|
|
3719
|
+
// Notes are not body words in either view (the paginator's per-page note block carries
|
|
3720
|
+
// the "page-footnotes" class; the continuous view's sections are ".footnotes"/".endnotes").
|
|
3721
|
+
if (block.closest('.footnotes, .endnotes, [class$="-footnotes"], [class$="-endnotes"]'))
|
|
3722
|
+
continue;
|
|
3723
|
+
const text = blockContentText(block);
|
|
3724
|
+
const words = text.match(/[\p{L}\p{N}][\p{L}\p{N}'’.-]*/gu);
|
|
3725
|
+
count += words ? words.length : 0;
|
|
3726
|
+
}
|
|
3727
|
+
return count;
|
|
3728
|
+
}
|
|
3729
|
+
/** In page view, the active block's page and the page total; null in continuous view. */
|
|
3730
|
+
pageInfo() {
|
|
3731
|
+
if (!this.options.paginated)
|
|
3732
|
+
return null;
|
|
3733
|
+
const boxes = this.editRoot.querySelectorAll(".page-box:not([data-section-filler])");
|
|
3734
|
+
const total = boxes.length;
|
|
3735
|
+
const box = this.activeBlock?.closest(".page-box");
|
|
3736
|
+
const page = box ? parseInt(box.dataset.pageNumber || "1", 10) : 1;
|
|
3737
|
+
return { page, total };
|
|
3738
|
+
}
|
|
3739
|
+
// ─── Layout: section page setup ──────────────────────────────────────
|
|
3740
|
+
/** The section governing the active block (or the first body block). */
|
|
3741
|
+
sectionInfo() {
|
|
3742
|
+
if (this.closed)
|
|
3743
|
+
return null;
|
|
3744
|
+
const block = this.activeBlock && !this.isBandBlock(this.activeBlock) ? this.activeBlock : this.editableList()[0];
|
|
3745
|
+
const unid = block?.getAttribute("data-anchor");
|
|
3746
|
+
const fullId = unid ? this.unidToFullId.get(unid) : undefined;
|
|
3747
|
+
if (!fullId)
|
|
3748
|
+
return null;
|
|
3749
|
+
try {
|
|
3750
|
+
const parsed = JSON.parse(this.exports.DocxSessionBridge.GetSectionInfo(this.handle, fullId));
|
|
3751
|
+
return parsed && typeof parsed.sectionUnid === "string" ? parsed : null;
|
|
3752
|
+
}
|
|
3753
|
+
catch {
|
|
3754
|
+
return null;
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
/** Word's Page Setup on the section holding the caret: size, orientation, margins. */
|
|
3758
|
+
setPageSetup(op) {
|
|
3759
|
+
this.assertOpen();
|
|
3760
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3761
|
+
if (typeof bridge.SetPageSetup !== "function")
|
|
3762
|
+
return false;
|
|
3763
|
+
const info = this.sectionInfo();
|
|
3764
|
+
if (!info)
|
|
3765
|
+
return false;
|
|
3766
|
+
const res = this.parseEdit(bridge.SetPageSetup(this.handle, info.anchorId, JSON.stringify(op)));
|
|
3767
|
+
if (!res.success)
|
|
3768
|
+
return false;
|
|
3769
|
+
// Geometry is whole-document context (sheet width, page boxes).
|
|
3770
|
+
this.remount(this.activeBlock ? this.blockIndex(this.activeBlock) : -1, false);
|
|
3771
|
+
return true;
|
|
3772
|
+
}
|
|
3773
|
+
// ─── Header & footer (extended) ──────────────────────────────────────
|
|
3774
|
+
/** Word's "Different first page" (`first`) / "Different odd & even pages" (`even`). */
|
|
3775
|
+
setHeaderFooterKindEnabled(kind, enabled) {
|
|
3776
|
+
this.assertOpen();
|
|
3777
|
+
return this.region?.setKindEnabled(kind, enabled) ?? false;
|
|
3778
|
+
}
|
|
3779
|
+
headerFooterKindEnabled(kind) {
|
|
3780
|
+
return this.region?.kindEnabled(kind) ?? false;
|
|
3781
|
+
}
|
|
3782
|
+
/** "First Page Header", "Footer", … for the story a band (or the active page area) shows. */
|
|
3783
|
+
headerFooterStoryLabel(which) {
|
|
3784
|
+
return this.region?.storyLabel(which) ?? (which === "header" ? "Header" : "Footer");
|
|
3785
|
+
}
|
|
3786
|
+
/** Put the caret in the header or footer (Word's "Go to Header / Go to Footer"). */
|
|
3787
|
+
goToHeaderFooter(which) {
|
|
3788
|
+
this.assertOpen();
|
|
3789
|
+
return this.region?.focusStory(which, this.activeBlock) ?? false;
|
|
3790
|
+
}
|
|
3791
|
+
/** Leave the header/footer and put the caret back in the body. */
|
|
3792
|
+
closeHeaderFooter() {
|
|
3793
|
+
this.assertOpen();
|
|
3794
|
+
this.region?.close();
|
|
3795
|
+
const body = this.editableList();
|
|
3796
|
+
const target = this.activeBlock && !this.isBandBlock(this.activeBlock) && this.activeBlock.isConnected
|
|
3797
|
+
? this.activeBlock
|
|
3798
|
+
: body[0];
|
|
3799
|
+
if (target)
|
|
3800
|
+
placeCaretAtOffset(target, 0);
|
|
3801
|
+
}
|
|
3802
|
+
/** Which story the caret is in, or null in the body. */
|
|
3803
|
+
get activeStoryKind() {
|
|
3804
|
+
return this.activeStory;
|
|
3805
|
+
}
|
|
3806
|
+
// ─── Introspection for chrome ────────────────────────────────────────
|
|
3807
|
+
/** The document's style definitions (for a styles gallery); empty on older bundles. */
|
|
3808
|
+
styles() {
|
|
3809
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
3810
|
+
if (this.closed || typeof bridge.ListStyles !== "function")
|
|
3811
|
+
return [];
|
|
3812
|
+
try {
|
|
3813
|
+
const parsed = JSON.parse(bridge.ListStyles(this.handle));
|
|
3814
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
3815
|
+
}
|
|
3816
|
+
catch {
|
|
3817
|
+
return [];
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
/** The active block's paragraph style id (from the render), or null. */
|
|
3821
|
+
styleAtCaret() {
|
|
3822
|
+
const info = this.paragraphFormatting();
|
|
3823
|
+
return info?.effectiveParagraph.styleId ?? info?.directParagraph.styleId ?? null;
|
|
3824
|
+
}
|
|
2942
3825
|
/** Which inline formats the current selection carries — for ribbon button highlighting. */
|
|
2943
3826
|
queryFormatState() {
|
|
2944
3827
|
const block = this.activeBlock ?? this.editRoot;
|
|
@@ -2975,6 +3858,8 @@ export class DocxEditor {
|
|
|
2975
3858
|
this.activeBlock = fresh;
|
|
2976
3859
|
// The throwaway render numbers citation markers from 1 — repair in place.
|
|
2977
3860
|
this.maybeRenumberNotes(fresh);
|
|
3861
|
+
if (inBand)
|
|
3862
|
+
this.region.afterStoryEdit(fresh);
|
|
2978
3863
|
this.options.onEdit?.({ anchorId, unid: newUnid });
|
|
2979
3864
|
return fresh;
|
|
2980
3865
|
}
|
|
@@ -2985,8 +3870,39 @@ export class DocxEditor {
|
|
|
2985
3870
|
* Save + ConvertDocxToHtmlComplete for older WASM bundles. Both paths use
|
|
2986
3871
|
* the same option profile, so the rendered HTML is identical.
|
|
2987
3872
|
*/
|
|
2988
|
-
|
|
3873
|
+
/** The editor's render profile, as the comment-aware bridge endpoints take it. */
|
|
3874
|
+
editorRenderProfile() {
|
|
3875
|
+
return JSON.stringify({
|
|
3876
|
+
cssPrefix: this.options.cssPrefix,
|
|
3877
|
+
fabricateClasses: this.options.fabricateClasses,
|
|
3878
|
+
paginated: this.options.paginated,
|
|
3879
|
+
scale: this.options.scale,
|
|
3880
|
+
renderTrackedChanges: this.renderTrackedChanges,
|
|
3881
|
+
comments: this.options.comments,
|
|
3882
|
+
});
|
|
3883
|
+
}
|
|
3884
|
+
/**
|
|
3885
|
+
* Full-document HTML from the live session. Prefers the session-attached
|
|
3886
|
+
* `RenderEditorHtml` (comment-aware) or `RenderHtml` bridge — the saved bytes never cross the
|
|
3887
|
+
* JS/WASM boundary — and falls back to `ConvertDocxToHtmlComplete` over `bytes` (the opened
|
|
3888
|
+
* document on first paint, else a fresh save) for older WASM bundles. Every path uses the
|
|
3889
|
+
* same option profile, so the rendered HTML is identical.
|
|
3890
|
+
*/
|
|
3891
|
+
renderFullHtml(bytes) {
|
|
2989
3892
|
const bridge = this.exports.DocxSessionBridge;
|
|
3893
|
+
if (typeof bridge.RenderEditorHtml === "function") {
|
|
3894
|
+
const html = bridge.RenderEditorHtml(this.handle, this.editorRenderProfile());
|
|
3895
|
+
if (html.charCodeAt(0) !== 0x7b)
|
|
3896
|
+
return html;
|
|
3897
|
+
}
|
|
3898
|
+
if (this.options.comments) {
|
|
3899
|
+
// Only the bytes path can render comment markup on a bundle without RenderEditorHtml.
|
|
3900
|
+
const source = bytes ??
|
|
3901
|
+
(typeof bridge.SaveWithAnchorIds === "function"
|
|
3902
|
+
? bridge.SaveWithAnchorIds(this.handle)
|
|
3903
|
+
: bridge.Save(this.handle));
|
|
3904
|
+
return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(source, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale, this.renderTrackedChanges, true));
|
|
3905
|
+
}
|
|
2990
3906
|
if (typeof bridge.RenderHtmlForReview === "function") {
|
|
2991
3907
|
const html = bridge.RenderHtmlForReview(this.handle, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale, this.renderTrackedChanges);
|
|
2992
3908
|
if (html.charCodeAt(0) !== 0x7b)
|
|
@@ -3001,14 +3917,17 @@ export class DocxEditor {
|
|
|
3001
3917
|
// discarded, and the re-render has to resolve to the SAME anchors the live session holds — a
|
|
3002
3918
|
// content change re-derives a block's content-hashed unid, which would leave it unwired. So ask
|
|
3003
3919
|
// for the Unid-bearing save here, and here only; DocxEditor.save() stays clean.
|
|
3004
|
-
const
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3920
|
+
const source = bytes ??
|
|
3921
|
+
(typeof bridge.SaveWithAnchorIds === "function"
|
|
3922
|
+
? bridge.SaveWithAnchorIds(this.handle)
|
|
3923
|
+
: bridge.Save(this.handle));
|
|
3924
|
+
return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(source, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale, this.renderTrackedChanges, false));
|
|
3925
|
+
}
|
|
3926
|
+
/** Editable BODY blocks in document order (band blocks are enumerated by `ownerRoot`). In page
|
|
3927
|
+
* view a live story sits INSIDE a page box, so it is excluded here explicitly — a remount
|
|
3928
|
+
* rebuilds the pages without it, and a focus index that counted it would land one block off. */
|
|
3010
3929
|
editableList() {
|
|
3011
|
-
return Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
|
|
3930
|
+
return Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]')).filter((el) => !el.closest("[data-hf-band]"));
|
|
3012
3931
|
}
|
|
3013
3932
|
blockIndex(el) {
|
|
3014
3933
|
return Array.from(this.ownerRoot(el).querySelectorAll('[data-anchor][contenteditable="true"]')).indexOf(el);
|
|
@@ -3096,6 +4015,7 @@ export class DocxEditor {
|
|
|
3096
4015
|
}
|
|
3097
4016
|
}
|
|
3098
4017
|
this.syncRegionToBody(this.activeBlock ?? undefined);
|
|
4018
|
+
this.gutter?.schedule();
|
|
3099
4019
|
}
|
|
3100
4020
|
/** The patch itself. Returns false to request the remount fallback. */
|
|
3101
4021
|
reconcileCore() {
|
|
@@ -3132,9 +4052,7 @@ export class DocxEditor {
|
|
|
3132
4052
|
let rendered = {};
|
|
3133
4053
|
if (allIds.length > 0) {
|
|
3134
4054
|
const idsJson = JSON.stringify(allIds);
|
|
3135
|
-
const renderedJson =
|
|
3136
|
-
? bridge.RenderBlocksHtmlForReview(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses, this.renderTrackedChanges)
|
|
3137
|
-
: bridge.RenderBlocksHtml(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses);
|
|
4055
|
+
const renderedJson = this.renderBlocksJson(idsJson);
|
|
3138
4056
|
rendered = JSON.parse(renderedJson);
|
|
3139
4057
|
if (rendered.error)
|
|
3140
4058
|
return this.bail(`render error: ${rendered.error}`);
|
|
@@ -3220,6 +4138,24 @@ export class DocxEditor {
|
|
|
3220
4138
|
for (const [nj, oi] of subOldByNew) {
|
|
3221
4139
|
const freshRoot = fresh.get(nj);
|
|
3222
4140
|
const oldWrapper = this.unitWrapperOf(oldNodes[oi]);
|
|
4141
|
+
// A render that differs from the live node ONLY in <img> attributes (a host replacing
|
|
4142
|
+
// an image's media through the session — the arcade's Doom cartridge does it every
|
|
4143
|
+
// frame) patches those attributes on the elements already on screen instead of
|
|
4144
|
+
// swapping nodes. Firefox and WebKit paint a freshly inserted <img> as an empty box
|
|
4145
|
+
// until its data URI is decoded, so a per-frame swap strobes white between frames;
|
|
4146
|
+
// an in-place src change keeps the previous bitmap up until the new one is ready.
|
|
4147
|
+
// The live node stays wired, so only its signature stamp needs refreshing. Any
|
|
4148
|
+
// other difference (or a border-wrapper change) falls through to the swap below.
|
|
4149
|
+
const leaf = freshRoot.hasAttribute("data-anchor");
|
|
4150
|
+
const imagePairs = imageOnlyDelta(leaf ? oldNodes[oi] : oldWrapper, freshRoot);
|
|
4151
|
+
if (imagePairs) {
|
|
4152
|
+
for (const [live, next] of imagePairs)
|
|
4153
|
+
patchImageAttributes(live, next);
|
|
4154
|
+
const sig = units[nj].sig;
|
|
4155
|
+
if (sig)
|
|
4156
|
+
oldNodes[oi].setAttribute("data-render-sig", sig);
|
|
4157
|
+
continue;
|
|
4158
|
+
}
|
|
3223
4159
|
if (!freshRoot.hasAttribute("data-anchor")) {
|
|
3224
4160
|
oldWrapper.replaceWith(freshRoot); // wrapper-shaped render (table) ⇄ wrapper
|
|
3225
4161
|
}
|
|
@@ -3546,6 +4482,21 @@ export class DocxEditor {
|
|
|
3546
4482
|
// section-affecting edit (or a pagination toggle) leaves the bands describing the right one.
|
|
3547
4483
|
this.syncRegionToBody(this.activeBlock ?? undefined);
|
|
3548
4484
|
this.setupBlockDrag();
|
|
4485
|
+
this.gutter?.schedule();
|
|
4486
|
+
}
|
|
4487
|
+
/** Re-append the gutter after a mount emptied the container (mounts replace `innerHTML`). */
|
|
4488
|
+
readoptGutter() {
|
|
4489
|
+
this.gutter?.reattach();
|
|
3549
4490
|
}
|
|
3550
4491
|
}
|
|
4492
|
+
const commentGutterStyledDocuments = new WeakSet();
|
|
4493
|
+
function ensureCommentGutterStyles(doc) {
|
|
4494
|
+
if (commentGutterStyledDocuments.has(doc))
|
|
4495
|
+
return;
|
|
4496
|
+
commentGutterStyledDocuments.add(doc);
|
|
4497
|
+
const style = doc.createElement("style");
|
|
4498
|
+
style.dataset.docxodusCommentGutter = "true";
|
|
4499
|
+
style.textContent = COMMENT_GUTTER_CSS;
|
|
4500
|
+
(doc.head ?? doc.documentElement).appendChild(style);
|
|
4501
|
+
}
|
|
3551
4502
|
//# sourceMappingURL=editor.js.map
|