@opengeni/react 0.46.1 → 0.52.1

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 (341) hide show
  1. package/README.md +158 -14
  2. package/dist/artifacts-document.d.ts +3 -0
  3. package/dist/artifacts-document.js +17 -0
  4. package/dist/artifacts-document.js.map +1 -0
  5. package/dist/artifacts-presentation.d.ts +3 -0
  6. package/dist/artifacts-presentation.js +17 -0
  7. package/dist/artifacts-presentation.js.map +1 -0
  8. package/dist/artifacts-spreadsheet.d.ts +3 -0
  9. package/dist/artifacts-spreadsheet.js +17 -0
  10. package/dist/artifacts-spreadsheet.js.map +1 -0
  11. package/dist/artifacts.d.ts +2 -0
  12. package/dist/artifacts.js +170 -0
  13. package/dist/artifacts.js.map +1 -0
  14. package/dist/chunk-6BBWWOOU.js +82 -0
  15. package/dist/chunk-6BBWWOOU.js.map +1 -0
  16. package/dist/{chunk-HFO4ERGQ.js → chunk-724BW4TW.js} +586 -35
  17. package/dist/chunk-724BW4TW.js.map +1 -0
  18. package/dist/chunk-7I2KB3OK.js +3895 -0
  19. package/dist/chunk-7I2KB3OK.js.map +1 -0
  20. package/dist/chunk-7WCLAEFV.js +2828 -0
  21. package/dist/chunk-7WCLAEFV.js.map +1 -0
  22. package/dist/{chunk-U3CSCMWA.js → chunk-FK6VG4LL.js} +9 -13
  23. package/dist/chunk-FK6VG4LL.js.map +1 -0
  24. package/dist/{chunk-SRFUT2ZU.js → chunk-FWS3KC3Z.js} +120 -64
  25. package/dist/chunk-FWS3KC3Z.js.map +1 -0
  26. package/dist/{chunk-EKZH6IDG.js → chunk-HDBG7XGO.js} +62 -1
  27. package/dist/chunk-HDBG7XGO.js.map +1 -0
  28. package/dist/chunk-HUIKIDM5.js +2148 -0
  29. package/dist/chunk-HUIKIDM5.js.map +1 -0
  30. package/dist/{chunk-LGVMUIRV.js → chunk-JAGDUCZQ.js} +1053 -390
  31. package/dist/chunk-JAGDUCZQ.js.map +1 -0
  32. package/dist/{chunk-LWR4MXSS.js → chunk-JR3QFY6B.js} +420 -35
  33. package/dist/chunk-JR3QFY6B.js.map +1 -0
  34. package/dist/chunk-JVYQRRC4.js +53 -0
  35. package/dist/chunk-JVYQRRC4.js.map +1 -0
  36. package/dist/chunk-KCGXPP5V.js +157 -0
  37. package/dist/chunk-KCGXPP5V.js.map +1 -0
  38. package/dist/{chunk-EB67J347.js → chunk-KZ73RL76.js} +111 -31
  39. package/dist/chunk-KZ73RL76.js.map +1 -0
  40. package/dist/chunk-PEI6RANI.js +3234 -0
  41. package/dist/chunk-PEI6RANI.js.map +1 -0
  42. package/dist/{chunk-3PB7MIT6.js → chunk-ROIRTM37.js} +220 -72
  43. package/dist/chunk-ROIRTM37.js.map +1 -0
  44. package/dist/chunk-VZTQ73XT.js +86 -0
  45. package/dist/chunk-VZTQ73XT.js.map +1 -0
  46. package/dist/{chunk-VCBFUSUZ.js → chunk-WSK7G5LE.js} +72 -4
  47. package/dist/chunk-WSK7G5LE.js.map +1 -0
  48. package/dist/{chunk-FGZUCXZF.js → chunk-WYA65Y3F.js} +16 -16
  49. package/dist/chunk-WYA65Y3F.js.map +1 -0
  50. package/dist/{chunk-6EDV6YFE.js → chunk-X4S56Y2H.js} +147 -35
  51. package/dist/chunk-X4S56Y2H.js.map +1 -0
  52. package/dist/client.d.ts +7 -1
  53. package/dist/clipboard.d.ts +1 -0
  54. package/dist/clipboard.js +7 -0
  55. package/dist/clipboard.js.map +1 -0
  56. package/dist/commands/index.d.ts +3 -3
  57. package/dist/commands/registry.d.ts +1 -1
  58. package/dist/commands/types.d.ts +1 -1
  59. package/dist/components/approval-surface.d.ts +2 -2
  60. package/dist/components/artifacts/artifact-surface.d.ts +18 -0
  61. package/dist/components/artifacts/document-editor.d.ts +172 -0
  62. package/dist/components/artifacts/editable-artifact-ui.d.ts +13 -0
  63. package/dist/components/artifacts/editable-artifact-workbench.d.ts +49 -0
  64. package/dist/components/artifacts/editable-document.d.ts +24 -0
  65. package/dist/components/artifacts/editable-presentation.d.ts +24 -0
  66. package/dist/components/artifacts/editable-spreadsheet.d.ts +32 -0
  67. package/dist/components/artifacts/index.d.ts +8 -0
  68. package/dist/components/artifacts/presentation-editor.d.ts +297 -0
  69. package/dist/components/artifacts/spreadsheet-canvas.d.ts +120 -0
  70. package/dist/components/artifacts/spreadsheet-grid.d.ts +113 -0
  71. package/dist/components/browser-viewer.d.ts +29 -0
  72. package/dist/components/chat-composer.d.ts +13 -8
  73. package/dist/components/code-editor.d.ts +25 -3
  74. package/dist/components/command-palette.d.ts +2 -2
  75. package/dist/components/composer-transcription-control.d.ts +4 -2
  76. package/dist/components/composer.d.ts +29 -15
  77. package/dist/components/computer-viewer.d.ts +23 -0
  78. package/dist/components/copy-button.d.ts +2 -2
  79. package/dist/components/desktop-viewer.d.ts +25 -21
  80. package/dist/components/diff-view.d.ts +1 -1
  81. package/dist/components/enrollment-consent.d.ts +1 -1
  82. package/dist/components/enrollment-device-flow.d.ts +1 -1
  83. package/dist/components/file-browser.d.ts +2 -2
  84. package/dist/components/fleet-tile.d.ts +1 -1
  85. package/dist/components/generated-video-player.d.ts +10 -0
  86. package/dist/components/human-input-form.d.ts +1 -1
  87. package/dist/components/human-input-surface.d.ts +2 -2
  88. package/dist/components/interactive-workbench-viewers.d.ts +2 -0
  89. package/dist/components/machine-card.d.ts +2 -2
  90. package/dist/components/machine-dock-bar.d.ts +3 -3
  91. package/dist/components/machine-health-pill.d.ts +2 -2
  92. package/dist/components/machine-input-display.d.ts +1 -1
  93. package/dist/components/machine-metrics.d.ts +2 -2
  94. package/dist/components/machine-status-pill.d.ts +4 -4
  95. package/dist/components/machines/health.d.ts +1 -1
  96. package/dist/components/machines/machine-detail.d.ts +5 -3
  97. package/dist/components/machines/metric-history-chart.d.ts +1 -1
  98. package/dist/components/machines/metric-sparkline.d.ts +2 -2
  99. package/dist/components/machines/series.d.ts +2 -2
  100. package/dist/components/machines-dashboard.d.ts +5 -5
  101. package/dist/components/markdown.d.ts +1 -1
  102. package/dist/components/message-timeline.d.ts +22 -5
  103. package/dist/components/model-picker.d.ts +2 -2
  104. package/dist/components/model-policy-picker.d.ts +7 -7
  105. package/dist/components/pierre-diff.d.ts +1 -1
  106. package/dist/components/pierre-file.d.ts +1 -1
  107. package/dist/components/queue-draft-policy.d.ts +1 -1
  108. package/dist/components/queue-surface-implementation.d.ts +3 -3
  109. package/dist/components/queue-surface-state.d.ts +4 -4
  110. package/dist/components/queue-surface.d.ts +5 -5
  111. package/dist/components/sandbox-files.d.ts +5 -5
  112. package/dist/components/sandbox-terminal.d.ts +50 -4
  113. package/dist/components/sandbox-workspace.d.ts +30 -10
  114. package/dist/components/session-chrome.d.ts +4 -4
  115. package/dist/components/session-status.d.ts +2 -2
  116. package/dist/components/timeline-annotation-selection.d.ts +9 -0
  117. package/dist/components/timeline-annotations-dialog.d.ts +16 -0
  118. package/dist/components/timeline-annotations.d.ts +13 -0
  119. package/dist/components/tooltip.d.ts +4 -4
  120. package/dist/components/user-message-body.d.ts +2 -2
  121. package/dist/components/workbench-changes.d.ts +2 -2
  122. package/dist/components/workspace-dock.d.ts +3 -1
  123. package/dist/composer.d.ts +11 -11
  124. package/dist/composer.js +7 -7
  125. package/dist/hooks/internal.d.ts +7 -1
  126. package/dist/hooks/use-attached-browsers.d.ts +21 -0
  127. package/dist/hooks/use-available-models.d.ts +2 -2
  128. package/dist/hooks/use-billing-usage.d.ts +1 -1
  129. package/dist/hooks/use-browser-frame-stream.d.ts +28 -0
  130. package/dist/hooks/use-browser-identities.d.ts +26 -0
  131. package/dist/hooks/use-browser-session.d.ts +28 -0
  132. package/dist/hooks/use-browser-sessions.d.ts +32 -0
  133. package/dist/hooks/use-codex-accounts.d.ts +3 -3
  134. package/dist/hooks/use-composer.d.ts +36 -9
  135. package/dist/hooks/use-computer-frame-stream.d.ts +24 -0
  136. package/dist/hooks/use-computer-session.d.ts +28 -0
  137. package/dist/hooks/use-computer-sessions.d.ts +28 -0
  138. package/dist/hooks/use-desktop-stream.d.ts +1 -1
  139. package/dist/hooks/use-environments.d.ts +11 -5
  140. package/dist/hooks/use-file-attachments.d.ts +1 -1
  141. package/dist/hooks/use-goal.d.ts +2 -2
  142. package/dist/hooks/use-human-input.d.ts +2 -2
  143. package/dist/hooks/use-last-started-turn-policy.d.ts +2 -2
  144. package/dist/hooks/use-machine-chip.d.ts +2 -2
  145. package/dist/hooks/use-machines.d.ts +10 -3
  146. package/dist/hooks/use-packs.d.ts +7 -2
  147. package/dist/hooks/use-rigs.d.ts +1 -1
  148. package/dist/hooks/use-sandbox-files.d.ts +25 -4
  149. package/dist/hooks/use-sandbox-git.d.ts +13 -5
  150. package/dist/hooks/use-sandbox-terminal.d.ts +1 -1
  151. package/dist/hooks/use-scheduled-tasks.d.ts +1 -1
  152. package/dist/hooks/use-session-capabilities.d.ts +5 -4
  153. package/dist/hooks/use-session-control.d.ts +1 -1
  154. package/dist/hooks/use-session-events.d.ts +2 -2
  155. package/dist/hooks/use-session-lineage.d.ts +1 -1
  156. package/dist/hooks/use-session-mcp-approval-policy.d.ts +2 -2
  157. package/dist/hooks/use-session.d.ts +2 -2
  158. package/dist/hooks/use-slash-commands.d.ts +1 -1
  159. package/dist/hooks/use-terminal-stream.d.ts +8 -2
  160. package/dist/hooks/use-transcription.d.ts +2 -2
  161. package/dist/hooks/use-turn-queue.d.ts +2 -2
  162. package/dist/hooks/use-video-artifact-playback.d.ts +9 -0
  163. package/dist/hooks/use-voice-input.d.ts +6 -4
  164. package/dist/hooks/use-workspace-capture.d.ts +1 -1
  165. package/dist/hooks/use-workspace-edit.d.ts +1 -1
  166. package/dist/hooks/use-workspace-sessions.d.ts +1 -1
  167. package/dist/hooks/use-workspaces.d.ts +1 -1
  168. package/dist/index.d.ts +158 -154
  169. package/dist/index.js +1718 -908
  170. package/dist/index.js.map +1 -1
  171. package/dist/interaction.d.ts +23 -0
  172. package/dist/interaction.js +36 -0
  173. package/dist/interaction.js.map +1 -0
  174. package/dist/interactive-workbench-viewers-KHNOHWQE.js +13 -0
  175. package/dist/interactive-workbench-viewers-KHNOHWQE.js.map +1 -0
  176. package/dist/lib/composer-responsive-context.d.ts +9 -0
  177. package/dist/lib/relay-wire.d.ts +4 -0
  178. package/dist/lib/sandbox-liveness.d.ts +9 -0
  179. package/dist/lib/terminal-capability.d.ts +5 -0
  180. package/dist/lib/use-portal-token-style.d.ts +14 -3
  181. package/dist/lib/xterm-theme.d.ts +1 -1
  182. package/dist/machines.d.ts +30 -30
  183. package/dist/machines.js +3 -3
  184. package/dist/provider.d.ts +4 -4
  185. package/dist/{queue-surface-implementation-J4XJFKRW.js → queue-surface-implementation-2KVJTYXH.js} +117 -64
  186. package/dist/queue-surface-implementation-2KVJTYXH.js.map +1 -0
  187. package/dist/realtime/dropdown-menu.d.ts +8 -8
  188. package/dist/realtime/realtime-control.d.ts +15 -6
  189. package/dist/realtime.d.ts +4 -4
  190. package/dist/realtime.js +30 -33
  191. package/dist/realtime.js.map +1 -1
  192. package/dist/session-context.d.ts +19 -1
  193. package/dist/session-ui.d.ts +14 -14
  194. package/dist/session-ui.js +6 -5
  195. package/dist/session.d.ts +28 -28
  196. package/dist/session.js +5 -5
  197. package/dist/timeline/activity-rail.d.ts +6 -3
  198. package/dist/timeline/disclosure-context.d.ts +1 -1
  199. package/dist/timeline/fleet-decision-projection.d.ts +1 -1
  200. package/dist/timeline/fleet-decision-row.d.ts +3 -3
  201. package/dist/timeline/index.d.ts +19 -19
  202. package/dist/timeline/parsers.d.ts +6 -3
  203. package/dist/timeline/projection.d.ts +3 -3
  204. package/dist/timeline/registry.d.ts +10 -1
  205. package/dist/timeline/screenshot-lightbox.d.ts +2 -2
  206. package/dist/timeline/shared.d.ts +21 -10
  207. package/dist/timeline/tool-diff.d.ts +2 -2
  208. package/dist/timeline/tool-renderers.d.ts +1 -1
  209. package/dist/timeline/turn-summary.d.ts +3 -3
  210. package/dist/timeline/types.d.ts +42 -2
  211. package/dist/timeline-annotation-selection-3L7LHGG2.js +187 -0
  212. package/dist/timeline-annotation-selection-3L7LHGG2.js.map +1 -0
  213. package/dist/timeline-annotations-dialog-U7EVLR75.js +196 -0
  214. package/dist/timeline-annotations-dialog-U7EVLR75.js.map +1 -0
  215. package/dist/voice-recording-store.d.ts +8 -2
  216. package/package.json +52 -7
  217. package/src/artifacts-document.ts +31 -0
  218. package/src/artifacts-presentation.ts +36 -0
  219. package/src/artifacts-spreadsheet.ts +21 -0
  220. package/src/artifacts.ts +86 -0
  221. package/src/client.ts +90 -1
  222. package/src/clipboard.ts +2 -0
  223. package/src/components/artifacts/artifact-surface.tsx +93 -0
  224. package/src/components/artifacts/document-editor.tsx +2255 -0
  225. package/src/components/artifacts/editable-artifact-ui.tsx +89 -0
  226. package/src/components/artifacts/editable-artifact-workbench.tsx +203 -0
  227. package/src/components/artifacts/editable-document.tsx +657 -0
  228. package/src/components/artifacts/editable-presentation.tsx +1020 -0
  229. package/src/components/artifacts/editable-spreadsheet.tsx +733 -0
  230. package/src/components/artifacts/index.ts +99 -0
  231. package/src/components/artifacts/presentation-editor.tsx +3148 -0
  232. package/src/components/artifacts/spreadsheet-canvas.ts +799 -0
  233. package/src/components/artifacts/spreadsheet-grid.tsx +2129 -0
  234. package/src/components/browser-viewer.tsx +1592 -0
  235. package/src/components/chat-composer.tsx +25 -2
  236. package/src/components/code-editor.tsx +90 -31
  237. package/src/components/command-palette.tsx +1 -1
  238. package/src/components/composer-transcription-control.tsx +51 -22
  239. package/src/components/composer.tsx +49 -23
  240. package/src/components/computer-viewer.tsx +1139 -0
  241. package/src/components/copy-button.tsx +1 -1
  242. package/src/components/desktop-viewer.tsx +103 -75
  243. package/src/components/file-browser.tsx +39 -17
  244. package/src/components/generated-video-player.tsx +89 -0
  245. package/src/components/human-input-form.tsx +2 -2
  246. package/src/components/interactive-workbench-viewers.ts +2 -0
  247. package/src/components/machine-card.tsx +1 -1
  248. package/src/components/machine-input-display.ts +3 -0
  249. package/src/components/machines/machine-detail.tsx +39 -15
  250. package/src/components/machines-dashboard.tsx +7 -6
  251. package/src/components/message-timeline.tsx +327 -37
  252. package/src/components/model-picker.tsx +2 -2
  253. package/src/components/model-policy-picker.tsx +16 -15
  254. package/src/components/queue-surface-implementation.tsx +135 -75
  255. package/src/components/queue-surface-state.tsx +2 -2
  256. package/src/components/sandbox-files.tsx +142 -62
  257. package/src/components/sandbox-terminal.tsx +119 -35
  258. package/src/components/sandbox-workspace.tsx +394 -228
  259. package/src/components/session-chrome.tsx +67 -27
  260. package/src/components/timeline-annotation-selection.tsx +221 -0
  261. package/src/components/timeline-annotations-dialog.tsx +213 -0
  262. package/src/components/timeline-annotations.tsx +84 -0
  263. package/src/components/tooltip.tsx +13 -16
  264. package/src/components/user-message-body.tsx +1 -1
  265. package/src/components/workbench-changes.tsx +192 -35
  266. package/src/components/workspace-dock.tsx +13 -5
  267. package/src/composer.ts +3 -0
  268. package/src/hooks/internal.ts +132 -38
  269. package/src/hooks/use-attached-browsers.ts +152 -0
  270. package/src/hooks/use-browser-frame-stream.ts +316 -0
  271. package/src/hooks/use-browser-identities.ts +225 -0
  272. package/src/hooks/use-browser-session.ts +476 -0
  273. package/src/hooks/use-browser-sessions.ts +281 -0
  274. package/src/hooks/use-codex-accounts.ts +4 -4
  275. package/src/hooks/use-composer.ts +769 -24
  276. package/src/hooks/use-computer-frame-stream.ts +311 -0
  277. package/src/hooks/use-computer-session.ts +442 -0
  278. package/src/hooks/use-computer-sessions.ts +245 -0
  279. package/src/hooks/use-environments.ts +24 -5
  280. package/src/hooks/use-file-attachments.ts +70 -32
  281. package/src/hooks/use-goal.ts +17 -5
  282. package/src/hooks/use-machine-chip.ts +5 -3
  283. package/src/hooks/use-machines.ts +79 -3
  284. package/src/hooks/use-packs.ts +51 -0
  285. package/src/hooks/use-sandbox-files.ts +382 -88
  286. package/src/hooks/use-sandbox-git.ts +208 -46
  287. package/src/hooks/use-sandbox-terminal.ts +3 -2
  288. package/src/hooks/use-session-capabilities.ts +94 -70
  289. package/src/hooks/use-session-events.ts +18 -4
  290. package/src/hooks/use-terminal-stream.ts +102 -23
  291. package/src/hooks/use-transcription.ts +4 -14
  292. package/src/hooks/use-turn-queue.ts +17 -5
  293. package/src/hooks/use-video-artifact-playback.ts +28 -0
  294. package/src/hooks/use-voice-input.ts +163 -19
  295. package/src/hooks/use-windowed-sections.ts +9 -7
  296. package/src/hooks/use-workspace-edit.ts +2 -1
  297. package/src/index.ts +21 -6
  298. package/src/interaction.ts +71 -0
  299. package/src/lib/composer-responsive-context.ts +15 -0
  300. package/src/lib/relay-wire.ts +4 -0
  301. package/src/lib/sandbox-liveness.ts +11 -0
  302. package/src/lib/terminal-capability.ts +10 -0
  303. package/src/lib/use-portal-token-style.ts +60 -15
  304. package/src/provider.tsx +52 -7
  305. package/src/realtime/dropdown-menu.tsx +15 -19
  306. package/src/realtime/realtime-control.tsx +16 -5
  307. package/src/session-context.ts +90 -0
  308. package/src/timeline/activity-rail.tsx +31 -6
  309. package/src/timeline/index.ts +4 -1
  310. package/src/timeline/parsers.ts +46 -20
  311. package/src/timeline/projection.ts +233 -13
  312. package/src/timeline/registry.ts +19 -0
  313. package/src/timeline/screenshot-lightbox.tsx +30 -9
  314. package/src/timeline/shared.tsx +59 -11
  315. package/src/timeline/tool-renderers.tsx +421 -24
  316. package/src/timeline/turn-summary.tsx +2 -2
  317. package/src/timeline/types.ts +58 -2
  318. package/src/voice-recording-store.ts +15 -0
  319. package/styles/compiled.css +5298 -0
  320. package/styles/compiled.d.ts +2 -0
  321. package/styles/effective-tokens.css +27 -0
  322. package/styles/index.css +18 -0
  323. package/styles/responsive.css +208 -0
  324. package/styles/responsive.d.ts +2 -0
  325. package/styles/tokens.css +50 -17
  326. package/dist/chunk-3PB7MIT6.js.map +0 -1
  327. package/dist/chunk-6EDV6YFE.js.map +0 -1
  328. package/dist/chunk-EB67J347.js.map +0 -1
  329. package/dist/chunk-EKZH6IDG.js.map +0 -1
  330. package/dist/chunk-FGZUCXZF.js.map +0 -1
  331. package/dist/chunk-HFO4ERGQ.js.map +0 -1
  332. package/dist/chunk-JB5725BI.js +0 -48
  333. package/dist/chunk-JB5725BI.js.map +0 -1
  334. package/dist/chunk-LGVMUIRV.js.map +0 -1
  335. package/dist/chunk-LWR4MXSS.js.map +0 -1
  336. package/dist/chunk-SRFUT2ZU.js.map +0 -1
  337. package/dist/chunk-U3CSCMWA.js.map +0 -1
  338. package/dist/chunk-VCBFUSUZ.js.map +0 -1
  339. package/dist/fleet-decision-projection-SLNZMS42.js +0 -241
  340. package/dist/fleet-decision-projection-SLNZMS42.js.map +0 -1
  341. package/dist/queue-surface-implementation-J4XJFKRW.js.map +0 -1
@@ -0,0 +1,2255 @@
1
+ import type {
2
+ Document as ReferenceDocument,
3
+ DocumentTextStyle as ReferenceDocumentTextStyle,
4
+ } from "@opengeni/artifact-tool/reference";
5
+ import {
6
+ BoldIcon,
7
+ Columns2Icon,
8
+ ItalicIcon,
9
+ MessageSquareTextIcon,
10
+ PilcrowIcon,
11
+ Rows3Icon,
12
+ UnderlineIcon,
13
+ } from "lucide-react";
14
+ import {
15
+ type CSSProperties,
16
+ type KeyboardEvent as ReactKeyboardEvent,
17
+ type MouseEvent as ReactMouseEvent,
18
+ type ReactNode,
19
+ type UIEvent as ReactUIEvent,
20
+ memo,
21
+ useCallback,
22
+ useEffect,
23
+ useLayoutEffect,
24
+ useMemo,
25
+ useRef,
26
+ useState,
27
+ } from "react";
28
+
29
+ import { cn } from "../../lib/cn";
30
+ import { ArtifactSurface } from "./artifact-surface";
31
+
32
+ export type DocumentLayoutMode = "paginated" | "continuous";
33
+
34
+ export type DocumentSelection = {
35
+ blockId: string;
36
+ start: number;
37
+ end: number;
38
+ };
39
+
40
+ export type DocumentCommit =
41
+ | {
42
+ kind: "text";
43
+ blockId: string;
44
+ text: string;
45
+ revision: string | number;
46
+ }
47
+ | {
48
+ kind: "format";
49
+ blockId: string;
50
+ range: { start: number; end: number };
51
+ style: Partial<ReferenceDocumentTextStyle>;
52
+ revision: string | number;
53
+ }
54
+ | {
55
+ kind: "insert-paragraph";
56
+ blockId: string;
57
+ revision: string | number;
58
+ };
59
+
60
+ export type DocumentCommitHandler =
61
+ | ((commit: DocumentCommit) => void)
62
+ | ((commit: DocumentCommit) => Promise<void>);
63
+
64
+ export type DocumentProjectionPageGeometry = Readonly<{
65
+ widthPt: number;
66
+ heightPt: number;
67
+ marginTopPt: number;
68
+ marginRightPt: number;
69
+ marginBottomPt: number;
70
+ marginLeftPt: number;
71
+ headerPt?: number;
72
+ footerPt?: number;
73
+ gutterPt?: number;
74
+ }>;
75
+
76
+ export type DocumentProjectionTextStyle = Readonly<{
77
+ fontFamily?: string;
78
+ fontSizePt?: number;
79
+ color?: string;
80
+ bold?: boolean;
81
+ italic?: boolean;
82
+ underline?: boolean;
83
+ strike?: boolean;
84
+ }>;
85
+
86
+ export type DocumentProjectionParagraphStyle = Readonly<{
87
+ headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
88
+ alignment?: "left" | "center" | "right" | "justify";
89
+ spaceBeforePt?: number;
90
+ spaceAfterPt?: number;
91
+ lineHeight?: number;
92
+ keepNext?: boolean;
93
+ pageBreakBefore?: boolean;
94
+ list?: Readonly<{
95
+ kind: "bullet" | "number";
96
+ level?: number;
97
+ instanceId?: string;
98
+ }>;
99
+ }>;
100
+
101
+ export type DocumentProjectionTextRun = Readonly<{
102
+ text: string;
103
+ style?: DocumentProjectionTextStyle;
104
+ }>;
105
+
106
+ export type DocumentProjectionParagraph = Readonly<{
107
+ kind: "paragraph";
108
+ id: string;
109
+ runs: readonly DocumentProjectionTextRun[];
110
+ style?: DocumentProjectionParagraphStyle;
111
+ }>;
112
+
113
+ export type DocumentProjectionTable = Readonly<{
114
+ kind: "table";
115
+ id: string;
116
+ rows: readonly (readonly (readonly DocumentProjectionTextRun[])[])[];
117
+ style?: Readonly<{
118
+ widthPt?: number;
119
+ columnWidthsPt?: readonly number[];
120
+ headerRows?: number;
121
+ cellPaddingPt?: number;
122
+ borderColor?: string;
123
+ headerFill?: string;
124
+ allowRowSplit?: boolean;
125
+ }>;
126
+ }>;
127
+
128
+ export type DocumentProjectionPageBreak = Readonly<{
129
+ kind: "pageBreak";
130
+ id: string;
131
+ }>;
132
+
133
+ export type DocumentProjectionBlock =
134
+ | DocumentProjectionParagraph
135
+ | DocumentProjectionTable
136
+ | DocumentProjectionPageBreak;
137
+
138
+ export type DocumentProjectionSection = Readonly<{
139
+ id: string;
140
+ startBlockIndex: number;
141
+ page: DocumentProjectionPageGeometry;
142
+ titlePage?: boolean;
143
+ headerBlockCounts?: readonly [number, number, number];
144
+ footerBlockCounts?: readonly [number, number, number];
145
+ }>;
146
+
147
+ export type DocumentProjectionComment = Readonly<{
148
+ id: string;
149
+ blockId: string;
150
+ start: number;
151
+ end: number;
152
+ resolved: boolean;
153
+ replies: readonly Readonly<{ author: string; text: string; createdAt: string }>[];
154
+ }>;
155
+
156
+ export type DocumentProjectionChange = Readonly<{
157
+ id: string;
158
+ blockId: string;
159
+ kind: "insert" | "delete";
160
+ start: number;
161
+ end: number;
162
+ author: string;
163
+ createdAt: string;
164
+ }>;
165
+
166
+ /** Immutable render/edit projection; canonical document state stays with the host. */
167
+ export type DocumentEditorProjection = Readonly<{
168
+ revision: string | number;
169
+ page: DocumentProjectionPageGeometry;
170
+ blocks: readonly DocumentProjectionBlock[];
171
+ sections?: readonly DocumentProjectionSection[];
172
+ comments?: readonly DocumentProjectionComment[];
173
+ changes?: readonly DocumentProjectionChange[];
174
+ }>;
175
+
176
+ export type DocumentEditorProps = {
177
+ document: ReferenceDocument;
178
+ /** Controlled projection mode. Omit to let the editor own the toggle. */
179
+ layout?: DocumentLayoutMode | undefined;
180
+ defaultLayout?: DocumentLayoutMode | undefined;
181
+ onLayoutChange?: ((layout: DocumentLayoutMode) => void) | undefined;
182
+ onSelectionChange?: ((selection: DocumentSelection) => void) | undefined;
183
+ onCommit?: DocumentCommitHandler | undefined;
184
+ onCommandError?: ((error: Error) => void) | undefined;
185
+ readOnly?: boolean | undefined;
186
+ ariaLabel?: string | undefined;
187
+ viewportHeight?: number | undefined;
188
+ overscanPages?: number | undefined;
189
+ className?: string | undefined;
190
+ };
191
+
192
+ export type DocumentProjectionEditorProps = Omit<
193
+ DocumentEditorProps,
194
+ "document" | "onSelectionChange" | "onCommit"
195
+ > & {
196
+ projection: DocumentEditorProjection;
197
+ commit?: DocumentCommitHandler | undefined;
198
+ onCommandError?: ((error: Error) => void) | undefined;
199
+ onSelectionChange?: ((selection: DocumentSelection) => void) | undefined;
200
+ };
201
+
202
+ export type DocumentProjectionArtifactSurfaceProps = Omit<
203
+ DocumentProjectionEditorProps,
204
+ "ariaLabel" | "className"
205
+ > & {
206
+ title: string;
207
+ subtitle?: ReactNode | undefined;
208
+ busy?: boolean | undefined;
209
+ className?: string | undefined;
210
+ editorClassName?: string | undefined;
211
+ };
212
+
213
+ export type DocumentArtifactSurfaceProps = Omit<DocumentEditorProps, "ariaLabel" | "className"> & {
214
+ title: string;
215
+ subtitle?: ReactNode | undefined;
216
+ busy?: boolean | undefined;
217
+ className?: string | undefined;
218
+ editorClassName?: string | undefined;
219
+ };
220
+
221
+ type DocumentViewTextRun = {
222
+ readonly text: string;
223
+ readonly style: DocumentProjectionTextStyle;
224
+ };
225
+
226
+ type DocumentViewParagraph = {
227
+ readonly kind: "paragraph";
228
+ readonly id: string;
229
+ readonly runs: readonly DocumentViewTextRun[];
230
+ readonly style: DocumentProjectionParagraphStyle;
231
+ text: string;
232
+ format(format: { start: number; end: number; style: Partial<DocumentProjectionTextStyle> }): void;
233
+ };
234
+
235
+ type DocumentViewTable = {
236
+ readonly kind: "table";
237
+ readonly id: string;
238
+ readonly rows: readonly (readonly (readonly DocumentViewTextRun[])[])[];
239
+ readonly style: NonNullable<DocumentProjectionTable["style"]>;
240
+ };
241
+
242
+ type DocumentViewPageBreak = {
243
+ readonly kind: "pageBreak";
244
+ readonly id: string;
245
+ };
246
+
247
+ type DocumentViewBlock = DocumentViewParagraph | DocumentViewTable | DocumentViewPageBreak;
248
+
249
+ type DocumentViewComment = DocumentProjectionComment;
250
+ type DocumentViewChange = DocumentProjectionChange;
251
+
252
+ type DocumentViewModel = {
253
+ readonly revision: number;
254
+ readonly page: DocumentProjectionPageGeometry;
255
+ readonly blocks: {
256
+ readonly items: readonly DocumentViewBlock[];
257
+ addParagraph(text?: string): DocumentViewParagraph;
258
+ };
259
+ readonly sections: {
260
+ readonly items: readonly DocumentProjectionSection[];
261
+ };
262
+ readonly comments: {
263
+ readonly items: readonly DocumentViewComment[];
264
+ };
265
+ readonly changes: {
266
+ readonly items: readonly DocumentViewChange[];
267
+ };
268
+ resolve(id: string): unknown;
269
+ };
270
+
271
+ type ProjectedBlock = {
272
+ block: Exclude<DocumentViewBlock, DocumentViewPageBreak>;
273
+ height: number;
274
+ listOrdinal?: number | undefined;
275
+ };
276
+
277
+ type ProjectedPage = {
278
+ key: string;
279
+ blocks: ProjectedBlock[];
280
+ height: number;
281
+ minimumHeight: number;
282
+ page: DocumentProjectionPageGeometry;
283
+ };
284
+
285
+ type RenderedPage = ProjectedPage & {
286
+ naturalHeight: number;
287
+ renderScale: number;
288
+ };
289
+
290
+ type PageAxis = {
291
+ offsets: Float64Array;
292
+ total: number;
293
+ };
294
+
295
+ type PageScrollAnchor = {
296
+ axis: PageAxis;
297
+ key: string;
298
+ offset: number;
299
+ scrollTop: number;
300
+ };
301
+
302
+ const POINT_TO_CSS_PIXEL = 4 / 3;
303
+ const DEFAULT_VIEWPORT_HEIGHT = 640;
304
+ const RESPONSIVE_CONTINUOUS_LAYOUT_WIDTH = 640;
305
+ const PAGINATED_GAP = 24;
306
+ const CONTINUOUS_GAP = 1;
307
+ const MAX_PLAIN_TEXT_TRANSFER = 1_000_000;
308
+ const MAX_PROJECTION_BLOCKS = 100_000;
309
+ const MAX_PROJECTION_TABLE_CELLS = 1_000_000;
310
+ const MAX_PROJECTION_ANNOTATIONS = 100_000;
311
+ const MAX_PROJECTION_TEXT = 1_000_000;
312
+ const EMPTY_COMMENTS: readonly DocumentViewComment[] = [];
313
+ const EMPTY_CHANGES: readonly DocumentViewChange[] = [];
314
+
315
+ /** Compatibility adapter for the public authoring model. */
316
+ export function DocumentEditor(props: DocumentEditorProps) {
317
+ return <DocumentEditorCore {...props} document={asDocumentView(props.document)} />;
318
+ }
319
+
320
+ /** Projection-first document editor with one async command boundary. */
321
+ export function DocumentProjectionEditor({
322
+ projection,
323
+ commit,
324
+ onCommandError,
325
+ onSelectionChange,
326
+ readOnly = false,
327
+ ...props
328
+ }: DocumentProjectionEditorProps) {
329
+ const view = useMemo(() => documentViewFromProjection(projection), [projection]);
330
+ return (
331
+ <DocumentEditorCore
332
+ {...props}
333
+ document={view.document}
334
+ revisionToken={projection.revision}
335
+ readOnly={readOnly || !commit}
336
+ onCommandError={onCommandError}
337
+ blockIdForView={(blockId) => view.externalIdByInternal.get(blockId) ?? blockId}
338
+ onSelectionChange={(selection) =>
339
+ onSelectionChange?.({
340
+ ...selection,
341
+ blockId: view.externalIdByInternal.get(selection.blockId) ?? selection.blockId,
342
+ })
343
+ }
344
+ onCommit={(command) => commit?.(mapDocumentCommit(command, view.externalIdByInternal))}
345
+ />
346
+ );
347
+ }
348
+
349
+ type DocumentEditorCoreProps = Omit<DocumentEditorProps, "document"> & {
350
+ document: DocumentViewModel;
351
+ revisionToken?: string | number | undefined;
352
+ blockIdForView?: ((blockId: string) => string) | undefined;
353
+ };
354
+
355
+ function DocumentEditorCore({
356
+ document,
357
+ revisionToken,
358
+ layout: controlledLayout,
359
+ defaultLayout,
360
+ onLayoutChange,
361
+ onSelectionChange,
362
+ onCommit,
363
+ onCommandError,
364
+ blockIdForView = identity,
365
+ readOnly = false,
366
+ ariaLabel = "Document editor",
367
+ viewportHeight = DEFAULT_VIEWPORT_HEIGHT,
368
+ overscanPages = 1,
369
+ className,
370
+ }: DocumentEditorCoreProps) {
371
+ const [uncontrolledLayout, setUncontrolledLayout] = useState<DocumentLayoutMode>(
372
+ defaultLayout ?? "paginated",
373
+ );
374
+ const [scrollTop, setScrollTop] = useState(0);
375
+ const [measuredViewport, setMeasuredViewport] = useState({ width: 0, height: 0 });
376
+ const [localRevision, setLocalRevision] = useState(document.revision);
377
+ const [measuredPageHeights, setMeasuredPageHeights] = useState<ReadonlyMap<string, number>>(
378
+ () => new Map(),
379
+ );
380
+ const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
381
+ const [hasTextSelection, setHasTextSelection] = useState(false);
382
+ const selectionRef = useRef<DocumentSelection | null>(null);
383
+ const [openThreadId, setOpenThreadId] = useState<string | null>(null);
384
+ const [pendingCommands, setPendingCommands] = useState(0);
385
+ const [commandFailure, setCommandFailure] = useState<{
386
+ message: string;
387
+ retry: () => void;
388
+ } | null>(null);
389
+ const editorNodes = useRef(new Map<string, HTMLElement>());
390
+ const viewportRef = useRef<HTMLDivElement | null>(null);
391
+ const pendingScrollTop = useRef(0);
392
+ const scrollFrame = useRef<number | null>(null);
393
+ const pendingMeasurementAnchor = useRef<PageScrollAnchor | null>(null);
394
+ const responsiveLayoutInitialized = useRef(false);
395
+ const measuredPageHeightsRef = useRef<ReadonlyMap<string, number>>(new Map());
396
+ const mountedRef = useRef(true);
397
+ const commandScopeRef = useRef(0);
398
+ const commandVersionsRef = useRef(new Map<string, number>());
399
+ const layout = controlledLayout ?? uncontrolledLayout;
400
+ const documentRevision = revisionToken ?? document.revision;
401
+ const pageGap = layout === "paginated" ? PAGINATED_GAP : CONTINUOUS_GAP;
402
+
403
+ // localRevision intentionally participates in this projection. The public
404
+ // reference model is synchronous; hosts can still replace/rerender the same
405
+ // document after remote changes without the editor owning persistence state.
406
+ const basePages = useMemo(
407
+ () => projectPages(document, layout),
408
+ // The revision values intentionally invalidate projections over the mutable public model.
409
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
410
+ [document, documentRevision, layout, localRevision],
411
+ );
412
+ const naturalPages = useMemo(
413
+ () =>
414
+ basePages.map((page) => ({
415
+ ...page,
416
+ // Estimates seed unmounted pages only. Once mounted, intrinsic content
417
+ // may correct the page in either direction while preserving paper size.
418
+ height: measuredPageHeights.has(page.key)
419
+ ? Math.max(page.minimumHeight, measuredPageHeights.get(page.key) ?? page.height)
420
+ : page.height,
421
+ })),
422
+ [basePages, measuredPageHeights],
423
+ );
424
+ const pages = useMemo<RenderedPage[]>(
425
+ () =>
426
+ naturalPages.map((page) => {
427
+ const renderScale =
428
+ layout === "paginated"
429
+ ? responsiveDocumentPageScale(page.page, measuredViewport.width)
430
+ : 1;
431
+ return {
432
+ ...page,
433
+ naturalHeight: page.height,
434
+ renderScale,
435
+ height: page.height * renderScale,
436
+ minimumHeight: page.minimumHeight * renderScale,
437
+ };
438
+ }),
439
+ [layout, measuredViewport.width, naturalPages],
440
+ );
441
+ const annotations = useMemo(
442
+ () => indexAnnotations(document),
443
+ // The revision values intentionally invalidate indexes over the mutable public model.
444
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
445
+ [document, documentRevision, localRevision],
446
+ );
447
+ const axis = useMemo(() => buildPageAxis(pages, pageGap), [pages, pageGap]);
448
+ const pagesRef = useRef(pages);
449
+ const axisRef = useRef(axis);
450
+ pagesRef.current = pages;
451
+ axisRef.current = axis;
452
+ const blockPageIndex = useMemo(() => {
453
+ const index = new Map<string, number>();
454
+ pages.forEach((page, pageIndex) => {
455
+ for (const { block } of page.blocks) index.set(block.id, pageIndex);
456
+ });
457
+ return index;
458
+ }, [pages]);
459
+ const activePage = selectedBlockId ? (blockPageIndex.get(selectedBlockId) ?? -1) : -1;
460
+ const mountedIndexes = useMemo(
461
+ () =>
462
+ visiblePageIndexes(
463
+ axis,
464
+ pages.length,
465
+ scrollTop,
466
+ Math.max(1, measuredViewport.height || viewportHeight),
467
+ Math.max(0, Math.floor(overscanPages)),
468
+ activePage,
469
+ ),
470
+ [
471
+ activePage,
472
+ axis,
473
+ measuredViewport.height,
474
+ overscanPages,
475
+ pages.length,
476
+ scrollTop,
477
+ viewportHeight,
478
+ ],
479
+ );
480
+
481
+ useLayoutEffect(() => {
482
+ const element = viewportRef.current;
483
+ if (!element) return;
484
+ const measure = () => {
485
+ const width = element.clientWidth;
486
+ const height = element.clientHeight;
487
+ if (width <= 0 && height <= 0) return;
488
+ setMeasuredViewport((current) =>
489
+ current.width === width && current.height === height ? current : { width, height },
490
+ );
491
+ };
492
+ measure();
493
+ const observer = globalThis.ResizeObserver ? new globalThis.ResizeObserver(measure) : null;
494
+ observer?.observe(element);
495
+ globalThis.addEventListener?.("resize", measure);
496
+ return () => {
497
+ observer?.disconnect();
498
+ globalThis.removeEventListener?.("resize", measure);
499
+ };
500
+ }, []);
501
+
502
+ useLayoutEffect(() => {
503
+ if (
504
+ responsiveLayoutInitialized.current ||
505
+ controlledLayout !== undefined ||
506
+ defaultLayout !== undefined ||
507
+ measuredViewport.width <= 0
508
+ ) {
509
+ return;
510
+ }
511
+ responsiveLayoutInitialized.current = true;
512
+ if (measuredViewport.width < RESPONSIVE_CONTINUOUS_LAYOUT_WIDTH) {
513
+ setUncontrolledLayout("continuous");
514
+ onLayoutChange?.("continuous");
515
+ }
516
+ }, [controlledLayout, defaultLayout, measuredViewport.width, onLayoutChange]);
517
+
518
+ useEffect(() => {
519
+ commandScopeRef.current += 1;
520
+ commandVersionsRef.current.clear();
521
+ selectionRef.current = null;
522
+ setSelectedBlockId(null);
523
+ setHasTextSelection(false);
524
+ setOpenThreadId(null);
525
+ setLocalRevision(document.revision);
526
+ setPendingCommands(0);
527
+ setCommandFailure(null);
528
+ }, [document, revisionToken]);
529
+
530
+ useEffect(() => {
531
+ mountedRef.current = true;
532
+ return () => {
533
+ mountedRef.current = false;
534
+ if (scrollFrame.current !== null && globalThis.cancelAnimationFrame) {
535
+ globalThis.cancelAnimationFrame(scrollFrame.current);
536
+ }
537
+ };
538
+ }, []);
539
+
540
+ const handleScroll = useCallback((event: ReactUIEvent<HTMLElement>) => {
541
+ pendingScrollTop.current = event.currentTarget.scrollTop;
542
+ if (scrollFrame.current !== null) return;
543
+ if (!globalThis.requestAnimationFrame) {
544
+ scrollFrame.current = -1;
545
+ queueMicrotask(() => {
546
+ scrollFrame.current = null;
547
+ setScrollTop(pendingScrollTop.current);
548
+ });
549
+ return;
550
+ }
551
+ scrollFrame.current = globalThis.requestAnimationFrame(() => {
552
+ scrollFrame.current = null;
553
+ setScrollTop(pendingScrollTop.current);
554
+ });
555
+ }, []);
556
+
557
+ const clearSelection = useCallback(() => {
558
+ selectionRef.current = null;
559
+ setSelectedBlockId(null);
560
+ setHasTextSelection(false);
561
+ }, []);
562
+
563
+ const recordPageHeight = useCallback((key: string, height: number) => {
564
+ if (!Number.isFinite(height) || height <= 0) return;
565
+ const rounded = Math.ceil(height);
566
+ const current = measuredPageHeightsRef.current;
567
+ if (Math.abs((current.get(key) ?? 0) - rounded) <= 1) return;
568
+
569
+ const currentPages = pagesRef.current;
570
+ if (!currentPages.some((page) => page.key === key)) return;
571
+ const viewport = viewportRef.current;
572
+ // Absolute positioning prevents useful native scroll anchoring. Retain the
573
+ // visible page and its intra-page offset across the upcoming axis change.
574
+ if (viewport && pendingMeasurementAnchor.current === null) {
575
+ const currentAxis = axisRef.current;
576
+ const anchorIndex = pageAtOffset(currentAxis, currentPages.length, viewport.scrollTop);
577
+ const anchorPage = currentPages[anchorIndex];
578
+ if (anchorPage) {
579
+ pendingMeasurementAnchor.current = {
580
+ axis: currentAxis,
581
+ key: anchorPage.key,
582
+ offset: viewport.scrollTop - currentAxis.offsets[anchorIndex]!,
583
+ scrollTop: viewport.scrollTop,
584
+ };
585
+ }
586
+ }
587
+
588
+ // Page keys include layout, section, position, and the first stable block
589
+ // id. Prune measurements that no longer belong to the current projection
590
+ // here instead of clearing them in an effect: a passive reset can run after
591
+ // a newly mounted page's layout-effect measurement and erase that report.
592
+ const next = new Map<string, number>();
593
+ for (const page of currentPages) {
594
+ const measured = current.get(page.key);
595
+ if (measured !== undefined) next.set(page.key, measured);
596
+ }
597
+ next.set(key, rounded);
598
+ measuredPageHeightsRef.current = next;
599
+ setMeasuredPageHeights(next);
600
+ }, []);
601
+
602
+ useLayoutEffect(() => {
603
+ const anchor = pendingMeasurementAnchor.current;
604
+ if (!anchor || anchor.axis === axis) return;
605
+ pendingMeasurementAnchor.current = null;
606
+ const viewport = viewportRef.current;
607
+ if (!viewport || Math.abs(viewport.scrollTop - anchor.scrollTop) > 1) return;
608
+ const anchorIndex = pages.findIndex((page) => page.key === anchor.key);
609
+ if (anchorIndex < 0) return;
610
+ const nextScrollTop = Math.max(0, axis.offsets[anchorIndex]! + anchor.offset);
611
+ if (Math.abs(viewport.scrollTop - nextScrollTop) > 0.5) {
612
+ viewport.scrollTop = nextScrollTop;
613
+ }
614
+ const correctedScrollTop = viewport.scrollTop;
615
+ pendingScrollTop.current = correctedScrollTop;
616
+ setScrollTop((current) =>
617
+ Math.abs(current - correctedScrollTop) > 0.5 ? correctedScrollTop : current,
618
+ );
619
+ }, [axis, pages]);
620
+
621
+ const publishSelection = useCallback(
622
+ (next: DocumentSelection) => {
623
+ selectionRef.current = next;
624
+ setSelectedBlockId((current) => (current === next.blockId ? current : next.blockId));
625
+ const nextHasTextSelection = next.end > next.start;
626
+ setHasTextSelection((current) =>
627
+ current === nextHasTextSelection ? current : nextHasTextSelection,
628
+ );
629
+ onSelectionChange?.(next);
630
+ },
631
+ [onSelectionChange],
632
+ );
633
+
634
+ const readDomSelection = useCallback(
635
+ (blockId: string): DocumentSelection => {
636
+ const node = editorNodes.current.get(blockId);
637
+ const offsets = node ? selectionOffsets(node) : null;
638
+ const next = {
639
+ blockId,
640
+ start: offsets?.start ?? 0,
641
+ end: offsets?.end ?? 0,
642
+ };
643
+ publishSelection(next);
644
+ return next;
645
+ },
646
+ [publishSelection],
647
+ );
648
+
649
+ const runCommit = useCallback(
650
+ (command: DocumentCommit) => {
651
+ if (!onCommit) return;
652
+ const scope = commandScopeRef.current;
653
+ const submit = () => {
654
+ const key = command.blockId;
655
+ const version = (commandVersionsRef.current.get(key) ?? 0) + 1;
656
+ commandVersionsRef.current.set(key, version);
657
+ setCommandFailure(null);
658
+ const fail = (cause: unknown) => {
659
+ if (
660
+ !mountedRef.current ||
661
+ commandScopeRef.current !== scope ||
662
+ commandVersionsRef.current.get(key) !== version
663
+ ) {
664
+ return;
665
+ }
666
+ const error = asDocumentError(cause);
667
+ setCommandFailure({ message: error.message, retry: submit });
668
+ onCommandError?.(error);
669
+ };
670
+ let result: void | Promise<void>;
671
+ try {
672
+ result = onCommit(command);
673
+ } catch (cause) {
674
+ fail(cause);
675
+ return;
676
+ }
677
+ if (!result || typeof result.then !== "function") return;
678
+ setPendingCommands((current) => current + 1);
679
+ void Promise.resolve(result).then(
680
+ () => {
681
+ if (!mountedRef.current || commandScopeRef.current !== scope) return;
682
+ setPendingCommands((current) => Math.max(0, current - 1));
683
+ },
684
+ (cause) => {
685
+ if (!mountedRef.current || commandScopeRef.current !== scope) return;
686
+ setPendingCommands((current) => Math.max(0, current - 1));
687
+ fail(cause);
688
+ },
689
+ );
690
+ };
691
+ submit();
692
+ },
693
+ [onCommandError, onCommit],
694
+ );
695
+
696
+ const commitText = useCallback(
697
+ (paragraph: DocumentViewParagraph, text: string) => {
698
+ if (readOnly || paragraph.text === text) return;
699
+ paragraph.text = text;
700
+ runCommit({
701
+ kind: "text",
702
+ blockId: paragraph.id,
703
+ text,
704
+ revision: revisionToken ?? document.revision,
705
+ });
706
+ },
707
+ [document, readOnly, revisionToken, runCommit],
708
+ );
709
+
710
+ const applyFormat = useCallback(
711
+ (property: "bold" | "italic" | "underline") => {
712
+ const selection = selectionRef.current;
713
+ if (readOnly || !selection) return;
714
+ let block: unknown;
715
+ try {
716
+ block = document.resolve(selection.blockId);
717
+ } catch {
718
+ clearSelection();
719
+ return;
720
+ }
721
+ if (!isDocumentParagraph(block)) return;
722
+ const node = editorNodes.current.get(block.id);
723
+ if (node) commitText(block, node.textContent ?? "");
724
+ const current = selectionOffsets(node) ?? selection;
725
+ const start = Math.max(0, Math.min(current.start, block.text.length));
726
+ const end = Math.max(start, Math.min(current.end, block.text.length));
727
+ if (end <= start) return;
728
+ const effectiveEnd = end;
729
+ const effectiveStart = start;
730
+ const style = toggledTextStyle(block, effectiveStart, effectiveEnd, property);
731
+ block.format({ start: effectiveStart, end: effectiveEnd, style });
732
+ runCommit({
733
+ kind: "format",
734
+ blockId: block.id,
735
+ range: { start: effectiveStart, end: effectiveEnd },
736
+ style,
737
+ revision: revisionToken ?? document.revision,
738
+ });
739
+ queueMicrotask(() => restoreSelection(editorNodes.current.get(block.id), start, end));
740
+ },
741
+ [clearSelection, commitText, document, readOnly, revisionToken, runCommit],
742
+ );
743
+
744
+ const handleEditorKeyDown = useCallback(
745
+ (event: ReactKeyboardEvent<HTMLElement>, paragraph: DocumentViewParagraph) => {
746
+ const modifier = event.metaKey || event.ctrlKey;
747
+ if (!modifier) return;
748
+ const key = event.key.toLowerCase();
749
+ if (key !== "b" && key !== "i" && key !== "u") return;
750
+ event.preventDefault();
751
+ const current = readDomSelection(paragraph.id);
752
+ selectionRef.current = current;
753
+ const property = key === "b" ? "bold" : key === "i" ? "italic" : "underline";
754
+ const start = current.start;
755
+ const end = current.end;
756
+ if (end <= start || readOnly) return;
757
+ const effectiveStart = start;
758
+ const effectiveEnd = end;
759
+ const style = toggledTextStyle(paragraph, effectiveStart, effectiveEnd, property);
760
+ paragraph.format({ start: effectiveStart, end: effectiveEnd, style });
761
+ runCommit({
762
+ kind: "format",
763
+ blockId: paragraph.id,
764
+ range: { start: effectiveStart, end: effectiveEnd },
765
+ style,
766
+ revision: revisionToken ?? document.revision,
767
+ });
768
+ queueMicrotask(() => restoreSelection(editorNodes.current.get(paragraph.id), start, end));
769
+ },
770
+ [document, readDomSelection, readOnly, revisionToken, runCommit],
771
+ );
772
+
773
+ const changeLayout = (next: DocumentLayoutMode) => {
774
+ if (controlledLayout === undefined) setUncontrolledLayout(next);
775
+ onLayoutChange?.(next);
776
+ };
777
+
778
+ const addFirstParagraph = () => {
779
+ if (readOnly) return;
780
+ const paragraph = document.blocks.addParagraph("");
781
+ setLocalRevision(document.revision);
782
+ publishSelection({ blockId: paragraph.id, start: 0, end: 0 });
783
+ runCommit({
784
+ kind: "insert-paragraph",
785
+ blockId: paragraph.id,
786
+ revision: revisionToken ?? document.revision,
787
+ });
788
+ };
789
+
790
+ return (
791
+ <div
792
+ data-og-document-editor=""
793
+ data-og-document-layout={layout}
794
+ data-og-command-state={commandFailure ? "error" : pendingCommands > 0 ? "pending" : "idle"}
795
+ aria-busy={pendingCommands > 0 ? "true" : undefined}
796
+ className={cn("flex h-full min-h-0 min-w-0 flex-col bg-og-surface-2", className)}
797
+ >
798
+ <div
799
+ role="toolbar"
800
+ aria-label="Document formatting"
801
+ className="flex min-h-10 shrink-0 items-center gap-1 border-b border-og-border bg-og-surface-1 px-2"
802
+ >
803
+ <ToolbarButton
804
+ label="Bold"
805
+ shortcut="⌘B"
806
+ disabled={readOnly || !selectedBlockId || !hasTextSelection}
807
+ onPress={() => applyFormat("bold")}
808
+ >
809
+ <BoldIcon className="size-4" />
810
+ </ToolbarButton>
811
+ <ToolbarButton
812
+ label="Italic"
813
+ shortcut="⌘I"
814
+ disabled={readOnly || !selectedBlockId || !hasTextSelection}
815
+ onPress={() => applyFormat("italic")}
816
+ >
817
+ <ItalicIcon className="size-4" />
818
+ </ToolbarButton>
819
+ <ToolbarButton
820
+ label="Underline"
821
+ shortcut="⌘U"
822
+ disabled={readOnly || !selectedBlockId || !hasTextSelection}
823
+ onPress={() => applyFormat("underline")}
824
+ >
825
+ <UnderlineIcon className="size-4" />
826
+ </ToolbarButton>
827
+ <span aria-hidden className="mx-1 h-5 w-px bg-og-border" />
828
+ <ToolbarButton
829
+ label="Continuous layout"
830
+ pressed={layout === "continuous"}
831
+ onPress={() => changeLayout("continuous")}
832
+ >
833
+ <Rows3Icon className="size-4" />
834
+ </ToolbarButton>
835
+ <ToolbarButton
836
+ label="Paginated layout"
837
+ pressed={layout === "paginated"}
838
+ onPress={() => changeLayout("paginated")}
839
+ >
840
+ <Columns2Icon className="size-4" />
841
+ </ToolbarButton>
842
+ <span className="ml-auto truncate text-og-xs text-og-fg-muted">
843
+ {commandFailure
844
+ ? "Change not saved"
845
+ : pendingCommands > 0
846
+ ? "Saving…"
847
+ : readOnly
848
+ ? "Read only"
849
+ : "Editing"}{" "}
850
+ · {document.blocks.items.length.toLocaleString()} blocks
851
+ </span>
852
+ </div>
853
+
854
+ <div role="status" aria-live="polite" className="sr-only">
855
+ {commandFailure?.message ?? (pendingCommands > 0 ? "Saving document changes" : "")}
856
+ </div>
857
+
858
+ <div
859
+ ref={viewportRef}
860
+ role="document"
861
+ aria-label={ariaLabel}
862
+ aria-readonly={readOnly}
863
+ tabIndex={-1}
864
+ onPointerDown={(event) => {
865
+ const target = event.target;
866
+ if (target instanceof Element && !target.closest("[data-og-paragraph]")) {
867
+ clearSelection();
868
+ }
869
+ }}
870
+ onScroll={handleScroll}
871
+ className={cn(
872
+ "relative min-h-0 min-w-0 flex-1 overflow-auto overscroll-contain outline-hidden [overflow-anchor:none]",
873
+ layout === "paginated" ? "bg-og-surface-3" : "bg-og-surface-1",
874
+ )}
875
+ style={{ height: Math.max(1, viewportHeight) }}
876
+ >
877
+ {commandFailure ? (
878
+ <div
879
+ role="alert"
880
+ className="absolute left-2 top-2 z-30 flex w-fit items-center gap-2 rounded-og-sm border border-og-status-failed/30 bg-og-surface-1/95 px-2 py-1 text-og-xs text-og-status-failed shadow-og-sm"
881
+ style={{ maxWidth: "calc(100% - 1rem)" }}
882
+ >
883
+ <span className="truncate">{commandFailure.message}</span>
884
+ <button
885
+ type="button"
886
+ onClick={commandFailure.retry}
887
+ className="shrink-0 rounded-og-xs px-1.5 py-0.5 font-medium outline-hidden hover:bg-og-surface-3 focus-visible:ring-2 focus-visible:ring-og-accent"
888
+ >
889
+ Retry
890
+ </button>
891
+ </div>
892
+ ) : null}
893
+ {pages.length === 0 ? (
894
+ <div className="grid h-full place-items-center p-8 text-center">
895
+ <div>
896
+ <PilcrowIcon aria-hidden className="mx-auto mb-3 size-7 text-og-fg-subtle" />
897
+ <p className="text-og-sm text-og-fg-muted">This document is empty.</p>
898
+ {!readOnly ? (
899
+ <button
900
+ type="button"
901
+ onClick={addFirstParagraph}
902
+ className="mt-3 rounded-og-md border border-og-border bg-og-surface-1 px-3 py-1.5 text-og-sm text-og-fg hover:bg-og-surface-2 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent"
903
+ >
904
+ Start writing
905
+ </button>
906
+ ) : null}
907
+ </div>
908
+ </div>
909
+ ) : (
910
+ <div className="relative min-w-full" style={{ height: axis.total }}>
911
+ {mountedIndexes.map((pageIndex) => {
912
+ const page = pages[pageIndex]!;
913
+ const top = axis.offsets[pageIndex]!;
914
+ return (
915
+ <article
916
+ key={page.key}
917
+ aria-label={`${layout === "paginated" ? "Page" : "Section"} ${pageIndex + 1} of ${pages.length}`}
918
+ aria-posinset={pageIndex + 1}
919
+ aria-setsize={pages.length}
920
+ data-og-document-page={pageIndex + 1}
921
+ data-og-page-width-pt={page.page.widthPt}
922
+ data-og-page-height-pt={page.page.heightPt}
923
+ className={cn(
924
+ "absolute overflow-hidden",
925
+ layout === "paginated"
926
+ ? "left-1/2 -translate-x-1/2 border shadow-og-sm"
927
+ : "left-0 right-0 border-b border-og-border bg-og-surface-1 text-og-fg",
928
+ )}
929
+ style={{
930
+ top,
931
+ color: layout === "paginated" ? "#171717" : undefined,
932
+ backgroundColor: layout === "paginated" ? "#fff" : undefined,
933
+ borderColor: layout === "paginated" ? "#0000001a" : undefined,
934
+ width:
935
+ layout === "paginated"
936
+ ? page.page.widthPt * POINT_TO_CSS_PIXEL * page.renderScale
937
+ : undefined,
938
+ height: page.height,
939
+ }}
940
+ >
941
+ <MeasuredPageBody
942
+ pageKey={page.key}
943
+ measureToken={documentRevision}
944
+ onHeight={recordPageHeight}
945
+ className={cn(
946
+ layout === "paginated" ? "absolute left-0 top-0 overflow-hidden" : "mx-auto",
947
+ )}
948
+ style={{
949
+ ...pageContentStyle(page.page, layout),
950
+ maxWidth: layout === "continuous" ? "68rem" : undefined,
951
+ ...(layout === "paginated"
952
+ ? {
953
+ width: page.page.widthPt * POINT_TO_CSS_PIXEL,
954
+ minHeight: page.naturalHeight,
955
+ transform: `scale(${page.renderScale})`,
956
+ transformOrigin: "top left",
957
+ }
958
+ : {}),
959
+ }}
960
+ >
961
+ {page.blocks.map(({ block, listOrdinal }) => (
962
+ <DocumentBlockView
963
+ key={block.id}
964
+ block={block}
965
+ viewBlockId={blockIdForView(block.id)}
966
+ comments={annotations.comments.get(block.id) ?? EMPTY_COMMENTS}
967
+ changes={annotations.changes.get(block.id) ?? EMPTY_CHANGES}
968
+ projectionRevision={localRevision}
969
+ listOrdinal={listOrdinal}
970
+ readOnly={readOnly}
971
+ selected={selectedBlockId === block.id}
972
+ openThreadId={openThreadId}
973
+ registerEditor={(node) => {
974
+ if (node) editorNodes.current.set(block.id, node);
975
+ else editorNodes.current.delete(block.id);
976
+ }}
977
+ onThreadToggle={(threadId) =>
978
+ setOpenThreadId((current) => (current === threadId ? null : threadId))
979
+ }
980
+ onInput={(paragraph, text) => commitText(paragraph, text)}
981
+ onBlur={() => setLocalRevision(document.revision)}
982
+ onSelection={(paragraph) => readDomSelection(paragraph.id)}
983
+ onKeyDown={handleEditorKeyDown}
984
+ />
985
+ ))}
986
+ </MeasuredPageBody>
987
+ </article>
988
+ );
989
+ })}
990
+ </div>
991
+ )}
992
+ </div>
993
+ </div>
994
+ );
995
+ }
996
+
997
+ /** Artifact chrome plus the reusable document editor. */
998
+ export function DocumentArtifactSurface({
999
+ document,
1000
+ title,
1001
+ subtitle,
1002
+ busy,
1003
+ className,
1004
+ editorClassName,
1005
+ ...editorProps
1006
+ }: DocumentArtifactSurfaceProps) {
1007
+ return (
1008
+ <ArtifactSurface
1009
+ modality="document"
1010
+ title={title}
1011
+ subtitle={subtitle}
1012
+ busy={busy}
1013
+ className={className}
1014
+ >
1015
+ <DocumentEditor
1016
+ {...editorProps}
1017
+ document={document}
1018
+ ariaLabel={`${title} document`}
1019
+ className={editorClassName}
1020
+ />
1021
+ </ArtifactSurface>
1022
+ );
1023
+ }
1024
+
1025
+ /** Artifact chrome over the projection-first document editor. */
1026
+ export function DocumentProjectionArtifactSurface({
1027
+ projection,
1028
+ title,
1029
+ subtitle,
1030
+ busy,
1031
+ className,
1032
+ editorClassName,
1033
+ ...editorProps
1034
+ }: DocumentProjectionArtifactSurfaceProps) {
1035
+ return (
1036
+ <ArtifactSurface
1037
+ modality="document"
1038
+ title={title}
1039
+ subtitle={subtitle}
1040
+ busy={busy}
1041
+ className={className}
1042
+ >
1043
+ <DocumentProjectionEditor
1044
+ {...editorProps}
1045
+ projection={projection}
1046
+ ariaLabel={`${title} document`}
1047
+ className={editorClassName}
1048
+ />
1049
+ </ArtifactSurface>
1050
+ );
1051
+ }
1052
+
1053
+ function MeasuredPageBody({
1054
+ pageKey,
1055
+ measureToken,
1056
+ onHeight,
1057
+ className,
1058
+ style,
1059
+ children,
1060
+ }: {
1061
+ pageKey: string;
1062
+ measureToken: string | number;
1063
+ onHeight: (key: string, height: number) => void;
1064
+ className: string;
1065
+ style: CSSProperties;
1066
+ children: ReactNode;
1067
+ }) {
1068
+ const nodeRef = useRef<HTMLDivElement | null>(null);
1069
+ useLayoutEffect(() => {
1070
+ const node = nodeRef.current;
1071
+ if (!node) return;
1072
+ const report = () => onHeight(pageKey, Math.max(node.clientHeight, node.scrollHeight));
1073
+ report();
1074
+ if (!globalThis.ResizeObserver) return;
1075
+ const observer = new globalThis.ResizeObserver(report);
1076
+ observer.observe(node);
1077
+ return () => observer.disconnect();
1078
+ }, [measureToken, onHeight, pageKey]);
1079
+ return (
1080
+ <div ref={nodeRef} className={className} style={style}>
1081
+ {children}
1082
+ </div>
1083
+ );
1084
+ }
1085
+
1086
+ function DocumentBlockView({
1087
+ block,
1088
+ viewBlockId,
1089
+ comments,
1090
+ changes,
1091
+ projectionRevision,
1092
+ listOrdinal,
1093
+ readOnly,
1094
+ selected,
1095
+ openThreadId,
1096
+ registerEditor,
1097
+ onThreadToggle,
1098
+ onInput,
1099
+ onBlur,
1100
+ onSelection,
1101
+ onKeyDown,
1102
+ }: {
1103
+ block: Exclude<DocumentViewBlock, DocumentViewPageBreak>;
1104
+ viewBlockId: string;
1105
+ comments: readonly DocumentViewComment[];
1106
+ changes: readonly DocumentViewChange[];
1107
+ projectionRevision: number;
1108
+ listOrdinal?: number | undefined;
1109
+ readOnly: boolean;
1110
+ selected: boolean;
1111
+ openThreadId: string | null;
1112
+ registerEditor: (node: HTMLElement | null) => void;
1113
+ onThreadToggle: (threadId: string) => void;
1114
+ onInput: (paragraph: DocumentViewParagraph, text: string) => void;
1115
+ onBlur: () => void;
1116
+ onSelection: (paragraph: DocumentViewParagraph) => void;
1117
+ onKeyDown: (event: ReactKeyboardEvent<HTMLElement>, paragraph: DocumentViewParagraph) => void;
1118
+ }) {
1119
+ if (isDocumentTable(block)) {
1120
+ return <DocumentTableView table={block} viewBlockId={viewBlockId} />;
1121
+ }
1122
+
1123
+ const unresolved = comments.filter((thread) => !thread.resolved);
1124
+ const commentPanelOpen = comments.some((thread) => thread.id === openThreadId);
1125
+ const paragraphStyle = toParagraphStyle(block);
1126
+ const segments = annotateRuns(block, unresolved, changes);
1127
+ const marker = block.style.list
1128
+ ? block.style.list.kind === "number"
1129
+ ? `${listOrdinal ?? 1}.`
1130
+ : "•"
1131
+ : null;
1132
+
1133
+ return (
1134
+ <div
1135
+ data-og-document-block={viewBlockId}
1136
+ data-og-block-kind="paragraph"
1137
+ className={cn(
1138
+ "group relative flex min-h-7 min-w-0 gap-2 rounded-sm px-1 transition-colors",
1139
+ selected && "bg-og-accent-soft ring-1 ring-inset ring-og-accent",
1140
+ )}
1141
+ >
1142
+ {marker ? (
1143
+ <span
1144
+ aria-hidden
1145
+ className="w-7 shrink-0 select-none text-right opacity-70"
1146
+ style={{
1147
+ paddingLeft: `${(block.style.list?.level ?? 0) * 12}px`,
1148
+ paddingTop: "0.1em",
1149
+ }}
1150
+ >
1151
+ {marker}
1152
+ </span>
1153
+ ) : null}
1154
+ <ParagraphLeaf
1155
+ key={`${block.id}:${projectionRevision}`}
1156
+ paragraph={block}
1157
+ viewBlockId={viewBlockId}
1158
+ segments={segments}
1159
+ paragraphStyle={paragraphStyle}
1160
+ listOrdinal={listOrdinal}
1161
+ readOnly={readOnly}
1162
+ registerEditor={registerEditor}
1163
+ onInput={onInput}
1164
+ onBlur={onBlur}
1165
+ onSelection={onSelection}
1166
+ onKeyDown={onKeyDown}
1167
+ />
1168
+
1169
+ {comments.length > 0 || changes.length > 0 ? (
1170
+ <div className="relative flex w-7 shrink-0 flex-col items-center gap-1 pt-0.5">
1171
+ {comments.length > 0 ? (
1172
+ <button
1173
+ type="button"
1174
+ aria-label={
1175
+ unresolved.length > 0
1176
+ ? `${unresolved.length} unresolved ${unresolved.length === 1 ? "comment" : "comments"}`
1177
+ : `${comments.length} resolved ${comments.length === 1 ? "comment" : "comments"}`
1178
+ }
1179
+ aria-expanded={commentPanelOpen}
1180
+ onClick={() => onThreadToggle((unresolved[0] ?? comments[0])!.id)}
1181
+ className={cn(
1182
+ "grid size-6 place-items-center rounded-full focus-visible:outline-hidden focus-visible:ring-2",
1183
+ unresolved.length > 0
1184
+ ? "bg-og-accent-soft text-og-status-waiting hover:bg-og-surface-3 focus-visible:ring-og-status-waiting"
1185
+ : "bg-og-surface-3 text-og-fg-muted hover:text-og-fg focus-visible:ring-og-accent",
1186
+ )}
1187
+ >
1188
+ <MessageSquareTextIcon className="size-3.5" />
1189
+ </button>
1190
+ ) : null}
1191
+ {changes.length > 0 ? (
1192
+ <span
1193
+ role="img"
1194
+ aria-label={`${changes.length} tracked ${changes.length === 1 ? "change" : "changes"}`}
1195
+ className="mt-1 h-4 w-1 rounded-full bg-og-status-idle"
1196
+ />
1197
+ ) : null}
1198
+ {commentPanelOpen ? (
1199
+ <aside
1200
+ aria-label="Comment thread"
1201
+ className="absolute right-0 top-8 z-20 w-64 rounded-og-md border border-og-border bg-og-surface-1 p-3 text-og-xs text-og-fg shadow-og-lg"
1202
+ >
1203
+ <div className="mb-2 font-medium">
1204
+ {comments.length === 1 ? "Comment" : `${comments.length} comments`}
1205
+ </div>
1206
+ <ol className="max-h-72 space-y-3 overflow-y-auto">
1207
+ {comments.map((thread) => (
1208
+ <li key={thread.id} className="space-y-2">
1209
+ <div className="text-og-fg-muted">{thread.resolved ? "Resolved" : "Open"}</div>
1210
+ {thread.replies.map((reply, index) => (
1211
+ // Reply position is stable within the append-only comment thread.
1212
+ // oxlint-disable-next-line react/no-array-index-key
1213
+ <div key={`${reply.createdAt}:${index}`}>
1214
+ <div className="text-og-fg-muted">{reply.author}</div>
1215
+ <div className="whitespace-pre-wrap">{reply.text}</div>
1216
+ </div>
1217
+ ))}
1218
+ </li>
1219
+ ))}
1220
+ </ol>
1221
+ </aside>
1222
+ ) : null}
1223
+ </div>
1224
+ ) : null}
1225
+ </div>
1226
+ );
1227
+ }
1228
+
1229
+ type ParagraphLeafProps = {
1230
+ paragraph: DocumentViewParagraph;
1231
+ viewBlockId: string;
1232
+ segments: ReturnType<typeof annotateRuns>;
1233
+ paragraphStyle: CSSProperties;
1234
+ listOrdinal?: number | undefined;
1235
+ readOnly: boolean;
1236
+ registerEditor: (node: HTMLElement | null) => void;
1237
+ onInput: (paragraph: DocumentViewParagraph, text: string) => void;
1238
+ onBlur: () => void;
1239
+ onSelection: (paragraph: DocumentViewParagraph) => void;
1240
+ onKeyDown: (event: ReactKeyboardEvent<HTMLElement>, paragraph: DocumentViewParagraph) => void;
1241
+ };
1242
+
1243
+ /**
1244
+ * The active contentEditable leaf is memo-frozen while focused. Browser input
1245
+ * and IME therefore own its DOM until blur; host rerenders caused by onCommit
1246
+ * cannot reconcile run spans underneath the live caret. The revision key on
1247
+ * the caller remounts a normalized model projection after blur.
1248
+ */
1249
+ const ParagraphLeaf = memo(
1250
+ function ParagraphLeaf({
1251
+ paragraph,
1252
+ viewBlockId,
1253
+ segments,
1254
+ paragraphStyle,
1255
+ listOrdinal,
1256
+ readOnly,
1257
+ registerEditor,
1258
+ onInput,
1259
+ onBlur,
1260
+ onSelection,
1261
+ onKeyDown,
1262
+ }: ParagraphLeafProps) {
1263
+ const composing = useRef(false);
1264
+ const Tag = headingTag(paragraph.style.headingLevel);
1265
+ const label =
1266
+ paragraph.style.list?.kind === "number"
1267
+ ? `Numbered list item ${listOrdinal ?? 1}`
1268
+ : paragraph.style.list?.kind === "bullet"
1269
+ ? "Bullet list item"
1270
+ : paragraph.style.headingLevel
1271
+ ? `Heading ${paragraph.style.headingLevel}`
1272
+ : "Paragraph";
1273
+
1274
+ return (
1275
+ <Tag
1276
+ ref={registerEditor}
1277
+ contentEditable={!readOnly}
1278
+ suppressContentEditableWarning
1279
+ tabIndex={readOnly ? 0 : undefined}
1280
+ role={!readOnly && !paragraph.style.headingLevel ? "textbox" : undefined}
1281
+ aria-label={label}
1282
+ aria-multiline={!readOnly || undefined}
1283
+ aria-readonly={readOnly}
1284
+ spellCheck={!readOnly}
1285
+ data-og-paragraph={viewBlockId}
1286
+ onFocus={() => onSelection(paragraph)}
1287
+ onSelect={() => onSelection(paragraph)}
1288
+ onKeyUp={() => onSelection(paragraph)}
1289
+ onMouseUp={() => onSelection(paragraph)}
1290
+ onCompositionStart={() => {
1291
+ composing.current = true;
1292
+ }}
1293
+ onCompositionEnd={(event) => {
1294
+ composing.current = false;
1295
+ onInput(paragraph, event.currentTarget.textContent ?? "");
1296
+ onSelection(paragraph);
1297
+ }}
1298
+ onPaste={(event) => {
1299
+ if (readOnly) return;
1300
+ // contentEditable otherwise accepts HTML-only clipboard payloads,
1301
+ // including live images/links. The artifact surface is a plain-text
1302
+ // editing boundary; always suppress the browser's rich insertion.
1303
+ event.preventDefault();
1304
+ const text = event.clipboardData.getData("text/plain").slice(0, MAX_PLAIN_TEXT_TRANSFER);
1305
+ if (!text) return;
1306
+ insertPlainText(event.currentTarget, text);
1307
+ onInput(paragraph, event.currentTarget.textContent ?? "");
1308
+ onSelection(paragraph);
1309
+ }}
1310
+ onDragOver={(event) => {
1311
+ if (!readOnly) event.preventDefault();
1312
+ }}
1313
+ onDrop={(event) => {
1314
+ if (!readOnly) event.preventDefault();
1315
+ }}
1316
+ onKeyDown={(event) => {
1317
+ onKeyDown(event, paragraph);
1318
+ if (event.defaultPrevented || readOnly) return;
1319
+ if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.altKey) {
1320
+ event.preventDefault();
1321
+ insertPlainText(event.currentTarget, "\n");
1322
+ onInput(paragraph, event.currentTarget.textContent ?? "");
1323
+ onSelection(paragraph);
1324
+ }
1325
+ }}
1326
+ onInput={(event) => {
1327
+ if (!composing.current && !(event.nativeEvent as InputEvent).isComposing) {
1328
+ onInput(paragraph, event.currentTarget.textContent ?? "");
1329
+ }
1330
+ }}
1331
+ onBlur={onBlur}
1332
+ className={cn(
1333
+ "min-w-0 flex-1 whitespace-pre-wrap break-words rounded-sm outline-hidden",
1334
+ !readOnly && "cursor-text focus-visible:ring-2 focus-visible:ring-og-accent",
1335
+ )}
1336
+ style={paragraphStyle}
1337
+ >
1338
+ {segments.map((segment, index) => (
1339
+ <span
1340
+ // Segment boundaries are derived from immutable offsets, so index
1341
+ // is stable until this paragraph is intentionally edited.
1342
+ // oxlint-disable-next-line react/no-array-index-key
1343
+ key={`${segment.start}:${index}`}
1344
+ data-og-commented={segment.commented || undefined}
1345
+ data-og-change={segment.changeKind}
1346
+ style={segment.style}
1347
+ >
1348
+ {segment.text}
1349
+ </span>
1350
+ ))}
1351
+ </Tag>
1352
+ );
1353
+ },
1354
+ (previous, next) => {
1355
+ if (next.readOnly || previous.viewBlockId !== next.viewBlockId) return false;
1356
+ const active = globalThis.document?.activeElement;
1357
+ return active instanceof HTMLElement && active.dataset.ogParagraph === next.viewBlockId;
1358
+ },
1359
+ );
1360
+
1361
+ function DocumentTableView({
1362
+ table,
1363
+ viewBlockId,
1364
+ }: {
1365
+ table: DocumentViewTable;
1366
+ viewBlockId: string;
1367
+ }) {
1368
+ const columnWidths = table.style.columnWidthsPt;
1369
+ return (
1370
+ <div
1371
+ data-og-document-block={viewBlockId}
1372
+ data-og-block-kind="table"
1373
+ className="overflow-x-auto py-2"
1374
+ >
1375
+ <table
1376
+ className="w-full border-collapse text-left"
1377
+ style={{
1378
+ fontSize: "0.92em",
1379
+ width: table.style.widthPt ? `${table.style.widthPt}pt` : undefined,
1380
+ }}
1381
+ >
1382
+ {columnWidths ? (
1383
+ <colgroup>
1384
+ {columnWidths.map((width, index) => (
1385
+ // Column position is its identity in the document table model.
1386
+ // oxlint-disable-next-line react/no-array-index-key
1387
+ <col key={index} style={{ width: `${width}pt` }} />
1388
+ ))}
1389
+ </colgroup>
1390
+ ) : null}
1391
+ <tbody>
1392
+ {table.rows.map((row, rowIndex) => (
1393
+ // Row position is its identity in the document table model.
1394
+ // oxlint-disable-next-line react/no-array-index-key
1395
+ <tr key={rowIndex}>
1396
+ {row.map((cell, columnIndex) => {
1397
+ const header = rowIndex < (table.style.headerRows ?? 0);
1398
+ const Cell = header ? "th" : "td";
1399
+ return (
1400
+ <Cell
1401
+ // Column position is its identity in the document table model.
1402
+ // oxlint-disable-next-line react/no-array-index-key
1403
+ key={columnIndex}
1404
+ scope={header ? "col" : undefined}
1405
+ className={cn("border align-middle", header && "font-semibold")}
1406
+ style={{
1407
+ borderColor: table.style.borderColor ?? "#d1d5db",
1408
+ background: header ? (table.style.headerFill ?? "#f3f4f6") : undefined,
1409
+ padding: `${table.style.cellPaddingPt ?? 6}pt`,
1410
+ }}
1411
+ >
1412
+ {cell.map((run, index) => (
1413
+ // Run position is stable within the immutable cell projection.
1414
+ // oxlint-disable-next-line react/no-array-index-key
1415
+ <span key={index} style={toRunStyle(run.style)}>
1416
+ {run.text}
1417
+ </span>
1418
+ ))}
1419
+ </Cell>
1420
+ );
1421
+ })}
1422
+ </tr>
1423
+ ))}
1424
+ </tbody>
1425
+ </table>
1426
+ </div>
1427
+ );
1428
+ }
1429
+
1430
+ function ToolbarButton({
1431
+ label,
1432
+ shortcut,
1433
+ pressed,
1434
+ disabled,
1435
+ onPress,
1436
+ children,
1437
+ }: {
1438
+ label: string;
1439
+ shortcut?: string | undefined;
1440
+ pressed?: boolean | undefined;
1441
+ disabled?: boolean | undefined;
1442
+ onPress: () => void;
1443
+ children: ReactNode;
1444
+ }) {
1445
+ return (
1446
+ <button
1447
+ type="button"
1448
+ aria-label={label}
1449
+ aria-pressed={pressed}
1450
+ title={shortcut ? `${label} (${shortcut})` : label}
1451
+ disabled={disabled}
1452
+ onMouseDown={(event: ReactMouseEvent<HTMLButtonElement>) => event.preventDefault()}
1453
+ onClick={onPress}
1454
+ className={cn(
1455
+ "grid size-7 place-items-center rounded-og-sm text-og-fg-muted outline-hidden transition-colors hover:bg-og-surface-3 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent disabled:pointer-events-none disabled:opacity-40",
1456
+ pressed && "bg-og-surface-3 text-og-fg",
1457
+ )}
1458
+ >
1459
+ {children}
1460
+ </button>
1461
+ );
1462
+ }
1463
+
1464
+ function projectPages(document: DocumentViewModel, layout: DocumentLayoutMode): ProjectedPage[] {
1465
+ const pages: ProjectedPage[] = [];
1466
+ const sections = document.sections.items;
1467
+ let sectionIndex = 0;
1468
+ let pageGeometry = sections[0]?.page ?? document.page;
1469
+ let blocks: ProjectedBlock[] = [];
1470
+ let used = 0;
1471
+ let pageIndex = 0;
1472
+ let numberOrdinal = 0;
1473
+
1474
+ const flush = (force = false) => {
1475
+ if (blocks.length === 0 && !force) return;
1476
+ const physicalPageHeight = pageGeometry.heightPt * POINT_TO_CSS_PIXEL;
1477
+ const pageVerticalChrome =
1478
+ (pageGeometry.marginTopPt + pageGeometry.marginBottomPt) * POINT_TO_CSS_PIXEL;
1479
+ pages.push({
1480
+ key: `${layout}:${sections[sectionIndex]?.id ?? "default"}:${pageIndex++}:${blocks[0]?.block.id ?? "blank"}`,
1481
+ blocks,
1482
+ height:
1483
+ layout === "paginated"
1484
+ ? Math.max(physicalPageHeight, used + pageVerticalChrome)
1485
+ : Math.max(1, used + 48),
1486
+ minimumHeight: layout === "paginated" ? physicalPageHeight : 1,
1487
+ page: { ...pageGeometry },
1488
+ });
1489
+ blocks = [];
1490
+ used = 0;
1491
+ };
1492
+
1493
+ for (let blockIndex = 0; blockIndex < document.blocks.items.length; blockIndex += 1) {
1494
+ while (
1495
+ sectionIndex + 1 < sections.length &&
1496
+ sections[sectionIndex + 1]!.startBlockIndex <= blockIndex
1497
+ ) {
1498
+ flush();
1499
+ sectionIndex += 1;
1500
+ pageGeometry = sections[sectionIndex]!.page;
1501
+ }
1502
+ const block = document.blocks.items[blockIndex]!;
1503
+ if (isDocumentPageBreak(block)) {
1504
+ flush(true);
1505
+ numberOrdinal = 0;
1506
+ continue;
1507
+ }
1508
+ if (isDocumentParagraph(block) && block.style.pageBreakBefore) flush();
1509
+ const height = estimateBlockHeight(block);
1510
+ const physicalPageHeight = pageGeometry.heightPt * POINT_TO_CSS_PIXEL;
1511
+ const usableHeight = Math.max(
1512
+ 120,
1513
+ physicalPageHeight -
1514
+ (pageGeometry.marginTopPt + pageGeometry.marginBottomPt) * POINT_TO_CSS_PIXEL,
1515
+ );
1516
+ const targetHeight = layout === "paginated" ? usableHeight : Math.max(560, usableHeight);
1517
+ if (blocks.length > 0 && used + height > targetHeight) flush();
1518
+ if (isDocumentParagraph(block) && block.style.list?.kind === "number") {
1519
+ numberOrdinal += 1;
1520
+ } else {
1521
+ numberOrdinal = 0;
1522
+ }
1523
+ blocks.push({
1524
+ block,
1525
+ height,
1526
+ listOrdinal:
1527
+ isDocumentParagraph(block) && block.style.list?.kind === "number"
1528
+ ? numberOrdinal
1529
+ : undefined,
1530
+ });
1531
+ used += height;
1532
+ }
1533
+ flush();
1534
+ return pages;
1535
+ }
1536
+
1537
+ function indexAnnotations(document: DocumentViewModel): {
1538
+ comments: Map<string, DocumentViewComment[]>;
1539
+ changes: Map<string, DocumentViewChange[]>;
1540
+ } {
1541
+ const comments = new Map<string, DocumentViewComment[]>();
1542
+ const changes = new Map<string, DocumentViewChange[]>();
1543
+ for (const thread of document.comments.items) {
1544
+ const items = comments.get(thread.blockId);
1545
+ if (items) items.push(thread);
1546
+ else comments.set(thread.blockId, [thread]);
1547
+ }
1548
+ for (const change of document.changes.items) {
1549
+ const items = changes.get(change.blockId);
1550
+ if (items) items.push(change);
1551
+ else changes.set(change.blockId, [change]);
1552
+ }
1553
+ return { comments, changes };
1554
+ }
1555
+
1556
+ function estimateBlockHeight(block: Exclude<DocumentViewBlock, DocumentViewPageBreak>): number {
1557
+ if (isDocumentTable(block)) {
1558
+ return Math.max(48, block.rows.length * 38 + 16);
1559
+ }
1560
+ const fontSize = block.runs.reduce(
1561
+ (largest, run) => Math.max(largest, run.style.fontSizePt ?? 11),
1562
+ block.style.headingLevel ? Math.max(14, 26 - block.style.headingLevel * 2) : 11,
1563
+ );
1564
+ const lineHeight = fontSize * POINT_TO_CSS_PIXEL * (block.style.lineHeight ?? 1.35);
1565
+ const lines = Math.max(
1566
+ 1,
1567
+ block.text
1568
+ .split("\n")
1569
+ .reduce((total, line) => total + Math.max(1, Math.ceil(line.length / 72)), 0),
1570
+ );
1571
+ return Math.max(
1572
+ 28,
1573
+ lines * lineHeight +
1574
+ ((block.style.spaceBeforePt ?? 2) + (block.style.spaceAfterPt ?? 6)) * POINT_TO_CSS_PIXEL,
1575
+ );
1576
+ }
1577
+
1578
+ function buildPageAxis(pages: ProjectedPage[], gap: number): PageAxis {
1579
+ const offsets = new Float64Array(pages.length + 1);
1580
+ for (let index = 0; index < pages.length; index += 1) {
1581
+ offsets[index + 1] = offsets[index]! + pages[index]!.height + gap;
1582
+ }
1583
+ return { offsets, total: Math.max(1, (offsets[pages.length] ?? 0) - gap) };
1584
+ }
1585
+
1586
+ function pageAtOffset(axis: PageAxis, count: number, offset: number): number {
1587
+ if (count <= 1) return 0;
1588
+ let low = 0;
1589
+ let high = count;
1590
+ while (low < high) {
1591
+ const middle = (low + high) >>> 1;
1592
+ if (axis.offsets[middle + 1]! <= offset) low = middle + 1;
1593
+ else high = middle;
1594
+ }
1595
+ return Math.max(0, Math.min(count - 1, low));
1596
+ }
1597
+
1598
+ function visiblePageIndexes(
1599
+ axis: PageAxis,
1600
+ count: number,
1601
+ scrollTop: number,
1602
+ viewportHeight: number,
1603
+ overscan: number,
1604
+ required: number,
1605
+ ): number[] {
1606
+ if (count === 0) return [];
1607
+ const start = Math.max(0, pageAtOffset(axis, count, scrollTop) - overscan);
1608
+ const end = Math.min(count, pageAtOffset(axis, count, scrollTop + viewportHeight) + overscan + 1);
1609
+ const indexes = Array.from({ length: end - start }, (_, index) => start + index);
1610
+ if (required >= 0 && (required < start || required >= end)) indexes.push(required);
1611
+ return indexes.sort((left, right) => left - right);
1612
+ }
1613
+
1614
+ function responsiveDocumentPageScale(
1615
+ page: DocumentProjectionPageGeometry,
1616
+ viewportWidth: number,
1617
+ ): number {
1618
+ if (viewportWidth <= 0) return 1;
1619
+ const naturalWidth = page.widthPt * POINT_TO_CSS_PIXEL;
1620
+ return Math.min(1, Math.max(0.1, (viewportWidth - 32) / naturalWidth));
1621
+ }
1622
+
1623
+ function pageContentStyle(
1624
+ page: DocumentProjectionPageGeometry,
1625
+ layout: DocumentLayoutMode,
1626
+ ): CSSProperties {
1627
+ if (layout === "continuous") {
1628
+ return {
1629
+ padding: "24px clamp(24px, 8vw, 96px)",
1630
+ };
1631
+ }
1632
+ return {
1633
+ paddingTop: `${page.marginTopPt * POINT_TO_CSS_PIXEL}px`,
1634
+ paddingRight: `${page.marginRightPt * POINT_TO_CSS_PIXEL}px`,
1635
+ paddingBottom: `${page.marginBottomPt * POINT_TO_CSS_PIXEL}px`,
1636
+ paddingLeft: `${page.marginLeftPt * POINT_TO_CSS_PIXEL}px`,
1637
+ };
1638
+ }
1639
+
1640
+ function headingTag(level: number | undefined): "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" {
1641
+ if (!level) return "p";
1642
+ return `h${Math.max(1, Math.min(6, level))}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
1643
+ }
1644
+
1645
+ function toParagraphStyle(paragraph: DocumentViewParagraph): CSSProperties {
1646
+ const firstRun = paragraph.runs[0];
1647
+ return {
1648
+ textAlign: paragraph.style.alignment,
1649
+ marginTop: `${paragraph.style.spaceBeforePt ?? 0}pt`,
1650
+ marginBottom: `${paragraph.style.spaceAfterPt ?? 0}pt`,
1651
+ lineHeight: paragraph.style.lineHeight ?? 1.35,
1652
+ breakBefore: paragraph.style.pageBreakBefore ? "page" : undefined,
1653
+ fontFamily: firstRun?.style.fontFamily,
1654
+ fontSize: firstRun?.style.fontSizePt ? `${firstRun.style.fontSizePt}pt` : undefined,
1655
+ fontWeight: paragraph.style.headingLevel ? 650 : undefined,
1656
+ };
1657
+ }
1658
+
1659
+ function toRunStyle(style: DocumentProjectionTextStyle): CSSProperties {
1660
+ return {
1661
+ fontFamily: style.fontFamily,
1662
+ fontSize: style.fontSizePt ? `${style.fontSizePt}pt` : undefined,
1663
+ color: style.color,
1664
+ fontWeight: style.bold ? 700 : undefined,
1665
+ fontStyle: style.italic ? "italic" : undefined,
1666
+ textDecoration:
1667
+ [style.underline ? "underline" : "", style.strike ? "line-through" : ""]
1668
+ .filter(Boolean)
1669
+ .join(" ") || undefined,
1670
+ };
1671
+ }
1672
+
1673
+ function annotateRuns(
1674
+ paragraph: DocumentViewParagraph,
1675
+ comments: ReadonlyArray<{ start: number; end: number }>,
1676
+ changes: ReadonlyArray<{
1677
+ start: number;
1678
+ end: number;
1679
+ kind: "insert" | "delete";
1680
+ }>,
1681
+ ) {
1682
+ const segments: Array<{
1683
+ text: string;
1684
+ start: number;
1685
+ style: CSSProperties;
1686
+ commented: boolean;
1687
+ changeKind?: "insert" | "delete" | undefined;
1688
+ }> = [];
1689
+ let runStart = 0;
1690
+ for (const run of paragraph.runs) {
1691
+ const runEnd = runStart + run.text.length;
1692
+ const boundaries = new Set([runStart, runEnd]);
1693
+ for (const annotation of [...comments, ...changes]) {
1694
+ if (annotation.start > runStart && annotation.start < runEnd)
1695
+ boundaries.add(annotation.start);
1696
+ if (annotation.end > runStart && annotation.end < runEnd) boundaries.add(annotation.end);
1697
+ }
1698
+ const ordered = [...boundaries].sort((left, right) => left - right);
1699
+ for (let index = 0; index < ordered.length - 1; index += 1) {
1700
+ const start = ordered[index]!;
1701
+ const end = ordered[index + 1]!;
1702
+ const commented = comments.some((comment) => comment.start < end && comment.end > start);
1703
+ const change = changes.find((item) => item.start < end && item.end > start);
1704
+ const baseStyle = toRunStyle(run.style);
1705
+ segments.push({
1706
+ text: run.text.slice(start - runStart, end - runStart),
1707
+ start,
1708
+ commented,
1709
+ changeKind: change?.kind,
1710
+ style: {
1711
+ ...baseStyle,
1712
+ backgroundColor: commented
1713
+ ? "color-mix(in oklch, var(--og-color-status-waiting) 18%, transparent)"
1714
+ : undefined,
1715
+ textDecoration:
1716
+ change?.kind === "delete"
1717
+ ? "line-through"
1718
+ : change?.kind === "insert"
1719
+ ? "underline"
1720
+ : baseStyle.textDecoration,
1721
+ textDecorationColor:
1722
+ change?.kind === "delete"
1723
+ ? "var(--og-color-status-failed)"
1724
+ : change?.kind === "insert"
1725
+ ? "var(--og-color-status-idle)"
1726
+ : undefined,
1727
+ },
1728
+ });
1729
+ }
1730
+ runStart = runEnd;
1731
+ }
1732
+ if (segments.length === 0) {
1733
+ segments.push({ text: "", start: 0, style: {}, commented: false });
1734
+ }
1735
+ return segments;
1736
+ }
1737
+
1738
+ function selectionOffsets(root: HTMLElement | undefined): { start: number; end: number } | null {
1739
+ if (!root) return null;
1740
+ const selection = globalThis.getSelection?.();
1741
+ if (!selection || selection.rangeCount === 0) return null;
1742
+ const range = selection.getRangeAt(0);
1743
+ if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null;
1744
+ const beforeStart = range.cloneRange();
1745
+ beforeStart.selectNodeContents(root);
1746
+ beforeStart.setEnd(range.startContainer, range.startOffset);
1747
+ const beforeEnd = range.cloneRange();
1748
+ beforeEnd.selectNodeContents(root);
1749
+ beforeEnd.setEnd(range.endContainer, range.endOffset);
1750
+ return {
1751
+ start: beforeStart.toString().length,
1752
+ end: beforeEnd.toString().length,
1753
+ };
1754
+ }
1755
+
1756
+ function insertPlainText(root: HTMLElement, text: string): void {
1757
+ const selection = globalThis.getSelection?.();
1758
+ if (!selection || selection.rangeCount === 0) return;
1759
+ const range = selection.getRangeAt(0);
1760
+ if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return;
1761
+ range.deleteContents();
1762
+ const node = globalThis.document.createTextNode(text);
1763
+ range.insertNode(node);
1764
+ range.setStartAfter(node);
1765
+ range.collapse(true);
1766
+ selection.removeAllRanges();
1767
+ selection.addRange(range);
1768
+ }
1769
+
1770
+ function restoreSelection(
1771
+ root: HTMLElement | undefined,
1772
+ requestedStart: number,
1773
+ requestedEnd: number,
1774
+ ): void {
1775
+ if (!root || !globalThis.getSelection) return;
1776
+ const textLength = root.textContent?.length ?? 0;
1777
+ const start = Math.max(0, Math.min(requestedStart, textLength));
1778
+ const end = Math.max(start, Math.min(requestedEnd, textLength));
1779
+ const startPoint = textPointAt(root, start);
1780
+ const endPoint = textPointAt(root, end);
1781
+ if (!startPoint || !endPoint) return;
1782
+ const range = document.createRange();
1783
+ range.setStart(startPoint.node, startPoint.offset);
1784
+ range.setEnd(endPoint.node, endPoint.offset);
1785
+ const domSelection = globalThis.getSelection();
1786
+ domSelection?.removeAllRanges();
1787
+ domSelection?.addRange(range);
1788
+ }
1789
+
1790
+ function textPointAt(root: HTMLElement, target: number): { node: Node; offset: number } | null {
1791
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1792
+ let remaining = target;
1793
+ let node = walker.nextNode();
1794
+ while (node) {
1795
+ const length = node.textContent?.length ?? 0;
1796
+ if (remaining <= length) return { node, offset: remaining };
1797
+ remaining -= length;
1798
+ node = walker.nextNode();
1799
+ }
1800
+ return { node: root, offset: root.childNodes.length };
1801
+ }
1802
+
1803
+ function toggledTextStyle(
1804
+ paragraph: DocumentViewParagraph,
1805
+ start: number,
1806
+ end: number,
1807
+ property: "bold" | "italic" | "underline",
1808
+ ): Partial<DocumentProjectionTextStyle> {
1809
+ let allEnabled = end > start;
1810
+ let offset = 0;
1811
+ for (const run of paragraph.runs) {
1812
+ const runEnd = offset + run.text.length;
1813
+ if (runEnd > start && offset < end && run.style[property] !== true) {
1814
+ allEnabled = false;
1815
+ break;
1816
+ }
1817
+ offset = runEnd;
1818
+ }
1819
+ return { [property]: !allEnabled };
1820
+ }
1821
+
1822
+ type DocumentProjectionView = {
1823
+ document: DocumentViewModel;
1824
+ externalIdByInternal: Map<string, string>;
1825
+ };
1826
+
1827
+ function documentViewFromProjection(projection: DocumentEditorProjection): DocumentProjectionView {
1828
+ if (projection.blocks.length > MAX_PROJECTION_BLOCKS) {
1829
+ throw new Error(`Document projection exceeds ${MAX_PROJECTION_BLOCKS} blocks`);
1830
+ }
1831
+ if (
1832
+ (projection.comments?.length ?? 0) > MAX_PROJECTION_ANNOTATIONS ||
1833
+ (projection.changes?.length ?? 0) > MAX_PROJECTION_ANNOTATIONS
1834
+ ) {
1835
+ throw new Error(`Document projection exceeds ${MAX_PROJECTION_ANNOTATIONS} annotations`);
1836
+ }
1837
+ const sections = [...(projection.sections ?? [])].sort(
1838
+ (left, right) => left.startBlockIndex - right.startBlockIndex,
1839
+ );
1840
+ if (
1841
+ sections.some(
1842
+ (section, index) =>
1843
+ section.startBlockIndex < 0 ||
1844
+ (index > 0 && section.startBlockIndex <= sections[index - 1]!.startBlockIndex),
1845
+ )
1846
+ ) {
1847
+ throw new Error("Document projection sections must have unique increasing block indexes");
1848
+ }
1849
+ const firstPage = sections[0]?.startBlockIndex === 0 ? sections[0].page : projection.page;
1850
+ validateProjectionPage(firstPage);
1851
+ const externalIdByInternal = new Map<string, string>();
1852
+ const paragraphByExternal = new Map<string, DocumentViewParagraph>();
1853
+ const objects = new Map<string, unknown>();
1854
+ const blocks: DocumentViewBlock[] = [];
1855
+ const documentSections: DocumentProjectionSection[] = [];
1856
+ const comments: DocumentViewComment[] = [];
1857
+ const changes: DocumentViewChange[] = [];
1858
+ const seenIds = new Set<string>();
1859
+ const remember = (internalId: string, externalId: string, value: unknown) => {
1860
+ if (!externalId || seenIds.has(externalId)) {
1861
+ throw new Error(`Document projection contains an invalid or duplicate id: ${externalId}`);
1862
+ }
1863
+ seenIds.add(externalId);
1864
+ externalIdByInternal.set(internalId, externalId);
1865
+ objects.set(internalId, value);
1866
+ };
1867
+
1868
+ let revision = 0;
1869
+ let localParagraphId = 0;
1870
+ const changed = () => {
1871
+ revision += 1;
1872
+ };
1873
+ const document: DocumentViewModel = {
1874
+ get revision() {
1875
+ return revision;
1876
+ },
1877
+ page: copyDocumentPage(firstPage),
1878
+ blocks: {
1879
+ items: blocks,
1880
+ addParagraph(text = "") {
1881
+ let id: string;
1882
+ do {
1883
+ localParagraphId += 1;
1884
+ id = `projection-paragraph-${localParagraphId}`;
1885
+ } while (objects.has(id));
1886
+ const paragraph = new ProjectionParagraphView(id, [{ text, style: {} }], {}, changed);
1887
+ blocks.push(paragraph);
1888
+ objects.set(id, paragraph);
1889
+ changed();
1890
+ return paragraph;
1891
+ },
1892
+ },
1893
+ sections: { items: documentSections },
1894
+ comments: { items: comments },
1895
+ changes: { items: changes },
1896
+ resolve(id) {
1897
+ const value = objects.get(id);
1898
+ if (!value) throw new Error(`Unknown document object id: ${id}`);
1899
+ return value;
1900
+ },
1901
+ };
1902
+
1903
+ let sectionIndex = sections[0]?.startBlockIndex === 0 ? 1 : 0;
1904
+ if (sections[0]?.startBlockIndex === 0) {
1905
+ const projectedSection = sections[0]!;
1906
+ validateProjectionPage(projectedSection.page);
1907
+ const section = {
1908
+ ...projectedSection,
1909
+ page: copyDocumentPage(projectedSection.page),
1910
+ };
1911
+ documentSections.push(section);
1912
+ remember(section.id, projectedSection.id, section);
1913
+ } else {
1914
+ documentSections.push({
1915
+ id: "projection-default-section",
1916
+ startBlockIndex: 0,
1917
+ page: copyDocumentPage(firstPage),
1918
+ });
1919
+ }
1920
+ for (let blockIndex = 0; blockIndex < projection.blocks.length; blockIndex += 1) {
1921
+ while (sections[sectionIndex]?.startBlockIndex === blockIndex) {
1922
+ const projectedSection = sections[sectionIndex]!;
1923
+ validateProjectionPage(projectedSection.page);
1924
+ const section = {
1925
+ ...projectedSection,
1926
+ page: copyDocumentPage(projectedSection.page),
1927
+ };
1928
+ documentSections.push(section);
1929
+ remember(section.id, projectedSection.id, section);
1930
+ sectionIndex += 1;
1931
+ }
1932
+ const projectedBlock = projection.blocks[blockIndex]!;
1933
+ if (projectedBlock.kind === "paragraph") {
1934
+ const paragraph = new ProjectionParagraphView(
1935
+ projectedBlock.id,
1936
+ projectedBlock.runs.length > 0
1937
+ ? projectedBlock.runs.map((run) => ({
1938
+ text: run.text.slice(0, MAX_PROJECTION_TEXT),
1939
+ style: { ...(run.style ?? {}) },
1940
+ }))
1941
+ : [{ text: "", style: {} }],
1942
+ { ...(projectedBlock.style ?? {}) },
1943
+ changed,
1944
+ );
1945
+ blocks.push(paragraph);
1946
+ remember(paragraph.id, projectedBlock.id, paragraph);
1947
+ paragraphByExternal.set(projectedBlock.id, paragraph);
1948
+ } else if (projectedBlock.kind === "table") {
1949
+ const columns = projectedBlock.rows.reduce(
1950
+ (maximum, row) => Math.max(maximum, row.length),
1951
+ 0,
1952
+ );
1953
+ if (
1954
+ projectedBlock.rows.length === 0 ||
1955
+ columns === 0 ||
1956
+ projectedBlock.rows.some((row) => row.length !== columns)
1957
+ ) {
1958
+ throw new Error(`Document table ${projectedBlock.id} must be a non-empty rectangle`);
1959
+ }
1960
+ if (projectedBlock.rows.length * columns > MAX_PROJECTION_TABLE_CELLS) {
1961
+ throw new Error(`Document table ${projectedBlock.id} exceeds the projected cell limit`);
1962
+ }
1963
+ const table: DocumentViewTable = {
1964
+ kind: "table",
1965
+ id: projectedBlock.id,
1966
+ rows: projectedBlock.rows.map((row) =>
1967
+ row.map((cell) =>
1968
+ cell.length > 0
1969
+ ? cell.map((run) => ({
1970
+ text: run.text.slice(0, MAX_PROJECTION_TEXT),
1971
+ style: { ...(run.style ?? {}) },
1972
+ }))
1973
+ : [{ text: "", style: {} }],
1974
+ ),
1975
+ ),
1976
+ style: { ...(projectedBlock.style ?? {}) },
1977
+ };
1978
+ blocks.push(table);
1979
+ remember(table.id, projectedBlock.id, table);
1980
+ } else {
1981
+ const pageBreak: DocumentViewPageBreak = {
1982
+ kind: "pageBreak",
1983
+ id: projectedBlock.id,
1984
+ };
1985
+ blocks.push(pageBreak);
1986
+ remember(pageBreak.id, projectedBlock.id, pageBreak);
1987
+ }
1988
+ }
1989
+ while (sections[sectionIndex]?.startBlockIndex === projection.blocks.length) {
1990
+ const projectedSection = sections[sectionIndex]!;
1991
+ validateProjectionPage(projectedSection.page);
1992
+ const section = {
1993
+ ...projectedSection,
1994
+ page: copyDocumentPage(projectedSection.page),
1995
+ };
1996
+ documentSections.push(section);
1997
+ remember(section.id, projectedSection.id, section);
1998
+ sectionIndex += 1;
1999
+ }
2000
+ if (sectionIndex < sections.length) {
2001
+ throw new Error("Document projection section starts beyond the projected blocks");
2002
+ }
2003
+
2004
+ for (const projected of projection.comments ?? []) {
2005
+ const paragraph = paragraphByExternal.get(projected.blockId);
2006
+ const first = projected.replies[0];
2007
+ if (!paragraph || !first) continue;
2008
+ if (
2009
+ !Number.isSafeInteger(projected.start) ||
2010
+ !Number.isSafeInteger(projected.end) ||
2011
+ projected.start < 0 ||
2012
+ projected.end < projected.start ||
2013
+ projected.end > paragraph.text.length
2014
+ ) {
2015
+ throw new Error(`Document comment ${projected.id} has an invalid range`);
2016
+ }
2017
+ const thread: DocumentViewComment = {
2018
+ ...projected,
2019
+ replies: projected.replies.map((reply) => ({ ...reply })),
2020
+ };
2021
+ comments.push(thread);
2022
+ remember(thread.id, projected.id, thread);
2023
+ }
2024
+ for (const projected of projection.changes ?? []) {
2025
+ const paragraph = paragraphByExternal.get(projected.blockId);
2026
+ if (!paragraph) continue;
2027
+ if (
2028
+ !Number.isSafeInteger(projected.start) ||
2029
+ !Number.isSafeInteger(projected.end) ||
2030
+ projected.start < 0 ||
2031
+ projected.end <= projected.start ||
2032
+ projected.end > paragraph.text.length
2033
+ ) {
2034
+ throw new Error(`Document tracked change ${projected.id} has an invalid range`);
2035
+ }
2036
+ const change: DocumentViewChange = { ...projected };
2037
+ changes.push(change);
2038
+ remember(change.id, projected.id, change);
2039
+ }
2040
+ return { document, externalIdByInternal };
2041
+ }
2042
+
2043
+ function copyDocumentPage(page: DocumentProjectionPageGeometry): DocumentProjectionPageGeometry {
2044
+ return {
2045
+ widthPt: page.widthPt,
2046
+ heightPt: page.heightPt,
2047
+ marginTopPt: page.marginTopPt,
2048
+ marginRightPt: page.marginRightPt,
2049
+ marginBottomPt: page.marginBottomPt,
2050
+ marginLeftPt: page.marginLeftPt,
2051
+ ...(page.headerPt === undefined ? {} : { headerPt: page.headerPt }),
2052
+ ...(page.footerPt === undefined ? {} : { footerPt: page.footerPt }),
2053
+ ...(page.gutterPt === undefined ? {} : { gutterPt: page.gutterPt }),
2054
+ };
2055
+ }
2056
+
2057
+ class ProjectionParagraphView implements DocumentViewParagraph {
2058
+ readonly kind = "paragraph" as const;
2059
+ private runStorage: DocumentViewTextRun[];
2060
+ readonly style: DocumentProjectionParagraphStyle;
2061
+
2062
+ constructor(
2063
+ readonly id: string,
2064
+ runs: readonly DocumentViewTextRun[],
2065
+ style: DocumentProjectionParagraphStyle,
2066
+ private readonly changed: () => void,
2067
+ ) {
2068
+ this.runStorage = normalizeProjectionRuns(runs);
2069
+ this.style = { ...style, ...(style.list ? { list: { ...style.list } } : {}) };
2070
+ }
2071
+
2072
+ get runs(): readonly DocumentViewTextRun[] {
2073
+ return this.runStorage;
2074
+ }
2075
+
2076
+ get text(): string {
2077
+ return this.runStorage.map((run) => run.text).join("");
2078
+ }
2079
+
2080
+ set text(value: string) {
2081
+ const current = this.text;
2082
+ if (current === value) return;
2083
+ let start = 0;
2084
+ while (start < current.length && start < value.length && current[start] === value[start]) {
2085
+ start += 1;
2086
+ }
2087
+ let currentEnd = current.length;
2088
+ let valueEnd = value.length;
2089
+ while (
2090
+ currentEnd > start &&
2091
+ valueEnd > start &&
2092
+ current[currentEnd - 1] === value[valueEnd - 1]
2093
+ ) {
2094
+ currentEnd -= 1;
2095
+ valueEnd -= 1;
2096
+ }
2097
+ const inheritedStyle = projectionStyleAt(this.runStorage, start);
2098
+ this.runStorage = normalizeProjectionRuns([
2099
+ ...sliceProjectionRuns(this.runStorage, 0, start),
2100
+ ...(valueEnd > start ? [{ text: value.slice(start, valueEnd), style: inheritedStyle }] : []),
2101
+ ...sliceProjectionRuns(this.runStorage, currentEnd, current.length),
2102
+ ]);
2103
+ this.changed();
2104
+ }
2105
+
2106
+ format({
2107
+ start,
2108
+ end,
2109
+ style,
2110
+ }: {
2111
+ start: number;
2112
+ end: number;
2113
+ style: Partial<DocumentProjectionTextStyle>;
2114
+ }): void {
2115
+ const textLength = this.text.length;
2116
+ if (start < 0 || end <= start || end > textLength || Object.keys(style).length === 0) return;
2117
+ this.runStorage = normalizeProjectionRuns([
2118
+ ...sliceProjectionRuns(this.runStorage, 0, start),
2119
+ ...sliceProjectionRuns(this.runStorage, start, end).map((run) => ({
2120
+ text: run.text,
2121
+ style: { ...run.style, ...style },
2122
+ })),
2123
+ ...sliceProjectionRuns(this.runStorage, end, textLength),
2124
+ ]);
2125
+ this.changed();
2126
+ }
2127
+ }
2128
+
2129
+ function sliceProjectionRuns(
2130
+ runs: readonly DocumentViewTextRun[],
2131
+ start: number,
2132
+ end: number,
2133
+ ): DocumentViewTextRun[] {
2134
+ if (end <= start) return [];
2135
+ const result: DocumentViewTextRun[] = [];
2136
+ let offset = 0;
2137
+ for (const run of runs) {
2138
+ const runEnd = offset + run.text.length;
2139
+ const overlapStart = Math.max(start, offset);
2140
+ const overlapEnd = Math.min(end, runEnd);
2141
+ if (overlapEnd > overlapStart) {
2142
+ result.push({
2143
+ text: run.text.slice(overlapStart - offset, overlapEnd - offset),
2144
+ style: { ...run.style },
2145
+ });
2146
+ }
2147
+ offset = runEnd;
2148
+ if (offset >= end) break;
2149
+ }
2150
+ return result;
2151
+ }
2152
+
2153
+ function normalizeProjectionRuns(runs: readonly DocumentViewTextRun[]): DocumentViewTextRun[] {
2154
+ const normalized: DocumentViewTextRun[] = [];
2155
+ for (const run of runs) {
2156
+ if (!run.text && runs.length > 1) continue;
2157
+ const style = { ...run.style };
2158
+ const previous = normalized.at(-1);
2159
+ if (previous && sameProjectionStyle(previous.style, style)) {
2160
+ normalized[normalized.length - 1] = { text: previous.text + run.text, style };
2161
+ } else {
2162
+ normalized.push({ text: run.text, style });
2163
+ }
2164
+ }
2165
+ return normalized.length > 0 ? normalized : [{ text: "", style: {} }];
2166
+ }
2167
+
2168
+ function projectionStyleAt(
2169
+ runs: readonly DocumentViewTextRun[],
2170
+ offset: number,
2171
+ ): DocumentProjectionTextStyle {
2172
+ let cursor = 0;
2173
+ for (const run of runs) {
2174
+ const end = cursor + run.text.length;
2175
+ if (offset <= end) return { ...run.style };
2176
+ cursor = end;
2177
+ }
2178
+ return { ...(runs.at(-1)?.style ?? {}) };
2179
+ }
2180
+
2181
+ function sameProjectionStyle(
2182
+ left: DocumentProjectionTextStyle,
2183
+ right: DocumentProjectionTextStyle,
2184
+ ): boolean {
2185
+ return (
2186
+ left.fontFamily === right.fontFamily &&
2187
+ left.fontSizePt === right.fontSizePt &&
2188
+ left.color === right.color &&
2189
+ left.bold === right.bold &&
2190
+ left.italic === right.italic &&
2191
+ left.underline === right.underline &&
2192
+ left.strike === right.strike
2193
+ );
2194
+ }
2195
+
2196
+ function validateProjectionPage(page: DocumentProjectionPageGeometry): void {
2197
+ const values = [
2198
+ page.widthPt,
2199
+ page.heightPt,
2200
+ page.marginTopPt,
2201
+ page.marginRightPt,
2202
+ page.marginBottomPt,
2203
+ page.marginLeftPt,
2204
+ ];
2205
+ if (
2206
+ values.some((value) => !Number.isFinite(value) || value < 0) ||
2207
+ page.widthPt <= 0 ||
2208
+ page.heightPt <= 0 ||
2209
+ page.marginLeftPt + page.marginRightPt >= page.widthPt ||
2210
+ page.marginTopPt + page.marginBottomPt >= page.heightPt
2211
+ ) {
2212
+ throw new Error("Document projection contains invalid page geometry");
2213
+ }
2214
+ }
2215
+
2216
+ function isDocumentParagraph(value: unknown): value is DocumentViewParagraph {
2217
+ return isDocumentBlockKind(value, "paragraph");
2218
+ }
2219
+
2220
+ function isDocumentTable(value: unknown): value is DocumentViewTable {
2221
+ return isDocumentBlockKind(value, "table");
2222
+ }
2223
+
2224
+ function isDocumentPageBreak(value: unknown): value is DocumentViewPageBreak {
2225
+ return isDocumentBlockKind(value, "pageBreak");
2226
+ }
2227
+
2228
+ function isDocumentBlockKind<K extends DocumentViewBlock["kind"]>(
2229
+ value: unknown,
2230
+ kind: K,
2231
+ ): value is Extract<DocumentViewBlock, { kind: K }> {
2232
+ return typeof value === "object" && value !== null && "kind" in value && value.kind === kind;
2233
+ }
2234
+
2235
+ function asDocumentView(document: ReferenceDocument): DocumentViewModel {
2236
+ return document as unknown as DocumentViewModel;
2237
+ }
2238
+
2239
+ function mapDocumentCommit(
2240
+ command: DocumentCommit,
2241
+ externalIdByInternal: ReadonlyMap<string, string>,
2242
+ ): DocumentCommit {
2243
+ return {
2244
+ ...command,
2245
+ blockId: externalIdByInternal.get(command.blockId) ?? command.blockId,
2246
+ };
2247
+ }
2248
+
2249
+ function identity(value: string): string {
2250
+ return value;
2251
+ }
2252
+
2253
+ function asDocumentError(cause: unknown): Error {
2254
+ return cause instanceof Error ? cause : new Error("Document change failed");
2255
+ }