@adeu/core 1.18.1 → 1.18.4
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/dist/index.cjs +54 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +54 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine.comment-preservation.test.ts +174 -0
- package/src/engine.ts +99 -41
- package/src/repro.comment-range-modify.test.ts +266 -0
package/package.json
CHANGED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// FILE: node/packages/core/src/engine.comment-preservation.test.ts
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { zipSync, strToU8 } from "fflate";
|
|
4
|
+
import { DocumentObject } from "./docx/bridge.js";
|
|
5
|
+
import { extract_comments_data } from "./comments.js";
|
|
6
|
+
import { RedlineEngine } from "./engine.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Regression test for author-aware comment preservation on accept/reject.
|
|
10
|
+
*
|
|
11
|
+
* Bug: accepting a tracked change whose run is wrapped by a comment caused
|
|
12
|
+
* `_clean_wrapping_comments` to delete that comment unconditionally — even when
|
|
13
|
+
* it belonged to another author. In the playbook-commenting scenario this
|
|
14
|
+
* silently erased the counterparty's ("Supplier's Counsel") annotation the
|
|
15
|
+
* instant their insertion (Chg:2) was accepted, destroying provenance and
|
|
16
|
+
* failing the "keep the counterparty comment" requirement.
|
|
17
|
+
*
|
|
18
|
+
* Fix: a wrapping comment authored by someone else has its range markers
|
|
19
|
+
* detached (so the accept proceeds with no orphaned anchor) but its BODY is
|
|
20
|
+
* kept in the comments part. Own-authored wrapping comments are still deleted.
|
|
21
|
+
*
|
|
22
|
+
* The triggering document is built in-memory as a minimal valid .docx so the
|
|
23
|
+
* test doesn't depend on the contents of any golden fixture: a paragraph with a
|
|
24
|
+
* foreign <w:ins id="2"> whose run is wrapped by comment id="1" from that same
|
|
25
|
+
* foreign author.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const WORD_XMLNS =
|
|
29
|
+
'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' +
|
|
30
|
+
'xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" ' +
|
|
31
|
+
'xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"';
|
|
32
|
+
|
|
33
|
+
function xmlDecl(body: string): string {
|
|
34
|
+
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + body;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a minimal, valid DOCX buffer containing a wrapped foreign insertion:
|
|
39
|
+
*
|
|
40
|
+
* commentRangeStart(1) → <w:ins id=2 author=insAuthor>run("INSERTED")</w:ins>
|
|
41
|
+
* → commentRangeEnd(1) → <w:r><w:commentReference id=1/></w:r>
|
|
42
|
+
*
|
|
43
|
+
* plus a comments part with one comment id=1 authored by commentAuthor, body
|
|
44
|
+
* "robust protection". Includes [Content_Types].xml (with the comments
|
|
45
|
+
* Override) and word/_rels/document.xml.rels so `load()` classifies the parts.
|
|
46
|
+
*/
|
|
47
|
+
async function buildWrappedInsertionDoc(
|
|
48
|
+
insAuthor: string,
|
|
49
|
+
commentAuthor: string,
|
|
50
|
+
): Promise<DocumentObject> {
|
|
51
|
+
const contentTypes = xmlDecl(
|
|
52
|
+
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
53
|
+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
54
|
+
<Default Extension="xml" ContentType="application/xml"/>
|
|
55
|
+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
|
56
|
+
<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>
|
|
57
|
+
</Types>`,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const rootRels = xmlDecl(
|
|
61
|
+
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
62
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
|
63
|
+
</Relationships>`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const documentRels = xmlDecl(
|
|
67
|
+
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
68
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>
|
|
69
|
+
</Relationships>`,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const documentXml = xmlDecl(
|
|
73
|
+
`<w:document ${WORD_XMLNS}>
|
|
74
|
+
<w:body>
|
|
75
|
+
<w:p w14:paraId="00000001">
|
|
76
|
+
<w:r><w:t xml:space="preserve">Prefix text. </w:t></w:r>
|
|
77
|
+
<w:commentRangeStart w:id="1"/>
|
|
78
|
+
<w:ins w:id="2" w:author="${insAuthor}" w:date="2026-01-01T00:00:00Z"><w:r><w:t>INSERTED</w:t></w:r></w:ins>
|
|
79
|
+
<w:commentRangeEnd w:id="1"/>
|
|
80
|
+
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="1"/></w:r>
|
|
81
|
+
<w:r><w:t xml:space="preserve"> suffix text.</w:t></w:r>
|
|
82
|
+
</w:p>
|
|
83
|
+
</w:body>
|
|
84
|
+
</w:document>`,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const commentsXml = xmlDecl(
|
|
88
|
+
`<w:comments ${WORD_XMLNS}>
|
|
89
|
+
<w:comment w:id="1" w:author="${commentAuthor}" w:date="2026-01-01T00:00:00Z" w:initials="SC"><w:p><w:r><w:t>robust protection</w:t></w:r></w:p></w:comment>
|
|
90
|
+
</w:comments>`,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const zip: Record<string, Uint8Array> = {
|
|
94
|
+
"[Content_Types].xml": strToU8(contentTypes),
|
|
95
|
+
"_rels/.rels": strToU8(rootRels),
|
|
96
|
+
"word/document.xml": strToU8(documentXml),
|
|
97
|
+
"word/comments.xml": strToU8(commentsXml),
|
|
98
|
+
"word/_rels/document.xml.rels": strToU8(documentRels),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const buf = Buffer.from(zipSync(zip));
|
|
102
|
+
return DocumentObject.load(buf);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe("author-aware wrapping-comment preservation", () => {
|
|
106
|
+
it("keeps a foreign author's wrapping comment when their change is accepted", async () => {
|
|
107
|
+
const doc = await buildWrappedInsertionDoc(
|
|
108
|
+
"Supplier's Counsel",
|
|
109
|
+
"Supplier's Counsel",
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Sanity: comment present before we touch anything.
|
|
113
|
+
const before = extract_comments_data(doc.pkg);
|
|
114
|
+
expect(Object.keys(before).length).toBe(1);
|
|
115
|
+
expect(before["1"].text).toContain("robust protection");
|
|
116
|
+
|
|
117
|
+
// We ("Authority Counsel") accept the counterparty's insertion Chg:2.
|
|
118
|
+
const engine = new RedlineEngine(doc, "Authority Counsel");
|
|
119
|
+
engine.process_batch(
|
|
120
|
+
[{ type: "accept", target_id: "Chg:2" } as any],
|
|
121
|
+
false,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// The counterparty's comment body must survive the accept.
|
|
125
|
+
const after = extract_comments_data(doc.pkg);
|
|
126
|
+
expect(after["1"]?.text).toContain("robust protection");
|
|
127
|
+
|
|
128
|
+
// The accept must actually have taken effect: the <w:ins> is unwrapped.
|
|
129
|
+
const savedBuf = await doc.save();
|
|
130
|
+
const reloaded = await DocumentObject.load(savedBuf);
|
|
131
|
+
expect(reloaded.element.getElementsByTagName("w:ins").length).toBe(0);
|
|
132
|
+
|
|
133
|
+
// Comment survives the roundtrip too.
|
|
134
|
+
const afterRoundtrip = extract_comments_data(reloaded.pkg);
|
|
135
|
+
expect(afterRoundtrip["1"]?.text).toContain("robust protection");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("keeps a foreign author's wrapping comment when their change is rejected", async () => {
|
|
139
|
+
const doc = await buildWrappedInsertionDoc(
|
|
140
|
+
"Supplier's Counsel",
|
|
141
|
+
"Supplier's Counsel",
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const engine = new RedlineEngine(doc, "Authority Counsel");
|
|
145
|
+
engine.process_batch(
|
|
146
|
+
[{ type: "reject", target_id: "Chg:2" } as any],
|
|
147
|
+
false,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Rejecting removes the inserted TEXT, but the wrapping annotation (a
|
|
151
|
+
// separate comment) is preserved rather than collaterally deleted.
|
|
152
|
+
const after = extract_comments_data(doc.pkg);
|
|
153
|
+
expect(after["1"]?.text).toContain("robust protection");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("still deletes our OWN wrapping comment when we accept our own change", async () => {
|
|
157
|
+
// Control: when the wrapping comment is ours, cleanup is unchanged — it is
|
|
158
|
+
// removed, matching prior behavior (keeping our own now-resolved note is not
|
|
159
|
+
// required).
|
|
160
|
+
const doc = await buildWrappedInsertionDoc(
|
|
161
|
+
"Authority Counsel",
|
|
162
|
+
"Authority Counsel",
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const engine = new RedlineEngine(doc, "Authority Counsel");
|
|
166
|
+
engine.process_batch(
|
|
167
|
+
[{ type: "accept", target_id: "Chg:2" } as any],
|
|
168
|
+
false,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const after = extract_comments_data(doc.pkg);
|
|
172
|
+
expect(after["1"]).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
});
|
package/src/engine.ts
CHANGED
|
@@ -65,7 +65,6 @@ function insertAtIndex(parent: Element, index: number, child: Node) {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
68
|
function safeCloneEdit(val: any, seen: WeakMap<any, any> = new WeakMap()): any {
|
|
70
69
|
if (val === null || typeof val !== "object") {
|
|
71
70
|
return val;
|
|
@@ -127,7 +126,10 @@ function restoreSnapshot(doc: any, snapshot: any): void {
|
|
|
127
126
|
}
|
|
128
127
|
}
|
|
129
128
|
|
|
130
|
-
function stripMatchingHeadingHashes(
|
|
129
|
+
function stripMatchingHeadingHashes(
|
|
130
|
+
target: string,
|
|
131
|
+
newText: string,
|
|
132
|
+
): [string, string] {
|
|
131
133
|
if (!target || !newText) return [target, newText];
|
|
132
134
|
const targetMatch = target.match(/^(#+)\s+/);
|
|
133
135
|
const newMatch = newText.match(/^(#+)\s+/);
|
|
@@ -150,7 +152,10 @@ export class BatchValidationError extends Error {
|
|
|
150
152
|
}
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
export function validate_edit_strings(
|
|
155
|
+
export function validate_edit_strings(
|
|
156
|
+
edits: any[],
|
|
157
|
+
index_offset: number = 0,
|
|
158
|
+
): string[] {
|
|
154
159
|
const errors: string[] = [];
|
|
155
160
|
|
|
156
161
|
for (let i = 0; i < edits.length; i++) {
|
|
@@ -397,7 +402,8 @@ export class RedlineEngine {
|
|
|
397
402
|
let target_text = edit.target_text || "";
|
|
398
403
|
let new_text = edit.new_text || "";
|
|
399
404
|
|
|
400
|
-
const [clean_target, target_style] =
|
|
405
|
+
const [clean_target, target_style] =
|
|
406
|
+
this._parse_markdown_style(target_text);
|
|
401
407
|
if (target_style && target_style.startsWith("Heading")) {
|
|
402
408
|
const prefix_len = target_text.length - clean_target.length;
|
|
403
409
|
start_idx += prefix_len;
|
|
@@ -1358,7 +1364,29 @@ export class RedlineEngine {
|
|
|
1358
1364
|
|
|
1359
1365
|
insertAfter(ref_run, new_end);
|
|
1360
1366
|
}
|
|
1361
|
-
|
|
1367
|
+
/**
|
|
1368
|
+
* Read a comment's author directly from the comments part. Used by
|
|
1369
|
+
* _clean_wrapping_comments to decide whether a wrapping comment belongs to
|
|
1370
|
+
* another author (and must be preserved when its anchored change is
|
|
1371
|
+
* accepted/rejected) or to us (safe to delete). Reads the part rather than
|
|
1372
|
+
* the mapper because the mapper's comments_map is not rebuilt between review
|
|
1373
|
+
* actions, so it can be stale mid-batch. Returns null if not found.
|
|
1374
|
+
*/
|
|
1375
|
+
private _get_comment_author(c_id: string): string | null {
|
|
1376
|
+
const part = this.doc.pkg.parts.find(
|
|
1377
|
+
(p) =>
|
|
1378
|
+
p.contentType ===
|
|
1379
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
|
|
1380
|
+
);
|
|
1381
|
+
if (!part) return null;
|
|
1382
|
+
const comments = findAllDescendants(part._element, "w:comment");
|
|
1383
|
+
for (const c of comments) {
|
|
1384
|
+
if (c.getAttribute("w:id") === c_id) {
|
|
1385
|
+
return c.getAttribute("w:author");
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
return null;
|
|
1389
|
+
}
|
|
1362
1390
|
private _clean_wrapping_comments(element: Element) {
|
|
1363
1391
|
let first_node: Element = element;
|
|
1364
1392
|
while (true) {
|
|
@@ -1431,7 +1459,21 @@ export class RedlineEngine {
|
|
|
1431
1459
|
for (const s of starts_to_remove) {
|
|
1432
1460
|
const c_id = s.getAttribute("w:id");
|
|
1433
1461
|
if (c_id && end_ids.has(c_id)) {
|
|
1434
|
-
|
|
1462
|
+
// Author-aware preservation. A comment that merely WRAPS the change
|
|
1463
|
+
// being accepted/rejected is a separate annotation — often the
|
|
1464
|
+
// counterparty's note explaining the very edit we are resolving.
|
|
1465
|
+
// Deleting it silently destroys their provenance (and, e.g., fails a
|
|
1466
|
+
// "keep the counterparty's comment" review requirement). So: always
|
|
1467
|
+
// detach the range markers (leaving no orphaned anchor, which lets the
|
|
1468
|
+
// accept/reject proceed cleanly), but only delete the comment BODY when
|
|
1469
|
+
// it is ours. Foreign-authored comment bodies are kept in the comments
|
|
1470
|
+
// part. When the author can't be read we default to preserving, since
|
|
1471
|
+
// deletion is the irreversible, higher-cost mistake.
|
|
1472
|
+
const author = this._get_comment_author(c_id);
|
|
1473
|
+
const is_own = author !== null && author === this.author;
|
|
1474
|
+
if (is_own) {
|
|
1475
|
+
this.comments_manager.deleteComment(c_id);
|
|
1476
|
+
}
|
|
1435
1477
|
if (s.parentNode) s.parentNode.removeChild(s);
|
|
1436
1478
|
for (const e of ends_to_remove) {
|
|
1437
1479
|
let e_id: string | null = null;
|
|
@@ -1583,10 +1625,9 @@ export class RedlineEngine {
|
|
|
1583
1625
|
if (edit.target_text.includes("|")) {
|
|
1584
1626
|
matches = matches.slice(0, 1);
|
|
1585
1627
|
} else {
|
|
1586
|
-
const positions: [number, number][] = matches.map(
|
|
1587
|
-
start,
|
|
1588
|
-
|
|
1589
|
-
]);
|
|
1628
|
+
const positions: [number, number][] = matches.map(
|
|
1629
|
+
([start, length]) => [start, start + length],
|
|
1630
|
+
);
|
|
1590
1631
|
errors.push(
|
|
1591
1632
|
format_ambiguity_error(
|
|
1592
1633
|
i + 1 + index_offset,
|
|
@@ -1694,30 +1735,35 @@ export class RedlineEngine {
|
|
|
1694
1735
|
}
|
|
1695
1736
|
}
|
|
1696
1737
|
}
|
|
1697
|
-
if (insAuthors.size > 0
|
|
1738
|
+
if (insAuthors.size > 0) {
|
|
1698
1739
|
// A single (strict/first) modification whose target lies ENTIRELY
|
|
1699
|
-
// inside foreign-authored insertion(s)
|
|
1700
|
-
//
|
|
1701
|
-
//
|
|
1702
|
-
//
|
|
1703
|
-
//
|
|
1704
|
-
|
|
1705
|
-
const fullyWithinForeignIns =
|
|
1706
|
-
insAuthors.size > 0 &&
|
|
1707
|
-
!hasNonForeignRealText &&
|
|
1708
|
-
commentAuthors.size === 0;
|
|
1740
|
+
// inside foreign-authored insertion(s) is allowed: track_delete_run
|
|
1741
|
+
// splits the enclosing <w:ins> and nests the change, producing valid
|
|
1742
|
+
// tracked-change XML. Refuse the remaining cases — match_mode "all"
|
|
1743
|
+
// fan-outs and partial overlaps that straddle the insertion
|
|
1744
|
+
// boundary.
|
|
1745
|
+
const fullyWithinForeignIns = !hasNonForeignRealText;
|
|
1709
1746
|
if (
|
|
1710
|
-
(
|
|
1711
|
-
|
|
1747
|
+
!(
|
|
1748
|
+
(match_mode === "strict" || match_mode === "first") &&
|
|
1749
|
+
fullyWithinForeignIns
|
|
1750
|
+
)
|
|
1712
1751
|
) {
|
|
1752
|
+
errors.push(
|
|
1753
|
+
`- Edit ${i + 1 + index_offset} Failed: Modification targets an active insertion from another author (${Array.from(insAuthors).join(", ")}). Accept that change first or scope your edit outside of it.`,
|
|
1754
|
+
);
|
|
1713
1755
|
continue;
|
|
1714
1756
|
}
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1757
|
+
}
|
|
1758
|
+
// Foreign comment ranges do NOT block deliberate single-occurrence
|
|
1759
|
+
// edits: amending body text under a colleague's comment is a normal
|
|
1760
|
+
// review workflow, and the comment anchor survives the tracked change.
|
|
1761
|
+
// Only blind match_mode="all" fan-outs are refused, so a bulk
|
|
1762
|
+
// replacement cannot silently sweep through another author's
|
|
1763
|
+
// annotations (transactional rollback).
|
|
1764
|
+
if (commentAuthors.size > 0 && match_mode === "all") {
|
|
1719
1765
|
errors.push(
|
|
1720
|
-
`- Edit ${i + 1 + index_offset} Failed:
|
|
1766
|
+
`- Edit ${i + 1 + index_offset} Failed: match_mode="all" would sweep through a comment range from another author (${Array.from(commentAuthors).join(", ")}). Target the commented text deliberately with match_mode "strict" or "first", or scope your edit outside of it.`,
|
|
1721
1767
|
);
|
|
1722
1768
|
}
|
|
1723
1769
|
}
|
|
@@ -1821,8 +1867,17 @@ export class RedlineEngine {
|
|
|
1821
1867
|
): any {
|
|
1822
1868
|
// Pre-process edits: strip identical leading heading hashes from target_text and new_text
|
|
1823
1869
|
for (const c of changes) {
|
|
1824
|
-
if (
|
|
1825
|
-
|
|
1870
|
+
if (
|
|
1871
|
+
c &&
|
|
1872
|
+
typeof c === "object" &&
|
|
1873
|
+
(c as any).type === "modify" &&
|
|
1874
|
+
(c as any).target_text &&
|
|
1875
|
+
(c as any).new_text
|
|
1876
|
+
) {
|
|
1877
|
+
const [strippedTarget, strippedNew] = stripMatchingHeadingHashes(
|
|
1878
|
+
(c as any).target_text,
|
|
1879
|
+
(c as any).new_text,
|
|
1880
|
+
);
|
|
1826
1881
|
(c as any).target_text = strippedTarget;
|
|
1827
1882
|
(c as any).new_text = strippedNew;
|
|
1828
1883
|
}
|
|
@@ -1861,10 +1916,7 @@ export class RedlineEngine {
|
|
|
1861
1916
|
actions.length > 0 ? this.validate_review_actions(actions) : [];
|
|
1862
1917
|
const validate_edits_now = edits.length > 0 && action_errors.length > 0;
|
|
1863
1918
|
const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
|
|
1864
|
-
const all_errors = [
|
|
1865
|
-
...action_errors,
|
|
1866
|
-
...edit_errors,
|
|
1867
|
-
];
|
|
1919
|
+
const all_errors = [...action_errors, ...edit_errors];
|
|
1868
1920
|
if (all_errors.length > 0) {
|
|
1869
1921
|
throw new BatchValidationError(all_errors);
|
|
1870
1922
|
}
|
|
@@ -1994,9 +2046,7 @@ export class RedlineEngine {
|
|
|
1994
2046
|
throw err;
|
|
1995
2047
|
}
|
|
1996
2048
|
|
|
1997
|
-
applied_edits = edits.filter(
|
|
1998
|
-
(e) => (e as any)._applied_status,
|
|
1999
|
-
).length;
|
|
2049
|
+
applied_edits = edits.filter((e) => (e as any)._applied_status).length;
|
|
2000
2050
|
skipped_edits = edits.length - applied_edits;
|
|
2001
2051
|
|
|
2002
2052
|
for (const edit of edits) {
|
|
@@ -2040,7 +2090,7 @@ export class RedlineEngine {
|
|
|
2040
2090
|
skipped_details: this.skipped_details,
|
|
2041
2091
|
edits: edits_reports,
|
|
2042
2092
|
engine: "node",
|
|
2043
|
-
version: "1.
|
|
2093
|
+
version: "1.18.2",
|
|
2044
2094
|
};
|
|
2045
2095
|
}
|
|
2046
2096
|
|
|
@@ -2585,7 +2635,10 @@ export class RedlineEngine {
|
|
|
2585
2635
|
const t_seg = target_segs[k];
|
|
2586
2636
|
const n_seg = new_segs[k];
|
|
2587
2637
|
if (t_seg !== n_seg) {
|
|
2588
|
-
const [seg_prefix, seg_suffix] = trim_common_context(
|
|
2638
|
+
const [seg_prefix, seg_suffix] = trim_common_context(
|
|
2639
|
+
t_seg,
|
|
2640
|
+
n_seg,
|
|
2641
|
+
);
|
|
2589
2642
|
const seg_target = t_seg.substring(
|
|
2590
2643
|
seg_prefix,
|
|
2591
2644
|
t_seg.length - seg_suffix,
|
|
@@ -2938,7 +2991,8 @@ export class RedlineEngine {
|
|
|
2938
2991
|
const is_inline_first = result.first_node.tagName === "w:ins";
|
|
2939
2992
|
if (is_inline_first) {
|
|
2940
2993
|
if (anchor_run) {
|
|
2941
|
-
const anchor_parent = anchor_run._element
|
|
2994
|
+
const anchor_parent = anchor_run._element
|
|
2995
|
+
.parentNode as Element | null;
|
|
2942
2996
|
if (anchor_parent && anchor_parent.tagName === "w:ins") {
|
|
2943
2997
|
// Inserting inside another author's pending <w:ins>: split it so our
|
|
2944
2998
|
// new <w:ins> lands as a sibling right after the anchor run, never
|
|
@@ -3099,7 +3153,11 @@ export class RedlineEngine {
|
|
|
3099
3153
|
// nested in their <w:ins> and splice our new <w:ins> in right after
|
|
3100
3154
|
// it by splitting their <w:ins>, so we never nest <w:ins> in
|
|
3101
3155
|
// <w:ins>.
|
|
3102
|
-
this._insert_and_split_ins(
|
|
3156
|
+
this._insert_and_split_ins(
|
|
3157
|
+
del_parent,
|
|
3158
|
+
last_del!,
|
|
3159
|
+
result.first_node,
|
|
3160
|
+
);
|
|
3103
3161
|
} else {
|
|
3104
3162
|
// Inline: place the first <w:ins> immediately after last_del.
|
|
3105
3163
|
insertAfter(result.first_node, last_del!);
|