@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,3148 @@
1
+ import {
2
+ ChevronLeftIcon,
3
+ ChevronRightIcon,
4
+ ExpandIcon,
5
+ FilePlus2Icon,
6
+ MinusIcon,
7
+ PlusIcon,
8
+ Trash2Icon,
9
+ TypeIcon,
10
+ } from "lucide-react";
11
+ import {
12
+ type KeyboardEvent as ReactKeyboardEvent,
13
+ type PointerEvent as ReactPointerEvent,
14
+ type ReactNode,
15
+ useCallback,
16
+ useEffect,
17
+ useId,
18
+ useLayoutEffect,
19
+ useMemo,
20
+ useRef,
21
+ useState,
22
+ } from "react";
23
+ import type {
24
+ Presentation,
25
+ PresentationChart,
26
+ PresentationElement,
27
+ PresentationFill,
28
+ PresentationGroup,
29
+ PresentationGroupChild,
30
+ PresentationImage,
31
+ PresentationLine,
32
+ PresentationPosition,
33
+ PresentationShape,
34
+ PresentationTable,
35
+ PresentationTextStyle,
36
+ Slide,
37
+ } from "@opengeni/artifact-tool/reference";
38
+
39
+ import { cn } from "../../lib/cn";
40
+ import { ArtifactSurface } from "./artifact-surface";
41
+
42
+ const MIN_ZOOM = 0.2;
43
+ const MAX_ZOOM = 4;
44
+ const RAIL_ITEM_HEIGHT = 116;
45
+ const RAIL_OVERSCAN = 3;
46
+ const DEFAULT_RAIL_HEIGHT = 640;
47
+ const DEFAULT_VIEWPORT = { width: 960, height: 640 };
48
+ const SPATIAL_TILE = 256;
49
+ const MAX_SPATIAL_BUCKETS_PER_OBJECT = 1_024;
50
+ const CANVAS_OVERSCAN_PX = 96;
51
+ const MAX_CANVAS_EDGE = 8_192;
52
+ const MAX_CANVAS_PIXELS = 16_000_000;
53
+ const MAX_EDITOR_GEOMETRY = 1_000_000;
54
+ const MAX_TABLE_CELLS_PER_PAINT = 20_000;
55
+ const MAX_PROJECTION_SLIDES = 100_000;
56
+ const MAX_PROJECTION_ELEMENTS_PER_SLIDE = 100_000;
57
+ const MAX_PROJECTION_GROUP_DEPTH = 16;
58
+ const MAX_PROJECTION_TABLE_CELLS = 1_000_000;
59
+ const MAX_PROJECTION_TEXT = 1_000_000;
60
+
61
+ type PresentationObject = PresentationElement;
62
+ type PresentationObjectKind = "shape" | "chart" | "image" | "table" | "group";
63
+
64
+ type DisplayObject = {
65
+ id: string;
66
+ kind: PresentationObjectKind;
67
+ object: PresentationObject;
68
+ readOnly: boolean;
69
+ };
70
+
71
+ export type PresentationCommit =
72
+ | {
73
+ kind: "slide-insert";
74
+ slideId: string;
75
+ index: number;
76
+ revision?: string | number;
77
+ }
78
+ | {
79
+ kind: "slide-delete";
80
+ slideId: string;
81
+ index: number;
82
+ revision?: string | number;
83
+ }
84
+ | {
85
+ kind: "object-insert";
86
+ slideId: string;
87
+ objectId: string;
88
+ index: number;
89
+ position: PresentationPosition;
90
+ revision?: string | number;
91
+ }
92
+ | {
93
+ kind: "object-delete";
94
+ slideId: string;
95
+ objectId: string;
96
+ revision?: string | number;
97
+ }
98
+ | {
99
+ kind: "move" | "resize";
100
+ slideId: string;
101
+ objectId: string;
102
+ before: PresentationPosition;
103
+ after: PresentationPosition;
104
+ revision?: string | number;
105
+ }
106
+ | {
107
+ kind: "text";
108
+ slideId: string;
109
+ objectId: string;
110
+ before: string;
111
+ after: string;
112
+ revision?: string | number;
113
+ };
114
+
115
+ export type PresentationCommitHandler =
116
+ | ((commit: PresentationCommit) => void)
117
+ | ((commit: PresentationCommit) => Promise<void>);
118
+
119
+ export type PresentationProjectionPosition = Readonly<{
120
+ left: number;
121
+ top: number;
122
+ width: number;
123
+ height: number;
124
+ }>;
125
+
126
+ export type PresentationProjectionFill = string | Readonly<{ type?: "solid"; color: string }>;
127
+
128
+ export type PresentationProjectionLine = Readonly<{
129
+ style?: "solid" | "dash" | "dot" | "none";
130
+ fill?: PresentationProjectionFill;
131
+ width?: number;
132
+ }>;
133
+
134
+ export type PresentationProjectionTextStyle = Readonly<{
135
+ fontFamily?: string;
136
+ fontSize?: number;
137
+ bold?: boolean;
138
+ italic?: boolean;
139
+ underline?: boolean;
140
+ color?: string;
141
+ alignment?: "left" | "center" | "right" | "justify";
142
+ verticalAlignment?: "top" | "middle" | "bottom";
143
+ }>;
144
+
145
+ export type PresentationProjectionTextRun = Readonly<{
146
+ text: string;
147
+ style: PresentationProjectionTextStyle;
148
+ language?: string | null;
149
+ }>;
150
+
151
+ export type PresentationProjectionTextParagraph = Readonly<{
152
+ runs: readonly PresentationProjectionTextRun[];
153
+ alignment: "left" | "center" | "right" | "justify";
154
+ }>;
155
+
156
+ export type PresentationProjectionRichText = Readonly<{
157
+ paragraphs: readonly PresentationProjectionTextParagraph[];
158
+ verticalAlignment: "top" | "middle" | "bottom";
159
+ }>;
160
+
161
+ export type PresentationProjectionElementMetadata = Readonly<{
162
+ nodeSource?: Readonly<{ kind: "master" | "layout" | "slide"; id: string }>;
163
+ inherited?: boolean;
164
+ parentId?: string | null;
165
+ order?: number;
166
+ /** Inherited master/layout nodes remain visible but cannot be edited in slide mode. */
167
+ readOnly?: boolean;
168
+ }>;
169
+
170
+ export type PresentationProjectionShape = Readonly<{
171
+ kind: "shape";
172
+ id: string;
173
+ name: string;
174
+ geometry: "textbox" | "rect" | "roundRect" | "ellipse" | "triangle" | "rightArrow" | "line";
175
+ position: PresentationProjectionPosition;
176
+ fill?: PresentationProjectionFill;
177
+ line?: PresentationProjectionLine;
178
+ rotation?: number;
179
+ borderRadius?: number | string;
180
+ text: string;
181
+ textStyle?: PresentationProjectionTextStyle;
182
+ richText?: PresentationProjectionRichText;
183
+ }> &
184
+ PresentationProjectionElementMetadata;
185
+
186
+ export type PresentationProjectionChartSeries = Readonly<{
187
+ name: string;
188
+ categories?: readonly string[];
189
+ values: readonly number[];
190
+ xValues?: readonly number[];
191
+ bubbleSizes?: readonly number[];
192
+ fill?: PresentationProjectionFill;
193
+ line?: PresentationProjectionLine;
194
+ }>;
195
+
196
+ export type PresentationProjectionChart = Readonly<{
197
+ kind: "chart";
198
+ id: string;
199
+ name: string;
200
+ type: "bar" | "line" | "area" | "pie" | "doughnut" | "scatter" | "bubble" | "radar";
201
+ position: PresentationProjectionPosition;
202
+ title?: string;
203
+ titleRichText?: PresentationProjectionRichText;
204
+ hasLegend?: boolean;
205
+ series: readonly PresentationProjectionChartSeries[];
206
+ xAxis?: Readonly<{ visible?: boolean; title?: string; min?: number; max?: number }>;
207
+ yAxis?: Readonly<{ visible?: boolean; title?: string; min?: number; max?: number }>;
208
+ dataLabels?: Readonly<{
209
+ showValue?: boolean;
210
+ showSeriesName?: boolean;
211
+ showCategoryName?: boolean;
212
+ showPercent?: boolean;
213
+ position?: "center" | "inEnd" | "outEnd";
214
+ }>;
215
+ }> &
216
+ PresentationProjectionElementMetadata;
217
+
218
+ export type PresentationProjectionImage = Readonly<{
219
+ kind: "image";
220
+ id: string;
221
+ name: string;
222
+ position: PresentationProjectionPosition;
223
+ /** Host-resolved data/blob/same-origin URL. Arbitrary remote URLs are ignored. */
224
+ source?: string;
225
+ alt?: string;
226
+ prompt?: string;
227
+ fit?: "contain" | "cover";
228
+ crop?: Readonly<{ left: number; top: number; right: number; bottom: number }>;
229
+ geometry?: "rect" | "roundRect" | "ellipse";
230
+ borderRadius?: number | string;
231
+ rotation?: number;
232
+ flipHorizontal?: boolean;
233
+ flipVertical?: boolean;
234
+ digest?: string;
235
+ contentType?: string;
236
+ intrinsicWidth?: number;
237
+ intrinsicHeight?: number;
238
+ }> &
239
+ PresentationProjectionElementMetadata;
240
+
241
+ export type PresentationProjectionTableCell = Readonly<{
242
+ text: string;
243
+ richText?: PresentationProjectionRichText;
244
+ fill?: PresentationProjectionFill;
245
+ textStyle?: PresentationProjectionTextStyle;
246
+ rowSpan?: number;
247
+ colSpan?: number;
248
+ }>;
249
+
250
+ export type PresentationProjectionTable = Readonly<{
251
+ kind: "table";
252
+ id: string;
253
+ name: string;
254
+ position: PresentationProjectionPosition;
255
+ rows: readonly (readonly (PresentationProjectionTableCell | null)[])[];
256
+ columnWidths?: readonly number[];
257
+ rowHeights?: readonly number[];
258
+ fill?: PresentationProjectionFill;
259
+ line?: PresentationProjectionLine;
260
+ textStyle?: PresentationProjectionTextStyle;
261
+ }> &
262
+ PresentationProjectionElementMetadata;
263
+
264
+ export type PresentationProjectionGroup = Readonly<{
265
+ kind: "group";
266
+ id: string;
267
+ name: string;
268
+ position: PresentationProjectionPosition;
269
+ childOffset: Readonly<{ left: number; top: number }>;
270
+ childExtent: Readonly<{ width: number; height: number }>;
271
+ rotation?: number;
272
+ flipHorizontal?: boolean;
273
+ flipVertical?: boolean;
274
+ children: readonly PresentationProjectionElement[];
275
+ }> &
276
+ PresentationProjectionElementMetadata;
277
+
278
+ export type PresentationProjectionConnector = Readonly<{
279
+ kind: "connector";
280
+ id: string;
281
+ name: string;
282
+ connectorKind: "straight" | "elbow" | "curved";
283
+ position: PresentationProjectionPosition;
284
+ start: Readonly<{ nodeId: string | null; x: number; y: number }>;
285
+ end: Readonly<{ nodeId: string | null; x: number; y: number }>;
286
+ line?: PresentationProjectionLine;
287
+ }> &
288
+ PresentationProjectionElementMetadata;
289
+
290
+ export type PresentationProjectionElement =
291
+ | PresentationProjectionShape
292
+ | PresentationProjectionChart
293
+ | PresentationProjectionImage
294
+ | PresentationProjectionTable
295
+ | PresentationProjectionGroup
296
+ | PresentationProjectionConnector;
297
+
298
+ export type PresentationSlideProjection = Readonly<{
299
+ id: string;
300
+ title?: string;
301
+ background?: PresentationProjectionFill;
302
+ elements: readonly PresentationProjectionElement[];
303
+ layout?: Readonly<{
304
+ id: string;
305
+ name: string;
306
+ masterId: string | null;
307
+ background: PresentationProjectionFill;
308
+ }> | null;
309
+ notes?: PresentationProjectionRichText | null;
310
+ }>;
311
+
312
+ /** Immutable render/edit projection; the host keeps the canonical deck off the React thread. */
313
+ export type PresentationEditorProjection = Readonly<{
314
+ revision: string | number;
315
+ slideSize: Readonly<{ width: number; height: number }>;
316
+ slides: readonly PresentationSlideProjection[];
317
+ }>;
318
+
319
+ export type PresentationProjectionEditorProps = Omit<
320
+ PresentationEditorProps,
321
+ "presentation" | "revision" | "onSlideChange" | "onSelectionChange" | "onCommit"
322
+ > & {
323
+ projection: PresentationEditorProjection;
324
+ commit?: PresentationCommitHandler | undefined;
325
+ onCommandError?: ((error: Error) => void) | undefined;
326
+ onSlideChange?: ((slide: PresentationSlideProjection, index: number) => void) | undefined;
327
+ onSelectionChange?:
328
+ | ((object: PresentationProjectionElement | null, slide: PresentationSlideProjection) => void)
329
+ | undefined;
330
+ };
331
+
332
+ export type PresentationProjectionArtifactSurfaceProps = PresentationProjectionEditorProps & {
333
+ title?: string | undefined;
334
+ subtitle?: ReactNode | undefined;
335
+ actions?: ReactNode | undefined;
336
+ busy?: boolean | undefined;
337
+ };
338
+
339
+ export type PresentationEditorProps = {
340
+ presentation: Presentation;
341
+ /** Increment/change after a host applies an out-of-band model mutation. */
342
+ revision?: string | number | undefined;
343
+ activeSlideId?: string | undefined;
344
+ defaultActiveSlideId?: string | undefined;
345
+ selectedObjectId?: string | null | undefined;
346
+ defaultSelectedObjectId?: string | null | undefined;
347
+ zoom?: number | undefined;
348
+ defaultZoom?: number | undefined;
349
+ readOnly?: boolean | undefined;
350
+ className?: string | undefined;
351
+ onSlideChange?: ((slide: Slide, index: number) => void) | undefined;
352
+ onSelectionChange?: ((object: PresentationObject | null, slide: Slide) => void) | undefined;
353
+ onCommit?: PresentationCommitHandler | undefined;
354
+ onCommandError?: ((error: Error) => void) | undefined;
355
+ onZoomChange?: ((zoom: number) => void) | undefined;
356
+ };
357
+
358
+ export type PresentationArtifactSurfaceProps = PresentationEditorProps & {
359
+ title?: string | undefined;
360
+ subtitle?: ReactNode | undefined;
361
+ actions?: ReactNode | undefined;
362
+ busy?: boolean | undefined;
363
+ };
364
+
365
+ type ViewportMetrics = {
366
+ scrollLeft: number;
367
+ scrollTop: number;
368
+ width: number;
369
+ height: number;
370
+ stageLeft: number;
371
+ stageTop: number;
372
+ };
373
+
374
+ type DragState = {
375
+ pointerId: number;
376
+ mode: "move" | "resize";
377
+ object: PresentationObject;
378
+ rotation: number;
379
+ start: { x: number; y: number };
380
+ before: PresentationPosition;
381
+ };
382
+
383
+ type TextEdit = {
384
+ slideId: string;
385
+ shape: PresentationShape;
386
+ before: string;
387
+ };
388
+
389
+ type DraftPosition = {
390
+ objectId: string;
391
+ position: PresentationPosition;
392
+ };
393
+
394
+ type SpatialIndex = {
395
+ objects: DisplayObject[];
396
+ bounds: PresentationPosition[];
397
+ buckets: Map<string, number[]>;
398
+ overflowObjects: number[];
399
+ };
400
+
401
+ type CanvasImageEntry = {
402
+ image: HTMLImageElement;
403
+ ready: boolean;
404
+ };
405
+
406
+ /** Compatibility adapter for the public authoring model. */
407
+ export function PresentationEditor(props: PresentationEditorProps) {
408
+ return <PresentationEditorCore {...props} />;
409
+ }
410
+
411
+ /**
412
+ * Projection-first editor. It creates only a disposable render view; all
413
+ * durable mutations leave through the async command port.
414
+ */
415
+ export function PresentationProjectionEditor({
416
+ projection,
417
+ commit,
418
+ onCommandError,
419
+ onSlideChange,
420
+ onSelectionChange,
421
+ readOnly = false,
422
+ ...props
423
+ }: PresentationProjectionEditorProps) {
424
+ const view = useMemo(() => presentationViewFromProjection(projection), [projection]);
425
+ return (
426
+ <PresentationEditorCore
427
+ {...props}
428
+ presentation={view.presentation}
429
+ revision={projection.revision}
430
+ readOnly={readOnly || !commit}
431
+ onCommit={
432
+ commit
433
+ ? (command) => commit({ ...command, revision: projection.revision } as PresentationCommit)
434
+ : undefined
435
+ }
436
+ onCommandError={onCommandError}
437
+ onSlideChange={(slide, index) => {
438
+ const projected = view.slideById.get(slide.id);
439
+ if (projected) onSlideChange?.(projected, index);
440
+ }}
441
+ onSelectionChange={(object, slide) => {
442
+ const projectedSlide = view.slideById.get(slide.id);
443
+ if (!projectedSlide) return;
444
+ onSelectionChange?.(
445
+ object ? (view.elementById.get(object.id) ?? null) : null,
446
+ projectedSlide,
447
+ );
448
+ }}
449
+ />
450
+ );
451
+ }
452
+
453
+ function PresentationEditorCore({
454
+ presentation,
455
+ revision,
456
+ activeSlideId: controlledSlideId,
457
+ defaultActiveSlideId,
458
+ selectedObjectId: controlledSelectionId,
459
+ defaultSelectedObjectId = null,
460
+ zoom: controlledZoom,
461
+ defaultZoom = 0.75,
462
+ readOnly = false,
463
+ className,
464
+ onSlideChange,
465
+ onSelectionChange,
466
+ onCommit,
467
+ onCommandError,
468
+ onZoomChange,
469
+ }: PresentationEditorProps) {
470
+ const slides = presentation.slides.items;
471
+ const firstSlideId = slides[0]?.id ?? null;
472
+ const [localSlideId, setLocalSlideId] = useState(
473
+ defaultActiveSlideId ?? controlledSlideId ?? firstSlideId,
474
+ );
475
+ const requestedSlideId = controlledSlideId ?? localSlideId;
476
+ const activeSlide = slides.find((slide) => slide.id === requestedSlideId) ?? slides[0] ?? null;
477
+ const activeIndex = activeSlide ? slides.indexOf(activeSlide) : -1;
478
+
479
+ const [localSelectionId, setLocalSelectionId] = useState<string | null>(defaultSelectedObjectId);
480
+ const selectedId = controlledSelectionId === undefined ? localSelectionId : controlledSelectionId;
481
+ const [localZoom, setLocalZoom] = useState(() => clampZoom(defaultZoom));
482
+ const zoom = clampZoom(controlledZoom ?? localZoom);
483
+ const [fitMode, setFitMode] = useState(false);
484
+ const [viewportMeasured, setViewportMeasured] = useState(false);
485
+ const [modelEpoch, setModelEpoch] = useState(0);
486
+ const [pendingCommands, setPendingCommands] = useState(0);
487
+ const [commandFailure, setCommandFailure] = useState<{
488
+ message: string;
489
+ retry: () => void;
490
+ } | null>(null);
491
+ const [rail, setRail] = useState({
492
+ scrollTop: 0,
493
+ height: DEFAULT_RAIL_HEIGHT,
494
+ });
495
+ const [viewport, setViewport] = useState<ViewportMetrics>({
496
+ scrollLeft: 0,
497
+ scrollTop: 0,
498
+ ...DEFAULT_VIEWPORT,
499
+ stageLeft: 0,
500
+ stageTop: 0,
501
+ });
502
+ const [draftPosition, setDraftPosition] = useState<DraftPosition | null>(null);
503
+ const [textEdit, setTextEdit] = useState<TextEdit | null>(null);
504
+ const [textDraft, setTextDraft] = useState("");
505
+ const [paintEpoch, setPaintEpoch] = useState(0);
506
+ const railRef = useRef<HTMLDivElement>(null);
507
+ const viewportRef = useRef<HTMLDivElement>(null);
508
+ const stageRef = useRef<HTMLDivElement>(null);
509
+ const canvasRef = useRef<HTMLCanvasElement>(null);
510
+ const overlayRef = useRef<SVGSVGElement>(null);
511
+ const editorRef = useRef<HTMLTextAreaElement>(null);
512
+ const dragRef = useRef<DragState | null>(null);
513
+ const textEditRef = useRef<TextEdit | null>(null);
514
+ const imageCacheRef = useRef(new Map<string, CanvasImageEntry>());
515
+ const mountedRef = useRef(true);
516
+ const commandScopeRef = useRef(0);
517
+ const commandVersionsRef = useRef(new Map<string, number>());
518
+ const pendingSlideIdRef = useRef<string | null>(null);
519
+ const pendingObjectIdRef = useRef<string | null>(null);
520
+ const domId = useId().replaceAll(":", "");
521
+ const selectionProxyId = `${domId}-presentation-selection`;
522
+ const instructionsId = `${domId}-presentation-instructions`;
523
+
524
+ const objects = useMemo(
525
+ () => (activeSlide ? listSlideObjects(activeSlide) : []),
526
+ // revision is a deliberate host invalidation token; modelEpoch covers local commits.
527
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
528
+ [activeSlide, modelEpoch, revision],
529
+ );
530
+ const objectById = useMemo(
531
+ () => new Map(objects.map((entry) => [entry.id, entry] as const)),
532
+ [objects],
533
+ );
534
+ const objectIndexById = useMemo(
535
+ () => new Map(objects.map((entry, index) => [entry.id, index] as const)),
536
+ [objects],
537
+ );
538
+ const selected = selectedId ? (objectById.get(selectedId) ?? null) : null;
539
+ const spatialIndex = useMemo(
540
+ () => buildSpatialIndex(objects, presentation.slideSize),
541
+ [objects, presentation.slideSize],
542
+ );
543
+ const effectiveSelectedPosition =
544
+ selected && draftPosition?.objectId === selected.id
545
+ ? draftPosition.position
546
+ : selected
547
+ ? copyPosition(selected.object.position)
548
+ : null;
549
+
550
+ useEffect(() => {
551
+ const pendingSlideId = pendingSlideIdRef.current;
552
+ if (
553
+ pendingSlideId &&
554
+ controlledSlideId === undefined &&
555
+ slides.some((slide) => slide.id === pendingSlideId)
556
+ ) {
557
+ pendingSlideIdRef.current = null;
558
+ setLocalSlideId(pendingSlideId);
559
+ return;
560
+ }
561
+ if (
562
+ activeSlide &&
563
+ requestedSlideId !== activeSlide.id &&
564
+ controlledSlideId === undefined &&
565
+ pendingSlideId === null
566
+ ) {
567
+ setLocalSlideId(activeSlide.id);
568
+ }
569
+ }, [activeSlide, controlledSlideId, requestedSlideId, slides]);
570
+
571
+ useEffect(() => {
572
+ if (selectedId && !objectById.has(selectedId) && controlledSelectionId === undefined) {
573
+ setLocalSelectionId(null);
574
+ }
575
+ }, [controlledSelectionId, objectById, selectedId]);
576
+
577
+ useEffect(() => {
578
+ const pendingObjectId = pendingObjectIdRef.current;
579
+ if (pendingObjectId && controlledSelectionId === undefined && objectById.has(pendingObjectId)) {
580
+ pendingObjectIdRef.current = null;
581
+ setLocalSelectionId(pendingObjectId);
582
+ }
583
+ }, [controlledSelectionId, objectById]);
584
+
585
+ useLayoutEffect(() => {
586
+ const element = railRef.current;
587
+ if (!element || typeof ResizeObserver === "undefined") return;
588
+ const measure = () =>
589
+ setRail((current) => ({
590
+ scrollTop: element.scrollTop,
591
+ height: element.clientHeight || current.height,
592
+ }));
593
+ measure();
594
+ const observer = new ResizeObserver(measure);
595
+ observer.observe(element);
596
+ return () => observer.disconnect();
597
+ }, []);
598
+
599
+ useLayoutEffect(() => {
600
+ const element = viewportRef.current;
601
+ if (!element) return;
602
+ const measure = () => {
603
+ const stage = stageRef.current;
604
+ const measuredWidth = element.clientWidth;
605
+ const measuredHeight = element.clientHeight;
606
+ const next: ViewportMetrics = {
607
+ scrollLeft: element.scrollLeft,
608
+ scrollTop: element.scrollTop,
609
+ width: measuredWidth || DEFAULT_VIEWPORT.width,
610
+ height: measuredHeight || DEFAULT_VIEWPORT.height,
611
+ stageLeft: stage?.offsetLeft ?? 0,
612
+ stageTop: stage?.offsetTop ?? 0,
613
+ };
614
+ setViewport((current) => (sameViewport(current, next) ? current : next));
615
+ if (measuredWidth > 0 && measuredHeight > 0) setViewportMeasured(true);
616
+ };
617
+ measure();
618
+ if (typeof ResizeObserver === "undefined") return;
619
+ const observer = new ResizeObserver(measure);
620
+ observer.observe(element);
621
+ if (stageRef.current) observer.observe(stageRef.current);
622
+ return () => observer.disconnect();
623
+ }, [activeSlide?.id, presentation.slideSize.height, presentation.slideSize.width, zoom]);
624
+
625
+ useLayoutEffect(() => {
626
+ const imageCache = imageCacheRef.current;
627
+ return () => disposeCanvasImageCache(imageCache);
628
+ }, [activeSlide]);
629
+
630
+ useEffect(() => {
631
+ const edit = textEditRef.current;
632
+ const currentEntry = edit ? objectById.get(edit.shape.id) : null;
633
+ if (
634
+ edit &&
635
+ activeSlide?.id === edit.slideId &&
636
+ currentEntry?.kind === "shape" &&
637
+ !currentEntry.readOnly &&
638
+ (currentEntry.object as PresentationShape).geometry !== "line"
639
+ ) {
640
+ const rebound = {
641
+ ...edit,
642
+ shape: currentEntry.object as PresentationShape,
643
+ };
644
+ textEditRef.current = rebound;
645
+ setTextEdit(rebound);
646
+ } else {
647
+ textEditRef.current = null;
648
+ setTextEdit(null);
649
+ }
650
+ }, [activeSlide?.id, objectById]);
651
+
652
+ useEffect(() => {
653
+ dragRef.current = null;
654
+ setDraftPosition(null);
655
+ commandScopeRef.current += 1;
656
+ commandVersionsRef.current.clear();
657
+ setPendingCommands(0);
658
+ setCommandFailure(null);
659
+ }, [activeSlide?.id, revision]);
660
+
661
+ useEffect(() => {
662
+ const edit = textEditRef.current;
663
+ if (edit && edit.shape.id !== selectedId) {
664
+ textEditRef.current = null;
665
+ setTextEdit(null);
666
+ }
667
+ const drag = dragRef.current;
668
+ if (drag && drag.object.id !== selectedId) {
669
+ dragRef.current = null;
670
+ setDraftPosition(null);
671
+ }
672
+ }, [selectedId]);
673
+
674
+ useEffect(() => {
675
+ if (!readOnly) return;
676
+ dragRef.current = null;
677
+ setDraftPosition(null);
678
+ textEditRef.current = null;
679
+ setTextEdit(null);
680
+ }, [readOnly]);
681
+
682
+ useEffect(() => {
683
+ mountedRef.current = true;
684
+ return () => {
685
+ mountedRef.current = false;
686
+ dragRef.current = null;
687
+ textEditRef.current = null;
688
+ };
689
+ }, []);
690
+
691
+ useEffect(() => {
692
+ const element = railRef.current;
693
+ if (!element || activeIndex < 0) return;
694
+ const top = activeIndex * RAIL_ITEM_HEIGHT;
695
+ const bottom = top + RAIL_ITEM_HEIGHT;
696
+ const height = element.clientHeight || rail.height;
697
+ if (top >= element.scrollTop && bottom <= element.scrollTop + height) return;
698
+ const nextTop = Math.max(0, top - height / 2);
699
+ element.scrollTop = nextTop;
700
+ setRail((current) => ({ ...current, scrollTop: nextTop }));
701
+ }, [activeIndex, rail.height]);
702
+
703
+ const visibleSlideRegion = useMemo<PresentationPosition>(() => {
704
+ const slideWidth = presentation.slideSize.width;
705
+ const slideHeight = presentation.slideSize.height;
706
+ const stageWidth = slideWidth * zoom;
707
+ const stageHeight = slideHeight * zoom;
708
+ if (stageWidth <= viewport.width && stageHeight <= viewport.height) {
709
+ return { left: 0, top: 0, width: slideWidth, height: slideHeight };
710
+ }
711
+ const overscan = CANVAS_OVERSCAN_PX / zoom;
712
+ const visibleLeft = Math.max(0, (viewport.scrollLeft - viewport.stageLeft) / zoom);
713
+ const visibleTop = Math.max(0, (viewport.scrollTop - viewport.stageTop) / zoom);
714
+ const left = Math.max(0, visibleLeft - overscan);
715
+ const top = Math.max(0, visibleTop - overscan);
716
+ return {
717
+ left,
718
+ top,
719
+ width: Math.max(0, Math.min(slideWidth - left, viewport.width / zoom + overscan * 2)),
720
+ height: Math.max(0, Math.min(slideHeight - top, viewport.height / zoom + overscan * 2)),
721
+ };
722
+ }, [presentation.slideSize.height, presentation.slideSize.width, viewport, zoom]);
723
+
724
+ const visibleObjectIndexes = useMemo(() => {
725
+ if (!activeSlide) return [];
726
+ if (
727
+ visibleSlideRegion.left === 0 &&
728
+ visibleSlideRegion.top === 0 &&
729
+ visibleSlideRegion.width === presentation.slideSize.width &&
730
+ visibleSlideRegion.height === presentation.slideSize.height
731
+ ) {
732
+ return objects.map((_, index) => index);
733
+ }
734
+ const visible = querySpatialIndex(spatialIndex, visibleSlideRegion);
735
+ const selectedIndex = selectedId ? (objectIndexById.get(selectedId) ?? -1) : -1;
736
+ if (selectedIndex >= 0 && !visible.includes(selectedIndex)) visible.push(selectedIndex);
737
+ const draftIndex = draftPosition ? (objectIndexById.get(draftPosition.objectId) ?? -1) : -1;
738
+ if (draftIndex >= 0 && !visible.includes(draftIndex)) visible.push(draftIndex);
739
+ return visible.sort((a, b) => a - b);
740
+ }, [
741
+ activeSlide,
742
+ objects,
743
+ objectIndexById,
744
+ presentation.slideSize,
745
+ draftPosition,
746
+ selectedId,
747
+ spatialIndex,
748
+ visibleSlideRegion,
749
+ ]);
750
+
751
+ useLayoutEffect(() => {
752
+ const canvas = canvasRef.current;
753
+ if (!canvas || !activeSlide) return;
754
+ paintSlideCanvas({
755
+ canvas,
756
+ slide: activeSlide,
757
+ slideSize: presentation.slideSize,
758
+ zoom,
759
+ objects,
760
+ objectIndexes: visibleObjectIndexes,
761
+ renderRegion: visibleSlideRegion,
762
+ draftPosition,
763
+ imageCache: imageCacheRef.current,
764
+ requestRepaint: () => setPaintEpoch((value) => value + 1),
765
+ });
766
+ }, [
767
+ activeSlide,
768
+ draftPosition,
769
+ objects,
770
+ paintEpoch,
771
+ presentation.slideSize,
772
+ visibleObjectIndexes,
773
+ visibleSlideRegion,
774
+ zoom,
775
+ ]);
776
+
777
+ const selectObject = useCallback(
778
+ (entry: DisplayObject | null) => {
779
+ textEditRef.current = null;
780
+ setTextEdit(null);
781
+ setDraftPosition(null);
782
+ if (controlledSelectionId === undefined) setLocalSelectionId(entry?.id ?? null);
783
+ if (activeSlide) onSelectionChange?.(entry?.object ?? null, activeSlide);
784
+ },
785
+ [activeSlide, controlledSelectionId, onSelectionChange],
786
+ );
787
+
788
+ const selectSlide = useCallback(
789
+ (slide: Slide, index: number) => {
790
+ if (controlledSlideId === undefined) setLocalSlideId(slide.id);
791
+ selectObject(null);
792
+ onSlideChange?.(slide, index);
793
+ },
794
+ [controlledSlideId, onSlideChange, selectObject],
795
+ );
796
+
797
+ const changeZoom = useCallback(
798
+ (next: number) => {
799
+ const clamped = clampZoom(next);
800
+ setFitMode(false);
801
+ if (clamped === zoom) return;
802
+ if (controlledZoom === undefined) setLocalZoom(clamped);
803
+ onZoomChange?.(clamped);
804
+ },
805
+ [controlledZoom, onZoomChange, zoom],
806
+ );
807
+
808
+ const applyFittedZoom = useCallback(() => {
809
+ const next = fittedPresentationZoom(viewport, presentation.slideSize);
810
+ if (next === zoom) return;
811
+ if (controlledZoom === undefined) setLocalZoom(next);
812
+ onZoomChange?.(next);
813
+ }, [controlledZoom, onZoomChange, presentation.slideSize, viewport, zoom]);
814
+
815
+ const fitSlide = useCallback(() => {
816
+ setFitMode(true);
817
+ applyFittedZoom();
818
+ }, [applyFittedZoom]);
819
+
820
+ useLayoutEffect(() => {
821
+ if (!viewportMeasured || controlledZoom !== undefined) return;
822
+ const fitted = fittedPresentationZoom(viewport, presentation.slideSize);
823
+ if (!fitMode && zoom <= fitted) return;
824
+ if (!fitMode) setFitMode(true);
825
+ applyFittedZoom();
826
+ }, [
827
+ applyFittedZoom,
828
+ controlledZoom,
829
+ fitMode,
830
+ presentation.slideSize,
831
+ viewport,
832
+ viewportMeasured,
833
+ zoom,
834
+ ]);
835
+
836
+ const runCommit = useCallback(
837
+ (key: string, command: PresentationCommit, rollback: () => void, retry: () => void) => {
838
+ if (!onCommit) return;
839
+ const version = (commandVersionsRef.current.get(key) ?? 0) + 1;
840
+ commandVersionsRef.current.set(key, version);
841
+ const scope = commandScopeRef.current;
842
+ setCommandFailure(null);
843
+ let result: void | Promise<void>;
844
+ try {
845
+ result = onCommit(command);
846
+ } catch (cause) {
847
+ if (
848
+ !mountedRef.current ||
849
+ commandScopeRef.current !== scope ||
850
+ commandVersionsRef.current.get(key) !== version
851
+ ) {
852
+ return;
853
+ }
854
+ rollback();
855
+ const error = asPresentationError(cause);
856
+ setCommandFailure({ message: error.message, retry });
857
+ onCommandError?.(error);
858
+ return;
859
+ }
860
+ if (!result || typeof result.then !== "function") return;
861
+ setPendingCommands((current) => current + 1);
862
+ void Promise.resolve(result).then(
863
+ () => {
864
+ if (!mountedRef.current || commandScopeRef.current !== scope) return;
865
+ setPendingCommands((current) => Math.max(0, current - 1));
866
+ },
867
+ (cause) => {
868
+ if (!mountedRef.current || commandScopeRef.current !== scope) return;
869
+ setPendingCommands((current) => Math.max(0, current - 1));
870
+ if (commandVersionsRef.current.get(key) !== version) return;
871
+ rollback();
872
+ const error = asPresentationError(cause);
873
+ setCommandFailure({ message: error.message, retry });
874
+ onCommandError?.(error);
875
+ },
876
+ );
877
+ },
878
+ [onCommandError, onCommit],
879
+ );
880
+
881
+ const commitPosition = useCallback(
882
+ (entry: DisplayObject, kind: "move" | "resize", next: PresentationPosition) => {
883
+ if (!activeSlide || readOnly || entry.readOnly) return;
884
+ const before = copyPosition(entry.object.position);
885
+ const after = normalizeEditorPosition(next);
886
+ if (samePosition(before, after)) return;
887
+ entry.object.position = after;
888
+ setModelEpoch((value) => value + 1);
889
+ const command: PresentationCommit = {
890
+ kind,
891
+ slideId: activeSlide.id,
892
+ objectId: entry.id,
893
+ before,
894
+ after,
895
+ };
896
+ const submit = () =>
897
+ runCommit(
898
+ `position:${activeSlide.id}:${entry.id}`,
899
+ command,
900
+ () => {
901
+ entry.object.position = before;
902
+ setModelEpoch((value) => value + 1);
903
+ },
904
+ () => {
905
+ entry.object.position = after;
906
+ setModelEpoch((value) => value + 1);
907
+ submit();
908
+ },
909
+ );
910
+ submit();
911
+ },
912
+ [activeSlide, readOnly, runCommit],
913
+ );
914
+
915
+ const insertSlide = useCallback(() => {
916
+ if (readOnly) return;
917
+ const slideId = randomPresentationId();
918
+ const index = activeIndex < 0 ? slides.length : activeIndex + 1;
919
+ const command: PresentationCommit = { kind: "slide-insert", slideId, index };
920
+ const submit = () => {
921
+ pendingSlideIdRef.current = slideId;
922
+ runCommit(
923
+ `slide-insert:${slideId}`,
924
+ command,
925
+ () => {
926
+ if (pendingSlideIdRef.current === slideId) pendingSlideIdRef.current = null;
927
+ },
928
+ submit,
929
+ );
930
+ };
931
+ submit();
932
+ }, [activeIndex, readOnly, runCommit, slides.length]);
933
+
934
+ const deleteSlide = useCallback(() => {
935
+ if (readOnly || !activeSlide) return;
936
+ const command: PresentationCommit = {
937
+ kind: "slide-delete",
938
+ slideId: activeSlide.id,
939
+ index: activeIndex,
940
+ };
941
+ const submit = () =>
942
+ runCommit(`slide-delete:${activeSlide.id}`, command, () => undefined, submit);
943
+ submit();
944
+ }, [activeIndex, activeSlide, readOnly, runCommit]);
945
+
946
+ const insertTextBox = useCallback(() => {
947
+ if (readOnly || !activeSlide) return;
948
+ const objectId = randomPresentationId();
949
+ const position = nextTextBoxPosition(objects, presentation.slideSize);
950
+ const command: PresentationCommit = {
951
+ kind: "object-insert",
952
+ slideId: activeSlide.id,
953
+ objectId,
954
+ index: activeSlide.elements.length,
955
+ position,
956
+ };
957
+ const submit = () => {
958
+ pendingObjectIdRef.current = objectId;
959
+ runCommit(
960
+ `object-insert:${activeSlide.id}:${objectId}`,
961
+ command,
962
+ () => {
963
+ if (pendingObjectIdRef.current === objectId) pendingObjectIdRef.current = null;
964
+ },
965
+ submit,
966
+ );
967
+ };
968
+ submit();
969
+ }, [activeSlide, objects, presentation.slideSize, readOnly, runCommit]);
970
+
971
+ const deleteObject = useCallback(() => {
972
+ if (readOnly || !activeSlide || !selected || selected.readOnly) return;
973
+ const command: PresentationCommit = {
974
+ kind: "object-delete",
975
+ slideId: activeSlide.id,
976
+ objectId: selected.id,
977
+ };
978
+ const submit = () =>
979
+ runCommit(`object-delete:${activeSlide.id}:${selected.id}`, command, () => undefined, submit);
980
+ submit();
981
+ }, [activeSlide, readOnly, runCommit, selected]);
982
+
983
+ const startTextEdit = useCallback(
984
+ (entry: DisplayObject | null) => {
985
+ if (readOnly || !entry || entry.readOnly || entry.kind !== "shape") return;
986
+ const shape = entry.object as PresentationShape;
987
+ if (shape.geometry === "line") return;
988
+ const before = shape.text.toString();
989
+ if (!activeSlide) return;
990
+ const edit = { slideId: activeSlide.id, shape, before };
991
+ textEditRef.current = edit;
992
+ setTextEdit(edit);
993
+ setTextDraft(before);
994
+ },
995
+ [activeSlide, readOnly],
996
+ );
997
+
998
+ useEffect(() => {
999
+ if (!textEdit) return;
1000
+ editorRef.current?.focus();
1001
+ editorRef.current?.select();
1002
+ }, [textEdit]);
1003
+
1004
+ const finishTextEdit = useCallback(
1005
+ (cancel = false) => {
1006
+ const edit = textEditRef.current;
1007
+ if (!edit) return;
1008
+ textEditRef.current = null;
1009
+ setTextEdit(null);
1010
+ const { shape, before, slideId } = edit;
1011
+ if (!activeSlide || activeSlide.id !== slideId) return;
1012
+ if (cancel || readOnly || before === textDraft) return;
1013
+ shape.text.set(textDraft);
1014
+ setModelEpoch((value) => value + 1);
1015
+ const command: PresentationCommit = {
1016
+ kind: "text",
1017
+ slideId,
1018
+ objectId: shape.id,
1019
+ before,
1020
+ after: textDraft,
1021
+ };
1022
+ const submit = () =>
1023
+ runCommit(
1024
+ `text:${slideId}:${shape.id}`,
1025
+ command,
1026
+ () => {
1027
+ shape.text.set(before);
1028
+ setModelEpoch((value) => value + 1);
1029
+ },
1030
+ () => {
1031
+ shape.text.set(textDraft);
1032
+ setModelEpoch((value) => value + 1);
1033
+ submit();
1034
+ },
1035
+ );
1036
+ submit();
1037
+ },
1038
+ [activeSlide, readOnly, runCommit, textDraft],
1039
+ );
1040
+
1041
+ const cycleObject = useCallback(
1042
+ (direction: -1 | 1) => {
1043
+ if (objects.length === 0) return;
1044
+ const current = selected ? objects.indexOf(selected) : direction === 1 ? -1 : 0;
1045
+ const next = (current + direction + objects.length) % objects.length;
1046
+ selectObject(objects[next] ?? null);
1047
+ },
1048
+ [objects, selectObject, selected],
1049
+ );
1050
+
1051
+ const changeSlideBy = useCallback(
1052
+ (amount: -1 | 1) => {
1053
+ if (activeIndex < 0) return;
1054
+ const nextIndex = Math.max(0, Math.min(slides.length - 1, activeIndex + amount));
1055
+ const next = slides[nextIndex];
1056
+ if (next && next !== activeSlide) selectSlide(next, nextIndex);
1057
+ },
1058
+ [activeIndex, activeSlide, selectSlide, slides],
1059
+ );
1060
+
1061
+ const handleCanvasKeyDown = useCallback(
1062
+ (event: ReactKeyboardEvent<SVGSVGElement>) => {
1063
+ if (event.metaKey || event.ctrlKey) {
1064
+ if (event.key === "+" || event.key === "=") {
1065
+ event.preventDefault();
1066
+ changeZoom(zoom * 1.15);
1067
+ } else if (event.key === "-") {
1068
+ event.preventDefault();
1069
+ changeZoom(zoom / 1.15);
1070
+ } else if (event.key === "0") {
1071
+ event.preventDefault();
1072
+ fitSlide();
1073
+ }
1074
+ return;
1075
+ }
1076
+ if (event.key === "]") {
1077
+ event.preventDefault();
1078
+ cycleObject(1);
1079
+ return;
1080
+ }
1081
+ if (event.key === "[") {
1082
+ event.preventDefault();
1083
+ cycleObject(-1);
1084
+ return;
1085
+ }
1086
+ if (event.key === "PageDown") {
1087
+ event.preventDefault();
1088
+ changeSlideBy(1);
1089
+ return;
1090
+ }
1091
+ if (event.key === "PageUp") {
1092
+ event.preventDefault();
1093
+ changeSlideBy(-1);
1094
+ return;
1095
+ }
1096
+ if (event.key === "Escape") {
1097
+ event.preventDefault();
1098
+ selectObject(null);
1099
+ return;
1100
+ }
1101
+ if (event.key === "Enter" || event.key === "F2") {
1102
+ if (selected?.kind === "shape") {
1103
+ event.preventDefault();
1104
+ startTextEdit(selected);
1105
+ }
1106
+ return;
1107
+ }
1108
+ if (!selected || !event.key.startsWith("Arrow")) return;
1109
+ event.preventDefault();
1110
+ if (readOnly || selected.readOnly) return;
1111
+ const amount = event.shiftKey ? 10 : 1;
1112
+ const dx = event.key === "ArrowLeft" ? -amount : event.key === "ArrowRight" ? amount : 0;
1113
+ const dy = event.key === "ArrowUp" ? -amount : event.key === "ArrowDown" ? amount : 0;
1114
+ const before = selected.object.position;
1115
+ commitPosition(
1116
+ selected,
1117
+ event.altKey ? "resize" : "move",
1118
+ event.altKey
1119
+ ? {
1120
+ ...before,
1121
+ width: Math.max(8, before.width + dx),
1122
+ height: Math.max(8, before.height + dy),
1123
+ }
1124
+ : { ...before, left: before.left + dx, top: before.top + dy },
1125
+ );
1126
+ },
1127
+ [
1128
+ changeSlideBy,
1129
+ changeZoom,
1130
+ commitPosition,
1131
+ cycleObject,
1132
+ fitSlide,
1133
+ readOnly,
1134
+ selectObject,
1135
+ selected,
1136
+ startTextEdit,
1137
+ zoom,
1138
+ ],
1139
+ );
1140
+
1141
+ const pointFromPointer = useCallback(
1142
+ (event: ReactPointerEvent<SVGElement>): { x: number; y: number } => {
1143
+ const rect =
1144
+ overlayRef.current?.getBoundingClientRect() ?? event.currentTarget.getBoundingClientRect();
1145
+ const width = rect.width || presentation.slideSize.width * zoom;
1146
+ const height = rect.height || presentation.slideSize.height * zoom;
1147
+ return {
1148
+ x: ((event.clientX - rect.left) / Math.max(1, width)) * presentation.slideSize.width,
1149
+ y: ((event.clientY - rect.top) / Math.max(1, height)) * presentation.slideSize.height,
1150
+ };
1151
+ },
1152
+ [presentation.slideSize.height, presentation.slideSize.width, zoom],
1153
+ );
1154
+
1155
+ const beginDrag = useCallback(
1156
+ (event: ReactPointerEvent<SVGElement>, entry: DisplayObject, mode: "move" | "resize") => {
1157
+ event.preventDefault();
1158
+ event.stopPropagation();
1159
+ selectObject(entry);
1160
+ if (readOnly || entry.readOnly) return;
1161
+ const start = pointFromPointer(event);
1162
+ const before = copyPosition(entry.object.position);
1163
+ dragRef.current = {
1164
+ pointerId: event.pointerId,
1165
+ mode,
1166
+ object: entry.object,
1167
+ rotation: objectRotation(entry),
1168
+ start,
1169
+ before,
1170
+ };
1171
+ setDraftPosition({ objectId: entry.id, position: before });
1172
+ overlayRef.current?.setPointerCapture?.(event.pointerId);
1173
+ },
1174
+ [pointFromPointer, readOnly, selectObject],
1175
+ );
1176
+
1177
+ const handlePointerDown = useCallback(
1178
+ (event: ReactPointerEvent<SVGSVGElement>) => {
1179
+ if (event.button !== 0) return;
1180
+ const point = pointFromPointer(event);
1181
+ const hit = hitTest(spatialIndex, point.x, point.y);
1182
+ if (!hit) {
1183
+ selectObject(null);
1184
+ return;
1185
+ }
1186
+ beginDrag(event, hit, "move");
1187
+ },
1188
+ [beginDrag, pointFromPointer, selectObject, spatialIndex],
1189
+ );
1190
+
1191
+ const handlePointerMove = useCallback(
1192
+ (event: ReactPointerEvent<SVGSVGElement>) => {
1193
+ const drag = dragRef.current;
1194
+ if (!drag || drag.pointerId !== event.pointerId) return;
1195
+ const point = pointFromPointer(event);
1196
+ setDraftPosition({ objectId: drag.object.id, position: positionFromDrag(drag, point) });
1197
+ },
1198
+ [pointFromPointer],
1199
+ );
1200
+
1201
+ const finishDrag = useCallback(
1202
+ (event: ReactPointerEvent<SVGSVGElement>, cancel = false) => {
1203
+ const drag = dragRef.current;
1204
+ if (!drag || drag.pointerId !== event.pointerId) return;
1205
+ dragRef.current = null;
1206
+ const captureTarget = overlayRef.current;
1207
+ if (captureTarget?.hasPointerCapture?.(event.pointerId)) {
1208
+ captureTarget.releasePointerCapture(event.pointerId);
1209
+ }
1210
+ setDraftPosition(null);
1211
+ if (cancel) return;
1212
+ const entry = objectById.get(drag.object.id);
1213
+ if (!entry) return;
1214
+ const point = pointFromPointer(event);
1215
+ commitPosition(entry, drag.mode, positionFromDrag(drag, point));
1216
+ },
1217
+ [commitPosition, objectById, pointFromPointer],
1218
+ );
1219
+
1220
+ const handleLostPointerCapture = useCallback((event: ReactPointerEvent<SVGSVGElement>) => {
1221
+ const drag = dragRef.current;
1222
+ if (!drag || drag.pointerId !== event.pointerId) return;
1223
+ dragRef.current = null;
1224
+ setDraftPosition(null);
1225
+ }, []);
1226
+
1227
+ const railStart = Math.max(0, Math.floor(rail.scrollTop / RAIL_ITEM_HEIGHT) - RAIL_OVERSCAN);
1228
+ const railEnd = Math.min(
1229
+ slides.length,
1230
+ Math.ceil((rail.scrollTop + rail.height) / RAIL_ITEM_HEIGHT) + RAIL_OVERSCAN,
1231
+ );
1232
+ const stageWidth = presentation.slideSize.width * zoom;
1233
+ const stageHeight = presentation.slideSize.height * zoom;
1234
+
1235
+ return (
1236
+ <div
1237
+ className={cn("flex size-full min-w-0 flex-col bg-og-surface-1", className)}
1238
+ style={{ minHeight: "22rem" }}
1239
+ data-og-presentation-editor
1240
+ data-og-painted-object-count={visibleObjectIndexes.length}
1241
+ data-og-command-state={commandFailure ? "error" : pendingCommands > 0 ? "pending" : "idle"}
1242
+ aria-busy={pendingCommands > 0 ? "true" : undefined}
1243
+ >
1244
+ <div
1245
+ role="toolbar"
1246
+ aria-label="Presentation controls"
1247
+ className="flex h-10 shrink-0 items-center border-b border-og-border bg-og-surface-2"
1248
+ >
1249
+ <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
1250
+ <button
1251
+ type="button"
1252
+ aria-label="Previous slide"
1253
+ disabled={activeIndex <= 0}
1254
+ onClick={() => changeSlideBy(-1)}
1255
+ className="grid size-7 shrink-0 place-items-center rounded-og-sm text-og-fg-muted hover:bg-og-surface-3 hover:text-og-fg disabled:opacity-35 [&>svg]:size-3.5"
1256
+ >
1257
+ <ChevronLeftIcon />
1258
+ </button>
1259
+ <span className="min-w-16 shrink-0 text-center text-og-xs tabular-nums text-og-fg-muted">
1260
+ {activeIndex >= 0 ? `${activeIndex + 1} / ${slides.length}` : `0 / ${slides.length}`}
1261
+ </span>
1262
+ <button
1263
+ type="button"
1264
+ aria-label="Next slide"
1265
+ disabled={activeIndex < 0 || activeIndex >= slides.length - 1}
1266
+ onClick={() => changeSlideBy(1)}
1267
+ className="grid size-7 shrink-0 place-items-center rounded-og-sm text-og-fg-muted hover:bg-og-surface-3 hover:text-og-fg disabled:opacity-35 [&>svg]:size-3.5"
1268
+ >
1269
+ <ChevronRightIcon />
1270
+ </button>
1271
+ <button
1272
+ type="button"
1273
+ aria-label="Add slide"
1274
+ disabled={readOnly}
1275
+ onClick={insertSlide}
1276
+ className="ml-0.5 grid size-7 shrink-0 place-items-center rounded-og-sm text-og-fg-muted hover:bg-og-surface-3 hover:text-og-fg disabled:opacity-35 [&>svg]:size-3.5"
1277
+ >
1278
+ <FilePlus2Icon />
1279
+ </button>
1280
+ <button
1281
+ type="button"
1282
+ aria-label="Delete slide"
1283
+ disabled={readOnly || !activeSlide}
1284
+ onClick={deleteSlide}
1285
+ className="grid size-7 shrink-0 place-items-center rounded-og-sm text-og-fg-muted hover:bg-og-surface-3 hover:text-og-status-failed disabled:opacity-35 [&>svg]:size-3.5"
1286
+ >
1287
+ <Trash2Icon />
1288
+ </button>
1289
+ <span className="mx-1 h-4 w-px shrink-0 bg-og-border" />
1290
+ <button
1291
+ type="button"
1292
+ aria-label="Zoom out"
1293
+ onClick={() => changeZoom(zoom / 1.15)}
1294
+ className="grid size-7 shrink-0 place-items-center rounded-og-sm text-og-fg-muted hover:bg-og-surface-3 hover:text-og-fg [&>svg]:size-3.5"
1295
+ >
1296
+ <MinusIcon />
1297
+ </button>
1298
+ <span className="min-w-11 shrink-0 text-center text-og-xs tabular-nums text-og-fg-muted">
1299
+ {Math.round(zoom * 100)}%
1300
+ </span>
1301
+ <button
1302
+ type="button"
1303
+ aria-label="Zoom in"
1304
+ onClick={() => changeZoom(zoom * 1.15)}
1305
+ className="grid size-7 shrink-0 place-items-center rounded-og-sm text-og-fg-muted hover:bg-og-surface-3 hover:text-og-fg [&>svg]:size-3.5"
1306
+ >
1307
+ <PlusIcon />
1308
+ </button>
1309
+ <button
1310
+ type="button"
1311
+ aria-label="Fit slide"
1312
+ onClick={fitSlide}
1313
+ className="ml-0.5 grid size-7 shrink-0 place-items-center rounded-og-sm text-og-fg-muted hover:bg-og-surface-3 hover:text-og-fg [&>svg]:size-3.5"
1314
+ >
1315
+ <ExpandIcon />
1316
+ </button>
1317
+ <span className="mx-1 h-4 w-px shrink-0 bg-og-border" />
1318
+ <button
1319
+ type="button"
1320
+ aria-label="Add text box"
1321
+ disabled={readOnly || !activeSlide}
1322
+ onClick={insertTextBox}
1323
+ className="grid size-7 shrink-0 place-items-center rounded-og-sm text-og-fg-muted hover:bg-og-surface-3 hover:text-og-fg disabled:opacity-35 [&>svg]:size-3.5"
1324
+ >
1325
+ <TypeIcon />
1326
+ </button>
1327
+ <button
1328
+ type="button"
1329
+ aria-label="Delete selected object"
1330
+ disabled={readOnly || !selected || selected.readOnly}
1331
+ onClick={deleteObject}
1332
+ className="grid size-7 shrink-0 place-items-center rounded-og-sm text-og-fg-muted hover:bg-og-surface-3 hover:text-og-status-failed disabled:opacity-35 [&>svg]:size-3.5"
1333
+ >
1334
+ <Trash2Icon />
1335
+ </button>
1336
+ </div>
1337
+ <span
1338
+ className="hidden max-w-44 shrink-0 truncate border-l border-og-border px-2 text-og-xs text-og-fg-subtle sm:block"
1339
+ title={
1340
+ commandFailure
1341
+ ? "Change not saved"
1342
+ : pendingCommands > 0
1343
+ ? "Saving…"
1344
+ : readOnly
1345
+ ? "View only"
1346
+ : selected
1347
+ ? `${objectLabel(selected)}${selected.readOnly ? " · View only" : ""}`
1348
+ : "Select an object to edit"
1349
+ }
1350
+ >
1351
+ {commandFailure
1352
+ ? "Change not saved"
1353
+ : pendingCommands > 0
1354
+ ? "Saving…"
1355
+ : readOnly
1356
+ ? "View only"
1357
+ : selected
1358
+ ? `${objectLabel(selected)}${selected.readOnly ? " · View only" : ""}`
1359
+ : "Select an object to edit"}
1360
+ </span>
1361
+ </div>
1362
+
1363
+ <div role="status" aria-live="polite" className="sr-only">
1364
+ {commandFailure?.message ?? (pendingCommands > 0 ? "Saving presentation changes" : "")}
1365
+ </div>
1366
+
1367
+ <div className="flex min-h-0 min-w-0 flex-1">
1368
+ <div
1369
+ ref={railRef}
1370
+ role="listbox"
1371
+ tabIndex={0}
1372
+ aria-label="Slides"
1373
+ aria-orientation="vertical"
1374
+ aria-activedescendant={
1375
+ activeSlide && activeIndex >= railStart && activeIndex < railEnd
1376
+ ? `${domId}-slide-${safeDomId(activeSlide.id)}`
1377
+ : undefined
1378
+ }
1379
+ className="relative hidden w-40 shrink-0 overflow-y-auto border-r border-og-border bg-og-surface-2/70 outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent sm:block"
1380
+ data-og-slide-rail
1381
+ data-og-window-start={railStart}
1382
+ data-og-window-end={railEnd}
1383
+ onScroll={(event) => {
1384
+ const scrollTop = event.currentTarget.scrollTop;
1385
+ const height = event.currentTarget.clientHeight;
1386
+ setRail((current) => ({
1387
+ scrollTop,
1388
+ height: height || current.height,
1389
+ }));
1390
+ }}
1391
+ onKeyDown={(event) => {
1392
+ if (slides.length === 0) return;
1393
+ let nextIndex: number | null = null;
1394
+ if (event.key === "ArrowDown") nextIndex = Math.min(slides.length - 1, activeIndex + 1);
1395
+ else if (event.key === "ArrowUp") nextIndex = Math.max(0, activeIndex - 1);
1396
+ else if (event.key === "Home") nextIndex = 0;
1397
+ else if (event.key === "End") nextIndex = slides.length - 1;
1398
+ if (nextIndex === null) return;
1399
+ event.preventDefault();
1400
+ const next = slides[nextIndex];
1401
+ if (next && next !== activeSlide) selectSlide(next, nextIndex);
1402
+ }}
1403
+ >
1404
+ <div className="relative w-full" style={{ height: slides.length * RAIL_ITEM_HEIGHT }}>
1405
+ {slides.slice(railStart, railEnd).map((slide, offset) => {
1406
+ const index = railStart + offset;
1407
+ const active = slide === activeSlide;
1408
+ return (
1409
+ <button
1410
+ key={slide.id}
1411
+ id={`${domId}-slide-${safeDomId(slide.id)}`}
1412
+ type="button"
1413
+ role="option"
1414
+ tabIndex={-1}
1415
+ aria-selected={active}
1416
+ aria-label={`Slide ${index + 1}${slide.title ? `: ${slide.title}` : ""}`}
1417
+ onClick={() => selectSlide(slide, index)}
1418
+ className={cn(
1419
+ "absolute left-0 flex w-full items-start gap-2 px-2 py-2 text-left transition-colors",
1420
+ active
1421
+ ? "bg-og-surface-3 text-og-fg"
1422
+ : "text-og-fg-muted hover:bg-og-surface-3/70",
1423
+ )}
1424
+ style={{ top: index * RAIL_ITEM_HEIGHT, height: 116 }}
1425
+ data-og-slide-index={index}
1426
+ >
1427
+ <span className="w-5 shrink-0 pt-0.5 text-right text-og-xs tabular-nums text-og-fg-subtle">
1428
+ {index + 1}
1429
+ </span>
1430
+ <span
1431
+ className={cn(
1432
+ "block min-w-0 flex-1 overflow-hidden border bg-white shadow-og-sm",
1433
+ active ? "border-og-accent" : "border-og-border",
1434
+ )}
1435
+ style={{ aspectRatio: "16 / 9", borderRadius: 3 }}
1436
+ >
1437
+ <SlideThumbnail
1438
+ slide={slide}
1439
+ width={presentation.slideSize.width}
1440
+ height={presentation.slideSize.height}
1441
+ paintRevision={`${String(revision ?? "")}:${modelEpoch}`}
1442
+ />
1443
+ </span>
1444
+ </button>
1445
+ );
1446
+ })}
1447
+ </div>
1448
+ </div>
1449
+
1450
+ <div
1451
+ ref={viewportRef}
1452
+ className="relative min-h-0 min-w-0 flex-1 overflow-auto bg-og-surface-3/60 p-6"
1453
+ onScroll={(event) => {
1454
+ const element = event.currentTarget;
1455
+ const width = element.clientWidth || viewport.width;
1456
+ const height = element.clientHeight || viewport.height;
1457
+ const next: ViewportMetrics = {
1458
+ scrollLeft: element.scrollLeft,
1459
+ scrollTop: element.scrollTop,
1460
+ width,
1461
+ height,
1462
+ stageLeft: stageRef.current?.offsetLeft ?? 0,
1463
+ stageTop: stageRef.current?.offsetTop ?? 0,
1464
+ };
1465
+ setViewport((current) => (sameViewport(current, next) ? current : next));
1466
+ if (element.clientWidth > 0 && element.clientHeight > 0) setViewportMeasured(true);
1467
+ }}
1468
+ data-og-presentation-viewport
1469
+ >
1470
+ {commandFailure ? (
1471
+ <div
1472
+ role="alert"
1473
+ 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"
1474
+ style={{ maxWidth: "calc(100% - 1rem)" }}
1475
+ >
1476
+ <span className="truncate">{commandFailure.message}</span>
1477
+ <button
1478
+ type="button"
1479
+ onClick={commandFailure.retry}
1480
+ 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"
1481
+ >
1482
+ Retry
1483
+ </button>
1484
+ </div>
1485
+ ) : null}
1486
+ {activeSlide ? (
1487
+ <div
1488
+ ref={stageRef}
1489
+ className="relative mx-auto shrink-0 overflow-hidden shadow-og-lg"
1490
+ style={{ width: stageWidth, height: stageHeight, backgroundColor: "#fff" }}
1491
+ data-og-presentation-stage
1492
+ >
1493
+ <canvas
1494
+ ref={canvasRef}
1495
+ aria-hidden
1496
+ className="absolute inset-0 size-full"
1497
+ style={{ width: stageWidth, height: stageHeight }}
1498
+ />
1499
+ <svg
1500
+ ref={overlayRef}
1501
+ role="application"
1502
+ tabIndex={0}
1503
+ aria-label={`Slide ${activeIndex + 1} editor`}
1504
+ aria-describedby={
1505
+ selected ? `${instructionsId} ${selectionProxyId}` : instructionsId
1506
+ }
1507
+ aria-readonly={readOnly || selected?.readOnly || undefined}
1508
+ viewBox={`0 0 ${presentation.slideSize.width} ${presentation.slideSize.height}`}
1509
+ className="absolute inset-0 size-full touch-none outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent"
1510
+ onKeyDown={handleCanvasKeyDown}
1511
+ onPointerDown={handlePointerDown}
1512
+ onPointerMove={handlePointerMove}
1513
+ onPointerUp={(event) => finishDrag(event)}
1514
+ onPointerCancel={(event) => finishDrag(event, true)}
1515
+ onLostPointerCapture={handleLostPointerCapture}
1516
+ onDoubleClick={() => startTextEdit(selected)}
1517
+ >
1518
+ {selected && effectiveSelectedPosition ? (
1519
+ <g
1520
+ data-og-presentation-object={selected.id}
1521
+ transform={selectionTransform(selected, effectiveSelectedPosition)}
1522
+ >
1523
+ <rect
1524
+ x={effectiveSelectedPosition.left}
1525
+ y={effectiveSelectedPosition.top}
1526
+ width={effectiveSelectedPosition.width}
1527
+ height={effectiveSelectedPosition.height}
1528
+ fill="transparent"
1529
+ stroke="var(--og-accent, #3b82f6)"
1530
+ strokeWidth={2}
1531
+ vectorEffect="non-scaling-stroke"
1532
+ pointerEvents="none"
1533
+ />
1534
+ {!readOnly && !selected.readOnly ? (
1535
+ <circle
1536
+ cx={effectiveSelectedPosition.left + effectiveSelectedPosition.width}
1537
+ cy={effectiveSelectedPosition.top + effectiveSelectedPosition.height}
1538
+ r={6 / zoom}
1539
+ fill="var(--og-accent, #3b82f6)"
1540
+ stroke="white"
1541
+ strokeWidth={1.5}
1542
+ vectorEffect="non-scaling-stroke"
1543
+ className="cursor-nwse-resize"
1544
+ onPointerDown={(event) => beginDrag(event, selected, "resize")}
1545
+ />
1546
+ ) : null}
1547
+ </g>
1548
+ ) : null}
1549
+ </svg>
1550
+
1551
+ {selected ? (
1552
+ <span id={selectionProxyId} role="status" aria-live="polite" className="sr-only">
1553
+ {accessibleObjectLabel(selected)}
1554
+ </span>
1555
+ ) : null}
1556
+
1557
+ <span id={instructionsId} className="sr-only">
1558
+ Use bracket keys to select objects, arrows to move, Alt plus arrows to resize, Enter
1559
+ to edit text, and Page Up or Page Down to change slides.
1560
+ </span>
1561
+
1562
+ {textEdit && selected?.id === textEdit.shape.id && effectiveSelectedPosition ? (
1563
+ <textarea
1564
+ ref={editorRef}
1565
+ aria-label={`Edit ${textEdit.shape.name}`}
1566
+ value={textDraft}
1567
+ maxLength={1_000_000}
1568
+ onChange={(event) => setTextDraft(event.currentTarget.value)}
1569
+ onInput={(event) => setTextDraft(event.currentTarget.value)}
1570
+ onBlur={() => finishTextEdit()}
1571
+ onKeyDown={(event) => {
1572
+ if (event.key === "Escape") {
1573
+ event.preventDefault();
1574
+ finishTextEdit(true);
1575
+ } else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
1576
+ event.preventDefault();
1577
+ finishTextEdit();
1578
+ }
1579
+ }}
1580
+ className="absolute resize-none overflow-hidden border-2 border-og-accent p-1 outline-hidden"
1581
+ style={{
1582
+ left: effectiveSelectedPosition.left * zoom,
1583
+ top: effectiveSelectedPosition.top * zoom,
1584
+ width: effectiveSelectedPosition.width * zoom,
1585
+ height: effectiveSelectedPosition.height * zoom,
1586
+ backgroundColor: "#fffffff2",
1587
+ color: resolveColor(textEdit.shape.text.style.color ?? "slate-950"),
1588
+ fontFamily: textEdit.shape.text.style.fontFamily ?? "Arial, sans-serif",
1589
+ fontSize: Math.max(8, (textEdit.shape.text.style.fontSize ?? 18) * zoom),
1590
+ fontWeight: textEdit.shape.text.style.bold ? 700 : 400,
1591
+ fontStyle: textEdit.shape.text.style.italic ? "italic" : "normal",
1592
+ textAlign: textEdit.shape.text.style.alignment ?? "left",
1593
+ transform:
1594
+ textEdit.shape.rotation === 0
1595
+ ? undefined
1596
+ : `rotate(${textEdit.shape.rotation}deg)`,
1597
+ transformOrigin: "center",
1598
+ }}
1599
+ />
1600
+ ) : null}
1601
+ </div>
1602
+ ) : (
1603
+ <div className="grid size-full min-h-72 place-items-center text-og-sm text-og-fg-muted">
1604
+ This presentation has no slides.
1605
+ </div>
1606
+ )}
1607
+ </div>
1608
+ </div>
1609
+ </div>
1610
+ );
1611
+ }
1612
+
1613
+ /** Artifact chrome plus the editor, for drop-in SDK embedding. */
1614
+ export function PresentationArtifactSurface({
1615
+ presentation,
1616
+ title = "Presentation",
1617
+ subtitle,
1618
+ actions,
1619
+ busy,
1620
+ className,
1621
+ ...editorProps
1622
+ }: PresentationArtifactSurfaceProps) {
1623
+ const count = presentation.slides.items.length;
1624
+ return (
1625
+ <ArtifactSurface
1626
+ modality="presentation"
1627
+ title={title}
1628
+ subtitle={subtitle ?? `${count} slide${count === 1 ? "" : "s"}`}
1629
+ actions={actions}
1630
+ busy={busy}
1631
+ className={className}
1632
+ >
1633
+ <PresentationEditor {...editorProps} presentation={presentation} />
1634
+ </ArtifactSurface>
1635
+ );
1636
+ }
1637
+
1638
+ /** Artifact chrome over the projection-first editor. */
1639
+ export function PresentationProjectionArtifactSurface({
1640
+ projection,
1641
+ title = "Presentation",
1642
+ subtitle,
1643
+ actions,
1644
+ busy,
1645
+ className,
1646
+ ...editorProps
1647
+ }: PresentationProjectionArtifactSurfaceProps) {
1648
+ const count = projection.slides.length;
1649
+ return (
1650
+ <ArtifactSurface
1651
+ modality="presentation"
1652
+ title={title}
1653
+ subtitle={subtitle ?? `${count} slide${count === 1 ? "" : "s"}`}
1654
+ actions={actions}
1655
+ busy={busy}
1656
+ className={className}
1657
+ >
1658
+ <PresentationProjectionEditor {...editorProps} projection={projection} />
1659
+ </ArtifactSurface>
1660
+ );
1661
+ }
1662
+
1663
+ function SlideThumbnail({
1664
+ slide,
1665
+ width,
1666
+ height,
1667
+ paintRevision,
1668
+ }: {
1669
+ slide: Slide;
1670
+ width: number;
1671
+ height: number;
1672
+ paintRevision: string;
1673
+ }) {
1674
+ const canvasRef = useRef<HTMLCanvasElement>(null);
1675
+ const imageCacheRef = useRef(new Map<string, CanvasImageEntry>());
1676
+ const [paintEpoch, setPaintEpoch] = useState(0);
1677
+ useLayoutEffect(() => {
1678
+ const imageCache = imageCacheRef.current;
1679
+ return () => disposeCanvasImageCache(imageCache);
1680
+ }, []);
1681
+ useLayoutEffect(() => {
1682
+ const canvas = canvasRef.current;
1683
+ const context = canvas?.getContext("2d");
1684
+ if (!canvas || !context) return;
1685
+ const displayWidth = 112;
1686
+ const scale = displayWidth / width;
1687
+ canvas.width = Math.max(1, Math.round(width * scale));
1688
+ canvas.height = Math.max(1, Math.round(height * scale));
1689
+ context.setTransform(scale, 0, 0, scale, 0, 0);
1690
+ context.fillStyle = resolveColor(slide.background.fill);
1691
+ context.fillRect(0, 0, width, height);
1692
+ const objects = listSlideObjects(slide);
1693
+ const usedImageSources = new Set<string>();
1694
+ for (const entry of objects) {
1695
+ drawObject(
1696
+ context,
1697
+ entry,
1698
+ entry.object.position,
1699
+ imageCacheRef.current,
1700
+ () => setPaintEpoch((value) => value + 1),
1701
+ { left: 0, top: 0, width, height },
1702
+ scale,
1703
+ usedImageSources,
1704
+ );
1705
+ }
1706
+ disposeUnusedCanvasImages(imageCacheRef.current, usedImageSources);
1707
+ }, [height, paintEpoch, paintRevision, slide, width]);
1708
+ return <canvas ref={canvasRef} aria-hidden className="block size-full" />;
1709
+ }
1710
+
1711
+ type PresentationProjectionView = {
1712
+ presentation: Presentation;
1713
+ slideById: Map<string, PresentationSlideProjection>;
1714
+ elementById: Map<string, PresentationProjectionElement>;
1715
+ };
1716
+
1717
+ function presentationViewFromProjection(
1718
+ projection: PresentationEditorProjection,
1719
+ ): PresentationProjectionView {
1720
+ if (projection.slides.length > MAX_PROJECTION_SLIDES) {
1721
+ throw new Error(`Presentation projection exceeds ${MAX_PROJECTION_SLIDES} slides`);
1722
+ }
1723
+ const slideById = new Map<string, PresentationSlideProjection>();
1724
+ const elementById = new Map<string, PresentationProjectionElement>();
1725
+ const slideSize = {
1726
+ width: Math.min(MAX_EDITOR_GEOMETRY, Math.max(1, finite(projection.slideSize.width, 1))),
1727
+ height: Math.min(MAX_EDITOR_GEOMETRY, Math.max(1, finite(projection.slideSize.height, 1))),
1728
+ };
1729
+ const slides = projection.slides.map((slideProjection) => {
1730
+ if (slideProjection.elements.length > MAX_PROJECTION_ELEMENTS_PER_SLIDE) {
1731
+ throw new Error(
1732
+ `Slide ${slideProjection.id} exceeds ${MAX_PROJECTION_ELEMENTS_PER_SLIDE} projected objects`,
1733
+ );
1734
+ }
1735
+ slideById.set(slideProjection.id, slideProjection);
1736
+ const elements = slideProjection.elements.map((element) =>
1737
+ presentationElementView(element, elementById, 0),
1738
+ );
1739
+ return {
1740
+ id: slideProjection.id,
1741
+ title: boundedProjectionText(slideProjection.title ?? ""),
1742
+ background: { fill: cloneProjectionFill(slideProjection.background ?? "white") },
1743
+ elements,
1744
+ } as unknown as Slide;
1745
+ });
1746
+ return {
1747
+ presentation: {
1748
+ slideSize,
1749
+ slides: { items: slides },
1750
+ } as unknown as Presentation,
1751
+ slideById,
1752
+ elementById,
1753
+ };
1754
+ }
1755
+
1756
+ function presentationElementView(
1757
+ element: PresentationProjectionElement,
1758
+ byId: Map<string, PresentationProjectionElement>,
1759
+ depth: number,
1760
+ ): PresentationElement {
1761
+ if (depth > MAX_PROJECTION_GROUP_DEPTH) {
1762
+ throw new Error(`Presentation group depth exceeds ${MAX_PROJECTION_GROUP_DEPTH}`);
1763
+ }
1764
+ byId.set(element.id, element);
1765
+ const position = normalizeEditorPosition(element.position);
1766
+ switch (element.kind) {
1767
+ case "shape": {
1768
+ const text = mutableProjectionText(element.text, element.textStyle);
1769
+ return {
1770
+ id: element.id,
1771
+ name: boundedProjectionText(element.name),
1772
+ __projectionReadOnly: element.readOnly ?? false,
1773
+ geometry: element.geometry,
1774
+ position,
1775
+ fill: cloneProjectionFill(
1776
+ element.fill ?? (element.geometry === "textbox" ? "none" : "white"),
1777
+ ),
1778
+ line: cloneProjectionLine(element.line),
1779
+ rotation: finite(element.rotation ?? 0, 0),
1780
+ borderRadius: element.borderRadius,
1781
+ text,
1782
+ } as unknown as PresentationShape;
1783
+ }
1784
+ case "chart":
1785
+ return {
1786
+ id: element.id,
1787
+ name: boundedProjectionText(element.name),
1788
+ __projectionReadOnly: element.readOnly ?? false,
1789
+ type: element.type,
1790
+ position,
1791
+ title: boundedProjectionText(element.title ?? ""),
1792
+ series: {
1793
+ items: element.series.map((series) => ({
1794
+ name: boundedProjectionText(series.name),
1795
+ categories: (series.categories ?? []).map(boundedProjectionText),
1796
+ values: series.values.map((value) => finite(value, 0)),
1797
+ xValues: (series.xValues ?? []).map((value) => finite(value, 0)),
1798
+ bubbleSizes: (series.bubbleSizes ?? []).map((value) => finite(value, 0)),
1799
+ fill: series.fill ? cloneProjectionFill(series.fill) : undefined,
1800
+ line: cloneProjectionLine(series.line),
1801
+ })),
1802
+ },
1803
+ xAxis: element.xAxis ? { ...element.xAxis } : undefined,
1804
+ yAxis: element.yAxis ? { ...element.yAxis } : undefined,
1805
+ dataLabels: element.dataLabels ? { ...element.dataLabels } : undefined,
1806
+ } as unknown as PresentationChart;
1807
+ case "image": {
1808
+ const source = safeProjectionImageSource(element.source);
1809
+ return {
1810
+ id: element.id,
1811
+ name: boundedProjectionText(element.name),
1812
+ __projectionReadOnly: element.readOnly ?? false,
1813
+ position,
1814
+ alt: boundedProjectionText(element.alt ?? ""),
1815
+ prompt: element.prompt ? boundedProjectionText(element.prompt) : undefined,
1816
+ fit: element.fit ?? "contain",
1817
+ crop: element.crop ? { ...element.crop } : undefined,
1818
+ geometry: element.geometry ?? "rect",
1819
+ borderRadius: element.borderRadius,
1820
+ rotation: finite(element.rotation ?? 0, 0),
1821
+ flipHorizontal: element.flipHorizontal ?? false,
1822
+ flipVertical: element.flipVertical ?? false,
1823
+ sourceForSvg: () => source,
1824
+ } as unknown as PresentationImage;
1825
+ }
1826
+ case "table": {
1827
+ const columns = element.rows[0]?.length ?? 0;
1828
+ if (
1829
+ columns <= 0 ||
1830
+ element.rows.some((row) => row.length !== columns) ||
1831
+ element.rows.length * columns > MAX_PROJECTION_TABLE_CELLS
1832
+ ) {
1833
+ throw new Error(`Table ${element.id} has an invalid or oversized projected grid`);
1834
+ }
1835
+ return {
1836
+ id: element.id,
1837
+ name: boundedProjectionText(element.name),
1838
+ __projectionReadOnly: element.readOnly ?? false,
1839
+ position,
1840
+ rows: element.rows.map((row) =>
1841
+ row.map((cell) =>
1842
+ cell
1843
+ ? {
1844
+ text: mutableProjectionText(cell.text, cell.textStyle),
1845
+ fill: cloneProjectionFill(cell.fill ?? "white"),
1846
+ rowSpan: boundedSpan(cell.rowSpan),
1847
+ colSpan: boundedSpan(cell.colSpan),
1848
+ }
1849
+ : null,
1850
+ ),
1851
+ ),
1852
+ columnWidths: [...(element.columnWidths ?? [])],
1853
+ rowHeights: [...(element.rowHeights ?? [])],
1854
+ fill: cloneProjectionFill(element.fill ?? "white"),
1855
+ line: cloneProjectionLine(element.line),
1856
+ textStyle: { ...(element.textStyle ?? {}) },
1857
+ } as unknown as PresentationTable;
1858
+ }
1859
+ case "group":
1860
+ return {
1861
+ id: element.id,
1862
+ name: boundedProjectionText(element.name),
1863
+ __projectionReadOnly: element.readOnly ?? false,
1864
+ position,
1865
+ childOffset: {
1866
+ left: finite(element.childOffset.left, 0),
1867
+ top: finite(element.childOffset.top, 0),
1868
+ },
1869
+ childExtent: {
1870
+ width: Math.max(1, finite(element.childExtent.width, 1)),
1871
+ height: Math.max(1, finite(element.childExtent.height, 1)),
1872
+ },
1873
+ rotation: finite(element.rotation ?? 0, 0),
1874
+ flipHorizontal: element.flipHorizontal ?? false,
1875
+ flipVertical: element.flipVertical ?? false,
1876
+ children: element.children.map((child) => presentationElementView(child, byId, depth + 1)),
1877
+ } as unknown as PresentationGroup;
1878
+ case "connector":
1879
+ return {
1880
+ id: element.id,
1881
+ name: boundedProjectionText(element.name),
1882
+ __projectionReadOnly: element.readOnly ?? false,
1883
+ geometry: "line",
1884
+ position,
1885
+ fill: "none",
1886
+ line: cloneProjectionLine(element.line),
1887
+ rotation: 0,
1888
+ text: mutableProjectionText("", undefined),
1889
+ } as unknown as PresentationShape;
1890
+ }
1891
+ }
1892
+
1893
+ function mutableProjectionText(
1894
+ initial: string,
1895
+ style: PresentationProjectionTextStyle | undefined,
1896
+ ): PresentationShape["text"] {
1897
+ let value = boundedProjectionText(initial);
1898
+ return {
1899
+ style: { ...(style ?? {}) },
1900
+ set(next: string) {
1901
+ value = boundedProjectionText(next);
1902
+ },
1903
+ toString() {
1904
+ return value;
1905
+ },
1906
+ } as PresentationShape["text"];
1907
+ }
1908
+
1909
+ function cloneProjectionFill(fill: PresentationProjectionFill): PresentationFill {
1910
+ return typeof fill === "string" ? fill : { ...fill };
1911
+ }
1912
+
1913
+ function cloneProjectionLine(line: PresentationProjectionLine | undefined): PresentationLine {
1914
+ return {
1915
+ style: line?.style ?? "solid",
1916
+ fill: line?.fill ? cloneProjectionFill(line.fill) : "slate-900",
1917
+ width: Math.max(0, finite(line?.width ?? 1, 1)),
1918
+ };
1919
+ }
1920
+
1921
+ function boundedProjectionText(value: string): string {
1922
+ return value.slice(0, MAX_PROJECTION_TEXT);
1923
+ }
1924
+
1925
+ function boundedSpan(value: number | undefined): number {
1926
+ return Math.max(1, Math.min(10_000, Math.floor(finite(value ?? 1, 1))));
1927
+ }
1928
+
1929
+ function safeProjectionImageSource(source: string | undefined): string | undefined {
1930
+ if (!source || source.length > 64 * 1024 * 1024) return undefined;
1931
+ if (/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source) || source.startsWith("blob:")) {
1932
+ return source;
1933
+ }
1934
+ try {
1935
+ const base = globalThis.location?.href;
1936
+ if (!base) return undefined;
1937
+ const url = new URL(source, base);
1938
+ return url.origin === globalThis.location.origin && /^https?:$/.test(url.protocol)
1939
+ ? url.href
1940
+ : undefined;
1941
+ } catch {
1942
+ return undefined;
1943
+ }
1944
+ }
1945
+
1946
+ function asPresentationError(cause: unknown): Error {
1947
+ return cause instanceof Error ? cause : new Error("Presentation change failed");
1948
+ }
1949
+
1950
+ function listSlideObjects(slide: Slide): DisplayObject[] {
1951
+ return slide.elements.map(displayObjectFromElement);
1952
+ }
1953
+
1954
+ function displayObjectFromElement(
1955
+ object: PresentationElement | PresentationGroupChild,
1956
+ ): DisplayObject {
1957
+ const readOnly =
1958
+ (object as PresentationElement & { __projectionReadOnly?: boolean }).__projectionReadOnly ===
1959
+ true;
1960
+ if ("geometry" in object && "text" in object)
1961
+ return { id: object.id, kind: "shape", object, readOnly };
1962
+ if ("series" in object) return { id: object.id, kind: "chart", object, readOnly };
1963
+ if ("sourceForSvg" in object) return { id: object.id, kind: "image", object, readOnly };
1964
+ if ("rows" in object) return { id: object.id, kind: "table", object, readOnly };
1965
+ return { id: object.id, kind: "group", object, readOnly };
1966
+ }
1967
+
1968
+ function buildSpatialIndex(
1969
+ objects: DisplayObject[],
1970
+ slideSize: { width: number; height: number },
1971
+ ): SpatialIndex {
1972
+ const buckets = new Map<string, number[]>();
1973
+ const overflowObjects: number[] = [];
1974
+ const bounds = objects.map(objectBounds);
1975
+ const maxX = Math.max(0, Math.ceil(slideSize.width / SPATIAL_TILE) - 1);
1976
+ const maxY = Math.max(0, Math.ceil(slideSize.height / SPATIAL_TILE) - 1);
1977
+ for (const [index, entry] of objects.entries()) {
1978
+ const objectBound = bounds[index] ?? entry.object.position;
1979
+ if (!intersects(objectBound, { left: 0, top: 0, ...slideSize })) continue;
1980
+ const left = Math.max(0, Math.floor(objectBound.left / SPATIAL_TILE));
1981
+ const top = Math.max(0, Math.floor(objectBound.top / SPATIAL_TILE));
1982
+ const right = Math.min(maxX, Math.floor((objectBound.left + objectBound.width) / SPATIAL_TILE));
1983
+ const bottom = Math.min(
1984
+ maxY,
1985
+ Math.floor((objectBound.top + objectBound.height) / SPATIAL_TILE),
1986
+ );
1987
+ const bucketCount = (right - left + 1) * (bottom - top + 1);
1988
+ if (!Number.isSafeInteger(bucketCount) || bucketCount > MAX_SPATIAL_BUCKETS_PER_OBJECT) {
1989
+ overflowObjects.push(index);
1990
+ continue;
1991
+ }
1992
+ for (let y = top; y <= bottom; y += 1) {
1993
+ for (let x = left; x <= right; x += 1) {
1994
+ const key = `${x}:${y}`;
1995
+ const bucket = buckets.get(key);
1996
+ if (bucket) bucket.push(index);
1997
+ else buckets.set(key, [index]);
1998
+ }
1999
+ }
2000
+ }
2001
+ return { objects, bounds, buckets, overflowObjects };
2002
+ }
2003
+
2004
+ function querySpatialIndex(index: SpatialIndex, viewport: PresentationPosition): number[] {
2005
+ const seen = new Set<number>(index.overflowObjects);
2006
+ const left = Math.floor(viewport.left / SPATIAL_TILE);
2007
+ const top = Math.floor(viewport.top / SPATIAL_TILE);
2008
+ const right = Math.floor((viewport.left + viewport.width) / SPATIAL_TILE);
2009
+ const bottom = Math.floor((viewport.top + viewport.height) / SPATIAL_TILE);
2010
+ for (let y = top; y <= bottom; y += 1) {
2011
+ for (let x = left; x <= right; x += 1) {
2012
+ for (const objectIndex of index.buckets.get(`${x}:${y}`) ?? []) {
2013
+ if (intersects(index.bounds[objectIndex], viewport)) seen.add(objectIndex);
2014
+ }
2015
+ }
2016
+ }
2017
+ return [...seen]
2018
+ .filter((objectIndex) => {
2019
+ return !!index.objects[objectIndex] && intersects(index.bounds[objectIndex], viewport);
2020
+ })
2021
+ .sort((a, b) => a - b);
2022
+ }
2023
+
2024
+ function paintSlideCanvas(options: {
2025
+ canvas: HTMLCanvasElement;
2026
+ slide: Slide;
2027
+ slideSize: { width: number; height: number };
2028
+ zoom: number;
2029
+ objects: DisplayObject[];
2030
+ objectIndexes: number[];
2031
+ renderRegion: PresentationPosition;
2032
+ draftPosition: DraftPosition | null;
2033
+ imageCache: Map<string, CanvasImageEntry>;
2034
+ requestRepaint: () => void;
2035
+ }): void {
2036
+ const { canvas, slide, slideSize, zoom } = options;
2037
+ const context = canvas.getContext("2d");
2038
+ if (!context) return;
2039
+ const cssWidth = slideSize.width * zoom;
2040
+ const cssHeight = slideSize.height * zoom;
2041
+ const ratio = Math.max(
2042
+ 0.0001,
2043
+ Math.min(
2044
+ 2,
2045
+ Math.max(1, globalThis.devicePixelRatio || 1),
2046
+ MAX_CANVAS_EDGE / Math.max(1, cssWidth),
2047
+ MAX_CANVAS_EDGE / Math.max(1, cssHeight),
2048
+ Math.sqrt(MAX_CANVAS_PIXELS / Math.max(1, cssWidth * cssHeight)),
2049
+ ),
2050
+ );
2051
+ const pixelWidth = Math.max(1, Math.round(slideSize.width * zoom * ratio));
2052
+ const pixelHeight = Math.max(1, Math.round(slideSize.height * zoom * ratio));
2053
+ if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
2054
+ if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
2055
+ context.setTransform(zoom * ratio, 0, 0, zoom * ratio, 0, 0);
2056
+ context.clearRect(0, 0, slideSize.width, slideSize.height);
2057
+ context.fillStyle = resolveColor(slide.background.fill);
2058
+ context.fillRect(0, 0, slideSize.width, slideSize.height);
2059
+ const usedImageSources = new Set<string>();
2060
+ for (const index of options.objectIndexes) {
2061
+ const entry = options.objects[index];
2062
+ if (!entry) continue;
2063
+ const position =
2064
+ entry.id === options.draftPosition?.objectId
2065
+ ? options.draftPosition.position
2066
+ : entry.object.position;
2067
+ drawObject(
2068
+ context,
2069
+ entry,
2070
+ position,
2071
+ options.imageCache,
2072
+ options.requestRepaint,
2073
+ options.renderRegion,
2074
+ zoom * ratio,
2075
+ usedImageSources,
2076
+ );
2077
+ }
2078
+ disposeUnusedCanvasImages(options.imageCache, usedImageSources);
2079
+ }
2080
+
2081
+ function drawObject(
2082
+ context: CanvasRenderingContext2D,
2083
+ entry: DisplayObject,
2084
+ position: PresentationPosition,
2085
+ imageCache: Map<string, CanvasImageEntry>,
2086
+ requestRepaint: () => void,
2087
+ renderRegion: PresentationPosition,
2088
+ pixelScale: number,
2089
+ usedImageSources: Set<string>,
2090
+ ): void {
2091
+ switch (entry.kind) {
2092
+ case "shape":
2093
+ drawShape(context, entry.object as PresentationShape, position);
2094
+ return;
2095
+ case "chart":
2096
+ drawChart(context, entry.object as PresentationChart, position);
2097
+ return;
2098
+ case "image": {
2099
+ const source = drawImage(
2100
+ context,
2101
+ entry.object as PresentationImage,
2102
+ position,
2103
+ imageCache,
2104
+ requestRepaint,
2105
+ );
2106
+ if (source) usedImageSources.add(source);
2107
+ return;
2108
+ }
2109
+ case "table":
2110
+ drawTable(context, entry.object as PresentationTable, position, renderRegion, pixelScale);
2111
+ return;
2112
+ case "group":
2113
+ drawGroup(
2114
+ context,
2115
+ entry.object as PresentationGroup,
2116
+ position,
2117
+ imageCache,
2118
+ requestRepaint,
2119
+ renderRegion,
2120
+ pixelScale,
2121
+ usedImageSources,
2122
+ );
2123
+ }
2124
+ }
2125
+
2126
+ function drawTable(
2127
+ context: CanvasRenderingContext2D,
2128
+ table: PresentationTable,
2129
+ position: PresentationPosition,
2130
+ renderRegion: PresentationPosition,
2131
+ pixelScale: number,
2132
+ ): void {
2133
+ const rowCount = table.rows.length;
2134
+ const columnCount = table.rows[0]?.length ?? 0;
2135
+ if (rowCount === 0 || columnCount === 0 || !intersects(position, renderRegion)) return;
2136
+ const columnWidths = normalizeTableTracks(table.columnWidths, columnCount, position.width);
2137
+ const rowHeights = normalizeTableTracks(table.rowHeights, rowCount, position.height);
2138
+ const columnOffsets = cumulativeOffsets(columnWidths, position.left);
2139
+ const rowOffsets = cumulativeOffsets(rowHeights, position.top);
2140
+ const columnStart = visibleTrackStart(columnOffsets, renderRegion.left);
2141
+ const columnEnd = visibleTrackEnd(columnOffsets, renderRegion.left + renderRegion.width);
2142
+ const rowStart = visibleTrackStart(rowOffsets, renderRegion.top);
2143
+ const rowEnd = visibleTrackEnd(rowOffsets, renderRegion.top + renderRegion.height);
2144
+ const visibleCellCount = (rowEnd - rowStart) * (columnEnd - columnStart);
2145
+ if (visibleCellCount > MAX_TABLE_CELLS_PER_PAINT) {
2146
+ drawTableOverview(context, table, position, columnOffsets, rowOffsets, {
2147
+ columnStart,
2148
+ columnEnd,
2149
+ rowStart,
2150
+ rowEnd,
2151
+ });
2152
+ return;
2153
+ }
2154
+ context.save();
2155
+ for (let rowIndex = rowStart; rowIndex < rowEnd; rowIndex += 1) {
2156
+ const row = table.rows[rowIndex];
2157
+ if (!row) continue;
2158
+ for (let columnIndex = columnStart; columnIndex < columnEnd; columnIndex += 1) {
2159
+ const cell = row[columnIndex];
2160
+ if (!cell) continue;
2161
+ const rightIndex = Math.min(columnCount, columnIndex + cell.colSpan);
2162
+ const bottomIndex = Math.min(rowCount, rowIndex + cell.rowSpan);
2163
+ const left = columnOffsets[columnIndex] ?? position.left;
2164
+ const top = rowOffsets[rowIndex] ?? position.top;
2165
+ const right = columnOffsets[rightIndex] ?? position.left + position.width;
2166
+ const bottom = rowOffsets[bottomIndex] ?? position.top + position.height;
2167
+ const width = Math.max(1, right - left);
2168
+ const height = Math.max(1, bottom - top);
2169
+ context.beginPath();
2170
+ context.rect(left, top, width, height);
2171
+ applyFillAndLine(context, cell.fill, table.line, false);
2172
+ if (width * pixelScale >= 8 && height * pixelScale >= 8) {
2173
+ drawText(context, cell.text.toString(), cell.text.style, {
2174
+ left: left + 4,
2175
+ top: top + 2,
2176
+ width: Math.max(1, width - 8),
2177
+ height: Math.max(1, height - 4),
2178
+ });
2179
+ }
2180
+ }
2181
+ }
2182
+ context.restore();
2183
+ }
2184
+
2185
+ function visibleTrackStart(offsets: readonly number[], value: number): number {
2186
+ return Math.max(0, Math.min(offsets.length - 2, lowerBound(offsets, value) - 1));
2187
+ }
2188
+
2189
+ function visibleTrackEnd(offsets: readonly number[], value: number): number {
2190
+ return Math.max(0, Math.min(offsets.length - 1, lowerBound(offsets, value) + 1));
2191
+ }
2192
+
2193
+ function lowerBound(values: readonly number[], target: number): number {
2194
+ let low = 0;
2195
+ let high = values.length;
2196
+ while (low < high) {
2197
+ const middle = low + Math.floor((high - low) / 2);
2198
+ if ((values[middle] ?? Number.POSITIVE_INFINITY) < target) low = middle + 1;
2199
+ else high = middle;
2200
+ }
2201
+ return low;
2202
+ }
2203
+
2204
+ function drawTableOverview(
2205
+ context: CanvasRenderingContext2D,
2206
+ table: PresentationTable,
2207
+ position: PresentationPosition,
2208
+ columnOffsets: readonly number[],
2209
+ rowOffsets: readonly number[],
2210
+ visible: { columnStart: number; columnEnd: number; rowStart: number; rowEnd: number },
2211
+ ): void {
2212
+ context.save();
2213
+ context.beginPath();
2214
+ context.rect(position.left, position.top, position.width, position.height);
2215
+ applyFillAndLine(context, table.fill, table.line, false);
2216
+ const columnStep = Math.max(1, Math.ceil((visible.columnEnd - visible.columnStart) / 96));
2217
+ const rowStep = Math.max(1, Math.ceil((visible.rowEnd - visible.rowStart) / 96));
2218
+ context.beginPath();
2219
+ for (let index = visible.columnStart; index <= visible.columnEnd; index += columnStep) {
2220
+ const x = columnOffsets[index];
2221
+ if (x === undefined) continue;
2222
+ context.moveTo(x, position.top);
2223
+ context.lineTo(x, position.top + position.height);
2224
+ }
2225
+ for (let index = visible.rowStart; index <= visible.rowEnd; index += rowStep) {
2226
+ const y = rowOffsets[index];
2227
+ if (y === undefined) continue;
2228
+ context.moveTo(position.left, y);
2229
+ context.lineTo(position.left + position.width, y);
2230
+ }
2231
+ applyFillAndLine(context, "none", table.line, true);
2232
+ context.restore();
2233
+ }
2234
+
2235
+ function normalizeTableTracks(values: readonly number[], count: number, total: number): number[] {
2236
+ if (values.length !== count) return Array.from({ length: count }, () => total / count);
2237
+ const sum = values.reduce((value, next) => value + next, 0);
2238
+ if (!Number.isFinite(sum) || sum <= 0) {
2239
+ return Array.from({ length: count }, () => total / count);
2240
+ }
2241
+ return values.map((value) => (value / sum) * total);
2242
+ }
2243
+
2244
+ function cumulativeOffsets(tracks: readonly number[], start: number): number[] {
2245
+ const offsets = [start];
2246
+ for (const track of tracks) offsets.push((offsets.at(-1) ?? start) + track);
2247
+ return offsets;
2248
+ }
2249
+
2250
+ function drawGroup(
2251
+ context: CanvasRenderingContext2D,
2252
+ group: PresentationGroup,
2253
+ position: PresentationPosition,
2254
+ imageCache: Map<string, CanvasImageEntry>,
2255
+ requestRepaint: () => void,
2256
+ renderRegion: PresentationPosition,
2257
+ pixelScale: number,
2258
+ usedImageSources: Set<string>,
2259
+ ): void {
2260
+ const scaleX = position.width / group.childExtent.width;
2261
+ const scaleY = position.height / group.childExtent.height;
2262
+ const translatedX = position.left - group.childOffset.left * scaleX;
2263
+ const translatedY = position.top - group.childOffset.top * scaleY;
2264
+ const centerX = position.left + position.width / 2;
2265
+ const centerY = position.top + position.height / 2;
2266
+ context.save();
2267
+ context.translate(centerX, centerY);
2268
+ context.rotate((group.rotation * Math.PI) / 180);
2269
+ context.scale(group.flipHorizontal ? -1 : 1, group.flipVertical ? -1 : 1);
2270
+ context.translate(-centerX, -centerY);
2271
+ context.translate(translatedX, translatedY);
2272
+ context.scale(scaleX, scaleY);
2273
+ const childRenderRegion = inverseGroupRegion(group, position, renderRegion);
2274
+ for (const child of group.children) {
2275
+ const entry = displayObjectFromElement(child);
2276
+ if (!intersects(objectBounds(entry), childRenderRegion)) continue;
2277
+ drawObject(
2278
+ context,
2279
+ entry,
2280
+ child.position,
2281
+ imageCache,
2282
+ requestRepaint,
2283
+ childRenderRegion,
2284
+ pixelScale * Math.min(Math.abs(scaleX), Math.abs(scaleY)),
2285
+ usedImageSources,
2286
+ );
2287
+ }
2288
+ context.restore();
2289
+ }
2290
+
2291
+ function inverseGroupRegion(
2292
+ group: PresentationGroup,
2293
+ position: PresentationPosition,
2294
+ region: PresentationPosition,
2295
+ ): PresentationPosition {
2296
+ const scaleX = position.width / group.childExtent.width;
2297
+ const scaleY = position.height / group.childExtent.height;
2298
+ const translatedX = position.left - group.childOffset.left * scaleX;
2299
+ const translatedY = position.top - group.childOffset.top * scaleY;
2300
+ const centerX = position.left + position.width / 2;
2301
+ const centerY = position.top + position.height / 2;
2302
+ const radians = (-group.rotation * Math.PI) / 180;
2303
+ const cos = Math.cos(radians);
2304
+ const sin = Math.sin(radians);
2305
+ const points = [
2306
+ [region.left, region.top],
2307
+ [region.left + region.width, region.top],
2308
+ [region.left, region.top + region.height],
2309
+ [region.left + region.width, region.top + region.height],
2310
+ ].map(([x = 0, y = 0]) => {
2311
+ const dx = x - centerX;
2312
+ const dy = y - centerY;
2313
+ const rotatedX = dx * cos - dy * sin;
2314
+ const rotatedY = dx * sin + dy * cos;
2315
+ const unflippedX = rotatedX * (group.flipHorizontal ? -1 : 1) + centerX;
2316
+ const unflippedY = rotatedY * (group.flipVertical ? -1 : 1) + centerY;
2317
+ return {
2318
+ x: (unflippedX - translatedX) / scaleX,
2319
+ y: (unflippedY - translatedY) / scaleY,
2320
+ };
2321
+ });
2322
+ const xs = points.map((point) => point.x);
2323
+ const ys = points.map((point) => point.y);
2324
+ const left = Math.min(...xs);
2325
+ const right = Math.max(...xs);
2326
+ const top = Math.min(...ys);
2327
+ const bottom = Math.max(...ys);
2328
+ return { left, top, width: right - left, height: bottom - top };
2329
+ }
2330
+
2331
+ function drawShape(
2332
+ context: CanvasRenderingContext2D,
2333
+ shape: PresentationShape,
2334
+ position: PresentationPosition,
2335
+ ): void {
2336
+ context.save();
2337
+ context.translate(position.left + position.width / 2, position.top + position.height / 2);
2338
+ context.rotate((shape.rotation * Math.PI) / 180);
2339
+ const x = -position.width / 2;
2340
+ const y = -position.height / 2;
2341
+ context.beginPath();
2342
+ switch (shape.geometry) {
2343
+ case "ellipse":
2344
+ context.ellipse(0, 0, position.width / 2, position.height / 2, 0, 0, Math.PI * 2);
2345
+ break;
2346
+ case "triangle":
2347
+ context.moveTo(0, y);
2348
+ context.lineTo(x + position.width, y + position.height);
2349
+ context.lineTo(x, y + position.height);
2350
+ context.closePath();
2351
+ break;
2352
+ case "rightArrow": {
2353
+ const body = position.width * 0.65;
2354
+ context.moveTo(x, y + position.height * 0.25);
2355
+ context.lineTo(x + body, y + position.height * 0.25);
2356
+ context.lineTo(x + body, y);
2357
+ context.lineTo(x + position.width, 0);
2358
+ context.lineTo(x + body, y + position.height);
2359
+ context.lineTo(x + body, y + position.height * 0.75);
2360
+ context.lineTo(x, y + position.height * 0.75);
2361
+ context.closePath();
2362
+ break;
2363
+ }
2364
+ case "line":
2365
+ context.moveTo(x, y);
2366
+ context.lineTo(x + position.width, y + position.height);
2367
+ break;
2368
+ case "roundRect":
2369
+ roundedRect(
2370
+ context,
2371
+ x,
2372
+ y,
2373
+ position.width,
2374
+ position.height,
2375
+ resolveRadius(shape.borderRadius, position),
2376
+ );
2377
+ break;
2378
+ default:
2379
+ context.rect(x, y, position.width, position.height);
2380
+ }
2381
+ applyFillAndLine(context, shape.fill, shape.line, shape.geometry === "line");
2382
+ const text = shape.text.toString();
2383
+ if (text)
2384
+ drawText(context, text, shape.text.style, {
2385
+ left: x,
2386
+ top: y,
2387
+ width: position.width,
2388
+ height: position.height,
2389
+ });
2390
+ context.restore();
2391
+ }
2392
+
2393
+ function drawChart(
2394
+ context: CanvasRenderingContext2D,
2395
+ chart: PresentationChart,
2396
+ position: PresentationPosition,
2397
+ ): void {
2398
+ context.save();
2399
+ context.fillStyle = "#ffffff";
2400
+ context.strokeStyle = "#e2e8f0";
2401
+ context.lineWidth = 1;
2402
+ context.fillRect(position.left, position.top, position.width, position.height);
2403
+ context.strokeRect(position.left, position.top, position.width, position.height);
2404
+ if (chart.title) {
2405
+ drawText(
2406
+ context,
2407
+ chart.title,
2408
+ { fontSize: 18, bold: true, alignment: "center" },
2409
+ {
2410
+ left: position.left + 16,
2411
+ top: position.top + 8,
2412
+ width: Math.max(1, position.width - 32),
2413
+ height: 28,
2414
+ },
2415
+ );
2416
+ }
2417
+ const plot = {
2418
+ left: position.left + 36,
2419
+ top: position.top + (chart.title ? 46 : 20),
2420
+ width: Math.max(1, position.width - 56),
2421
+ height: Math.max(1, position.height - (chart.title ? 72 : 48)),
2422
+ };
2423
+ const values = chart.series.items.flatMap((series) => series.values).filter(Number.isFinite);
2424
+ const minValue = Math.min(0, chart.yAxis?.min ?? Math.min(...values, 0));
2425
+ const maxValue = Math.max(1, chart.yAxis?.max ?? Math.max(...values, 1));
2426
+ const valueRange = Math.max(Number.EPSILON, maxValue - minValue);
2427
+ const yForValue = (value: number) =>
2428
+ plot.top + plot.height - ((value - minValue) / valueRange) * plot.height;
2429
+ const palette = ["#2563eb", "#0f766e", "#f59e0b", "#dc2626", "#7c3aed"];
2430
+ if (chart.type === "pie" || chart.type === "doughnut") {
2431
+ const series = chart.series.items[0];
2432
+ const slices = series?.values.map((value) => Math.max(0, value)) ?? [];
2433
+ const total = slices.reduce((sum, value) => sum + value, 0);
2434
+ let angle = -Math.PI / 2;
2435
+ const radius = Math.min(plot.width, plot.height) * 0.42;
2436
+ const cx = plot.left + plot.width / 2;
2437
+ const cy = plot.top + plot.height / 2;
2438
+ slices.forEach((value, index) => {
2439
+ const next = angle + (total > 0 ? (value / total) * Math.PI * 2 : 0);
2440
+ context.beginPath();
2441
+ context.moveTo(cx, cy);
2442
+ context.arc(cx, cy, radius, angle, next);
2443
+ context.closePath();
2444
+ context.fillStyle = palette[index % palette.length] ?? "#2563eb";
2445
+ context.fill();
2446
+ angle = next;
2447
+ });
2448
+ if (chart.type === "doughnut") {
2449
+ context.beginPath();
2450
+ context.arc(cx, cy, radius * 0.55, 0, Math.PI * 2);
2451
+ context.fillStyle = "#ffffff";
2452
+ context.fill();
2453
+ }
2454
+ } else if (chart.type === "line" || chart.type === "area") {
2455
+ drawChartAxes(context, chart, plot, yForValue(0));
2456
+ chart.series.items.forEach((series, seriesIndex) => {
2457
+ if (series.values.length === 0) return;
2458
+ const points = series.values.map((value, index) => ({
2459
+ x: plot.left + (index / Math.max(1, series.values.length - 1)) * plot.width,
2460
+ y: yForValue(value),
2461
+ }));
2462
+ const color = resolveColor(
2463
+ series.line?.fill ?? series.fill ?? palette[seriesIndex % palette.length] ?? "#2563eb",
2464
+ );
2465
+ if (chart.type === "area") {
2466
+ context.beginPath();
2467
+ context.moveTo(points[0]?.x ?? plot.left, yForValue(0));
2468
+ for (const point of points) context.lineTo(point.x, point.y);
2469
+ context.lineTo(points.at(-1)?.x ?? plot.left + plot.width, yForValue(0));
2470
+ context.closePath();
2471
+ context.globalAlpha = 0.22;
2472
+ context.fillStyle = color;
2473
+ context.fill();
2474
+ context.globalAlpha = 1;
2475
+ }
2476
+ context.beginPath();
2477
+ points.forEach((point, index) => {
2478
+ if (index === 0) context.moveTo(point.x, point.y);
2479
+ else context.lineTo(point.x, point.y);
2480
+ });
2481
+ context.strokeStyle = color;
2482
+ context.lineWidth = series.line?.width ?? 3;
2483
+ context.stroke();
2484
+ for (const point of points) {
2485
+ context.beginPath();
2486
+ context.arc(point.x, point.y, 3, 0, Math.PI * 2);
2487
+ context.fillStyle = color;
2488
+ context.fill();
2489
+ }
2490
+ });
2491
+ } else if (chart.type === "scatter" || chart.type === "bubble") {
2492
+ drawChartAxes(context, chart, plot, yForValue(0));
2493
+ const xValues = chart.series.items.flatMap((series) =>
2494
+ series.values.map((_value, index) => series.xValues[index] ?? index),
2495
+ );
2496
+ const minX = chart.xAxis?.min ?? Math.min(...xValues, 0);
2497
+ const maxX = chart.xAxis?.max ?? Math.max(...xValues, 1);
2498
+ const xRange = Math.max(Number.EPSILON, maxX - minX);
2499
+ chart.series.items.forEach((series, seriesIndex) => {
2500
+ context.fillStyle = resolveColor(
2501
+ series.fill ?? palette[seriesIndex % palette.length] ?? "#2563eb",
2502
+ );
2503
+ series.values.forEach((value, index) => {
2504
+ const xValue = series.xValues[index] ?? index;
2505
+ const x = plot.left + ((xValue - minX) / xRange) * plot.width;
2506
+ const radius =
2507
+ chart.type === "bubble"
2508
+ ? Math.max(3, Math.sqrt(Math.max(0, series.bubbleSizes[index] ?? 9)))
2509
+ : 4;
2510
+ context.beginPath();
2511
+ context.arc(x, yForValue(value), radius, 0, Math.PI * 2);
2512
+ context.fill();
2513
+ });
2514
+ });
2515
+ } else if (chart.type === "radar") {
2516
+ drawRadarChart(context, chart, plot, palette);
2517
+ } else {
2518
+ drawChartAxes(context, chart, plot, yForValue(0));
2519
+ const count = Math.max(1, ...chart.series.items.map((series) => series.values.length));
2520
+ const groupWidth = plot.width / count;
2521
+ const barWidth = (groupWidth * 0.72) / Math.max(1, chart.series.items.length);
2522
+ const baseline = yForValue(0);
2523
+ chart.series.items.forEach((series, seriesIndex) => {
2524
+ series.values.forEach((value, valueIndex) => {
2525
+ const valueY = yForValue(value);
2526
+ const height = Math.abs(baseline - valueY);
2527
+ context.fillStyle = resolveColor(
2528
+ series.fill ?? palette[seriesIndex % palette.length] ?? "#2563eb",
2529
+ );
2530
+ const left =
2531
+ plot.left + valueIndex * groupWidth + groupWidth * 0.14 + seriesIndex * barWidth;
2532
+ context.fillRect(left, Math.min(baseline, valueY), Math.max(1, barWidth), height);
2533
+ if (chart.dataLabels?.showValue) {
2534
+ drawText(
2535
+ context,
2536
+ String(value),
2537
+ { fontSize: 11, alignment: "center" },
2538
+ {
2539
+ left,
2540
+ top: Math.max(plot.top, Math.min(baseline, valueY) - 14),
2541
+ width: Math.max(1, barWidth),
2542
+ height: 14,
2543
+ },
2544
+ );
2545
+ }
2546
+ });
2547
+ });
2548
+ }
2549
+ context.restore();
2550
+ }
2551
+
2552
+ function drawChartAxes(
2553
+ context: CanvasRenderingContext2D,
2554
+ chart: PresentationChart,
2555
+ plot: PresentationPosition,
2556
+ baseline: number,
2557
+ ): void {
2558
+ context.save();
2559
+ context.strokeStyle = "#94a3b8";
2560
+ context.lineWidth = 1;
2561
+ if (chart.xAxis?.visible !== false) {
2562
+ context.beginPath();
2563
+ context.moveTo(plot.left, Math.max(plot.top, Math.min(plot.top + plot.height, baseline)));
2564
+ context.lineTo(
2565
+ plot.left + plot.width,
2566
+ Math.max(plot.top, Math.min(plot.top + plot.height, baseline)),
2567
+ );
2568
+ context.stroke();
2569
+ }
2570
+ if (chart.yAxis?.visible !== false) {
2571
+ context.beginPath();
2572
+ context.moveTo(plot.left, plot.top);
2573
+ context.lineTo(plot.left, plot.top + plot.height);
2574
+ context.stroke();
2575
+ }
2576
+ context.restore();
2577
+ }
2578
+
2579
+ function drawRadarChart(
2580
+ context: CanvasRenderingContext2D,
2581
+ chart: PresentationChart,
2582
+ plot: PresentationPosition,
2583
+ palette: readonly string[],
2584
+ ): void {
2585
+ const count = Math.max(3, ...chart.series.items.map((series) => series.values.length));
2586
+ const maximum = Math.max(
2587
+ 1,
2588
+ ...chart.series.items.flatMap((series) => series.values.map((value) => Math.max(0, value))),
2589
+ );
2590
+ const centerX = plot.left + plot.width / 2;
2591
+ const centerY = plot.top + plot.height / 2;
2592
+ const radius = Math.min(plot.width, plot.height) * 0.42;
2593
+ const point = (index: number, value: number) => {
2594
+ const angle = -Math.PI / 2 + (index / count) * Math.PI * 2;
2595
+ const scaled = (Math.max(0, value) / maximum) * radius;
2596
+ return { x: centerX + Math.cos(angle) * scaled, y: centerY + Math.sin(angle) * scaled };
2597
+ };
2598
+ context.save();
2599
+ context.strokeStyle = "#cbd5e1";
2600
+ for (let ring = 1; ring <= 4; ring += 1) {
2601
+ context.beginPath();
2602
+ for (let index = 0; index < count; index += 1) {
2603
+ const next = point(index, (maximum * ring) / 4);
2604
+ if (index === 0) context.moveTo(next.x, next.y);
2605
+ else context.lineTo(next.x, next.y);
2606
+ }
2607
+ context.closePath();
2608
+ context.stroke();
2609
+ }
2610
+ chart.series.items.forEach((series, seriesIndex) => {
2611
+ const color = resolveColor(series.fill ?? palette[seriesIndex % palette.length] ?? "#2563eb");
2612
+ context.beginPath();
2613
+ for (let index = 0; index < count; index += 1) {
2614
+ const next = point(index, series.values[index] ?? 0);
2615
+ if (index === 0) context.moveTo(next.x, next.y);
2616
+ else context.lineTo(next.x, next.y);
2617
+ }
2618
+ context.closePath();
2619
+ context.globalAlpha = 0.18;
2620
+ context.fillStyle = color;
2621
+ context.fill();
2622
+ context.globalAlpha = 1;
2623
+ context.strokeStyle = color;
2624
+ context.lineWidth = series.line?.width ?? 2;
2625
+ context.stroke();
2626
+ });
2627
+ context.restore();
2628
+ }
2629
+
2630
+ function drawImage(
2631
+ context: CanvasRenderingContext2D,
2632
+ image: PresentationImage,
2633
+ position: PresentationPosition,
2634
+ imageCache: Map<string, CanvasImageEntry>,
2635
+ requestRepaint: () => void,
2636
+ ): string | undefined {
2637
+ context.save();
2638
+ applyImageTransform(context, image, position);
2639
+ imageClipPath(context, image, position);
2640
+ context.clip();
2641
+ context.fillStyle = "#f1f5f9";
2642
+ context.fillRect(position.left, position.top, position.width, position.height);
2643
+ const source = imageSourceForCanvas(image);
2644
+ if (source && typeof Image !== "undefined") {
2645
+ let cached = imageCache.get(source);
2646
+ if (!cached) {
2647
+ const element = new Image();
2648
+ cached = { image: element, ready: false };
2649
+ imageCache.set(source, cached);
2650
+ element.onload = () => {
2651
+ const current = imageCache.get(source);
2652
+ if (!current || current.image !== element) return;
2653
+ current.ready = true;
2654
+ requestRepaint();
2655
+ };
2656
+ element.onerror = () => {
2657
+ const current = imageCache.get(source);
2658
+ if (current?.image === element) current.ready = false;
2659
+ };
2660
+ element.src = source;
2661
+ }
2662
+ if (cached.ready && cached.image.naturalWidth > 0 && cached.image.naturalHeight > 0) {
2663
+ drawFittedImage(context, cached.image, image, position);
2664
+ context.restore();
2665
+ return source;
2666
+ }
2667
+ }
2668
+ context.strokeStyle = "#94a3b8";
2669
+ context.lineWidth = 1;
2670
+ context.setLineDash([8, 6]);
2671
+ imageClipPath(context, image, position);
2672
+ context.stroke();
2673
+ context.setLineDash([]);
2674
+ drawText(
2675
+ context,
2676
+ image.alt || image.prompt || "Image",
2677
+ { fontSize: 14, color: "slate-600" },
2678
+ {
2679
+ left: position.left + 12,
2680
+ top: position.top + 12,
2681
+ width: Math.max(1, position.width - 24),
2682
+ height: Math.max(1, position.height - 24),
2683
+ },
2684
+ );
2685
+ context.restore();
2686
+ return source;
2687
+ }
2688
+
2689
+ function imageSourceForCanvas(image: PresentationImage): string | undefined {
2690
+ try {
2691
+ return image.sourceForSvg();
2692
+ } catch {
2693
+ return undefined;
2694
+ }
2695
+ }
2696
+
2697
+ function applyImageTransform(
2698
+ context: CanvasRenderingContext2D,
2699
+ image: PresentationImage,
2700
+ position: PresentationPosition,
2701
+ ): void {
2702
+ const centerX = position.left + position.width / 2;
2703
+ const centerY = position.top + position.height / 2;
2704
+ context.translate(centerX, centerY);
2705
+ context.rotate((image.rotation * Math.PI) / 180);
2706
+ context.scale(image.flipHorizontal ? -1 : 1, image.flipVertical ? -1 : 1);
2707
+ context.translate(-centerX, -centerY);
2708
+ }
2709
+
2710
+ function imageClipPath(
2711
+ context: CanvasRenderingContext2D,
2712
+ image: PresentationImage,
2713
+ position: PresentationPosition,
2714
+ ): void {
2715
+ context.beginPath();
2716
+ if (image.geometry === "ellipse") {
2717
+ context.ellipse(
2718
+ position.left + position.width / 2,
2719
+ position.top + position.height / 2,
2720
+ position.width / 2,
2721
+ position.height / 2,
2722
+ 0,
2723
+ 0,
2724
+ Math.PI * 2,
2725
+ );
2726
+ } else if (image.geometry === "roundRect") {
2727
+ roundedRect(
2728
+ context,
2729
+ position.left,
2730
+ position.top,
2731
+ position.width,
2732
+ position.height,
2733
+ resolveRadius(image.borderRadius, position),
2734
+ );
2735
+ } else {
2736
+ context.rect(position.left, position.top, position.width, position.height);
2737
+ }
2738
+ context.closePath();
2739
+ }
2740
+
2741
+ function drawFittedImage(
2742
+ context: CanvasRenderingContext2D,
2743
+ source: HTMLImageElement,
2744
+ image: PresentationImage,
2745
+ position: PresentationPosition,
2746
+ ): void {
2747
+ const crop = image.crop ?? { left: 0, top: 0, right: 0, bottom: 0 };
2748
+ const sourceX = source.naturalWidth * crop.left;
2749
+ const sourceY = source.naturalHeight * crop.top;
2750
+ const sourceWidth = source.naturalWidth * (1 - crop.left - crop.right);
2751
+ const sourceHeight = source.naturalHeight * (1 - crop.top - crop.bottom);
2752
+ if (sourceWidth <= 0 || sourceHeight <= 0) return;
2753
+ const scale =
2754
+ image.fit === "cover"
2755
+ ? Math.max(position.width / sourceWidth, position.height / sourceHeight)
2756
+ : Math.min(position.width / sourceWidth, position.height / sourceHeight);
2757
+ const width = sourceWidth * scale;
2758
+ const height = sourceHeight * scale;
2759
+ const left = position.left + (position.width - width) / 2;
2760
+ const top = position.top + (position.height - height) / 2;
2761
+ context.drawImage(source, sourceX, sourceY, sourceWidth, sourceHeight, left, top, width, height);
2762
+ }
2763
+
2764
+ function disposeCanvasImageCache(cache: Map<string, CanvasImageEntry>): void {
2765
+ for (const entry of cache.values()) disposeCanvasImageEntry(entry);
2766
+ cache.clear();
2767
+ }
2768
+
2769
+ function disposeUnusedCanvasImages(
2770
+ cache: Map<string, CanvasImageEntry>,
2771
+ usedSources: ReadonlySet<string>,
2772
+ ): void {
2773
+ for (const [source, entry] of cache) {
2774
+ if (usedSources.has(source)) continue;
2775
+ disposeCanvasImageEntry(entry);
2776
+ cache.delete(source);
2777
+ }
2778
+ }
2779
+
2780
+ function disposeCanvasImageEntry(entry: CanvasImageEntry): void {
2781
+ entry.image.onload = null;
2782
+ entry.image.onerror = null;
2783
+ entry.image.removeAttribute("src");
2784
+ }
2785
+
2786
+ function drawText(
2787
+ context: CanvasRenderingContext2D,
2788
+ text: string,
2789
+ style: PresentationTextStyle,
2790
+ position: PresentationPosition,
2791
+ ): void {
2792
+ const fontSize = style.fontSize ?? 18;
2793
+ const fontStyle = style.italic ? "italic " : "";
2794
+ const fontWeight = style.bold ? "700 " : "400 ";
2795
+ context.font = `${fontStyle}${fontWeight}${fontSize}px ${style.fontFamily ?? "Arial, sans-serif"}`;
2796
+ context.fillStyle = resolveColor(style.color ?? "slate-950");
2797
+ context.textAlign =
2798
+ style.alignment === "center" ? "center" : style.alignment === "right" ? "right" : "left";
2799
+ context.textBaseline = "top";
2800
+ const x =
2801
+ style.alignment === "center"
2802
+ ? position.left + position.width / 2
2803
+ : style.alignment === "right"
2804
+ ? position.left + position.width
2805
+ : position.left;
2806
+ const lineHeight = fontSize * 1.2;
2807
+ const lines = wrapCanvasText(context, text, position.width);
2808
+ const totalHeight = lines.length * lineHeight;
2809
+ const top =
2810
+ style.verticalAlignment === "middle"
2811
+ ? position.top + (position.height - totalHeight) / 2
2812
+ : style.verticalAlignment === "bottom"
2813
+ ? position.top + position.height - totalHeight
2814
+ : position.top;
2815
+ context.save();
2816
+ context.beginPath();
2817
+ context.rect(position.left, position.top, position.width, position.height);
2818
+ context.clip();
2819
+ lines.forEach((line, index) => context.fillText(line, x, top + index * lineHeight));
2820
+ context.restore();
2821
+ }
2822
+
2823
+ function wrapCanvasText(context: CanvasRenderingContext2D, text: string, width: number): string[] {
2824
+ const result: string[] = [];
2825
+ for (const paragraph of text.split("\n")) {
2826
+ const words = paragraph.split(/\s+/).filter(Boolean);
2827
+ if (words.length === 0) {
2828
+ result.push("");
2829
+ continue;
2830
+ }
2831
+ let line = words[0] ?? "";
2832
+ for (let index = 1; index < words.length; index += 1) {
2833
+ const word = words[index] ?? "";
2834
+ const candidate = `${line} ${word}`;
2835
+ if (context.measureText(candidate).width <= width || line.length === 0) line = candidate;
2836
+ else {
2837
+ result.push(line);
2838
+ line = word;
2839
+ }
2840
+ }
2841
+ result.push(line);
2842
+ }
2843
+ return result;
2844
+ }
2845
+
2846
+ function applyFillAndLine(
2847
+ context: CanvasRenderingContext2D,
2848
+ fill: PresentationFill,
2849
+ line: PresentationLine,
2850
+ lineOnly: boolean,
2851
+ ): void {
2852
+ if (!lineOnly && resolveColor(fill) !== "transparent") {
2853
+ context.fillStyle = resolveColor(fill);
2854
+ context.fill();
2855
+ }
2856
+ if (
2857
+ line.style !== "none" &&
2858
+ resolveColor(line.fill ?? "slate-900") !== "transparent" &&
2859
+ (line.width ?? 1) > 0
2860
+ ) {
2861
+ context.strokeStyle = resolveColor(line.fill ?? "slate-900");
2862
+ context.lineWidth = line.width ?? 1;
2863
+ context.setLineDash(line.style === "dash" ? [8, 5] : line.style === "dot" ? [2, 4] : []);
2864
+ context.stroke();
2865
+ context.setLineDash([]);
2866
+ }
2867
+ }
2868
+
2869
+ function roundedRect(
2870
+ context: CanvasRenderingContext2D,
2871
+ x: number,
2872
+ y: number,
2873
+ width: number,
2874
+ height: number,
2875
+ radius: number,
2876
+ ): void {
2877
+ const r = Math.min(Math.max(0, radius), width / 2, height / 2);
2878
+ context.moveTo(x + r, y);
2879
+ context.lineTo(x + width - r, y);
2880
+ context.quadraticCurveTo(x + width, y, x + width, y + r);
2881
+ context.lineTo(x + width, y + height - r);
2882
+ context.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
2883
+ context.lineTo(x + r, y + height);
2884
+ context.quadraticCurveTo(x, y + height, x, y + height - r);
2885
+ context.lineTo(x, y + r);
2886
+ context.quadraticCurveTo(x, y, x + r, y);
2887
+ context.closePath();
2888
+ }
2889
+
2890
+ function hitTest(index: SpatialIndex, x: number, y: number): DisplayObject | null {
2891
+ const candidates = querySpatialIndex(index, { left: x, top: y, width: 0, height: 0 });
2892
+ for (let candidateIndex = candidates.length - 1; candidateIndex >= 0; candidateIndex -= 1) {
2893
+ const entry = index.objects[candidates[candidateIndex] ?? -1];
2894
+ if (entry && objectContainsPoint(entry, x, y)) return entry;
2895
+ }
2896
+ return null;
2897
+ }
2898
+
2899
+ function objectContainsPoint(entry: DisplayObject, x: number, y: number): boolean {
2900
+ const position = entry.object.position;
2901
+ const rotation = objectRotation(entry);
2902
+ if (rotation === 0) {
2903
+ return (
2904
+ x >= position.left &&
2905
+ x <= position.left + position.width &&
2906
+ y >= position.top &&
2907
+ y <= position.top + position.height
2908
+ );
2909
+ }
2910
+ const centerX = position.left + position.width / 2;
2911
+ const centerY = position.top + position.height / 2;
2912
+ const radians = (-rotation * Math.PI) / 180;
2913
+ const dx = x - centerX;
2914
+ const dy = y - centerY;
2915
+ const localX = dx * Math.cos(radians) - dy * Math.sin(radians) + centerX;
2916
+ const localY = dx * Math.sin(radians) + dy * Math.cos(radians) + centerY;
2917
+ return (
2918
+ localX >= position.left &&
2919
+ localX <= position.left + position.width &&
2920
+ localY >= position.top &&
2921
+ localY <= position.top + position.height
2922
+ );
2923
+ }
2924
+
2925
+ function objectBounds(entry: DisplayObject): PresentationPosition {
2926
+ const position = entry.object.position;
2927
+ const rotation = objectRotation(entry);
2928
+ if (rotation === 0) return position;
2929
+ const radians = (rotation * Math.PI) / 180;
2930
+ const width =
2931
+ Math.abs(position.width * Math.cos(radians)) + Math.abs(position.height * Math.sin(radians));
2932
+ const height =
2933
+ Math.abs(position.width * Math.sin(radians)) + Math.abs(position.height * Math.cos(radians));
2934
+ return {
2935
+ left: position.left + (position.width - width) / 2,
2936
+ top: position.top + (position.height - height) / 2,
2937
+ width,
2938
+ height,
2939
+ };
2940
+ }
2941
+
2942
+ function objectRotation(entry: DisplayObject): number {
2943
+ if (entry.kind === "shape") return finite((entry.object as PresentationShape).rotation, 0);
2944
+ if (entry.kind === "image") return finite((entry.object as PresentationImage).rotation, 0);
2945
+ if (entry.kind === "group") return finite((entry.object as PresentationGroup).rotation, 0);
2946
+ return 0;
2947
+ }
2948
+
2949
+ function objectLabel(entry: DisplayObject): string {
2950
+ const object = entry.object;
2951
+ if (entry.kind === "shape") {
2952
+ const shape = object as PresentationShape;
2953
+ return shape.text.toString().trim() || shape.name || "Shape";
2954
+ }
2955
+ if (entry.kind === "chart") return (object as PresentationChart).title || object.name || "Chart";
2956
+ if (entry.kind === "image") return (object as PresentationImage).alt || object.name || "Image";
2957
+ if (entry.kind === "table") return object.name || "Table";
2958
+ return object.name || "Group";
2959
+ }
2960
+
2961
+ function accessibleObjectLabel(entry: DisplayObject): string {
2962
+ const position = entry.object.position;
2963
+ return `${objectLabel(entry)}. Position ${Math.round(position.left)}, ${Math.round(position.top)}. Size ${Math.round(position.width)} by ${Math.round(position.height)}.`;
2964
+ }
2965
+
2966
+ function nextTextBoxPosition(
2967
+ objects: readonly DisplayObject[],
2968
+ slideSize: Readonly<{ width: number; height: number }>,
2969
+ ): PresentationPosition {
2970
+ const width = slideSize.width * 0.6;
2971
+ const height = Math.max(48, slideSize.height * 0.16);
2972
+ const lefts = [0.2, 0.08, 0.32].map((ratio) =>
2973
+ Math.max(0, Math.min(slideSize.width - width, slideSize.width * ratio)),
2974
+ );
2975
+ const tops = [0.2, 0.42, 0.64, 0.06].map((ratio) =>
2976
+ Math.max(0, Math.min(slideSize.height - height, slideSize.height * ratio)),
2977
+ );
2978
+ const occupied = objects.map(objectBounds);
2979
+ let best: PresentationPosition | null = null;
2980
+ let bestOverlap = Number.POSITIVE_INFINITY;
2981
+ for (const left of lefts) {
2982
+ for (const top of tops) {
2983
+ const candidate = normalizeEditorPosition({ left, top, width, height });
2984
+ const overlap = occupied.reduce(
2985
+ (total, bounds) => total + intersectionArea(candidate, bounds),
2986
+ 0,
2987
+ );
2988
+ if (overlap < bestOverlap) {
2989
+ best = candidate;
2990
+ bestOverlap = overlap;
2991
+ if (overlap === 0) return candidate;
2992
+ }
2993
+ }
2994
+ }
2995
+ return best ?? normalizeEditorPosition({ left: 0, top: 0, width, height });
2996
+ }
2997
+
2998
+ function intersectionArea(a: PresentationPosition, b: PresentationPosition): number {
2999
+ const width = Math.max(
3000
+ 0,
3001
+ Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left),
3002
+ );
3003
+ const height = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top));
3004
+ return width * height;
3005
+ }
3006
+
3007
+ function normalizeEditorPosition(position: PresentationPosition): PresentationPosition {
3008
+ return {
3009
+ left: clampGeometry(finite(position.left, 0)),
3010
+ top: clampGeometry(finite(position.top, 0)),
3011
+ width: Math.min(MAX_EDITOR_GEOMETRY, Math.max(8, finite(position.width, 8))),
3012
+ height: Math.min(MAX_EDITOR_GEOMETRY, Math.max(8, finite(position.height, 8))),
3013
+ };
3014
+ }
3015
+
3016
+ function clampGeometry(value: number): number {
3017
+ return Math.max(-MAX_EDITOR_GEOMETRY, Math.min(MAX_EDITOR_GEOMETRY, value));
3018
+ }
3019
+
3020
+ function positionFromDrag(drag: DragState, point: { x: number; y: number }): PresentationPosition {
3021
+ const dx = point.x - drag.start.x;
3022
+ const dy = point.y - drag.start.y;
3023
+ const radians = (-drag.rotation * Math.PI) / 180;
3024
+ const resizeDx = dx * Math.cos(radians) - dy * Math.sin(radians);
3025
+ const resizeDy = dx * Math.sin(radians) + dy * Math.cos(radians);
3026
+ return normalizeEditorPosition(
3027
+ drag.mode === "move"
3028
+ ? {
3029
+ ...drag.before,
3030
+ left: drag.before.left + dx,
3031
+ top: drag.before.top + dy,
3032
+ }
3033
+ : {
3034
+ ...drag.before,
3035
+ width: Math.max(8, drag.before.width + resizeDx),
3036
+ height: Math.max(8, drag.before.height + resizeDy),
3037
+ },
3038
+ );
3039
+ }
3040
+
3041
+ function selectionTransform(
3042
+ entry: DisplayObject,
3043
+ position: PresentationPosition,
3044
+ ): string | undefined {
3045
+ const rotation = objectRotation(entry);
3046
+ if (rotation === 0) return undefined;
3047
+ const centerX = position.left + position.width / 2;
3048
+ const centerY = position.top + position.height / 2;
3049
+ return `rotate(${rotation} ${centerX} ${centerY})`;
3050
+ }
3051
+
3052
+ function copyPosition(position: PresentationPosition): PresentationPosition {
3053
+ return {
3054
+ left: position.left,
3055
+ top: position.top,
3056
+ width: position.width,
3057
+ height: position.height,
3058
+ };
3059
+ }
3060
+
3061
+ function samePosition(a: PresentationPosition, b: PresentationPosition): boolean {
3062
+ return a.left === b.left && a.top === b.top && a.width === b.width && a.height === b.height;
3063
+ }
3064
+
3065
+ function sameViewport(a: ViewportMetrics, b: ViewportMetrics): boolean {
3066
+ return (
3067
+ a.scrollLeft === b.scrollLeft &&
3068
+ a.scrollTop === b.scrollTop &&
3069
+ a.width === b.width &&
3070
+ a.height === b.height &&
3071
+ a.stageLeft === b.stageLeft &&
3072
+ a.stageTop === b.stageTop
3073
+ );
3074
+ }
3075
+
3076
+ function intersects(a: PresentationPosition | undefined, b: PresentationPosition): boolean {
3077
+ return (
3078
+ !!a &&
3079
+ a.left <= b.left + b.width &&
3080
+ a.left + a.width >= b.left &&
3081
+ a.top <= b.top + b.height &&
3082
+ a.top + a.height >= b.top
3083
+ );
3084
+ }
3085
+
3086
+ function clampZoom(value: number): number {
3087
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, finite(value, 0.75)));
3088
+ }
3089
+
3090
+ function fittedPresentationZoom(
3091
+ viewport: Pick<ViewportMetrics, "width" | "height">,
3092
+ slideSize: Readonly<{ width: number; height: number }>,
3093
+ ): number {
3094
+ const horizontalRoom = Math.max(1, viewport.width - 48);
3095
+ const verticalRoom = Math.max(1, viewport.height - 48);
3096
+ return clampZoom(Math.min(horizontalRoom / slideSize.width, verticalRoom / slideSize.height));
3097
+ }
3098
+
3099
+ function finite(value: number, fallback: number): number {
3100
+ return Number.isFinite(value) ? value : fallback;
3101
+ }
3102
+
3103
+ function safeDomId(value: string): string {
3104
+ return value.replaceAll(/[^a-zA-Z0-9_-]/g, "-");
3105
+ }
3106
+
3107
+ function randomPresentationId(): string {
3108
+ for (;;) {
3109
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
3110
+ if (bytes.some((byte) => byte !== 0)) {
3111
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
3112
+ }
3113
+ }
3114
+ }
3115
+
3116
+ function resolveRadius(value: number | string | undefined, position: PresentationPosition): number {
3117
+ if (typeof value === "number") return value;
3118
+ const min = Math.min(position.width, position.height);
3119
+ const preset = value?.startsWith("rounded-") ? value.slice("rounded-".length) : undefined;
3120
+ if (preset === "full") return min / 2;
3121
+ if (preset === "2xl") return 16;
3122
+ if (preset === "xl") return 12;
3123
+ if (preset === "lg") return 8;
3124
+ return 6;
3125
+ }
3126
+
3127
+ const COLORS: Record<string, string> = {
3128
+ none: "transparent",
3129
+ transparent: "transparent",
3130
+ white: "#ffffff",
3131
+ black: "#000000",
3132
+ "slate-50": "#f8fafc",
3133
+ "slate-100": "#f1f5f9",
3134
+ "slate-200": "#e2e8f0",
3135
+ "slate-300": "#cbd5e1",
3136
+ "slate-400": "#94a3b8",
3137
+ "slate-500": "#64748b",
3138
+ "slate-600": "#475569",
3139
+ "slate-700": "#334155",
3140
+ "slate-800": "#1e293b",
3141
+ "slate-900": "#0f172a",
3142
+ "slate-950": "#020617",
3143
+ };
3144
+
3145
+ function resolveColor(fill: PresentationFill): string {
3146
+ const value = typeof fill === "string" ? fill : fill.color;
3147
+ return COLORS[value] ?? value;
3148
+ }