docxodus 7.1.0 → 9.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 +65 -24
- package/dist/docxodus.worker.js +2 -2
- package/dist/docxodus.worker.js.map +1 -1
- package/dist/editor-headerfooter.d.ts +166 -0
- package/dist/editor-headerfooter.d.ts.map +1 -0
- package/dist/editor-headerfooter.js +530 -0
- package/dist/editor-headerfooter.js.map +1 -0
- package/dist/editor-reconcile.d.ts +75 -0
- package/dist/editor-reconcile.d.ts.map +1 -0
- package/dist/editor-reconcile.js +125 -0
- package/dist/editor-reconcile.js.map +1 -0
- package/dist/editor.bundle.js +1861 -110
- package/dist/editor.d.ts +200 -6
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +868 -86
- package/dist/editor.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -4
- package/dist/index.js.map +1 -1
- package/dist/page-number-format.d.ts +22 -0
- package/dist/page-number-format.d.ts.map +1 -0
- package/dist/page-number-format.js +60 -0
- package/dist/page-number-format.js.map +1 -0
- package/dist/pagination.bundle.js +635 -37
- package/dist/pagination.d.ts +119 -0
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +706 -52
- package/dist/pagination.js.map +1 -1
- package/dist/react.d.ts +3 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +6 -4
- package/dist/react.js.map +1 -1
- package/dist/session.bundle.js +241 -4
- package/dist/session.d.ts +160 -6
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +202 -4
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +238 -14
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -11
- package/dist/types.js.map +1 -1
- package/dist/wasm/_framework/Docxodus.wasm +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.NonGeneric.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Specialized.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.wasm +0 -0
- package/dist/wasm/_framework/System.Console.wasm +0 -0
- package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.Expressions.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Memory.wasm +0 -0
- package/dist/wasm/_framework/System.Net.Http.wasm +0 -0
- package/dist/wasm/_framework/System.Net.Primitives.wasm +0 -0
- package/dist/wasm/_framework/System.ObjectModel.wasm +0 -0
- package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
- package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
- package/dist/wasm/_framework/System.Runtime.wasm +0 -0
- package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Encoding.Extensions.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
- package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
- package/dist/wasm/_framework/System.Threading.Thread.wasm +0 -0
- package/dist/wasm/_framework/System.Threading.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.ReaderWriter.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XDocument.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XPath.XDocument.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XPath.wasm +0 -0
- package/dist/wasm/_framework/System.wasm +0 -0
- package/dist/wasm/_framework/dotnet.boot.js +41 -41
- package/dist/wasm/_framework/dotnet.js +1 -1
- package/dist/wasm/_framework/dotnet.js.map +1 -1
- package/dist/wasm/_framework/dotnet.native.js +3 -3
- package/dist/wasm/_framework/dotnet.native.js.symbols +2102 -2101
- package/dist/wasm/_framework/dotnet.native.wasm +0 -0
- package/dist/wasm/_framework/dotnet.runtime.js +1 -1
- package/dist/wasm/_framework/dotnet.runtime.js.map +1 -1
- package/dist/wasm/index.html +2 -1
- package/package.json +17 -4
package/dist/editor.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* full fidelity, and save() is lossless for them.
|
|
18
18
|
*/
|
|
19
19
|
import { paginateHtml } from "./pagination.js";
|
|
20
|
+
import { HeaderFooterRegion } from "./editor-headerfooter.js";
|
|
21
|
+
import { diffUnits, needsRemount, unidOf } from "./editor-reconcile.js";
|
|
20
22
|
const EDITABLE_TAGS = new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
|
|
21
23
|
function fontWeightIsBold(w) {
|
|
22
24
|
if (w === "bold" || w === "bolder")
|
|
@@ -30,8 +32,9 @@ function escapeInlineMarkdown(text) {
|
|
|
30
32
|
}
|
|
31
33
|
function collectInlineSegments(node, out) {
|
|
32
34
|
node.childNodes.forEach((child) => {
|
|
33
|
-
// Skip generated list
|
|
34
|
-
|
|
35
|
+
// Skip converter-generated chrome (list markers, note citation markers, note backrefs) —
|
|
36
|
+
// it isn't part of the paragraph's content and must never be committed as text.
|
|
37
|
+
if (isGeneratedChrome(child))
|
|
35
38
|
return;
|
|
36
39
|
if (child.nodeType === 3 /* TEXT_NODE */) {
|
|
37
40
|
const text = child.textContent ?? "";
|
|
@@ -111,11 +114,27 @@ export function serializeInlineMarkdown(block) {
|
|
|
111
114
|
function isListBlock(block) {
|
|
112
115
|
return !!block.querySelector(":scope > [data-list-marker]");
|
|
113
116
|
}
|
|
114
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Inline chrome the CONVERTER generates that is not part of a paragraph's run text: list
|
|
119
|
+
* number/bullet markers, footnote/endnote citation markers
|
|
120
|
+
* (`<a class="footnote-ref"><sup>1</sup></a>`), and the note backrefs (`↩`).
|
|
121
|
+
*
|
|
122
|
+
* The session's run text contains none of it. A citation is a zero-width
|
|
123
|
+
* `w:footnoteReference` — the displayed number is computed by the renderer from document order —
|
|
124
|
+
* so every character of chrome the editor fails to exclude shifts its content-offset space away
|
|
125
|
+
* from the session's. Each omission has its own failure mode: excluded from offsets but not from
|
|
126
|
+
* serialization and the display number gets COMMITTED as literal text (destroying the citation
|
|
127
|
+
* run); left editable and the user can delete a marker outright, orphaning the note.
|
|
128
|
+
*/
|
|
129
|
+
const GENERATED_CHROME_SELECTOR = '[data-list-marker], a.footnote-ref, a.endnote-ref, a[class$="-backref"]';
|
|
130
|
+
function isGeneratedChrome(node) {
|
|
131
|
+
return node?.nodeType === 1 && !!node.matches?.(GENERATED_CHROME_SELECTOR);
|
|
132
|
+
}
|
|
133
|
+
/** True if `node` is, or is inside, generated chrome (not editable content). */
|
|
115
134
|
function isInMarker(node) {
|
|
116
135
|
let el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
|
|
117
136
|
while (el) {
|
|
118
|
-
if (el
|
|
137
|
+
if (isGeneratedChrome(el))
|
|
119
138
|
return true;
|
|
120
139
|
el = el.parentElement;
|
|
121
140
|
}
|
|
@@ -221,11 +240,36 @@ function blockContentText(block) {
|
|
|
221
240
|
* trimmed length keeps the split offset consistent with what was committed.
|
|
222
241
|
*/
|
|
223
242
|
function trimmedSplitOffset(block, domOffset) {
|
|
224
|
-
const
|
|
225
|
-
const leading = content.length - content.replace(/^\s+/, "").length;
|
|
226
|
-
const trimmedLen = content.trim().length;
|
|
243
|
+
const { leading, trimmedLen } = trimBounds(block);
|
|
227
244
|
return Math.max(0, Math.min(domOffset - Math.min(domOffset, leading), trimmedLen));
|
|
228
245
|
}
|
|
246
|
+
/** How far `block`'s DOM content text is offset from, and longer than, its committed form. */
|
|
247
|
+
function trimBounds(block) {
|
|
248
|
+
const content = blockContentText(block);
|
|
249
|
+
return {
|
|
250
|
+
leading: content.length - content.replace(/^\s+/, "").length,
|
|
251
|
+
trimmedLen: content.trim().length,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Map a DOM content SPAN into the run-text space the session holds after a commit — the span
|
|
256
|
+
* analogue of {@link trimmedSplitOffset}, and for the same reason.
|
|
257
|
+
*
|
|
258
|
+
* A block rendered with edge whitespace produces a span longer than the text the commit stores:
|
|
259
|
+
* an empty header/footer story renders as a lone NBSP placeholder, so typing into it leaves a
|
|
260
|
+
* trailing NBSP that `serializeInlineMarkdown(...).trim()` removes (JS `trim()` treats U+00A0 as
|
|
261
|
+
* whitespace). "Select all, then Bold" then asks `ApplyFormat` for [0, len+1) — one past the
|
|
262
|
+
* committed end — and the op is REJECTED with OffsetOutOfRange, so the format silently does
|
|
263
|
+
* nothing. The demo's format buttons preventDefault on mousedown to keep the selection alive,
|
|
264
|
+
* which is exactly the path that computes the span before `syncBlock` commits, so this is the
|
|
265
|
+
* ordinary case rather than an edge one.
|
|
266
|
+
*/
|
|
267
|
+
function trimmedSpan(block, span) {
|
|
268
|
+
const { leading, trimmedLen } = trimBounds(block);
|
|
269
|
+
const start = Math.max(0, Math.min(span.start - Math.min(span.start, leading), trimmedLen));
|
|
270
|
+
const end = Math.max(start, Math.min(span.start + span.length - leading, trimmedLen));
|
|
271
|
+
return { start, length: end - start };
|
|
272
|
+
}
|
|
229
273
|
/**
|
|
230
274
|
* DOM (node, offset) for content offset `offset` within `el` — the same content-offset
|
|
231
275
|
* space as contentOffsetOf (marker text and injected bidi marks excluded). Clamps past-end
|
|
@@ -314,7 +358,10 @@ function selectionSpanIn(block) {
|
|
|
314
358
|
return null;
|
|
315
359
|
const start = contentOffsetOf(block, range.startContainer, range.startOffset);
|
|
316
360
|
const end = contentOffsetOf(block, range.endContainer, range.endOffset);
|
|
317
|
-
|
|
361
|
+
// Normalized into the committed run-text space: every consumer feeds this straight to a
|
|
362
|
+
// DocxSession op, which rejects a span that overshoots the committed length.
|
|
363
|
+
const span = trimmedSpan(block, { start: Math.min(start, end), length: Math.abs(end - start) });
|
|
364
|
+
return span.length > 0 ? span : null;
|
|
318
365
|
}
|
|
319
366
|
/** Restore a content-text selection spanning [start, start+length) within `el` (skips markers). */
|
|
320
367
|
function selectRange(el, start, length) {
|
|
@@ -396,7 +443,10 @@ function completeArgs(bytes, cssPrefix, fabricate, paginated, scale) {
|
|
|
396
443
|
bytes, "Document", cssPrefix, fabricate, "", -1, "comment-",
|
|
397
444
|
/* paginationMode */ paginated ? 1 : 0, /* paginationScale */ scale, "page-",
|
|
398
445
|
false, 0, "annot-",
|
|
399
|
-
|
|
446
|
+
// Footnotes/endnotes ON: they are document content, and the editor makes the rendered note
|
|
447
|
+
// paragraphs editable. Must stay in step with DocxSessionOps.RenderHtml (the remount path),
|
|
448
|
+
// whose output has to match this first paint byte-for-byte.
|
|
449
|
+
/* renderFootnotesAndEndnotes */ true, /* renderHeadersAndFooters */ paginated,
|
|
400
450
|
false, true, true, false, null, /* stampAnchors */ true,
|
|
401
451
|
];
|
|
402
452
|
}
|
|
@@ -422,6 +472,11 @@ export class DocxEditor {
|
|
|
422
472
|
* block, and cleared when a caret is collapsed inside a block (so it never goes stale).
|
|
423
473
|
*/
|
|
424
474
|
this.lastSelection = null;
|
|
475
|
+
/** The docked header/footer bands, when `options.headerFooter` is on. */
|
|
476
|
+
this.region = null;
|
|
477
|
+
/** Why the last reconcile() fell back to a full remount (null = it patched). For
|
|
478
|
+
* diagnostics/specs; not part of the public API. */
|
|
479
|
+
this.lastReconcileFallback = null;
|
|
425
480
|
/** Track the last meaningful selection so focus-stealing toolbar controls can still target it. */
|
|
426
481
|
this.onSelectionChange = () => {
|
|
427
482
|
if (this.closed)
|
|
@@ -452,13 +507,46 @@ export class DocxEditor {
|
|
|
452
507
|
if (typeof document !== "undefined")
|
|
453
508
|
document.addEventListener("selectionchange", this.onSelectionChange);
|
|
454
509
|
}
|
|
455
|
-
/** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor.
|
|
510
|
+
/** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor.
|
|
511
|
+
* Fenced by `container`, not `editRoot`, so header/footer band blocks — which live outside the
|
|
512
|
+
* body edit root by design — also register. The fence still rejects other editors on the page. */
|
|
456
513
|
editableBlockOf(node) {
|
|
457
514
|
if (!node)
|
|
458
515
|
return null;
|
|
459
516
|
const start = node.nodeType === 1 ? node : node.parentElement;
|
|
460
517
|
const block = start?.closest('[data-anchor][contenteditable="true"]') ?? null;
|
|
461
|
-
return block && this.
|
|
518
|
+
return block && this.container.contains(block) ? block : null;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* The root owning `el`'s sibling block list: its header/footer band's story container, else the
|
|
522
|
+
* body edit root. Keeps a multi-block selection from spanning a band and the body, whose block
|
|
523
|
+
* lists belong to different OOXML parts.
|
|
524
|
+
*/
|
|
525
|
+
ownerRoot(el) {
|
|
526
|
+
return this.region?.blockRootOf(el) ?? this.editRoot;
|
|
527
|
+
}
|
|
528
|
+
/** True when `el` is a header/footer band block rather than a body block. */
|
|
529
|
+
isBandBlock(el) {
|
|
530
|
+
return !!this.region?.contains(el);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Repaint after an edit to `block` that would otherwise remount the whole document: a band
|
|
534
|
+
* repaints only itself (a story is one to three paragraphs), leaving the body DOM — and the
|
|
535
|
+
* user's place in it — untouched; a body edit reconciles incrementally. `forceRemount` is
|
|
536
|
+
* for ops whose repaint provably needs whole-document context the reconciler cannot see:
|
|
537
|
+
* list membership/level changes (sibling numbering shifts without sibling XML changing)
|
|
538
|
+
* and border-div regrouping (HR insert, clearBorders).
|
|
539
|
+
*/
|
|
540
|
+
refreshAfter(block, focusIndex, caretAtEnd = false, forceRemount = false) {
|
|
541
|
+
const band = this.region?.bandOf(block);
|
|
542
|
+
if (band) {
|
|
543
|
+
this.region.refresh(this.region.whichOf(band));
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (forceRemount)
|
|
547
|
+
this.remount(focusIndex, caretAtEnd);
|
|
548
|
+
else
|
|
549
|
+
this.reconcile(focusIndex, caretAtEnd);
|
|
462
550
|
}
|
|
463
551
|
/** Open a document, render it into `container`, and wire up editing. */
|
|
464
552
|
static open(container, bytes, exports, options = {}) {
|
|
@@ -468,20 +556,26 @@ export class DocxEditor {
|
|
|
468
556
|
editable: options.editable ?? true,
|
|
469
557
|
paginated: options.paginated ?? false,
|
|
470
558
|
scale: options.scale ?? 1,
|
|
559
|
+
headerFooter: options.headerFooter ?? false,
|
|
471
560
|
onEdit: options.onEdit,
|
|
472
561
|
};
|
|
473
|
-
// persistAnchorIds
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
|
|
562
|
+
// NOT persistAnchorIds: that setting applies to every Save on the session, so it put the
|
|
563
|
+
// projector's Unid bookkeeping into the bytes the USER downloads — ~6x the file size for
|
|
564
|
+
// attributes no renderer reads. Only the remount's re-render needs id stability across a
|
|
565
|
+
// save/re-render hop, and it asks for that per call via SaveWithAnchorIds.
|
|
566
|
+
// emitMarkdownPatch off: the editor re-renders from HTML, never from markdown patches,
|
|
567
|
+
// so paying a whole-document re-projection per op would be dead weight.
|
|
568
|
+
const handle = exports.DocxSessionBridge.OpenSession(bytes, '{"emitMarkdownPatch":false}');
|
|
478
569
|
const editor = new DocxEditor(container, exports, handle, opts);
|
|
479
570
|
editor.refreshAnchorMap();
|
|
571
|
+
if (opts.headerFooter)
|
|
572
|
+
editor.createRegion();
|
|
480
573
|
const fullHtml = exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, opts.cssPrefix, opts.fabricateClasses, opts.paginated, opts.scale));
|
|
481
574
|
if (opts.paginated)
|
|
482
575
|
editor.mountPaginated(fullHtml);
|
|
483
576
|
else
|
|
484
577
|
editor.mountHtml(fullHtml);
|
|
578
|
+
editor.syncRegionToBody();
|
|
485
579
|
return editor;
|
|
486
580
|
}
|
|
487
581
|
/**
|
|
@@ -527,28 +621,126 @@ export class DocxEditor {
|
|
|
527
621
|
if (this.closed)
|
|
528
622
|
throw new Error("DocxEditor is closed");
|
|
529
623
|
}
|
|
530
|
-
/**
|
|
624
|
+
/**
|
|
625
|
+
* Rebuild unid → full-anchor-id from the live session projection.
|
|
626
|
+
*
|
|
627
|
+
* Unids are CONTENT-ADDRESSED, so blocks with identical content in DIFFERENT parts collide —
|
|
628
|
+
* e.g. a document with empty default/first/even header stories has one unid for several
|
|
629
|
+
* header parts. A collision must resolve to the BODY entry: body blocks carry only
|
|
630
|
+
* `data-anchor` (the bare unid) and have nothing else to resolve through, whereas a
|
|
631
|
+
* header/footer band block carries its full anchor in `data-hf-anchor` and is resolved from
|
|
632
|
+
* that (see `anchorIdOf`). Letting a non-body scope win here would silently redirect a body
|
|
633
|
+
* edit into a header part.
|
|
634
|
+
*/
|
|
531
635
|
refreshAnchorMap() {
|
|
532
|
-
const
|
|
636
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
637
|
+
// ListAnchors returns the same {anchorIndex} object WITHOUT the markdown payload —
|
|
638
|
+
// marshaling the full projection (a couple hundred KB on a real document) made
|
|
639
|
+
// this refresh the single biggest term of every incremental repaint.
|
|
640
|
+
const raw = typeof bridge.ListAnchors === "function"
|
|
641
|
+
? bridge.ListAnchors(this.handle)
|
|
642
|
+
: bridge.Project(this.handle);
|
|
643
|
+
const proj = JSON.parse(raw);
|
|
533
644
|
this.unidToFullId.clear();
|
|
645
|
+
const bodyOwned = new Set();
|
|
534
646
|
for (const [fullId, target] of Object.entries(proj.anchorIndex)) {
|
|
647
|
+
const isBody = target.scope === "body";
|
|
648
|
+
if (!isBody && bodyOwned.has(target.unid))
|
|
649
|
+
continue;
|
|
535
650
|
this.unidToFullId.set(target.unid, fullId);
|
|
651
|
+
if (isBody)
|
|
652
|
+
bodyOwned.add(target.unid);
|
|
536
653
|
}
|
|
537
654
|
}
|
|
655
|
+
/**
|
|
656
|
+
* The full `kind:scope:unid` anchor for a rendered block. A header/footer band block carries
|
|
657
|
+
* its own — the unid map cannot disambiguate one, since several parts' story paragraphs can
|
|
658
|
+
* share a content-addressed unid (a real Word document with empty default/first/even stories
|
|
659
|
+
* does exactly that, and a unid-keyed lookup would land the edit in the wrong header part).
|
|
660
|
+
*/
|
|
661
|
+
anchorIdOf(el) {
|
|
662
|
+
const stamped = el.getAttribute("data-hf-anchor");
|
|
663
|
+
if (stamped)
|
|
664
|
+
return stamped;
|
|
665
|
+
const unid = el.getAttribute("data-anchor");
|
|
666
|
+
return unid ? this.unidToFullId.get(unid) : undefined;
|
|
667
|
+
}
|
|
668
|
+
/** Build the header/footer region (called once, before the first mount). */
|
|
669
|
+
createRegion() {
|
|
670
|
+
this.region = new HeaderFooterRegion(this.exports.DocxSessionBridge, this.handle, { cssPrefix: this.options.cssPrefix, fabricateClasses: this.options.fabricateClasses }, {
|
|
671
|
+
wireBlock: (el) => this.wireBlock(el),
|
|
672
|
+
refreshAnchorMap: () => this.refreshAnchorMap(),
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Point the bands at the section governing the current focus (or the first body block).
|
|
677
|
+
* Called after every mount, and on focus of a body block, so a multi-section document shows
|
|
678
|
+
* the stories that actually apply where the caret is.
|
|
679
|
+
*/
|
|
680
|
+
syncRegionToBody(fromBlock) {
|
|
681
|
+
if (!this.region)
|
|
682
|
+
return;
|
|
683
|
+
const block = fromBlock ?? this.editableList()[0];
|
|
684
|
+
const unid = block?.getAttribute("data-anchor");
|
|
685
|
+
const id = unid ? this.unidToFullId.get(unid) : undefined;
|
|
686
|
+
this.region.syncToBody(id ?? null);
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Insert the bands around `bodyRoot` and make `bodyRoot` the edit root. Band blocks must stay
|
|
690
|
+
* OUT of the edit root: `editableList()`/`blockIndex()` enumerate it to compute remount focus
|
|
691
|
+
* indices, and band blocks in that list would shift every index.
|
|
692
|
+
*/
|
|
693
|
+
dockBands(bodyRoot) {
|
|
694
|
+
if (!this.region)
|
|
695
|
+
return;
|
|
696
|
+
bodyRoot.before(this.region.headerBand);
|
|
697
|
+
bodyRoot.after(this.region.footerBand);
|
|
698
|
+
this.region.refreshAll();
|
|
699
|
+
}
|
|
538
700
|
/** Continuous (non-paginated) mount: inject the converter's styles + body, wire blocks. */
|
|
539
701
|
mountHtml(fullHtml) {
|
|
540
702
|
const parsed = new DOMParser().parseFromString(fullHtml, "text/html");
|
|
541
703
|
const styles = Array.from(parsed.querySelectorAll("style"))
|
|
542
704
|
.map((s) => s.outerHTML)
|
|
543
705
|
.join("");
|
|
544
|
-
this.
|
|
545
|
-
|
|
706
|
+
if (!this.region) {
|
|
707
|
+
this.container.innerHTML = styles + parsed.body.innerHTML;
|
|
708
|
+
this.editRoot = this.container;
|
|
709
|
+
if (this.options.editable)
|
|
710
|
+
this.wireBlocks(this.container);
|
|
711
|
+
this.stampPlanState();
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
// With bands docked, the body flow needs its own wrapper to be the edit root.
|
|
715
|
+
this.container.innerHTML = styles;
|
|
716
|
+
const flow = document.createElement("div");
|
|
717
|
+
flow.className = "docx-body-flow";
|
|
718
|
+
flow.innerHTML = parsed.body.innerHTML;
|
|
719
|
+
this.container.appendChild(flow);
|
|
720
|
+
this.editRoot = flow;
|
|
546
721
|
if (this.options.editable)
|
|
547
|
-
this.wireBlocks(
|
|
722
|
+
this.wireBlocks(flow);
|
|
723
|
+
this.stampPlanState();
|
|
724
|
+
this.dockBands(flow);
|
|
548
725
|
}
|
|
549
726
|
/** Paginated mount: flow blocks into page boxes via pagination.ts, wire the page clones. */
|
|
550
727
|
mountPaginated(fullHtml) {
|
|
551
|
-
|
|
728
|
+
// With bands docked, pagination writes into its own wrapper so the bands can sit outside the
|
|
729
|
+
// page stack (and so pagination's innerHTML reset can never eat them).
|
|
730
|
+
let target = this.container;
|
|
731
|
+
if (this.region) {
|
|
732
|
+
this.container.innerHTML = "";
|
|
733
|
+
target = document.createElement("div");
|
|
734
|
+
target.className = "docx-body-flow";
|
|
735
|
+
this.container.appendChild(target);
|
|
736
|
+
}
|
|
737
|
+
// Fragmented paragraphs intentionally have only one addressable head and
|
|
738
|
+
// are therefore unsuitable for the editor's one-block editing model.
|
|
739
|
+
paginateHtml(fullHtml, target, {
|
|
740
|
+
scale: this.options.scale,
|
|
741
|
+
cssPrefix: "page-",
|
|
742
|
+
fragmentParagraphs: false,
|
|
743
|
+
});
|
|
552
744
|
// pagination.ts measures the hidden #pagination-staging subtree ONCE, then flows CLONES of its
|
|
553
745
|
// blocks into the visible page boxes. Leaving staging in the live DOM is a trap: every
|
|
554
746
|
// data-anchor exists twice (staging + page-box copy), so document.querySelector('[data-anchor]')
|
|
@@ -557,10 +749,14 @@ export class DocxEditor {
|
|
|
557
749
|
// is a transient measurement scaffold; drop it so the page-box copies are the single source of
|
|
558
750
|
// truth. A remount (setPaginated, list/undo edits) rebuilds staging fresh from the live session.
|
|
559
751
|
this.container.querySelector("#pagination-staging, .page-staging")?.remove();
|
|
560
|
-
const pageRoot =
|
|
752
|
+
const pageRoot = target.querySelector("#pagination-container") ?? target;
|
|
561
753
|
this.editRoot = pageRoot;
|
|
562
754
|
if (this.options.editable)
|
|
563
755
|
this.wireBlocks(pageRoot);
|
|
756
|
+
// The page boxes render their own (read-only) header/footer margins; the editable bands dock
|
|
757
|
+
// around the page stack, so there is still exactly one addressable node per story paragraph.
|
|
758
|
+
if (this.region)
|
|
759
|
+
this.dockBands(target);
|
|
564
760
|
}
|
|
565
761
|
wireBlocks(root) {
|
|
566
762
|
root.querySelectorAll("[data-anchor]").forEach((el) => this.wireBlock(el));
|
|
@@ -573,18 +769,34 @@ export class DocxEditor {
|
|
|
573
769
|
// table-cell paragraphs (the projection indexes them), so cell text IS editable — but structural
|
|
574
770
|
// keys are kept inert inside a cell (see onKeydown / GAP3) so single-block editing can't corrupt
|
|
575
771
|
// table structure. Anything the projection does not index (unstamped content) stays read-only.
|
|
576
|
-
|
|
772
|
+
// A band block is authoritative via its stamped `data-hf-anchor` even when the unid map
|
|
773
|
+
// resolves that unid to a different part (content-addressed unids collide across parts).
|
|
774
|
+
if (!unid || !this.anchorIdOf(el))
|
|
577
775
|
return;
|
|
578
776
|
el.setAttribute("contenteditable", "true");
|
|
579
|
-
// Generated list
|
|
580
|
-
// caret out
|
|
581
|
-
|
|
777
|
+
// Generated chrome (list number/bullet, footnote/endnote citation markers, note backrefs) is
|
|
778
|
+
// not editable content — keep the caret out so offsets stay aligned with the run text, and so
|
|
779
|
+
// a citation marker can't be deleted directly (which would orphan its note definition).
|
|
780
|
+
el.querySelectorAll(GENERATED_CHROME_SELECTOR)
|
|
781
|
+
.forEach((m) => m.setAttribute("contenteditable", "false"));
|
|
582
782
|
// Baseline for the commit diff: CONTENT text (list markers + injected bidi marks excluded),
|
|
583
783
|
// matching the session's flat run-text offset space.
|
|
584
784
|
el.dataset.committedText = blockContentText(el);
|
|
585
|
-
el.addEventListener("focus", () => {
|
|
785
|
+
el.addEventListener("focus", () => {
|
|
786
|
+
this.activeBlock = el;
|
|
787
|
+
// Follow the caret's section so a cover-page-plus-body document shows the stories that
|
|
788
|
+
// actually apply. Focusing a BAND block must not re-sync: it has no governing section of
|
|
789
|
+
// its own, and re-resolving would clobber the user's kind selection.
|
|
790
|
+
if (this.region && !this.isBandBlock(el))
|
|
791
|
+
this.syncRegionToBody(el);
|
|
792
|
+
});
|
|
586
793
|
el.addEventListener("blur", () => this.commitBlock(el));
|
|
587
794
|
el.addEventListener("keydown", (ev) => this.onKeydown(el, ev));
|
|
795
|
+
// A band block re-rendered by an incremental swap is a fresh DOM node; re-adopt it (with the
|
|
796
|
+
// anchor its caller already stamped) so the band chrome can still address it.
|
|
797
|
+
const stamped = el.getAttribute("data-hf-anchor");
|
|
798
|
+
if (stamped && this.region?.contains(el))
|
|
799
|
+
this.region.adoptBlock(el, stamped);
|
|
588
800
|
}
|
|
589
801
|
/**
|
|
590
802
|
* Replace `oldEl` with `newNodes`, suppressing the re-entrant blur→commit that removing a focused
|
|
@@ -619,7 +831,7 @@ export class DocxEditor {
|
|
|
619
831
|
const unid = el.getAttribute("data-anchor");
|
|
620
832
|
if (!unid)
|
|
621
833
|
return;
|
|
622
|
-
const fullId = this.
|
|
834
|
+
const fullId = this.anchorIdOf(el);
|
|
623
835
|
if (!fullId)
|
|
624
836
|
return;
|
|
625
837
|
const result = this.commitTextChange(el, fullId);
|
|
@@ -651,12 +863,22 @@ export class DocxEditor {
|
|
|
651
863
|
const html = this.exports.DocxSessionBridge.RenderBlockHtml(this.handle, newAnchor, this.options.cssPrefix, this.options.fabricateClasses);
|
|
652
864
|
if (html.charCodeAt(0) !== 0x7b /* not an error object */) {
|
|
653
865
|
const fresh = new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
|
|
866
|
+
const inBand = this.isBandBlock(el);
|
|
654
867
|
if (fresh && this.replaceNode(el, fresh)) {
|
|
655
|
-
|
|
656
|
-
|
|
868
|
+
// Band blocks resolve through `data-hf-anchor`; their unid can collide with another
|
|
869
|
+
// part's, so writing it into the map would corrupt that entry (see anchorIdOf).
|
|
870
|
+
if (inBand) {
|
|
871
|
+
this.region.adoptBlock(fresh, newAnchor);
|
|
872
|
+
}
|
|
873
|
+
else {
|
|
874
|
+
this.unidToFullId.delete(unid);
|
|
875
|
+
this.unidToFullId.set(newUnid, newAnchor);
|
|
876
|
+
}
|
|
657
877
|
this.wireBlock(fresh);
|
|
658
878
|
if (this.activeBlock === el)
|
|
659
879
|
this.activeBlock = fresh; // keep ribbon target valid
|
|
880
|
+
// The throwaway render numbers citation markers from 1 — repair in place.
|
|
881
|
+
this.maybeRenumberNotes(fresh);
|
|
660
882
|
}
|
|
661
883
|
}
|
|
662
884
|
this.options.onEdit?.({ anchorId: newAnchor, unid: newUnid });
|
|
@@ -748,7 +970,7 @@ export class DocxEditor {
|
|
|
748
970
|
const unid = el.getAttribute("data-anchor");
|
|
749
971
|
if (rawOffset == null || !unid)
|
|
750
972
|
return;
|
|
751
|
-
let fullId = this.
|
|
973
|
+
let fullId = this.anchorIdOf(el);
|
|
752
974
|
if (!fullId)
|
|
753
975
|
return;
|
|
754
976
|
// The session commits trimmed text, so map the DOM caret offset into the trimmed run-text
|
|
@@ -770,7 +992,7 @@ export class DocxEditor {
|
|
|
770
992
|
// document re-render so numbering continues / border <div>s regroup correctly. An in-place node
|
|
771
993
|
// swap would leave the new paragraph inside the old border div (the rule's line under its text).
|
|
772
994
|
if (this.affectsList(res) || wrappedInBorder) {
|
|
773
|
-
this.
|
|
995
|
+
this.refreshAfter(el, idx + 1, false);
|
|
774
996
|
this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
|
|
775
997
|
return;
|
|
776
998
|
}
|
|
@@ -780,11 +1002,19 @@ export class DocxEditor {
|
|
|
780
1002
|
return;
|
|
781
1003
|
// el is the focused block — replaceNode guards the re-entrant blur→commit and tolerates a
|
|
782
1004
|
// node detached mid-focus-transfer; replacing with both new blocks at once keeps them adjacent.
|
|
1005
|
+
const inBand = this.isBandBlock(el);
|
|
783
1006
|
if (!this.replaceNode(el, firstEl, secondEl))
|
|
784
1007
|
return;
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1008
|
+
// Band blocks resolve through `data-hf-anchor` (their unids can collide across parts).
|
|
1009
|
+
if (inBand) {
|
|
1010
|
+
this.region.adoptBlock(firstEl, first.id);
|
|
1011
|
+
this.region.adoptBlock(secondEl, second.id);
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
this.unidToFullId.delete(unid);
|
|
1015
|
+
this.unidToFullId.set(first.unid, first.id);
|
|
1016
|
+
this.unidToFullId.set(second.unid, second.id);
|
|
1017
|
+
}
|
|
788
1018
|
this.wireBlock(firstEl);
|
|
789
1019
|
this.wireBlock(secondEl);
|
|
790
1020
|
placeCaretAtOffset(secondEl, 0);
|
|
@@ -796,8 +1026,8 @@ export class DocxEditor {
|
|
|
796
1026
|
const thisUnid = el.getAttribute("data-anchor");
|
|
797
1027
|
if (!prevUnid || !thisUnid)
|
|
798
1028
|
return;
|
|
799
|
-
let prevId = this.
|
|
800
|
-
let thisId = this.
|
|
1029
|
+
let prevId = this.anchorIdOf(prev);
|
|
1030
|
+
let thisId = this.anchorIdOf(el);
|
|
801
1031
|
if (!prevId || !thisId)
|
|
802
1032
|
return;
|
|
803
1033
|
const prevIdx = this.blockIndex(prev); // capture before the op
|
|
@@ -816,7 +1046,7 @@ export class DocxEditor {
|
|
|
816
1046
|
// Merging list items renumbers the list, and merging across a border <div> boundary changes the
|
|
817
1047
|
// border grouping — both need a whole-document re-render (caret at the merge boundary).
|
|
818
1048
|
if (this.affectsList(res) || wrappedInBorder) {
|
|
819
|
-
this.
|
|
1049
|
+
this.refreshAfter(el, prevIdx, true);
|
|
820
1050
|
this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
|
|
821
1051
|
return;
|
|
822
1052
|
}
|
|
@@ -824,12 +1054,19 @@ export class DocxEditor {
|
|
|
824
1054
|
if (!mergedEl)
|
|
825
1055
|
return;
|
|
826
1056
|
// prev may be focused — replaceNode guards re-entrancy and tolerates a detached node.
|
|
1057
|
+
const inBand = this.isBandBlock(prev);
|
|
827
1058
|
if (!this.replaceNode(prev, mergedEl))
|
|
828
1059
|
return;
|
|
829
1060
|
el.remove();
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1061
|
+
// Band blocks resolve through `data-hf-anchor` (their unids can collide across parts).
|
|
1062
|
+
if (inBand) {
|
|
1063
|
+
this.region.adoptBlock(mergedEl, merged.id);
|
|
1064
|
+
}
|
|
1065
|
+
else {
|
|
1066
|
+
this.unidToFullId.delete(prevUnid);
|
|
1067
|
+
this.unidToFullId.delete(thisUnid);
|
|
1068
|
+
this.unidToFullId.set(merged.unid, merged.id);
|
|
1069
|
+
}
|
|
833
1070
|
this.wireBlock(mergedEl);
|
|
834
1071
|
placeCaretAtOffset(mergedEl, caret);
|
|
835
1072
|
this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
|
|
@@ -896,9 +1133,9 @@ export class DocxEditor {
|
|
|
896
1133
|
return null;
|
|
897
1134
|
return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
|
|
898
1135
|
}
|
|
899
|
-
/** The editable block immediately before `el`
|
|
1136
|
+
/** The editable block immediately before `el` within its own root, or null. */
|
|
900
1137
|
previousEditable(el) {
|
|
901
|
-
const all = Array.from(this.
|
|
1138
|
+
const all = Array.from(this.ownerRoot(el).querySelectorAll('[data-anchor][contenteditable="true"]'));
|
|
902
1139
|
const i = all.indexOf(el);
|
|
903
1140
|
return i > 0 ? all[i - 1] : null;
|
|
904
1141
|
}
|
|
@@ -918,7 +1155,10 @@ export class DocxEditor {
|
|
|
918
1155
|
* A collapsed or single-block selection yields just the active block. */
|
|
919
1156
|
selectedBlocks() {
|
|
920
1157
|
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
921
|
-
|
|
1158
|
+
// Enumerate the ACTIVE block's own root — a band's story container, else the body edit root —
|
|
1159
|
+
// so a selection can never span a band and the body (different OOXML parts).
|
|
1160
|
+
const root = this.activeBlock ? this.ownerRoot(this.activeBlock) : this.editRoot;
|
|
1161
|
+
const all = Array.from(root.querySelectorAll('[data-anchor][contenteditable="true"]'));
|
|
922
1162
|
if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
|
|
923
1163
|
const range = sel.getRangeAt(0);
|
|
924
1164
|
const hit = all.filter((b) => {
|
|
@@ -948,16 +1188,18 @@ export class DocxEditor {
|
|
|
948
1188
|
const hasEnd = block.contains(range.endContainer);
|
|
949
1189
|
if (hasStart && hasEnd)
|
|
950
1190
|
return selectionSpanIn(block);
|
|
1191
|
+
// Partial/whole slices are normalized into the committed run-text space for the same reason
|
|
1192
|
+
// selectionSpanIn is (see trimmedSpan): a session op rejects a span past the committed end.
|
|
951
1193
|
const contentLen = blockContentText(block).length;
|
|
952
1194
|
if (hasStart) {
|
|
953
1195
|
const start = contentOffsetOf(block, range.startContainer, range.startOffset);
|
|
954
|
-
return { start, length: Math.max(0, contentLen - start) };
|
|
1196
|
+
return trimmedSpan(block, { start, length: Math.max(0, contentLen - start) });
|
|
955
1197
|
}
|
|
956
1198
|
if (hasEnd) {
|
|
957
1199
|
const end = contentOffsetOf(block, range.endContainer, range.endOffset);
|
|
958
|
-
return { start: 0, length: end };
|
|
1200
|
+
return trimmedSpan(block, { start: 0, length: end });
|
|
959
1201
|
}
|
|
960
|
-
return { start: 0, length: contentLen }; // fully-spanned middle block
|
|
1202
|
+
return trimmedSpan(block, { start: 0, length: contentLen }); // fully-spanned middle block
|
|
961
1203
|
}
|
|
962
1204
|
/** Apply an inline ApplyFormat op to each block's slice of the selection, then reconcile
|
|
963
1205
|
* the DOM incrementally (see {@link finishMultiBlockOp} — a full remount costs a whole-
|
|
@@ -1005,7 +1247,7 @@ export class DocxEditor {
|
|
|
1005
1247
|
return {
|
|
1006
1248
|
block: b,
|
|
1007
1249
|
unid,
|
|
1008
|
-
fullId:
|
|
1250
|
+
fullId: this.anchorIdOf(b),
|
|
1009
1251
|
span: this.blockSpanForSelection(b),
|
|
1010
1252
|
res: null,
|
|
1011
1253
|
};
|
|
@@ -1029,7 +1271,7 @@ export class DocxEditor {
|
|
|
1029
1271
|
// need a reflow — keep the (pre-existing) full remount there until M4 lands a scoped
|
|
1030
1272
|
// re-paginate. Continuous mode reconciles incrementally.
|
|
1031
1273
|
if (forceRemount || this.options.paginated || edited.some((t) => this.affectsList(t.res))) {
|
|
1032
|
-
this.
|
|
1274
|
+
this.refreshAfter(edited[0].block, -1, false);
|
|
1033
1275
|
return;
|
|
1034
1276
|
}
|
|
1035
1277
|
const swapped = [];
|
|
@@ -1081,7 +1323,7 @@ export class DocxEditor {
|
|
|
1081
1323
|
const unid = block.getAttribute("data-anchor");
|
|
1082
1324
|
if (!unid)
|
|
1083
1325
|
return;
|
|
1084
|
-
let fullId = this.
|
|
1326
|
+
let fullId = this.anchorIdOf(block);
|
|
1085
1327
|
if (!fullId)
|
|
1086
1328
|
return;
|
|
1087
1329
|
const span = selectionSpanIn(block);
|
|
@@ -1095,7 +1337,7 @@ export class DocxEditor {
|
|
|
1095
1337
|
if (!res.success)
|
|
1096
1338
|
return;
|
|
1097
1339
|
if (this.affectsList(res)) {
|
|
1098
|
-
this.
|
|
1340
|
+
this.refreshAfter(block, this.blockIndex(block), false);
|
|
1099
1341
|
return;
|
|
1100
1342
|
}
|
|
1101
1343
|
const fresh = this.swapBlock(block, unid, res.modified?.[0]);
|
|
@@ -1119,7 +1361,7 @@ export class DocxEditor {
|
|
|
1119
1361
|
const unid = block.getAttribute("data-anchor");
|
|
1120
1362
|
if (!unid)
|
|
1121
1363
|
return;
|
|
1122
|
-
let fullId = this.
|
|
1364
|
+
let fullId = this.anchorIdOf(block);
|
|
1123
1365
|
if (!fullId)
|
|
1124
1366
|
return;
|
|
1125
1367
|
// Use the live selection; if the font-size combobox stole focus and collapsed it, fall back to
|
|
@@ -1132,7 +1374,7 @@ export class DocxEditor {
|
|
|
1132
1374
|
if (!res.success)
|
|
1133
1375
|
return;
|
|
1134
1376
|
if (this.affectsList(res)) {
|
|
1135
|
-
this.
|
|
1377
|
+
this.refreshAfter(block, this.blockIndex(block), false);
|
|
1136
1378
|
return;
|
|
1137
1379
|
}
|
|
1138
1380
|
const fresh = this.swapBlock(block, unid, res.modified?.[0]);
|
|
@@ -1158,7 +1400,7 @@ export class DocxEditor {
|
|
|
1158
1400
|
const unid = block.getAttribute("data-anchor");
|
|
1159
1401
|
if (!unid)
|
|
1160
1402
|
return;
|
|
1161
|
-
let fullId = this.
|
|
1403
|
+
let fullId = this.anchorIdOf(block);
|
|
1162
1404
|
if (!fullId)
|
|
1163
1405
|
return;
|
|
1164
1406
|
let span = selectionSpanIn(block);
|
|
@@ -1169,7 +1411,7 @@ export class DocxEditor {
|
|
|
1169
1411
|
if (!res.success)
|
|
1170
1412
|
return;
|
|
1171
1413
|
if (this.affectsList(res)) {
|
|
1172
|
-
this.
|
|
1414
|
+
this.refreshAfter(block, this.blockIndex(block), false);
|
|
1173
1415
|
return;
|
|
1174
1416
|
}
|
|
1175
1417
|
const fresh = this.swapBlock(block, unid, res.modified?.[0]);
|
|
@@ -1194,7 +1436,7 @@ export class DocxEditor {
|
|
|
1194
1436
|
const unid = block.getAttribute("data-anchor");
|
|
1195
1437
|
if (!unid)
|
|
1196
1438
|
return;
|
|
1197
|
-
let fullId = this.
|
|
1439
|
+
let fullId = this.anchorIdOf(block);
|
|
1198
1440
|
if (!fullId)
|
|
1199
1441
|
return;
|
|
1200
1442
|
const idx = this.blockIndex(block);
|
|
@@ -1202,9 +1444,10 @@ export class DocxEditor {
|
|
|
1202
1444
|
const res = this.parseEdit(this.exports.DocxSessionBridge.InsertHorizontalRule(this.handle, fullId, position === "above" ? "before" : "after", JSON.stringify({ style, size: weight, color: "auto" })));
|
|
1203
1445
|
if (!res.success)
|
|
1204
1446
|
return;
|
|
1205
|
-
//
|
|
1206
|
-
// above (at idx) or just below (at idx+1) the active block.
|
|
1207
|
-
|
|
1447
|
+
// Remount from the active block's index re-renders the new rule whether it landed just
|
|
1448
|
+
// above (at idx) or just below (at idx+1) the active block. Forced: a rule is a bordered
|
|
1449
|
+
// paragraph, and border-div grouping is whole-document render context.
|
|
1450
|
+
this.refreshAfter(block, idx, false, /* forceRemount */ true);
|
|
1208
1451
|
}
|
|
1209
1452
|
/**
|
|
1210
1453
|
* Insert a `rows`×`cols` table after the active block. `options.cellContents` (row-major
|
|
@@ -1218,7 +1461,7 @@ export class DocxEditor {
|
|
|
1218
1461
|
const unid = block.getAttribute("data-anchor");
|
|
1219
1462
|
if (!unid)
|
|
1220
1463
|
return;
|
|
1221
|
-
let fullId = this.
|
|
1464
|
+
let fullId = this.anchorIdOf(block);
|
|
1222
1465
|
if (!fullId)
|
|
1223
1466
|
return;
|
|
1224
1467
|
const idx = this.blockIndex(block);
|
|
@@ -1230,7 +1473,7 @@ export class DocxEditor {
|
|
|
1230
1473
|
const res = this.parseEdit(this.exports.DocxSessionBridge.InsertTable(this.handle, fullId, emptyHere ? "before" : "after", rows, cols, options ? JSON.stringify(options) : ""));
|
|
1231
1474
|
if (!res.success)
|
|
1232
1475
|
return;
|
|
1233
|
-
this.
|
|
1476
|
+
this.refreshAfter(block, idx, false);
|
|
1234
1477
|
}
|
|
1235
1478
|
// ─── Table row / column editing (active block must be inside a table cell) ──────────
|
|
1236
1479
|
/** Run a table-structure op on the active cell (a cell-paragraph block) and re-render. */
|
|
@@ -1241,7 +1484,7 @@ export class DocxEditor {
|
|
|
1241
1484
|
const unid = block.getAttribute("data-anchor");
|
|
1242
1485
|
if (!unid)
|
|
1243
1486
|
return;
|
|
1244
|
-
let fullId = this.
|
|
1487
|
+
let fullId = this.anchorIdOf(block);
|
|
1245
1488
|
if (!fullId)
|
|
1246
1489
|
return;
|
|
1247
1490
|
const idx = this.blockIndex(block);
|
|
@@ -1249,7 +1492,7 @@ export class DocxEditor {
|
|
|
1249
1492
|
const res = this.parseEdit(run(fullId));
|
|
1250
1493
|
if (!res.success)
|
|
1251
1494
|
return;
|
|
1252
|
-
this.
|
|
1495
|
+
this.refreshAfter(block, idx, false);
|
|
1253
1496
|
}
|
|
1254
1497
|
/** Insert a row above/below the active cell's row. No-op outside a table. */
|
|
1255
1498
|
insertTableRow(where) {
|
|
@@ -1288,7 +1531,7 @@ export class DocxEditor {
|
|
|
1288
1531
|
const unid = block.getAttribute("data-anchor");
|
|
1289
1532
|
if (!unid)
|
|
1290
1533
|
return;
|
|
1291
|
-
let fullId = this.
|
|
1534
|
+
let fullId = this.anchorIdOf(block);
|
|
1292
1535
|
if (!fullId)
|
|
1293
1536
|
return;
|
|
1294
1537
|
const idx = this.blockIndex(block);
|
|
@@ -1297,8 +1540,8 @@ export class DocxEditor {
|
|
|
1297
1540
|
if (!res.success)
|
|
1298
1541
|
return;
|
|
1299
1542
|
// A level change ripples through the whole list's numbering — re-render with full document
|
|
1300
|
-
// context (
|
|
1301
|
-
this.
|
|
1543
|
+
// context (sibling numbers shift without sibling XML changing), keeping the caret in place.
|
|
1544
|
+
this.refreshAfter(block, idx, false, /* forceRemount */ true);
|
|
1302
1545
|
}
|
|
1303
1546
|
/** Toggle (or set) page-break-before on the active block. */
|
|
1304
1547
|
pageBreakBefore(value = true) {
|
|
@@ -1315,7 +1558,7 @@ export class DocxEditor {
|
|
|
1315
1558
|
const unid = block.getAttribute("data-anchor");
|
|
1316
1559
|
if (!unid)
|
|
1317
1560
|
return;
|
|
1318
|
-
let fullId = this.
|
|
1561
|
+
let fullId = this.anchorIdOf(block);
|
|
1319
1562
|
if (!fullId)
|
|
1320
1563
|
return;
|
|
1321
1564
|
let membership = null;
|
|
@@ -1331,8 +1574,8 @@ export class DocxEditor {
|
|
|
1331
1574
|
if (!res.success)
|
|
1332
1575
|
return;
|
|
1333
1576
|
// Numbering continuation across the list needs whole-document context — re-render fully
|
|
1334
|
-
// (
|
|
1335
|
-
this.
|
|
1577
|
+
// (sibling numbers shift without sibling XML changing).
|
|
1578
|
+
this.refreshAfter(block, idx, false, /* forceRemount */ true);
|
|
1336
1579
|
}
|
|
1337
1580
|
/** Clear all paragraph borders (e.g. remove an inserted horizontal rule) on the active block —
|
|
1338
1581
|
* or every block in a multi-block selection. The engine/wire already accept `clearBorders`;
|
|
@@ -1358,14 +1601,56 @@ export class DocxEditor {
|
|
|
1358
1601
|
const unid = block.getAttribute("data-anchor");
|
|
1359
1602
|
if (!unid)
|
|
1360
1603
|
return;
|
|
1361
|
-
const fullId = this.
|
|
1604
|
+
const fullId = this.anchorIdOf(block);
|
|
1362
1605
|
if (!fullId)
|
|
1363
1606
|
return;
|
|
1364
1607
|
const idx = this.blockIndex(block);
|
|
1365
1608
|
const res = this.parseEdit(this.exports.DocxSessionBridge.DeleteBlock(this.handle, fullId));
|
|
1366
1609
|
if (!res.success)
|
|
1367
1610
|
return;
|
|
1368
|
-
this.
|
|
1611
|
+
this.refreshAfter(block, Math.max(0, idx - 1), true);
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* Cite a new footnote from the caret position in the active body block. The note definition is
|
|
1615
|
+
* created (writing the whole Word scaffold — part, reserved separator notes, settings
|
|
1616
|
+
* declaration, styles — on a document that has none yet) and its body renders as ordinary
|
|
1617
|
+
* editable `data-anchor` blocks in the notes section, so editing it afterwards needs no new op.
|
|
1618
|
+
*
|
|
1619
|
+
* Body blocks only: Word disallows a note reference inside a header/footer story or inside
|
|
1620
|
+
* another note, and the session rejects those with `AnchorWrongKind`. Remounts, because a new
|
|
1621
|
+
* note renumbers the citations after it and can add a whole part.
|
|
1622
|
+
*/
|
|
1623
|
+
insertFootnote(markdown = "New footnote.") {
|
|
1624
|
+
this.insertNote("footnote", markdown);
|
|
1625
|
+
}
|
|
1626
|
+
/** Cite a new endnote from the caret — see {@link insertFootnote}; writes the endnotes part. */
|
|
1627
|
+
insertEndnote(markdown = "New endnote.") {
|
|
1628
|
+
this.insertNote("endnote", markdown);
|
|
1629
|
+
}
|
|
1630
|
+
insertNote(kind, markdown) {
|
|
1631
|
+
const block = this.activeBlock;
|
|
1632
|
+
if (this.closed || !block)
|
|
1633
|
+
return;
|
|
1634
|
+
// A note reference is legal only in the main story: not in a header/footer band, and not
|
|
1635
|
+
// inside an existing note's body (both render as editable blocks here).
|
|
1636
|
+
if (this.isBandBlock(block) || block.closest(".footnotes, .endnotes"))
|
|
1637
|
+
return;
|
|
1638
|
+
let fullId = this.anchorIdOf(block);
|
|
1639
|
+
if (!fullId)
|
|
1640
|
+
return;
|
|
1641
|
+
const idx = this.blockIndex(block);
|
|
1642
|
+
// Offset first: syncBlock re-renders the block and would drop the live selection.
|
|
1643
|
+
const raw = caretOffsetIn(block);
|
|
1644
|
+
fullId = this.syncBlock(block, fullId);
|
|
1645
|
+
const offset = trimmedSplitOffset(block, raw ?? (block.textContent ?? "").length);
|
|
1646
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
1647
|
+
const call = kind === "footnote" ? bridge.InsertFootnote : bridge.InsertEndnote;
|
|
1648
|
+
if (!call)
|
|
1649
|
+
return; // bridge predates note authoring
|
|
1650
|
+
const res = this.parseEdit(call.call(bridge, this.handle, fullId, offset, markdown));
|
|
1651
|
+
if (!res.success)
|
|
1652
|
+
return;
|
|
1653
|
+
this.reconcile(idx, false);
|
|
1369
1654
|
}
|
|
1370
1655
|
applyParagraphFormat(op) {
|
|
1371
1656
|
const block = this.activeBlock;
|
|
@@ -1380,7 +1665,7 @@ export class DocxEditor {
|
|
|
1380
1665
|
const unid = block.getAttribute("data-anchor");
|
|
1381
1666
|
if (!unid)
|
|
1382
1667
|
return;
|
|
1383
|
-
let fullId = this.
|
|
1668
|
+
let fullId = this.anchorIdOf(block);
|
|
1384
1669
|
if (!fullId)
|
|
1385
1670
|
return;
|
|
1386
1671
|
const idx = this.blockIndex(block);
|
|
@@ -1391,7 +1676,7 @@ export class DocxEditor {
|
|
|
1391
1676
|
// A border change adds/removes the wrapping border <div>, so a single-block swap can't restructure
|
|
1392
1677
|
// it correctly — re-render fully (like list edits) so the wrapper appears/disappears cleanly.
|
|
1393
1678
|
if (this.affectsList(res) || op.clearBorders) {
|
|
1394
|
-
this.
|
|
1679
|
+
this.refreshAfter(block, idx, false, true);
|
|
1395
1680
|
return;
|
|
1396
1681
|
}
|
|
1397
1682
|
this.swapBlock(block, unid, res.modified?.[0])?.focus();
|
|
@@ -1409,7 +1694,7 @@ export class DocxEditor {
|
|
|
1409
1694
|
const unid = block.getAttribute("data-anchor");
|
|
1410
1695
|
if (!unid)
|
|
1411
1696
|
return;
|
|
1412
|
-
let fullId = this.
|
|
1697
|
+
let fullId = this.anchorIdOf(block);
|
|
1413
1698
|
if (!fullId)
|
|
1414
1699
|
return;
|
|
1415
1700
|
const idx = this.blockIndex(block);
|
|
@@ -1418,24 +1703,82 @@ export class DocxEditor {
|
|
|
1418
1703
|
if (!res.success)
|
|
1419
1704
|
return;
|
|
1420
1705
|
if (this.affectsList(res)) {
|
|
1421
|
-
this.
|
|
1706
|
+
this.refreshAfter(block, idx, false, true);
|
|
1422
1707
|
return;
|
|
1423
1708
|
}
|
|
1424
1709
|
this.swapBlock(block, unid, res.modified?.[0])?.focus();
|
|
1425
1710
|
}
|
|
1426
|
-
/** Undo the last edit (re-
|
|
1711
|
+
/** Undo the last edit (incremental repaint; falls back to a full re-render). */
|
|
1427
1712
|
undo() {
|
|
1428
1713
|
if (this.closed)
|
|
1429
1714
|
return;
|
|
1430
1715
|
if (this.exports.DocxSessionBridge.Undo(this.handle))
|
|
1431
|
-
this.
|
|
1716
|
+
this.reconcile();
|
|
1432
1717
|
}
|
|
1433
|
-
/** Redo the last undone edit (re-
|
|
1718
|
+
/** Redo the last undone edit (incremental repaint; falls back to a full re-render). */
|
|
1434
1719
|
redo() {
|
|
1435
1720
|
if (this.closed)
|
|
1436
1721
|
return;
|
|
1437
1722
|
if (this.exports.DocxSessionBridge.Redo(this.handle))
|
|
1438
|
-
this.
|
|
1723
|
+
this.reconcile();
|
|
1724
|
+
}
|
|
1725
|
+
// ─── Header/footer region commands (no-ops unless `headerFooter` is on) ───────────────
|
|
1726
|
+
/**
|
|
1727
|
+
* Select which story kind a band edits (`"default"` / `"first"` / `"even"`). A kind with no
|
|
1728
|
+
* existing part is created empty, so the band always presents something editable. `"first"`
|
|
1729
|
+
* sets the section's `w:titlePg`; `"even"` sets the document-global `w:evenAndOddHeaders`
|
|
1730
|
+
* (which also governs footers — the band surfaces that caveat inline).
|
|
1731
|
+
*/
|
|
1732
|
+
setHeaderFooterKind(which, kind) {
|
|
1733
|
+
this.assertOpen();
|
|
1734
|
+
this.region?.setKind(which, kind);
|
|
1735
|
+
}
|
|
1736
|
+
/** The story kind a band is currently editing, or null when the region is off. */
|
|
1737
|
+
headerFooterKind(which) {
|
|
1738
|
+
return this.region?.kindOf(which) ?? null;
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* Append a page-number field to the focused header/footer story paragraph (falling back to the
|
|
1742
|
+
* band's last paragraph — Word's convention). No-op outside a band.
|
|
1743
|
+
*/
|
|
1744
|
+
insertPageNumber(field = "currentPage") {
|
|
1745
|
+
this.assertOpen();
|
|
1746
|
+
if (!this.region)
|
|
1747
|
+
return;
|
|
1748
|
+
const block = this.activeBlock;
|
|
1749
|
+
const band = block ? this.region.bandOf(block) : null;
|
|
1750
|
+
const anchorId = block?.getAttribute("data-hf-anchor");
|
|
1751
|
+
if (band && anchorId) {
|
|
1752
|
+
this.region.insertPageNumber(this.region.whichOf(band), anchorId, field);
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
// No band block focused — target the footer, where page numbers overwhelmingly live.
|
|
1756
|
+
this.region.insertPageNumberInBand("footer", field);
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* Set the page numbering of the section the bands describe (`w:pgNumType`) — Word's *Format Page
|
|
1760
|
+
* Numbers…*: `start` restarts numbering at that number, `format` chooses `1, 2, 3` vs
|
|
1761
|
+
* `i, ii, iii` etc. Omitted fields are left unchanged. Requires the header/footer region
|
|
1762
|
+
* (`{ headerFooter: true }`); a no-op otherwise.
|
|
1763
|
+
*
|
|
1764
|
+
* Inserted page-number fields are plain, so they render through this. The editor's own view still
|
|
1765
|
+
* shows each field's cached result — Word recomputes on open — but `{ paginated: true }`
|
|
1766
|
+
* substitutes the real per-page number and so reflects the change immediately.
|
|
1767
|
+
*/
|
|
1768
|
+
setPageNumbering(op) {
|
|
1769
|
+
this.assertOpen();
|
|
1770
|
+
this.region?.setPageNumbering(op);
|
|
1771
|
+
}
|
|
1772
|
+
/** Remove the section's page-numbering start/format: it reverts to continuing the previous
|
|
1773
|
+
* section's numbering in Word's default `1, 2, 3`. */
|
|
1774
|
+
clearPageNumbering() {
|
|
1775
|
+
this.assertOpen();
|
|
1776
|
+
this.region?.clearPageNumbering();
|
|
1777
|
+
}
|
|
1778
|
+
/** This section's page numbering as the document currently states it — `{}` when the section
|
|
1779
|
+
* sets neither (it continues the previous section in the default format). */
|
|
1780
|
+
pageNumbering() {
|
|
1781
|
+
return this.region?.pageNumbering() ?? {};
|
|
1439
1782
|
}
|
|
1440
1783
|
/** Which inline formats the current selection carries — for ribbon button highlighting. */
|
|
1441
1784
|
queryFormatState() {
|
|
@@ -1452,17 +1795,27 @@ export class DocxEditor {
|
|
|
1452
1795
|
}
|
|
1453
1796
|
/** Re-render one block from the live session by EditResult ref, swapping it in place. */
|
|
1454
1797
|
swapBlock(oldEl, oldUnid, ref) {
|
|
1455
|
-
const
|
|
1798
|
+
const inBand = this.isBandBlock(oldEl);
|
|
1799
|
+
const anchorId = ref?.id ?? this.anchorIdOf(oldEl);
|
|
1456
1800
|
const newUnid = ref?.unid ?? oldUnid;
|
|
1457
1801
|
if (!anchorId)
|
|
1458
1802
|
return null;
|
|
1459
1803
|
const fresh = this.renderInto(anchorId);
|
|
1460
1804
|
if (!fresh || !this.replaceNode(oldEl, fresh))
|
|
1461
1805
|
return null;
|
|
1462
|
-
|
|
1463
|
-
|
|
1806
|
+
// A band block resolves through its stamped `data-hf-anchor`, never the unid map — and its
|
|
1807
|
+
// unid can collide with another part's, so writing it here would corrupt that entry.
|
|
1808
|
+
if (!inBand) {
|
|
1809
|
+
this.unidToFullId.delete(oldUnid);
|
|
1810
|
+
this.unidToFullId.set(newUnid, anchorId);
|
|
1811
|
+
}
|
|
1812
|
+
// Stamp BEFORE wiring so wireBlock's own resolution sees the authoritative id.
|
|
1813
|
+
if (inBand)
|
|
1814
|
+
this.region.adoptBlock(fresh, anchorId);
|
|
1464
1815
|
this.wireBlock(fresh);
|
|
1465
1816
|
this.activeBlock = fresh;
|
|
1817
|
+
// The throwaway render numbers citation markers from 1 — repair in place.
|
|
1818
|
+
this.maybeRenumberNotes(fresh);
|
|
1466
1819
|
this.options.onEdit?.({ anchorId, unid: newUnid });
|
|
1467
1820
|
return fresh;
|
|
1468
1821
|
}
|
|
@@ -1480,15 +1833,21 @@ export class DocxEditor {
|
|
|
1480
1833
|
if (html.charCodeAt(0) !== 0x7b /* not an error object */)
|
|
1481
1834
|
return html;
|
|
1482
1835
|
}
|
|
1483
|
-
|
|
1836
|
+
// Fallback only (no RenderHtml on this bridge, or it errored). These bytes are re-rendered and
|
|
1837
|
+
// discarded, and the re-render has to resolve to the SAME anchors the live session holds — a
|
|
1838
|
+
// content change re-derives a block's content-hashed unid, which would leave it unwired. So ask
|
|
1839
|
+
// for the Unid-bearing save here, and here only; DocxEditor.save() stays clean.
|
|
1840
|
+
const bytes = typeof bridge.SaveWithAnchorIds === "function"
|
|
1841
|
+
? bridge.SaveWithAnchorIds(this.handle)
|
|
1842
|
+
: bridge.Save(this.handle);
|
|
1484
1843
|
return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale));
|
|
1485
1844
|
}
|
|
1486
|
-
/** Editable blocks in document order. */
|
|
1845
|
+
/** Editable BODY blocks in document order (band blocks are enumerated by `ownerRoot`). */
|
|
1487
1846
|
editableList() {
|
|
1488
1847
|
return Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
|
|
1489
1848
|
}
|
|
1490
1849
|
blockIndex(el) {
|
|
1491
|
-
return this.
|
|
1850
|
+
return Array.from(this.ownerRoot(el).querySelectorAll('[data-anchor][contenteditable="true"]')).indexOf(el);
|
|
1492
1851
|
}
|
|
1493
1852
|
/**
|
|
1494
1853
|
* True when an edit produced or touched a list item (kind "li"). List markers and
|
|
@@ -1498,6 +1857,426 @@ export class DocxEditor {
|
|
|
1498
1857
|
affectsList(res) {
|
|
1499
1858
|
return [...(res.modified ?? []), ...(res.created ?? [])].some((r) => r.kind === "li");
|
|
1500
1859
|
}
|
|
1860
|
+
// ─── Incremental structural reconcile ─────────────────────────────────
|
|
1861
|
+
//
|
|
1862
|
+
// After a structural op (insert table/row/col, footnote, delete block, undo/redo)
|
|
1863
|
+
// the DOM is patched from a unit-sequence diff against the session's render plan
|
|
1864
|
+
// instead of remounting the whole document (~3 s of full-document conversion on a
|
|
1865
|
+
// 350-block file). Full remount remains the universal FALLBACK: any ambiguity,
|
|
1866
|
+
// unsupported bridge, paginated mode, list-membership change, or thrown error
|
|
1867
|
+
// lands there — correctness never depends on the diff being right.
|
|
1868
|
+
/** True when the bridge carries the reconcile trio and the mode allows patching. */
|
|
1869
|
+
canReconcile() {
|
|
1870
|
+
const b = this.exports.DocxSessionBridge;
|
|
1871
|
+
return (!this.options.paginated &&
|
|
1872
|
+
typeof b.ListBlocks === "function" &&
|
|
1873
|
+
typeof b.RenderBlocksHtml === "function" &&
|
|
1874
|
+
typeof b.ListNotes === "function");
|
|
1875
|
+
}
|
|
1876
|
+
/** The body's top-level unit nodes in document order: `[data-anchor]` elements not
|
|
1877
|
+
* nested in another unit (cell paragraphs collapse into their table) and not in the
|
|
1878
|
+
* notes sections. */
|
|
1879
|
+
bodyUnitNodes() {
|
|
1880
|
+
const all = Array.from(this.editRoot.querySelectorAll("[data-anchor]"));
|
|
1881
|
+
return all.filter((el) => {
|
|
1882
|
+
if (el.closest("section.footnotes, section.endnotes"))
|
|
1883
|
+
return false;
|
|
1884
|
+
const ancestor = el.parentElement?.closest("[data-anchor]");
|
|
1885
|
+
return !(ancestor && this.editRoot.contains(ancestor));
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
/** The DOM diff token for a body unit node (see editor-reconcile.tokenOf). */
|
|
1889
|
+
static domTokenOf(el) {
|
|
1890
|
+
const unid = el.getAttribute("data-anchor") ?? "";
|
|
1891
|
+
const sig = el.getAttribute("data-render-sig");
|
|
1892
|
+
return sig ? `${unid}|${sig}` : unid;
|
|
1893
|
+
}
|
|
1894
|
+
/** The kind a body unit node would have in the plan (only 'li'/'tbl' matter to the
|
|
1895
|
+
* remount guard). */
|
|
1896
|
+
static domKindOf(el) {
|
|
1897
|
+
if (el.tagName === "TABLE")
|
|
1898
|
+
return "tbl";
|
|
1899
|
+
return el.querySelector(":scope > [data-list-marker]") ? "li" : "p";
|
|
1900
|
+
}
|
|
1901
|
+
static listMarkerText(el) {
|
|
1902
|
+
const m = el?.querySelector(":scope > [data-list-marker]");
|
|
1903
|
+
return m ? m.textContent : null;
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Incrementally patch the DOM from the session's render plan; falls back to
|
|
1907
|
+
* {@link remount} whenever it cannot prove the patch correct. Same focus contract
|
|
1908
|
+
* as remount.
|
|
1909
|
+
*/
|
|
1910
|
+
reconcile(focusIndex = -1, caretAtEnd = false) {
|
|
1911
|
+
if (!this.canReconcile()) {
|
|
1912
|
+
this.remount(focusIndex, caretAtEnd);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
try {
|
|
1916
|
+
if (!this.reconcileCore()) {
|
|
1917
|
+
this.remount(focusIndex, caretAtEnd);
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
catch (err) {
|
|
1922
|
+
this.lastReconcileFallback = `threw: ${err instanceof Error ? err.message : String(err)}`;
|
|
1923
|
+
this.remount(focusIndex, caretAtEnd);
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
if (focusIndex >= 0) {
|
|
1927
|
+
const blocks = this.editableList();
|
|
1928
|
+
const target = blocks[Math.min(focusIndex, blocks.length - 1)];
|
|
1929
|
+
if (target) {
|
|
1930
|
+
this.activeBlock = target;
|
|
1931
|
+
placeCaretAtOffset(target, caretAtEnd ? (target.textContent ?? "").length : 0);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
this.syncRegionToBody(this.activeBlock ?? undefined);
|
|
1935
|
+
}
|
|
1936
|
+
/** The patch itself. Returns false to request the remount fallback. */
|
|
1937
|
+
reconcileCore() {
|
|
1938
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
1939
|
+
// Refresh the unid → anchor map FIRST: wiring freshly rendered nodes (wireBlock)
|
|
1940
|
+
// resolves through it, and the map must reflect the post-op session.
|
|
1941
|
+
this.refreshAnchorMap();
|
|
1942
|
+
const plan = JSON.parse(bridge.ListBlocks(this.handle));
|
|
1943
|
+
if (plan.error)
|
|
1944
|
+
return this.bail(`plan error: ${plan.error}`);
|
|
1945
|
+
const oldNodes = this.bodyUnitNodes();
|
|
1946
|
+
const oldTokens = oldNodes.map(DocxEditor.domTokenOf);
|
|
1947
|
+
const oldKinds = oldNodes.map(DocxEditor.domKindOf);
|
|
1948
|
+
const bodyDiff = diffUnits(oldTokens, plan.body);
|
|
1949
|
+
if (needsRemount(bodyDiff, plan.body, oldKinds))
|
|
1950
|
+
return this.bail("needsRemount (li change or churn)");
|
|
1951
|
+
const fnState = this.notesDiff("footnotes", plan.footnotes);
|
|
1952
|
+
const enState = this.notesDiff("endnotes", plan.endnotes);
|
|
1953
|
+
if (fnState === null || enState === null)
|
|
1954
|
+
return this.bail("notes container unstampable/missing");
|
|
1955
|
+
// One batch render for everything that needs fresh HTML.
|
|
1956
|
+
const addedBodyIds = bodyDiff.added.map((j) => plan.body[j].id);
|
|
1957
|
+
const addedNoteIds = fnState.diff.added
|
|
1958
|
+
.map((j) => plan.footnotes[j].id)
|
|
1959
|
+
.concat(enState.diff.added.map((j) => plan.endnotes[j].id));
|
|
1960
|
+
const allIds = addedBodyIds.concat(addedNoteIds);
|
|
1961
|
+
let rendered = {};
|
|
1962
|
+
if (allIds.length > 0) {
|
|
1963
|
+
rendered = JSON.parse(bridge.RenderBlocksHtml(this.handle, JSON.stringify(allIds), this.options.cssPrefix, this.options.fabricateClasses));
|
|
1964
|
+
if (rendered.error)
|
|
1965
|
+
return this.bail(`render error: ${rendered.error}`);
|
|
1966
|
+
for (const id of allIds)
|
|
1967
|
+
if (!rendered[id])
|
|
1968
|
+
return this.bail(`unrenderable: ${id}`);
|
|
1969
|
+
}
|
|
1970
|
+
// A substituted list item may only swap in place if its rendered marker matches
|
|
1971
|
+
// the old node's — a marker change (level/membership/numbering) means sibling
|
|
1972
|
+
// numbers shifted too, which only a remount repaints.
|
|
1973
|
+
const parse = (html) => {
|
|
1974
|
+
const el = new DOMParser().parseFromString(html, "text/html").body
|
|
1975
|
+
.firstElementChild;
|
|
1976
|
+
// Per-block converter output carries the XHTML xmlns; a full render only has it
|
|
1977
|
+
// on the document root, so drop it to keep reconciled DOM ≡ remounted DOM.
|
|
1978
|
+
el?.removeAttribute("xmlns");
|
|
1979
|
+
return el;
|
|
1980
|
+
};
|
|
1981
|
+
const freshBody = new Map();
|
|
1982
|
+
for (const j of bodyDiff.added) {
|
|
1983
|
+
const el = parse(rendered[plan.body[j].id]);
|
|
1984
|
+
if (!el)
|
|
1985
|
+
return this.bail(`unparseable render: ${plan.body[j].id}`);
|
|
1986
|
+
freshBody.set(j, el);
|
|
1987
|
+
}
|
|
1988
|
+
for (const { oldIndex, newIndex } of bodyDiff.substituted) {
|
|
1989
|
+
const freshRoot = freshBody.get(newIndex);
|
|
1990
|
+
const oldMarker = DocxEditor.listMarkerText(oldNodes[oldIndex]);
|
|
1991
|
+
const newMarker = DocxEditor.listMarkerText(freshRoot ? DocxEditor.anchorElOf(freshRoot) : null);
|
|
1992
|
+
if (oldMarker !== newMarker)
|
|
1993
|
+
return this.bail("substituted li marker drift");
|
|
1994
|
+
}
|
|
1995
|
+
if (!this.applyBodyDiff(oldNodes, plan.body, bodyDiff, freshBody))
|
|
1996
|
+
return this.bail("applyBodyDiff bail");
|
|
1997
|
+
this.applyNotesDiff("footnotes", plan.footnotes, fnState, rendered);
|
|
1998
|
+
this.applyNotesDiff("endnotes", plan.endnotes, enState, rendered);
|
|
1999
|
+
// Note chrome (marker sup text / hrefs / li values) is position-derived and NOT
|
|
2000
|
+
// covered by the unit diff — renumber whenever notes changed or any fresh body
|
|
2001
|
+
// node carries a citation marker.
|
|
2002
|
+
const freshHasMarker = [...freshBody.values()].some((el) => el.querySelector("a.footnote-ref, a.endnote-ref"));
|
|
2003
|
+
if (fnState.diff.added.length + fnState.diff.removed.length > 0 || freshHasMarker)
|
|
2004
|
+
this.renumberNoteChrome("footnote");
|
|
2005
|
+
if (enState.diff.added.length + enState.diff.removed.length > 0 || freshHasMarker)
|
|
2006
|
+
this.renumberNoteChrome("endnote");
|
|
2007
|
+
this.lastReconcileFallback = null;
|
|
2008
|
+
return true;
|
|
2009
|
+
}
|
|
2010
|
+
bail(reason) {
|
|
2011
|
+
this.lastReconcileFallback = reason;
|
|
2012
|
+
return false;
|
|
2013
|
+
}
|
|
2014
|
+
/** The generated single-child wrapper chain around a unit node (a table's alignment
|
|
2015
|
+
* <div>). Climbs while the parent is an anchor-less DIV whose ONLY element child is
|
|
2016
|
+
* the current node — never a section div (multi-child) or the edit root. */
|
|
2017
|
+
unitWrapperOf(el) {
|
|
2018
|
+
let n = el;
|
|
2019
|
+
while (n.parentElement &&
|
|
2020
|
+
n.parentElement !== this.editRoot &&
|
|
2021
|
+
n.parentElement.tagName === "DIV" &&
|
|
2022
|
+
!n.parentElement.hasAttribute("data-anchor") &&
|
|
2023
|
+
n.parentElement.childElementCount === 1) {
|
|
2024
|
+
n = n.parentElement;
|
|
2025
|
+
}
|
|
2026
|
+
return n;
|
|
2027
|
+
}
|
|
2028
|
+
/** The `[data-anchor]` element of a fresh render root (the root itself for a leaf
|
|
2029
|
+
* block, its descendant for a wrapper-shaped render like a table's align div). */
|
|
2030
|
+
static anchorElOf(root) {
|
|
2031
|
+
return root.hasAttribute("data-anchor")
|
|
2032
|
+
? root
|
|
2033
|
+
: root.querySelector("[data-anchor]");
|
|
2034
|
+
}
|
|
2035
|
+
/** Insert/remove/swap body unit nodes per the diff. Returns false to bail (parent
|
|
2036
|
+
* ambiguity, order violation, wrapper semantics) — the session is already correct,
|
|
2037
|
+
* so bailing just means a full repaint. */
|
|
2038
|
+
applyBodyDiff(oldNodes, units, diff, fresh) {
|
|
2039
|
+
// Kept nodes must appear in increasing old order (no move support in v1).
|
|
2040
|
+
let lastOld = -1;
|
|
2041
|
+
for (let j = 0; j < units.length; j++) {
|
|
2042
|
+
const oi = diff.keep.get(j);
|
|
2043
|
+
if (oi === undefined)
|
|
2044
|
+
continue;
|
|
2045
|
+
if (oi < lastOld)
|
|
2046
|
+
return false;
|
|
2047
|
+
lastOld = oi;
|
|
2048
|
+
}
|
|
2049
|
+
// In-place substitutions first: replace at WRAPPER level so a table swaps with its
|
|
2050
|
+
// alignment div. A LEAF render replacing a wrapped node would break the wrapper's
|
|
2051
|
+
// semantics (border <div> grouping) — that is remount territory.
|
|
2052
|
+
const subOldByNew = new Map(diff.substituted.map((s) => [s.newIndex, s.oldIndex]));
|
|
2053
|
+
for (const [nj, oi] of subOldByNew) {
|
|
2054
|
+
const freshRoot = fresh.get(nj);
|
|
2055
|
+
const oldWrapper = this.unitWrapperOf(oldNodes[oi]);
|
|
2056
|
+
if (!freshRoot.hasAttribute("data-anchor")) {
|
|
2057
|
+
oldWrapper.replaceWith(freshRoot); // wrapper-shaped render (table) ⇄ wrapper
|
|
2058
|
+
}
|
|
2059
|
+
else if (oldWrapper === oldNodes[oi]) {
|
|
2060
|
+
oldNodes[oi].replaceWith(freshRoot);
|
|
2061
|
+
}
|
|
2062
|
+
else {
|
|
2063
|
+
return false; // leaf render into a wrapped slot — border-div semantics, remount
|
|
2064
|
+
}
|
|
2065
|
+
this.wireUnit(freshRoot, units[nj]);
|
|
2066
|
+
}
|
|
2067
|
+
// Pure inserts against kept/substituted neighbors (at wrapper level).
|
|
2068
|
+
const pureAdded = diff.added.filter((j) => !subOldByNew.has(j));
|
|
2069
|
+
const pureRemoved = diff.removed.filter((i) => !diff.substituted.some((s) => s.oldIndex === i));
|
|
2070
|
+
const nodeAt = (j) => {
|
|
2071
|
+
const oi = diff.keep.get(j);
|
|
2072
|
+
if (oi !== undefined)
|
|
2073
|
+
return oldNodes[oi];
|
|
2074
|
+
if (subOldByNew.has(j))
|
|
2075
|
+
return fresh.get(j);
|
|
2076
|
+
const f = fresh.get(j);
|
|
2077
|
+
return f && f.isConnected ? f : null;
|
|
2078
|
+
};
|
|
2079
|
+
for (const j of pureAdded) {
|
|
2080
|
+
const el = fresh.get(j);
|
|
2081
|
+
let prev = null;
|
|
2082
|
+
for (let k = j - 1; k >= 0 && !prev; k--)
|
|
2083
|
+
prev = nodeAt(k);
|
|
2084
|
+
let next = null;
|
|
2085
|
+
for (let k = j + 1; k < units.length && !next; k++) {
|
|
2086
|
+
const oi = diff.keep.get(k);
|
|
2087
|
+
if (oi !== undefined)
|
|
2088
|
+
next = oldNodes[oi];
|
|
2089
|
+
else if (subOldByNew.has(k))
|
|
2090
|
+
next = fresh.get(k);
|
|
2091
|
+
}
|
|
2092
|
+
const prevW = prev ? this.unitWrapperOf(prev) : null;
|
|
2093
|
+
const nextW = next ? this.unitWrapperOf(next) : null;
|
|
2094
|
+
if (prevW && nextW && prevW.parentElement !== nextW.parentElement)
|
|
2095
|
+
return false;
|
|
2096
|
+
if (prevW)
|
|
2097
|
+
prevW.after(el);
|
|
2098
|
+
else if (nextW)
|
|
2099
|
+
nextW.before(el);
|
|
2100
|
+
else
|
|
2101
|
+
return false; // empty container — nowhere provably correct to insert
|
|
2102
|
+
this.wireUnit(el, units[j]);
|
|
2103
|
+
}
|
|
2104
|
+
// Pure removals last, taking now-empty generated wrappers with them.
|
|
2105
|
+
for (const i of pureRemoved) {
|
|
2106
|
+
const wrapper = this.unitWrapperOf(oldNodes[i]);
|
|
2107
|
+
wrapper.remove();
|
|
2108
|
+
}
|
|
2109
|
+
return true;
|
|
2110
|
+
}
|
|
2111
|
+
/** Wire a freshly rendered unit root (and its nested blocks) and stamp the unit's
|
|
2112
|
+
* content signature on its `[data-anchor]` element — the element the next
|
|
2113
|
+
* reconcile's DOM walk reads tokens from. */
|
|
2114
|
+
wireUnit(root, unit) {
|
|
2115
|
+
const anchorEl = DocxEditor.anchorElOf(root);
|
|
2116
|
+
if (anchorEl && unit.sig)
|
|
2117
|
+
anchorEl.setAttribute("data-render-sig", unit.sig);
|
|
2118
|
+
if (anchorEl)
|
|
2119
|
+
this.wireBlock(anchorEl);
|
|
2120
|
+
root.querySelectorAll("[data-anchor]").forEach((b) => this.wireBlock(b));
|
|
2121
|
+
}
|
|
2122
|
+
/** Old-sequence diff state for one notes section. `null` requests remount (DOM not
|
|
2123
|
+
* stampable/consistent). */
|
|
2124
|
+
notesDiff(sectionClass, units) {
|
|
2125
|
+
const ol = this.editRoot.querySelector(`section.${sectionClass} > ol`);
|
|
2126
|
+
const lis = ol ? Array.from(ol.children).filter((c) => c.tagName === "LI") : [];
|
|
2127
|
+
if (units.length === 0 && lis.length === 0)
|
|
2128
|
+
return { lis, diff: diffUnits([], []) };
|
|
2129
|
+
// A document that gains its FIRST note has no section to patch — remount builds it.
|
|
2130
|
+
if (!ol)
|
|
2131
|
+
return null;
|
|
2132
|
+
const tokens = [];
|
|
2133
|
+
for (const li of lis) {
|
|
2134
|
+
const unid = li.getAttribute("data-note-anchor");
|
|
2135
|
+
if (!unid)
|
|
2136
|
+
return null; // unstamped DOM (older mount) — remount restamps
|
|
2137
|
+
const sig = li.getAttribute("data-render-sig");
|
|
2138
|
+
tokens.push(sig ? `${unid}|${sig}` : unid);
|
|
2139
|
+
}
|
|
2140
|
+
return { lis, diff: diffUnits(tokens, units) };
|
|
2141
|
+
}
|
|
2142
|
+
/** Apply a notes-section diff: rebuild the `<ol>`'s li list, preserving kept nodes. */
|
|
2143
|
+
applyNotesDiff(sectionClass, units, state, rendered) {
|
|
2144
|
+
if (state.diff.added.length === 0 && state.diff.removed.length === 0)
|
|
2145
|
+
return;
|
|
2146
|
+
// Removing the LAST note removes the whole section — a full render emits no
|
|
2147
|
+
// section for a document without notes, and equivalence with remount is the pin.
|
|
2148
|
+
if (units.length === 0) {
|
|
2149
|
+
this.editRoot.querySelector(`section.${sectionClass}`)?.remove();
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
const ol = this.editRoot.querySelector(`section.${sectionClass} > ol`);
|
|
2153
|
+
const prefix = sectionClass === "footnotes" ? "fn" : "en";
|
|
2154
|
+
const nodes = [];
|
|
2155
|
+
for (let j = 0; j < units.length; j++) {
|
|
2156
|
+
const oi = state.diff.keep.get(j);
|
|
2157
|
+
if (oi !== undefined) {
|
|
2158
|
+
nodes.push(state.lis[oi]);
|
|
2159
|
+
continue;
|
|
2160
|
+
}
|
|
2161
|
+
nodes.push(this.buildNoteLi(prefix, units[j], rendered[units[j].id]));
|
|
2162
|
+
}
|
|
2163
|
+
ol.replaceChildren(...nodes);
|
|
2164
|
+
}
|
|
2165
|
+
/** Build a notes-section `<li>` for a freshly rendered note — replicating the
|
|
2166
|
+
* converter's chrome (id/value are re-stamped by the renumber pass; the backref
|
|
2167
|
+
* goes inside the last paragraph, matching RenderFootnoteItem). */
|
|
2168
|
+
buildNoteLi(prefix, unit, html) {
|
|
2169
|
+
const li = document.createElement("li");
|
|
2170
|
+
li.setAttribute("data-note-anchor", unidOf(unit.id));
|
|
2171
|
+
if (unit.sig)
|
|
2172
|
+
li.setAttribute("data-render-sig", unit.sig);
|
|
2173
|
+
li.innerHTML = html;
|
|
2174
|
+
const paras = li.querySelectorAll(":scope > p");
|
|
2175
|
+
const last = paras[paras.length - 1];
|
|
2176
|
+
if (last) {
|
|
2177
|
+
const backref = document.createElement("a");
|
|
2178
|
+
backref.setAttribute("class", `${prefix}-backref`);
|
|
2179
|
+
backref.setAttribute("contenteditable", "false");
|
|
2180
|
+
backref.textContent = "↩";
|
|
2181
|
+
last.append(" ", backref);
|
|
2182
|
+
}
|
|
2183
|
+
li.querySelectorAll("[data-anchor]").forEach((b) => this.wireBlock(b));
|
|
2184
|
+
return li;
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Rewrite position-derived note chrome from the session's citation-ordered note
|
|
2188
|
+
* list: the k-th marker in document order IS note k (ids ascend in reference
|
|
2189
|
+
* order), so marker sup text, hrefs/ids, li ids/values and backref hrefs are all
|
|
2190
|
+
* re-derived positionally. Pure attribute/text patching of generated chrome.
|
|
2191
|
+
*/
|
|
2192
|
+
renumberNoteChrome(kind) {
|
|
2193
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
2194
|
+
if (typeof bridge.ListNotes !== "function")
|
|
2195
|
+
return;
|
|
2196
|
+
const prefix = kind === "footnote" ? "fn" : "en";
|
|
2197
|
+
let notes;
|
|
2198
|
+
try {
|
|
2199
|
+
notes = JSON.parse(bridge.ListNotes(this.handle, kind === "endnote"));
|
|
2200
|
+
}
|
|
2201
|
+
catch {
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
if (!Array.isArray(notes))
|
|
2205
|
+
return;
|
|
2206
|
+
const markers = Array.from(this.editRoot.querySelectorAll(`a.${kind}-ref`)).filter((a) => !a.closest("section.footnotes, section.endnotes"));
|
|
2207
|
+
markers.forEach((a, k) => {
|
|
2208
|
+
const n = notes[k];
|
|
2209
|
+
if (!n)
|
|
2210
|
+
return;
|
|
2211
|
+
a.setAttribute("href", `#${prefix}-${n.id}`);
|
|
2212
|
+
a.id = `${prefix}-ref-${n.id}`;
|
|
2213
|
+
if (kind === "footnote")
|
|
2214
|
+
a.setAttribute("data-footnote-id", n.id);
|
|
2215
|
+
const sup = a.querySelector("sup");
|
|
2216
|
+
if (sup)
|
|
2217
|
+
sup.textContent = String(n.ordinal);
|
|
2218
|
+
});
|
|
2219
|
+
// Match list items by their stamped note anchor, NOT position: the section can
|
|
2220
|
+
// hold rendered-but-never-cited notes (Word's continuationNotice) that ListNotes
|
|
2221
|
+
// — a citation walk — does not list; positional pairing would relabel them.
|
|
2222
|
+
const byUnid = new Map(notes.map((n) => [unidOf(n.defAnchorId), n]));
|
|
2223
|
+
const lis = this.editRoot.querySelectorAll(`section.${kind}s > ol > li`);
|
|
2224
|
+
lis.forEach((li) => {
|
|
2225
|
+
const unid = li.getAttribute("data-note-anchor");
|
|
2226
|
+
const n = unid ? byUnid.get(unid) : undefined;
|
|
2227
|
+
if (!n)
|
|
2228
|
+
return;
|
|
2229
|
+
li.id = `${prefix}-${n.id}`;
|
|
2230
|
+
li.setAttribute("value", String(n.ordinal));
|
|
2231
|
+
li.querySelectorAll(`a.${prefix}-backref`).forEach((b) => b.setAttribute("href", `#${prefix}-ref-${n.id}`));
|
|
2232
|
+
});
|
|
2233
|
+
}
|
|
2234
|
+
/** After an incremental block swap, stale marker chrome in the swapped node (the
|
|
2235
|
+
* throwaway render numbers citations from 1) is repaired in place. */
|
|
2236
|
+
maybeRenumberNotes(fresh) {
|
|
2237
|
+
if (fresh.querySelector("a.footnote-ref"))
|
|
2238
|
+
this.renumberNoteChrome("footnote");
|
|
2239
|
+
if (fresh.querySelector("a.endnote-ref"))
|
|
2240
|
+
this.renumberNoteChrome("endnote");
|
|
2241
|
+
}
|
|
2242
|
+
/** Stamp the DOM state the reconciler diffs against: container signatures on body
|
|
2243
|
+
* tables and `data-note-anchor` + signature on notes-section items. Called after
|
|
2244
|
+
* every full mount; reconcile stamps its own insertions. */
|
|
2245
|
+
stampPlanState() {
|
|
2246
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
2247
|
+
if (typeof bridge.ListBlocks !== "function")
|
|
2248
|
+
return;
|
|
2249
|
+
try {
|
|
2250
|
+
const plan = JSON.parse(bridge.ListBlocks(this.handle));
|
|
2251
|
+
if (plan.error)
|
|
2252
|
+
return;
|
|
2253
|
+
// Positional pairing: a fresh full mount renders exactly the plan's units in
|
|
2254
|
+
// order (verified invariant). On any mismatch, leave unstamped — an unstamped
|
|
2255
|
+
// unit just diffs as changed and re-renders once.
|
|
2256
|
+
const nodes = this.bodyUnitNodes();
|
|
2257
|
+
if (nodes.length === plan.body.length) {
|
|
2258
|
+
nodes.forEach((el, k) => {
|
|
2259
|
+
if (plan.body[k].sig && el.getAttribute("data-anchor") === unidOf(plan.body[k].id))
|
|
2260
|
+
el.setAttribute("data-render-sig", plan.body[k].sig);
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
const stampNotes = (sectionClass, units) => {
|
|
2264
|
+
const lis = this.editRoot.querySelectorAll(`section.${sectionClass} > ol > li`);
|
|
2265
|
+
if (lis.length !== units.length)
|
|
2266
|
+
return; // inconsistent — leave unstamped (reconcile will remount)
|
|
2267
|
+
lis.forEach((li, k) => {
|
|
2268
|
+
li.setAttribute("data-note-anchor", unidOf(units[k].id));
|
|
2269
|
+
if (units[k].sig)
|
|
2270
|
+
li.setAttribute("data-render-sig", units[k].sig);
|
|
2271
|
+
});
|
|
2272
|
+
};
|
|
2273
|
+
stampNotes("footnotes", plan.footnotes);
|
|
2274
|
+
stampNotes("endnotes", plan.endnotes);
|
|
2275
|
+
}
|
|
2276
|
+
catch {
|
|
2277
|
+
/* stamping is best-effort; unstamped DOM just falls back to remount */
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
1501
2280
|
/**
|
|
1502
2281
|
* Full re-render from current session state (after undo/redo, and after list edits where
|
|
1503
2282
|
* single-block rendering can't compute numbering). Optionally focus the editable block at
|
|
@@ -1520,6 +2299,9 @@ export class DocxEditor {
|
|
|
1520
2299
|
placeCaretAtOffset(target, caretAtEnd ? (target.textContent ?? "").length : 0);
|
|
1521
2300
|
}
|
|
1522
2301
|
}
|
|
2302
|
+
// A remount rebuilds the body from the live session; re-resolve the section so undo/redo of a
|
|
2303
|
+
// section-affecting edit (or a pagination toggle) leaves the bands describing the right one.
|
|
2304
|
+
this.syncRegionToBody(this.activeBlock ?? undefined);
|
|
1523
2305
|
}
|
|
1524
2306
|
}
|
|
1525
2307
|
//# sourceMappingURL=editor.js.map
|