@beyondwork/docx-react-component 1.0.86 → 1.0.88

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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +49 -0
  3. package/src/api/v3/ui/chrome-composition.ts +2 -11
  4. package/src/api/v3/ui/chrome.ts +6 -8
  5. package/src/index.ts +5 -0
  6. package/src/io/export/serialize-main-document.ts +215 -6
  7. package/src/io/ooxml/parse-drawing.ts +15 -1
  8. package/src/io/ooxml/parse-fields.ts +410 -12
  9. package/src/model/canonical-document.ts +177 -2
  10. package/src/model/layout/page-layout-snapshot.ts +2 -0
  11. package/src/model/layout/runtime-page-graph-types.ts +6 -0
  12. package/src/preservation/store.ts +4 -5
  13. package/src/runtime/document-outline.ts +80 -0
  14. package/src/runtime/document-runtime.ts +580 -40
  15. package/src/runtime/formatting/field/page-number-format.ts +49 -0
  16. package/src/runtime/formatting/field/resolver.ts +61 -40
  17. package/src/runtime/layout/layout-engine-instance.ts +18 -1
  18. package/src/runtime/layout/layout-engine-version.ts +19 -1
  19. package/src/runtime/layout/measurement-backend-canvas.ts +21 -9
  20. package/src/runtime/layout/measurement-backend-empirical.ts +18 -4
  21. package/src/runtime/layout/page-graph.ts +13 -2
  22. package/src/runtime/layout/paginated-layout-engine.ts +440 -117
  23. package/src/runtime/layout/project-block-fragments.ts +87 -4
  24. package/src/runtime/layout/resolve-page-fields.ts +8 -5
  25. package/src/runtime/layout/table-row-split.ts +97 -23
  26. package/src/runtime/surface-projection.ts +227 -27
  27. package/src/shell/session-bootstrap.ts +6 -1
  28. package/src/ui/WordReviewEditor.tsx +8 -0
  29. package/src/ui/editor-surface-controller.tsx +1 -0
  30. package/src/ui/headless/revision-decoration-model.ts +11 -13
  31. package/src/ui-tailwind/chrome/responsive-chrome.ts +2 -2
  32. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +27 -0
  33. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +57 -6
  34. package/src/ui-tailwind/editor-surface/page-slice-util.ts +17 -0
  35. package/src/ui-tailwind/editor-surface/perf-probe.ts +5 -0
  36. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +34 -0
  37. package/src/ui-tailwind/editor-surface/pm-decorations.ts +146 -20
  38. package/src/ui-tailwind/editor-surface/pm-position-map.ts +8 -2
  39. package/src/ui-tailwind/editor-surface/preserve-position.ts +31 -6
  40. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +20 -5
  41. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +92 -50
  42. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +1 -1
  43. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +32 -3
  44. package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +1 -1
  45. package/src/ui-tailwind/tw-review-workspace.tsx +18 -0
@@ -312,6 +312,7 @@ export function parseBookmarkEnd(
312
312
 
313
313
  import type {
314
314
  CanonicalDocument,
315
+ BlockNode,
315
316
  DocumentNode,
316
317
  FieldFamily,
317
318
  FieldNode,
@@ -322,13 +323,16 @@ import type {
322
323
  ParagraphNode,
323
324
  SubPartsCatalog,
324
325
  SupportedFieldFamily,
326
+ TocCachedEntry,
325
327
  TocEntry,
328
+ TocInstructionModel,
329
+ TocRegion,
326
330
  TocStructure,
327
331
  } from "../../model/canonical-document.ts";
328
332
  import { parseFieldSwitches } from "./parse-field-switches.ts";
329
333
 
330
334
  const FIELD_FAMILY_PATTERN =
331
- /^\s*(REF|PAGEREF|NOTEREF|TOC|PAGE|NUMPAGES|DATE|TIME|AUTHOR|FILENAME|MERGEFIELD|IF|SEQ|INDEX|TC|STYLEREF|SECTIONPAGES)\b/i;
335
+ /^\s*(REF|PAGEREF|NOTEREF|TOC|PAGE|NUMPAGES|DATE|TIME|AUTHOR|FILENAME|MERGEFIELD|IF|SEQ|INDEX|TC|FORMTEXT|FORMCHECKBOX|FORMDROPDOWN|STYLEREF|SECTIONPAGES)\b/i;
332
336
 
333
337
  const SUPPORTED_FAMILIES = new Set<string>([
334
338
  "REF",
@@ -415,9 +419,9 @@ export function buildFieldRegistry(
415
419
  const root = document.content;
416
420
  const supported: FieldRegistryEntry[] = [];
417
421
  const preserveOnly: FieldRegistryEntry[] = [];
422
+ const tocFieldEntries: FieldRegistryEntry[] = [];
418
423
  let fieldIndex = 0;
419
424
  let paragraphIndex = -1;
420
- let tocInstruction: string | undefined;
421
425
 
422
426
  walkFieldDocument(root, (node, pIdx) => {
423
427
  paragraphIndex = pIdx;
@@ -439,8 +443,8 @@ export function buildFieldRegistry(
439
443
  };
440
444
  if (classification.supported) {
441
445
  supported.push(entry);
442
- if (classification.family === "TOC" && !tocInstruction) {
443
- tocInstruction = node.instruction;
446
+ if (classification.family === "TOC") {
447
+ tocFieldEntries.push(entry);
444
448
  }
445
449
  } else {
446
450
  preserveOnly.push(entry);
@@ -477,13 +481,23 @@ export function buildFieldRegistry(
477
481
  });
478
482
  }
479
483
 
480
- const tocStructure = tocInstruction
481
- ? buildTocStructure(document, tocInstruction)
482
- : undefined;
484
+ const tocRegions = buildTocRegions(document, tocFieldEntries);
485
+ const firstToc = tocRegions[0];
486
+ const tocStructure = firstToc
487
+ ? {
488
+ instruction: firstToc.instruction.raw,
489
+ levelRange: firstToc.instruction.outlineRange,
490
+ entries: firstToc.generatedEntries,
491
+ status: firstToc.status,
492
+ }
493
+ : tocFieldEntries[0]
494
+ ? buildTocStructure(document, tocFieldEntries[0].instruction)
495
+ : undefined;
483
496
 
484
497
  return {
485
498
  supported,
486
499
  preserveOnly,
500
+ ...(tocRegions.length > 0 ? { tocRegions } : {}),
487
501
  ...(tocStructure ? { tocStructure } : {}),
488
502
  };
489
503
  }
@@ -494,11 +508,120 @@ export function buildFieldRegistry(
494
508
  * Defaults to 1-9 if no \\o switch is present.
495
509
  */
496
510
  export function parseTocLevelRange(instruction: string): { from: number; to: number } {
497
- const match = /\\o\s+"(\d+)-(\d+)"/.exec(instruction);
498
- if (match) {
499
- return { from: Number.parseInt(match[1], 10), to: Number.parseInt(match[2], 10) };
511
+ return parseTocInstruction(instruction).outlineRange;
512
+ }
513
+
514
+ export function parseTocInstruction(instruction: string): TocInstructionModel {
515
+ const model: TocInstructionModel = {
516
+ raw: instruction,
517
+ outlineRange: { from: 1, to: 9 },
518
+ };
519
+ const unsupportedSwitches: string[] = [];
520
+
521
+ for (const sw of readFieldSwitches(instruction)) {
522
+ const code = sw.code.toLowerCase();
523
+ switch (code) {
524
+ case "o": {
525
+ const range = parseLevelRangeValue(sw.value);
526
+ if (range) {
527
+ model.outlineRange = range;
528
+ }
529
+ break;
530
+ }
531
+ case "h":
532
+ model.hyperlink = true;
533
+ break;
534
+ case "z":
535
+ model.hidePageNumbersInWeb = true;
536
+ break;
537
+ case "u":
538
+ model.useOutlineLevels = true;
539
+ break;
540
+ case "t": {
541
+ const styleMap = parseTocStyleMap(sw.value);
542
+ if (styleMap && styleMap.length > 0) {
543
+ model.styleMap = styleMap;
544
+ }
545
+ break;
546
+ }
547
+ case "b":
548
+ if (sw.value) model.bookmarkName = sw.value;
549
+ break;
550
+ case "c":
551
+ if (sw.value) model.sequenceIdentifier = sw.value;
552
+ break;
553
+ case "f":
554
+ if (sw.value) model.tcIdentifier = sw.value;
555
+ break;
556
+ case "p":
557
+ if (sw.value) model.entryPageSeparator = sw.value;
558
+ break;
559
+ case "d":
560
+ if (sw.value) model.sequenceSeparator = sw.value;
561
+ break;
562
+ case "n": {
563
+ const range = parseLevelRangeValue(sw.value);
564
+ model.omitPageNumbers = range ?? true;
565
+ break;
566
+ }
567
+ default:
568
+ unsupportedSwitches.push(sw.code);
569
+ break;
570
+ }
571
+ }
572
+
573
+ if (unsupportedSwitches.length > 0) {
574
+ model.unsupportedSwitches = unsupportedSwitches;
575
+ }
576
+ return model;
577
+ }
578
+
579
+ function readFieldSwitches(instruction: string): Array<{ code: string; value?: string }> {
580
+ const switches: Array<{ code: string; value?: string }> = [];
581
+ const re = /\\([A-Za-z])(?:\s+(?:"([^"]*)"|([^\\\s]+)))?/gu;
582
+ let match: RegExpExecArray | null;
583
+ while ((match = re.exec(instruction)) !== null) {
584
+ const code = match[1] ?? "";
585
+ const rawValue = match[2] !== undefined ? match[2] : match[3]?.trim();
586
+ switches.push({
587
+ code,
588
+ ...(rawValue !== undefined ? { value: rawValue } : {}),
589
+ });
590
+ }
591
+ return switches;
592
+ }
593
+
594
+ function parseLevelRangeValue(value: string | undefined): { from: number; to: number } | undefined {
595
+ if (!value) return undefined;
596
+ const match = /^\s*(\d+)\s*-\s*(\d+)\s*$/u.exec(value);
597
+ if (!match) return undefined;
598
+ const from = Number.parseInt(match[1]!, 10);
599
+ const to = Number.parseInt(match[2]!, 10);
600
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return undefined;
601
+ return {
602
+ from: Math.max(1, Math.min(9, from)),
603
+ to: Math.max(1, Math.min(9, to)),
604
+ };
605
+ }
606
+
607
+ function parseTocStyleMap(
608
+ value: string | undefined,
609
+ ): TocInstructionModel["styleMap"] {
610
+ if (!value) return undefined;
611
+ const tokens = value
612
+ .split(",")
613
+ .map((token) => token.trim())
614
+ .filter((token) => token.length > 0);
615
+ const entries: NonNullable<TocInstructionModel["styleMap"]> = [];
616
+ for (let index = 0; index + 1 < tokens.length; index += 2) {
617
+ const styleName = tokens[index]!;
618
+ const level = Number.parseInt(tokens[index + 1]!, 10);
619
+ if (!Number.isInteger(level) || level < 1 || level > 9) {
620
+ continue;
621
+ }
622
+ entries.push({ styleName, level });
500
623
  }
501
- return { from: 1, to: 9 };
624
+ return entries.length > 0 ? entries : undefined;
502
625
  }
503
626
 
504
627
  /**
@@ -562,6 +685,281 @@ export function buildTocStructure(
562
685
  };
563
686
  }
564
687
 
688
+ function buildTocRegions(
689
+ document: Pick<CanonicalDocument, "content" | "styles">,
690
+ tocFields: readonly FieldRegistryEntry[],
691
+ ): TocRegion[] {
692
+ if (tocFields.length === 0) {
693
+ return [];
694
+ }
695
+
696
+ const paragraphIndexByNode = new WeakMap<ParagraphNode, number>();
697
+ walkFieldDocument(document.content, (node, pIdx) => {
698
+ if (node.type === "paragraph") {
699
+ paragraphIndexByNode.set(node as ParagraphNode, pIdx);
700
+ }
701
+ });
702
+
703
+ const tocFieldByParagraph = new Map<number, FieldRegistryEntry>();
704
+ for (const entry of tocFields) {
705
+ if (!tocFieldByParagraph.has(entry.paragraphIndex)) {
706
+ tocFieldByParagraph.set(entry.paragraphIndex, entry);
707
+ }
708
+ }
709
+
710
+ const regions: TocRegion[] = [];
711
+ collectTocRegionsFromBlocks(
712
+ document,
713
+ document.content.children,
714
+ paragraphIndexByNode,
715
+ tocFieldByParagraph,
716
+ regions,
717
+ "body",
718
+ "body",
719
+ );
720
+
721
+ const seenFieldIndexes = new Set(regions.map((region) => region.sourceFieldIndex));
722
+ for (const entry of tocFields) {
723
+ if (seenFieldIndexes.has(entry.fieldIndex)) continue;
724
+ const generated = buildTocStructure(document, entry.instruction).entries;
725
+ regions.push({
726
+ tocId: `toc-${regions.length + 1}`,
727
+ sourceFieldIndex: entry.fieldIndex,
728
+ instruction: parseTocInstruction(entry.instruction),
729
+ resultRange: {
730
+ fromParagraphIndex: entry.paragraphIndex,
731
+ toParagraphIndex: entry.paragraphIndex,
732
+ },
733
+ parentKind: "body",
734
+ sourcePath: `field:${entry.fieldIndex}`,
735
+ cachedEntries: [],
736
+ generatedEntries: generated,
737
+ status: "stale",
738
+ });
739
+ }
740
+
741
+ return regions;
742
+ }
743
+
744
+ function collectTocRegionsFromBlocks(
745
+ document: Pick<CanonicalDocument, "content" | "styles">,
746
+ blocks: readonly BlockNode[],
747
+ paragraphIndexByNode: WeakMap<ParagraphNode, number>,
748
+ tocFieldByParagraph: ReadonlyMap<number, FieldRegistryEntry>,
749
+ regions: TocRegion[],
750
+ parentKind: TocRegion["parentKind"],
751
+ sourcePath: string,
752
+ ): void {
753
+ for (let index = 0; index < blocks.length; index += 1) {
754
+ const block = blocks[index];
755
+
756
+ if (block.type === "paragraph") {
757
+ const paragraphIndex = paragraphIndexByNode.get(block);
758
+ const tocField = paragraphIndex === undefined
759
+ ? undefined
760
+ : tocFieldByParagraph.get(paragraphIndex);
761
+ if (tocField && startsTocParagraphRegion(blocks, index)) {
762
+ const endIndex = findTocParagraphRegionEnd(blocks, index);
763
+ const paragraphs = blocks
764
+ .slice(index, endIndex + 1)
765
+ .filter((candidate): candidate is ParagraphNode => candidate.type === "paragraph");
766
+ const cachedEntries = paragraphs
767
+ .map((paragraph) => extractCachedTocEntry(paragraph, paragraphIndexByNode))
768
+ .filter((entry): entry is TocCachedEntry => Boolean(entry));
769
+ const generatedEntries = buildTocStructure(document, tocField.instruction).entries;
770
+ const firstParagraph = paragraphs[0];
771
+ const lastParagraph = paragraphs[paragraphs.length - 1];
772
+ const fromParagraphIndex = firstParagraph
773
+ ? paragraphIndexByNode.get(firstParagraph) ?? tocField.paragraphIndex
774
+ : tocField.paragraphIndex;
775
+ const toParagraphIndex = lastParagraph
776
+ ? paragraphIndexByNode.get(lastParagraph) ?? fromParagraphIndex
777
+ : fromParagraphIndex;
778
+ regions.push({
779
+ tocId: `toc-${regions.length + 1}`,
780
+ sourceFieldIndex: tocField.fieldIndex,
781
+ instruction: parseTocInstruction(tocField.instruction),
782
+ resultRange: { fromParagraphIndex, toParagraphIndex },
783
+ parentKind,
784
+ sourcePath: `${sourcePath}/${index}`,
785
+ cachedEntries,
786
+ generatedEntries,
787
+ status: tocEntriesMatch(cachedEntries, generatedEntries) ? "current" : "stale",
788
+ });
789
+ index = endIndex;
790
+ continue;
791
+ }
792
+ }
793
+
794
+ if (block.type === "sdt") {
795
+ collectTocRegionsFromBlocks(
796
+ document,
797
+ block.children,
798
+ paragraphIndexByNode,
799
+ tocFieldByParagraph,
800
+ regions,
801
+ "sdt",
802
+ `${sourcePath}/sdt[${index}]`,
803
+ );
804
+ continue;
805
+ }
806
+
807
+ if (block.type === "custom_xml") {
808
+ collectTocRegionsFromBlocks(
809
+ document,
810
+ block.children,
811
+ paragraphIndexByNode,
812
+ tocFieldByParagraph,
813
+ regions,
814
+ "custom_xml",
815
+ `${sourcePath}/customXml[${index}]`,
816
+ );
817
+ continue;
818
+ }
819
+
820
+ if (block.type === "table") {
821
+ block.rows.forEach((row, rowIndex) => {
822
+ row.cells.forEach((cell, cellIndex) => {
823
+ collectTocRegionsFromBlocks(
824
+ document,
825
+ cell.children,
826
+ paragraphIndexByNode,
827
+ tocFieldByParagraph,
828
+ regions,
829
+ "table_cell",
830
+ `${sourcePath}/table[${index}]/row[${rowIndex}]/cell[${cellIndex}]`,
831
+ );
832
+ });
833
+ });
834
+ }
835
+ }
836
+ }
837
+
838
+ function startsTocParagraphRegion(blocks: readonly BlockNode[], index: number): boolean {
839
+ const current = blocks[index];
840
+ if (current?.type === "paragraph" && isTocParagraphStyle(current.styleId)) {
841
+ return true;
842
+ }
843
+ const next = blocks[index + 1];
844
+ return next?.type === "paragraph" && isTocParagraphStyle(next.styleId);
845
+ }
846
+
847
+ function findTocParagraphRegionEnd(blocks: readonly BlockNode[], startIndex: number): number {
848
+ let endIndex = startIndex;
849
+ while (endIndex + 1 < blocks.length) {
850
+ const next = blocks[endIndex + 1];
851
+ if (next?.type !== "paragraph" || !isTocParagraphStyle(next.styleId)) {
852
+ break;
853
+ }
854
+ endIndex += 1;
855
+ }
856
+ return endIndex;
857
+ }
858
+
859
+ function isTocParagraphStyle(styleId: string | undefined): boolean {
860
+ return /^TOC\d+$/u.test(styleId ?? "");
861
+ }
862
+
863
+ function tocLevelFromStyle(styleId: string | undefined): number {
864
+ const match = /^TOC(\d+)$/u.exec(styleId ?? "");
865
+ if (!match) return 1;
866
+ return Math.max(1, Math.min(9, Number.parseInt(match[1]!, 10)));
867
+ }
868
+
869
+ function extractCachedTocEntry(
870
+ paragraph: ParagraphNode,
871
+ paragraphIndexByNode: WeakMap<ParagraphNode, number>,
872
+ ): TocCachedEntry | undefined {
873
+ if (!isTocParagraphStyle(paragraph.styleId)) {
874
+ return undefined;
875
+ }
876
+ const parts = flattenTocResultParts(paragraph.children);
877
+ const displayText = parts.map((part) => part.kind === "tab" ? "\t" : part.text).join("");
878
+ const trimmedDisplay = displayText.trim();
879
+ if (trimmedDisplay.length === 0) {
880
+ return undefined;
881
+ }
882
+ const lastTabIndex = parts.map((part) => part.kind).lastIndexOf("tab");
883
+ const titleParts = lastTabIndex >= 0 ? parts.slice(0, lastTabIndex) : parts;
884
+ const pageParts = lastTabIndex >= 0 ? parts.slice(lastTabIndex + 1) : [];
885
+ const text = titleParts
886
+ .filter((part): part is { kind: "text"; text: string; bookmarkName?: string } => part.kind === "text")
887
+ .map((part) => part.text)
888
+ .join("")
889
+ .trim();
890
+ const pageText = pageParts
891
+ .filter((part): part is { kind: "text"; text: string; bookmarkName?: string } => part.kind === "text")
892
+ .map((part) => part.text)
893
+ .join("")
894
+ .trim();
895
+ const bookmarkName = parts.find(
896
+ (part): part is { kind: "text"; text: string; bookmarkName: string } =>
897
+ part.kind === "text" && Boolean(part.bookmarkName),
898
+ )?.bookmarkName;
899
+ return {
900
+ text: text || trimmedDisplay,
901
+ level: tocLevelFromStyle(paragraph.styleId),
902
+ paragraphIndex: paragraphIndexByNode.get(paragraph) ?? -1,
903
+ ...(paragraph.styleId ? { styleId: paragraph.styleId } : {}),
904
+ ...(bookmarkName ? { bookmarkName } : {}),
905
+ ...(pageText ? { pageText } : {}),
906
+ displayText,
907
+ };
908
+ }
909
+
910
+ function flattenTocResultParts(
911
+ children: readonly InlineNode[],
912
+ ): Array<{ kind: "text"; text: string; bookmarkName?: string } | { kind: "tab" }> {
913
+ const parts: Array<{ kind: "text"; text: string; bookmarkName?: string } | { kind: "tab" }> = [];
914
+ for (const child of children) {
915
+ switch (child.type) {
916
+ case "text":
917
+ parts.push({ kind: "text", text: child.text });
918
+ break;
919
+ case "tab":
920
+ parts.push({ kind: "tab" });
921
+ break;
922
+ case "hard_break":
923
+ parts.push({ kind: "text", text: "\n" });
924
+ break;
925
+ case "hyperlink": {
926
+ const bookmarkName = child.href?.startsWith("#") ? child.href.slice(1) : undefined;
927
+ const nested = flattenTocResultParts(child.children);
928
+ for (const part of nested) {
929
+ parts.push(part.kind === "text" && bookmarkName ? { ...part, bookmarkName } : part);
930
+ }
931
+ break;
932
+ }
933
+ case "field":
934
+ if (child.fieldFamily !== "TOC") {
935
+ parts.push(...flattenTocResultParts(child.children));
936
+ }
937
+ break;
938
+ default:
939
+ break;
940
+ }
941
+ }
942
+ return parts;
943
+ }
944
+
945
+ function tocEntriesMatch(
946
+ cachedEntries: readonly TocCachedEntry[],
947
+ generatedEntries: readonly TocEntry[],
948
+ ): boolean {
949
+ if (cachedEntries.length !== generatedEntries.length) {
950
+ return false;
951
+ }
952
+ return cachedEntries.every((entry, index) => {
953
+ const generated = generatedEntries[index];
954
+ if (!generated) return false;
955
+ return entry.level === generated.level && normalizeTocText(entry.text) === normalizeTocText(generated.text);
956
+ });
957
+ }
958
+
959
+ function normalizeTocText(text: string): string {
960
+ return text.replace(/\s+/gu, " ").trim();
961
+ }
962
+
565
963
  /**
566
964
  * Deterministic refresh helper for REF fields.
567
965
  * Given a bookmark name map and the document content, resolves the display
@@ -627,6 +1025,7 @@ export function refreshFieldRegistry(
627
1025
  return {
628
1026
  supported: refreshed,
629
1027
  preserveOnly: registry.preserveOnly,
1028
+ ...(registry.tocRegions ? { tocRegions: registry.tocRegions } : {}),
630
1029
  ...(registry.tocStructure ? { tocStructure: registry.tocStructure } : {}),
631
1030
  };
632
1031
  }
@@ -838,4 +1237,3 @@ function findFirstChildEl(node: XmlElementNode, childLocalName: string): XmlElem
838
1237
  (c): c is XmlElementNode => c.type === "element" && localName(c.name) === childLocalName,
839
1238
  );
840
1239
  }
841
-