@agent-native/core 0.131.5 → 0.131.7

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 (350) hide show
  1. package/corpus/README.md +3 -3
  2. package/corpus/core/CHANGELOG.md +70 -0
  3. package/corpus/core/docs/content/agent-native-toolkit.mdx +13 -6
  4. package/corpus/core/docs/content/cloneable-saas.mdx +5 -5
  5. package/corpus/core/docs/content/creating-templates.mdx +9 -0
  6. package/corpus/core/docs/content/deployment.mdx +18 -12
  7. package/corpus/core/docs/content/doctor.mdx +8 -1
  8. package/corpus/core/docs/content/drop-in-agent.mdx +1 -1
  9. package/corpus/core/docs/content/extensions.mdx +18 -18
  10. package/corpus/core/docs/content/faq.mdx +4 -4
  11. package/corpus/core/docs/content/key-concepts.mdx +30 -21
  12. package/corpus/core/docs/content/security.mdx +2 -2
  13. package/corpus/core/docs/content/template-brain-developers.mdx +4 -1
  14. package/corpus/core/docs/content/template-mail-developers.mdx +4 -1
  15. package/corpus/core/docs/content/what-is-agent-native.mdx +6 -6
  16. package/corpus/core/package.json +1 -1
  17. package/corpus/core/src/a2a/client.ts +449 -66
  18. package/corpus/core/src/a2a/correlation.ts +31 -0
  19. package/corpus/core/src/a2a/invoke.ts +4 -2
  20. package/corpus/core/src/a2a/types.ts +4 -0
  21. package/corpus/core/src/action.ts +3 -0
  22. package/corpus/core/src/agent/action-continuation-guidance.ts +2 -1
  23. package/corpus/core/src/agent/engine/registry.ts +98 -6
  24. package/corpus/core/src/agent/production-agent.ts +151 -18
  25. package/corpus/core/src/agent/run-loop-with-resume.ts +190 -3
  26. package/corpus/core/src/agent/run-manager.ts +117 -41
  27. package/corpus/core/src/agent/thread-debug-history.ts +86 -0
  28. package/corpus/core/src/agent/types.ts +3 -1
  29. package/corpus/core/src/cli/create.ts +11 -1
  30. package/corpus/core/src/cli/templates-meta.ts +2 -2
  31. package/corpus/core/src/client/agent-chat-adapter.ts +91 -13
  32. package/corpus/core/src/client/resources/McpIntegrationDialog.tsx +11 -9
  33. package/corpus/core/src/client/sse-event-processor.ts +17 -3
  34. package/corpus/core/src/db/client.ts +57 -17
  35. package/corpus/core/src/guards/no-env-credentials.ts +2 -0
  36. package/corpus/core/src/integrations/adapters/slack.ts +8 -3
  37. package/corpus/core/src/localization/default-messages.ts +44 -0
  38. package/corpus/core/src/observability/traces.ts +78 -5
  39. package/corpus/core/src/scripts/call-agent.ts +294 -11
  40. package/corpus/core/src/scripts/describe-workspace-apps.ts +2 -2
  41. package/corpus/core/src/secrets/crypto.ts +156 -35
  42. package/corpus/core/src/secrets/storage.ts +64 -26
  43. package/corpus/core/src/server/agent-capabilities.ts +1 -1
  44. package/corpus/core/src/server/agent-chat/action-filters-a2a.ts +113 -7
  45. package/corpus/core/src/server/agent-chat/context-tools.ts +2 -0
  46. package/corpus/core/src/server/agent-chat/plugin-options.ts +34 -1
  47. package/corpus/core/src/server/agent-chat/prompt-resources.ts +1 -1
  48. package/corpus/core/src/server/agent-chat-plugin.ts +91 -59
  49. package/corpus/core/src/templates/default/.agents/skills/secrets/SKILL.md +19 -7
  50. package/corpus/core/src/templates/headless/.agents/skills/secrets/SKILL.md +19 -7
  51. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +18 -7
  52. package/corpus/core/src/templates/workspace-core/.agents/skills/composable-mini-apps/SKILL.md +15 -14
  53. package/corpus/core/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +19 -7
  54. package/corpus/core/src/vite/client.ts +1 -0
  55. package/corpus/templates/analytics/.agents/skills/dashboard-ops/SKILL.md +5 -4
  56. package/corpus/templates/analytics/.agents/skills/gong/SKILL.md +6 -4
  57. package/corpus/templates/analytics/AGENTS.md +7 -4
  58. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +4 -3
  59. package/corpus/templates/analytics/changelog/2026-07-30-delegated-analytics-questions-now-return-a-useful-current-me.md +6 -0
  60. package/corpus/templates/analytics/changelog/2026-07-30-scheduled-dashboard-emails-complete-every-panel-without-pool.md +6 -0
  61. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +28 -1
  62. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +18 -11
  63. package/corpus/templates/analytics/server/lib/analytics-connector-catalog.ts +4 -4
  64. package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +9 -5
  65. package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +6 -2
  66. package/corpus/templates/analytics/server/lib/dashboard-panel-source-resolver.ts +11 -4
  67. package/corpus/templates/analytics/server/lib/dashboard-report-render.ts +49 -31
  68. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +16 -8
  69. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +9 -3
  70. package/corpus/templates/analytics/server/lib/production-serverless-runtime.ts +11 -0
  71. package/corpus/templates/analytics/server/lib/real-data-actions.ts +10 -0
  72. package/corpus/templates/analytics/server/plugins/agent-chat.ts +3 -2
  73. package/corpus/templates/analytics/server/plugins/analytics-alert-jobs.ts +2 -1
  74. package/corpus/templates/analytics/server/plugins/dashboard-report-jobs.ts +2 -1
  75. package/corpus/templates/analytics/server/plugins/uptime-monitor-jobs.ts +1 -12
  76. package/corpus/templates/brain/.agents/skills/brain/RUNBOOK.md +54 -6
  77. package/corpus/templates/brain/.agents/skills/brain/SKILL.md +20 -9
  78. package/corpus/templates/brain/.agents/skills/ingestion-and-connectors/SKILL.md +33 -1
  79. package/corpus/templates/brain/AGENTS.md +3 -1
  80. package/corpus/templates/brain/README.md +4 -1
  81. package/corpus/templates/brain/actions/_schemas.ts +30 -8
  82. package/corpus/templates/brain/actions/ask-brain.ts +124 -14
  83. package/corpus/templates/brain/actions/create-source.ts +18 -2
  84. package/corpus/templates/brain/actions/enqueue-captures-distillation.ts +8 -123
  85. package/corpus/templates/brain/actions/enqueue-distillation.ts +6 -127
  86. package/corpus/templates/brain/actions/import-capture.ts +17 -5
  87. package/corpus/templates/brain/actions/import-transcript.ts +17 -5
  88. package/corpus/templates/brain/actions/update-source.ts +25 -5
  89. package/corpus/templates/brain/app/components/layout/Sidebar.tsx +1 -1
  90. package/corpus/templates/brain/changelog/2026-07-30-blessed-resources-can-feed-cited-company-answers.md +6 -0
  91. package/corpus/templates/brain/changelog/2026-07-30-the-left-sidebar-no-longer-shows-a-blank-organization-placeh.md +6 -0
  92. package/corpus/templates/brain/server/lib/brain.ts +49 -19
  93. package/corpus/templates/brain/server/lib/distillation-queue.ts +147 -0
  94. package/corpus/templates/brain/server/lib/source-policy.ts +264 -0
  95. package/corpus/templates/brain/server/plugins/agent-chat.ts +3 -2
  96. package/corpus/templates/brain/server/routes/api/_agent-native/brain/ingest.post.ts +116 -19
  97. package/corpus/templates/clips/app/components/meetings/google-oauth-identity-notice.tsx +51 -0
  98. package/corpus/templates/clips/app/i18n/en-US.ts +4 -0
  99. package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +3 -1
  100. package/corpus/templates/clips/changelog/2026-07-30-google-calendar-warning.md +6 -0
  101. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +72 -8
  102. package/corpus/templates/content/changelog/2026-07-30-the-slash-command-hint-now-stays-on-only-the-active-empty-li.md +6 -0
  103. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +506 -11
  104. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +148 -17
  105. package/corpus/templates/design/app/components/design/canvas-interactions/design-canvas-interactions.ts +357 -0
  106. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +7 -466
  107. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +15 -311
  108. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +19 -997
  109. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +8 -1216
  110. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +31 -38
  111. package/corpus/templates/design/package.json +0 -1
  112. package/corpus/templates/dispatch/AGENTS.md +8 -0
  113. package/corpus/templates/dispatch/DEVELOPING.md +29 -0
  114. package/corpus/templates/dispatch/actions/view-screen.ts +34 -2
  115. package/corpus/templates/dispatch/app/i18n-data.ts +457 -0
  116. package/corpus/templates/dispatch/changelog/2026-07-30-delegated-dispatch-work-continues-reliably-in-the-background.md +6 -0
  117. package/corpus/templates/dispatch/changelog/2026-07-30-find-failed-runs-across-apps.md +6 -0
  118. package/corpus/templates/dispatch/netlify.toml +3 -0
  119. package/corpus/templates/plan/actions/get-visual-plan.ts +10 -6
  120. package/corpus/templates/plan/actions/patch-visual-plan-source.ts +117 -1
  121. package/corpus/templates/plan/actions/update-visual-plan.ts +56 -14
  122. package/corpus/templates/plan/app/pages/PlansPage.tsx +1 -1
  123. package/corpus/templates/plan/changelog/2026-07-30-plan-agents-recover-with-targeted-writes.md +6 -0
  124. package/corpus/templates/plan/shared/plan-content.ts +35 -0
  125. package/corpus/templates/slides/.agents/skills/analytics-data-for-decks/SKILL.md +8 -12
  126. package/corpus/templates/slides/AGENTS.md +4 -4
  127. package/corpus/templates/slides/actions/create-deck.ts +1 -1
  128. package/corpus/templates/slides/actions/duplicate-deck.ts +19 -4
  129. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +2 -10
  130. package/corpus/templates/slides/app/components/deck/MermaidRenderer.tsx +12 -1
  131. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +30 -13
  132. package/corpus/templates/slides/app/components/editor/DeckEditorSkeleton.tsx +35 -0
  133. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +63 -56
  134. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +21 -76
  135. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +1110 -292
  136. package/corpus/templates/slides/app/components/editor/SlideOverflowWarning.tsx +53 -0
  137. package/corpus/templates/slides/app/components/editor/SlideStyleInspector.tsx +235 -18
  138. package/corpus/templates/slides/app/components/editor/SpeakerNotesPanel.tsx +4 -1
  139. package/corpus/templates/slides/app/components/editor/canvas-interactions/index.ts +11 -0
  140. package/corpus/templates/slides/app/components/editor/canvas-interactions/slides-canvas-adapter.ts +129 -0
  141. package/corpus/templates/slides/app/components/editor/selection-overlay-measurement.ts +70 -0
  142. package/corpus/templates/slides/app/components/editor/slide-object-interactions.ts +519 -65
  143. package/corpus/templates/slides/app/components/visual-editor/CanvasCommentPins.tsx +36 -426
  144. package/corpus/templates/slides/app/components/visual-editor/DrawOverlay.tsx +19 -458
  145. package/corpus/templates/slides/app/context/DeckContext.tsx +179 -72
  146. package/corpus/templates/slides/app/global.css +105 -0
  147. package/corpus/templates/slides/app/i18n/en-US.ts +8 -0
  148. package/corpus/templates/slides/app/lib/deck-editor-loading.ts +26 -0
  149. package/corpus/templates/slides/app/lib/mermaid-blocks.ts +27 -0
  150. package/corpus/templates/slides/app/pages/DeckEditor.tsx +71 -38
  151. package/corpus/templates/slides/app/pages/Index.tsx +98 -41
  152. package/corpus/templates/slides/changelog/2026-07-30-deck-loading-no-longer-flashes-an-unavailable-message.md +6 -0
  153. package/corpus/templates/slides/changelog/2026-07-30-duplicating-a-deck-from-the-deck-list-now-opens-the-copy-imm.md +6 -0
  154. package/corpus/templates/slides/changelog/2026-07-30-layout-overflow-warning-is-readable-and-dismissible.md +6 -0
  155. package/corpus/templates/slides/changelog/2026-07-30-new-decks-now-start-with-an-empty-slide-list-when-no-slides-.md +6 -0
  156. package/corpus/templates/slides/changelog/2026-07-30-shared-canvas-annotations.md +6 -0
  157. package/corpus/templates/slides/changelog/2026-07-30-slide-objects-can-be-moved-as-a-group-copied-and-pasted-with.md +6 -0
  158. package/corpus/templates/slides/changelog/2026-07-30-slide-text-editing-and-object-controls-now-match-google-slides.md +6 -0
  159. package/corpus/templates/slides/changelog/2026-07-30-style-panel-now-scrolls-with-speaker-notes-open-shows-the-sl.md +6 -0
  160. package/corpus/templates/slides/changelog/2026-07-30-top-toolbar-controls-now-use-consistent-sizing-with-undo-and.md +6 -0
  161. package/corpus/templates/slides/netlify.toml +3 -1
  162. package/corpus/templates/slides/package.json +0 -1
  163. package/corpus/templates/slides/server/lib/chat-attachments.ts +18 -0
  164. package/corpus/templates/slides/server/plugins/agent-chat.ts +5 -0
  165. package/corpus/templates/slides/vite.config.ts +0 -1
  166. package/corpus/toolkit/CHANGELOG.md +13 -0
  167. package/corpus/toolkit/README.md +19 -0
  168. package/corpus/toolkit/agent-native.eject.json +38 -0
  169. package/corpus/toolkit/package.json +29 -1
  170. package/corpus/toolkit/src/canvas-annotations/CanvasCommentPins.tsx +861 -0
  171. package/corpus/toolkit/src/canvas-annotations/DrawOverlay.tsx +1233 -0
  172. package/corpus/toolkit/src/canvas-annotations/index.ts +15 -0
  173. package/corpus/toolkit/src/canvas-annotations/types.ts +14 -0
  174. package/corpus/toolkit/src/canvas-interactions/canvas-interactions.ts +933 -0
  175. package/corpus/toolkit/src/canvas-interactions/index.ts +67 -0
  176. package/corpus/toolkit/src/design-tweaks/scrub-input-utils.ts +311 -0
  177. package/corpus/toolkit/src/design-tweaks/scrub-input.tsx +491 -0
  178. package/corpus/toolkit/src/design-tweaks/visual-style-controls.tsx +26 -171
  179. package/corpus/toolkit/src/index.ts +1 -0
  180. package/dist/a2a/client.d.ts +23 -0
  181. package/dist/a2a/client.d.ts.map +1 -1
  182. package/dist/a2a/client.js +309 -44
  183. package/dist/a2a/client.js.map +1 -1
  184. package/dist/a2a/correlation.d.ts +1 -0
  185. package/dist/a2a/correlation.d.ts.map +1 -1
  186. package/dist/a2a/correlation.js +27 -0
  187. package/dist/a2a/correlation.js.map +1 -1
  188. package/dist/a2a/invoke.d.ts.map +1 -1
  189. package/dist/a2a/invoke.js +4 -2
  190. package/dist/a2a/invoke.js.map +1 -1
  191. package/dist/a2a/types.d.ts +4 -0
  192. package/dist/a2a/types.d.ts.map +1 -1
  193. package/dist/a2a/types.js.map +1 -1
  194. package/dist/action.d.ts +3 -0
  195. package/dist/action.d.ts.map +1 -1
  196. package/dist/action.js.map +1 -1
  197. package/dist/agent/action-continuation-guidance.d.ts.map +1 -1
  198. package/dist/agent/action-continuation-guidance.js +2 -1
  199. package/dist/agent/action-continuation-guidance.js.map +1 -1
  200. package/dist/agent/engine/registry.d.ts.map +1 -1
  201. package/dist/agent/engine/registry.js +82 -8
  202. package/dist/agent/engine/registry.js.map +1 -1
  203. package/dist/agent/production-agent.d.ts +40 -1
  204. package/dist/agent/production-agent.d.ts.map +1 -1
  205. package/dist/agent/production-agent.js +113 -15
  206. package/dist/agent/production-agent.js.map +1 -1
  207. package/dist/agent/run-loop-with-resume.d.ts +21 -0
  208. package/dist/agent/run-loop-with-resume.d.ts.map +1 -1
  209. package/dist/agent/run-loop-with-resume.js +163 -4
  210. package/dist/agent/run-loop-with-resume.js.map +1 -1
  211. package/dist/agent/run-manager.d.ts +16 -9
  212. package/dist/agent/run-manager.d.ts.map +1 -1
  213. package/dist/agent/run-manager.js +93 -42
  214. package/dist/agent/run-manager.js.map +1 -1
  215. package/dist/agent/thread-debug-history.d.ts +10 -0
  216. package/dist/agent/thread-debug-history.d.ts.map +1 -0
  217. package/dist/agent/thread-debug-history.js +68 -0
  218. package/dist/agent/thread-debug-history.js.map +1 -0
  219. package/dist/agent/types.d.ts +3 -1
  220. package/dist/agent/types.d.ts.map +1 -1
  221. package/dist/agent/types.js.map +1 -1
  222. package/dist/cli/create.d.ts.map +1 -1
  223. package/dist/cli/create.js +6 -1
  224. package/dist/cli/create.js.map +1 -1
  225. package/dist/cli/templates-meta.js +2 -2
  226. package/dist/cli/templates-meta.js.map +1 -1
  227. package/dist/client/agent-chat-adapter.d.ts +1 -1
  228. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  229. package/dist/client/agent-chat-adapter.js +76 -13
  230. package/dist/client/agent-chat-adapter.js.map +1 -1
  231. package/dist/client/resources/McpIntegrationDialog.d.ts.map +1 -1
  232. package/dist/client/resources/McpIntegrationDialog.js +1 -1
  233. package/dist/client/resources/McpIntegrationDialog.js.map +1 -1
  234. package/dist/client/sse-event-processor.d.ts.map +1 -1
  235. package/dist/client/sse-event-processor.js +12 -3
  236. package/dist/client/sse-event-processor.js.map +1 -1
  237. package/dist/collab/routes.d.ts +1 -1
  238. package/dist/db/client.d.ts.map +1 -1
  239. package/dist/db/client.js +48 -17
  240. package/dist/db/client.js.map +1 -1
  241. package/dist/guards/no-env-credentials.d.ts.map +1 -1
  242. package/dist/guards/no-env-credentials.js +2 -0
  243. package/dist/guards/no-env-credentials.js.map +1 -1
  244. package/dist/integrations/adapters/slack.js +7 -3
  245. package/dist/integrations/adapters/slack.js.map +1 -1
  246. package/dist/localization/default-messages.d.ts +43 -0
  247. package/dist/localization/default-messages.d.ts.map +1 -1
  248. package/dist/localization/default-messages.js +43 -0
  249. package/dist/localization/default-messages.js.map +1 -1
  250. package/dist/observability/traces.d.ts +3 -1
  251. package/dist/observability/traces.d.ts.map +1 -1
  252. package/dist/observability/traces.js +73 -4
  253. package/dist/observability/traces.js.map +1 -1
  254. package/dist/progress/routes.d.ts +1 -1
  255. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  256. package/dist/provider-api/actions/provider-api.d.ts +13 -13
  257. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  258. package/dist/scripts/call-agent.d.ts.map +1 -1
  259. package/dist/scripts/call-agent.js +237 -12
  260. package/dist/scripts/call-agent.js.map +1 -1
  261. package/dist/scripts/describe-workspace-apps.js +2 -2
  262. package/dist/scripts/describe-workspace-apps.js.map +1 -1
  263. package/dist/secrets/crypto.d.ts +36 -21
  264. package/dist/secrets/crypto.d.ts.map +1 -1
  265. package/dist/secrets/crypto.js +134 -33
  266. package/dist/secrets/crypto.js.map +1 -1
  267. package/dist/secrets/storage.d.ts +6 -6
  268. package/dist/secrets/storage.d.ts.map +1 -1
  269. package/dist/secrets/storage.js +43 -26
  270. package/dist/secrets/storage.js.map +1 -1
  271. package/dist/server/agent-capabilities.js +1 -1
  272. package/dist/server/agent-capabilities.js.map +1 -1
  273. package/dist/server/agent-chat/action-filters-a2a.d.ts +17 -3
  274. package/dist/server/agent-chat/action-filters-a2a.d.ts.map +1 -1
  275. package/dist/server/agent-chat/action-filters-a2a.js +94 -3
  276. package/dist/server/agent-chat/action-filters-a2a.js.map +1 -1
  277. package/dist/server/agent-chat/context-tools.d.ts.map +1 -1
  278. package/dist/server/agent-chat/context-tools.js +2 -0
  279. package/dist/server/agent-chat/context-tools.js.map +1 -1
  280. package/dist/server/agent-chat/plugin-options.d.ts +23 -0
  281. package/dist/server/agent-chat/plugin-options.d.ts.map +1 -1
  282. package/dist/server/agent-chat/plugin-options.js +4 -1
  283. package/dist/server/agent-chat/plugin-options.js.map +1 -1
  284. package/dist/server/agent-chat/prompt-resources.js +1 -1
  285. package/dist/server/agent-chat/prompt-resources.js.map +1 -1
  286. package/dist/server/agent-chat-plugin.d.ts +2 -1
  287. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  288. package/dist/server/agent-chat-plugin.js +79 -56
  289. package/dist/server/agent-chat-plugin.js.map +1 -1
  290. package/dist/server/realtime-token.d.ts +1 -1
  291. package/dist/templates/default/.agents/skills/secrets/SKILL.md +19 -7
  292. package/dist/templates/headless/.agents/skills/secrets/SKILL.md +19 -7
  293. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +18 -7
  294. package/dist/templates/workspace-core/.agents/skills/composable-mini-apps/SKILL.md +15 -14
  295. package/dist/templates/workspace-core/.agents/skills/secrets/SKILL.md +19 -7
  296. package/dist/vite/client.d.ts.map +1 -1
  297. package/dist/vite/client.js +1 -0
  298. package/dist/vite/client.js.map +1 -1
  299. package/docs/content/agent-native-toolkit.mdx +13 -6
  300. package/docs/content/cloneable-saas.mdx +5 -5
  301. package/docs/content/creating-templates.mdx +9 -0
  302. package/docs/content/deployment.mdx +18 -12
  303. package/docs/content/doctor.mdx +8 -1
  304. package/docs/content/drop-in-agent.mdx +1 -1
  305. package/docs/content/extensions.mdx +18 -18
  306. package/docs/content/faq.mdx +4 -4
  307. package/docs/content/key-concepts.mdx +30 -21
  308. package/docs/content/security.mdx +2 -2
  309. package/docs/content/template-brain-developers.mdx +4 -1
  310. package/docs/content/template-mail-developers.mdx +4 -1
  311. package/docs/content/what-is-agent-native.mdx +6 -6
  312. package/package.json +3 -3
  313. package/src/a2a/client.ts +449 -66
  314. package/src/a2a/correlation.ts +31 -0
  315. package/src/a2a/invoke.ts +4 -2
  316. package/src/a2a/types.ts +4 -0
  317. package/src/action.ts +3 -0
  318. package/src/agent/action-continuation-guidance.ts +2 -1
  319. package/src/agent/engine/registry.ts +98 -6
  320. package/src/agent/production-agent.ts +151 -18
  321. package/src/agent/run-loop-with-resume.ts +190 -3
  322. package/src/agent/run-manager.ts +117 -41
  323. package/src/agent/thread-debug-history.ts +86 -0
  324. package/src/agent/types.ts +3 -1
  325. package/src/cli/create.ts +11 -1
  326. package/src/cli/templates-meta.ts +2 -2
  327. package/src/client/agent-chat-adapter.ts +91 -13
  328. package/src/client/resources/McpIntegrationDialog.tsx +11 -9
  329. package/src/client/sse-event-processor.ts +17 -3
  330. package/src/db/client.ts +57 -17
  331. package/src/guards/no-env-credentials.ts +2 -0
  332. package/src/integrations/adapters/slack.ts +8 -3
  333. package/src/localization/default-messages.ts +44 -0
  334. package/src/observability/traces.ts +78 -5
  335. package/src/scripts/call-agent.ts +294 -11
  336. package/src/scripts/describe-workspace-apps.ts +2 -2
  337. package/src/secrets/crypto.ts +156 -35
  338. package/src/secrets/storage.ts +64 -26
  339. package/src/server/agent-capabilities.ts +1 -1
  340. package/src/server/agent-chat/action-filters-a2a.ts +113 -7
  341. package/src/server/agent-chat/context-tools.ts +2 -0
  342. package/src/server/agent-chat/plugin-options.ts +34 -1
  343. package/src/server/agent-chat/prompt-resources.ts +1 -1
  344. package/src/server/agent-chat-plugin.ts +91 -59
  345. package/src/templates/default/.agents/skills/secrets/SKILL.md +19 -7
  346. package/src/templates/headless/.agents/skills/secrets/SKILL.md +19 -7
  347. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +18 -7
  348. package/src/templates/workspace-core/.agents/skills/composable-mini-apps/SKILL.md +15 -14
  349. package/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +19 -7
  350. package/src/vite/client.ts +1 -0
@@ -16,13 +16,7 @@ import {
16
16
  RecentEditHighlights,
17
17
  } from "@agent-native/toolkit/collab-ui";
18
18
  import { appStateKeyForBrowserTab } from "@shared/app-state-tabs";
19
- import {
20
- IconAlertTriangle,
21
- IconMaximize,
22
- IconX,
23
- IconZoomIn,
24
- IconZoomOut,
25
- } from "@tabler/icons-react";
19
+ import { IconMaximize, IconZoomIn, IconZoomOut } from "@tabler/icons-react";
26
20
  import {
27
21
  useState,
28
22
  useCallback,
@@ -55,13 +49,14 @@ import {
55
49
  CanvasCommentPins,
56
50
  MultiSelectChip,
57
51
  } from "@/components/visual-editor";
58
- import type { Slide } from "@/context/DeckContext";
52
+ import type { Slide, UpdateSlideOptions } from "@/context/DeckContext";
59
53
  import { getAspectRatioDims, type AspectRatio } from "@/lib/aspect-ratios";
60
54
  import {
61
55
  computeCanvasFitZoom,
62
56
  MAX_CANVAS_ZOOM,
63
57
  MIN_CANVAS_ZOOM,
64
58
  } from "@/lib/canvas-zoom";
59
+ import { extractMermaidBlocks } from "@/lib/mermaid-blocks";
65
60
  import {
66
61
  createPlaceholderImageTarget,
67
62
  imageFileLooksSupported,
@@ -71,6 +66,12 @@ import { enterSelectionMode } from "@/root";
71
66
 
72
67
  import type { DesignSystemData } from "../../../shared/api";
73
68
  import { BlockBubbleMenu } from "./BlockBubbleMenu";
69
+ import {
70
+ createSlidesCanvasGestureController,
71
+ isWithinSlidesCanvasEdgeMoveBand,
72
+ resolveSlidesCanvasPointerIntent,
73
+ slidesCanvasInteractionCore,
74
+ } from "./canvas-interactions";
74
75
  import ImageOverlay from "./ImageOverlay";
75
76
  import {
76
77
  applyInlineTextStyle,
@@ -81,29 +82,45 @@ import {
81
82
  type InlineTextStylePatch,
82
83
  type InlineTextStyleSnapshot,
83
84
  } from "./rich-text-selection";
85
+ import {
86
+ createSelectionOverlayAutofitKey,
87
+ createSelectionOverlayMeasurementKey,
88
+ currentSelectionOverlayRect,
89
+ isSelectionOverlayAutofitSettled,
90
+ isSelectionOverlayOnActiveSlide,
91
+ type SelectionOverlayMeasurement,
92
+ } from "./selection-overlay-measurement";
84
93
  import { decideSlideEscape } from "./slide-escape-arbiter";
85
94
  import {
95
+ applySlideObjectMoveDelta,
96
+ buildPastedSlideObjects,
86
97
  clientPointToSlideCoordinates,
87
98
  cloneSlideObject,
99
+ collectMovableSlideObjects,
100
+ copySlideObjects,
101
+ computeSlideObjectZOrder,
88
102
  createSlidesSelectionState,
89
103
  ensureSlideObjectId,
90
- escapedEditingSelection,
104
+ ensureSlideTextBoxCanvas,
91
105
  findSlideObjectById,
92
106
  freezeSlideElementForFreeform,
93
- getSelectedObjectDragStart,
107
+ getSlideTextBoxDefaultColor,
94
108
  getSlideSelectionIdentity,
95
109
  getSlideSelectionMode,
96
110
  removeSlideObjectAndLayoutSpacer,
97
111
  resolveSlideObjectContainingBlock,
98
- resizeSlideObject,
112
+ type CopiedSlideObjects,
99
113
  type ResizeHandle,
100
114
  type SlideObjectGeometry,
115
+ type SlideObjectZOrderTarget,
101
116
  type SlidesSelectionMode,
102
117
  type SlidesSelectionState as BaseSlidesSelectionState,
103
118
  type SlidesSelectionTool,
104
119
  } from "./slide-object-interactions";
105
120
  import { getPassiveSlidePresenceUsers } from "./slide-presence";
121
+ import { SlideOverflowWarning } from "./SlideOverflowWarning";
106
122
  import {
123
+ SlideBackgroundInspector,
107
124
  SlideStyleInspector,
108
125
  type SlideStylePatch,
109
126
  type SlideStyleSnapshot,
@@ -112,35 +129,17 @@ import { SpeakerNotesPanel } from "./SpeakerNotesPanel";
112
129
 
113
130
  let builderIdCounter = 0;
114
131
  const CANVAS_ZOOM_PRESETS = [10, 25, 50, 75, 100, 125, 150, 200] as const;
115
- // Keep the text itself a normal click-to-edit surface. Only the slim perimeter
116
- // is reserved for object movement, matching the familiar Slides interaction.
117
- const ELEMENT_EDGE_MOVE_BAND = 8;
118
-
119
- function isWithinElementEdgeMoveBand(
120
- rect: DOMRect,
121
- clientX: number,
122
- clientY: number,
123
- ): boolean {
124
- // Tiny labels still need a usable center click target. Cap the move band at
125
- // one quarter of either dimension so its opposite edges never consume it.
126
- const edgeBand = Math.min(
127
- ELEMENT_EDGE_MOVE_BAND,
128
- rect.width / 4,
129
- rect.height / 4,
130
- );
131
- const outerBand = edgeBand / 2;
132
- const withinExpandedBounds =
133
- clientX >= rect.left - outerBand &&
134
- clientX <= rect.right + outerBand &&
135
- clientY >= rect.top - outerBand &&
136
- clientY <= rect.bottom + outerBand;
137
- if (!withinExpandedBounds) return false;
138
132
 
133
+ /**
134
+ * Object operations are intentionally narrower than ordinary canvas
135
+ * selection. A flow-layout node may be selectable for inspection, but must
136
+ * never be copied, reordered, deleted, or moved as though it were a canvas
137
+ * object.
138
+ */
139
+ function isPersistedFreeformObject(element: HTMLElement): boolean {
139
140
  return (
140
- Math.abs(clientX - rect.left) <= edgeBand ||
141
- Math.abs(clientX - rect.right) <= edgeBand ||
142
- Math.abs(clientY - rect.top) <= edgeBand ||
143
- Math.abs(clientY - rect.bottom) <= edgeBand
141
+ Boolean(element.getAttribute("data-slide-object-id")) &&
142
+ window.getComputedStyle(element).position === "absolute"
144
143
  );
145
144
  }
146
145
 
@@ -440,6 +439,8 @@ function buildStyleSnapshot(
440
439
  const paddingRight = cssPx(computed.paddingRight);
441
440
  const paddingTop = cssPx(computed.paddingTop);
442
441
  const paddingBottom = cssPx(computed.paddingBottom);
442
+ const parsedZIndex = Number(computed.zIndex);
443
+ const zIndex = Number.isFinite(parsedZIndex) ? parsedZIndex : 0;
443
444
 
444
445
  const selectedTextStyle =
445
446
  inlineTextStyle?.scope === "selection" ? inlineTextStyle : null;
@@ -477,6 +478,7 @@ function buildStyleSnapshot(
477
478
  borderColor: normalizedColor(computed.borderTopColor),
478
479
  paddingX: Math.round((paddingLeft + paddingRight) / 2),
479
480
  paddingY: Math.round((paddingTop + paddingBottom) / 2),
481
+ zIndex,
480
482
  textStyleScope: inlineTextStyle?.scope ?? "block",
481
483
  mixedTextStyles: selectedTextStyle?.mixed ?? [],
482
484
  isAbsolute,
@@ -556,6 +558,7 @@ interface SlideEditorProps {
556
558
  onUpdateSlide: (
557
559
  updates: Partial<Omit<Slide, "id">>,
558
560
  slideIdOverride?: string,
561
+ options?: UpdateSlideOptions,
559
562
  ) => void;
560
563
  /** When true, all inline-edit affordances are disabled — the slide is
561
564
  * navigable but contentEditable / image overlays don't activate.
@@ -781,17 +784,15 @@ function ElementSelectionOutline({
781
784
  onResizeStart?: (handle: ResizeHandle, e: React.PointerEvent) => void;
782
785
  }) {
783
786
  const pad = 2;
784
- const handle = 7;
785
- const handleClass =
786
- "absolute size-[7px] touch-none rounded-sm border border-background bg-[#609FF8] shadow-sm";
787
+ const handleClass = "absolute touch-none rounded-sm";
787
788
  const edgeHandleClass =
788
789
  "absolute flex touch-none items-center justify-center bg-transparent p-0";
789
- const edgeBarClass =
790
- "rounded-sm border border-background bg-[#609FF8] shadow-sm";
790
+ const edgeBarClass = "rounded-sm";
791
791
  return (
792
792
  <SelectionOverlayPortal viewportRect={viewportRect} zIndex={51}>
793
793
  <div
794
794
  data-slide-selection-outline="true"
795
+ data-slide-selection-chrome="true"
795
796
  style={{
796
797
  position: "absolute",
797
798
  top: rect.top - pad,
@@ -799,7 +800,7 @@ function ElementSelectionOutline({
799
800
  width: rect.width + pad * 2,
800
801
  height: rect.height + pad * 2,
801
802
  pointerEvents: "none",
802
- border: "1.5px solid #609FF8",
803
+ border: "1px solid #609FF8",
803
804
  borderRadius: 3,
804
805
  boxShadow: "0 0 0 1px rgba(96, 159, 248, 0.2)",
805
806
  }}
@@ -809,8 +810,6 @@ function ElementSelectionOutline({
809
810
  onPointerDown={(e) => onResizeStart?.("nw", e)}
810
811
  className={handleClass}
811
812
  style={{
812
- left: -handle / 2,
813
- top: -handle / 2,
814
813
  pointerEvents: "auto",
815
814
  cursor: "nwse-resize",
816
815
  zIndex: 2,
@@ -821,8 +820,6 @@ function ElementSelectionOutline({
821
820
  onPointerDown={(e) => onResizeStart?.("ne", e)}
822
821
  className={handleClass}
823
822
  style={{
824
- right: -handle / 2,
825
- top: -handle / 2,
826
823
  pointerEvents: "auto",
827
824
  cursor: "nesw-resize",
828
825
  zIndex: 2,
@@ -833,8 +830,6 @@ function ElementSelectionOutline({
833
830
  onPointerDown={(e) => onResizeStart?.("sw", e)}
834
831
  className={handleClass}
835
832
  style={{
836
- left: -handle / 2,
837
- bottom: -handle / 2,
838
833
  pointerEvents: "auto",
839
834
  cursor: "nesw-resize",
840
835
  zIndex: 2,
@@ -845,8 +840,6 @@ function ElementSelectionOutline({
845
840
  onPointerDown={(e) => onResizeStart?.("se", e)}
846
841
  className={handleClass}
847
842
  style={{
848
- right: -handle / 2,
849
- bottom: -handle / 2,
850
843
  pointerEvents: "auto",
851
844
  cursor: "nwse-resize",
852
845
  zIndex: 2,
@@ -857,68 +850,48 @@ function ElementSelectionOutline({
857
850
  onPointerDown={(e) => onResizeStart?.("n", e)}
858
851
  className={edgeHandleClass}
859
852
  style={{
860
- width: 22,
861
- height: 12,
862
- left: "50%",
863
- top: -6,
864
- transform: "translateX(-50%)",
865
853
  pointerEvents: "auto",
866
854
  cursor: "ns-resize",
867
855
  zIndex: 1,
868
856
  }}
869
857
  >
870
- <span className={`${edgeBarClass} h-1 w-3.5`} />
858
+ <span data-slide-resize-handle-bar="true" className={edgeBarClass} />
871
859
  </span>
872
860
  <span
873
861
  data-slide-resize-handle="e"
874
862
  onPointerDown={(e) => onResizeStart?.("e", e)}
875
863
  className={edgeHandleClass}
876
864
  style={{
877
- width: 12,
878
- height: 22,
879
- right: -6,
880
- top: "50%",
881
- transform: "translateY(-50%)",
882
865
  pointerEvents: "auto",
883
866
  cursor: "ew-resize",
884
867
  zIndex: 1,
885
868
  }}
886
869
  >
887
- <span className={`${edgeBarClass} h-3.5 w-1`} />
870
+ <span data-slide-resize-handle-bar="true" className={edgeBarClass} />
888
871
  </span>
889
872
  <span
890
873
  data-slide-resize-handle="s"
891
874
  onPointerDown={(e) => onResizeStart?.("s", e)}
892
875
  className={edgeHandleClass}
893
876
  style={{
894
- width: 22,
895
- height: 12,
896
- left: "50%",
897
- bottom: -6,
898
- transform: "translateX(-50%)",
899
877
  pointerEvents: "auto",
900
878
  cursor: "ns-resize",
901
879
  zIndex: 1,
902
880
  }}
903
881
  >
904
- <span className={`${edgeBarClass} h-1 w-3.5`} />
882
+ <span data-slide-resize-handle-bar="true" className={edgeBarClass} />
905
883
  </span>
906
884
  <span
907
885
  data-slide-resize-handle="w"
908
886
  onPointerDown={(e) => onResizeStart?.("w", e)}
909
887
  className={edgeHandleClass}
910
888
  style={{
911
- width: 12,
912
- height: 22,
913
- left: -6,
914
- top: "50%",
915
- transform: "translateY(-50%)",
916
889
  pointerEvents: "auto",
917
890
  cursor: "ew-resize",
918
891
  zIndex: 1,
919
892
  }}
920
893
  >
921
- <span className={`${edgeBarClass} h-3.5 w-1`} />
894
+ <span data-slide-resize-handle-bar="true" className={edgeBarClass} />
922
895
  </span>
923
896
  </div>
924
897
  </SelectionOverlayPortal>
@@ -1130,17 +1103,53 @@ export default function SlideEditor({
1130
1103
  const [multiSelectionRects, setMultiSelectionRects] = useState<
1131
1104
  Map<string, { rect: DOMRect; text: string; selector: string }>
1132
1105
  >(() => new Map());
1106
+ const copiedObjectClipboardRef = useRef<{
1107
+ copied: CopiedSlideObjects;
1108
+ deckId?: string;
1109
+ slideId: string;
1110
+ sourceObjectId: string;
1111
+ } | null>(null);
1133
1112
  const [selectedElementPath, setSelectedElementPath] = useState<
1134
1113
  number[] | null
1135
1114
  >(null);
1136
1115
  const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null);
1116
+ const [selectedElementSlideId, setSelectedElementSlideId] = useState<
1117
+ string | null
1118
+ >(null);
1137
1119
  const [selectedElementSelector, setSelectedElementSelector] = useState<
1138
1120
  string | null
1139
1121
  >(null);
1140
- const [selectedElementRect, setSelectedElementRect] =
1141
- useState<DOMRect | null>(null);
1122
+ const [selectedElementMeasurement, setSelectedElementMeasurement] =
1123
+ useState<SelectionOverlayMeasurement | null>(null);
1124
+ const [selectionMeasurementRevision, setSelectionMeasurementRevision] =
1125
+ useState(0);
1126
+ const canvasAutofitKey = createSelectionOverlayAutofitKey(slide.id, content);
1127
+ const [settledAutofitKey, setSettledAutofitKey] = useState<string | null>(
1128
+ null,
1129
+ );
1142
1130
  const [selectedStyleSnapshot, setSelectedStyleSnapshot] =
1143
1131
  useState<SlideStyleSnapshot | null>(null);
1132
+ const selectionOverlayMeasurementKey = createSelectionOverlayMeasurementKey({
1133
+ slideId: slide.id,
1134
+ content,
1135
+ objectId: selectedObjectId,
1136
+ selector: selectedElementSelector,
1137
+ path: selectedElementPath,
1138
+ canvasZoom,
1139
+ stylePanelOpen,
1140
+ revision: selectionMeasurementRevision,
1141
+ });
1142
+ const selectedElementRect = currentSelectionOverlayRect(
1143
+ selectedElementMeasurement,
1144
+ selectionOverlayMeasurementKey,
1145
+ );
1146
+ const invalidateSelectionOverlayMeasurement = useCallback(() => {
1147
+ setSelectedElementMeasurement(null);
1148
+ setSelectionMeasurementRevision((revision) => revision + 1);
1149
+ }, []);
1150
+ const handleAutofitSettled = useCallback(() => {
1151
+ setSettledAutofitKey(canvasAutofitKey);
1152
+ }, [canvasAutofitKey]);
1144
1153
  /** Anchor rect for the floating chip (the slide canvas) */
1145
1154
  const [chipAnchorRect, setChipAnchorRect] = useState<DOMRect | null>(null);
1146
1155
  /** Active marquee rectangle (viewport coords). null = not dragging. */
@@ -1158,6 +1167,8 @@ export default function SlideEditor({
1158
1167
  null,
1159
1168
  );
1160
1169
  const [isAskingAgentToFix, setIsAskingAgentToFix] = useState(false);
1170
+ const [isOverflowWarningDismissed, setIsOverflowWarningDismissed] =
1171
+ useState(false);
1161
1172
  const dims = getAspectRatioDims(aspectRatio);
1162
1173
  const [fitCanvasZoom, setFitCanvasZoom] = useState(100);
1163
1174
  const userSetCanvasZoomRef = useRef(false);
@@ -1276,6 +1287,7 @@ export default function SlideEditor({
1276
1287
  useEffect(() => {
1277
1288
  setOverflowInfo(null);
1278
1289
  setIsAskingAgentToFix(false);
1290
+ setIsOverflowWarningDismissed(false);
1279
1291
  syncOverflowToAppState(null);
1280
1292
  }, [slide.id, slide.content]);
1281
1293
 
@@ -1386,8 +1398,53 @@ export default function SlideEditor({
1386
1398
  const slideContent = containerRef.current?.querySelector(
1387
1399
  ".slide-content",
1388
1400
  ) as HTMLElement | null;
1389
- return slideContent ? stripBuilderIds(slideContent.innerHTML) : null;
1390
- }, []);
1401
+ if (!slideContent) return null;
1402
+ // SlideRenderer swaps each `<div class="mermaid">` for a
1403
+ // `data-mermaid-index` placeholder and renders the diagram as SVG via
1404
+ // MermaidRenderer — the live DOM never contains the original mermaid
1405
+ // syntax. Serializing it as-is here would permanently bake the rendered
1406
+ // SVG into slide.content and turn a diagram edited or moved alongside
1407
+ // (e.g. another text block on the same slide) into inert markup that can
1408
+ // never be resized, edited, or re-rendered again. Restore the original
1409
+ // `<div class="mermaid">` markup from slide.content — the untouched
1410
+ // source of truth — before saving.
1411
+ const clone = slideContent.cloneNode(true) as HTMLElement;
1412
+ const placeholders = clone.querySelectorAll("[data-mermaid-index]");
1413
+ // Look up source blocks by index before touching the DOM. If slide.content
1414
+ // changed since this placeholder last rendered (e.g. a concurrent update),
1415
+ // its index may no longer have a matching block — leave that placeholder's
1416
+ // node untouched rather than swapping in a marker with nothing to restore
1417
+ // it, which would otherwise persist as inert marker text.
1418
+ const { blocks } = extractMermaidBlocks(slide.content);
1419
+ // Swap each rendered node for a plain-text marker now, and splice the
1420
+ // real `<div class="mermaid">` markup back in as a raw string AFTER
1421
+ // stripBuilderIds() below. stripBuilderIds round-trips through
1422
+ // DOMParser + innerHTML, which HTML-escapes `>` in text nodes (mangling
1423
+ // `A --> B` into `A --&gt; B`) — the same reason SlideRenderer extracts
1424
+ // mermaid blocks before its own sanitization pass. Doing the real
1425
+ // substitution as a plain string replace, after all DOM round-trips,
1426
+ // avoids that entirely.
1427
+ const nonce = Math.random().toString(36).slice(2);
1428
+ const markerFor = (idx: number) => `__mermaid_${nonce}_${idx}__`;
1429
+ const restorable = new Map<number, string>();
1430
+ placeholders.forEach((placeholder) => {
1431
+ const idx = Number(placeholder.getAttribute("data-mermaid-index"));
1432
+ const definition = blocks[idx];
1433
+ if (definition === undefined) return;
1434
+ restorable.set(idx, definition);
1435
+ placeholder.replaceWith(
1436
+ clone.ownerDocument.createTextNode(markerFor(idx)),
1437
+ );
1438
+ });
1439
+ let html = stripBuilderIds(clone.innerHTML);
1440
+ restorable.forEach((definition, idx) => {
1441
+ html = html.replace(
1442
+ markerFor(idx),
1443
+ `<div class="mermaid">${definition}</div>`,
1444
+ );
1445
+ });
1446
+ return html;
1447
+ }, [slide.content]);
1391
1448
 
1392
1449
  const captureInlineEditDraft = useCallback(
1393
1450
  (slideId = slide.id) => {
@@ -1414,27 +1471,80 @@ export default function SlideEditor({
1414
1471
  * the rest of the slide does not reflow when Escape selects the block.
1415
1472
  */
1416
1473
  const freezeElementForFreeformSelection = useCallback(
1417
- (element: HTMLElement): HTMLElement => {
1474
+ (
1475
+ element: HTMLElement,
1476
+ ): { element: HTMLElement; restoreMarkdownTree?: () => void } | null => {
1418
1477
  if (window.getComputedStyle(element).position === "absolute") {
1419
1478
  ensureSlideObjectId(element);
1420
- return element;
1479
+ return { element };
1421
1480
  }
1422
1481
 
1423
- const fmdSlide = element.closest(".fmd-slide") as HTMLElement | null;
1424
- const positioningLayer = fmdSlide
1425
- ? (Array.from(fmdSlide.children).find(
1482
+ // Markdown slides normally render directly into their AutoFit root. Take
1483
+ // the visual snapshot before promotion, then use the same fmd canvas as
1484
+ // manually placed text boxes. Persisting a bare absolute Markdown node
1485
+ // would switch the renderer to raw HTML without its coordinate root.
1486
+ const originalRect = element.getBoundingClientRect();
1487
+ const originalComputed = window.getComputedStyle(element);
1488
+ let fmdSlide = element.closest(".fmd-slide") as HTMLElement | null;
1489
+ let positioningLayer: HTMLElement | null = null;
1490
+ let restoreMarkdownTree: (() => void) | undefined;
1491
+ if (fmdSlide) {
1492
+ positioningLayer =
1493
+ Array.from(fmdSlide.children).find(
1426
1494
  (child): child is HTMLElement =>
1427
1495
  child instanceof HTMLElement &&
1428
1496
  child.hasAttribute("data-fmd-autofit-content"),
1429
- ) ?? fmdSlide)
1430
- : null;
1431
- if (!positioningLayer) return element;
1497
+ ) ?? fmdSlide;
1498
+ } else {
1499
+ const markdownRoot = element.closest(".slide-content");
1500
+ const originalChildren = markdownRoot
1501
+ ? Array.from(markdownRoot.childNodes)
1502
+ : [];
1503
+ const originalClassName = element.className;
1504
+ const originalStyle = element.getAttribute("style");
1505
+ const originalContentEditable = element.getAttribute("contenteditable");
1506
+ const originalEditingBlock = element.getAttribute("data-editing-block");
1507
+ const promoted = containerRef.current
1508
+ ? ensureSlideTextBoxCanvas(containerRef.current)
1509
+ : null;
1510
+ // Two-column Markdown has independent AutoFit roots. Do not save one
1511
+ // column as raw HTML and silently discard the other one.
1512
+ if (!promoted) return null;
1513
+ fmdSlide = promoted.fmdSlide;
1514
+ positioningLayer = promoted.positioningLayer;
1515
+ // The promotion moves ReactMarkdown's live child nodes. Restore its
1516
+ // original tree after serializing the raw fmd HTML and before the
1517
+ // parent state write, otherwise React tries to delete a child that we
1518
+ // already moved and the Markdown-to-raw rerender can fail.
1519
+ restoreMarkdownTree = () => {
1520
+ if (!markdownRoot) return;
1521
+ for (const child of originalChildren) markdownRoot.append(child);
1522
+ fmdSlide?.remove();
1523
+ element.className = originalClassName;
1524
+ if (originalStyle === null) element.removeAttribute("style");
1525
+ else element.setAttribute("style", originalStyle);
1526
+ if (originalContentEditable === null) {
1527
+ element.removeAttribute("contenteditable");
1528
+ } else {
1529
+ element.setAttribute("contenteditable", originalContentEditable);
1530
+ }
1531
+ if (originalEditingBlock === null) {
1532
+ element.removeAttribute("data-editing-block");
1533
+ } else {
1534
+ element.setAttribute("data-editing-block", originalEditingBlock);
1535
+ }
1536
+ };
1537
+ }
1538
+
1539
+ // Markdown blocks become persisted raw HTML when Escape makes them
1540
+ // freeform, so their renderer canvas is the initial positioning layer.
1541
+ if (!positioningLayer) return { element };
1432
1542
 
1433
1543
  const containingBlock = resolveSlideObjectContainingBlock(
1434
1544
  element,
1435
1545
  positioningLayer,
1436
1546
  );
1437
- const elementRect = element.getBoundingClientRect();
1547
+ const elementRect = originalRect;
1438
1548
  const layerRect = containingBlock.getBoundingClientRect();
1439
1549
  if (
1440
1550
  !elementRect.width ||
@@ -1444,7 +1554,7 @@ export default function SlideEditor({
1444
1554
  !containingBlock.offsetWidth ||
1445
1555
  !containingBlock.offsetHeight
1446
1556
  ) {
1447
- return element;
1557
+ return { element, restoreMarkdownTree };
1448
1558
  }
1449
1559
 
1450
1560
  const { x, y } = clientPointToSlideCoordinates(
@@ -1456,7 +1566,6 @@ export default function SlideEditor({
1456
1566
  );
1457
1567
  const scaleX = containingBlock.offsetWidth / layerRect.width;
1458
1568
  const scaleY = containingBlock.offsetHeight / layerRect.height;
1459
- const computed = window.getComputedStyle(element);
1460
1569
  freezeSlideElementForFreeform(
1461
1570
  element,
1462
1571
  {
@@ -1466,14 +1575,30 @@ export default function SlideEditor({
1466
1575
  height: Math.round(elementRect.height * scaleY),
1467
1576
  },
1468
1577
  {
1469
- display: computed.display,
1470
- flexGrow: computed.flexGrow,
1471
- flexShrink: computed.flexShrink,
1472
- flexBasis: computed.flexBasis,
1473
- alignSelf: computed.alignSelf,
1578
+ display: originalComputed.display,
1579
+ flexGrow: originalComputed.flexGrow,
1580
+ flexShrink: originalComputed.flexShrink,
1581
+ flexBasis: originalComputed.flexBasis,
1582
+ alignSelf: originalComputed.alignSelf,
1583
+ },
1584
+ {
1585
+ color: originalComputed.color,
1586
+ direction: originalComputed.direction,
1587
+ fontFamily: originalComputed.fontFamily,
1588
+ fontSize: originalComputed.fontSize,
1589
+ fontStyle: originalComputed.fontStyle,
1590
+ fontWeight: originalComputed.fontWeight,
1591
+ letterSpacing: originalComputed.letterSpacing,
1592
+ lineHeight: originalComputed.lineHeight,
1593
+ textAlign: originalComputed.textAlign,
1594
+ textDecoration: originalComputed.textDecoration,
1595
+ textShadow: originalComputed.textShadow,
1596
+ textTransform: originalComputed.textTransform,
1597
+ whiteSpace: originalComputed.whiteSpace,
1598
+ wordSpacing: originalComputed.wordSpacing,
1474
1599
  },
1475
1600
  );
1476
- return element;
1601
+ return { element, restoreMarkdownTree };
1477
1602
  },
1478
1603
  [],
1479
1604
  );
@@ -1513,11 +1638,33 @@ export default function SlideEditor({
1513
1638
  richTextSelectionRef.current = null;
1514
1639
  setSelectedElementPath(null);
1515
1640
  setSelectedObjectId(null);
1641
+ setSelectedElementSlideId(null);
1516
1642
  setSelectedElementSelector(null);
1517
- setSelectedElementRect(null);
1643
+ setSelectedElementMeasurement(null);
1518
1644
  setSelectedStyleSnapshot(null);
1519
1645
  }, []);
1520
1646
 
1647
+ // `slide.background` paints the canvas wrapper, but a generated `.fmd-slide`
1648
+ // root usually carries its own inline background that covers the whole
1649
+ // canvas. Writing only the field would leave the picker looking broken on
1650
+ // exactly the slides the agent produces, so repaint the root as well when it
1651
+ // declares one.
1652
+ const applySlideBackground = useCallback(
1653
+ (background: string) => {
1654
+ const updates: Partial<Omit<Slide, "id">> = { background };
1655
+ const root = getSlideContent()?.querySelector(
1656
+ ".fmd-slide",
1657
+ ) as HTMLElement | null;
1658
+ if (root && (root.style.background || root.style.backgroundColor)) {
1659
+ root.style.background = background;
1660
+ const html = readCurrentSlideContentHtml();
1661
+ if (html !== null) updates.content = html;
1662
+ }
1663
+ onUpdateSlideRef.current(updates);
1664
+ },
1665
+ [getSlideContent, readCurrentSlideContentHtml],
1666
+ );
1667
+
1521
1668
  const selectElementForStyling = useCallback(
1522
1669
  (
1523
1670
  element: HTMLElement,
@@ -1531,8 +1678,12 @@ export default function SlideEditor({
1531
1678
  const snapshot = buildStyleSnapshot(element, selector);
1532
1679
  setSelectedElementPath(path);
1533
1680
  setSelectedObjectId(element.getAttribute("data-slide-object-id"));
1681
+ setSelectedElementSlideId(slide.id);
1534
1682
  setSelectedElementSelector(selector);
1535
- setSelectedElementRect(element.getBoundingClientRect());
1683
+ // Geometry can be invalidated by an async slide-content commit or
1684
+ // AutoFit transform. Reveal the portal only after the layout effect
1685
+ // measures this exact rendered selection.
1686
+ invalidateSelectionOverlayMeasurement();
1536
1687
  setSelectedStyleSnapshot(snapshot);
1537
1688
  syncSelectionToAppState(
1538
1689
  buildSelectionState(getSlideSelectionMode(snapshot, selectionMode), [
@@ -1540,7 +1691,12 @@ export default function SlideEditor({
1540
1691
  ]),
1541
1692
  );
1542
1693
  },
1543
- [buildSelectionState, getSlideContent],
1694
+ [
1695
+ buildSelectionState,
1696
+ getSlideContent,
1697
+ invalidateSelectionOverlayMeasurement,
1698
+ slide.id,
1699
+ ],
1544
1700
  );
1545
1701
 
1546
1702
  /** Exit edit mode, saving changes to slide.content */
@@ -1552,18 +1708,26 @@ export default function SlideEditor({
1552
1708
  el.contentEditable = "false";
1553
1709
  el.removeAttribute("data-editing-block");
1554
1710
 
1555
- const selected = freezeElementForFreeformSelection(el);
1556
- const selector = getBuilderSelector(selected);
1711
+ const frozen = freezeElementForFreeformSelection(el);
1712
+ const selected = frozen?.element ?? null;
1713
+ const selector = selected ? getBuilderSelector(selected) : null;
1557
1714
 
1558
- const html = readCurrentSlideContentHtml();
1715
+ const html = selected ? readCurrentSlideContentHtml() : null;
1716
+ frozen?.restoreMarkdownTree?.();
1559
1717
  if (html !== null) {
1560
1718
  onUpdateSlideRef.current({ content: html });
1561
1719
  }
1562
1720
  inlineEditDraftRef.current = null;
1563
- const next = escapedEditingSelection(selected, resolveSelectedElement());
1564
- setEditingEl(next.editing);
1565
- if (next.selected && selector) {
1566
- selectElementForStyling(next.selected, selector);
1721
+ const escape = slidesCanvasInteractionCore.escape({
1722
+ editingObjectId: "inline-editing",
1723
+ selectedObjectIds: resolveSelectedElement() ? ["selected"] : [],
1724
+ });
1725
+ setEditingEl(null);
1726
+ if (escape.action === "select-object" && selected && selector) {
1727
+ selectElementForStyling(selected, selector);
1728
+ } else if (escape.action === "clear-selection") {
1729
+ clearSelectedElement();
1730
+ syncSelectionToAppState(null);
1567
1731
  } else {
1568
1732
  syncSelectionToAppState(null);
1569
1733
  }
@@ -1572,6 +1736,8 @@ export default function SlideEditor({
1572
1736
  resolveSelectedElement,
1573
1737
  selectElementForStyling,
1574
1738
  freezeElementForFreeformSelection,
1739
+ clearSelectedElement,
1740
+ syncSelectionToAppState,
1575
1741
  ]);
1576
1742
 
1577
1743
  /** Enter edit mode on a smart block (text leaf or smart group) */
@@ -1583,7 +1749,7 @@ export default function SlideEditor({
1583
1749
  // Keep the inspector selection mounted while text is being edited. The
1584
1750
  // inspector is a stable dock, so clearing it here would make the canvas
1585
1751
  // resize and auto-fit again on the second click.
1586
- setSelectedElementRect(null);
1752
+ setSelectedElementMeasurement(null);
1587
1753
  captureInlineEditDraft(slide.id);
1588
1754
  // Mark the deck dirty immediately so SSE/poll refreshes do not replace
1589
1755
  // the deck under an active contentEditable edit, even before the user
@@ -1794,7 +1960,21 @@ export default function SlideEditor({
1794
1960
  }, [selectedImg]);
1795
1961
 
1796
1962
  useLayoutEffect(() => {
1797
- if (!selectedElementPath || !selectedElementSelector) return;
1963
+ if (
1964
+ !selectedElementPath ||
1965
+ !selectedElementSelector ||
1966
+ !isSelectionOverlayOnActiveSlide(selectedElementSlideId, slide.id)
1967
+ ) {
1968
+ return;
1969
+ }
1970
+ if (
1971
+ !isSelectionOverlayAutofitSettled(settledAutofitKey, canvasAutofitKey)
1972
+ ) {
1973
+ // AutoFit changes the element's viewport rect in a rAF. Do not publish
1974
+ // pre-fit coordinates into the portal while the renderer is settling.
1975
+ setSelectedElementMeasurement(null);
1976
+ return;
1977
+ }
1798
1978
  let observedElement: HTMLElement | null = null;
1799
1979
  const update = () => {
1800
1980
  const element = resolveSelectedElement();
@@ -1815,7 +1995,10 @@ export default function SlideEditor({
1815
1995
  selectedElementSelector,
1816
1996
  inlineTextStyle,
1817
1997
  );
1818
- setSelectedElementRect(element.getBoundingClientRect());
1998
+ setSelectedElementMeasurement({
1999
+ key: selectionOverlayMeasurementKey,
2000
+ rect: element.getBoundingClientRect(),
2001
+ });
1819
2002
  setSelectedStyleSnapshot(snapshot);
1820
2003
  syncSelectionToAppState(
1821
2004
  buildSelectionState(getSlideSelectionMode(snapshot), [
@@ -1839,7 +2022,13 @@ export default function SlideEditor({
1839
2022
  const mutationObserver =
1840
2023
  typeof MutationObserver === "undefined" || !positioningLayer
1841
2024
  ? null
1842
- : new MutationObserver(update);
2025
+ : new MutationObserver(() => {
2026
+ // The transform has already changed. Drop the old viewport rect
2027
+ // before publishing a fresh one so portal chrome cannot paint at
2028
+ // the element's pre-AutoFit coordinates.
2029
+ setSelectedElementMeasurement(null);
2030
+ update();
2031
+ });
1843
2032
  if (mutationObserver && positioningLayer) {
1844
2033
  mutationObserver.observe(positioningLayer, {
1845
2034
  attributes: true,
@@ -1859,7 +2048,11 @@ export default function SlideEditor({
1859
2048
  clearSelectedElement,
1860
2049
  resolveSelectedElement,
1861
2050
  selectedElementPath,
2051
+ selectedElementSlideId,
1862
2052
  selectedElementSelector,
2053
+ canvasAutofitKey,
2054
+ settledAutofitKey,
2055
+ selectionOverlayMeasurementKey,
1863
2056
  slide.content,
1864
2057
  ]);
1865
2058
 
@@ -1944,6 +2137,47 @@ export default function SlideEditor({
1944
2137
  applyMultiSelection(new Set());
1945
2138
  }, [applyMultiSelection, multiSelection.size]);
1946
2139
 
2140
+ /**
2141
+ * A group move/paste writes new slide content, which replaces the live DOM
2142
+ * (the saved HTML has no data-builder-id attributes — see stripBuilderIds).
2143
+ * `multiSelection` is keyed by those transient ids, so it goes stale the
2144
+ * instant the content commits. Stash the durable data-slide-object-ids here
2145
+ * and re-derive the (freshly stamped) builder ids once the new content has
2146
+ * actually rendered, so the on-canvas selection survives its own commit.
2147
+ */
2148
+ const pendingMultiSelectionResyncRef = useRef<string[] | null>(null);
2149
+
2150
+ const commitMultiObjectChange = useCallback(
2151
+ (objectIds: string[]) => {
2152
+ pendingMultiSelectionResyncRef.current = objectIds;
2153
+ const html = readCurrentSlideContentHtml();
2154
+ if (html !== null) onUpdateSlideRef.current({ content: html });
2155
+ },
2156
+ [readCurrentSlideContentHtml],
2157
+ );
2158
+
2159
+ useEffect(() => {
2160
+ const objectIds = pendingMultiSelectionResyncRef.current;
2161
+ pendingMultiSelectionResyncRef.current = null;
2162
+ if (!objectIds) {
2163
+ // Undo/redo, agent reconciliation, and external updates replace the DOM
2164
+ // without preserving transient builder ids. Never leave stale ids in a
2165
+ // multi-selection that could later target unrelated newly-stamped nodes.
2166
+ applyMultiSelection(new Set());
2167
+ return;
2168
+ }
2169
+ const slideContent = getSlideContent();
2170
+ if (!slideContent) return;
2171
+ stampBuilderIds(slideContent);
2172
+ const ids = new Set<string>();
2173
+ for (const objectId of objectIds) {
2174
+ const element = findSlideObjectById(slideContent, objectId);
2175
+ const builderId = element?.getAttribute("data-builder-id");
2176
+ if (builderId) ids.add(builderId);
2177
+ }
2178
+ applyMultiSelection(ids);
2179
+ }, [slide.content, getSlideContent, applyMultiSelection]);
2180
+
1947
2181
  // One Escape owner for the HTML editor. Radix dialogs/popovers and native
1948
2182
  // form controls retain their own Escape behavior before we arbitrate canvas
1949
2183
  // state. Gesture cancellation is deliberately ahead of selection clearing.
@@ -2062,17 +2296,15 @@ export default function SlideEditor({
2062
2296
  [getPlaceholderTarget],
2063
2297
  );
2064
2298
 
2065
- // Keep cached rects fresh on scroll/resize so outlines + chip stay aligned
2066
- useEffect(() => {
2067
- if (multiSelection.size === 0) return;
2068
- const update = () => {
2299
+ const refreshMultiSelectionRects = useCallback(
2300
+ (ids: Set<string>) => {
2069
2301
  const slideContent = getSlideContent();
2070
2302
  if (!slideContent) return;
2071
2303
  const next = new Map<
2072
2304
  string,
2073
2305
  { rect: DOMRect; text: string; selector: string }
2074
2306
  >();
2075
- multiSelection.forEach((id) => {
2307
+ ids.forEach((id) => {
2076
2308
  const el = slideContent.querySelector(
2077
2309
  `[data-builder-id="${id}"]`,
2078
2310
  ) as HTMLElement | null;
@@ -2088,6 +2320,17 @@ export default function SlideEditor({
2088
2320
  ".slide-image-clickable",
2089
2321
  ) as HTMLElement | null;
2090
2322
  setChipAnchorRect(canvas?.getBoundingClientRect() || null);
2323
+ },
2324
+ [getSlideContent],
2325
+ );
2326
+
2327
+ // Keep cached rects fresh on scroll/resize so outlines + chip stay aligned.
2328
+ // Group drag calls the same helper every pointer move so its outlines do not
2329
+ // lag behind the objects until the next scroll or resize.
2330
+ useEffect(() => {
2331
+ if (multiSelection.size === 0) return;
2332
+ const update = () => {
2333
+ refreshMultiSelectionRects(multiSelection);
2091
2334
  };
2092
2335
  window.addEventListener("resize", update);
2093
2336
  window.addEventListener("scroll", update, true);
@@ -2095,10 +2338,10 @@ export default function SlideEditor({
2095
2338
  window.removeEventListener("resize", update);
2096
2339
  window.removeEventListener("scroll", update, true);
2097
2340
  };
2098
- }, [multiSelection, getSlideContent]);
2341
+ }, [multiSelection, refreshMultiSelectionRects]);
2099
2342
 
2100
2343
  // Clear multi-selection when slide changes (and clear app state too)
2101
- useEffect(() => {
2344
+ useLayoutEffect(() => {
2102
2345
  setMultiSelection(new Set());
2103
2346
  setMultiSelectionRects(new Map());
2104
2347
  setChipAnchorRect(null);
@@ -2122,19 +2365,39 @@ export default function SlideEditor({
2122
2365
 
2123
2366
  const slideContent = getSlideContent();
2124
2367
  if (!slideContent) return;
2125
- e.preventDefault();
2126
2368
 
2127
2369
  if (multiSelection.size > 0) {
2128
- for (const id of multiSelection) {
2129
- const element = slideContent.querySelector(
2130
- `[data-builder-id="${id}"]`,
2131
- ) as HTMLElement | null;
2132
- if (element) removeSlideObjectAndLayoutSpacer(element);
2370
+ const selected = Array.from(multiSelection)
2371
+ .map(
2372
+ (id) =>
2373
+ slideContent.querySelector(
2374
+ `[data-builder-id="${id}"]`,
2375
+ ) as HTMLElement | null,
2376
+ )
2377
+ .filter((element): element is HTMLElement => element !== null);
2378
+ const roots = selected.filter(
2379
+ (element) =>
2380
+ !selected.some(
2381
+ (candidate) =>
2382
+ candidate !== element && candidate.contains(element),
2383
+ ),
2384
+ );
2385
+ if (
2386
+ roots.length === 0 ||
2387
+ roots.some((element) => !isPersistedFreeformObject(element))
2388
+ ) {
2389
+ return;
2390
+ }
2391
+ e.preventDefault();
2392
+ for (const element of roots) {
2393
+ removeSlideObjectAndLayoutSpacer(element);
2133
2394
  }
2134
2395
  clearMultiSelection();
2135
2396
  } else {
2136
2397
  const element = resolveSelectedElement();
2137
- if (element) removeSlideObjectAndLayoutSpacer(element);
2398
+ if (!element || !isPersistedFreeformObject(element)) return;
2399
+ e.preventDefault();
2400
+ removeSlideObjectAndLayoutSpacer(element);
2138
2401
  clearSelectedElement();
2139
2402
  }
2140
2403
 
@@ -2203,8 +2466,83 @@ export default function SlideEditor({
2203
2466
  [],
2204
2467
  );
2205
2468
 
2206
- const placeTextBoxAt = useCallback(
2207
- (clientX: number, clientY: number) => {
2469
+ /**
2470
+ * Return the current selection only when every selected root is a persisted
2471
+ * canvas object in one coordinate space. This deliberately refuses mixed
2472
+ * flow/freeform and cross-container selections: pretending their local
2473
+ * left/top values share a coordinate system would silently corrupt layout.
2474
+ */
2475
+ const getObjectOperationSelection = useCallback(() => {
2476
+ const slideContent = getSlideContent();
2477
+ if (!slideContent) return null;
2478
+
2479
+ const selectedElements =
2480
+ multiSelection.size > 0
2481
+ ? Array.from(multiSelection)
2482
+ .map(
2483
+ (id) =>
2484
+ slideContent.querySelector(
2485
+ `[data-builder-id="${id}"]`,
2486
+ ) as HTMLElement | null,
2487
+ )
2488
+ .filter((element): element is HTMLElement => element !== null)
2489
+ : (() => {
2490
+ const element = resolveSelectedElement();
2491
+ return element ? [element] : [];
2492
+ })();
2493
+ if (selectedElements.length === 0) return null;
2494
+
2495
+ const roots = selectedElements.filter(
2496
+ (element) =>
2497
+ !selectedElements.some(
2498
+ (candidate) => candidate !== element && candidate.contains(element),
2499
+ ),
2500
+ );
2501
+ if (
2502
+ roots.length === 0 ||
2503
+ roots.some((element) => !isPersistedFreeformObject(element))
2504
+ ) {
2505
+ return null;
2506
+ }
2507
+
2508
+ const fmdSlide = roots[0].closest(".fmd-slide") as HTMLElement | null;
2509
+ if (
2510
+ !fmdSlide ||
2511
+ roots.some((element) => element.closest(".fmd-slide") !== fmdSlide)
2512
+ ) {
2513
+ return null;
2514
+ }
2515
+ const positioningLayer =
2516
+ Array.from(fmdSlide.children).find(
2517
+ (child): child is HTMLElement =>
2518
+ child instanceof HTMLElement &&
2519
+ child.hasAttribute("data-fmd-autofit-content"),
2520
+ ) ?? fmdSlide;
2521
+ const containingBlock = resolveSlideObjectContainingBlock(
2522
+ roots[0],
2523
+ positioningLayer,
2524
+ );
2525
+ if (
2526
+ roots.some(
2527
+ (element) =>
2528
+ resolveSlideObjectContainingBlock(element, positioningLayer) !==
2529
+ containingBlock,
2530
+ )
2531
+ ) {
2532
+ return null;
2533
+ }
2534
+
2535
+ return { elements: roots, positioningLayer, containingBlock };
2536
+ }, [getSlideContent, multiSelection, resolveSelectedElement]);
2537
+
2538
+ /**
2539
+ * Paste (or duplicate-via-copy+paste) `copied` into the slide, anchored to
2540
+ * the same containing block as `anchorElement` (the object that was
2541
+ * selected when the shortcut fired) rather than always the slide root, so
2542
+ * an object nested inside a positioned group pastes back into that group.
2543
+ */
2544
+ const pasteSlideObjects = useCallback(
2545
+ (copied: CopiedSlideObjects, anchorElement: HTMLElement) => {
2208
2546
  const fmdSlide = containerRef.current?.querySelector(
2209
2547
  ".fmd-slide",
2210
2548
  ) as HTMLElement | null;
@@ -2215,6 +2553,151 @@ export default function SlideEditor({
2215
2553
  child instanceof HTMLElement &&
2216
2554
  child.hasAttribute("data-fmd-autofit-content"),
2217
2555
  ) ?? fmdSlide;
2556
+ const containingBlock = resolveSlideObjectContainingBlock(
2557
+ anchorElement,
2558
+ positioningLayer,
2559
+ );
2560
+
2561
+ const pasted = buildPastedSlideObjects(copied, document);
2562
+ if (pasted.length === 0) return;
2563
+
2564
+ const objectIds: string[] = [];
2565
+ for (const element of pasted) {
2566
+ containingBlock.appendChild(element);
2567
+ ensureBuilderId(element);
2568
+ stampBuilderIds(element);
2569
+ objectIds.push(ensureSlideObjectId(element));
2570
+ }
2571
+
2572
+ // A single pasted object gets the richer single-selection treatment
2573
+ // (resize handles, style inspector) instead of the multi-select outline.
2574
+ if (pasted.length === 1) {
2575
+ const selector = getBuilderSelector(pasted[0]);
2576
+ if (selector) selectElementForStyling(pasted[0], selector);
2577
+ const html = readCurrentSlideContentHtml();
2578
+ if (html !== null) onUpdateSlideRef.current({ content: html });
2579
+ return;
2580
+ }
2581
+
2582
+ const ids = new Set<string>();
2583
+ for (const element of pasted) {
2584
+ const builderId = element.getAttribute("data-builder-id");
2585
+ if (builderId) ids.add(builderId);
2586
+ }
2587
+ applyMultiSelection(ids);
2588
+ commitMultiObjectChange(objectIds);
2589
+ },
2590
+ [
2591
+ applyMultiSelection,
2592
+ commitMultiObjectChange,
2593
+ readCurrentSlideContentHtml,
2594
+ selectElementForStyling,
2595
+ ],
2596
+ );
2597
+
2598
+ // Object clipboard contents are editor-local. It intentionally does not
2599
+ // survive a deck switch, unlike the browser's native text clipboard.
2600
+ useEffect(() => {
2601
+ copiedObjectClipboardRef.current = null;
2602
+ }, [deckId]);
2603
+
2604
+ // One window listener for object copy/paste/duplicate. Native text
2605
+ // copy/paste must always win: bail the instant a text edit is active or
2606
+ // focus is on any form control, BEFORE touching the clipboard or selection,
2607
+ // so ordinary Cmd/Ctrl+C/V/D typing is never hijacked.
2608
+ useEffect(() => {
2609
+ if (readOnly) return;
2610
+ const onKey = (e: KeyboardEvent) => {
2611
+ if (!(e.metaKey || e.ctrlKey)) return;
2612
+ const key = e.key.toLowerCase();
2613
+ if (key !== "c" && key !== "v" && key !== "d") return;
2614
+
2615
+ const active = document.activeElement;
2616
+ const isTextSurface =
2617
+ active instanceof HTMLInputElement ||
2618
+ active instanceof HTMLTextAreaElement ||
2619
+ active instanceof HTMLSelectElement ||
2620
+ (active instanceof HTMLElement && active.isContentEditable);
2621
+ if (editingEl || isTextSurface) return;
2622
+
2623
+ const selection = getObjectOperationSelection();
2624
+ // Only claim the shortcut for persisted freeform roots which share a
2625
+ // containing block. Everything else remains native browser behavior.
2626
+ if (!selection) return;
2627
+
2628
+ const copySelection = () => {
2629
+ const copied = copySlideObjects(selection.elements);
2630
+ const sourceObjectId = selection.elements[0].getAttribute(
2631
+ "data-slide-object-id",
2632
+ );
2633
+ if (!sourceObjectId) return null;
2634
+ copiedObjectClipboardRef.current = {
2635
+ copied,
2636
+ deckId,
2637
+ slideId: slide.id,
2638
+ sourceObjectId,
2639
+ };
2640
+ return copiedObjectClipboardRef.current;
2641
+ };
2642
+
2643
+ if (key === "c") {
2644
+ e.preventDefault();
2645
+ copySelection();
2646
+ return;
2647
+ }
2648
+
2649
+ if (key === "v") {
2650
+ const clipboard = copiedObjectClipboardRef.current;
2651
+ if (
2652
+ !clipboard ||
2653
+ clipboard.deckId !== deckId ||
2654
+ clipboard.slideId !== slide.id
2655
+ ) {
2656
+ return;
2657
+ }
2658
+ const slideContent = getSlideContent();
2659
+ const source =
2660
+ slideContent &&
2661
+ findSlideObjectById(slideContent, clipboard.sourceObjectId);
2662
+ if (
2663
+ !source ||
2664
+ resolveSlideObjectContainingBlock(
2665
+ source,
2666
+ selection.positioningLayer,
2667
+ ) !== selection.containingBlock
2668
+ ) {
2669
+ return;
2670
+ }
2671
+ e.preventDefault();
2672
+ pasteSlideObjects(clipboard.copied, selection.elements[0]);
2673
+ return;
2674
+ }
2675
+
2676
+ // Duplicate re-copies the live selection so it duplicates what's
2677
+ // currently selected regardless of what's on the clipboard.
2678
+ e.preventDefault();
2679
+ const clipboard = copySelection();
2680
+ if (clipboard) pasteSlideObjects(clipboard.copied, selection.elements[0]);
2681
+ };
2682
+ window.addEventListener("keydown", onKey);
2683
+ return () => window.removeEventListener("keydown", onKey);
2684
+ }, [
2685
+ deckId,
2686
+ editingEl,
2687
+ getSlideContent,
2688
+ getObjectOperationSelection,
2689
+ pasteSlideObjects,
2690
+ readOnly,
2691
+ slide.id,
2692
+ ]);
2693
+
2694
+ const placeTextBoxAt = useCallback(
2695
+ (clientX: number, clientY: number, target: HTMLElement | null) => {
2696
+ const canvas = containerRef.current
2697
+ ? ensureSlideTextBoxCanvas(containerRef.current)
2698
+ : null;
2699
+ if (!canvas) return;
2700
+ const { fmdSlide, positioningLayer } = canvas;
2218
2701
  const rect = positioningLayer.getBoundingClientRect();
2219
2702
  const { x, y } = clientPointToSlideCoordinates(
2220
2703
  clientX,
@@ -2237,7 +2720,7 @@ export default function SlideEditor({
2237
2720
  box.style.top = `${y}px`;
2238
2721
  box.style.width = "320px";
2239
2722
  box.style.fontSize = "24px";
2240
- box.style.color = "#fff";
2723
+ box.style.color = getSlideTextBoxDefaultColor(target, positioningLayer);
2241
2724
  box.style.fontFamily = "'Poppins', sans-serif";
2242
2725
  box.style.lineHeight = "1.3";
2243
2726
  box.textContent = ZERO_WIDTH_SPACE;
@@ -2290,14 +2773,16 @@ export default function SlideEditor({
2290
2773
  }: { preserveClickWithoutMove?: boolean } = {},
2291
2774
  ) => {
2292
2775
  if (readOnly) return;
2293
- const fmdSlide = element.closest(".fmd-slide") as HTMLElement | null;
2294
- if (!fmdSlide || getComputedStyle(element).position !== "absolute") {
2776
+ const slideCanvas = element.closest(
2777
+ ".fmd-slide, [data-slide-canvas]",
2778
+ ) as HTMLElement | null;
2779
+ if (!slideCanvas || getComputedStyle(element).position !== "absolute") {
2295
2780
  return;
2296
2781
  }
2297
2782
 
2298
- const slideRect = fmdSlide.getBoundingClientRect();
2299
- const slideWidth = fmdSlide.offsetWidth;
2300
- const slideHeight = fmdSlide.offsetHeight;
2783
+ const slideRect = slideCanvas.getBoundingClientRect();
2784
+ const slideWidth = slideCanvas.offsetWidth;
2785
+ const slideHeight = slideCanvas.offsetHeight;
2301
2786
  if (
2302
2787
  !slideRect.width ||
2303
2788
  !slideRect.height ||
@@ -2319,12 +2804,8 @@ export default function SlideEditor({
2319
2804
 
2320
2805
  const origin = getObjectGeometry(element);
2321
2806
  const originalObjectId = element.getAttribute("data-slide-object-id");
2322
- const startClientX = e.clientX;
2323
- const startClientY = e.clientY;
2324
- const duplicateRequested = e.altKey;
2325
2807
  let activeElement = element;
2326
2808
  let clone: HTMLElement | null = null;
2327
- let moved = false;
2328
2809
 
2329
2810
  const stop = () => {
2330
2811
  window.removeEventListener("pointermove", onMove);
@@ -2350,44 +2831,91 @@ export default function SlideEditor({
2350
2831
  if (selector) selectElementForStyling(element, selector);
2351
2832
  };
2352
2833
 
2353
- const onMove = (moveEvent: PointerEvent) => {
2354
- let dx =
2355
- ((moveEvent.clientX - startClientX) / slideRect.width) * slideWidth;
2356
- let dy =
2357
- ((moveEvent.clientY - startClientY) / slideRect.height) * slideHeight;
2358
- if (!moved && Math.hypot(dx, dy) < 2) return;
2359
- if (moveEvent.shiftKey) {
2360
- if (Math.abs(dx) >= Math.abs(dy)) dy = 0;
2361
- else dx = 0;
2362
- }
2363
- if (!moved) {
2364
- moved = true;
2834
+ const controller = createSlidesCanvasGestureController({
2835
+ preview: (gesture) => {
2365
2836
  // React still receives a click after a pointer drag. Suppress that
2366
2837
  // trailing click so a moved text box does not immediately reopen
2367
2838
  // inline editing.
2368
2839
  suppressNextClickRef.current = true;
2369
2840
  ensureSlideObjectId(element);
2370
- if (duplicateRequested && moveEvent.altKey) {
2841
+ if (!clone && gesture.duplicate) {
2371
2842
  clone = cloneSlideObject(element);
2372
2843
  element.after(clone);
2373
2844
  ensureBuilderId(clone);
2374
2845
  stampBuilderIds(clone);
2375
2846
  activeElement = clone;
2376
2847
  }
2377
- }
2378
- moveEvent.preventDefault();
2379
- applyObjectGeometry(activeElement, {
2380
- ...origin,
2381
- x: origin.x + dx,
2382
- y: origin.y + dy,
2848
+ applyObjectGeometry(activeElement, {
2849
+ ...origin,
2850
+ x: origin.x + gesture.canvasDelta.x,
2851
+ y: origin.y + gesture.canvasDelta.y,
2852
+ });
2853
+ const selector = getBuilderSelector(activeElement);
2854
+ if (selector) selectElementForStyling(activeElement, selector);
2855
+ return { handled: true };
2856
+ },
2857
+ commit: (gesture) => {
2858
+ // Keeping Option pressed is the explicit duplicate commit. If it
2859
+ // was released before drop, turn the gesture back into a normal move.
2860
+ if (clone && !gesture.duplicate) {
2861
+ applyObjectGeometry(element, getObjectGeometry(clone));
2862
+ clone.remove();
2863
+ activeElement = element;
2864
+ }
2865
+ const html = readCurrentSlideContentHtml();
2866
+ if (html !== null) {
2867
+ onUpdateSlideRef.current({ content: html }, undefined, {
2868
+ persistence: "immediate",
2869
+ });
2870
+ }
2871
+ const selector = getBuilderSelector(activeElement);
2872
+ if (selector) selectElementForStyling(activeElement, selector);
2873
+ return { handled: true };
2874
+ },
2875
+ cancel: () => {
2876
+ restore();
2877
+ return { handled: true };
2878
+ },
2879
+ });
2880
+ controller.pointerDown({
2881
+ kind: "move",
2882
+ objectIds: [originalObjectId ?? getBuilderSelector(element) ?? ""],
2883
+ pointer: {
2884
+ x: e.clientX,
2885
+ y: e.clientY,
2886
+ shiftKey: e.shiftKey,
2887
+ altKey: e.altKey,
2888
+ metaKey: e.metaKey,
2889
+ ctrlKey: e.ctrlKey,
2890
+ },
2891
+ viewport: slideRect,
2892
+ canvas: { width: slideWidth, height: slideHeight },
2893
+ });
2894
+
2895
+ const onMove = (moveEvent: PointerEvent) => {
2896
+ const update = controller.pointerMove({
2897
+ x: moveEvent.clientX,
2898
+ y: moveEvent.clientY,
2899
+ shiftKey: moveEvent.shiftKey,
2900
+ altKey: moveEvent.altKey,
2901
+ metaKey: moveEvent.metaKey,
2902
+ ctrlKey: moveEvent.ctrlKey,
2383
2903
  });
2384
- const selector = getBuilderSelector(activeElement);
2385
- if (selector) selectElementForStyling(activeElement, selector);
2904
+ if (update.phase !== "active") return;
2905
+ moveEvent.preventDefault();
2386
2906
  };
2387
2907
 
2388
2908
  const onUp = (upEvent: PointerEvent) => {
2389
2909
  stop();
2390
- if (!moved) {
2910
+ const result = controller.pointerUp({
2911
+ x: upEvent.clientX,
2912
+ y: upEvent.clientY,
2913
+ shiftKey: upEvent.shiftKey,
2914
+ altKey: upEvent.altKey,
2915
+ metaKey: upEvent.metaKey,
2916
+ ctrlKey: upEvent.ctrlKey,
2917
+ });
2918
+ if (!result.committed) {
2391
2919
  if (preserveClickWithoutMove) return;
2392
2920
  // Pointer-up can occur outside the canvas, where React will not see
2393
2921
  // the click that normally clears this flag.
@@ -2402,23 +2930,12 @@ export default function SlideEditor({
2402
2930
  window.setTimeout(function clearDragClickSuppression() {
2403
2931
  suppressNextClickRef.current = false;
2404
2932
  }, 0);
2405
- // Keeping Option pressed is the explicit duplicate commit. If it was
2406
- // released before drop, turn the gesture back into a normal move.
2407
- if (clone && !upEvent.altKey) {
2408
- applyObjectGeometry(element, getObjectGeometry(clone));
2409
- clone.remove();
2410
- activeElement = element;
2411
- }
2412
- const html = readCurrentSlideContentHtml();
2413
- if (html !== null) onUpdateSlideRef.current({ content: html });
2414
- const selector = getBuilderSelector(activeElement);
2415
- if (selector) selectElementForStyling(activeElement, selector);
2416
2933
  };
2417
2934
 
2418
2935
  const onCancel = () => {
2419
2936
  stop();
2420
2937
  suppressNextClickRef.current = false;
2421
- restore();
2938
+ controller.cancel();
2422
2939
  };
2423
2940
 
2424
2941
  window.addEventListener("pointermove", onMove);
@@ -2439,10 +2956,12 @@ export default function SlideEditor({
2439
2956
  (handle: ResizeHandle, e: React.PointerEvent) => {
2440
2957
  if (readOnly) return;
2441
2958
  const element = resolveSelectedElement();
2442
- const fmdSlide = element?.closest(".fmd-slide") as HTMLElement | null;
2959
+ const slideCanvas = element?.closest(
2960
+ ".fmd-slide, [data-slide-canvas]",
2961
+ ) as HTMLElement | null;
2443
2962
  if (
2444
2963
  !element ||
2445
- !fmdSlide ||
2964
+ !slideCanvas ||
2446
2965
  getComputedStyle(element).position !== "absolute"
2447
2966
  ) {
2448
2967
  return;
@@ -2450,9 +2969,9 @@ export default function SlideEditor({
2450
2969
  e.preventDefault();
2451
2970
  e.stopPropagation();
2452
2971
 
2453
- const slideRect = fmdSlide.getBoundingClientRect();
2454
- const slideWidth = fmdSlide.offsetWidth;
2455
- const slideHeight = fmdSlide.offsetHeight;
2972
+ const slideRect = slideCanvas.getBoundingClientRect();
2973
+ const slideWidth = slideCanvas.offsetWidth;
2974
+ const slideHeight = slideCanvas.offsetHeight;
2456
2975
  if (
2457
2976
  !slideRect.width ||
2458
2977
  !slideRect.height ||
@@ -2464,9 +2983,6 @@ export default function SlideEditor({
2464
2983
 
2465
2984
  const origin = getObjectGeometry(element);
2466
2985
  const originalObjectId = element.getAttribute("data-slide-object-id");
2467
- const startClientX = e.clientX;
2468
- const startClientY = e.clientY;
2469
- let resized = false;
2470
2986
  const selector = getBuilderSelector(element);
2471
2987
  if (selector) selectElementForStyling(element, selector, "resizing");
2472
2988
 
@@ -2479,48 +2995,92 @@ export default function SlideEditor({
2479
2995
  }
2480
2996
  };
2481
2997
 
2998
+ const controller = createSlidesCanvasGestureController({
2999
+ preview: (gesture) => {
3000
+ if (gesture.kind !== "resize")
3001
+ return { handled: false, reason: "unhandled" };
3002
+ ensureSlideObjectId(element);
3003
+ applyObjectGeometry(element, gesture.rect);
3004
+ const currentSelector = getBuilderSelector(element);
3005
+ if (currentSelector) {
3006
+ selectElementForStyling(element, currentSelector, "resizing");
3007
+ }
3008
+ return { handled: true };
3009
+ },
3010
+ commit: () => {
3011
+ const currentSelector = getBuilderSelector(element);
3012
+ if (currentSelector)
3013
+ selectElementForStyling(element, currentSelector);
3014
+ const html = readCurrentSlideContentHtml();
3015
+ if (html !== null) {
3016
+ onUpdateSlideRef.current({ content: html }, undefined, {
3017
+ persistence: "immediate",
3018
+ });
3019
+ }
3020
+ return { handled: true };
3021
+ },
3022
+ cancel: () => {
3023
+ applyObjectGeometry(element, origin);
3024
+ if (originalObjectId) {
3025
+ element.setAttribute("data-slide-object-id", originalObjectId);
3026
+ } else {
3027
+ element.removeAttribute("data-slide-object-id");
3028
+ }
3029
+ const currentSelector = getBuilderSelector(element);
3030
+ if (currentSelector)
3031
+ selectElementForStyling(element, currentSelector);
3032
+ return { handled: true };
3033
+ },
3034
+ });
3035
+ controller.pointerDown({
3036
+ kind: "resize",
3037
+ objectIds: [originalObjectId ?? selector ?? ""],
3038
+ pointer: {
3039
+ x: e.clientX,
3040
+ y: e.clientY,
3041
+ shiftKey: e.shiftKey,
3042
+ altKey: e.altKey,
3043
+ metaKey: e.metaKey,
3044
+ ctrlKey: e.ctrlKey,
3045
+ },
3046
+ viewport: slideRect,
3047
+ canvas: { width: slideWidth, height: slideHeight },
3048
+ handle,
3049
+ rect: origin,
3050
+ });
3051
+
2482
3052
  const onMove = (moveEvent: PointerEvent) => {
2483
- const dx =
2484
- ((moveEvent.clientX - startClientX) / slideRect.width) * slideWidth;
2485
- const dy =
2486
- ((moveEvent.clientY - startClientY) / slideRect.height) * slideHeight;
2487
- if (!resized && Math.hypot(dx, dy) < 1) return;
2488
- resized = true;
2489
- ensureSlideObjectId(element);
2490
- applyObjectGeometry(
2491
- element,
2492
- resizeSlideObject(origin, {
2493
- handle,
2494
- dx,
2495
- dy,
2496
- preserveAspectRatio: moveEvent.shiftKey,
2497
- }),
2498
- );
2499
- const currentSelector = getBuilderSelector(element);
2500
- if (currentSelector) {
2501
- selectElementForStyling(element, currentSelector, "resizing");
2502
- }
3053
+ const update = controller.pointerMove({
3054
+ x: moveEvent.clientX,
3055
+ y: moveEvent.clientY,
3056
+ shiftKey: moveEvent.shiftKey,
3057
+ altKey: moveEvent.altKey,
3058
+ metaKey: moveEvent.metaKey,
3059
+ ctrlKey: moveEvent.ctrlKey,
3060
+ });
3061
+ if (update.phase === "active") moveEvent.preventDefault();
2503
3062
  };
2504
3063
 
2505
- const onUp = () => {
3064
+ const onUp = (upEvent: PointerEvent) => {
2506
3065
  stop();
3066
+ const result = controller.pointerUp({
3067
+ x: upEvent.clientX,
3068
+ y: upEvent.clientY,
3069
+ shiftKey: upEvent.shiftKey,
3070
+ altKey: upEvent.altKey,
3071
+ metaKey: upEvent.metaKey,
3072
+ ctrlKey: upEvent.ctrlKey,
3073
+ });
2507
3074
  const currentSelector = getBuilderSelector(element);
2508
3075
  if (currentSelector) selectElementForStyling(element, currentSelector);
2509
- if (!resized) return;
2510
- const html = readCurrentSlideContentHtml();
2511
- if (html !== null) onUpdateSlideRef.current({ content: html });
3076
+ if (!result.committed) return;
2512
3077
  };
2513
3078
 
2514
3079
  const onCancel = () => {
2515
3080
  stop();
2516
- applyObjectGeometry(element, origin);
2517
- if (originalObjectId) {
2518
- element.setAttribute("data-slide-object-id", originalObjectId);
2519
- } else {
2520
- element.removeAttribute("data-slide-object-id");
2521
- }
2522
- const selector = getBuilderSelector(element);
2523
- if (selector) selectElementForStyling(element, selector);
3081
+ controller.cancel();
3082
+ const currentSelector = getBuilderSelector(element);
3083
+ if (currentSelector) selectElementForStyling(element, currentSelector);
2524
3084
  };
2525
3085
 
2526
3086
  window.addEventListener("pointermove", onMove);
@@ -2538,15 +3098,177 @@ export default function SlideEditor({
2538
3098
  ],
2539
3099
  );
2540
3100
 
3101
+ /**
3102
+ * Same gesture shape as startElementDrag (2px threshold, Shift axis lock,
3103
+ * commit-once-on-pointerup, Escape-cancel restore), but moves every
3104
+ * absolutely-positioned member of the multi-selection by one shared delta
3105
+ * instead of a single element.
3106
+ */
3107
+ const startGroupDrag = useCallback(
3108
+ (e: React.PointerEvent, ids: Set<string>) => {
3109
+ if (readOnly) return;
3110
+ const slideContent = getSlideContent();
3111
+ if (!slideContent) return;
3112
+ const elements = Array.from(ids)
3113
+ .map(
3114
+ (id) =>
3115
+ slideContent.querySelector(
3116
+ `[data-builder-id="${id}"]`,
3117
+ ) as HTMLElement | null,
3118
+ )
3119
+ .filter((el): el is HTMLElement => el !== null);
3120
+ if (elements.some((element) => !isPersistedFreeformObject(element))) {
3121
+ return;
3122
+ }
3123
+ const members = collectMovableSlideObjects(elements, getObjectGeometry);
3124
+ if (members.length === 0) return;
3125
+
3126
+ // Resolve the slide from a member, the way startElementDrag does.
3127
+ // `.fmd-slide` is a DESCENDANT of `.slide-content`, so walking up from
3128
+ // the container never finds it and the whole gesture aborts silently.
3129
+ const fmdSlide = members[0].element.closest(
3130
+ ".fmd-slide",
3131
+ ) as HTMLElement | null;
3132
+ if (!fmdSlide) return;
3133
+ const positioningLayer =
3134
+ Array.from(fmdSlide.children).find(
3135
+ (child): child is HTMLElement =>
3136
+ child instanceof HTMLElement &&
3137
+ child.hasAttribute("data-fmd-autofit-content"),
3138
+ ) ?? fmdSlide;
3139
+ const containingBlock = resolveSlideObjectContainingBlock(
3140
+ members[0].element,
3141
+ positioningLayer,
3142
+ );
3143
+ if (
3144
+ members.some(
3145
+ (member) =>
3146
+ member.element.closest(".fmd-slide") !== fmdSlide ||
3147
+ resolveSlideObjectContainingBlock(
3148
+ member.element,
3149
+ positioningLayer,
3150
+ ) !== containingBlock,
3151
+ )
3152
+ ) {
3153
+ return;
3154
+ }
3155
+ const slideRect = fmdSlide.getBoundingClientRect();
3156
+ const slideWidth = fmdSlide.offsetWidth;
3157
+ const slideHeight = fmdSlide.offsetHeight;
3158
+ if (
3159
+ !slideRect.width ||
3160
+ !slideRect.height ||
3161
+ !slideWidth ||
3162
+ !slideHeight
3163
+ ) {
3164
+ return;
3165
+ }
3166
+
3167
+ const stop = () => {
3168
+ window.removeEventListener("pointermove", onMove);
3169
+ window.removeEventListener("pointerup", onUp);
3170
+ window.removeEventListener("pointercancel", onCancel);
3171
+ if (activeGestureCancelRef.current === onCancel) {
3172
+ activeGestureCancelRef.current = null;
3173
+ }
3174
+ };
3175
+
3176
+ const controller = createSlidesCanvasGestureController({
3177
+ preview: (gesture) => {
3178
+ // Claim the click only once a real drag starts, so a click without
3179
+ // movement on a multi-selected object remains normal editor input.
3180
+ suppressNextClickRef.current = true;
3181
+ applySlideObjectMoveDelta(
3182
+ members,
3183
+ gesture.canvasDelta.x,
3184
+ gesture.canvasDelta.y,
3185
+ applyObjectGeometry,
3186
+ );
3187
+ refreshMultiSelectionRects(ids);
3188
+ return { handled: true };
3189
+ },
3190
+ commit: () => {
3191
+ commitMultiObjectChange(members.map((member) => member.objectId));
3192
+ return { handled: true };
3193
+ },
3194
+ cancel: () => {
3195
+ applySlideObjectMoveDelta(members, 0, 0, applyObjectGeometry);
3196
+ refreshMultiSelectionRects(ids);
3197
+ return { handled: true };
3198
+ },
3199
+ });
3200
+ controller.pointerDown({
3201
+ kind: "move",
3202
+ objectIds: members.map((member) => member.objectId),
3203
+ pointer: {
3204
+ x: e.clientX,
3205
+ y: e.clientY,
3206
+ shiftKey: e.shiftKey,
3207
+ altKey: e.altKey,
3208
+ metaKey: e.metaKey,
3209
+ ctrlKey: e.ctrlKey,
3210
+ },
3211
+ viewport: slideRect,
3212
+ canvas: { width: slideWidth, height: slideHeight },
3213
+ });
3214
+
3215
+ const onMove = (moveEvent: PointerEvent) => {
3216
+ const update = controller.pointerMove({
3217
+ x: moveEvent.clientX,
3218
+ y: moveEvent.clientY,
3219
+ shiftKey: moveEvent.shiftKey,
3220
+ altKey: moveEvent.altKey,
3221
+ metaKey: moveEvent.metaKey,
3222
+ ctrlKey: moveEvent.ctrlKey,
3223
+ });
3224
+ if (update.phase === "active") moveEvent.preventDefault();
3225
+ };
3226
+
3227
+ const onUp = (upEvent: PointerEvent) => {
3228
+ stop();
3229
+ const result = controller.pointerUp({
3230
+ x: upEvent.clientX,
3231
+ y: upEvent.clientY,
3232
+ shiftKey: upEvent.shiftKey,
3233
+ altKey: upEvent.altKey,
3234
+ metaKey: upEvent.metaKey,
3235
+ ctrlKey: upEvent.ctrlKey,
3236
+ });
3237
+ if (!result.committed) {
3238
+ window.setTimeout(function clearEdgeClickSuppression() {
3239
+ suppressNextClickRef.current = false;
3240
+ }, 0);
3241
+ return;
3242
+ }
3243
+ window.setTimeout(function clearDragClickSuppression() {
3244
+ suppressNextClickRef.current = false;
3245
+ }, 0);
3246
+ };
3247
+
3248
+ const onCancel = () => {
3249
+ stop();
3250
+ suppressNextClickRef.current = false;
3251
+ controller.cancel();
3252
+ };
3253
+
3254
+ window.addEventListener("pointermove", onMove);
3255
+ window.addEventListener("pointerup", onUp);
3256
+ window.addEventListener("pointercancel", onCancel);
3257
+ activeGestureCancelRef.current = onCancel;
3258
+ },
3259
+ [
3260
+ applyObjectGeometry,
3261
+ commitMultiObjectChange,
3262
+ refreshMultiSelectionRects,
3263
+ getObjectGeometry,
3264
+ getSlideContent,
3265
+ readOnly,
3266
+ ],
3267
+ );
3268
+
2541
3269
  useEffect(() => {
2542
- if (
2543
- readOnly ||
2544
- editingEl ||
2545
- multiSelection.size > 0 ||
2546
- !selectedElementSelector
2547
- ) {
2548
- return;
2549
- }
3270
+ if (readOnly || editingEl) return;
3271
+ if (multiSelection.size === 0 && !selectedElementSelector) return;
2550
3272
  const onKey = (e: KeyboardEvent) => {
2551
3273
  if (!e.key.startsWith("Arrow")) return;
2552
3274
  const active = document.activeElement;
@@ -2558,20 +3280,65 @@ export default function SlideEditor({
2558
3280
  ) {
2559
3281
  return;
2560
3282
  }
2561
- const element = resolveSelectedElement();
2562
- if (!element || getComputedStyle(element).position !== "absolute") {
3283
+ const nudge = slidesCanvasInteractionCore.nudge(e);
3284
+ if (!nudge) return;
3285
+ const { x: dx, y: dy } = nudge.delta;
3286
+
3287
+ if (multiSelection.size > 0) {
3288
+ const slideContent = getSlideContent();
3289
+ if (!slideContent) return;
3290
+ const elements = Array.from(multiSelection)
3291
+ .map(
3292
+ (id) =>
3293
+ slideContent.querySelector(
3294
+ `[data-builder-id="${id}"]`,
3295
+ ) as HTMLElement | null,
3296
+ )
3297
+ .filter((el): el is HTMLElement => el !== null);
3298
+ if (elements.some((element) => !isPersistedFreeformObject(element))) {
3299
+ return;
3300
+ }
3301
+ const members = collectMovableSlideObjects(elements, getObjectGeometry);
3302
+ if (members.length === 0) return;
3303
+ const fmdSlide = members[0].element.closest(
3304
+ ".fmd-slide",
3305
+ ) as HTMLElement | null;
3306
+ if (!fmdSlide) return;
3307
+ const positioningLayer =
3308
+ Array.from(fmdSlide.children).find(
3309
+ (child): child is HTMLElement =>
3310
+ child instanceof HTMLElement &&
3311
+ child.hasAttribute("data-fmd-autofit-content"),
3312
+ ) ?? fmdSlide;
3313
+ const containingBlock = resolveSlideObjectContainingBlock(
3314
+ members[0].element,
3315
+ positioningLayer,
3316
+ );
3317
+ if (
3318
+ members.some(
3319
+ (member) =>
3320
+ member.element.closest(".fmd-slide") !== fmdSlide ||
3321
+ resolveSlideObjectContainingBlock(
3322
+ member.element,
3323
+ positioningLayer,
3324
+ ) !== containingBlock,
3325
+ )
3326
+ ) {
3327
+ return;
3328
+ }
3329
+ e.preventDefault();
3330
+ applySlideObjectMoveDelta(members, dx, dy, applyObjectGeometry);
3331
+ refreshMultiSelectionRects(multiSelection);
3332
+ commitMultiObjectChange(members.map((member) => member.objectId));
2563
3333
  return;
2564
3334
  }
2565
- const amount = e.shiftKey ? 10 : 1;
2566
- const geometry = getObjectGeometry(element);
2567
- if (e.key === "ArrowLeft") geometry.x -= amount;
2568
- else if (e.key === "ArrowRight") geometry.x += amount;
2569
- else if (e.key === "ArrowUp") geometry.y -= amount;
2570
- else if (e.key === "ArrowDown") geometry.y += amount;
2571
- else return;
2572
3335
 
3336
+ const element = resolveSelectedElement();
3337
+ if (!element || !isPersistedFreeformObject(element)) return;
2573
3338
  e.preventDefault();
2574
- ensureSlideObjectId(element);
3339
+ const geometry = getObjectGeometry(element);
3340
+ geometry.x += dx;
3341
+ geometry.y += dy;
2575
3342
  applyObjectGeometry(element, geometry);
2576
3343
  const html = readCurrentSlideContentHtml();
2577
3344
  if (html !== null) onUpdateSlideRef.current({ content: html });
@@ -2582,11 +3349,14 @@ export default function SlideEditor({
2582
3349
  return () => window.removeEventListener("keydown", onKey);
2583
3350
  }, [
2584
3351
  applyObjectGeometry,
3352
+ commitMultiObjectChange,
2585
3353
  editingEl,
2586
3354
  getObjectGeometry,
2587
- multiSelection.size,
3355
+ getSlideContent,
3356
+ multiSelection,
2588
3357
  readCurrentSlideContentHtml,
2589
3358
  readOnly,
3359
+ refreshMultiSelectionRects,
2590
3360
  resolveSelectedElement,
2591
3361
  selectElementForStyling,
2592
3362
  selectedElementSelector,
@@ -2608,7 +3378,7 @@ export default function SlideEditor({
2608
3378
  const shouldShowMoveCursor =
2609
3379
  selected !== null &&
2610
3380
  getComputedStyle(selected).position === "absolute" &&
2611
- isWithinElementEdgeMoveBand(
3381
+ isWithinSlidesCanvasEdgeMoveBand(
2612
3382
  selected.getBoundingClientRect(),
2613
3383
  e.clientX,
2614
3384
  e.clientY,
@@ -2629,7 +3399,7 @@ export default function SlideEditor({
2629
3399
  if (!slideContent) return;
2630
3400
  const target = e.target as HTMLElement;
2631
3401
 
2632
- if (textBoxMode && isHtmlSlide) {
3402
+ if (textBoxMode) {
2633
3403
  // Unlike the marquee/select flow below, the text-box tool places a
2634
3404
  // box on the very next click no matter what it lands on (mirroring
2635
3405
  // Google Slides / PowerPoint). Gating this on "whitespace" left the
@@ -2639,27 +3409,47 @@ export default function SlideEditor({
2639
3409
  e.preventDefault();
2640
3410
  if (editingEl) exitInlineEdit();
2641
3411
  suppressNextClickRef.current = true;
2642
- placeTextBoxAt(e.clientX, e.clientY);
3412
+ placeTextBoxAt(e.clientX, e.clientY, target);
2643
3413
  onExitTextBoxMode?.();
2644
3414
  return;
2645
3415
  }
2646
3416
  if (editingEl) return; // avoid interfering with an active inline edit
2647
3417
 
3418
+ // Pointer-down on a member of the current multi-selection drags the
3419
+ // whole group instead of the single-object flow below.
3420
+ if (multiSelection.size > 0) {
3421
+ const id = findSelectableId(target, slideContent);
3422
+ if (id && multiSelection.has(id)) {
3423
+ startGroupDrag(e, multiSelection);
3424
+ return;
3425
+ }
3426
+ }
3427
+
2648
3428
  const selected = resolveSelectedElement();
2649
- const dragStart =
2650
- selected && getComputedStyle(selected).position === "absolute"
2651
- ? getSelectedObjectDragStart({
2652
- targetWithinSelectedObject: selected.contains(target),
2653
- pointerWithinMoveBand: isWithinElementEdgeMoveBand(
2654
- selected.getBoundingClientRect(),
2655
- e.clientX,
2656
- e.clientY,
2657
- ),
2658
- })
2659
- : null;
2660
- if (dragStart && selected) {
3429
+ const pointerIntent = resolveSlidesCanvasPointerIntent({
3430
+ hasSelectedObject:
3431
+ selected !== null &&
3432
+ getComputedStyle(selected).position === "absolute",
3433
+ targetWithinSelectedObject: selected?.contains(target) ?? false,
3434
+ targetContainsSelectedObject: selected
3435
+ ? target.contains(selected)
3436
+ : false,
3437
+ pointerWithinMoveBand:
3438
+ selected !== null &&
3439
+ isWithinSlidesCanvasEdgeMoveBand(
3440
+ selected.getBoundingClientRect(),
3441
+ e.clientX,
3442
+ e.clientY,
3443
+ ),
3444
+ targetIsEditableText: Boolean(findSmartBlock(target, slideContent)),
3445
+ });
3446
+ if (
3447
+ selected &&
3448
+ (pointerIntent === "move-object-body" ||
3449
+ pointerIntent === "move-object-perimeter")
3450
+ ) {
2661
3451
  startElementDrag(e, selected, {
2662
- preserveClickWithoutMove: dragStart === "body",
3452
+ preserveClickWithoutMove: pointerIntent === "move-object-body",
2663
3453
  });
2664
3454
  return;
2665
3455
  }
@@ -2687,19 +3477,19 @@ export default function SlideEditor({
2687
3477
  },
2688
3478
  [
2689
3479
  editingEl,
3480
+ findSelectableId,
2690
3481
  getSlideContent,
2691
3482
  isSlideWhitespaceTarget,
2692
3483
  multiSelection,
2693
3484
  applyMultiSelection,
2694
3485
  clearSelectedElement,
2695
3486
  textBoxMode,
2696
- isHtmlSlide,
2697
3487
  exitInlineEdit,
2698
3488
  placeTextBoxAt,
2699
3489
  onExitTextBoxMode,
2700
- getSelectedObjectDragStart,
2701
3490
  resolveSelectedElement,
2702
3491
  startElementDrag,
3492
+ startGroupDrag,
2703
3493
  ],
2704
3494
  );
2705
3495
 
@@ -2938,9 +3728,15 @@ export default function SlideEditor({
2938
3728
  // leaf, or an entire bullet list) — not the individual line — so typing,
2939
3729
  // highlighting, shortcuts, and Enter-to-add-bullet all work, and the
2940
3730
  // style dock targets the same block being edited.
2941
- if (!readOnly && isHtmlSlide && slideContent) {
3731
+ if (!readOnly && slideContent) {
2942
3732
  const block = findSmartBlock(target, slideContent);
2943
- if (block) {
3733
+ if (
3734
+ block &&
3735
+ slidesCanvasInteractionCore.textActivation({
3736
+ clickCount: 1,
3737
+ textEditable: true,
3738
+ }) === "edit"
3739
+ ) {
2944
3740
  const blockSelector = getBuilderSelector(block);
2945
3741
  if (blockSelector) {
2946
3742
  selectElementForStyling(block, blockSelector);
@@ -3082,7 +3878,7 @@ export default function SlideEditor({
3082
3878
  inlineTextStyle,
3083
3879
  );
3084
3880
  if (!editing) {
3085
- setSelectedElementRect(element.getBoundingClientRect());
3881
+ invalidateSelectionOverlayMeasurement();
3086
3882
  }
3087
3883
  setSelectedStyleSnapshot(snapshot);
3088
3884
  if (!editing) {
@@ -3096,6 +3892,7 @@ export default function SlideEditor({
3096
3892
  [
3097
3893
  buildSelectionState,
3098
3894
  captureInlineEditDraft,
3895
+ invalidateSelectionOverlayMeasurement,
3099
3896
  readCurrentSlideContentHtml,
3100
3897
  resolveSelectedElement,
3101
3898
  selectedElementSelector,
@@ -3103,6 +3900,45 @@ export default function SlideEditor({
3103
3900
  ],
3104
3901
  );
3105
3902
 
3903
+ /** Bring-to-front / send-to-back for the selected freeform object. */
3904
+ const handleArrangeSelected = useCallback(
3905
+ (target: SlideObjectZOrderTarget) => {
3906
+ const element = resolveSelectedElement();
3907
+ if (!element) return;
3908
+ const fmdSlide = element.closest(".fmd-slide") as HTMLElement | null;
3909
+ const positioningLayer = fmdSlide
3910
+ ? (Array.from(fmdSlide.children).find(
3911
+ (child): child is HTMLElement =>
3912
+ child instanceof HTMLElement &&
3913
+ child.hasAttribute("data-fmd-autofit-content"),
3914
+ ) ?? fmdSlide)
3915
+ : null;
3916
+ if (!positioningLayer) return;
3917
+
3918
+ const containingBlock = resolveSlideObjectContainingBlock(
3919
+ element,
3920
+ positioningLayer,
3921
+ );
3922
+ const change = computeSlideObjectZOrder(element, containingBlock, target);
3923
+ if (!change) return;
3924
+
3925
+ element.style.zIndex = String(change.value);
3926
+ for (const shift of change.shiftPeers) {
3927
+ shift.element.style.zIndex = String(shift.value);
3928
+ }
3929
+
3930
+ const html = readCurrentSlideContentHtml();
3931
+ if (html !== null) onUpdateSlideRef.current({ content: html });
3932
+ const selector = getBuilderSelector(element);
3933
+ if (selector) selectElementForStyling(element, selector);
3934
+ },
3935
+ [
3936
+ readCurrentSlideContentHtml,
3937
+ resolveSelectedElement,
3938
+ selectElementForStyling,
3939
+ ],
3940
+ );
3941
+
3106
3942
  // --- Pending visual updates ---
3107
3943
  const [pendingUpdateCount, setPendingUpdateCount] = useState(0);
3108
3944
 
@@ -3167,7 +4003,6 @@ export default function SlideEditor({
3167
4003
  const isSelectedElementDraggable = selectedForDrag
3168
4004
  ? getComputedStyle(selectedForDrag).position === "absolute"
3169
4005
  : false;
3170
-
3171
4006
  return (
3172
4007
  <div
3173
4008
  className="relative flex h-full min-h-0 flex-1 flex-col overflow-hidden"
@@ -3272,6 +4107,7 @@ export default function SlideEditor({
3272
4107
  designSystem={designSystem}
3273
4108
  aspectRatio={aspectRatio}
3274
4109
  onOverflowChange={handleOverflowChange}
4110
+ onAutofitSettled={handleAutofitSettled}
3275
4111
  />
3276
4112
  {/* Fading "AI edited" ring around the canvas when the
3277
4113
  agent just edited THIS slide (component handles fade). */}
@@ -3292,27 +4128,20 @@ export default function SlideEditor({
3292
4128
  )}
3293
4129
  </div>
3294
4130
  )}
3295
- {overflowInfo && !readOnly && !agentActive && (
3296
- <div className="absolute top-3 left-3 z-20 flex items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 backdrop-blur px-2.5 py-1.5 text-xs text-amber-100 shadow-lg">
3297
- <IconAlertTriangle
3298
- className="h-3.5 w-3.5 flex-shrink-0"
3299
- stroke={2}
4131
+ {overflowInfo &&
4132
+ !readOnly &&
4133
+ !agentActive &&
4134
+ !isOverflowWarningDismissed && (
4135
+ <SlideOverflowWarning
4136
+ verticalOverflow={overflowInfo.verticalOverflow}
4137
+ isAskingAgentToFix={isAskingAgentToFix}
4138
+ dismissLabel={t("deckEditor.dismissLayoutWarning")}
4139
+ onFix={handleAskAgentToFixLayout}
4140
+ onDismiss={() =>
4141
+ setIsOverflowWarningDismissed(true)
4142
+ }
3300
4143
  />
3301
- <span className="leading-tight">
3302
- Layout overflows by {overflowInfo.verticalOverflow}
3303
- px
3304
- </span>
3305
- <Button
3306
- size="sm"
3307
- variant="ghost"
3308
- className="ml-1 h-6 cursor-pointer px-2 text-[11px] font-medium text-amber-100 hover:bg-amber-500/20 hover:text-white"
3309
- onClick={handleAskAgentToFixLayout}
3310
- disabled={isAskingAgentToFix}
3311
- >
3312
- {isAskingAgentToFix ? "Asking…" : "Fix with AI"}
3313
- </Button>
3314
- </div>
3315
- )}
4144
+ )}
3316
4145
  </div>
3317
4146
  </div>
3318
4147
  </div>
@@ -3333,6 +4162,7 @@ export default function SlideEditor({
3333
4162
  designSystem={designSystem}
3334
4163
  className="h-full w-full rounded-none border-0 bg-transparent shadow-none"
3335
4164
  onChange={applySelectedStylePatch}
4165
+ onArrange={handleArrangeSelected}
3336
4166
  onClose={() => {
3337
4167
  clearSelectedElement();
3338
4168
  syncSelectionToAppState(null);
@@ -3340,26 +4170,13 @@ export default function SlideEditor({
3340
4170
  }}
3341
4171
  />
3342
4172
  ) : (
3343
- <div className="flex h-11 items-center justify-between border-b border-border/70 px-3">
3344
- <span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground/70">
3345
- {t("styleInspector.title")}
3346
- </span>
3347
- <Tooltip>
3348
- <TooltipTrigger asChild>
3349
- <Button
3350
- type="button"
3351
- variant="ghost"
3352
- size="icon"
3353
- className="size-7 cursor-pointer text-muted-foreground hover:text-foreground"
3354
- onClick={onCloseStylePanel}
3355
- aria-label={t("styleInspector.close")}
3356
- >
3357
- <IconX className="size-3.5" />
3358
- </Button>
3359
- </TooltipTrigger>
3360
- <TooltipContent>{t("styleInspector.close")}</TooltipContent>
3361
- </Tooltip>
3362
- </div>
4173
+ <SlideBackgroundInspector
4174
+ background={slide.background}
4175
+ designSystem={designSystem}
4176
+ className="h-full w-full rounded-none border-0 bg-transparent shadow-none"
4177
+ onChange={applySlideBackground}
4178
+ onClose={onCloseStylePanel}
4179
+ />
3363
4180
  )}
3364
4181
  </div>
3365
4182
  )}
@@ -3370,6 +4187,7 @@ export default function SlideEditor({
3370
4187
  onChange={(notes) => onUpdateSlide({ notes })}
3371
4188
  slideIndex={slideIndex}
3372
4189
  slideCount={slideCount}
4190
+ readOnly={readOnly}
3373
4191
  />
3374
4192
 
3375
4193
  {selectionRect && (