@beyondwork/docx-react-component 1.0.67 → 1.0.70

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 (384) hide show
  1. package/README.md +75 -932
  2. package/package.json +26 -27
  3. package/src/api/anchor-conversion.ts +43 -0
  4. package/src/api/editor-state-types.ts +2 -1
  5. package/src/api/public-types.ts +504 -101
  6. package/src/api/session-state.ts +4 -0
  7. package/src/api/v3/README.md +91 -0
  8. package/src/api/v3/_create.ts +146 -0
  9. package/src/api/v3/_layer-metadata.ts +362 -0
  10. package/src/api/v3/_mocks.ts +84 -0
  11. package/src/api/v3/_runtime-handle.ts +162 -0
  12. package/src/api/v3/_ux-response.ts +73 -0
  13. package/src/api/v3/ai/_metadata-audit.ts +225 -0
  14. package/src/api/v3/ai/attach.ts +235 -0
  15. package/src/api/v3/ai/bundle.ts +132 -0
  16. package/src/api/v3/ai/explain.ts +144 -0
  17. package/src/api/v3/ai/export.ts +54 -0
  18. package/src/api/v3/ai/inspect.ts +118 -0
  19. package/src/api/v3/ai/policy.ts +77 -0
  20. package/src/api/v3/ai/replacement.ts +341 -0
  21. package/src/api/v3/ai/resolve.ts +133 -0
  22. package/src/api/v3/index.ts +79 -0
  23. package/src/api/v3/runtime/chart.ts +310 -0
  24. package/src/api/v3/runtime/clipboard.ts +81 -0
  25. package/src/api/v3/runtime/collab.ts +331 -0
  26. package/src/api/v3/runtime/content.ts +236 -0
  27. package/src/api/v3/runtime/document.ts +282 -0
  28. package/src/api/v3/runtime/formatting.ts +186 -0
  29. package/src/api/v3/runtime/geometry.ts +349 -0
  30. package/src/api/v3/runtime/layout.ts +108 -0
  31. package/src/api/v3/runtime/review.ts +129 -0
  32. package/src/api/v3/runtime/search.ts +74 -0
  33. package/src/api/v3/runtime/table.ts +63 -0
  34. package/src/api/v3/runtime/workflow.ts +434 -0
  35. package/src/api/v3/ui/_context.ts +86 -0
  36. package/src/api/v3/ui/_create.ts +65 -0
  37. package/src/api/v3/ui/_types.ts +520 -0
  38. package/src/api/v3/ui/chrome-composition.ts +342 -0
  39. package/src/{ui-tailwind/chrome → api/v3/ui}/chrome-preset-model.ts +11 -1
  40. package/src/api/v3/ui/chrome.ts +476 -0
  41. package/src/api/v3/ui/debug.ts +124 -0
  42. package/src/api/v3/ui/index.ts +64 -0
  43. package/src/api/v3/ui/overlays-visibility.ts +170 -0
  44. package/src/api/v3/ui/overlays.ts +427 -0
  45. package/src/api/v3/ui/scope.ts +71 -0
  46. package/src/api/v3/ui/session.ts +100 -0
  47. package/src/api/v3/ui/surface.ts +170 -0
  48. package/src/api/v3/ui/viewport.ts +303 -0
  49. package/src/core/commands/index.ts +28 -6
  50. package/src/core/commands/list-commands.ts +3 -2
  51. package/src/core/commands/section-layout-commands.ts +9 -8
  52. package/src/core/schema/text-schema.ts +16 -0
  53. package/src/core/selection/mapping.ts +33 -72
  54. package/src/core/state/editor-state.ts +96 -189
  55. package/src/index.ts +23 -4
  56. package/src/io/chart-preview-resolver.ts +1 -1
  57. package/src/io/docx-session.ts +36 -4797
  58. package/src/io/export/build-app-properties-xml.ts +1 -1
  59. package/src/io/export/serialize-comments.ts +1 -1
  60. package/src/io/export/serialize-headers-footers.ts +6 -1
  61. package/src/io/export/serialize-main-document.ts +45 -0
  62. package/src/io/export/serialize-run-formatting.ts +17 -2
  63. package/src/io/export/twip.ts +1 -1
  64. package/src/io/normalize/normalize-text.ts +27 -20
  65. package/src/io/ooxml/chart/parse-series.ts +1 -1
  66. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  67. package/src/io/ooxml/chart/types.ts +1 -1
  68. package/src/io/ooxml/classify-embedding.ts +83 -33
  69. package/src/io/ooxml/parse-fill.ts +1 -1
  70. package/src/io/ooxml/parse-main-document.ts +71 -1
  71. package/src/io/ooxml/parse-object.ts +14 -10
  72. package/src/io/ooxml/parse-run-formatting.ts +47 -1
  73. package/src/io/ooxml/property-grab-bag.ts +2 -2
  74. package/src/io/ooxml/units.ts +11 -0
  75. package/src/io/ooxml/workflow-payload.ts +282 -7
  76. package/src/model/anchor.ts +85 -0
  77. package/src/model/canonical-document.ts +351 -15
  78. package/src/model/chart-types.ts +1 -1
  79. package/src/model/layout/index.ts +83 -0
  80. package/src/model/layout/page-graph-types.ts +181 -0
  81. package/src/model/layout/page-layout-snapshot.ts +105 -0
  82. package/src/model/layout/resolved-layout-types.ts +47 -0
  83. package/src/model/layout/runtime-page-graph-types.ts +102 -0
  84. package/src/model/paragraph-scope-ids.ts +72 -0
  85. package/src/model/review/comment-types.ts +112 -0
  86. package/src/model/review/index.ts +2 -0
  87. package/src/model/review/revision-types.ts +215 -0
  88. package/src/model/snapshot.ts +32 -0
  89. package/src/review/store/comment-store.ts +21 -47
  90. package/src/review/store/revision-types.ts +40 -198
  91. package/src/runtime/collab/base-doc-fingerprint.ts +6 -1
  92. package/src/runtime/collab/runtime-collab-sync.ts +13 -3
  93. package/src/runtime/collab-session.ts +1 -1
  94. package/src/runtime/debug/build-debug-inspector-snapshot.ts +686 -0
  95. package/src/runtime/debug/event-ring-buffer.ts +64 -0
  96. package/src/runtime/debug/probability-sampler.ts +18 -0
  97. package/src/runtime/debug/runtime-debug-facet.ts +67 -0
  98. package/src/runtime/debug/stage-tokens.ts +31 -0
  99. package/src/runtime/debug/telemetry-bus.ts +271 -0
  100. package/src/runtime/debug/types.ts +275 -0
  101. package/src/runtime/debug/wrap-ref-for-telemetry.ts +118 -0
  102. package/src/runtime/document-layout.ts +8 -6
  103. package/src/runtime/document-runtime.ts +843 -1141
  104. package/src/runtime/document-search.ts +1 -1
  105. package/src/runtime/edit-ops/index.ts +1 -1
  106. package/src/runtime/external-send-runtime.ts +1 -1
  107. package/src/runtime/formatting/document-lookup.ts +235 -0
  108. package/src/runtime/formatting/field/registry.ts +41 -0
  109. package/src/runtime/{field-resolver.ts → formatting/field/resolver.ts} +27 -2
  110. package/src/runtime/formatting/font-resolution.ts +83 -0
  111. package/src/runtime/formatting/formatting-context.ts +903 -0
  112. package/src/runtime/formatting/formatting-types.ts +157 -0
  113. package/src/runtime/{hyperlink-color-resolver.ts → formatting/hyperlink-color.ts} +2 -2
  114. package/src/runtime/formatting/index.ts +125 -0
  115. package/src/runtime/{resolved-numbering-geometry.ts → formatting/numbering/geometry.ts} +1 -1
  116. package/src/runtime/{numbering-prefix.ts → formatting/numbering/prefix.ts} +170 -3
  117. package/src/runtime/formatting/paragraph-style-resolver.ts +92 -0
  118. package/src/runtime/formatting/projector.ts +75 -0
  119. package/src/runtime/formatting/resolve-effective.ts +407 -0
  120. package/src/runtime/formatting/revision-display.ts +105 -0
  121. package/src/runtime/{paragraph-style-resolver.ts → formatting/style-cascade.ts} +84 -141
  122. package/src/runtime/{table-style-resolver.ts → formatting/table-style-resolver.ts} +1 -1
  123. package/src/runtime/formatting/telemetry-bridge.ts +106 -0
  124. package/src/runtime/{theme-color-resolver.ts → formatting/theme-color.ts} +2 -30
  125. package/src/runtime/geometry/caret-geometry.ts +164 -0
  126. package/src/runtime/geometry/geometry-facet.ts +364 -0
  127. package/src/runtime/geometry/geometry-types.ts +256 -0
  128. package/src/runtime/geometry/hit-test.ts +125 -0
  129. package/src/runtime/geometry/index.ts +71 -0
  130. package/src/runtime/geometry/inert-geometry-facet.ts +43 -0
  131. package/src/runtime/geometry/invalidation.ts +35 -0
  132. package/src/runtime/geometry/object-handles.ts +77 -0
  133. package/src/runtime/geometry/overlay-rects.ts +85 -0
  134. package/src/runtime/geometry/project-anchors.ts +100 -0
  135. package/src/runtime/geometry/project-fragments.ts +216 -0
  136. package/src/runtime/geometry/projector.ts +129 -0
  137. package/src/runtime/geometry/replacement-envelope.ts +130 -0
  138. package/src/runtime/geometry/viewport.ts +218 -0
  139. package/src/runtime/layout/compat-input-ledger.ts +211 -0
  140. package/src/runtime/layout/index.ts +6 -1
  141. package/src/runtime/layout/inert-layout-facet.ts +12 -7
  142. package/src/runtime/layout/layout-engine-instance.ts +189 -11
  143. package/src/runtime/layout/layout-engine-version.ts +450 -1
  144. package/src/runtime/layout/layout-facet-types.ts +60 -0
  145. package/src/runtime/layout/layout-measurement-provider.ts +13 -0
  146. package/src/runtime/layout/measurement-backend-canvas.ts +14 -2
  147. package/src/runtime/layout/measurement-backend-empirical.ts +23 -4
  148. package/src/runtime/layout/page-graph.ts +62 -209
  149. package/src/runtime/layout/page-story-resolver.ts +7 -12
  150. package/src/runtime/layout/paginated-layout-engine.ts +186 -11
  151. package/src/runtime/layout/project-block-fragments.ts +11 -0
  152. package/src/runtime/layout/projector.ts +90 -0
  153. package/src/runtime/layout/public-facet.ts +187 -442
  154. package/src/runtime/layout/resolved-formatting-state.ts +158 -26
  155. package/src/runtime/layout/table-render-plan.ts +1 -1
  156. package/src/runtime/prerender/cache-envelope.ts +6 -1
  157. package/src/runtime/prerender/prerender-document.ts +18 -23
  158. package/src/runtime/render/decoration-resolver.ts +1 -1
  159. package/src/runtime/render/render-frame-types.ts +20 -0
  160. package/src/runtime/render/render-kernel.ts +94 -25
  161. package/src/runtime/scopes/_formatting-seam.ts +262 -0
  162. package/src/runtime/scopes/_scope-dependencies.ts +49 -0
  163. package/src/runtime/scopes/action-validation.ts +356 -0
  164. package/src/runtime/scopes/attach-explanation.ts +102 -0
  165. package/src/runtime/scopes/audit-bundle.ts +71 -0
  166. package/src/runtime/scopes/compile-scope-bundle.ts +163 -0
  167. package/src/runtime/scopes/compile-scope.ts +262 -0
  168. package/src/runtime/scopes/compiler-service.ts +431 -0
  169. package/src/runtime/scopes/create-issue.ts +107 -0
  170. package/src/runtime/scopes/enumerate-scopes.ts +543 -0
  171. package/src/runtime/scopes/evidence.ts +233 -0
  172. package/src/runtime/scopes/index.ts +150 -0
  173. package/src/runtime/scopes/position-map.ts +214 -0
  174. package/src/runtime/scopes/preservation-boundary.ts +91 -0
  175. package/src/runtime/scopes/projector.ts +49 -0
  176. package/src/runtime/scopes/replaceability.ts +87 -0
  177. package/src/runtime/scopes/replacement/apply.ts +228 -0
  178. package/src/runtime/scopes/replacement/compile.ts +59 -0
  179. package/src/runtime/scopes/replacement/propose.ts +42 -0
  180. package/src/runtime/scopes/resolve-reference.ts +347 -0
  181. package/src/runtime/scopes/review-bundle.ts +141 -0
  182. package/src/runtime/scopes/scope-kinds/_paragraph-text.ts +57 -0
  183. package/src/runtime/scopes/scope-kinds/_table-text.ts +42 -0
  184. package/src/runtime/scopes/scope-kinds/comment-thread.ts +59 -0
  185. package/src/runtime/scopes/scope-kinds/field.ts +65 -0
  186. package/src/runtime/scopes/scope-kinds/heading.ts +84 -0
  187. package/src/runtime/scopes/scope-kinds/list-item.ts +77 -0
  188. package/src/runtime/scopes/scope-kinds/paragraph.ts +182 -0
  189. package/src/runtime/scopes/scope-kinds/revision.ts +62 -0
  190. package/src/runtime/scopes/scope-kinds/table-cell.ts +57 -0
  191. package/src/runtime/scopes/scope-kinds/table-row.ts +61 -0
  192. package/src/runtime/scopes/scope-kinds/table.ts +55 -0
  193. package/src/runtime/scopes/scope-range.ts +208 -0
  194. package/src/runtime/scopes/semantic-scope-types.ts +454 -0
  195. package/src/runtime/scopes/workflow-overlap.ts +92 -0
  196. package/src/runtime/selection/index.ts +1 -1
  197. package/src/runtime/structure-ops/fragment-insert.ts +1 -1
  198. package/src/runtime/structure-ops/index.ts +1 -1
  199. package/src/runtime/surface-projection.ts +232 -262
  200. package/src/runtime/units.ts +4 -2
  201. package/src/runtime/workflow/coordinator.ts +1348 -0
  202. package/src/runtime/workflow/derived-scope-resolver.ts +125 -0
  203. package/src/runtime/workflow/index.ts +25 -0
  204. package/src/runtime/workflow/markup-mode-policy.ts +98 -0
  205. package/src/runtime/{workflow-markup.ts → workflow/markup.ts} +6 -6
  206. package/src/runtime/workflow/metadata-persistence.ts +306 -0
  207. package/src/runtime/workflow/metadata-writer.ts +123 -0
  208. package/src/runtime/workflow/overlay-store.ts +690 -0
  209. package/src/runtime/workflow/projector.ts +127 -0
  210. package/src/runtime/{query-scopes.ts → workflow/query-scopes.ts} +3 -3
  211. package/src/runtime/{workflow-rail-segments.ts → workflow/rail/compose.ts} +60 -165
  212. package/src/runtime/workflow/rail/types.ts +198 -0
  213. package/src/runtime/workflow/scope-rail-composer.ts +39 -0
  214. package/src/runtime/{scope-resolver.ts → workflow/scope-resolver.ts} +3 -3
  215. package/src/runtime/workflow/scope-writer.ts +188 -0
  216. package/src/runtime/{tamper-gate.ts → workflow/tamper-gate.ts} +1 -1
  217. package/src/runtime/workflow/visibility-policy.ts +129 -0
  218. package/src/session/_sync-legacy.ts +66 -0
  219. package/src/session/export/embedded-reconstitute.ts +104 -0
  220. package/src/session/export/export-diagnostics.ts +85 -0
  221. package/src/session/export/export-validation.ts +110 -0
  222. package/src/session/export/index.ts +34 -0
  223. package/src/session/export/preservation-reattach.ts +30 -0
  224. package/src/session/export/serialize-dispatch.ts +165 -0
  225. package/src/session/export/stateful-export-pipeline.ts +432 -0
  226. package/src/session/export/stateful-export.ts +684 -0
  227. package/src/session/import/canonical-assembly.ts +227 -0
  228. package/src/session/import/diagnostics-session.ts +54 -0
  229. package/src/session/import/embedded-discovery.ts +225 -0
  230. package/src/session/import/embedded-offload.ts +337 -0
  231. package/src/session/import/import-diagnostics.ts +69 -0
  232. package/src/session/import/loader-types.ts +313 -0
  233. package/src/session/import/loader.ts +1834 -0
  234. package/src/session/import/normalize.ts +195 -0
  235. package/src/session/import/package-parts.ts +217 -0
  236. package/src/session/import/package-read.ts +195 -0
  237. package/src/session/import/parse-orchestration.ts +105 -0
  238. package/src/session/import/part-constants.ts +70 -0
  239. package/src/session/import/part-discovery.ts +94 -0
  240. package/src/session/import/preservation-index.ts +46 -0
  241. package/src/{runtime/read-only-diagnostics-runtime.ts → session/import/read-only-diagnostics.ts} +24 -3
  242. package/src/session/import/review-import.ts +508 -0
  243. package/src/session/import/styles-consolidation.ts +281 -0
  244. package/src/session/import/workflow-scope-import.ts +256 -0
  245. package/src/session/index.ts +37 -0
  246. package/src/session/session-state.ts +69 -0
  247. package/src/session/session.ts +532 -0
  248. package/src/session/shared/protection.ts +228 -0
  249. package/src/session/shared/session-utils.ts +82 -0
  250. package/src/session/types.ts +499 -0
  251. package/src/shell/chart-snapshots.ts +96 -0
  252. package/src/shell/media-previews.ts +85 -0
  253. package/src/shell/overlay-anchor-bridge.ts +53 -0
  254. package/src/shell/paste-adapter.ts +23 -0
  255. package/src/shell/ref-commands.ts +1697 -0
  256. package/src/shell/ref-utilities.ts +48 -0
  257. package/src/shell/search.ts +51 -0
  258. package/src/{ui/editor-runtime-boundary.ts → shell/session-bootstrap.ts} +243 -67
  259. package/src/shell/ui-subscriber-channels.ts +81 -0
  260. package/src/shell/use-collab-sync.ts +116 -0
  261. package/src/ui/WordReviewEditor.tsx +496 -2051
  262. package/src/ui/editor-shell-view.tsx +30 -1
  263. package/src/ui/editor-surface-controller.tsx +49 -1
  264. package/src/ui/headless/revision-decoration-model.ts +83 -0
  265. package/src/{ui-tailwind/chrome → ui/headless}/role-action-sets.ts +1 -1
  266. package/src/ui/headless/scoped-chrome-policy.ts +2 -2
  267. package/src/ui/headless/selection-tool-context.ts +1 -1
  268. package/src/ui/headless/selection-tool-resolver.ts +1 -1
  269. package/src/ui/runtime-shortcut-dispatch.ts +46 -1
  270. package/src/ui/ui-controller-factory.ts +221 -0
  271. package/src/ui-tailwind/chart/ChartSurface.tsx +2 -2
  272. package/src/ui-tailwind/chart/layout/legend-layout.ts +1 -1
  273. package/src/ui-tailwind/chart/layout/plot-area.ts +2 -2
  274. package/src/ui-tailwind/chart/layout/title-layout.ts +1 -1
  275. package/src/ui-tailwind/chart/render/area.tsx +3 -3
  276. package/src/ui-tailwind/chart/render/bar-column.tsx +3 -3
  277. package/src/ui-tailwind/chart/render/bubble.tsx +3 -3
  278. package/src/ui-tailwind/chart/render/combo.tsx +2 -2
  279. package/src/ui-tailwind/chart/render/data-labels.tsx +2 -2
  280. package/src/ui-tailwind/chart/render/font-metrics.ts +2 -2
  281. package/src/ui-tailwind/chart/render/line.tsx +3 -3
  282. package/src/ui-tailwind/chart/render/pie.tsx +6 -6
  283. package/src/ui-tailwind/chart/render/scatter.tsx +3 -3
  284. package/src/ui-tailwind/chart/render/svg-primitives.ts +3 -3
  285. package/src/ui-tailwind/chart/render/unsupported.tsx +2 -2
  286. package/src/ui-tailwind/chrome/build-context-menu-entries.ts +88 -0
  287. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +1 -1
  288. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +1 -1
  289. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +1 -1
  290. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +1 -1
  291. package/src/ui-tailwind/chrome/editor-action-registry.ts +553 -0
  292. package/src/ui-tailwind/chrome/editor-actions-to-palette.ts +182 -0
  293. package/src/ui-tailwind/chrome/local-surface-arbiter.ts +534 -0
  294. package/src/ui-tailwind/chrome/resolve-target-kind.ts +226 -0
  295. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +38 -4
  296. package/src/ui-tailwind/chrome/tw-context-band.tsx +125 -0
  297. package/src/ui-tailwind/chrome/tw-context-menu-portal.tsx +248 -0
  298. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +42 -1
  299. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +8 -7
  300. package/src/ui-tailwind/chrome/tw-selection-tool-blocked.tsx +38 -4
  301. package/src/ui-tailwind/chrome/tw-selection-tool-comment.tsx +104 -6
  302. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +66 -7
  303. package/src/ui-tailwind/chrome/tw-selection-tool-workflow.tsx +54 -8
  304. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +7 -1
  305. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +33 -0
  306. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +78 -1
  307. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +16 -8
  308. package/src/ui-tailwind/chrome/tw-workspace-chrome-host.tsx +276 -0
  309. package/src/ui-tailwind/chrome/use-context-menu-controller.ts +201 -0
  310. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +1 -1
  311. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +22 -4
  312. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +1 -1
  313. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +1 -1
  314. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +11 -5
  315. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +197 -3
  316. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +1 -1
  317. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +35 -6
  318. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +24 -16
  319. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +1 -1
  320. package/src/ui-tailwind/debug/README.md +57 -0
  321. package/src/ui-tailwind/debug/index.ts +3 -0
  322. package/src/ui-tailwind/debug/tw-debug-overlay.tsx +186 -0
  323. package/src/ui-tailwind/debug/tw-debug-presentation.tsx +80 -0
  324. package/src/ui-tailwind/debug/tw-debug-top-bar.tsx +83 -0
  325. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +2 -2
  326. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +1 -1
  327. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +135 -10
  328. package/src/ui-tailwind/editor-surface/pm-decorations.ts +40 -13
  329. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +1 -1
  330. package/src/ui-tailwind/editor-surface/pm-schema.ts +1 -1
  331. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +3 -3
  332. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +1 -1
  333. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +2 -2
  334. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +91 -9
  335. package/src/ui-tailwind/editor-surface/shape-renderer.ts +1 -1
  336. package/src/ui-tailwind/editor-surface/surface-layer.ts +1 -1
  337. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +1 -1
  338. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +23 -6
  339. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +132 -22
  340. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +1 -1
  341. package/src/ui-tailwind/index.ts +0 -5
  342. package/src/ui-tailwind/overlay-anchor-bridge-context.tsx +33 -0
  343. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +66 -29
  344. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +25 -2
  345. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +15 -0
  346. package/src/ui-tailwind/review/tw-review-rail.tsx +92 -4
  347. package/src/ui-tailwind/review/tw-workflow-tab.tsx +1 -1
  348. package/src/ui-tailwind/review-workspace/page-chrome.ts +210 -0
  349. package/src/ui-tailwind/review-workspace/page-shell-metrics.ts +101 -0
  350. package/src/ui-tailwind/review-workspace/paragraph-layout.ts +115 -0
  351. package/src/ui-tailwind/review-workspace/selection-toolbar-placement.ts +97 -0
  352. package/src/ui-tailwind/review-workspace/tw-review-workspace-navigator.tsx +130 -0
  353. package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +240 -0
  354. package/src/ui-tailwind/review-workspace/tw-review-workspace-rail.tsx +59 -0
  355. package/src/ui-tailwind/review-workspace/types.ts +408 -0
  356. package/src/ui-tailwind/review-workspace/use-chrome-policy.ts +104 -0
  357. package/src/ui-tailwind/review-workspace/use-derived-view-state.ts +151 -0
  358. package/src/ui-tailwind/review-workspace/use-diagnostics-signal.ts +70 -0
  359. package/src/ui-tailwind/review-workspace/use-grabbed-segment-offsets.ts +40 -0
  360. package/src/ui-tailwind/review-workspace/use-layout-facet-render-signal.ts +55 -0
  361. package/src/ui-tailwind/review-workspace/use-page-markers.ts +130 -0
  362. package/src/ui-tailwind/review-workspace/use-pm-surface-capture.ts +60 -0
  363. package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +63 -0
  364. package/src/ui-tailwind/review-workspace/use-scope-card-state.ts +170 -0
  365. package/src/ui-tailwind/review-workspace/use-scroll-root-capture.ts +28 -0
  366. package/src/ui-tailwind/review-workspace/use-selection-toolbar-placement.ts +113 -0
  367. package/src/ui-tailwind/review-workspace/use-shell-selection-anchor-bridge.ts +120 -0
  368. package/src/ui-tailwind/review-workspace/use-status-bar-page-facts.ts +55 -0
  369. package/src/ui-tailwind/review-workspace/use-viewport-dimensions.ts +43 -0
  370. package/src/ui-tailwind/review-workspace/use-workspace-arbiter.ts +25 -0
  371. package/src/ui-tailwind/review-workspace/use-workspace-composition.ts +86 -0
  372. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +150 -0
  373. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  374. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +2 -2
  375. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +61 -98
  376. package/src/ui-tailwind/tw-review-workspace.tsx +521 -1802
  377. package/src/ui-tailwind/ui-api-context.tsx +43 -0
  378. package/src/ui-tailwind/ui-shell-channels-context.tsx +49 -0
  379. package/src/validation/compatibility-engine.ts +6 -6
  380. package/src/runtime/styles-cascade.ts +0 -33
  381. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +0 -85
  382. /package/src/runtime/{page-number-format.ts → formatting/field/page-number-format.ts} +0 -0
  383. /package/src/runtime/{ai-action-policy.ts → workflow/ai-action-policy.ts} +0 -0
  384. /package/src/runtime/{scope-tag-registry.ts → workflow/scope-tag-registry.ts} +0 -0
@@ -1,8 +1,5 @@
1
1
  import React, {
2
2
  type CSSProperties,
3
- type FocusEventHandler,
4
- type ReactNode,
5
- type Ref,
6
3
  useCallback,
7
4
  useEffect,
8
5
  useMemo,
@@ -11,41 +8,8 @@ import React, {
11
8
  } from "react";
12
9
 
13
10
  import * as Tooltip from "@radix-ui/react-tooltip";
14
- import { ChevronLeft, ChevronRight, List } from "lucide-react";
11
+ import { ChevronRight } from "lucide-react";
15
12
 
16
- import type {
17
- ActiveListContext,
18
- CommentSidebarThreadSnapshot,
19
- DocumentNavigationSnapshot,
20
- EditorAnchorProjection,
21
- EditorStoryTarget,
22
- EditorViewStateSnapshot,
23
- FormattingStateSnapshot,
24
- FormattingAlignment,
25
- HeaderFooterLinkPatch,
26
- InteractionGuardSnapshot,
27
- InsertImageOptions,
28
- RuntimeContextAnalyticsSnapshot,
29
- RuntimeRenderSnapshot,
30
- ReviewQueueSnapshot,
31
- SectionPageNumberingPatch,
32
- SectionBreakType,
33
- StyleCatalogSnapshot,
34
- SurfaceBlockSnapshot,
35
- TrackedChangeEntrySnapshot,
36
- WordReviewEditorChromeOptions,
37
- WordReviewEditorChromePreset,
38
- WordReviewEditorChromeVisibility,
39
- WorkflowScopeSnapshot,
40
- WorkspaceMode,
41
- ZoomLevel,
42
- } from "../api/public-types";
43
- import { createCanvasBackend } from "../runtime/layout/index.ts";
44
- import { DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP } from "../runtime/page-layout-estimation.ts";
45
- import {
46
- incrementInvalidationCounter,
47
- recordPerfSample,
48
- } from "./editor-surface/perf-probe.ts";
49
13
  import {
50
14
  useVisibleBlockRange,
51
15
  useVisiblePageIndexRange,
@@ -57,384 +21,110 @@ import {
57
21
  } from "./editor-surface/scroll-anchor.ts";
58
22
  import { TwPageBlockView } from "./editor-surface/tw-page-block-view.tsx";
59
23
  import { computeLineMarkersIfEnabled } from "./page-chrome-model.ts";
60
- import type { SessionCapabilities } from "../runtime/session-capabilities";
61
- import type {
62
- ActiveSelectionToolModel,
63
- SelectionToolAnchor,
64
- } from "../ui/headless/selection-tool-types";
65
- import type { MarkupDisplay } from "../ui/headless/comment-decoration-model";
66
- import {
67
- resolveScopedChromePolicy,
68
- shouldRenderSelectionToolKind,
69
- } from "../ui/headless/scoped-chrome-policy";
24
+ import { shouldRenderSelectionToolKind } from "../ui/headless/scoped-chrome-policy";
70
25
  import type { EditorCommandBag } from "../ui/editor-command-bag.ts";
71
26
  import { preserveEditorSelectionMouseDown } from "../ui/headless/preserve-editor-selection";
72
27
  import { TwAlertBanner } from "./chrome/tw-alert-banner";
73
- import { TwModeDock } from "./chrome/tw-mode-dock";
74
28
  import { TwLayoutPanel } from "./chrome/tw-layout-panel";
75
29
  import { TwPageRuler } from "./chrome/tw-page-ruler";
76
- import {
77
- getInitialReviewRailOpen,
78
- isNarrowChromeViewport,
79
- resolveResponsiveChromeState,
80
- } from "./chrome/responsive-chrome";
81
30
  import { ChromePresetToolbar } from "./chrome/chrome-preset-toolbar";
82
31
  import {
83
32
  resolveChromePreset,
84
33
  resolveChromePresetOptions,
85
34
  resolveChromeVisibilityForPreset,
86
- } from "./chrome/chrome-preset-model";
35
+ } from "../api/v3/ui/chrome-preset-model";
87
36
  import { TwSelectionToolHost } from "./chrome/tw-selection-tool-host";
88
- import { resolveSelectionAnchor } from "./chrome/tw-selection-anchor-resolver";
89
- import { resolveSelectionToolPlacement } from "./chrome/tw-selection-tool-placement";
90
- import { TwReviewRail, type ReviewRailTab } from "./review/tw-review-rail";
37
+ import { type ReviewRailTab } from "./review/tw-review-rail";
38
+ import {
39
+ TwReviewWorkspaceRail,
40
+ type TwReviewWorkspaceRailMode,
41
+ } from "./review-workspace/tw-review-workspace-rail.tsx";
91
42
  import { TwStatusBar } from "./status/tw-status-bar";
92
- import { type ToolbarInteractionPolicy } from "./toolbar/tw-toolbar";
43
+ import {
44
+ TwShellHeader,
45
+ type ShellHeaderMode,
46
+ type ShellHeaderModeOption,
47
+ } from "./toolbar/tw-shell-header";
48
+ import { TwContextBand } from "./chrome/tw-context-band";
49
+ import { TwRoleActionRegion } from "./toolbar/tw-role-action-region";
50
+ import { LocalSurfaceArbiterContext } from "./chrome/local-surface-arbiter";
51
+ import { TwWorkspaceChromeHost } from "./chrome/tw-workspace-chrome-host";
93
52
  import { TwChromeOverlay, TwPageStackOverlayLayer } from "./chrome-overlay";
94
53
  import { TwFloatingImageLayer } from "./page-stack/tw-floating-image-layer.tsx";
95
- import type { MediaPreviewDescriptor } from "./editor-surface/pm-state-from-snapshot.ts";
96
- import {
97
- cycleScopeIndex,
98
- shouldHandleScopeNavKey,
99
- } from "./chrome-overlay/scope-keyboard-cycle";
100
-
101
- export type ReviewWorkspaceChromeVisibility = WordReviewEditorChromeVisibility;
54
+ import { shouldHidePageBorderForSelection } from "./review-workspace/paragraph-layout.ts";
55
+ import { useSelectionToolbarPlacement } from "./review-workspace/use-selection-toolbar-placement.ts";
56
+ import { useShellSelectionAnchorBridge } from "./review-workspace/use-shell-selection-anchor-bridge.ts";
57
+ import { useChromePolicy } from "./review-workspace/use-chrome-policy.ts";
58
+ import { useStatusBarPageFacts } from "./review-workspace/use-status-bar-page-facts.ts";
59
+ import { useGrabbedSegmentOffsets } from "./review-workspace/use-grabbed-segment-offsets.ts";
60
+ import { useDerivedViewState } from "./review-workspace/use-derived-view-state.ts";
61
+ import { useReviewRailState } from "./review-workspace/use-review-rail-state.ts";
62
+ import { useWorkspaceSideEffects } from "./review-workspace/use-workspace-side-effects.ts";
63
+ import { useViewportDimensions } from "./review-workspace/use-viewport-dimensions.ts";
64
+ import { useScopeCardState } from "./review-workspace/use-scope-card-state.ts";
65
+ import { usePageMarkers } from "./review-workspace/use-page-markers.ts";
66
+ import { useDiagnosticsSignal } from "./review-workspace/use-diagnostics-signal.ts";
67
+ import { useWorkspaceComposition } from "./review-workspace/use-workspace-composition.ts";
68
+ import { useWorkspaceArbiter } from "./review-workspace/use-workspace-arbiter.ts";
69
+ import { useLayoutFacetRenderSignal } from "./review-workspace/use-layout-facet-render-signal.ts";
70
+ import { useScrollRootCapture } from "./review-workspace/use-scroll-root-capture.ts";
71
+ import { usePmSurfaceCapture } from "./review-workspace/use-pm-surface-capture.ts";
72
+ import { TwReviewWorkspaceNavigator } from "./review-workspace/tw-review-workspace-navigator.tsx";
73
+ import { TwReviewWorkspacePageToolbar } from "./review-workspace/tw-review-workspace-page-toolbar.tsx";
74
+
75
+ export {
76
+ FRAME_PX_PER_TWIP_AT_96DPI,
77
+ MIN_BAND_HEIGHT_PX,
78
+ buildPageShellMetrics,
79
+ resolveZoomMultiplier,
80
+ type PageShellMetrics,
81
+ } from "./review-workspace/page-shell-metrics.ts";
82
+
83
+ import type { TwReviewWorkspaceProps } from "./review-workspace/types.ts";
84
+ import { useUiApi } from "./ui-api-context.tsx";
85
+ import { useUiShellChannels } from "./ui-shell-channels-context.tsx";
86
+ export type {
87
+ ReviewWorkspaceChromeVisibility,
88
+ TwReviewWorkspaceProps,
89
+ } from "./review-workspace/types.ts";
90
+
91
+ import type { EditorRole } from "../api/public-types.ts";
92
+
93
+ // Default shell-header modes for the workspace's default composition.
94
+ // Designsystem §6.1 prescribes a 4-mode switcher (edit / review / workflow
95
+ // / more). "more" is kept in the layout for parity but marked disabled
96
+ // until its handler is defined by refactor/11 Slice 7 + refactor/10
97
+ // Slice 5 (Phase Q debug UX). Hosts that want a fully-wired 4-mode set
98
+ // supply their own `shellHeader` prop.
99
+ const DEFAULT_WORKSPACE_SHELL_MODES: readonly ShellHeaderModeOption[] = [
100
+ { id: "edit", label: "Edit" },
101
+ { id: "review", label: "Review" },
102
+ { id: "workflow", label: "Workflow" },
103
+ { id: "more", label: "More", disabled: true },
104
+ ];
105
+
106
+ function editorRoleToShellMode(role: EditorRole): ShellHeaderMode {
107
+ switch (role) {
108
+ case "editor":
109
+ return "edit";
110
+ case "review":
111
+ return "review";
112
+ case "workflow":
113
+ return "workflow";
114
+ }
115
+ }
102
116
 
103
- export interface TwReviewWorkspaceProps {
104
- snapshot: RuntimeRenderSnapshot;
105
- viewState: EditorViewStateSnapshot;
106
- markupDisplay: MarkupDisplay;
107
- currentUserId?: string;
108
- capabilities?: SessionCapabilities;
109
- mediaPreviews?: Record<string, MediaPreviewDescriptor>;
110
- reviewMode?: "editing" | "review";
111
- /**
112
- * Runtime-owned layout facet. Optional so existing tests + host apps
113
- * continue to mount the workspace without installing a facet. When
114
- * supplied, the ChromeOverlay plane (scope rail, workflow dock, etc.)
115
- * renders over the document column.
116
- */
117
- layoutFacet?: import("../runtime/layout/index.ts").WordReviewEditorLayoutFacet;
118
- /**
119
- * Optional shell header mounted above the formatting toolbar. Pass a
120
- * pre-assembled `<TwShellHeader />` with brand / mode switcher /
121
- * primaryAction, or any other ReactNode. Hosts that do not supply this
122
- * get the legacy layout.
123
- */
124
- shellHeader?: ReactNode;
125
- /**
126
- * Optional host-provided Workflow-tab override for the review rail.
127
- * When unset the rail renders the built-in `TwWorkflowTab` sourced from
128
- * `layoutFacet.getAllScopeRailSegments()`.
129
- */
130
- reviewRailWorkflowTab?: ReactNode;
131
- reviewRailWorkflowCount?: number;
132
- reviewRailWorkflowScopesTitle?: string;
133
- reviewRailIntelligenceEyebrow?: string;
134
- /** Opt in to the editorial DOCUMENT INTELLIGENCE header + underline tab chip. */
135
- reviewRailIntelligenceHeader?: boolean;
136
- /** Optional SEARCH / HELP utility footer at the bottom of the rail. */
137
- reviewRailFooter?: {
138
- onSearch?: () => void;
139
- helpHref?: string;
140
- searchLabel?: string;
141
- helpLabel?: string;
142
- };
143
- /**
144
- * @deprecated — Lane 6b §6b.U4 (designsystem §6.26).
145
- *
146
- * The floating bottom-center mode dock is retained for back-compat but is
147
- * off by default. Its functionality is covered by TwShellHeader mode tabs
148
- * + TwRoleActionRegion. To render it, hosts must supply BOTH `modeDock`
149
- * data and `experimental.showModeDock = true`.
150
- */
151
- modeDock?: {
152
- label: string;
153
- icon?: ReactNode;
154
- actions?: readonly import("./chrome/tw-mode-dock").TwModeDockAction[];
155
- };
156
- /**
157
- * Experimental feature flags. Anything here is subject to change or
158
- * removal without a major-version bump. Do not depend on these in
159
- * production consumers.
160
- */
161
- experimental?: {
162
- /**
163
- * Re-enable the deprecated TwModeDock floating bottom dock. Defaults to
164
- * `false` — the shell header + role action region supersede this surface
165
- * per designsystem §6.26.
166
- */
167
- showModeDock?: boolean;
168
- };
169
- document: ReactNode;
170
- workspaceMode: WorkspaceMode;
171
- zoomLevel?: ZoomLevel;
172
- formattingState?: FormattingStateSnapshot;
173
- activeListContext?: ActiveListContext | null;
174
- styleCatalog?: StyleCatalogSnapshot;
175
- activeRailTab: ReviewRailTab;
176
- activeCommentId?: string;
177
- activeRevisionId?: string;
178
- showTrackedChanges: boolean;
179
- workflowScopeSnapshot?: WorkflowScopeSnapshot | null;
180
- interactionGuardSnapshot?: InteractionGuardSnapshot;
181
- chromePreset?: WordReviewEditorChromePreset;
182
- chromeOptions?: Partial<WordReviewEditorChromeOptions>;
183
- /** P9g — live collab session for the `"collab"` chrome preset's top nav. */
184
- collabSession?: import("../runtime/collab-session.ts").CollabSession;
185
- collabTransportStatus?: import("../api/awareness-identity-types.ts").TransportStatus;
186
- collabActorId?: string;
187
- collabSendBaseline?: {
188
- originDocumentId: string;
189
- originPayloadId: string;
190
- originContentHash: string;
191
- payloadXml: string;
192
- };
193
- reviewQueue?: ReviewQueueSnapshot;
194
- documentContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
195
- selectionContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
196
- currentScopeContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
197
- commands: EditorCommandBag;
198
- onActivateFloatingImage?: (payload: {
199
- mediaId: string;
200
- from: number;
201
- to: number;
202
- storyTarget: EditorStoryTarget;
203
- }) => void;
204
- /** N6 — release the grabbed image/shape. Wired to `runtime.deselectObject()` by the host. */
205
- onDeselectObject?: () => void;
206
- activeSelectionTool?: ActiveSelectionToolModel | null;
207
- selectionToolAnchor?: SelectionToolAnchor | null;
208
- documentNavigation?: DocumentNavigationSnapshot;
209
- /**
210
- * R2.3: chrome-pin change handler. When supplied, selection tools
211
- * expose their detach affordance and persist pin state through to
212
- * runtime ViewState (via the host's `setChromePin` action). When
213
- * omitted, the detach handle is suppressed — the tool behaves as
214
- * a non-pinnable anchored panel (pre-R2 behavior for most kinds).
215
- */
216
- onChromePinChange?: (
217
- surface: import("../api/public-types").ChromePinSurface,
218
- pin: import("../api/public-types").PinState | null,
219
- ) => void;
220
- onWorkspaceModeChange?: (value: WorkspaceMode) => void;
221
- onZoomChange?: (level: ZoomLevel) => void;
222
- onActiveRailTabChange?: (value: ReviewRailTab) => void;
223
- onShowTrackedChangesChange?: (show: boolean) => void;
224
- onUndo?: () => void;
225
- onRedo?: () => void;
226
- onSetParagraphStyle?: (styleId: string) => void;
227
- onToggleBold?: () => void;
228
- onToggleItalic?: () => void;
229
- onToggleUnderline?: () => void;
230
- onSetSelectionTextColor?: (color: string) => void;
231
- onSetSelectionHighlightColor?: (color: string | null) => void;
232
- onToggleStrikethrough?: () => void;
233
- onToggleSuperscript?: () => void;
234
- onToggleSubscript?: () => void;
235
- onSetFontFamily?: (fontFamily: string) => void;
236
- onSetFontSize?: (fontSize: number) => void;
237
- onSetTextColor?: (color: string) => void;
238
- onSetHighlightColor?: (color: string | null) => void;
239
- onSetAlignment?: (alignment: FormattingAlignment) => void;
240
- onToggleBulletedList?: () => void;
241
- onToggleNumberedList?: () => void;
242
- onOutdent?: () => void;
243
- onIndent?: () => void;
244
- onAddComment?: () => void;
245
- onInsertPageBreak?: () => void;
246
- onInsertTable?: () => void;
247
- onInsertSectionBreak?: (type: SectionBreakType) => void;
248
- onInsertImage?: (options: InsertImageOptions) => void;
249
- onSetTableStyle?: (styleId: string) => void;
250
- onAddRowBefore?: () => void;
251
- onAddRowAfter?: () => void;
252
- onAddColumnBefore?: () => void;
253
- onAddColumnAfter?: () => void;
254
- onDeleteRow?: () => void;
255
- onDeleteColumn?: () => void;
256
- onDeleteTable?: () => void;
257
- onMergeCells?: () => void;
258
- onSplitCell?: () => void;
259
- onSetCellBackground?: (color: string) => void;
260
- onSetImageLayout?: (
261
- mediaId: string,
262
- dimensions: { widthEmu: number; heightEmu: number },
263
- ) => void;
264
- onSetImageFrame?: (
265
- mediaId: string,
266
- offsets: { horizontalOffsetEmu?: number; verticalOffsetEmu?: number },
267
- ) => void;
268
- onDeleteSectionBreak?: (sectionIndex: number) => void;
269
- onUpdateSectionLayout?: (
270
- sectionIndex: number,
271
- patch: {
272
- pageSize?: { width?: number; height?: number; orientation?: "portrait" | "landscape" };
273
- pageMargins?: {
274
- top?: number;
275
- right?: number;
276
- bottom?: number;
277
- left?: number;
278
- header?: number;
279
- footer?: number;
280
- gutter?: number;
281
- };
282
- columns?: {
283
- count?: number;
284
- space?: number;
285
- equalWidth?: boolean;
286
- columns?: Array<{ width: number; space?: number }>;
287
- separator?: boolean;
288
- };
289
- titlePage?: boolean;
290
- sectionType?: SectionBreakType;
291
- },
292
- ) => void;
293
- onSetSectionPageNumbering?: (
294
- sectionIndex: number,
295
- patch: SectionPageNumberingPatch | null,
296
- ) => void;
297
- onSetHeaderFooterLink?: (
298
- sectionIndex: number,
299
- patch: HeaderFooterLinkPatch,
300
- ) => void;
301
- onAddCommentFromSelection?: () => void;
302
- onExport?: () => void;
303
- onDismissSelectionToolbar?: () => void;
304
- onAcceptSuggestion?: () => void;
305
- onRejectSuggestion?: () => void;
306
- onEditSuggestion?: () => void;
307
- onAddCommentFromSuggestion?: () => void;
308
- onSelectionToolbarFocusCapture?: FocusEventHandler<HTMLDivElement>;
309
- onSelectionToolbarBlurCapture?: FocusEventHandler<HTMLDivElement>;
310
- selectionToolbarRef?: Ref<HTMLDivElement>;
311
- onOpenComment?: (thread: CommentSidebarThreadSnapshot) => void;
312
- onResolveComment?: (commentId: string) => void;
313
- onReopenComment?: (commentId: string) => void;
314
- onAddReply?: (commentId: string, body: string) => void;
315
- onEditBody?: (commentId: string, body: string) => void;
316
- onOpenRevision?: (revision: TrackedChangeEntrySnapshot) => void;
317
- onAcceptRevision?: (revisionId: string) => void;
318
- onRejectRevision?: (revisionId: string) => void;
319
- onAcceptAllChanges?: () => void;
320
- onRejectAllChanges?: () => void;
321
- onCloseStory?: () => void;
322
- /**
323
- * @deprecated P8.11 — the workspace no longer renders a workspace-level
324
- * header band with an "Edit header" button; per-page header bands route
325
- * clicks via `onOpenStory` / `runtime.openStory` directly. The prop
326
- * remains optional for one release so existing hosts continue to
327
- * compile; supplying it emits a `console.warn` on mount.
328
- */
329
- onOpenHeaderStory?: () => void;
330
- /**
331
- * @deprecated P8.11 — see `onOpenHeaderStory`. Footer variant of the
332
- * same deprecation.
333
- */
334
- onOpenFooterStory?: () => void;
335
- /**
336
- * Open a header/footer story for a specific page. Called when the user
337
- * double-clicks a per-page header/footer band in the page-stack chrome.
338
- * Must resolve the correct variant for that page's section and call
339
- * `runtime.openStory()`.
340
- */
341
- onOpenHeaderStoryForPage?: (pageIndex: number) => void;
342
- onOpenFooterStoryForPage?: (pageIndex: number) => void;
343
- /**
344
- * P8.11 — fired when a per-page chrome band (header / footer) is
345
- * clicked to promote it into the active editing surface. Wire to
346
- * `runtime.openStory(target)`; the chrome layer's portal mechanism
347
- * then reparents the PM surface into the matching band's active slot.
348
- */
349
- onOpenStory?: (target: EditorStoryTarget) => void;
350
- onSetParagraphIndentation?: (indentation: {
351
- left?: number;
352
- right?: number;
353
- firstLine?: number;
354
- hanging?: number;
355
- }) => void;
356
- onSetParagraphTabStops?: (tabStops: Array<{ pos: number; val?: string; leader?: string }>) => void;
357
- onRestartNumbering?: () => void;
358
- onContinueNumbering?: () => void;
359
- // P6: new table ops
360
- onToggleRowHeader?: () => void;
361
- onToggleRowCantSplit?: () => void;
362
- onDistributeColumnsEvenly?: () => void;
363
- onSetTableAlignment?: (alignment: "left" | "center" | "right") => void;
364
- onSetCellVerticalAlign?: (align: "top" | "center" | "bottom") => void;
365
- /** P6: active table context for chrome overlay grips. */
366
- tableContext?: import("../api/public-types").TableStructureContextSnapshot | null;
367
- /** P6: column resize committed from overlay grip → set-column-width op. */
368
- onSetColumnWidth?: (columnIndex: number, twips: number) => void;
369
- /** P6: row resize committed from overlay grip → set-row-height op. */
370
- onSetRowHeight?: (rowIndex: number, twips: number, rule: "auto" | "atLeast" | "exact") => void;
371
- onListIndent?: () => void;
372
- onListOutdent?: () => void;
373
- onUpdateFields?: () => void;
374
- onUpdateTableOfContents?: () => void;
375
- onGoToPreviousReviewItem?: () => void;
376
- onGoToNextReviewItem?: () => void;
377
- onMarkSectionForReview?: () => void;
378
- /** Optional: open sidebar to tracked-changes panel. When provided, the review role shows a sidebar-TC icon. */
379
- onReviewSidebarTrackedChanges?: () => void;
380
- /** Optional: open sidebar to comments panel. When provided, the review role shows a sidebar-comments icon. */
381
- onReviewSidebarComments?: () => void;
382
- onNavigateHeading?: (headingId: string) => void;
383
- chromeVisibility?: Partial<ReviewWorkspaceChromeVisibility>;
384
- /**
385
- * Called when the shell-header mode tab changes or any chrome surface fires
386
- * a role switch. Wire to `runtime.setEditorRole(role)` so the workspace
387
- * re-renders with the new per-role action set.
388
- */
389
- onEditorRoleChange?: (role: import("../api/public-types.ts").EditorRole) => void;
390
- /**
391
- * Scope card mode selector fired a mode change. Wire to the host's
392
- * existing overlay-apply path (or an equivalent CCEP workflow
393
- * endpoint). The card never mutates runtime state directly.
394
- */
395
- onScopeModeChangeRequested?: (payload: {
396
- scopeId: string;
397
- mode: import("../api/public-types.ts").WorkflowScopeMode;
398
- }) => void;
399
- /**
400
- * Scope card issue row fired an action (resolve/waive/escalate/
401
- * acknowledge). Host updates the attached `IssueMetadataValue`
402
- * state and re-pushes via `setWorkflowMetadataEntries`.
403
- */
404
- onScopeIssueActionRequested?: (payload: {
405
- scopeId: string;
406
- issueId: string;
407
- action: import("../api/public-types.ts").ScopeIssueAction;
408
- }) => void;
409
- /**
410
- * R3 — scope card suggestion-group accept button fired. WordReview-
411
- * Editor relays to `ref.acceptSuggestionGroup(groupId)` which fans
412
- * out to individual `acceptChange` calls across the group members.
413
- */
414
- onScopeAcceptSuggestionGroup?: (payload: {
415
- scopeId: string;
416
- groupId: string;
417
- }) => void;
418
- /** R3 — scope card suggestion-group reject. */
419
- onScopeRejectSuggestionGroup?: (payload: {
420
- scopeId: string;
421
- groupId: string;
422
- }) => void;
423
- /**
424
- * K2 — scope card "Ask review agent" fired. WordReviewEditor emits
425
- * `agent-on-selection-requested` via WordReviewEditorEvent.
426
- */
427
- onScopeAskAgent?: (payload: {
428
- scopeId: string;
429
- anchor?: EditorAnchorProjection;
430
- }) => void;
431
- /**
432
- * P3 — optional scope-tag editor slot rendered inside the scope
433
- * card when `editorRole === "workflow"`. Hosts pass a chip picker,
434
- * free-text input, or whatever authoring surface they want. Unset
435
- * in editor/review roles.
436
- */
437
- scopeCardScopeTagEditor?: ReactNode;
117
+ function shellModeToEditorRole(mode: ShellHeaderMode): EditorRole | null {
118
+ switch (mode) {
119
+ case "edit":
120
+ return "editor";
121
+ case "review":
122
+ return "review";
123
+ case "workflow":
124
+ return "workflow";
125
+ case "more":
126
+ return null;
127
+ }
438
128
  }
439
129
 
440
130
  export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
@@ -449,12 +139,8 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
449
139
  // measure per-page rects and to reparent PM's DOM node across band
450
140
  // portals when `activeStory` changes. See comment near the body slot
451
141
  // in the render tree below.
452
- const bodySlotRef = useRef<HTMLDivElement | null>(null);
453
- const scrollRootRef = useRef<HTMLDivElement | null>(null);
454
- const [pmSurfaceElement, setPmSurfaceElement] =
455
- useState<HTMLElement | null>(null);
456
- const [pageStackScrollRoot, setPageStackScrollRoot] =
457
- useState<HTMLElement | null>(null);
142
+ const { bodySlotRef, pmSurfaceElement } = usePmSurfaceCapture();
143
+ const { scrollRootRef, pageStackScrollRoot } = useScrollRootCapture();
458
144
  const caps = props.capabilities;
459
145
  const isPageWorkspace = props.workspaceMode === "page";
460
146
  const markupDisplay = props.markupDisplay;
@@ -462,93 +148,27 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
462
148
  const [layoutToolsOpen, setLayoutToolsOpen] = useState(false);
463
149
 
464
150
  // Scope card state — tracks which scope's card is currently open so
465
- // the ChromeOverlay's card layer renders the right one. The card
466
- // closes on click-outside, Escape, or a repeat click on its stripe.
467
- const [activeScopeId, setActiveScopeId] = useState<string | null>(null);
468
- const handleScopeStripeClick = useCallback(
469
- (segment: { scopeId: string }) => {
470
- setActiveScopeId((current) =>
471
- current === segment.scopeId ? null : segment.scopeId,
472
- );
473
- },
474
- [],
475
- );
476
- const handleScopeCardClose = useCallback(() => {
477
- setActiveScopeId(null);
478
- }, []);
479
-
480
- // P3d: keyboard scope navigation. J / K cycle the active scope in
481
- // document order; Enter opens the first scope when none is active.
482
- // `shouldHandleScopeNavKey` + `cycleScopeIndex` are extracted pure
483
- // helpers so the logic is unit-testable without a workspace mount.
484
- useEffect(() => {
485
- const layoutFacet = props.layoutFacet;
486
- if (!layoutFacet || typeof layoutFacet.getAllScopeCardModels !== "function") {
487
- return undefined;
488
- }
489
- const onKey = (event: KeyboardEvent) => {
490
- if (!shouldHandleScopeNavKey(event)) return;
491
- const models = layoutFacet.getAllScopeCardModels();
492
- if (models.length === 0) return;
493
- const ids = models.map((model) => model.scopeId);
494
- const key = event.key.toLowerCase();
495
- if (key === "enter") {
496
- if (!activeScopeId) {
497
- setActiveScopeId(ids[0] ?? null);
498
- event.preventDefault();
499
- }
500
- return;
501
- }
502
- const direction: 1 | -1 = key === "j" ? 1 : -1;
503
- const next = cycleScopeIndex(activeScopeId, ids, direction);
504
- setActiveScopeId(next);
505
- event.preventDefault();
506
- };
507
- window.addEventListener("keydown", onKey);
508
- return () => window.removeEventListener("keydown", onKey);
509
- }, [props.layoutFacet, activeScopeId]);
510
- const onScopeModeChangeRequested = props.onScopeModeChangeRequested;
511
- const handleScopeCardModeChange = useCallback(
512
- (scopeId: string, mode: import("../api/public-types.ts").WorkflowScopeMode) => {
513
- onScopeModeChangeRequested?.({ scopeId, mode });
514
- },
515
- [onScopeModeChangeRequested],
516
- );
517
- const onScopeIssueActionRequested = props.onScopeIssueActionRequested;
518
- const handleScopeCardIssueAction = useCallback(
519
- (
520
- scopeId: string,
521
- issueId: string,
522
- action: import("../api/public-types.ts").ScopeIssueAction,
523
- ) => {
524
- onScopeIssueActionRequested?.({ scopeId, issueId, action });
525
- },
526
- [onScopeIssueActionRequested],
527
- );
528
- const onScopeAcceptSuggestionGroup = props.onScopeAcceptSuggestionGroup;
529
- const handleScopeCardAcceptSuggestionGroup = useCallback(
530
- (scopeId: string, groupId: string) => {
531
- onScopeAcceptSuggestionGroup?.({ scopeId, groupId });
532
- },
533
- [onScopeAcceptSuggestionGroup],
534
- );
535
- const onScopeRejectSuggestionGroup = props.onScopeRejectSuggestionGroup;
536
- const handleScopeCardRejectSuggestionGroup = useCallback(
537
- (scopeId: string, groupId: string) => {
538
- onScopeRejectSuggestionGroup?.({ scopeId, groupId });
539
- },
540
- [onScopeRejectSuggestionGroup],
541
- );
542
- const onScopeAskAgent = props.onScopeAskAgent;
543
- const handleScopeCardAskAgent = useCallback(
544
- (scopeId: string) => {
545
- const cardModel = props.layoutFacet
546
- ?.getAllScopeCardModels?.()
547
- ?.find((m) => m.scopeId === scopeId);
548
- onScopeAskAgent?.({ scopeId, anchor: cardModel?.anchor });
549
- },
550
- [onScopeAskAgent, props.layoutFacet],
551
- );
151
+ // the ChromeOverlay's card layer renders the right one. Open/close
152
+ // rules + J/K keyboard navigation + all 5 scope-card callback
153
+ // handlers live in the hook.
154
+ const {
155
+ activeScopeId,
156
+ handleScopeStripeClick,
157
+ handleScopeCardClose,
158
+ handleScopeCardModeChange,
159
+ handleScopeCardIssueAction,
160
+ handleScopeCardAcceptSuggestionGroup,
161
+ handleScopeCardRejectSuggestionGroup,
162
+ handleScopeCardAskAgent,
163
+ } = useScopeCardState({
164
+ layoutFacet: props.layoutFacet,
165
+ workflowFacet: props.workflowFacet,
166
+ onScopeModeChangeRequested: props.onScopeModeChangeRequested,
167
+ onScopeIssueActionRequested: props.onScopeIssueActionRequested,
168
+ onScopeAcceptSuggestionGroup: props.onScopeAcceptSuggestionGroup,
169
+ onScopeRejectSuggestionGroup: props.onScopeRejectSuggestionGroup,
170
+ onScopeAskAgent: props.onScopeAskAgent,
171
+ });
552
172
  const zoomLevel = props.zoomLevel ?? 100;
553
173
  // Numeric zooms resolve immediately; "pageWidth" / "onePage" need the
554
174
  // page-frame dimensions to fit against — they're resolved below once
@@ -570,18 +190,36 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
570
190
  chromeVisibility: props.chromeVisibility,
571
191
  });
572
192
  const reviewRailAvailable = chromeVisibility.reviewRail && (caps?.reviewRailVisible ?? true);
573
- const [viewportWidth, setViewportWidth] = useState<number | undefined>(() => readViewportWidth());
574
- const [viewportHeight, setViewportHeight] = useState<number | undefined>(() => readViewportHeight());
575
- const [reviewRailOpen, setReviewRailOpen] = useState(() =>
576
- getInitialReviewRailOpen({
577
- viewportWidth: readViewportWidth(),
578
- reviewRailAvailable,
579
- }),
580
- );
193
+ const { viewportWidth, viewportHeight } = useViewportDimensions();
194
+ const { reviewRailOpen, setReviewRailOpen } = useReviewRailState({
195
+ reviewRailAvailable,
196
+ viewportWidth,
197
+ });
581
198
  // Incremented on zoom_changed / render_frame_ready so the placement
582
199
  // useMemo below re-executes when the render kernel emits new rects.
583
- const [renderFrameRevision, setRenderFrameRevision] = useState(0);
584
- const responsiveChromeSignatureRef = useRef<string | null>(null);
200
+ const renderFrameRevision = useLayoutFacetRenderSignal(props.layoutFacet);
201
+
202
+ // refactor/10 chrome-contract Slice 2 (2026-04-23) — emit into the
203
+ // shell UI-API subscriber channels when the geometry/layout facet
204
+ // bumps its render-frame revision. Consumers of
205
+ // `ui.viewport.subscribe` / `ui.overlays.subscribe` get one event per
206
+ // layout invalidation. Coalescing is upstream (the layout facet
207
+ // itself bumps at most once per frame).
208
+ //
209
+ // Payload choices:
210
+ // - viewport: current `ViewportState` snapshot. Consumer can diff
211
+ // or re-read as they like.
212
+ // - overlays: `{kind: "page", value: 0}` as a universal "geometry
213
+ // changed, re-read any attached anchors" marker. Per-query
214
+ // invalidation fan-out is a Phase Q follow-up — the geometry
215
+ // facet does not yet expose per-kind invalidation events.
216
+ const uiApiForEmit = useUiApi();
217
+ const shellChannels = useUiShellChannels();
218
+ React.useEffect(() => {
219
+ if (!uiApiForEmit || !shellChannels) return;
220
+ shellChannels.viewport.emit(uiApiForEmit.viewport.get());
221
+ shellChannels.overlays.emit({ kind: "page", value: 0 });
222
+ }, [renderFrameRevision, uiApiForEmit, shellChannels]);
585
223
  const headings = props.documentNavigation?.headings ?? [];
586
224
  const headerVariant = snapshot.pageLayout?.headerVariants[0]?.variant ?? "default";
587
225
  const footerVariant = snapshot.pageLayout?.footerVariants[0]?.variant ?? "default";
@@ -590,98 +228,47 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
590
228
  ? viewState.selection.activeRange.at
591
229
  : viewState.selection.head;
592
230
  const shouldResolveActiveParagraphLayout =
593
- isPageWorkspace &&
594
- chromeVisibility.pageChrome &&
595
- layoutToolsOpen;
596
- const activeParagraphLayout = useMemo(
597
- () =>
598
- shouldResolveActiveParagraphLayout
599
- ? resolveActiveParagraphLayout(snapshot.surface, selectionPosition)
600
- : null,
601
- [selectionPosition, shouldResolveActiveParagraphLayout, snapshot.surface],
602
- );
603
- const pageChromeModel = useMemo(
604
- () =>
605
- buildPageChromeModel(
606
- snapshot.surface,
607
- snapshot.pageLayout,
608
- props.documentNavigation,
609
- snapshot.activeStory,
610
- ),
611
- [props.documentNavigation, snapshot.activeStory, snapshot.pageLayout, snapshot.surface],
612
- );
231
+ isPageWorkspace && chromeVisibility.pageChrome && layoutToolsOpen;
613
232
  const effectiveSelectionMode = props.interactionGuardSnapshot?.effectiveMode ?? "edit";
614
233
  const allowLocalChromeMutations = Boolean(caps?.canEdit) && effectiveSelectionMode === "edit";
615
- const gatedSelectionTool = useMemo(() => {
616
- if (!props.activeSelectionTool) {
617
- return null;
618
- }
619
- if (props.activeSelectionTool.kind === "structure-context" && !chromeVisibility.contextToolbars) {
620
- return null;
621
- }
622
- return props.activeSelectionTool;
623
- }, [props.activeSelectionTool, chromeVisibility.contextToolbars]);
624
- const pageShellMetrics = useMemo(
625
- () => buildPageShellMetrics(snapshot.pageLayout),
626
- [snapshot.pageLayout],
627
- );
628
- // P2.c — resolve "pageWidth" / "onePage" against the active section's
629
- // real paper dimensions. Numeric zooms pass through. Falls back to
630
- // `numericZoomScale` (1.0 for symbolic zooms when paper dims are
631
- // unavailable, e.g., during initial load).
632
- const zoomScale = useMemo(() => {
633
- if (typeof zoomLevel === "number") return numericZoomScale;
634
- return resolveZoomMultiplier(
635
- zoomLevel,
636
- pageShellMetrics.frameWidthPx ?? 0,
637
- pageShellMetrics.frameHeightPx ?? 0,
638
- viewportWidth,
639
- viewportHeight,
640
- );
641
- }, [
234
+ const {
235
+ activeParagraphLayout,
236
+ pageChromeModel,
237
+ gatedSelectionTool,
238
+ pageShellMetrics,
239
+ zoomScale,
240
+ pageZoomBucket,
241
+ } = useDerivedViewState({
242
+ snapshot,
243
+ documentNavigation: props.documentNavigation,
244
+ activeSelectionTool: props.activeSelectionTool,
245
+ chromeVisibility,
246
+ selectionPosition,
247
+ shouldResolveActiveParagraphLayout,
642
248
  zoomLevel,
643
249
  numericZoomScale,
644
- pageShellMetrics.frameWidthPx,
645
- pageShellMetrics.frameHeightPx,
646
250
  viewportWidth,
647
251
  viewportHeight,
648
- ]);
649
- const pageZoomBucket =
650
- !isPageWorkspace ? undefined : zoomScale < 1 ? "low" : zoomScale > 1 ? "high" : "base";
651
- const selectionToolbarPlacement = useMemo(() => {
652
- // Prefer render-frame anchors when the layout facet is available this
653
- // keeps the tool glued to kernel coordinates across zoom, scroll, and
654
- // predicted-text reconciliation (R4).
655
- if (props.layoutFacet && gatedSelectionTool) {
656
- const anchorRect = resolveSelectionAnchor({
657
- facet: props.layoutFacet,
658
- selection: viewState.selection,
659
- tool: gatedSelectionTool,
660
- });
661
- if (anchorRect && selectionToolbarRootRef.current) {
662
- const containerRect = selectionToolbarRootRef.current.getBoundingClientRect();
663
- const result = resolveSelectionToolPlacement({
664
- anchor: anchorRect,
665
- container: { widthPx: containerRect.width, heightPx: containerRect.height },
666
- });
667
- if (result) return result;
668
- }
669
- }
670
- // Fall back to DOM rects for hosts that do not supply a layout facet.
671
- return resolveSelectionToolbarPlacement(
672
- props.selectionToolAnchor,
673
- selectionToolbarRootRef.current,
674
- zoomScale,
675
- );
676
- // eslint-disable-next-line react-hooks/exhaustive-deps
677
- }, [
678
- props.layoutFacet,
679
- props.selectionToolAnchor,
252
+ isPageWorkspace,
253
+ });
254
+ // DS-C1 publish the current selection anchor so
255
+ // `ui.overlays.getAnchor({ kind: "selection" })` resolves to a real
256
+ // rect on the mounted path (designsystem.md §8.8.1 "Selection toolbar"
257
+ // row). No-op when no `OverlayAnchorBridgeProvider` is mounted.
258
+ useShellSelectionAnchorBridge({
259
+ geometryFacet: props.geometryFacet,
260
+ selection: viewState.selection,
261
+ gatedSelectionTool,
262
+ renderFrameRevision,
263
+ });
264
+ const selectionToolbarPlacement = useSelectionToolbarPlacement({
680
265
  gatedSelectionTool,
681
- viewState.selection,
266
+ selection: viewState.selection,
267
+ selectionToolAnchor: props.selectionToolAnchor,
682
268
  zoomScale,
683
269
  renderFrameRevision,
684
- ]);
270
+ selectionToolbarRootRef,
271
+ });
685
272
  const activePage = props.documentNavigation?.pages[props.documentNavigation.activePageIndex] ?? null;
686
273
  // P5b — status-bar facts derived from the layout facet so the
687
274
  // Page-N-of-M display + measurement-fidelity badge ("E" / "C" / "C+F")
@@ -689,48 +276,14 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
689
276
  // above bumps `renderFrameRevision` on the same kinds; including it
690
277
  // in the dependency list re-runs this memo without a separate
691
278
  // subscription.
692
- // N6 resolve grabbed-object segment offsets from the surface so the
693
- // selection overlay can query the anchor index without a full surface walk.
694
- const grabbedSegmentOffsets = useMemo(() => {
695
- const objectId = snapshot.grabbedObjectId ?? null;
696
- if (!objectId || !snapshot.surface) return null;
697
- for (const block of snapshot.surface.blocks) {
698
- if (!("segments" in block)) continue;
699
- for (const seg of (block as { segments?: unknown[] }).segments ?? []) {
700
- const s = seg as { kind?: string; mediaId?: string; from?: number; to?: number };
701
- if ((s.kind === "image" || s.kind === "shape") && s.mediaId === objectId && s.from != null) {
702
- return { from: s.from, to: s.to ?? s.from + 1 };
703
- }
704
- }
705
- }
706
- return null;
707
- }, [snapshot.grabbedObjectId, snapshot.surface]);
279
+ const grabbedSegmentOffsets = useGrabbedSegmentOffsets(snapshot);
708
280
 
709
- const statusBarPageFacts = useMemo(() => {
710
- const facet = props.layoutFacet;
711
- if (!facet) {
712
- return {
713
- displayPageNumber: null as number | null,
714
- pageCount: null as number | null,
715
- measurementFidelity: undefined as
716
- | import("../api/public-types.ts").PublicMeasurementFidelity
717
- | undefined,
718
- };
719
- }
720
- const head = selectionPosition;
721
- const pageRef = facet.getPageForOffset(head, snapshot.activeStory);
722
- const displayPageNumber =
723
- pageRef !== null && typeof pageRef.pageIndex === "number"
724
- ? facet.getDisplayPageNumber(pageRef.pageIndex) ?? pageRef.pageIndex + 1
725
- : null;
726
- const pageCount = facet.getPageCount();
727
- return {
728
- displayPageNumber,
729
- pageCount,
730
- measurementFidelity: facet.getMeasurementFidelity(),
731
- };
732
- // eslint-disable-next-line react-hooks/exhaustive-deps
733
- }, [props.layoutFacet, selectionPosition, snapshot.activeStory, renderFrameRevision]);
281
+ const statusBarPageFacts = useStatusBarPageFacts({
282
+ layoutFacet: props.layoutFacet,
283
+ selectionPosition,
284
+ activeStory: snapshot.activeStory,
285
+ renderFrameRevision,
286
+ });
734
287
  // P8.11 — `headerBandLabel` / `footerBandLabel` retired along with the
735
288
  // workspace-level bands. Per-page bands in `TwPageStackChromeLayer`
736
289
  // render the actual header / footer story blocks via
@@ -743,377 +296,250 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
743
296
  snapshot.readOnly ||
744
297
  snapshot.activeStory.kind !== "main" ||
745
298
  effectiveSelectionMode !== "edit";
746
- const responsiveChrome = useMemo(
747
- () =>
748
- resolveResponsiveChromeState({
749
- viewportWidth,
750
- reviewRailAvailable,
751
- reviewRailOpen,
752
- }),
753
- [reviewRailAvailable, reviewRailOpen, viewportWidth],
754
- );
755
299
  const hasSidebarPanelAccess = Boolean(
756
300
  props.onReviewSidebarTrackedChanges || props.onReviewSidebarComments,
757
301
  );
758
- const scopedChromePolicy = useMemo(
759
- () =>
760
- resolveScopedChromePolicy({
761
- preset: chromePreset,
762
- compactMode: responsiveChrome.isNarrow,
763
- capabilities: caps,
764
- interactionGuardSnapshot: props.interactionGuardSnapshot,
765
- workflowScopeSnapshot: props.workflowScopeSnapshot,
766
- activeListContext: props.activeListContext,
767
- role: viewState.editorRole,
768
- hasSidebarPanelAccess,
769
- }),
770
- [
771
- caps,
302
+ const { responsiveChrome, scopedChromePolicy, toolbarInteractionPolicy } =
303
+ useChromePolicy({
304
+ viewportWidth,
305
+ reviewRailAvailable,
306
+ reviewRailOpen,
772
307
  chromePreset,
308
+ caps,
309
+ interactionGuardSnapshot: props.interactionGuardSnapshot,
310
+ workflowScopeSnapshot: props.workflowScopeSnapshot,
311
+ activeListContext: props.activeListContext,
312
+ role: viewState.editorRole,
773
313
  hasSidebarPanelAccess,
774
- props.activeListContext,
775
- props.interactionGuardSnapshot,
776
- props.workflowScopeSnapshot,
777
- responsiveChrome.isNarrow,
778
- viewState.editorRole,
779
- ],
780
- );
781
- const toolbarInteractionPolicy: ToolbarInteractionPolicy | undefined = caps
782
- ? {
783
- mode: effectiveSelectionMode,
784
- canFormatText: caps.canEdit && effectiveSelectionMode === "edit",
785
- canInsertStructural: caps.canEdit && effectiveSelectionMode === "edit",
786
- canAddComment:
787
- caps.canAddComment &&
788
- effectiveSelectionMode !== "view" &&
789
- effectiveSelectionMode !== "blocked",
790
- }
791
- : undefined;
792
-
793
- useEffect(() => {
794
- recordPerfSample("workspace.chrome");
795
- incrementInvalidationCounter("workspace.chrome.recomputes");
796
- }, [activeParagraphLayout, pageChromeModel, pageShellMetrics]);
797
-
798
- useEffect(() => {
799
- if (isPageWorkspace && snapshot.activeStory.kind !== "main") {
800
- setLayoutToolsOpen(true);
801
- }
802
- }, [isPageWorkspace, snapshot.activeStory.kind]);
803
-
804
- // P8.11 — capture the scroll-root DOM element on mount so the chrome
805
- // overlay's `TwPageStackChromeLayer` can measure per-page rects and
806
- // observe DOM mutations. `scrollRootRef` is attached to the existing
807
- // `[data-wre-scroll-root]` container; rely on a mount effect rather
808
- // than a ref callback so render-time state stays cheap.
809
- useEffect(() => {
810
- if (scrollRootRef.current !== pageStackScrollRoot) {
811
- setPageStackScrollRoot(scrollRootRef.current);
812
- }
813
- // A `useEffect` re-runs after every render; the comparison guard
814
- // keeps `setPageStackScrollRoot` from firing every commit. The
815
- // scroll-root identity only changes when the component re-mounts.
816
- });
817
-
818
- // P8.11 — capture the PM surface DOM element. The ProseMirror surface
819
- // mounts inside `bodySlotRef` on its own schedule (the PM constructor
820
- // runs inside the `TwProseMirrorSurface` child component). A
821
- // `MutationObserver` scoped to the body slot's `childList` picks up
822
- // the PM root on first commit; once captured, the chrome layer owns
823
- // reparent state (including portal-slot promotion), so we skip
824
- // further updates unless PM is actually disconnected from the
825
- // document (e.g. session/document swap tearing PM down).
826
- useEffect(() => {
827
- const slot = bodySlotRef.current;
828
- if (!slot) return undefined;
829
-
830
- // If we already hold a live reference, the chrome layer may have
831
- // portaled PM into a per-page band — PM has left `bodySlotRef` but
832
- // is still connected to the document. We keep the reference until
833
- // the node is fully disconnected.
834
- if (pmSurfaceElement && pmSurfaceElement.isConnected) {
835
- return undefined;
836
- }
837
-
838
- const readPm = (): HTMLElement | null =>
839
- slot.querySelector<HTMLElement>(".ProseMirror");
840
-
841
- const current = readPm();
842
- if (current !== pmSurfaceElement) {
843
- setPmSurfaceElement(current);
844
- }
845
- const runtime = slot.ownerDocument?.defaultView as
846
- | (Window & { MutationObserver?: typeof MutationObserver })
847
- | null;
848
- if (!runtime?.MutationObserver) return undefined;
849
- const observer = new runtime.MutationObserver(() => {
850
- const next = readPm();
851
- if (next !== null && next !== pmSurfaceElement) {
852
- setPmSurfaceElement(next);
853
- }
314
+ effectiveSelectionMode,
854
315
  });
855
- // `childList: true, subtree: false` — we only care when children of
856
- // the body slot change (e.g. PM is added for the first time).
857
- // Subtree mutations (PM's own edits) are not our concern and would
858
- // fire on every keystroke.
859
- observer.observe(slot, { childList: true, subtree: false });
860
- return () => observer.disconnect();
861
- }, [pmSurfaceElement]);
862
-
863
- // P8.11 — deprecation shim for the legacy `onOpenHeaderStory` /
864
- // `onOpenFooterStory` props. Per-page chrome bands route clicks via
865
- // `onOpenStory` + `runtime.openStory` directly; the workspace-level
866
- // bands that consumed these callbacks are gone. Kept optional for one
867
- // release so existing hosts compile; a mount-time `console.warn` nudges
868
- // them toward `onOpenStory`.
869
- useEffect(() => {
870
- if (props.onOpenHeaderStory) {
871
- // eslint-disable-next-line no-console
872
- console.warn(
873
- "[docx-react-component] `onOpenHeaderStory` is deprecated. Per-page header bands route clicks via runtime.openStory directly. (P8)",
874
- );
875
- }
876
- if (props.onOpenFooterStory) {
877
- // eslint-disable-next-line no-console
878
- console.warn(
879
- "[docx-react-component] `onOpenFooterStory` is deprecated. Per-page footer bands route clicks via runtime.openStory directly. (P8)",
880
- );
881
- }
882
- // Mount-once: we only want to nudge hosts at startup, not per render.
883
- // eslint-disable-next-line react-hooks/exhaustive-deps
884
- }, []);
885
316
 
886
- useEffect(() => {
887
- if (typeof window === "undefined") {
888
- return;
889
- }
890
-
891
- const updateViewport = () => {
892
- setViewportWidth(readViewportWidth());
893
- setViewportHeight(readViewportHeight());
894
- };
895
-
896
- updateViewport();
897
- window.addEventListener("resize", updateViewport);
898
- return () => {
899
- window.removeEventListener("resize", updateViewport);
900
- };
901
- }, []);
317
+ // L7 Phase 2 Task 2.2.4a — viewport-scroll wiring. Page marker
318
+ // collection, selection-backed visible block/page ranges, and the
319
+ // facet push all live in the hook.
320
+ const { visibleBlockRange, visiblePageIndexRange } = usePageMarkers({
321
+ pageStackScrollRoot,
322
+ snapshot,
323
+ layoutFacet: props.layoutFacet,
324
+ });
902
325
 
903
- // Subscribe to layout facet events so chrome re-projects whenever the
904
- // engine produces new pagination state, fields dirty, or measurement
905
- // fidelity changes. P5b broadened this beyond the original P3.b set
906
- // ("zoom_changed" / "render_frame_ready") so the status-bar Page-N-of-M
907
- // and fidelity badge transition in real time; the hardening commit
908
- // added "measurement_backend_ready" so the canvas backend swap also
909
- // refreshes the badge. P14.b adds "layout_committed" — a single
910
- // coalesced event per applyPatch — so consumers that only care
911
- // about "the engine just finished a build" can react once instead
912
- // of N times.
913
- useEffect(() => {
914
- if (!props.layoutFacet) return;
915
- let pendingBump = false;
916
- let cancelled = false;
917
- const scheduleBump = () => {
918
- if (pendingBump || cancelled) return;
919
- pendingBump = true;
920
- queueMicrotask(() => {
921
- pendingBump = false;
922
- if (cancelled) return;
923
- setRenderFrameRevision((n) => n + 1);
924
- });
925
- };
926
- const unsub = props.layoutFacet.subscribe((event) => {
927
- switch (event.kind) {
928
- case "zoom_changed":
929
- case "render_frame_ready":
930
- case "layout_recomputed":
931
- case "incremental_relayout":
932
- case "page_count_changed":
933
- case "page_field_dirtied":
934
- case "measurement_backend_ready":
935
- case "layout_committed":
936
- scheduleBump();
937
- break;
938
- default:
939
- break;
940
- }
326
+ const { dismissSelectionToolbar, runWithSelectionToolbarDismiss } =
327
+ useWorkspaceSideEffects({
328
+ layoutFacet: props.layoutFacet,
329
+ activeParagraphLayout,
330
+ pageChromeModel,
331
+ pageShellMetrics,
332
+ isPageWorkspace,
333
+ activeStoryKind: snapshot.activeStory.kind,
334
+ setLayoutToolsOpen,
335
+ showDrawerReviewRail: responsiveChrome.showDrawerReviewRail,
336
+ setReviewRailOpen,
337
+ onOpenHeaderStory: props.onOpenHeaderStory,
338
+ onOpenFooterStory: props.onOpenFooterStory,
339
+ onDismissSelectionToolbar: props.onDismissSelectionToolbar,
941
340
  });
942
- return () => {
943
- cancelled = true;
944
- unsub();
945
- };
946
- }, [props.layoutFacet]);
947
341
 
948
- useEffect(() => {
949
- const responsiveSignature = `${reviewRailAvailable ? "1" : "0"}:${isNarrowChromeViewport(viewportWidth) ? "n" : "d"}`;
950
- if (responsiveChromeSignatureRef.current === responsiveSignature) {
951
- return;
952
- }
953
-
954
- responsiveChromeSignatureRef.current = responsiveSignature;
955
- setReviewRailOpen(
956
- getInitialReviewRailOpen({
957
- viewportWidth,
958
- reviewRailAvailable,
959
- }),
342
+ // Audit §2.4 — the shell header is ALWAYS present in default composition.
343
+ // When the host does not supply a pre-assembled shell node, fall back to
344
+ // a default TwShellHeader wired to the workspace's editor-role state so
345
+ // the mode tabs actually change the active role instead of being
346
+ // decorative. The "more" tab is included for layout parity with §6.1
347
+ // but disabled until its handler is defined (Phase Q debug UX / Slice 7).
348
+ // Host-supplied shells continue to win (back-compat).
349
+ const defaultShellModes: readonly ShellHeaderModeOption[] =
350
+ DEFAULT_WORKSPACE_SHELL_MODES;
351
+ const defaultShellActiveMode: ShellHeaderMode = editorRoleToShellMode(
352
+ viewState.editorRole,
353
+ );
354
+ const renderedShell =
355
+ props.shellHeader !== undefined ? (
356
+ props.shellHeader
357
+ ) : (
358
+ <TwShellHeader
359
+ modes={defaultShellModes}
360
+ activeMode={defaultShellActiveMode}
361
+ onModeChange={(mode) => {
362
+ const nextRole = shellModeToEditorRole(mode);
363
+ if (nextRole !== null && nextRole !== viewState.editorRole) {
364
+ props.onEditorRoleChange?.(nextRole);
365
+ }
366
+ }}
367
+ />
960
368
  );
961
- }, [reviewRailAvailable, viewportWidth]);
962
-
963
- // L7 Phase 2 Task 2.2.4a — viewport-scroll wiring.
964
- // Collect DOM elements with `[data-page-frame]` from the PM surface so the
965
- // IntersectionObserver inside `useVisibleBlockRange` can determine which
966
- // pages are currently visible. The MutationObserver refreshes the set when
967
- // the PM surface re-renders (e.g. a document load changes the page count).
968
- const [pageMarkers, setPageMarkers] = useState<readonly HTMLElement[]>([]);
969
369
 
970
- useEffect(() => {
971
- const root = pageStackScrollRoot;
972
- if (!root) {
973
- setPageMarkers([]);
974
- return undefined;
975
- }
976
- const refresh = () => {
977
- const found = Array.from(root.querySelectorAll<HTMLElement>("[data-page-frame]"));
978
- // The boundary widgets between pages N and N+1 carry `data-page-frame`
979
- // for the *next* page (pages 1 … N). Page 0 has no widget before it,
980
- // so we synthesize an in-memory element that carries page-0's attributes.
981
- // This element is NOT in the DOM — the IntersectionObserver won't fire
982
- // on it — but the hook's useMemo uses it to look up block indices when
983
- // the overscan expansion includes page 0 (which happens whenever any of
984
- // pages 1-2 are visible with overscan 1).
985
- const hasPage0 = found.some(
986
- (el) => el.getAttribute("data-page-frame") === "0",
987
- );
988
- if (!hasPage0 && found.length > 0) {
989
- // Derive page-0 block range from the snapshot surface: page 0 starts
990
- // at block 0 and ends just before the first block that belongs to page 1.
991
- // The boundary widget's `data-page-first-block-index` for page 1 tells
992
- // us where page 0 ends.
993
- const page1Marker = found.find(
994
- (el) => el.getAttribute("data-page-frame") === "1",
995
- );
996
- const page1First = page1Marker
997
- ? Number(page1Marker.getAttribute("data-page-first-block-index") ?? "")
998
- : NaN;
999
- const page0Last = Number.isFinite(page1First) && page1First > 0
1000
- ? page1First - 1
1001
- : -1; // page 0 empty or unknown; synthetic marker contributes nothing.
1002
- const ownerDoc = found[0]!.ownerDocument;
1003
- const synth = ownerDoc.createElement("span");
1004
- synth.setAttribute("data-page-frame", "0");
1005
- synth.setAttribute("data-page-first-block-index", "0");
1006
- synth.setAttribute("data-page-last-block-index", String(Math.max(0, page0Last)));
1007
- setPageMarkers([synth, ...found]);
1008
- } else {
1009
- setPageMarkers(found);
1010
- }
1011
- };
1012
- refresh();
1013
- // Observe PM surface mutations for page-count changes (new doc load, etc).
1014
- const view = root.ownerDocument?.defaultView;
1015
- if (!view?.MutationObserver) return undefined;
1016
- const mo = new view.MutationObserver(refresh);
1017
- mo.observe(root, { childList: true, subtree: true });
1018
- return () => mo.disconnect();
1019
- // Re-run when the scroll root changes OR when a new snapshot lands
1020
- // (which may have a different page count and new widgets in the PM DOM).
1021
- // NOTE: snapshot.surface is intentionally excluded — its reference changes on
1022
- // every requestViewportRefresh(), which would add two extra render passes per
1023
- // scroll event. The page-0 fallback uses -1 when page1First is unknown,
1024
- // which is correct (no page-0 blocks → synthetic marker contributes nothing).
1025
- }, [pageStackScrollRoot, snapshot.revisionToken]);
1026
-
1027
- // Derive the surface block index for the current selection head so the
1028
- // hook can extend the visible range to always include the selection.
1029
- const selectionBlockIndex = useMemo(() => {
1030
- const sel = snapshot.selection;
1031
- const blocks = snapshot.surface?.blocks;
1032
- if (!sel || !blocks) return null;
1033
- for (let i = 0; i < blocks.length; i++) {
1034
- const block = blocks[i]!; // from/to are required on all SurfaceBlockSnapshot variants
1035
- const blockFrom = block.from;
1036
- const blockTo = block.to;
1037
- if (sel.head >= blockFrom && sel.head <= blockTo) return i;
1038
- }
1039
- return null;
1040
- }, [snapshot.selection, snapshot.surface]);
1041
-
1042
- const visibleBlockRange = useVisibleBlockRange({
1043
- pageMarkers,
1044
- overscanPages: 2,
1045
- selectionBlockIndex,
1046
- totalBlockCount: snapshot.surface?.blocks.length ?? 0,
370
+ // Audit §2.5 — context band mounts as a composition-level sibling of
371
+ // the toolbar so the workspace row becomes
372
+ // [toolbar left cluster] · [context band] · [toolbar right cluster]
373
+ // and the band can render mode-owned content (Phase B.3 additive mount;
374
+ // Phase D migrates role-action contents into the band and retires the
375
+ // legacy inline role region inside TwToolbar).
376
+ //
377
+ // `resolveChromeComposition` is pure but each call allocates new Sets +
378
+ // arrays. `TwReviewWorkspace` re-renders on every PM transaction (the
379
+ // inverted-truth architecture calls `view.updateState()` wholesale) so
380
+ // we `useMemo` to keep downstream React.memo consumers stable across
381
+ // commits perf invariant #4. `readOnly` is intentionally omitted:
382
+ // TwReviewWorkspaceProps doesn't expose it and the composition seam is
383
+ // independent of read-only posture today; Phase E will thread it
384
+ // through the interactionGuardSnapshot wiring once the rail owns the
385
+ // full posture composition.
386
+ const diagnosticsSignal = useDiagnosticsSignal({
387
+ snapshot,
388
+ caps,
389
+ preserveOnlyCount,
390
+ blockedReasonsCount: blockedReasons.length,
1047
391
  });
392
+ const healthIssueCount = diagnosticsSignal.count;
1048
393
 
1049
- // L7 Phase 2.8 — viewport cull for `TwPageStackChromeLayer`. Returns
1050
- // `null` while the IntersectionObserver hasn't reported yet; the chrome
1051
- // layer treats null as "render every page" so first paint is
1052
- // unaffected. Once the observer fires, the chrome layer only mounts
1053
- // bands for pages inside `[start, end)` plus overscan, eliminating the
1054
- // measured 412 ms Layout + 229 ms Pre-paint cost on 138-pp extra-large.
1055
- const visiblePageIndexRange = useVisiblePageIndexRange({
1056
- pageMarkers,
1057
- overscanPages: 2,
394
+ const composition = useWorkspaceComposition({
395
+ chromePreset,
396
+ chromeOptions: props.chromeOptions,
397
+ reviewMode: props.reviewMode,
398
+ role: viewState.editorRole,
399
+ markupDisplay: props.markupDisplay,
400
+ diagnosticsSignal,
1058
401
  });
402
+ const showHealthRailTab = composition.rail.visibleTabs.has("health");
1059
403
 
1060
- // Push the visible range into the layout facet (which delegates to the
1061
- // runtime's viewport-culling machinery). Depend on `[start, end]` values
1062
- // (not the range object) so identity-preserving updates are a no-op.
1063
- useEffect(() => {
1064
- if (!props.layoutFacet) return;
1065
- props.layoutFacet.setVisibleBlockRange(visibleBlockRange);
1066
- props.layoutFacet.requestViewportRefresh();
1067
- }, [props.layoutFacet, visibleBlockRange.start, visibleBlockRange.end]);
1068
-
1069
- const dismissSelectionToolbar = useCallback(() => {
1070
- props.onDismissSelectionToolbar?.();
1071
- }, [props.onDismissSelectionToolbar]);
1072
-
1073
- const runWithSelectionToolbarDismiss = useCallback(
1074
- (action?: () => void) => () => {
1075
- dismissSelectionToolbar();
1076
- action?.();
1077
- },
1078
- [dismissSelectionToolbar],
1079
- );
1080
-
1081
- useEffect(() => {
1082
- if (!responsiveChrome.showDrawerReviewRail || typeof window === "undefined") {
1083
- return;
1084
- }
1085
-
1086
- const handleKeyDown = (event: KeyboardEvent) => {
1087
- if (event.key !== "Escape") {
1088
- return;
1089
- }
1090
-
1091
- setReviewRailOpen(false);
1092
- };
1093
-
1094
- window.addEventListener("keydown", handleKeyDown);
1095
- return () => {
1096
- window.removeEventListener("keydown", handleKeyDown);
1097
- };
1098
- }, [responsiveChrome.showDrawerReviewRail]);
1099
-
1100
- useEffect(() => {
1101
- if (!props.layoutFacet) return;
1102
- const facet = props.layoutFacet;
1103
- const fontSet = document.fonts;
1104
- if (!fontSet?.ready) {
1105
- facet.swapMeasurementProvider(createCanvasBackend());
1106
- return;
1107
- }
1108
- void fontSet.ready.then(() => {
1109
- facet.swapMeasurementProvider(createCanvasBackend());
1110
- });
1111
- }, [props.layoutFacet]);
404
+ const arbiter = useWorkspaceArbiter();
1112
405
 
1113
406
  return (
407
+ <LocalSurfaceArbiterContext.Provider value={arbiter}>
1114
408
  <Tooltip.Provider delayDuration={400}>
1115
409
  <div className="flex h-full flex-col bg-canvas text-primary">
1116
- {props.shellHeader}
410
+ {renderedShell}
411
+ {/*
412
+ * The context band is a workspace-layer surface (DESIGN-EDITOR.md
413
+ * §4.2) and therefore gated on the toolbar-layer visibility flag
414
+ * — the `selection` chrome preset suppresses both toolbar and
415
+ * band so minimal embeds stay chrome-free. If a future preset
416
+ * wants the band without the formatting toolbar, introduce a
417
+ * dedicated `chromeVisibility.contextBand` flag rather than
418
+ * decoupling this branch.
419
+ */}
420
+ {/*
421
+ * Chrome Closure Pass · Task 1 (designsystem.md §6.3) — the
422
+ * role-action region used to render inline in the toolbar's
423
+ * center subregion. It now lives inside `TwContextBand` so the
424
+ * workspace row reads
425
+ * [shell header] · [TwContextBand carrying role actions] · [TwToolbar (formatting only)]
426
+ * matching §8.8.1's "Workspace context row / mode-owned band"
427
+ * row. Callbacks below mirror what was previously threaded
428
+ * through `ChromePresetToolbar` to `TwRoleActionRegion`; the
429
+ * toolbar still receives `role` for `isChromeItemOwnedByRoleRegion`
430
+ * deferral but no longer renders the region itself.
431
+ */}
432
+ {chromeVisibility.toolbar ? (
433
+ <TwContextBand mode={composition.mode}>
434
+ {viewState.editorRole ? (
435
+ <TwRoleActionRegion
436
+ role={viewState.editorRole}
437
+ policy={scopedChromePolicy}
438
+ compactMode={responsiveChrome.isNarrow}
439
+ reviewQueue={props.reviewQueue}
440
+ markupDisplay={markupDisplay}
441
+ canAddComment={
442
+ toolbarInteractionPolicy?.canAddComment ??
443
+ (caps ? caps.canAddComment : false)
444
+ }
445
+ showTrackedChanges={props.showTrackedChanges}
446
+ capabilities={caps}
447
+ onAddComment={
448
+ props.onAddComment
449
+ ? runWithSelectionToolbarDismiss(props.onAddComment)
450
+ : undefined
451
+ }
452
+ onShowTrackedChangesChange={(show) => {
453
+ dismissSelectionToolbar();
454
+ props.onShowTrackedChangesChange(show);
455
+ }}
456
+ onReviewSidebarTrackedChanges={
457
+ props.onReviewSidebarTrackedChanges
458
+ ? runWithSelectionToolbarDismiss(props.onReviewSidebarTrackedChanges)
459
+ : undefined
460
+ }
461
+ onReviewSidebarComments={
462
+ props.onReviewSidebarComments
463
+ ? runWithSelectionToolbarDismiss(props.onReviewSidebarComments)
464
+ : undefined
465
+ }
466
+ onMarkScopePosture={
467
+ props.onMarkSectionForReview
468
+ ? runWithSelectionToolbarDismiss(props.onMarkSectionForReview)
469
+ : undefined
470
+ }
471
+ onReviewPrev={
472
+ props.onGoToPreviousReviewItem
473
+ ? runWithSelectionToolbarDismiss(props.onGoToPreviousReviewItem)
474
+ : undefined
475
+ }
476
+ onReviewNext={
477
+ props.onGoToNextReviewItem
478
+ ? runWithSelectionToolbarDismiss(props.onGoToNextReviewItem)
479
+ : undefined
480
+ }
481
+ onReviewAccept={(() => {
482
+ const active = props.reviewQueue?.items[props.reviewQueue.activeIndex];
483
+ if (active?.kind !== "change" || !props.onAcceptRevision) {
484
+ return undefined;
485
+ }
486
+ const revisionId = active.itemId;
487
+ return () => {
488
+ dismissSelectionToolbar();
489
+ props.onAcceptRevision?.(revisionId);
490
+ };
491
+ })()}
492
+ onReviewReject={(() => {
493
+ const active = props.reviewQueue?.items[props.reviewQueue.activeIndex];
494
+ if (active?.kind !== "change" || !props.onRejectRevision) {
495
+ return undefined;
496
+ }
497
+ const revisionId = active.itemId;
498
+ return () => {
499
+ dismissSelectionToolbar();
500
+ props.onRejectRevision?.(revisionId);
501
+ };
502
+ })()}
503
+ onReviewAcceptAll={
504
+ props.onAcceptAllChanges
505
+ ? runWithSelectionToolbarDismiss(props.onAcceptAllChanges)
506
+ : undefined
507
+ }
508
+ onReviewRejectAll={
509
+ props.onRejectAllChanges
510
+ ? runWithSelectionToolbarDismiss(props.onRejectAllChanges)
511
+ : undefined
512
+ }
513
+ />
514
+ ) : null}
515
+ </TwContextBand>
516
+ ) : null}
517
+ {/*
518
+ * Phase C.γ.3 — mount the workspace chrome host when the
519
+ * integrator has wired an editor-action bag. Omitting the
520
+ * bag preserves pre-C behavior. Renders portals + a global
521
+ * palette listener; adds no DOM on the edit path.
522
+ */}
523
+ {props.editorActionHost ? (
524
+ <TwWorkspaceChromeHost
525
+ mode={composition.mode}
526
+ editorActionHost={props.editorActionHost}
527
+ {...(props.chromeControllerRef
528
+ ? { controllerRef: props.chromeControllerRef }
529
+ : {})}
530
+ {...(props.commandPaletteDisabled !== undefined
531
+ ? { paletteDisabled: props.commandPaletteDisabled }
532
+ : {})}
533
+ // Chrome Closure Pass · Task 3 — thread the live table
534
+ // context into the chrome host so the right-click menu
535
+ // augments target-kinds with the matching tier.
536
+ tableContext={props.tableContext ?? null}
537
+ onOpenRailTab={(tab) => {
538
+ setReviewRailOpen(true);
539
+ props.onActiveRailTabChange?.(tab);
540
+ }}
541
+ />
542
+ ) : null}
1117
543
  {chromeVisibility.toolbar ? (
1118
544
  <div className="px-3 pt-3">
1119
545
  <ChromePresetToolbar
@@ -1140,6 +566,10 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1140
566
  interactionPolicy={toolbarInteractionPolicy}
1141
567
  scopedChromePolicy={scopedChromePolicy}
1142
568
  compactMode={responsiveChrome.isNarrow}
569
+ onOpenHealthRail={() => {
570
+ setReviewRailOpen(true);
571
+ props.onActiveRailTabChange?.("health");
572
+ }}
1143
573
  workspaceMode={props.workspaceMode}
1144
574
  zoomLevel={props.zoomLevel}
1145
575
  formattingState={props.formattingState}
@@ -1305,99 +735,23 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1305
735
  snapshot={snapshot}
1306
736
  preserveOnlyCount={preserveOnlyCount}
1307
737
  workflowBlockedReasons={blockedReasons}
738
+ onShowDetail={() => {
739
+ setReviewRailOpen(true);
740
+ props.onActiveRailTabChange?.("health");
741
+ }}
1308
742
  /> : null}
1309
743
 
1310
744
  <div className="relative flex flex-1 min-h-0">
1311
- {/* Collapsible document navigator — page mode only */}
1312
- {isPageWorkspace && chromeVisibility.pageChrome ? (
1313
- <aside
1314
- aria-label="Document navigator"
1315
- className={`shrink-0 border-r border-border bg-surface transition-[width] duration-200 ${
1316
- navOpen ? "w-48" : "w-0"
1317
- } overflow-hidden`}
1318
- >
1319
- {navOpen ? (
1320
- <div className="flex h-full flex-col">
1321
- <div className="flex items-center justify-between px-3 py-2 border-b border-border">
1322
- <span className="text-xs font-medium text-secondary uppercase tracking-wider">Navigator</span>
1323
- <Tooltip.Root>
1324
- <Tooltip.Trigger asChild>
1325
- <button
1326
- type="button"
1327
- aria-label="Collapse navigator"
1328
- onMouseDown={preserveEditorSelectionMouseDown}
1329
- onClick={() => {
1330
- dismissSelectionToolbar();
1331
- setNavOpen(false);
1332
- }}
1333
- className="inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary hover:bg-surface-hover transition-colors"
1334
- >
1335
- <ChevronLeft className="h-3.5 w-3.5" />
1336
- </button>
1337
- </Tooltip.Trigger>
1338
- <Tooltip.Portal>
1339
- <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
1340
- Collapse navigator
1341
- </Tooltip.Content>
1342
- </Tooltip.Portal>
1343
- </Tooltip.Root>
1344
- </div>
1345
- <nav className="flex-1 overflow-y-auto px-2 py-2" aria-label="Document headings">
1346
- {headings.length > 0 ? (
1347
- <ul className="space-y-0.5">
1348
- {headings.map((entry) => (
1349
- <li key={entry.headingId}>
1350
- <button
1351
- type="button"
1352
- className="block w-full truncate rounded-md px-2 py-1 text-left text-xs text-primary hover:bg-surface-hover"
1353
- style={{ paddingLeft: `${8 + (entry.level - 1) * 12}px` }}
1354
- onMouseDown={preserveEditorSelectionMouseDown}
1355
- onClick={() => {
1356
- dismissSelectionToolbar();
1357
- props.onNavigateHeading?.(entry.headingId);
1358
- setNavOpen(false);
1359
- }}
1360
- >
1361
- {entry.text}
1362
- </button>
1363
- </li>
1364
- ))}
1365
- </ul>
1366
- ) : (
1367
- <p className="px-2 py-4 text-xs text-tertiary">No headings found.</p>
1368
- )}
1369
- </nav>
1370
- </div>
1371
- ) : null}
1372
- </aside>
1373
- ) : null}
1374
-
1375
- {/* Navigator expand toggle — page mode only when collapsed */}
1376
- {isPageWorkspace && chromeVisibility.pageChrome && !navOpen ? (
1377
- <div className="shrink-0 flex items-start pt-2 pl-1">
1378
- <Tooltip.Root>
1379
- <Tooltip.Trigger asChild>
1380
- <button
1381
- type="button"
1382
- aria-label="Open document navigator"
1383
- onMouseDown={preserveEditorSelectionMouseDown}
1384
- onClick={() => {
1385
- dismissSelectionToolbar();
1386
- setNavOpen(true);
1387
- }}
1388
- className="inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary hover:bg-surface-hover transition-colors"
1389
- >
1390
- <List className="h-3.5 w-3.5" />
1391
- </button>
1392
- </Tooltip.Trigger>
1393
- <Tooltip.Portal>
1394
- <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
1395
- Open document navigator
1396
- </Tooltip.Content>
1397
- </Tooltip.Portal>
1398
- </Tooltip.Root>
1399
- </div>
1400
- ) : null}
745
+ <TwReviewWorkspaceNavigator
746
+ enabled={isPageWorkspace && chromeVisibility.pageChrome}
747
+ navOpen={navOpen}
748
+ setNavOpen={setNavOpen}
749
+ headings={headings}
750
+ {...(props.onNavigateHeading
751
+ ? { onNavigateHeading: props.onNavigateHeading }
752
+ : {})}
753
+ dismissSelectionToolbar={dismissSelectionToolbar}
754
+ />
1401
755
 
1402
756
  {/* Document column */}
1403
757
  <div className="flex flex-1 flex-col min-w-0">
@@ -1414,157 +768,38 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1414
768
  : "wre-canvas-surface relative my-8 overflow-hidden"
1415
769
  }`}
1416
770
  data-zoom-bucket={pageZoomBucket}
1417
- data-zoom-scale={isPageWorkspace ? zoomScale : undefined}
1418
771
  data-workspace-canvas={isPageWorkspace ? "true" : undefined}
1419
772
  data-workspace-mode={isPageWorkspace ? "page" : "canvas"}
1420
773
  >
1421
- {isPageWorkspace && chromeVisibility.pageChrome && snapshot.pageLayout ? (
1422
- <div className="border-b border-border/70 bg-surface/65 px-5 py-3" data-testid="page-context-summary">
1423
- <div className="flex flex-wrap items-center justify-between gap-2">
1424
- <div className="flex flex-wrap items-center gap-2 text-xs text-secondary">
1425
- <span className="rounded-full bg-canvas px-2 py-1 font-medium text-primary">
1426
- {activePage
1427
- ? `Page ${activePage.pageIndex + 1} of ${props.documentNavigation?.pageCount ?? 1}`
1428
- : "Page workspace"}
1429
- </span>
1430
- <span>{`Section ${snapshot.pageLayout.sectionIndex + 1}`}</span>
1431
- <span className="uppercase tracking-[0.12em] text-tertiary">
1432
- {snapshot.pageLayout.orientation}
1433
- </span>
1434
- </div>
1435
- <div className="flex items-center gap-2">
1436
- {snapshot.activeStory.kind !== "main" ? (
1437
- <button
1438
- type="button"
1439
- aria-label="Return to document body"
1440
- onMouseDown={preserveEditorSelectionMouseDown}
1441
- onClick={runWithSelectionToolbarDismiss(props.onCloseStory)}
1442
- className="inline-flex items-center gap-1 rounded-md border border-border bg-canvas px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface"
1443
- >
1444
- Body
1445
- </button>
1446
- ) : null}
1447
- {snapshot.activeStory.kind === "main" && snapshot.pageLayout.sectionIndex > 0 ? (
1448
- <>
1449
- <button
1450
- type="button"
1451
- aria-label="Link header to previous"
1452
- disabled={!props.onSetHeaderFooterLink || !allowLocalChromeMutations}
1453
- onMouseDown={preserveEditorSelectionMouseDown}
1454
- onClick={() => {
1455
- dismissSelectionToolbar();
1456
- props.onSetHeaderFooterLink?.(snapshot.pageLayout!.sectionIndex, {
1457
- kind: "header",
1458
- variant: headerVariant,
1459
- linkToPrevious: true,
1460
- });
1461
- }}
1462
- className="inline-flex items-center gap-1 rounded-md border border-border bg-canvas px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40"
1463
- >
1464
- Link header
1465
- </button>
1466
- <button
1467
- type="button"
1468
- aria-label="Link footer to previous"
1469
- disabled={!props.onSetHeaderFooterLink || !allowLocalChromeMutations}
1470
- onMouseDown={preserveEditorSelectionMouseDown}
1471
- onClick={() => {
1472
- dismissSelectionToolbar();
1473
- props.onSetHeaderFooterLink?.(snapshot.pageLayout!.sectionIndex, {
1474
- kind: "footer",
1475
- variant: footerVariant,
1476
- linkToPrevious: true,
1477
- });
1478
- }}
1479
- className="inline-flex items-center gap-1 rounded-md border border-border bg-canvas px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40"
1480
- >
1481
- Link footer
1482
- </button>
1483
- </>
1484
- ) : null}
1485
- <button
1486
- type="button"
1487
- aria-label="Toggle layout tools"
1488
- aria-expanded={layoutToolsOpen}
1489
- onMouseDown={preserveEditorSelectionMouseDown}
1490
- onClick={() => {
1491
- dismissSelectionToolbar();
1492
- setLayoutToolsOpen((open) => !open);
1493
- }}
1494
- className="inline-flex items-center gap-1 rounded-md border border-border bg-canvas px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface"
1495
- >
1496
- <ChevronRight className={`h-3.5 w-3.5 transition-transform ${layoutToolsOpen ? "rotate-90" : ""}`} />
1497
- Layout tools
1498
- </button>
1499
- </div>
1500
- </div>
1501
- </div>
1502
- ) : null}
1503
- {isPageWorkspace && chromeVisibility.pageChrome && snapshot.pageLayout && layoutToolsOpen ? (
1504
- <div className="px-5 pt-3">
1505
- <TwPageRuler
1506
- pageLayout={snapshot.pageLayout}
1507
- viewState={viewState}
1508
- paragraphLayout={activeParagraphLayout}
1509
- readOnly={pageChromeReadOnly}
1510
- onReturnToBody={props.onCloseStory
1511
- ? runWithSelectionToolbarDismiss(props.onCloseStory)
1512
- : () => undefined}
1513
- onOpenHeader={props.onOpenHeaderStory
1514
- ? runWithSelectionToolbarDismiss(props.onOpenHeaderStory)
1515
- : undefined}
1516
- onOpenFooter={props.onOpenFooterStory
1517
- ? runWithSelectionToolbarDismiss(props.onOpenFooterStory)
1518
- : undefined}
1519
- onSetIndentation={props.onSetParagraphIndentation
1520
- ? (indentation) => {
1521
- dismissSelectionToolbar();
1522
- props.onSetParagraphIndentation?.(indentation);
1523
- }
1524
- : undefined}
1525
- onSetTabStops={props.onSetParagraphTabStops
1526
- ? (tabStops) => {
1527
- dismissSelectionToolbar();
1528
- props.onSetParagraphTabStops?.(tabStops);
1529
- }
1530
- : undefined}
1531
- onRestartNumbering={props.onRestartNumbering
1532
- ? runWithSelectionToolbarDismiss(props.onRestartNumbering)
1533
- : undefined}
1534
- onContinueNumbering={props.onContinueNumbering
1535
- ? runWithSelectionToolbarDismiss(props.onContinueNumbering)
1536
- : undefined}
1537
- />
1538
- <TwLayoutPanel
1539
- pageLayout={snapshot.pageLayout}
1540
- readOnly={pageChromeReadOnly}
1541
- onInsertSectionBreak={props.onInsertSectionBreak
1542
- ? (type) => {
1543
- dismissSelectionToolbar();
1544
- props.onInsertSectionBreak?.(type);
1545
- }
1546
- : undefined}
1547
- onDeleteSectionBreak={props.onDeleteSectionBreak
1548
- ? (sectionIndex) => {
1549
- dismissSelectionToolbar();
1550
- props.onDeleteSectionBreak?.(sectionIndex);
1551
- }
1552
- : undefined}
1553
- onUpdateSectionLayout={props.onUpdateSectionLayout
1554
- ? (sectionIndex, patch) => {
1555
- dismissSelectionToolbar();
1556
- props.onUpdateSectionLayout?.(sectionIndex, patch);
1557
- }
1558
- : undefined}
1559
- onSetSectionPageNumbering={props.onSetSectionPageNumbering
1560
- ? (sectionIndex, patch) => {
1561
- dismissSelectionToolbar();
1562
- props.onSetSectionPageNumbering?.(sectionIndex, patch);
1563
- }
1564
- : undefined}
1565
- />
1566
- </div>
1567
- ) : null}
774
+ <TwReviewWorkspacePageToolbar
775
+ enabled={isPageWorkspace && chromeVisibility.pageChrome && Boolean(snapshot.pageLayout)}
776
+ pageLayout={snapshot.pageLayout}
777
+ activeStory={snapshot.activeStory}
778
+ activePage={activePage}
779
+ pageCount={props.documentNavigation?.pageCount ?? 1}
780
+ headerVariant={headerVariant}
781
+ footerVariant={footerVariant}
782
+ allowLocalChromeMutations={allowLocalChromeMutations}
783
+ pageChromeReadOnly={pageChromeReadOnly}
784
+ layoutToolsOpen={layoutToolsOpen}
785
+ setLayoutToolsOpen={setLayoutToolsOpen}
786
+ viewState={viewState}
787
+ activeParagraphLayout={activeParagraphLayout}
788
+ dismissSelectionToolbar={dismissSelectionToolbar}
789
+ runWithSelectionToolbarDismiss={runWithSelectionToolbarDismiss}
790
+ {...(props.onCloseStory ? { onCloseStory: props.onCloseStory } : {})}
791
+ {...(props.onOpenHeaderStory ? { onOpenHeaderStory: props.onOpenHeaderStory } : {})}
792
+ {...(props.onOpenFooterStory ? { onOpenFooterStory: props.onOpenFooterStory } : {})}
793
+ {...(props.onSetHeaderFooterLink ? { onSetHeaderFooterLink: props.onSetHeaderFooterLink } : {})}
794
+ {...(props.onSetParagraphIndentation ? { onSetParagraphIndentation: props.onSetParagraphIndentation } : {})}
795
+ {...(props.onSetParagraphTabStops ? { onSetParagraphTabStops: props.onSetParagraphTabStops } : {})}
796
+ {...(props.onRestartNumbering ? { onRestartNumbering: props.onRestartNumbering } : {})}
797
+ {...(props.onContinueNumbering ? { onContinueNumbering: props.onContinueNumbering } : {})}
798
+ {...(props.onInsertSectionBreak ? { onInsertSectionBreak: props.onInsertSectionBreak } : {})}
799
+ {...(props.onDeleteSectionBreak ? { onDeleteSectionBreak: props.onDeleteSectionBreak } : {})}
800
+ {...(props.onUpdateSectionLayout ? { onUpdateSectionLayout: props.onUpdateSectionLayout } : {})}
801
+ {...(props.onSetSectionPageNumbering ? { onSetSectionPageNumbering: props.onSetSectionPageNumbering } : {})}
802
+ />
1568
803
  {chromeVisibility.selectionOverlay &&
1569
804
  gatedSelectionTool &&
1570
805
  shouldRenderSelectionToolKind(scopedChromePolicy, gatedSelectionTool.kind) ? (
@@ -1624,6 +859,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1624
859
  }
1625
860
  data-line-numbering={pageChromeModel.lineNumberingEnabled ? "enabled" : "disabled"}
1626
861
  data-paper-frame={isPageWorkspace ? "true" : undefined}
862
+ data-zoom-scale={zoomScale}
1627
863
  data-debug-page-layout={
1628
864
  isPageWorkspace && snapshot.pageLayout
1629
865
  ? `${snapshot.pageLayout.pageWidth}:${snapshot.pageLayout.pageHeight}:${snapshot.pageLayout.orientation}:${snapshot.pageLayout.sectionIndex}`
@@ -1646,7 +882,15 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1646
882
  : {}),
1647
883
  ...(zoomScale !== 1 ? { zoom: zoomScale } : {}),
1648
884
  }
1649
- : undefined
885
+ : zoomScale !== 1
886
+ ? // U2 fix — canvas mode's zoom control was a no-op
887
+ // because this wrapper only applied `zoom:` in page
888
+ // mode. Toolbar advertises "Zoom controls —
889
+ // available in all workspace modes", so canvas must
890
+ // honor it too. Same CSS `zoom` property as page
891
+ // mode for consistent overlay/selection math.
892
+ { zoom: zoomScale }
893
+ : undefined
1650
894
  }
1651
895
  >
1652
896
  {/* N1 (L8 Phase D): per-page paper card backgrounds at z-0,
@@ -1656,6 +900,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1656
900
  {isPageWorkspace && chromeVisibility.pageChrome && props.layoutFacet ? (
1657
901
  <TwPageStackOverlayLayer
1658
902
  facet={props.layoutFacet}
903
+ geometryFacet={props.geometryFacet}
1659
904
  scrollRoot={pageStackScrollRoot}
1660
905
  renderFrameRevision={renderFrameRevision}
1661
906
  visiblePageIndexRange={visiblePageIndexRange}
@@ -1665,6 +910,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1665
910
  {isPageWorkspace && chromeVisibility.pageChrome && props.layoutFacet ? (
1666
911
  <TwFloatingImageLayer
1667
912
  facet={props.layoutFacet}
913
+ geometryFacet={props.geometryFacet}
1668
914
  scrollRoot={pageStackScrollRoot}
1669
915
  renderFrameRevision={renderFrameRevision}
1670
916
  visiblePageIndexRange={visiblePageIndexRange}
@@ -1730,6 +976,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1730
976
  {isPageWorkspace && chromeVisibility.pageChrome && props.layoutFacet ? (
1731
977
  <TwFloatingImageLayer
1732
978
  facet={props.layoutFacet}
979
+ geometryFacet={props.geometryFacet}
1733
980
  scrollRoot={pageStackScrollRoot}
1734
981
  renderFrameRevision={renderFrameRevision}
1735
982
  visiblePageIndexRange={visiblePageIndexRange}
@@ -1739,9 +986,11 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1739
986
  onActivateFloatingImage={props.onActivateFloatingImage}
1740
987
  />
1741
988
  ) : null}
1742
- {props.layoutFacet ? (
989
+ {props.layoutFacet && props.geometryFacet ? (
1743
990
  <TwChromeOverlay
1744
991
  facet={props.layoutFacet}
992
+ geometryFacet={props.geometryFacet}
993
+ workflowFacet={props.workflowFacet ?? null}
1745
994
  grabbedObjectId={snapshot.grabbedObjectId ?? null}
1746
995
  grabbedObjectFromOffset={grabbedSegmentOffsets?.from ?? null}
1747
996
  grabbedObjectToOffset={grabbedSegmentOffsets?.to ?? null}
@@ -1757,17 +1006,17 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1757
1006
  onScopeCardModeChange={handleScopeCardModeChange}
1758
1007
  onScopeCardIssueAction={handleScopeCardIssueAction}
1759
1008
  onScopeCardAcceptSuggestionGroup={
1760
- onScopeAcceptSuggestionGroup
1009
+ props.onScopeAcceptSuggestionGroup
1761
1010
  ? handleScopeCardAcceptSuggestionGroup
1762
1011
  : undefined
1763
1012
  }
1764
1013
  onScopeCardRejectSuggestionGroup={
1765
- onScopeRejectSuggestionGroup
1014
+ props.onScopeRejectSuggestionGroup
1766
1015
  ? handleScopeCardRejectSuggestionGroup
1767
1016
  : undefined
1768
1017
  }
1769
1018
  onScopeCardAskAgent={
1770
- onScopeAskAgent
1019
+ props.onScopeAskAgent
1771
1020
  ? handleScopeCardAskAgent
1772
1021
  : undefined
1773
1022
  }
@@ -1815,590 +1064,60 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1815
1064
  </div>
1816
1065
 
1817
1066
  {/* Review rail — docked on desktop, drawer-backed on narrow layouts */}
1818
- {responsiveChrome.showDockedReviewRail ? <TwReviewRail
1819
- activeTab={props.activeRailTab}
1820
- currentUserId={props.currentUserId}
1821
- comments={snapshot.comments}
1822
- trackedChanges={snapshot.trackedChanges}
1823
- compatibility={snapshot.compatibility}
1824
- warnings={snapshot.warnings}
1825
- markupDisplay={markupDisplay}
1826
- contextAnalytics={
1827
- chromeVisibility.contextAnalytics
1828
- ? props.currentScopeContextAnalytics
1829
- : null
1067
+ <TwReviewWorkspaceRail
1068
+ mode={
1069
+ (responsiveChrome.showDockedReviewRail
1070
+ ? "docked"
1071
+ : responsiveChrome.showDrawerReviewRail
1072
+ ? "drawer"
1073
+ : "hidden") satisfies TwReviewWorkspaceRailMode
1830
1074
  }
1831
- activeCommentId={props.activeCommentId}
1832
- activeRevisionId={props.activeRevisionId}
1833
- onActiveTabChange={props.onActiveRailTabChange}
1834
- onOpenComment={props.onOpenComment}
1835
- onResolveComment={props.onResolveComment}
1836
- onReopenComment={props.onReopenComment}
1837
- onAddReply={props.onAddReply}
1838
- onEditBody={props.onEditBody}
1839
- onOpenRevision={props.onOpenRevision}
1840
- onAcceptRevision={props.onAcceptRevision}
1841
- onRejectRevision={props.onRejectRevision}
1842
- onAcceptAllChanges={props.onAcceptAllChanges}
1843
- onRejectAllChanges={props.onRejectAllChanges}
1844
- scopeRailSegments={props.layoutFacet?.getAllScopeRailSegments?.() ?? []}
1845
- workflowTab={props.reviewRailWorkflowTab}
1846
- workflowCount={props.reviewRailWorkflowCount}
1847
- workflowScopesTitle={props.reviewRailWorkflowScopesTitle}
1848
- intelligenceEyebrow={props.reviewRailIntelligenceEyebrow}
1849
- intelligenceHeader={props.reviewRailIntelligenceHeader}
1850
- railFooter={props.reviewRailFooter}
1851
- /> : null}
1852
-
1853
- {responsiveChrome.showDrawerReviewRail ? (
1854
- <div
1855
- className="pointer-events-none absolute inset-0 z-30 flex justify-end"
1856
- data-testid="review-rail-drawer"
1857
- >
1858
- <button
1859
- type="button"
1860
- aria-label="Close sidebar overlay"
1861
- className="pointer-events-auto absolute inset-0 border-0 bg-[color:rgba(21,26,23,0.08)] dark:bg-[color:rgba(0,0,0,0.32)]"
1862
- onClick={() => setReviewRailOpen(false)}
1863
- />
1864
- <div className="pointer-events-auto relative h-full">
1865
- <TwReviewRail
1866
- variant="drawer"
1867
- activeTab={props.activeRailTab}
1868
- currentUserId={props.currentUserId}
1869
- comments={snapshot.comments}
1870
- trackedChanges={snapshot.trackedChanges}
1871
- compatibility={snapshot.compatibility}
1872
- warnings={snapshot.warnings}
1873
- markupDisplay={markupDisplay}
1874
- contextAnalytics={
1875
- chromeVisibility.contextAnalytics
1876
- ? props.currentScopeContextAnalytics
1877
- : null
1878
- }
1879
- activeCommentId={props.activeCommentId}
1880
- activeRevisionId={props.activeRevisionId}
1881
- onActiveTabChange={props.onActiveRailTabChange}
1882
- onOpenComment={props.onOpenComment}
1883
- onResolveComment={props.onResolveComment}
1884
- onReopenComment={props.onReopenComment}
1885
- onAddReply={props.onAddReply}
1886
- onEditBody={props.onEditBody}
1887
- onOpenRevision={props.onOpenRevision}
1888
- onAcceptRevision={props.onAcceptRevision}
1889
- onRejectRevision={props.onRejectRevision}
1890
- onAcceptAllChanges={props.onAcceptAllChanges}
1891
- onRejectAllChanges={props.onRejectAllChanges}
1892
- scopeRailSegments={props.layoutFacet?.getAllScopeRailSegments?.() ?? []}
1893
- workflowTab={props.reviewRailWorkflowTab}
1894
- workflowCount={props.reviewRailWorkflowCount}
1895
- workflowScopesTitle={props.reviewRailWorkflowScopesTitle}
1896
- intelligenceEyebrow={props.reviewRailIntelligenceEyebrow}
1897
- intelligenceHeader={props.reviewRailIntelligenceHeader}
1898
- railFooter={props.reviewRailFooter}
1899
- />
1900
- </div>
1901
- </div>
1902
- ) : null}
1903
- </div>
1904
- {props.modeDock && props.experimental?.showModeDock === true ? (
1905
- <TwModeDock
1906
- label={props.modeDock.label}
1907
- icon={props.modeDock.icon}
1908
- actions={props.modeDock.actions}
1075
+ onDrawerClose={() => setReviewRailOpen(false)}
1076
+ rail={{
1077
+ activeTab: props.activeRailTab,
1078
+ currentUserId: props.currentUserId,
1079
+ comments: snapshot.comments,
1080
+ trackedChanges: snapshot.trackedChanges,
1081
+ compatibility: snapshot.compatibility,
1082
+ warnings: snapshot.warnings,
1083
+ markupDisplay,
1084
+ contextAnalytics: chromeVisibility.contextAnalytics
1085
+ ? props.currentScopeContextAnalytics
1086
+ : null,
1087
+ activeCommentId: props.activeCommentId,
1088
+ activeRevisionId: props.activeRevisionId,
1089
+ onActiveTabChange: props.onActiveRailTabChange,
1090
+ onOpenComment: props.onOpenComment,
1091
+ onResolveComment: props.onResolveComment,
1092
+ onReopenComment: props.onReopenComment,
1093
+ onAddReply: props.onAddReply,
1094
+ onEditBody: props.onEditBody,
1095
+ onOpenRevision: props.onOpenRevision,
1096
+ onAcceptRevision: props.onAcceptRevision,
1097
+ onRejectRevision: props.onRejectRevision,
1098
+ onAcceptAllChanges: props.onAcceptAllChanges,
1099
+ onRejectAllChanges: props.onRejectAllChanges,
1100
+ // Slice 4C rail-seam inversion: segments now come from the
1101
+ // Layer-06 workflow facet. Layout facet no longer exposes
1102
+ // `getAllScopeRailSegments` (methods removed in v40 / Slice 4C).
1103
+ scopeRailSegments: props.workflowFacet?.getAllRailSegments() ?? [],
1104
+ workflowTab: props.reviewRailWorkflowTab,
1105
+ workflowCount: props.reviewRailWorkflowCount,
1106
+ workflowScopesTitle: props.reviewRailWorkflowScopesTitle,
1107
+ intelligenceEyebrow: props.reviewRailIntelligenceEyebrow,
1108
+ intelligenceHeader: props.reviewRailIntelligenceHeader,
1109
+ railFooter: props.reviewRailFooter,
1110
+ showHealthTab: showHealthRailTab,
1111
+ healthIssueCount,
1112
+ ...(diagnosticsSignal.severity !== "none"
1113
+ ? { healthSeverity: diagnosticsSignal.severity }
1114
+ : {}),
1115
+ workflowBlockedReasons: blockedReasons,
1116
+ }}
1909
1117
  />
1910
- ) : null}
1118
+ </div>
1911
1119
  </div>
1912
1120
  </Tooltip.Provider>
1121
+ </LocalSurfaceArbiterContext.Provider>
1913
1122
  );
1914
1123
  }
1915
-
1916
- function readViewportWidth(): number | undefined {
1917
- return typeof window === "undefined" ? undefined : window.innerWidth;
1918
- }
1919
-
1920
- function readViewportHeight(): number | undefined {
1921
- return typeof window === "undefined" ? undefined : window.innerHeight;
1922
- }
1923
-
1924
- function shouldHidePageBorderForSelection(
1925
- selection: EditorViewStateSnapshot["selection"],
1926
- ): boolean {
1927
- if (selection.isCollapsed) {
1928
- return false;
1929
- }
1930
-
1931
- return selection.activeRange.kind === "range";
1932
- }
1933
-
1934
- function resolveActiveParagraphLayout(
1935
- surface: RuntimeRenderSnapshot["surface"],
1936
- position: number,
1937
- ): {
1938
- leftIndent: number;
1939
- rightIndent: number;
1940
- firstLineOffset: number;
1941
- tabStops: Array<{ pos: number; val?: string; leader?: string }>;
1942
- indentationReadOnly?: boolean;
1943
- tabStopsReadOnly?: boolean;
1944
- } | null {
1945
- const paragraph = surface ? findActiveParagraph(surface.blocks, position) : null;
1946
- if (!paragraph) {
1947
- return null;
1948
- }
1949
- const resolvedIndentation = paragraph.resolvedNumbering?.geometry.indentation;
1950
- const resolvedTabStops = paragraph.resolvedNumbering?.geometry.tabStops;
1951
- const indentation = resolvedIndentation ?? paragraph.indentation;
1952
- const tabStops = resolvedTabStops ?? paragraph.tabStops;
1953
-
1954
- return {
1955
- leftIndent: indentation?.left ?? 0,
1956
- rightIndent: indentation?.right ?? 0,
1957
- firstLineOffset:
1958
- indentation?.firstLine ??
1959
- (indentation?.hanging ? -indentation.hanging : 0),
1960
- tabStops: tabStops ? [...tabStops] : [],
1961
- indentationReadOnly:
1962
- Boolean(resolvedIndentation) &&
1963
- !areIndentationsEqual(resolvedIndentation, paragraph.indentation),
1964
- tabStopsReadOnly:
1965
- Boolean(resolvedTabStops) &&
1966
- !areTabStopsEqual(resolvedTabStops, paragraph.tabStops),
1967
- };
1968
- }
1969
-
1970
- function areIndentationsEqual(
1971
- left:
1972
- | Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>["indentation"]
1973
- | undefined,
1974
- right:
1975
- | Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>["indentation"]
1976
- | undefined,
1977
- ): boolean {
1978
- return (
1979
- left?.left === right?.left &&
1980
- left?.right === right?.right &&
1981
- left?.firstLine === right?.firstLine &&
1982
- left?.hanging === right?.hanging
1983
- );
1984
- }
1985
-
1986
- function areTabStopsEqual(
1987
- left: ReadonlyArray<{ pos: number; val?: string; leader?: string }> | undefined,
1988
- right: ReadonlyArray<{ pos: number; val?: string; leader?: string }> | undefined,
1989
- ): boolean {
1990
- if (!left?.length && !right?.length) {
1991
- return true;
1992
- }
1993
- if (!left || !right || left.length !== right.length) {
1994
- return false;
1995
- }
1996
- return left.every(
1997
- (tabStop, index) =>
1998
- tabStop.pos === right[index]?.pos &&
1999
- tabStop.val === right[index]?.val &&
2000
- tabStop.leader === right[index]?.leader,
2001
- );
2002
- }
2003
-
2004
- function findActiveParagraph(
2005
- blocks: readonly SurfaceBlockSnapshot[],
2006
- position: number,
2007
- ): Extract<SurfaceBlockSnapshot, { kind: "paragraph" }> | null {
2008
- for (const block of blocks) {
2009
- if (block.kind === "paragraph" && position >= block.from && position <= block.to) {
2010
- return block;
2011
- }
2012
- if (block.kind === "table") {
2013
- for (const row of block.rows) {
2014
- for (const cell of row.cells) {
2015
- const paragraph = findActiveParagraph(cell.content, position);
2016
- if (paragraph) {
2017
- return paragraph;
2018
- }
2019
- }
2020
- }
2021
- }
2022
- if (block.kind === "sdt_block") {
2023
- const paragraph = findActiveParagraph(block.children, position);
2024
- if (paragraph) {
2025
- return paragraph;
2026
- }
2027
- }
2028
- }
2029
- return null;
2030
- }
2031
-
2032
- interface PageChromeModel {
2033
- lineNumberingEnabled: boolean;
2034
- gutterWidthPx: number;
2035
- lineMarkers: Array<{ id: string; label: string; topPx: number }>;
2036
- showPageBorder: boolean;
2037
- pageBorderDisplay: string;
2038
- pageBorderStyle: CSSProperties | undefined;
2039
- documentGridType: string;
2040
- documentGridStyle: CSSProperties | undefined;
2041
- }
2042
-
2043
- const EMPTY_PAGE_CHROME_MODEL: PageChromeModel = {
2044
- lineNumberingEnabled: false,
2045
- gutterWidthPx: 0,
2046
- lineMarkers: [],
2047
- showPageBorder: false,
2048
- pageBorderDisplay: "none",
2049
- pageBorderStyle: undefined,
2050
- documentGridType: "none",
2051
- documentGridStyle: undefined,
2052
- };
2053
-
2054
-
2055
- // P2.a — real-dimension page frame. Page frame width/height are
2056
- // `pageLayout.pageWidth/pageHeight × FRAME_PX_PER_TWIP_AT_96DPI` so
2057
- // every paper size renders at its Word-matching CSS px (Letter
2058
- // 816×1056, A4 794×1123, Legal 816×1344, …). Constants are exported
2059
- // so tests + harness panels can derive the same values.
2060
- export const FRAME_PX_PER_TWIP_AT_96DPI = 96 / 1440;
2061
- /** Floor on header/footer band heights so empty bands stay clickable. */
2062
- export const MIN_BAND_HEIGHT_PX = 20;
2063
-
2064
- export interface PageShellMetrics {
2065
- /** P2.a — page frame CSS px width = `pageWidth × FRAME_PX_PER_TWIP_AT_96DPI`. */
2066
- frameWidthPx?: number;
2067
- /** P2.a — page frame CSS px height = `pageHeight × FRAME_PX_PER_TWIP_AT_96DPI`. */
2068
- frameHeightPx?: number;
2069
- contentInsetStyle: CSSProperties;
2070
- pageFrameStyle: CSSProperties;
2071
- }
2072
-
2073
- function buildPageChromeModel(
2074
- surface: RuntimeRenderSnapshot["surface"] | undefined,
2075
- pageLayout: RuntimeRenderSnapshot["pageLayout"] | undefined,
2076
- navigation: DocumentNavigationSnapshot | undefined,
2077
- activeStory: RuntimeRenderSnapshot["activeStory"],
2078
- ): PageChromeModel {
2079
- if (!surface || !pageLayout || !navigation || activeStory.kind !== "main") {
2080
- return EMPTY_PAGE_CHROME_MODEL;
2081
- }
2082
-
2083
- const lineMarkers = computeLineMarkersIfEnabled({
2084
- pageLayout,
2085
- surfaceBlocks: surface.blocks,
2086
- pages: navigation.pages,
2087
- });
2088
- const lineNumberingEnabled =
2089
- Boolean(pageLayout.lineNumbering) && lineMarkers.length > 0;
2090
- const distance = pageLayout.lineNumbering?.distance ?? 0;
2091
- const gutterWidthPx = lineNumberingEnabled
2092
- ? Math.max(40, Math.min(88, 24 + Math.round(distance * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP)))
2093
- : 0;
2094
- const showPageBorder = shouldRenderPageBorder(pageLayout, navigation.pages, navigation.activePageIndex);
2095
-
2096
- return {
2097
- lineNumberingEnabled,
2098
- gutterWidthPx,
2099
- lineMarkers,
2100
- showPageBorder,
2101
- pageBorderDisplay: pageLayout.pageBorders?.display ?? "none",
2102
- pageBorderStyle: showPageBorder ? buildPageBorderStyle(pageLayout) : undefined,
2103
- documentGridType: pageLayout.documentGrid?.type ?? "none",
2104
- documentGridStyle: buildDocumentGridStyle(pageLayout.documentGrid),
2105
- };
2106
- }
2107
-
2108
- export function buildPageShellMetrics(
2109
- pageLayout: RuntimeRenderSnapshot["pageLayout"] | undefined,
2110
- ): PageShellMetrics {
2111
- if (!pageLayout) {
2112
- return {
2113
- contentInsetStyle: {},
2114
- pageFrameStyle: {},
2115
- frameWidthPx: 0,
2116
- frameHeightPx: 0,
2117
- };
2118
- }
2119
-
2120
- // P2.a — frame dimensions follow the section's real twip width/height
2121
- // so every paper size in the catalog renders at its Word-matching CSS
2122
- // px (Letter 816×1056, A4 794×1123, Legal 816×1344, …).
2123
- const pxPerTwip = FRAME_PX_PER_TWIP_AT_96DPI;
2124
- const frameWidthPx = Math.round(pageLayout.pageWidth * pxPerTwip);
2125
- const frameHeightPx = Math.round(pageLayout.pageHeight * pxPerTwip);
2126
- const horizontalInsetPx = Math.round(pageLayout.marginLeft * pxPerTwip);
2127
- const horizontalInsetRightPx = Math.round(pageLayout.marginRight * pxPerTwip);
2128
-
2129
- // P8.11 — `headerBandStyle` / `footerBandStyle` removed. The
2130
- // workspace-level band divs that consumed them are gone; per-page
2131
- // bands (rendered by `TwPageStackChromeLayer`) compute their own
2132
- // heights from the runtime's `PageRegionsSnapshot`.
2133
-
2134
- return {
2135
- contentInsetStyle: {
2136
- paddingLeft: `${horizontalInsetPx}px`,
2137
- paddingRight: `${horizontalInsetRightPx}px`,
2138
- },
2139
- pageFrameStyle: {
2140
- backgroundColor: "var(--color-page-bg)",
2141
- borderRadius: "8px",
2142
- boxShadow: "0 24px 48px -32px rgba(15, 23, 42, 0.38), 0 8px 20px -18px rgba(15, 23, 42, 0.22)",
2143
- border: "1px solid rgba(148, 163, 184, 0.2)",
2144
- },
2145
- frameWidthPx,
2146
- frameHeightPx,
2147
- };
2148
- }
2149
-
2150
- // P2.c — fit-to-width / fit-to-page resolves against the active section's
2151
- // real paper size (not a global constant), so Letter and A4 produce the
2152
- // expected 1.029:1 fit-width ratio at the same viewport. Clamped so
2153
- // extreme viewports don't pin the editor at unreadable zooms.
2154
- const FIT_WIDTH_CHROME_RESERVATION_PX = 96;
2155
- const FIT_HEIGHT_CHROME_RESERVATION_PX = 180;
2156
- const MIN_FIT_ZOOM = 0.5;
2157
- const MAX_FIT_ZOOM = 2.0;
2158
-
2159
- export function resolveZoomMultiplier(
2160
- zoomLevel: number | "pageWidth" | "onePage",
2161
- frameWidthPx: number,
2162
- frameHeightPx: number,
2163
- viewportWidth: number | undefined,
2164
- viewportHeight: number | undefined,
2165
- ): number {
2166
- if (typeof zoomLevel === "number") {
2167
- return zoomLevel / 100;
2168
- }
2169
- if (!viewportWidth || frameWidthPx <= 0) return 1;
2170
- const widthFit =
2171
- (viewportWidth - FIT_WIDTH_CHROME_RESERVATION_PX) / frameWidthPx;
2172
- if (zoomLevel === "pageWidth") {
2173
- return Math.max(MIN_FIT_ZOOM, Math.min(MAX_FIT_ZOOM, widthFit));
2174
- }
2175
- if (!viewportHeight || frameHeightPx <= 0) {
2176
- return Math.max(MIN_FIT_ZOOM, Math.min(MAX_FIT_ZOOM, widthFit));
2177
- }
2178
- const heightFit =
2179
- (viewportHeight - FIT_HEIGHT_CHROME_RESERVATION_PX) / frameHeightPx;
2180
- return Math.max(
2181
- MIN_FIT_ZOOM,
2182
- Math.min(MAX_FIT_ZOOM, Math.min(widthFit, heightFit)),
2183
- );
2184
- }
2185
-
2186
- function shouldRenderPageBorder(
2187
- pageLayout: RuntimeRenderSnapshot["pageLayout"],
2188
- pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
2189
- activePageIndex: number,
2190
- ): boolean {
2191
- const display = pageLayout?.pageBorders?.display ?? "allPages";
2192
- const activePage = pages[activePageIndex];
2193
- if (!pageLayout?.pageBorders || !activePage) {
2194
- return false;
2195
- }
2196
-
2197
- switch (display) {
2198
- case "firstPage":
2199
- return activePage.pageInSection === 0;
2200
- case "notFirstPage":
2201
- return activePage.pageInSection > 0;
2202
- default:
2203
- return true;
2204
- }
2205
- }
2206
-
2207
- function buildPageBorderStyle(
2208
- pageLayout: NonNullable<RuntimeRenderSnapshot["pageLayout"]>,
2209
- ): CSSProperties | undefined {
2210
- const pageBorders = pageLayout.pageBorders;
2211
- if (!pageBorders) {
2212
- return undefined;
2213
- }
2214
-
2215
- const leftInset = createInsetValue(
2216
- pageBorders.left?.space,
2217
- pageBorders.offsetFrom === "text"
2218
- ? (pageLayout.marginLeft / Math.max(1, pageLayout.pageWidth)) * 100
2219
- : 1.25,
2220
- );
2221
- const rightInset = createInsetValue(
2222
- pageBorders.right?.space,
2223
- pageBorders.offsetFrom === "text"
2224
- ? (pageLayout.marginRight / Math.max(1, pageLayout.pageWidth)) * 100
2225
- : 1.25,
2226
- );
2227
- const topInset = createInsetValue(
2228
- pageBorders.top?.space,
2229
- pageBorders.offsetFrom === "text"
2230
- ? (pageLayout.marginTop / Math.max(1, pageLayout.pageHeight)) * 100
2231
- : 1.5,
2232
- );
2233
- const bottomInset = createInsetValue(
2234
- pageBorders.bottom?.space,
2235
- pageBorders.offsetFrom === "text"
2236
- ? (pageLayout.marginBottom / Math.max(1, pageLayout.pageHeight)) * 100
2237
- : 1.5,
2238
- );
2239
-
2240
- return {
2241
- top: topInset,
2242
- right: rightInset,
2243
- bottom: bottomInset,
2244
- left: leftInset,
2245
- borderTop: toBorderCss(pageBorders.top),
2246
- borderRight: toBorderCss(pageBorders.right),
2247
- borderBottom: toBorderCss(pageBorders.bottom),
2248
- borderLeft: toBorderCss(pageBorders.left),
2249
- boxSizing: "border-box",
2250
- mixBlendMode: pageBorders.zOrder === "back" ? "multiply" : undefined,
2251
- };
2252
- }
2253
-
2254
- function buildDocumentGridStyle(
2255
- documentGrid: NonNullable<RuntimeRenderSnapshot["pageLayout"]>["documentGrid"] | undefined,
2256
- ): CSSProperties | undefined {
2257
- if (!documentGrid || !documentGrid.type || documentGrid.type === "default") {
2258
- return undefined;
2259
- }
2260
-
2261
- const linePitchPx = Math.max(
2262
- 18,
2263
- Math.round((documentGrid.linePitch ?? 360) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP),
2264
- );
2265
- const charSpacePx = Math.max(
2266
- 12,
2267
- Math.round((documentGrid.charSpace ?? 204) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP),
2268
- );
2269
- const gridColor = "rgba(15, 23, 42, 0.06)";
2270
- const backgrounds: string[] = [];
2271
-
2272
- if (
2273
- documentGrid.type === "lines" ||
2274
- documentGrid.type === "linesAndChars" ||
2275
- documentGrid.type === "snapToChars"
2276
- ) {
2277
- backgrounds.push(
2278
- `repeating-linear-gradient(to bottom, ${gridColor} 0, ${gridColor} 1px, transparent 1px, transparent ${linePitchPx}px)`,
2279
- );
2280
- }
2281
- if (
2282
- documentGrid.type === "linesAndChars" ||
2283
- documentGrid.type === "snapToChars"
2284
- ) {
2285
- backgrounds.push(
2286
- `repeating-linear-gradient(to right, rgba(15, 23, 42, 0.04) 0, rgba(15, 23, 42, 0.04) 1px, transparent 1px, transparent ${charSpacePx}px)`,
2287
- );
2288
- }
2289
-
2290
- if (backgrounds.length === 0) {
2291
- return undefined;
2292
- }
2293
-
2294
- return {
2295
- backgroundImage: backgrounds.join(", "),
2296
- backgroundOrigin: "content-box",
2297
- };
2298
- }
2299
-
2300
- function createInsetValue(spaceTwips: number | undefined, percent: number): string {
2301
- const spacingPx = Math.max(0, Math.round((spaceTwips ?? 0) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP));
2302
- return `calc(${percent.toFixed(2)}% + ${spacingPx}px)`;
2303
- }
2304
-
2305
- function resolveSelectionToolbarPlacement(
2306
- anchor: SelectionToolAnchor | null | undefined,
2307
- root: HTMLDivElement | null,
2308
- zoomScale: number,
2309
- ): { placement: "right" | "left" | "above" | "below"; style: CSSProperties } | null {
2310
- if (!anchor || !root) {
2311
- return null;
2312
- }
2313
-
2314
- const rootRect = root.getBoundingClientRect();
2315
- if (rootRect.width <= 0 || rootRect.height <= 0 || zoomScale <= 0) {
2316
- return null;
2317
- }
2318
-
2319
- const centerX = (anchor.left + anchor.right) / 2;
2320
- const centerY = (anchor.top + anchor.bottom) / 2;
2321
- const localLeftEdge = (anchor.left - rootRect.left) / zoomScale;
2322
- const localRightEdge = (anchor.right - rootRect.left) / zoomScale;
2323
- const localLeft = (centerX - rootRect.left) / zoomScale;
2324
- const localCenterY = (centerY - rootRect.top) / zoomScale;
2325
- const localTop = (anchor.top - rootRect.top) / zoomScale;
2326
- const localBottom = (anchor.bottom - rootRect.top) / zoomScale;
2327
- const edgePadding = 16 / zoomScale;
2328
- const containerWidth = rootRect.width / zoomScale;
2329
- const containerHeight = rootRect.height / zoomScale;
2330
- const gapPx = 12 / zoomScale;
2331
- const estimatedToolbarWidth = Math.min(260 / zoomScale, Math.max(168 / zoomScale, containerWidth * 0.32));
2332
- const estimatedToolbarHeight = 44 / zoomScale;
2333
- const clampedCenterLeft = Math.max(
2334
- edgePadding,
2335
- Math.min(localLeft, Math.max(edgePadding, containerWidth - edgePadding)),
2336
- );
2337
- const clampedCenterY = Math.max(
2338
- edgePadding + estimatedToolbarHeight / 2,
2339
- Math.min(localCenterY, Math.max(edgePadding + estimatedToolbarHeight / 2, containerHeight - edgePadding - estimatedToolbarHeight / 2)),
2340
- );
2341
- const rightClearance = containerWidth - localRightEdge - gapPx - edgePadding;
2342
- const leftClearance = localLeftEdge - gapPx - edgePadding;
2343
-
2344
- if (rightClearance >= estimatedToolbarWidth) {
2345
- return {
2346
- placement: "right",
2347
- style: {
2348
- left: `${localRightEdge}px`,
2349
- top: `${clampedCenterY}px`,
2350
- maxWidth: `${Math.max(220, containerWidth - edgePadding * 2)}px`,
2351
- transform: `translate(${gapPx}px, -50%)`,
2352
- },
2353
- };
2354
- }
2355
-
2356
- if (leftClearance >= estimatedToolbarWidth) {
2357
- return {
2358
- placement: "left",
2359
- style: {
2360
- left: `${localLeftEdge}px`,
2361
- top: `${clampedCenterY}px`,
2362
- maxWidth: `${Math.max(220, containerWidth - edgePadding * 2)}px`,
2363
- transform: `translate(calc(-100% - ${gapPx}px), -50%)`,
2364
- },
2365
- };
2366
- }
2367
-
2368
- const placement = localTop < estimatedToolbarHeight + gapPx + edgePadding ? "below" : "above";
2369
-
2370
- return {
2371
- placement,
2372
- style: {
2373
- left: `${clampedCenterLeft}px`,
2374
- top: `${placement === "above" ? localTop : localBottom}px`,
2375
- maxWidth: `${Math.max(220, containerWidth - edgePadding * 2)}px`,
2376
- transform:
2377
- placement === "above"
2378
- ? `translate(-50%, calc(-100% - ${gapPx}px))`
2379
- : `translate(-50%, ${gapPx}px)`,
2380
- },
2381
- };
2382
- }
2383
-
2384
- function toBorderCss(
2385
- border:
2386
- | NonNullable<NonNullable<RuntimeRenderSnapshot["pageLayout"]>["pageBorders"]>["top"]
2387
- | undefined,
2388
- ): string | undefined {
2389
- if (!border || border.value === "none" || border.value === "nil") {
2390
- return undefined;
2391
- }
2392
-
2393
- const width = border.size ? `${Math.max(1, Math.round(border.size / 8))}px` : "1px";
2394
- const style =
2395
- border.value === "double"
2396
- ? "double"
2397
- : border.value === "dotted"
2398
- ? "dotted"
2399
- : border.value === "dashed" || border.value === "dashSmallGap"
2400
- ? "dashed"
2401
- : "solid";
2402
- const color = border.color && border.color !== "auto" ? `#${border.color}` : "rgba(31, 31, 31, 0.28)";
2403
- return `${width} ${style} ${color}`;
2404
- }