@beyondwork/docx-react-component 1.0.18 → 1.0.20

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 (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. package/src/validation/docx-comment-proof.ts +707 -0
@@ -0,0 +1,145 @@
1
+ import type {
2
+ DocumentNavigationSnapshot,
3
+ EditorStoryTarget,
4
+ SearchOptions,
5
+ SearchResultSnapshot,
6
+ SelectionSnapshot,
7
+ } from "../api/public-types";
8
+ import {
9
+ MAIN_STORY_TARGET,
10
+ storyTargetsEqual,
11
+ } from "../core/selection/mapping.ts";
12
+ import {
13
+ createSelectionSnapshot,
14
+ type CanonicalDocumentEnvelope,
15
+ } from "../core/state/editor-state.ts";
16
+ import {
17
+ searchSecondaryStories,
18
+ searchSurfaceBlocks,
19
+ } from "../core/search/search-text.ts";
20
+ import { findPageForOffset } from "./document-navigation.ts";
21
+ import {
22
+ buildResolvedSections,
23
+ resolveSectionForStoryTarget,
24
+ } from "./document-layout.ts";
25
+ import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
26
+
27
+ export function searchDocument(
28
+ document: CanonicalDocumentEnvelope,
29
+ selection: SelectionSnapshot,
30
+ activeStory: EditorStoryTarget,
31
+ navigation: DocumentNavigationSnapshot,
32
+ query: string,
33
+ options: SearchOptions = {},
34
+ ): SearchResultSnapshot[] {
35
+ const normalizedQuery = query.trim();
36
+ if (!normalizedQuery) {
37
+ return [];
38
+ }
39
+
40
+ const mainSurface = createEditorSurfaceSnapshot(
41
+ document,
42
+ createSelectionSnapshot(selection.anchor, selection.head),
43
+ MAIN_STORY_TARGET,
44
+ );
45
+ const sections = buildResolvedSections(document);
46
+ const combined: SearchResultSnapshot[] = [];
47
+
48
+ for (const match of searchSurfaceBlocks(mainSurface.blocks, normalizedQuery, options)) {
49
+ const pageIndex = findPageForOffset(navigation.pages, match.from);
50
+ combined.push({
51
+ resultId: `search-main-${combined.length}`,
52
+ anchor: {
53
+ kind: "range",
54
+ from: match.from,
55
+ to: match.to,
56
+ assoc: {
57
+ start: -1,
58
+ end: 1,
59
+ },
60
+ },
61
+ excerpt: match.excerpt,
62
+ isActive: false,
63
+ storyTarget: MAIN_STORY_TARGET,
64
+ sectionIndex: navigation.pages[pageIndex]?.sectionIndex ?? 0,
65
+ pageIndex,
66
+ });
67
+ }
68
+
69
+ for (const match of searchSecondaryStories(
70
+ mainSurface.secondaryStories,
71
+ normalizedQuery,
72
+ options,
73
+ )) {
74
+ const section = resolveSectionForStoryTarget(
75
+ document,
76
+ sections,
77
+ match.storyTarget,
78
+ );
79
+ const pageIndex =
80
+ section === undefined
81
+ ? undefined
82
+ : navigation.pages.find((page) => page.sectionIndex === section.index)
83
+ ?.pageIndex ?? 0;
84
+
85
+ combined.push({
86
+ resultId: `search-secondary-${combined.length}`,
87
+ anchor: {
88
+ kind: "range",
89
+ from: match.from,
90
+ to: match.to,
91
+ assoc: {
92
+ start: -1,
93
+ end: 1,
94
+ },
95
+ },
96
+ excerpt: match.excerpt,
97
+ isActive: false,
98
+ storyTarget: match.storyTarget,
99
+ ...(section ? { sectionIndex: section.index } : {}),
100
+ ...(pageIndex !== undefined ? { pageIndex } : {}),
101
+ });
102
+ }
103
+
104
+ const limited = combined.slice(0, options.limit ?? Number.POSITIVE_INFINITY);
105
+ const activeIndex = getActiveSearchResultIndex(limited, selection, activeStory);
106
+
107
+ return limited.map((result, index) => ({
108
+ ...result,
109
+ isActive: index === activeIndex,
110
+ }));
111
+ }
112
+
113
+ function getActiveSearchResultIndex(
114
+ results: readonly SearchResultSnapshot[],
115
+ selection: SelectionSnapshot,
116
+ activeStory: EditorStoryTarget,
117
+ ): number {
118
+ if (results.length === 0) {
119
+ return -1;
120
+ }
121
+
122
+ const selectionFrom = Math.min(selection.anchor, selection.head);
123
+ const selectionTo = Math.max(selection.anchor, selection.head);
124
+ const activeIndex = results.findIndex((result) => {
125
+ if (!result.storyTarget || !storyTargetsEqual(result.storyTarget, activeStory)) {
126
+ return false;
127
+ }
128
+
129
+ if (result.anchor.kind !== "range") {
130
+ return false;
131
+ }
132
+
133
+ if (selectionFrom === selectionTo) {
134
+ return (
135
+ selectionFrom >= result.anchor.from && selectionFrom <= result.anchor.to
136
+ );
137
+ }
138
+
139
+ return (
140
+ selectionFrom < result.anchor.to && selectionTo > result.anchor.from
141
+ );
142
+ });
143
+
144
+ return activeIndex >= 0 ? activeIndex : 0;
145
+ }
@@ -9,8 +9,14 @@ interface NumberingSequenceState {
9
9
  lastLevel: number | null;
10
10
  }
11
11
 
12
+ export interface NumberingPrefixResult {
13
+ text: string;
14
+ suffix?: "tab" | "space" | "nothing";
15
+ }
16
+
12
17
  export interface NumberingPrefixResolver {
13
18
  resolve(numbering: ParagraphNode["numbering"] | undefined): string | null;
19
+ resolveDetailed(numbering: ParagraphNode["numbering"] | undefined): NumberingPrefixResult | null;
14
20
  }
15
21
 
16
22
  const DEFAULT_START_AT = 1;
@@ -20,34 +26,49 @@ export function createNumberingPrefixResolver(
20
26
  ): NumberingPrefixResolver {
21
27
  const sequenceStates = new Map<string, NumberingSequenceState>();
22
28
 
29
+ function resolveInternal(numbering: ParagraphNode["numbering"] | undefined): NumberingPrefixResult | null {
30
+ if (!numbering) {
31
+ return null;
32
+ }
33
+
34
+ const instance = catalog.instances[numbering.numberingInstanceId];
35
+ if (!instance) {
36
+ return null;
37
+ }
38
+
39
+ const definition = catalog.abstractDefinitions[instance.abstractNumberingId];
40
+ if (!definition) {
41
+ return null;
42
+ }
43
+
44
+ const levelDefinitions = new Map(
45
+ definition.levels.map((level) => [level.level, level] as const),
46
+ );
47
+ const levelDefinition = levelDefinitions.get(numbering.level);
48
+ if (!levelDefinition || levelDefinition.format === "none") {
49
+ return null;
50
+ }
51
+
52
+ const sequenceState = getSequenceState(sequenceStates, numbering.numberingInstanceId);
53
+ advanceSequence(sequenceState, numbering.level, levelDefinitions, instance.overrides);
54
+
55
+ // When isLegalNumbering is true, use decimal format for all referenced levels
56
+ const effectiveLevelDefs = levelDefinition.isLegalNumbering
57
+ ? new Map(Array.from(levelDefinitions.entries()).map(([k, v]) => [k, { ...v, format: "decimal" }]))
58
+ : levelDefinitions;
59
+
60
+ const text = renderLevelText(levelDefinition.text, sequenceState.counters, effectiveLevelDefs);
61
+ if (text === null) return null;
62
+ return { text, suffix: levelDefinition.suffix };
63
+ }
64
+
23
65
  return {
24
66
  resolve(numbering) {
25
- if (!numbering) {
26
- return null;
27
- }
28
-
29
- const instance = catalog.instances[numbering.numberingInstanceId];
30
- if (!instance) {
31
- return null;
32
- }
33
-
34
- const definition = catalog.abstractDefinitions[instance.abstractNumberingId];
35
- if (!definition) {
36
- return null;
37
- }
38
-
39
- const levelDefinitions = new Map(
40
- definition.levels.map((level) => [level.level, level] as const),
41
- );
42
- const levelDefinition = levelDefinitions.get(numbering.level);
43
- if (!levelDefinition || levelDefinition.format === "none") {
44
- return null;
45
- }
46
-
47
- const sequenceState = getSequenceState(sequenceStates, numbering.numberingInstanceId);
48
- advanceSequence(sequenceState, numbering.level, levelDefinitions, instance.overrides);
49
-
50
- return renderLevelText(levelDefinition.text, sequenceState.counters, levelDefinitions);
67
+ const result = resolveInternal(numbering);
68
+ return result?.text ?? null;
69
+ },
70
+ resolveDetailed(numbering) {
71
+ return resolveInternal(numbering);
51
72
  },
52
73
  };
53
74
  }
@@ -0,0 +1,212 @@
1
+ import type {
2
+ PageLayoutSnapshot,
3
+ SurfaceBlockSnapshot,
4
+ SurfaceInlineSegment,
5
+ } from "../api/public-types";
6
+
7
+ const DEFAULT_FONT_SIZE_POINTS = 12;
8
+ const DEFAULT_LINE_HEIGHT_TWIPS = 280;
9
+ const MIN_BLOCK_HEIGHT_TWIPS = 240;
10
+ const TABLE_ROW_PADDING_TWIPS = 120;
11
+
12
+ export const DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP = 1 / 15;
13
+
14
+ export function estimateBlockHeight(
15
+ block: SurfaceBlockSnapshot | undefined,
16
+ columnWidth: number,
17
+ ): number {
18
+ if (!block) {
19
+ return 0;
20
+ }
21
+
22
+ switch (block.kind) {
23
+ case "paragraph":
24
+ return estimateParagraphHeight(block, columnWidth);
25
+ case "table":
26
+ return estimateTableHeight(block, columnWidth);
27
+ case "sdt_block":
28
+ return Math.max(
29
+ MIN_BLOCK_HEIGHT_TWIPS,
30
+ block.children.reduce((total, child) => total + estimateBlockHeight(child, columnWidth), 0),
31
+ );
32
+ case "opaque_block":
33
+ return block.label === "Section break" ? 0 : MIN_BLOCK_HEIGHT_TWIPS;
34
+ }
35
+ }
36
+
37
+ export function estimateParagraphHeight(
38
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
39
+ columnWidth: number,
40
+ ): number {
41
+ const lineHeight = estimateParagraphLineHeight(block);
42
+ const lineCount = estimateParagraphLineCount(block, columnWidth);
43
+ const spacingBefore = block.spacing?.before ?? 0;
44
+ const spacingAfter = block.spacing?.after ?? 0;
45
+ return Math.max(
46
+ MIN_BLOCK_HEIGHT_TWIPS,
47
+ lineHeight * lineCount + spacingBefore + spacingAfter,
48
+ );
49
+ }
50
+
51
+ export function estimateParagraphLineHeight(
52
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
53
+ ): number {
54
+ const explicitLine = block.spacing?.line;
55
+ if (typeof explicitLine === "number" && explicitLine > 0) {
56
+ return explicitLine;
57
+ }
58
+
59
+ const fontSizeHalfPoints = block.segments.find(
60
+ (segment): segment is Extract<SurfaceInlineSegment, { kind: "text" }> =>
61
+ segment.kind === "text" && typeof segment.markAttrs?.fontSize === "number",
62
+ )?.markAttrs?.fontSize;
63
+ const fontSizePoints =
64
+ typeof fontSizeHalfPoints === "number"
65
+ ? fontSizeHalfPoints / 2
66
+ : DEFAULT_FONT_SIZE_POINTS;
67
+ return Math.max(
68
+ DEFAULT_LINE_HEIGHT_TWIPS,
69
+ Math.round(fontSizePoints * 20 * 1.35),
70
+ );
71
+ }
72
+
73
+ export function estimateParagraphLineCount(
74
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
75
+ columnWidth: number,
76
+ ): number {
77
+ const averageCharWidth = estimateAverageCharWidth(block);
78
+ const charsPerLine = Math.max(12, Math.floor(columnWidth / averageCharWidth));
79
+ let lineCount = 1;
80
+ let currentLineChars = estimatedPrefixLength(block);
81
+
82
+ for (const segment of block.segments) {
83
+ switch (segment.kind) {
84
+ case "text":
85
+ currentLineChars += Array.from(segment.text).length;
86
+ while (currentLineChars > charsPerLine) {
87
+ lineCount += 1;
88
+ currentLineChars -= charsPerLine;
89
+ }
90
+ break;
91
+ case "tab":
92
+ currentLineChars += 4;
93
+ break;
94
+ case "hard_break":
95
+ lineCount += 1;
96
+ currentLineChars = 0;
97
+ break;
98
+ case "image":
99
+ lineCount += Math.max(1, Math.round(segment.display === "floating" ? 2 : 1));
100
+ currentLineChars = 0;
101
+ break;
102
+ case "note_ref":
103
+ currentLineChars += 1;
104
+ break;
105
+ case "opaque_inline":
106
+ if (segment.presentation !== "quiet-marker") {
107
+ currentLineChars += segment.label.length > 0 ? 1 : 0;
108
+ }
109
+ break;
110
+ }
111
+ }
112
+
113
+ return Math.max(1, lineCount);
114
+ }
115
+
116
+ export function getUsablePageHeight(layout: PageLayoutSnapshot): number {
117
+ return Math.max(
118
+ 1440,
119
+ layout.pageHeight - layout.marginTop - layout.marginBottom,
120
+ );
121
+ }
122
+
123
+ export interface PageColumnMetric {
124
+ width: number;
125
+ space: number;
126
+ }
127
+
128
+ export function getUsableColumnMetrics(
129
+ layout: PageLayoutSnapshot,
130
+ ): PageColumnMetric[] {
131
+ const usableWidth = Math.max(
132
+ 1440,
133
+ layout.pageWidth - layout.marginLeft - layout.marginRight - layout.gutter,
134
+ );
135
+ const columnCount = Math.max(1, layout.columns);
136
+ if (
137
+ !layout.equalWidthColumns &&
138
+ layout.columnDefinitions.length > 0
139
+ ) {
140
+ return layout.columnDefinitions.map((column, index) => ({
141
+ width: Math.max(720, column.width),
142
+ space:
143
+ index < layout.columnDefinitions.length - 1
144
+ ? Math.max(0, column.space ?? 0)
145
+ : 0,
146
+ }));
147
+ }
148
+
149
+ const width = columnCount <= 1
150
+ ? usableWidth
151
+ : Math.max(720, Math.floor(usableWidth / columnCount));
152
+ return Array.from({ length: columnCount }, () => ({
153
+ width,
154
+ space: 0,
155
+ }));
156
+ }
157
+
158
+ export function getUsableColumnWidth(layout: PageLayoutSnapshot): number {
159
+ return getUsableColumnMetrics(layout)[0]?.width ?? 1440;
160
+ }
161
+
162
+ function estimateTableHeight(
163
+ block: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
164
+ columnWidth: number,
165
+ ): number {
166
+ let totalHeight = 0;
167
+ for (const row of block.rows) {
168
+ const explicitHeight = row.height ?? 0;
169
+ if (explicitHeight > 0) {
170
+ totalHeight += explicitHeight;
171
+ continue;
172
+ }
173
+
174
+ let rowHeight = MIN_BLOCK_HEIGHT_TWIPS;
175
+ for (const cell of row.cells) {
176
+ const cellHeight = cell.content.reduce(
177
+ (total, child) => total + estimateBlockHeight(child, columnWidth),
178
+ 0,
179
+ );
180
+ rowHeight = Math.max(rowHeight, cellHeight + TABLE_ROW_PADDING_TWIPS);
181
+ }
182
+ totalHeight += rowHeight;
183
+ }
184
+ return Math.max(MIN_BLOCK_HEIGHT_TWIPS, totalHeight);
185
+ }
186
+
187
+ function estimateAverageCharWidth(
188
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
189
+ ): number {
190
+ const fontSizeHalfPoints = block.segments.find(
191
+ (segment): segment is Extract<SurfaceInlineSegment, { kind: "text" }> =>
192
+ segment.kind === "text" && typeof segment.markAttrs?.fontSize === "number",
193
+ )?.markAttrs?.fontSize;
194
+ const fontSizePoints =
195
+ typeof fontSizeHalfPoints === "number"
196
+ ? fontSizeHalfPoints / 2
197
+ : DEFAULT_FONT_SIZE_POINTS;
198
+ return Math.max(96, Math.round(fontSizePoints * 12));
199
+ }
200
+
201
+ function estimatedPrefixLength(
202
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
203
+ ): number {
204
+ const prefix = block.numberingPrefix ?? "";
205
+ const suffix =
206
+ block.numberingSuffix === "space"
207
+ ? 1
208
+ : block.numberingSuffix === "tab"
209
+ ? 4
210
+ : 0;
211
+ return prefix.length + suffix;
212
+ }
@@ -155,6 +155,7 @@ function createDiagnosticsRenderSnapshot(
155
155
  isDirty: false,
156
156
  readOnly: true,
157
157
  selection: collapsedSelection(),
158
+ activeStory: { kind: "main" },
158
159
  documentStats: {
159
160
  storyLength: 0,
160
161
  commentCount: 0,
@@ -192,6 +193,14 @@ function createDiagnosticsRenderSnapshot(
192
193
  canRedo: false,
193
194
  readOnly: true,
194
195
  },
196
+ documentMode: "viewing",
197
+ protectionSnapshot: {
198
+ hasDocumentProtection: false,
199
+ enforcementActive: false,
200
+ ranges: [],
201
+ enforcedRangeCount: 0,
202
+ preservedRangeCount: 0,
203
+ },
195
204
  };
196
205
  }
197
206
 
@@ -1,4 +1,4 @@
1
- import type { RuntimeRenderSnapshot } from "../api/public-types";
1
+ import type { RuntimeRenderSnapshot, WorkflowScopeSnapshot } from "../api/public-types";
2
2
  import {
3
3
  createDetachedAnchor,
4
4
  createNodeAnchor,
@@ -21,6 +21,10 @@ export interface SessionCapabilities {
21
21
  /** Effective editor mode after accounting for runtime state. */
22
22
  mode: "editing" | "review" | "read-only-diagnostics";
23
23
 
24
+ // ── Document mode ──
25
+ /** Runtime document mode — editing authority, distinct from view/workspace mode. */
26
+ documentMode: "editing" | "suggesting" | "viewing";
27
+
24
28
  // ── Command capabilities ──
25
29
  canUndo: boolean;
26
30
  canRedo: boolean;
@@ -44,10 +48,22 @@ export interface SessionCapabilities {
44
48
  preserveOnlyCount: number;
45
49
  unsupportedFatalCount: number;
46
50
 
51
+ // ── Protection posture ──
52
+ /** Whether the source package declares document-level protection. */
53
+ hasDocumentProtection: boolean;
54
+ /** Count of permission ranges from the source package. */
55
+ protectedRangeCount: number;
56
+
47
57
  // ── Health ──
48
58
  /** Total count of health issues (preserve-only + unsupported-fatal + warnings). */
49
59
  healthIssueCount: number;
50
60
 
61
+ // ── Workflow ──
62
+ /** Whether a workflow overlay is currently applied. */
63
+ workflowOverlayPresent: boolean;
64
+ /** Whether the current selection is blocked by workflow scope enforcement. */
65
+ workflowBlocked: boolean;
66
+
51
67
  // ── Status ──
52
68
  isDirty: boolean;
53
69
  isReady: boolean;
@@ -63,11 +79,14 @@ export interface SessionCapabilities {
63
79
  export function deriveCapabilities(
64
80
  snapshot: RuntimeRenderSnapshot,
65
81
  reviewMode: "editing" | "review",
82
+ workflowScope?: WorkflowScopeSnapshot | null,
66
83
  ): SessionCapabilities {
67
84
  const hasFatalError = Boolean(snapshot.fatalError);
68
85
  const isReady = snapshot.isReady;
69
86
  const isReadOnly = snapshot.readOnly;
70
87
  const exportBlocked = snapshot.compatibility.blockExport;
88
+ const activeStory = snapshot.activeStory ?? { kind: "main" as const };
89
+ const documentMode = snapshot.documentMode ?? "editing";
71
90
 
72
91
  // Phase derivation
73
92
  const phase: SessionCapabilities["phase"] = !isReady
@@ -82,12 +101,13 @@ export function deriveCapabilities(
82
101
  ? "read-only-diagnostics"
83
102
  : reviewMode;
84
103
 
85
- // Command capabilities
86
- const canEdit = isReady && !isReadOnly && !hasFatalError;
104
+ // Command capabilities — document mode "viewing" disables editing
105
+ const canEdit = isReady && !isReadOnly && !hasFatalError && documentMode !== "viewing";
87
106
  const canUndo = snapshot.commandState.canUndo && canEdit;
88
107
  const canRedo = snapshot.commandState.canRedo && canEdit;
89
108
  const canAddComment =
90
109
  canEdit &&
110
+ activeStory.kind === "main" &&
91
111
  !snapshot.selection.isCollapsed &&
92
112
  Boolean(snapshot.surface) &&
93
113
  canCreateDocxCommentAnchor(snapshot.surface, toRuntimeAnchor(snapshot.selection.activeRange));
@@ -123,9 +143,17 @@ export function deriveCapabilities(
123
143
 
124
144
  const healthIssueCount = preserveOnlyCount + unsupportedFatalCount + snapshot.warnings.length;
125
145
 
146
+ const protection = snapshot.protectionSnapshot;
147
+ const hasDocumentProtection = protection?.hasDocumentProtection ?? false;
148
+ const protectedRangeCount = protection?.ranges?.length ?? 0;
149
+
150
+ const workflowOverlayPresent = workflowScope?.overlayPresent ?? false;
151
+ const workflowBlocked = (workflowScope?.blockedReasons?.length ?? 0) > 0;
152
+
126
153
  return {
127
154
  phase,
128
155
  mode,
156
+ documentMode,
129
157
  canUndo,
130
158
  canRedo,
131
159
  canEdit,
@@ -140,7 +168,11 @@ export function deriveCapabilities(
140
168
  exportBlocked,
141
169
  preserveOnlyCount,
142
170
  unsupportedFatalCount,
171
+ hasDocumentProtection,
172
+ protectedRangeCount,
143
173
  healthIssueCount,
174
+ workflowOverlayPresent,
175
+ workflowBlocked,
144
176
  isDirty: snapshot.isDirty,
145
177
  isReady,
146
178
  hasFatalError,