@beyondwork/docx-react-component 1.0.42 → 1.0.45

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 (82) hide show
  1. package/README.md +17 -0
  2. package/package.json +5 -4
  3. package/src/api/editor-state-types.ts +110 -0
  4. package/src/api/public-types.ts +333 -4
  5. package/src/core/commands/formatting-commands.ts +7 -1
  6. package/src/core/commands/index.ts +60 -10
  7. package/src/core/commands/text-commands.ts +59 -0
  8. package/src/core/search/search-text.ts +15 -2
  9. package/src/core/selection/review-anchors.ts +131 -21
  10. package/src/index.ts +29 -1
  11. package/src/io/chart-preview-resolver.ts +281 -0
  12. package/src/io/docx-session.ts +692 -2
  13. package/src/io/export/build-app-properties-xml.ts +1 -1
  14. package/src/io/export/serialize-comments.ts +38 -9
  15. package/src/io/export/twip.ts +1 -1
  16. package/src/io/load-scheduler.ts +230 -0
  17. package/src/io/normalize/normalize-text.ts +116 -0
  18. package/src/io/ooxml/parse-comments.ts +0 -33
  19. package/src/io/ooxml/parse-complex-content.ts +14 -0
  20. package/src/io/ooxml/parse-main-document.ts +4 -0
  21. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  22. package/src/io/ooxml/workflow-payload.ts +172 -1
  23. package/src/preservation/opaque-region.ts +5 -0
  24. package/src/review/store/comment-remapping.ts +2 -2
  25. package/src/runtime/collab-session.ts +1 -1
  26. package/src/runtime/document-runtime.ts +661 -42
  27. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  28. package/src/runtime/edit-dispatch/index.ts +2 -0
  29. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  30. package/src/runtime/editor-state-channel.ts +544 -0
  31. package/src/runtime/editor-state-integration.ts +217 -0
  32. package/src/runtime/editor-surface/capabilities.ts +411 -0
  33. package/src/runtime/layout/index.ts +2 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +63 -2
  36. package/src/runtime/layout/layout-engine-version.ts +41 -0
  37. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  38. package/src/runtime/layout/public-facet.ts +430 -1
  39. package/src/runtime/perf-counters.ts +28 -0
  40. package/src/runtime/prerender/cache-envelope.ts +29 -0
  41. package/src/runtime/prerender/cache-key.ts +66 -0
  42. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  43. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  44. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  45. package/src/runtime/prerender/prerender-document.ts +145 -0
  46. package/src/runtime/render/block-fragment-projection.ts +2 -0
  47. package/src/runtime/render/render-frame-types.ts +17 -0
  48. package/src/runtime/render/render-kernel.ts +172 -29
  49. package/src/runtime/selection/post-edit-validator.ts +77 -0
  50. package/src/runtime/surface-projection.ts +45 -7
  51. package/src/runtime/workflow-markup.ts +71 -16
  52. package/src/ui/WordReviewEditor.tsx +142 -237
  53. package/src/ui/editor-command-bag.ts +14 -0
  54. package/src/ui/editor-runtime-boundary.ts +115 -12
  55. package/src/ui/editor-shell-view.tsx +10 -0
  56. package/src/ui/editor-surface-controller.tsx +5 -0
  57. package/src/ui/headless/selection-helpers.ts +10 -0
  58. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  59. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  60. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  62. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  63. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  64. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  65. package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
  66. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
  67. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  68. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  69. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  70. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
  71. package/src/ui-tailwind/index.ts +5 -1
  72. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  73. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  74. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  75. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  76. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  77. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  78. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  79. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  80. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  81. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  82. package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
@@ -98,6 +98,7 @@ function buildPageBreakDecorationsFromProps(
98
98
  footerBandPx?: number;
99
99
  interGapPx?: number;
100
100
  } = {},
101
+ surfaceBlocks?: readonly import("../../api/public-types.ts").SurfaceBlockSnapshot[],
101
102
  ): ReturnType<typeof buildPageBreakDecorations> {
102
103
  if (!facet || !isMainStory) return [];
103
104
  if (typeof facet.getRenderFrame !== "function") return [];
@@ -133,6 +134,41 @@ function buildPageBreakDecorationsFromProps(
133
134
  })
134
135
  : undefined;
135
136
 
137
+ // L7 Phase 2 Task 2.2.4a — compute per-page block-index ranges from the
138
+ // render frame's page offsets + the surface blocks list. Each block has a
139
+ // `from`/`to` offset; we find the first and last block whose offset range
140
+ // falls within each page's [startOffset, nextPage.startOffset) window.
141
+ // This map is passed into `buildPageBreakDecorations` so the chrome widgets
142
+ // carry `data-page-first-block-index` / `data-page-last-block-index`
143
+ // attributes needed by `useVisibleBlockRange`.
144
+ let blockIndexRangeByPageIndex: Map<number, { first: number; last: number }> | undefined;
145
+ if (surfaceBlocks && surfaceBlocks.length > 0 && frame.pages.length > 0) {
146
+ blockIndexRangeByPageIndex = new Map();
147
+ for (let pi = 0; pi < frame.pages.length; pi++) {
148
+ const page = frame.pages[pi]!;
149
+ if (page.page.isBlankFiller) continue;
150
+ const pageStart = page.page.startOffset;
151
+ const pageEnd =
152
+ pi + 1 < frame.pages.length
153
+ ? frame.pages[pi + 1]!.page.startOffset
154
+ : Infinity;
155
+ let first = -1;
156
+ let last = -1;
157
+ for (let bi = 0; bi < surfaceBlocks.length; bi++) {
158
+ const block = surfaceBlocks[bi]!;
159
+ const blockFrom = block.from; // from is required on all SurfaceBlockSnapshot variants
160
+ // Block belongs to this page if its start falls within the page's offset window.
161
+ if (blockFrom >= pageStart && blockFrom < pageEnd) {
162
+ if (first === -1) first = bi;
163
+ last = bi;
164
+ }
165
+ }
166
+ if (first !== -1) {
167
+ blockIndexRangeByPageIndex.set(page.page.pageIndex, { first, last });
168
+ }
169
+ }
170
+ }
171
+
136
172
  return buildPageBreakDecorations({
137
173
  graph: fakeGraph as never,
138
174
  posture,
@@ -142,6 +178,7 @@ function buildPageBreakDecorationsFromProps(
142
178
  runtimeToPmOffset: (offset) => positionMap.runtimeToPm(offset),
143
179
  headerPreviewByPageId: previews?.headerPreviewByPageId,
144
180
  footerPreviewByPageId: previews?.footerPreviewByPageId,
181
+ blockIndexRangeByPageIndex,
145
182
  });
146
183
  }
147
184
 
@@ -188,6 +225,11 @@ export interface TwProseMirrorSurfaceProps {
188
225
  onUndo?: () => void;
189
226
  onRedo?: () => void;
190
227
  onBlockedInput?: (command: "paste" | "drop", message: string) => void;
228
+ onPasteApplied?: (meta: {
229
+ segmentCount: number;
230
+ charCount: number;
231
+ source: "paste" | "drop";
232
+ }) => void;
191
233
  onCommentActivated?: (commentId: string) => void;
192
234
  onRevisionActivated?: (revisionId: string) => void;
193
235
  onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
@@ -321,6 +363,9 @@ export const TwProseMirrorSurface = forwardRef<
321
363
  onBlockedInput: (command, message) => {
322
364
  props.onBlockedInput?.(command, message);
323
365
  },
366
+ onPasteApplied: (meta) => {
367
+ props.onPasteApplied?.(meta);
368
+ },
324
369
  onSelectionChange: (sel) => {
325
370
  pendingSelectionProbeRef.current = startPerfProbe("selection");
326
371
  props.onSelectionChange?.(
@@ -448,6 +493,7 @@ export const TwProseMirrorSurface = forwardRef<
448
493
  onUndo: () => callbacksRef.current?.onUndo(),
449
494
  onRedo: () => callbacksRef.current?.onRedo(),
450
495
  onBlockedInput: (command, message) => callbacksRef.current?.onBlockedInput?.(command, message),
496
+ onPasteApplied: (meta) => callbacksRef.current?.onPasteApplied?.(meta),
451
497
  onCompositionChange: (composing) => {
452
498
  sessionRef.current?.setComposing(composing);
453
499
  },
@@ -503,6 +549,7 @@ export const TwProseMirrorSurface = forwardRef<
503
549
  footerBandPx: props.pageChromeFooterBandPx,
504
550
  interGapPx: props.pageChromeInterGapPx,
505
551
  },
552
+ snapshot.surface?.blocks,
506
553
  );
507
554
  const decorations = pageBreakDecos.length > 0
508
555
  ? DecorationSet.create(view.state.doc, [
@@ -11,7 +11,11 @@ export { renderTwCaret } from "./editor-surface/tw-caret";
11
11
 
12
12
  // Review rail
13
13
  export { TwReviewRail, type TwReviewRailProps, type ReviewRailTab } from "./review/tw-review-rail";
14
- export { TwCommentSidebar } from "./review/tw-comment-sidebar";
14
+ export { TwCommentSidebar, type TwCommentSidebarProps } from "./review/tw-comment-sidebar";
15
+ export {
16
+ CommentMarkdownRenderer,
17
+ type CommentMarkdownRendererProps,
18
+ } from "./review/comment-markdown-renderer";
15
19
  export { TwRevisionSidebar } from "./review/tw-revision-sidebar";
16
20
  export { TwHealthPanel } from "./review/tw-health-panel";
17
21
  export { TwWorkflowTab, type TwWorkflowTabProps } from "./review/tw-workflow-tab";
@@ -0,0 +1,57 @@
1
+ import React from "react";
2
+
3
+ import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // TwEndnoteArea (P8.7)
8
+ //
9
+ // Read-only area for document-end endnote placement. Unlike footnotes, which
10
+ // the chrome layer positions per-page via an absolute rectangle, endnotes in
11
+ // the default mode sit at the end of the document (Word's `w:pos="docEnd"`
12
+ // behavior). This component is mounted as a sibling of the chrome layer
13
+ // after the last page rect — not as an absolutely positioned chrome overlay.
14
+ //
15
+ // It renders:
16
+ // 1. A default 1px separator (1/3 of the parent width) along the top edge,
17
+ // and
18
+ // 2. The ordered endnote bodies via `TwRegionBlockRenderer` (P8.4).
19
+ //
20
+ // When `blocks` is empty the component returns `null` so the document-end
21
+ // slot stays visually absent.
22
+ //
23
+ // Per-section endnote placement (`w:endnotePr/w:pos` other than `docEnd`)
24
+ // is a follow-up — tracked with the P8.b polish pass.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface TwEndnoteAreaProps {
28
+ blocks: readonly SurfaceBlockSnapshot[];
29
+ "data-testid"?: string;
30
+ }
31
+
32
+ export const TwEndnoteArea: React.FC<TwEndnoteAreaProps> = ({
33
+ blocks,
34
+ "data-testid": testId,
35
+ }) => {
36
+ if (blocks.length === 0) return null;
37
+ return (
38
+ <div
39
+ data-endnote-area
40
+ data-testid={testId}
41
+ style={{ marginTop: "24pt" }}
42
+ >
43
+ <div
44
+ data-endnote-separator
45
+ style={{
46
+ width: "33%",
47
+ height: "1px",
48
+ backgroundColor: "currentColor",
49
+ marginBottom: "8pt",
50
+ }}
51
+ />
52
+ <TwRegionBlockRenderer blocks={blocks} />
53
+ </div>
54
+ );
55
+ };
56
+
57
+ export default TwEndnoteArea;
@@ -0,0 +1,71 @@
1
+ import React from "react";
2
+
3
+ import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // TwFootnoteArea (P8.6)
8
+ //
9
+ // Read-only area mounted at an absolute pixel rectangle inside a per-page
10
+ // chrome overlay. Renders:
11
+ // 1. A default 1px separator (1/3 of the parent width) along the top edge,
12
+ // and
13
+ // 2. The ordered footnote bodies via `TwRegionBlockRenderer` (P8.4).
14
+ //
15
+ // The chrome layer (P8.8) computes the `topPx / leftPx / widthPx / heightPx`
16
+ // rectangle from the page graph's `regions.footnotes` geometry and mounts
17
+ // this component above the footer band.
18
+ //
19
+ // The default separator matches Word's implicit footnote separator; reading
20
+ // the `w:separator` / `w:continuationSeparator` parts from the footnotes
21
+ // package is deferred to P8.b polish. No active-slot / portal plumbing in
22
+ // this pass — the P8 plan only reserves portal slots for header / footer.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface TwFootnoteAreaProps {
26
+ pageIndex: number;
27
+ blocks: readonly SurfaceBlockSnapshot[];
28
+ topPx: number;
29
+ leftPx: number;
30
+ widthPx: number;
31
+ heightPx: number;
32
+ "data-testid"?: string;
33
+ }
34
+
35
+ export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = ({
36
+ pageIndex,
37
+ blocks,
38
+ topPx,
39
+ leftPx,
40
+ widthPx,
41
+ heightPx,
42
+ "data-testid": testId,
43
+ }) => {
44
+ return (
45
+ <div
46
+ data-footnote-area
47
+ data-page-index={pageIndex}
48
+ data-testid={testId}
49
+ style={{
50
+ position: "absolute",
51
+ top: `${topPx}px`,
52
+ left: `${leftPx}px`,
53
+ width: `${widthPx}px`,
54
+ height: `${heightPx}px`,
55
+ }}
56
+ >
57
+ <div
58
+ data-footnote-separator
59
+ style={{
60
+ width: `${Math.round(widthPx / 3)}px`,
61
+ height: "1px",
62
+ backgroundColor: "currentColor",
63
+ marginBottom: "4pt",
64
+ }}
65
+ />
66
+ <TwRegionBlockRenderer blocks={blocks} />
67
+ </div>
68
+ );
69
+ };
70
+
71
+ export default TwFootnoteArea;
@@ -0,0 +1,73 @@
1
+ import React from "react";
2
+
3
+ import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // TwPageFooterBand (P8.5)
8
+ //
9
+ // Symmetric counterpart to `TwPageHeaderBand` (see its header for design
10
+ // context). The footer band positions itself via `bottom` rather than
11
+ // `top` so the chrome layer can measure the footer margin up from the
12
+ // page rect's bottom edge and keep footer content pinned correctly when
13
+ // page size / margin changes.
14
+ //
15
+ // When `isActiveSlot` is true, the band emits a single `data-pm-portal-slot`
16
+ // div tagged `data-page-band-slot="footer"` — the chrome layer portals the
17
+ // PM surface into this target in P8.10. Otherwise it renders the footer
18
+ // story's `SurfaceBlockSnapshot[]` through `TwRegionBlockRenderer` (P8.4).
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export interface TwPageFooterBandProps {
22
+ pageIndex: number;
23
+ blocks: readonly SurfaceBlockSnapshot[];
24
+ bandHeightPx: number;
25
+ bottomPx: number;
26
+ leftPx: number;
27
+ widthPx: number;
28
+ /** True when this band is the active-story slot. Renders portal target instead of read-only DOM. */
29
+ isActiveSlot: boolean;
30
+ onClick: () => void;
31
+ "data-testid"?: string;
32
+ }
33
+
34
+ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = ({
35
+ pageIndex,
36
+ blocks,
37
+ bandHeightPx,
38
+ bottomPx,
39
+ leftPx,
40
+ widthPx,
41
+ isActiveSlot,
42
+ onClick,
43
+ "data-testid": testId,
44
+ }) => {
45
+ return (
46
+ <div
47
+ data-page-band="footer"
48
+ data-page-index={pageIndex}
49
+ data-testid={testId}
50
+ onClick={onClick}
51
+ style={{
52
+ position: "absolute",
53
+ bottom: `${bottomPx}px`,
54
+ left: `${leftPx}px`,
55
+ width: `${widthPx}px`,
56
+ height: `${bandHeightPx}px`,
57
+ cursor: "pointer",
58
+ }}
59
+ >
60
+ {isActiveSlot ? (
61
+ <div
62
+ data-pm-portal-slot
63
+ data-page-band-slot="footer"
64
+ style={{ width: "100%", height: "100%" }}
65
+ />
66
+ ) : (
67
+ <TwRegionBlockRenderer blocks={blocks} />
68
+ )}
69
+ </div>
70
+ );
71
+ };
72
+
73
+ export default TwPageFooterBand;
@@ -0,0 +1,74 @@
1
+ import React from "react";
2
+
3
+ import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // TwPageHeaderBand (P8.5)
8
+ //
9
+ // Read-only band mounted at an absolute pixel position inside a per-page
10
+ // chrome overlay. The band either:
11
+ // - Renders the header story's `SurfaceBlockSnapshot[]` through
12
+ // `TwRegionBlockRenderer` (P8.4) as pure presentational DOM, or
13
+ // - Emits a single `data-pm-portal-slot` div when the chrome layer has
14
+ // promoted this band to the active story slot (P8.10 wires the PM
15
+ // surface into this target via React portal).
16
+ //
17
+ // Clicks on the band bubble to the chrome layer's `openStory` dispatch
18
+ // (wired in P8.8 / P8.10) so legal reviewers can promote a header into
19
+ // the active editing surface.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export interface TwPageHeaderBandProps {
23
+ pageIndex: number;
24
+ blocks: readonly SurfaceBlockSnapshot[];
25
+ bandHeightPx: number;
26
+ topPx: number;
27
+ leftPx: number;
28
+ widthPx: number;
29
+ /** True when this band is the active-story slot. Renders portal target instead of read-only DOM. */
30
+ isActiveSlot: boolean;
31
+ onClick: () => void;
32
+ "data-testid"?: string;
33
+ }
34
+
35
+ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = ({
36
+ pageIndex,
37
+ blocks,
38
+ bandHeightPx,
39
+ topPx,
40
+ leftPx,
41
+ widthPx,
42
+ isActiveSlot,
43
+ onClick,
44
+ "data-testid": testId,
45
+ }) => {
46
+ return (
47
+ <div
48
+ data-page-band="header"
49
+ data-page-index={pageIndex}
50
+ data-testid={testId}
51
+ onClick={onClick}
52
+ style={{
53
+ position: "absolute",
54
+ top: `${topPx}px`,
55
+ left: `${leftPx}px`,
56
+ width: `${widthPx}px`,
57
+ height: `${bandHeightPx}px`,
58
+ cursor: "pointer",
59
+ }}
60
+ >
61
+ {isActiveSlot ? (
62
+ <div
63
+ data-pm-portal-slot
64
+ data-page-band-slot="header"
65
+ style={{ width: "100%", height: "100%" }}
66
+ />
67
+ ) : (
68
+ <TwRegionBlockRenderer blocks={blocks} />
69
+ )}
70
+ </div>
71
+ );
72
+ };
73
+
74
+ export default TwPageHeaderBand;