docxodus 8.0.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/editor-headerfooter.d.ts +39 -3
- package/dist/editor-headerfooter.d.ts.map +1 -1
- package/dist/editor-headerfooter.js +118 -2
- package/dist/editor-headerfooter.js.map +1 -1
- 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 +799 -42
- package/dist/editor.d.ts +121 -5
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +572 -31
- 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.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 +158 -21
- package/dist/pagination.d.ts +38 -0
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +181 -29
- package/dist/pagination.js.map +1 -1
- package/dist/session.bundle.js +226 -4
- package/dist/session.d.ts +147 -6
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +187 -4
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +191 -4
- package/dist/types.d.ts.map +1 -1
- 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.IO.Compression.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Runtime.wasm +0 -0
- package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
- package/dist/wasm/_framework/dotnet.boot.js +10 -10
- package/dist/wasm/_framework/dotnet.native.js.symbols +2102 -2101
- package/dist/wasm/_framework/dotnet.native.wasm +0 -0
- package/package.json +17 -4
package/dist/editor.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { paginateHtml } from "./pagination.js";
|
|
20
20
|
import { HeaderFooterRegion } from "./editor-headerfooter.js";
|
|
21
|
+
import { diffUnits, needsRemount, unidOf } from "./editor-reconcile.js";
|
|
21
22
|
const EDITABLE_TAGS = new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
|
|
22
23
|
function fontWeightIsBold(w) {
|
|
23
24
|
if (w === "bold" || w === "bolder")
|
|
@@ -31,8 +32,9 @@ function escapeInlineMarkdown(text) {
|
|
|
31
32
|
}
|
|
32
33
|
function collectInlineSegments(node, out) {
|
|
33
34
|
node.childNodes.forEach((child) => {
|
|
34
|
-
// Skip generated list
|
|
35
|
-
|
|
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))
|
|
36
38
|
return;
|
|
37
39
|
if (child.nodeType === 3 /* TEXT_NODE */) {
|
|
38
40
|
const text = child.textContent ?? "";
|
|
@@ -112,11 +114,27 @@ export function serializeInlineMarkdown(block) {
|
|
|
112
114
|
function isListBlock(block) {
|
|
113
115
|
return !!block.querySelector(":scope > [data-list-marker]");
|
|
114
116
|
}
|
|
115
|
-
/**
|
|
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). */
|
|
116
134
|
function isInMarker(node) {
|
|
117
135
|
let el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
|
|
118
136
|
while (el) {
|
|
119
|
-
if (el
|
|
137
|
+
if (isGeneratedChrome(el))
|
|
120
138
|
return true;
|
|
121
139
|
el = el.parentElement;
|
|
122
140
|
}
|
|
@@ -425,7 +443,10 @@ function completeArgs(bytes, cssPrefix, fabricate, paginated, scale) {
|
|
|
425
443
|
bytes, "Document", cssPrefix, fabricate, "", -1, "comment-",
|
|
426
444
|
/* paginationMode */ paginated ? 1 : 0, /* paginationScale */ scale, "page-",
|
|
427
445
|
false, 0, "annot-",
|
|
428
|
-
|
|
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,
|
|
429
450
|
false, true, true, false, null, /* stampAnchors */ true,
|
|
430
451
|
];
|
|
431
452
|
}
|
|
@@ -453,6 +474,9 @@ export class DocxEditor {
|
|
|
453
474
|
this.lastSelection = null;
|
|
454
475
|
/** The docked header/footer bands, when `options.headerFooter` is on. */
|
|
455
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;
|
|
456
480
|
/** Track the last meaningful selection so focus-stealing toolbar controls can still target it. */
|
|
457
481
|
this.onSelectionChange = () => {
|
|
458
482
|
if (this.closed)
|
|
@@ -508,15 +532,21 @@ export class DocxEditor {
|
|
|
508
532
|
/**
|
|
509
533
|
* Repaint after an edit to `block` that would otherwise remount the whole document: a band
|
|
510
534
|
* repaints only itself (a story is one to three paragraphs), leaving the body DOM — and the
|
|
511
|
-
* user's place in it — untouched.
|
|
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).
|
|
512
539
|
*/
|
|
513
|
-
refreshAfter(block, focusIndex, caretAtEnd = false) {
|
|
540
|
+
refreshAfter(block, focusIndex, caretAtEnd = false, forceRemount = false) {
|
|
514
541
|
const band = this.region?.bandOf(block);
|
|
515
542
|
if (band) {
|
|
516
543
|
this.region.refresh(this.region.whichOf(band));
|
|
517
544
|
return;
|
|
518
545
|
}
|
|
519
|
-
|
|
546
|
+
if (forceRemount)
|
|
547
|
+
this.remount(focusIndex, caretAtEnd);
|
|
548
|
+
else
|
|
549
|
+
this.reconcile(focusIndex, caretAtEnd);
|
|
520
550
|
}
|
|
521
551
|
/** Open a document, render it into `container`, and wire up editing. */
|
|
522
552
|
static open(container, bytes, exports, options = {}) {
|
|
@@ -529,11 +559,13 @@ export class DocxEditor {
|
|
|
529
559
|
headerFooter: options.headerFooter ?? false,
|
|
530
560
|
onEdit: options.onEdit,
|
|
531
561
|
};
|
|
532
|
-
// persistAnchorIds
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
|
|
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}');
|
|
537
569
|
const editor = new DocxEditor(container, exports, handle, opts);
|
|
538
570
|
editor.refreshAnchorMap();
|
|
539
571
|
if (opts.headerFooter)
|
|
@@ -601,7 +633,14 @@ export class DocxEditor {
|
|
|
601
633
|
* edit into a header part.
|
|
602
634
|
*/
|
|
603
635
|
refreshAnchorMap() {
|
|
604
|
-
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);
|
|
605
644
|
this.unidToFullId.clear();
|
|
606
645
|
const bodyOwned = new Set();
|
|
607
646
|
for (const [fullId, target] of Object.entries(proj.anchorIndex)) {
|
|
@@ -669,6 +708,7 @@ export class DocxEditor {
|
|
|
669
708
|
this.editRoot = this.container;
|
|
670
709
|
if (this.options.editable)
|
|
671
710
|
this.wireBlocks(this.container);
|
|
711
|
+
this.stampPlanState();
|
|
672
712
|
return;
|
|
673
713
|
}
|
|
674
714
|
// With bands docked, the body flow needs its own wrapper to be the edit root.
|
|
@@ -680,6 +720,7 @@ export class DocxEditor {
|
|
|
680
720
|
this.editRoot = flow;
|
|
681
721
|
if (this.options.editable)
|
|
682
722
|
this.wireBlocks(flow);
|
|
723
|
+
this.stampPlanState();
|
|
683
724
|
this.dockBands(flow);
|
|
684
725
|
}
|
|
685
726
|
/** Paginated mount: flow blocks into page boxes via pagination.ts, wire the page clones. */
|
|
@@ -733,9 +774,11 @@ export class DocxEditor {
|
|
|
733
774
|
if (!unid || !this.anchorIdOf(el))
|
|
734
775
|
return;
|
|
735
776
|
el.setAttribute("contenteditable", "true");
|
|
736
|
-
// Generated list
|
|
737
|
-
// caret out
|
|
738
|
-
|
|
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"));
|
|
739
782
|
// Baseline for the commit diff: CONTENT text (list markers + injected bidi marks excluded),
|
|
740
783
|
// matching the session's flat run-text offset space.
|
|
741
784
|
el.dataset.committedText = blockContentText(el);
|
|
@@ -834,6 +877,8 @@ export class DocxEditor {
|
|
|
834
877
|
this.wireBlock(fresh);
|
|
835
878
|
if (this.activeBlock === el)
|
|
836
879
|
this.activeBlock = fresh; // keep ribbon target valid
|
|
880
|
+
// The throwaway render numbers citation markers from 1 — repair in place.
|
|
881
|
+
this.maybeRenumberNotes(fresh);
|
|
837
882
|
}
|
|
838
883
|
}
|
|
839
884
|
this.options.onEdit?.({ anchorId: newAnchor, unid: newUnid });
|
|
@@ -1399,9 +1444,10 @@ export class DocxEditor {
|
|
|
1399
1444
|
const res = this.parseEdit(this.exports.DocxSessionBridge.InsertHorizontalRule(this.handle, fullId, position === "above" ? "before" : "after", JSON.stringify({ style, size: weight, color: "auto" })));
|
|
1400
1445
|
if (!res.success)
|
|
1401
1446
|
return;
|
|
1402
|
-
//
|
|
1403
|
-
// above (at idx) or just below (at idx+1) the active block.
|
|
1404
|
-
|
|
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);
|
|
1405
1451
|
}
|
|
1406
1452
|
/**
|
|
1407
1453
|
* Insert a `rows`×`cols` table after the active block. `options.cellContents` (row-major
|
|
@@ -1494,8 +1540,8 @@ export class DocxEditor {
|
|
|
1494
1540
|
if (!res.success)
|
|
1495
1541
|
return;
|
|
1496
1542
|
// A level change ripples through the whole list's numbering — re-render with full document
|
|
1497
|
-
// context (
|
|
1498
|
-
this.refreshAfter(block, idx, false);
|
|
1543
|
+
// context (sibling numbers shift without sibling XML changing), keeping the caret in place.
|
|
1544
|
+
this.refreshAfter(block, idx, false, /* forceRemount */ true);
|
|
1499
1545
|
}
|
|
1500
1546
|
/** Toggle (or set) page-break-before on the active block. */
|
|
1501
1547
|
pageBreakBefore(value = true) {
|
|
@@ -1528,8 +1574,8 @@ export class DocxEditor {
|
|
|
1528
1574
|
if (!res.success)
|
|
1529
1575
|
return;
|
|
1530
1576
|
// Numbering continuation across the list needs whole-document context — re-render fully
|
|
1531
|
-
// (
|
|
1532
|
-
this.refreshAfter(block, idx, false);
|
|
1577
|
+
// (sibling numbers shift without sibling XML changing).
|
|
1578
|
+
this.refreshAfter(block, idx, false, /* forceRemount */ true);
|
|
1533
1579
|
}
|
|
1534
1580
|
/** Clear all paragraph borders (e.g. remove an inserted horizontal rule) on the active block —
|
|
1535
1581
|
* or every block in a multi-block selection. The engine/wire already accept `clearBorders`;
|
|
@@ -1564,6 +1610,48 @@ export class DocxEditor {
|
|
|
1564
1610
|
return;
|
|
1565
1611
|
this.refreshAfter(block, Math.max(0, idx - 1), true);
|
|
1566
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);
|
|
1654
|
+
}
|
|
1567
1655
|
applyParagraphFormat(op) {
|
|
1568
1656
|
const block = this.activeBlock;
|
|
1569
1657
|
if (this.closed || !block)
|
|
@@ -1588,7 +1676,7 @@ export class DocxEditor {
|
|
|
1588
1676
|
// A border change adds/removes the wrapping border <div>, so a single-block swap can't restructure
|
|
1589
1677
|
// it correctly — re-render fully (like list edits) so the wrapper appears/disappears cleanly.
|
|
1590
1678
|
if (this.affectsList(res) || op.clearBorders) {
|
|
1591
|
-
this.refreshAfter(block, idx, false);
|
|
1679
|
+
this.refreshAfter(block, idx, false, true);
|
|
1592
1680
|
return;
|
|
1593
1681
|
}
|
|
1594
1682
|
this.swapBlock(block, unid, res.modified?.[0])?.focus();
|
|
@@ -1615,24 +1703,24 @@ export class DocxEditor {
|
|
|
1615
1703
|
if (!res.success)
|
|
1616
1704
|
return;
|
|
1617
1705
|
if (this.affectsList(res)) {
|
|
1618
|
-
this.refreshAfter(block, idx, false);
|
|
1706
|
+
this.refreshAfter(block, idx, false, true);
|
|
1619
1707
|
return;
|
|
1620
1708
|
}
|
|
1621
1709
|
this.swapBlock(block, unid, res.modified?.[0])?.focus();
|
|
1622
1710
|
}
|
|
1623
|
-
/** Undo the last edit (re-
|
|
1711
|
+
/** Undo the last edit (incremental repaint; falls back to a full re-render). */
|
|
1624
1712
|
undo() {
|
|
1625
1713
|
if (this.closed)
|
|
1626
1714
|
return;
|
|
1627
1715
|
if (this.exports.DocxSessionBridge.Undo(this.handle))
|
|
1628
|
-
this.
|
|
1716
|
+
this.reconcile();
|
|
1629
1717
|
}
|
|
1630
|
-
/** Redo the last undone edit (re-
|
|
1718
|
+
/** Redo the last undone edit (incremental repaint; falls back to a full re-render). */
|
|
1631
1719
|
redo() {
|
|
1632
1720
|
if (this.closed)
|
|
1633
1721
|
return;
|
|
1634
1722
|
if (this.exports.DocxSessionBridge.Redo(this.handle))
|
|
1635
|
-
this.
|
|
1723
|
+
this.reconcile();
|
|
1636
1724
|
}
|
|
1637
1725
|
// ─── Header/footer region commands (no-ops unless `headerFooter` is on) ───────────────
|
|
1638
1726
|
/**
|
|
@@ -1667,6 +1755,31 @@ export class DocxEditor {
|
|
|
1667
1755
|
// No band block focused — target the footer, where page numbers overwhelmingly live.
|
|
1668
1756
|
this.region.insertPageNumberInBand("footer", field);
|
|
1669
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() ?? {};
|
|
1782
|
+
}
|
|
1670
1783
|
/** Which inline formats the current selection carries — for ribbon button highlighting. */
|
|
1671
1784
|
queryFormatState() {
|
|
1672
1785
|
const block = this.activeBlock ?? this.editRoot;
|
|
@@ -1701,6 +1814,8 @@ export class DocxEditor {
|
|
|
1701
1814
|
this.region.adoptBlock(fresh, anchorId);
|
|
1702
1815
|
this.wireBlock(fresh);
|
|
1703
1816
|
this.activeBlock = fresh;
|
|
1817
|
+
// The throwaway render numbers citation markers from 1 — repair in place.
|
|
1818
|
+
this.maybeRenumberNotes(fresh);
|
|
1704
1819
|
this.options.onEdit?.({ anchorId, unid: newUnid });
|
|
1705
1820
|
return fresh;
|
|
1706
1821
|
}
|
|
@@ -1718,7 +1833,13 @@ export class DocxEditor {
|
|
|
1718
1833
|
if (html.charCodeAt(0) !== 0x7b /* not an error object */)
|
|
1719
1834
|
return html;
|
|
1720
1835
|
}
|
|
1721
|
-
|
|
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);
|
|
1722
1843
|
return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale));
|
|
1723
1844
|
}
|
|
1724
1845
|
/** Editable BODY blocks in document order (band blocks are enumerated by `ownerRoot`). */
|
|
@@ -1736,6 +1857,426 @@ export class DocxEditor {
|
|
|
1736
1857
|
affectsList(res) {
|
|
1737
1858
|
return [...(res.modified ?? []), ...(res.created ?? [])].some((r) => r.kind === "li");
|
|
1738
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
|
+
}
|
|
1739
2280
|
/**
|
|
1740
2281
|
* Full re-render from current session state (after undo/redo, and after list edits where
|
|
1741
2282
|
* single-block rendering can't compute numbering). Optionally focus the editable block at
|