docx-diff-editor 1.0.45 → 1.0.47

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.d.mts CHANGED
@@ -29,6 +29,10 @@ type DocxContent = File | ProseMirrorJSON | string;
29
29
  interface DiffSegment {
30
30
  type: 'equal' | 'insert' | 'delete';
31
31
  text: string;
32
+ /** Position in docA where this segment starts (for equal/delete segments) */
33
+ posA?: number;
34
+ /** Position in docB where this segment starts (for equal/insert segments) */
35
+ posB?: number;
32
36
  }
33
37
  /**
34
38
  * A format change on unchanged text
@@ -54,6 +58,8 @@ interface DiffResult {
54
58
  textB: string;
55
59
  /** Human-readable summary */
56
60
  summary: string[];
61
+ /** Text spans from docB with marks (for mark preservation during merge) */
62
+ spansB?: TextSpan[];
57
63
  }
58
64
  /**
59
65
  * Type of structural change
@@ -365,6 +371,15 @@ interface DocxDiffEditorRef {
365
371
  /** Parse HTML string to ProseMirror JSON (uses hidden SuperDoc instance) */
366
372
  parseHtml(html: string): Promise<ProseMirrorJSON>;
367
373
  }
374
+ /**
375
+ * Text span with position and marks (used in diffing)
376
+ */
377
+ interface TextSpan {
378
+ text: string;
379
+ from: number;
380
+ to: number;
381
+ marks: ProseMirrorMark[];
382
+ }
368
383
 
369
384
  /**
370
385
  * DocxDiffEditor Component
@@ -454,6 +469,10 @@ declare function parseDocxFile(file: File, SuperDoc: SuperDocConstructor): Promi
454
469
  /**
455
470
  * Diff two ProseMirror JSON documents at the character level.
456
471
  * Detects both text changes and formatting changes.
472
+ *
473
+ * Now also tracks positions in both documents for mark preservation:
474
+ * - posA: position in docA (for equal/delete segments)
475
+ * - posB: position in docB (for equal/insert segments)
457
476
  */
458
477
  declare function diffDocuments(docA: ProseMirrorJSON, docB: ProseMirrorJSON): DiffResult;
459
478
 
package/dist/index.d.ts CHANGED
@@ -29,6 +29,10 @@ type DocxContent = File | ProseMirrorJSON | string;
29
29
  interface DiffSegment {
30
30
  type: 'equal' | 'insert' | 'delete';
31
31
  text: string;
32
+ /** Position in docA where this segment starts (for equal/delete segments) */
33
+ posA?: number;
34
+ /** Position in docB where this segment starts (for equal/insert segments) */
35
+ posB?: number;
32
36
  }
33
37
  /**
34
38
  * A format change on unchanged text
@@ -54,6 +58,8 @@ interface DiffResult {
54
58
  textB: string;
55
59
  /** Human-readable summary */
56
60
  summary: string[];
61
+ /** Text spans from docB with marks (for mark preservation during merge) */
62
+ spansB?: TextSpan[];
57
63
  }
58
64
  /**
59
65
  * Type of structural change
@@ -365,6 +371,15 @@ interface DocxDiffEditorRef {
365
371
  /** Parse HTML string to ProseMirror JSON (uses hidden SuperDoc instance) */
366
372
  parseHtml(html: string): Promise<ProseMirrorJSON>;
367
373
  }
374
+ /**
375
+ * Text span with position and marks (used in diffing)
376
+ */
377
+ interface TextSpan {
378
+ text: string;
379
+ from: number;
380
+ to: number;
381
+ marks: ProseMirrorMark[];
382
+ }
368
383
 
369
384
  /**
370
385
  * DocxDiffEditor Component
@@ -454,6 +469,10 @@ declare function parseDocxFile(file: File, SuperDoc: SuperDocConstructor): Promi
454
469
  /**
455
470
  * Diff two ProseMirror JSON documents at the character level.
456
471
  * Detects both text changes and formatting changes.
472
+ *
473
+ * Now also tracks positions in both documents for mark preservation:
474
+ * - posA: position in docA (for equal/delete segments)
475
+ * - posB: position in docB (for equal/insert segments)
457
476
  */
458
477
  declare function diffDocuments(docA: ProseMirrorJSON, docB: ProseMirrorJSON): DiffResult;
459
478
 
package/dist/index.js CHANGED
@@ -453,6 +453,170 @@ var TIMEOUTS = {
453
453
  CLEANUP_DELAY: 100
454
454
  };
455
455
 
456
+ // src/services/runPropertiesSync.ts
457
+ var PT_TO_TWIPS = 20;
458
+ function ptToTwips(ptValue) {
459
+ return Math.round(ptValue * PT_TO_TWIPS);
460
+ }
461
+ function stripHashFromColor(color) {
462
+ return color.replace(/^#/, "");
463
+ }
464
+ function parseFontSizeToPoints(fontSize) {
465
+ if (typeof fontSize === "number") {
466
+ return fontSize;
467
+ }
468
+ const value = parseFloat(fontSize);
469
+ if (isNaN(value)) {
470
+ return null;
471
+ }
472
+ if (fontSize.toLowerCase().includes("px")) {
473
+ return value * 0.75;
474
+ }
475
+ return value;
476
+ }
477
+ function cleanFontFamily(fontFamily) {
478
+ return fontFamily.split(",")[0].trim().replace(/^["']|["']$/g, "");
479
+ }
480
+ function marksToRunProperties(marks) {
481
+ const runProperties = {};
482
+ if (!marks || !Array.isArray(marks)) {
483
+ return runProperties;
484
+ }
485
+ for (const mark of marks) {
486
+ const type = mark.type;
487
+ const attrs = mark.attrs || {};
488
+ switch (type) {
489
+ // Boolean marks: bold, italic, strike
490
+ case "bold":
491
+ case "italic":
492
+ case "strike": {
493
+ const isNegated = attrs.value === "0" || attrs.value === false;
494
+ runProperties[type] = !isNegated;
495
+ break;
496
+ }
497
+ // Underline with optional type and color
498
+ case "underline": {
499
+ const underlineAttrs = {};
500
+ if (attrs.underlineType) {
501
+ underlineAttrs["w:val"] = String(attrs.underlineType);
502
+ } else {
503
+ underlineAttrs["w:val"] = "single";
504
+ }
505
+ if (attrs.underlineColor) {
506
+ underlineAttrs["w:color"] = stripHashFromColor(String(attrs.underlineColor));
507
+ }
508
+ if (Object.keys(underlineAttrs).length > 0) {
509
+ runProperties.underline = underlineAttrs;
510
+ }
511
+ break;
512
+ }
513
+ // Highlight (background color)
514
+ case "highlight": {
515
+ if (attrs.color) {
516
+ const color = String(attrs.color).toLowerCase();
517
+ if (color === "transparent") {
518
+ runProperties.highlight = { "w:val": "none" };
519
+ } else {
520
+ runProperties.highlight = { "w:val": color };
521
+ }
522
+ }
523
+ break;
524
+ }
525
+ // textStyle contains multiple style attributes
526
+ case "textStyle": {
527
+ if (attrs.color != null) {
528
+ runProperties.color = {
529
+ val: stripHashFromColor(String(attrs.color))
530
+ };
531
+ }
532
+ if (attrs.fontSize != null) {
533
+ const points = parseFontSizeToPoints(attrs.fontSize);
534
+ if (points !== null) {
535
+ runProperties.fontSize = points * 2;
536
+ }
537
+ }
538
+ if (attrs.fontFamily != null) {
539
+ const cleanedFont = cleanFontFamily(String(attrs.fontFamily));
540
+ runProperties.fontFamily = {
541
+ ascii: cleanedFont,
542
+ eastAsia: cleanedFont,
543
+ hAnsi: cleanedFont,
544
+ cs: cleanedFont
545
+ };
546
+ }
547
+ if (attrs.letterSpacing != null) {
548
+ const ptValue = parseFloat(String(attrs.letterSpacing));
549
+ if (!isNaN(ptValue)) {
550
+ runProperties.letterSpacing = ptToTwips(ptValue);
551
+ }
552
+ }
553
+ if (attrs.textTransform != null) {
554
+ runProperties.textTransform = String(attrs.textTransform);
555
+ }
556
+ break;
557
+ }
558
+ }
559
+ }
560
+ return runProperties;
561
+ }
562
+ function collectMarksRecursively(node, allMarks) {
563
+ if (node.type === "text" && node.marks && Array.isArray(node.marks)) {
564
+ allMarks.push(...node.marks);
565
+ }
566
+ if (node.content && Array.isArray(node.content)) {
567
+ for (const child of node.content) {
568
+ collectMarksRecursively(child, allMarks);
569
+ }
570
+ }
571
+ }
572
+ function collectMarksFromRunChildren(runNode) {
573
+ const allMarks = [];
574
+ if (!runNode.content || !Array.isArray(runNode.content)) {
575
+ return allMarks;
576
+ }
577
+ for (const child of runNode.content) {
578
+ collectMarksRecursively(child, allMarks);
579
+ }
580
+ const marksByType = /* @__PURE__ */ new Map();
581
+ for (const mark of allMarks) {
582
+ marksByType.set(mark.type, mark);
583
+ }
584
+ return Array.from(marksByType.values());
585
+ }
586
+ function normalizeNode(node) {
587
+ if (node.type === "run") {
588
+ const marks = collectMarksFromRunChildren(node);
589
+ if (marks.length > 0) {
590
+ const runPropsFromMarks = marksToRunProperties(marks);
591
+ const existingRunProps = node.attrs?.runProperties || {};
592
+ const mergedRunProps = {
593
+ ...existingRunProps,
594
+ ...runPropsFromMarks
595
+ };
596
+ return {
597
+ ...node,
598
+ attrs: {
599
+ ...node.attrs,
600
+ runProperties: mergedRunProps
601
+ },
602
+ // Also recursively process children (though runs usually just have text)
603
+ content: node.content?.map(normalizeNode)
604
+ };
605
+ }
606
+ }
607
+ if (node.content && Array.isArray(node.content)) {
608
+ return {
609
+ ...node,
610
+ content: node.content.map(normalizeNode)
611
+ };
612
+ }
613
+ return node;
614
+ }
615
+ function normalizeRunProperties(doc) {
616
+ const cloned = JSON.parse(JSON.stringify(doc));
617
+ return normalizeNode(cloned);
618
+ }
619
+
456
620
  // src/services/contentResolver.ts
457
621
  function detectContentType(content) {
458
622
  if (content instanceof File) {
@@ -519,7 +683,8 @@ async function parseHtmlToJson(html, SuperDoc) {
519
683
  try {
520
684
  const json = editor.getJSON();
521
685
  if (json?.content?.length > 0) {
522
- onSuccess(json);
686
+ const normalizedJson = normalizeRunProperties(json);
687
+ onSuccess(normalizedJson);
523
688
  } else {
524
689
  onFail();
525
690
  }
@@ -556,9 +721,10 @@ async function parseHtmlToJson(html, SuperDoc) {
556
721
  throw new Error("No active editor found");
557
722
  }
558
723
  const json = editor.getJSON();
724
+ const normalizedJson = normalizeRunProperties(json);
559
725
  resolved = true;
560
726
  cleanup();
561
- resolve(json);
727
+ resolve(normalizedJson);
562
728
  } catch (err) {
563
729
  resolved = true;
564
730
  cleanup();
@@ -836,14 +1002,20 @@ function diffDocuments(docA, docB) {
836
1002
  const segments = [];
837
1003
  let insertCount = 0;
838
1004
  let deleteCount = 0;
1005
+ let posA = 0;
1006
+ let posB = 0;
839
1007
  for (const [op, text] of diffs) {
840
1008
  if (op === DIFF_EQUAL) {
841
- segments.push({ type: "equal", text });
1009
+ segments.push({ type: "equal", text, posA, posB });
1010
+ posA += text.length;
1011
+ posB += text.length;
842
1012
  } else if (op === DIFF_INSERT) {
843
- segments.push({ type: "insert", text });
1013
+ segments.push({ type: "insert", text, posB });
1014
+ posB += text.length;
844
1015
  insertCount++;
845
1016
  } else if (op === DIFF_DELETE) {
846
- segments.push({ type: "delete", text });
1017
+ segments.push({ type: "delete", text, posA });
1018
+ posA += text.length;
847
1019
  deleteCount++;
848
1020
  }
849
1021
  }
@@ -868,7 +1040,9 @@ function diffDocuments(docA, docB) {
868
1040
  formatChanges,
869
1041
  textA,
870
1042
  textB,
871
- summary
1043
+ summary,
1044
+ spansB
1045
+ // Include docB spans for mark preservation during merge
872
1046
  };
873
1047
  }
874
1048
 
@@ -1350,6 +1524,69 @@ function alignListItems(listA, listB, listPathA, listPathB) {
1350
1524
  function cloneNode(node) {
1351
1525
  return JSON.parse(JSON.stringify(node));
1352
1526
  }
1527
+ function getMarkSpansForRange(spansB, start, end) {
1528
+ const result = [];
1529
+ for (const span of spansB) {
1530
+ if (span.to > start && span.from < end) {
1531
+ const overlapStart = Math.max(span.from, start);
1532
+ const overlapEnd = Math.min(span.to, end);
1533
+ result.push({
1534
+ relStart: overlapStart - start,
1535
+ relEnd: overlapEnd - start,
1536
+ marks: span.marks || []
1537
+ });
1538
+ }
1539
+ }
1540
+ return result;
1541
+ }
1542
+ function createInsertedTextNodes(text, posB, spansB, author, replacementId) {
1543
+ const result = [];
1544
+ const trackMark = createTrackInsertMark(author, replacementId);
1545
+ if (posB === void 0 || spansB.length === 0) {
1546
+ return [{
1547
+ type: "text",
1548
+ text,
1549
+ marks: [trackMark]
1550
+ }];
1551
+ }
1552
+ const markSpans = getMarkSpansForRange(spansB, posB, posB + text.length);
1553
+ if (markSpans.length === 0) {
1554
+ return [{
1555
+ type: "text",
1556
+ text,
1557
+ marks: [trackMark]
1558
+ }];
1559
+ }
1560
+ markSpans.sort((a, b) => a.relStart - b.relStart);
1561
+ let processedUpTo = 0;
1562
+ for (const span of markSpans) {
1563
+ if (span.relStart > processedUpTo) {
1564
+ result.push({
1565
+ type: "text",
1566
+ text: text.substring(processedUpTo, span.relStart),
1567
+ marks: [trackMark]
1568
+ });
1569
+ }
1570
+ if (span.relEnd > span.relStart) {
1571
+ const spanText = text.substring(span.relStart, span.relEnd);
1572
+ const marks = [...span.marks, trackMark];
1573
+ result.push({
1574
+ type: "text",
1575
+ text: spanText,
1576
+ marks
1577
+ });
1578
+ processedUpTo = span.relEnd;
1579
+ }
1580
+ }
1581
+ if (processedUpTo < text.length) {
1582
+ result.push({
1583
+ type: "text",
1584
+ text: text.substring(processedUpTo),
1585
+ marks: [trackMark]
1586
+ });
1587
+ }
1588
+ return result;
1589
+ }
1353
1590
  function mergeDocuments(docA, docB, diffResult, author = DEFAULT_AUTHOR) {
1354
1591
  const merged = cloneNode(docA);
1355
1592
  const charStates = [];
@@ -1384,17 +1621,22 @@ function mergeDocuments(docA, docB, diffResult, author = DEFAULT_AUTHOR) {
1384
1621
  insertions.push({
1385
1622
  afterOffset: docAOffset,
1386
1623
  text: nextSegment.text,
1387
- replacementId
1624
+ replacementId,
1625
+ posB: nextSegment.posB
1626
+ // Capture docB position for mark lookup
1388
1627
  });
1389
1628
  segIdx++;
1390
1629
  }
1391
1630
  } else if (segment.type === "insert") {
1392
1631
  insertions.push({
1393
1632
  afterOffset: docAOffset,
1394
- text: segment.text
1633
+ text: segment.text,
1634
+ posB: segment.posB
1635
+ // Capture docB position for mark lookup
1395
1636
  });
1396
1637
  }
1397
1638
  }
1639
+ const spansB = diffResult.spansB || [];
1398
1640
  function transformNode(node, nodeOffset, path) {
1399
1641
  if (node.type === "text" && node.text) {
1400
1642
  const text = node.text;
@@ -1405,11 +1647,14 @@ function mergeDocuments(docA, docB, diffResult, author = DEFAULT_AUTHOR) {
1405
1647
  const charState = charStates[charOffset] || { type: "equal" };
1406
1648
  const insertionsHere = insertions.filter((ins) => ins.afterOffset === charOffset);
1407
1649
  for (const ins of insertionsHere) {
1408
- result.push({
1409
- type: "text",
1410
- text: ins.text,
1411
- marks: [...node.marks || [], createTrackInsertMark(author, ins.replacementId)]
1412
- });
1650
+ const insertedNodes = createInsertedTextNodes(
1651
+ ins.text,
1652
+ ins.posB,
1653
+ spansB,
1654
+ author,
1655
+ ins.replacementId
1656
+ );
1657
+ result.push(...insertedNodes);
1413
1658
  }
1414
1659
  const currentFormatChange = getFormatChangeAt(nodeOffset + i);
1415
1660
  let j = i + 1;
@@ -1445,11 +1690,14 @@ function mergeDocuments(docA, docB, diffResult, author = DEFAULT_AUTHOR) {
1445
1690
  const endOffset = nodeOffset + text.length;
1446
1691
  const endInsertions = insertions.filter((ins) => ins.afterOffset === endOffset);
1447
1692
  for (const ins of endInsertions) {
1448
- result.push({
1449
- type: "text",
1450
- text: ins.text,
1451
- marks: [...node.marks || [], createTrackInsertMark(author, ins.replacementId)]
1452
- });
1693
+ const insertedNodes = createInsertedTextNodes(
1694
+ ins.text,
1695
+ ins.posB,
1696
+ spansB,
1697
+ author,
1698
+ ins.replacementId
1699
+ );
1700
+ result.push(...insertedNodes);
1453
1701
  }
1454
1702
  insertions = insertions.filter(
1455
1703
  (ins) => ins.afterOffset < nodeOffset || ins.afterOffset > endOffset
@@ -1484,18 +1732,19 @@ function mergeDocuments(docA, docB, diffResult, author = DEFAULT_AUTHOR) {
1484
1732
  }
1485
1733
  if (insertions.length > 0) {
1486
1734
  for (const ins of insertions) {
1735
+ const insertedNodes = createInsertedTextNodes(
1736
+ ins.text,
1737
+ ins.posB,
1738
+ spansB,
1739
+ author,
1740
+ ins.replacementId
1741
+ );
1487
1742
  const insertNode = {
1488
1743
  type: "paragraph",
1489
1744
  content: [
1490
1745
  {
1491
1746
  type: "run",
1492
- content: [
1493
- {
1494
- type: "text",
1495
- text: ins.text,
1496
- marks: [createTrackInsertMark(author, ins.replacementId)]
1497
- }
1498
- ]
1747
+ content: insertedNodes
1499
1748
  }
1500
1749
  ]
1501
1750
  };
@@ -2861,9 +3110,10 @@ var DocxDiffEditor = react.forwardRef(
2861
3110
  }
2862
3111
  newJson = content;
2863
3112
  }
3113
+ const normalizedNewJson = normalizeRunProperties(newJson);
2864
3114
  const structuralResult = mergeWithStructuralAwareness(
2865
3115
  sourceJson,
2866
- newJson,
3116
+ normalizedNewJson,
2867
3117
  author
2868
3118
  );
2869
3119
  const merged = structuralResult.mergedDoc;