@agent-native/core 0.149.6 → 0.151.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  3. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +83 -45
  4. package/corpus/templates/analytics/changelog/2026-08-11-analytics-dashboards-keep-the-selected-tab-when-reopened.md +6 -0
  5. package/corpus/templates/assets/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  6. package/corpus/templates/brain/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  7. package/corpus/templates/calendar/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  8. package/corpus/templates/chat/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  9. package/corpus/templates/clips/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  10. package/corpus/templates/clips/changelog/2026-08-11-clips-recovers-from-transient-updates-and-avoids-macos-priva.md +6 -0
  11. package/corpus/templates/clips/desktop/src/app.tsx +32 -176
  12. package/corpus/templates/clips/desktop/src/lib/updater.ts +52 -37
  13. package/corpus/templates/clips/desktop/src/styles.css +1 -166
  14. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +2 -0
  15. package/corpus/templates/clips/desktop/src-tauri/Info.plist +4 -0
  16. package/corpus/templates/clips/desktop/src-tauri/src/eventkit.rs +28 -1
  17. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +2 -0
  18. package/corpus/templates/clips/desktop/src-tauri/src/native_speech.rs +48 -1
  19. package/corpus/templates/clips/desktop/src-tauri/src/permission_status.rs +3 -0
  20. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +5 -0
  21. package/corpus/templates/clips/server/plugins/auth.ts +3 -0
  22. package/corpus/templates/content/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  23. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +2 -1
  24. package/corpus/templates/design/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  25. package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +5 -1
  26. package/corpus/templates/design/changelog/2026-08-11-design-agent-prompts-copy-successfully-in-the-desktop-app.md +6 -0
  27. package/corpus/templates/dispatch/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  28. package/corpus/templates/dispatch/app/global.css +2 -2
  29. package/corpus/templates/dispatch/changelog/2026-08-10-desktop-workspace-sign-in.md +6 -0
  30. package/corpus/templates/dispatch/changelog/2026-08-11-dispatch-no-longer-shows-the-generic-chat-starter-among-defa.md +6 -0
  31. package/corpus/templates/dispatch/changelog/2026-08-11-integrations-and-scheduled-work-open-as-first-class-electron-pages.md +6 -0
  32. package/corpus/templates/dispatch/changelog/2026-08-11-sidebar-text-is-slightly-easier-to-read-in-light-and-dark-mo.md +6 -0
  33. package/corpus/templates/dispatch/server/lib/identity-sso.ts +57 -31
  34. package/corpus/templates/dispatch/server/plugins/feature-flags.ts +5 -0
  35. package/corpus/templates/dispatch/server/plugins/identity-sso.ts +82 -33
  36. package/corpus/templates/dispatch/server/plugins/org-apps-directory.ts +7 -15
  37. package/corpus/templates/dispatch/server/plugins/setup-dispatch.ts +9 -1
  38. package/corpus/templates/dispatch/shared/feature-flags.ts +15 -0
  39. package/corpus/templates/factory/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  40. package/corpus/templates/forms/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  41. package/corpus/templates/forms/.agents/skills/form-responses/SKILL.md +8 -7
  42. package/corpus/templates/forms/app/hooks/use-forms.ts +8 -1
  43. package/corpus/templates/forms/changelog/2026-08-11-fixed-public-forms-so-conditional-questions-stay-hidden-unti.md +6 -0
  44. package/corpus/templates/forms/server/handlers/submissions.ts +2 -1
  45. package/corpus/templates/forms/server/lib/public-form-ssr.ts +37 -2
  46. package/corpus/templates/forms/shared/page-url.ts +54 -0
  47. package/corpus/templates/forms/shared/types.ts +4 -4
  48. package/corpus/templates/mail/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  49. package/corpus/templates/plan/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  50. package/corpus/templates/slides/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  51. package/corpus/templates/slides/actions/add-slide.ts +0 -27
  52. package/corpus/templates/slides/actions/import-file.ts +28 -9
  53. package/corpus/templates/slides/actions/view-screen.ts +4 -4
  54. package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +8 -1
  55. package/corpus/templates/slides/app/components/editor/AiEditingMarker.tsx +25 -0
  56. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +43 -48
  57. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +9 -6
  58. package/corpus/templates/slides/app/components/editor/GeneratingSlidePreview.tsx +1 -9
  59. package/corpus/templates/slides/app/components/editor/ImageDropPromptPopover.tsx +2 -0
  60. package/corpus/templates/slides/app/components/editor/ImageOverlay.tsx +0 -26
  61. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +904 -217
  62. package/corpus/templates/slides/app/components/editor/canvas-interactions/index.ts +1 -0
  63. package/corpus/templates/slides/app/components/editor/canvas-interactions/slides-canvas-adapter.ts +11 -0
  64. package/corpus/templates/slides/app/components/editor/inline-edit-session.ts +16 -0
  65. package/corpus/templates/slides/app/components/editor/slide-object-interactions.ts +22 -6
  66. package/corpus/templates/slides/app/components/editor/style-clipboard.ts +61 -0
  67. package/corpus/templates/slides/app/components/layout/Layout.tsx +26 -2
  68. package/corpus/templates/slides/app/components/ui/context-menu.tsx +1 -0
  69. package/corpus/templates/slides/app/context/DeckContext.tsx +75 -25
  70. package/corpus/templates/slides/app/global.css +1 -1
  71. package/corpus/templates/slides/app/i18n/en-US.ts +6 -1
  72. package/corpus/templates/slides/app/lib/create-deck-generation.ts +1 -1
  73. package/corpus/templates/slides/app/lib/image-drop-to-agent.ts +12 -12
  74. package/corpus/templates/slides/app/lib/slide-agent-context.ts +52 -0
  75. package/corpus/templates/slides/app/lib/slide-image-replacement.ts +61 -0
  76. package/corpus/templates/slides/app/pages/DeckEditor.tsx +55 -116
  77. package/corpus/templates/slides/app/pages/Index.tsx +28 -3
  78. package/corpus/templates/slides/changelog/2026-08-11-agent-chat-now-follows-the-current-slide-and-selection.md +6 -0
  79. package/corpus/templates/slides/changelog/2026-08-11-deck-titles-now-use-slide-content-instead-of-placeholder-filenames.md +6 -0
  80. package/corpus/templates/slides/changelog/2026-08-11-history-now-opens-from-the-deck-overflow-menu.md +6 -0
  81. package/corpus/templates/slides/changelog/2026-08-11-images-can-be-dropped-directly-onto-slides-and-resized-or-re.md +6 -0
  82. package/corpus/templates/slides/changelog/2026-08-11-new-decks-now-transition-directly-to-a-full-page-editor-load.md +6 -0
  83. package/corpus/templates/slides/changelog/2026-08-11-selected-slide-layers-can-be-nudged-precisely-with-arrow-key.md +6 -0
  84. package/corpus/templates/slides/changelog/2026-08-11-slides-can-copy-and-paste-element-styles-with-cmd-option-c-v.md +6 -0
  85. package/corpus/templates/slides/changelog/2026-08-11-slides-shows-one-compact-ai-marker-and-keeps-your-current-sl.md +6 -0
  86. package/corpus/templates/slides/changelog/2026-08-11-text-edits-preserve-slide-layout-when-no-changes-are-made.md +6 -0
  87. package/corpus/templates/slides/changelog/2026-08-11-the-style-toolbar-now-uses-the-full-available-width.md +6 -0
  88. package/corpus/templates/slides/changelog/2026-08-11-undo-and-redo-now-move-one-visible-slide-state-at-a-time.md +6 -0
  89. package/corpus/templates/slides/server/lib/chat-attachments.ts +6 -2
  90. package/corpus/templates/slides/shared/deck-title.ts +56 -0
  91. package/corpus/templates/tasks/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  92. package/dist/agent/run-store.js +6 -1
  93. package/dist/agent/thread-data-builder.js +19 -44
  94. package/dist/agent/types.d.ts +13 -0
  95. package/dist/catalog.json +1 -0
  96. package/dist/cli/create.js +1 -1
  97. package/dist/client/AssistantChat.js +58 -10
  98. package/dist/client/FileStorageSetupCard.d.ts +6 -0
  99. package/dist/client/FileStorageSetupCard.js +18 -0
  100. package/dist/client/NewWorkspaceAppFlow.js +10 -0
  101. package/dist/client/agent-chat-adapter.js +87 -11
  102. package/dist/client/agent-chat.d.ts +1 -1
  103. package/dist/client/agent-chat.js +8 -3
  104. package/dist/client/chat/attachment-adapters.js +10 -0
  105. package/dist/client/chat/message-components.d.ts +4 -1
  106. package/dist/client/chat/message-components.js +37 -13
  107. package/dist/client/chat/tool-call-display.js +14 -0
  108. package/dist/client/chat-first/apps-rail.d.ts +1 -1
  109. package/dist/client/chat-first/apps-rail.js +6 -6
  110. package/dist/client/chat-first/copy.js +1 -0
  111. package/dist/client/chat-first/types.d.ts +1 -0
  112. package/dist/client/extensions/EmbeddedExtension.js +1 -1
  113. package/dist/client/extensions/ExtensionViewer.js +1 -1
  114. package/dist/client/extensions/InlineExtensionFrame.js +1 -1
  115. package/dist/client/onboarding/FirstRunOnboarding.js +17 -2
  116. package/dist/client/settings/FileStorageSettingsForm.d.ts +2 -0
  117. package/dist/client/settings/FileStorageSettingsForm.js +113 -0
  118. package/dist/client/settings/SettingsPanel.js +2 -1
  119. package/dist/client/sse-event-processor.js +21 -0
  120. package/dist/collab/struct-routes.d.ts +1 -1
  121. package/dist/extensions/actions.js +2 -2
  122. package/dist/extensions/html-shell.js +3 -1
  123. package/dist/file-upload/pre-upload-attachments.d.ts +15 -10
  124. package/dist/file-upload/pre-upload-attachments.js +68 -24
  125. package/dist/file-upload/registry.d.ts +4 -5
  126. package/dist/file-upload/registry.js +7 -14
  127. package/dist/file-upload/s3.d.ts +9 -0
  128. package/dist/file-upload/s3.js +168 -0
  129. package/dist/file-upload/types.d.ts +3 -3
  130. package/dist/file-upload/types.js +2 -2
  131. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  132. package/dist/notifications/routes.d.ts +3 -3
  133. package/dist/onboarding/app-profile.js +23 -1
  134. package/dist/onboarding/default-steps.js +69 -0
  135. package/dist/onboarding/types.d.ts +2 -0
  136. package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
  137. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  138. package/dist/secrets/routes.d.ts +3 -3
  139. package/dist/server/agent-chat/browser-team-tools.js +20 -0
  140. package/dist/server/agent-chat-plugin.js +12 -3
  141. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  142. package/dist/server/auth.js +45 -21
  143. package/dist/server/better-auth-instance.d.ts +2 -0
  144. package/dist/server/better-auth-instance.js +2 -2
  145. package/dist/server/core-routes-plugin.d.ts +1 -0
  146. package/dist/server/core-routes-plugin.js +26 -16
  147. package/dist/server/identity-sso-store.d.ts +3 -0
  148. package/dist/server/identity-sso-store.js +45 -0
  149. package/dist/server/identity-sso.d.ts +20 -10
  150. package/dist/server/identity-sso.js +79 -31
  151. package/dist/server/index.d.ts +1 -1
  152. package/dist/server/index.js +1 -1
  153. package/dist/templates/chat/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  154. package/dist/templates/default/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  155. package/dist/templates/workspace-core/.agents/skills/authentication/SKILL.md +24 -1
  156. package/dist/templates/workspace-core/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  157. package/dist/templates/workspace-core/.agents/skills/extensions/SKILL.md +6 -2
  158. package/dist/templates/workspace-core/.agents/skills/extensions/references/api.md +1 -1
  159. package/dist/templates/workspace-core/.agents/skills/generative-ui/SKILL.md +3 -1
  160. package/docs/content/authentication.mdx +27 -0
  161. package/docs/content/cross-app-sso.mdx +38 -8
  162. package/docs/content/extensions.mdx +15 -15
  163. package/docs/content/file-uploads.mdx +10 -12
  164. package/docs/content/generative-ui.mdx +6 -1
  165. package/docs/content/key-concepts.mdx +141 -83
  166. package/package.json +2 -2
  167. package/src/templates/chat/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  168. package/src/templates/default/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  169. package/src/templates/workspace-core/.agents/skills/authentication/SKILL.md +24 -1
  170. package/src/templates/workspace-core/.agents/skills/delegate-to-agent/SKILL.md +28 -0
  171. package/src/templates/workspace-core/.agents/skills/extensions/SKILL.md +6 -2
  172. package/src/templates/workspace-core/.agents/skills/extensions/references/api.md +1 -1
  173. package/src/templates/workspace-core/.agents/skills/generative-ui/SKILL.md +3 -1
@@ -18,7 +18,13 @@ import {
18
18
  import { appStateKeyForBrowserTab } from "@shared/app-state-tabs";
19
19
  import { hashSlideContent } from "@shared/slide-fit";
20
20
  import {
21
+ IconBrush,
22
+ IconClipboard,
23
+ IconCopy,
21
24
  IconMaximize,
25
+ IconStackBack,
26
+ IconStackFront,
27
+ IconTrash,
22
28
  IconX,
23
29
  IconZoomIn,
24
30
  IconZoomOut,
@@ -45,6 +51,14 @@ import {
45
51
  ZERO_WIDTH_SPACE,
46
52
  } from "@/components/editor/bullet-editing";
47
53
  import { Button } from "@/components/ui/button";
54
+ import {
55
+ ContextMenu,
56
+ ContextMenuContent,
57
+ ContextMenuItem,
58
+ ContextMenuSeparator,
59
+ ContextMenuShortcut,
60
+ ContextMenuTrigger,
61
+ } from "@/components/ui/context-menu";
48
62
  import {
49
63
  Tooltip,
50
64
  TooltipContent,
@@ -63,11 +77,14 @@ import {
63
77
  MIN_CANVAS_ZOOM,
64
78
  } from "@/lib/canvas-zoom";
65
79
  import { extractMermaidBlocks } from "@/lib/mermaid-blocks";
80
+ import { publishSlidesSelection } from "@/lib/slide-agent-context";
66
81
  import {
67
82
  createPlaceholderImageTarget,
68
83
  imageFileLooksSupported,
84
+ type SlideImageDropPosition,
69
85
  } from "@/lib/slide-image-replacement";
70
86
  import { TAB_ID } from "@/lib/tab-id";
87
+ import { shortcutLabel } from "@/lib/utils";
71
88
  import { enterSelectionMode } from "@/root";
72
89
 
73
90
  import type { DesignSystemData } from "../../../shared/api";
@@ -77,9 +94,14 @@ import {
77
94
  isWithinSlidesCanvasEdgeMoveBand,
78
95
  resolveSlidesCanvasPointerIntent,
79
96
  resolveSlidesCanvasDragTarget,
97
+ resolveSlidesCanvasNudge,
80
98
  slidesCanvasInteractionCore,
81
99
  } from "./canvas-interactions";
82
100
  import ImageOverlay from "./ImageOverlay";
101
+ import {
102
+ shouldPersistInlineEditContent,
103
+ type InlineEditContentSnapshot,
104
+ } from "./inline-edit-session";
83
105
  import {
84
106
  detectSlideListKind,
85
107
  toggleSlideList,
@@ -121,6 +143,7 @@ import {
121
143
  getSlideSelectionMode,
122
144
  findPersistedImageObject,
123
145
  isDeletableFlowImage,
146
+ isDeletableSlideElement,
124
147
  removeSlideObjectAndLayoutSpacer,
125
148
  resolveSlideObjectContainingBlock,
126
149
  type CopiedSlideObjects,
@@ -142,6 +165,12 @@ import {
142
165
  import { SlideContextToolbar } from "./SlideContextToolbar";
143
166
  import { SlideOverflowWarning } from "./SlideOverflowWarning";
144
167
  import { SpeakerNotesPanel } from "./SpeakerNotesPanel";
168
+ import {
169
+ copiedElementStyleFromSnapshot,
170
+ getCopiedElementStyle,
171
+ setCopiedElementStyle,
172
+ type CopiedElementStyle,
173
+ } from "./style-clipboard";
145
174
 
146
175
  function ExcalidrawExitButton(props: { onExit: () => void; label: string }) {
147
176
  return (
@@ -166,10 +195,9 @@ let builderIdCounter = 0;
166
195
  const CANVAS_ZOOM_PRESETS = [10, 25, 50, 75, 100, 125, 150, 200] as const;
167
196
 
168
197
  /**
169
- * Object operations are intentionally narrower than ordinary canvas
170
- * selection. A flow-layout node may be selectable for inspection, but must
171
- * never be copied, reordered, deleted, or moved as though it were a canvas
172
- * object.
198
+ * Operations that require an already-persisted freeform object are narrower
199
+ * than ordinary canvas selection. A flow-layout node can still be promoted by
200
+ * a thresholded drag, but it is not eligible for copy or arrange beforehand.
173
201
  */
174
202
  function isPersistedFreeformObject(element: HTMLElement): boolean {
175
203
  return (
@@ -237,7 +265,14 @@ function stripBuilderIds(html: string): string {
237
265
  parent.removeChild(wrapper);
238
266
  }
239
267
  const stripRoot = doc.querySelector("[data-strip-root]");
240
- if (stripRoot) stripPlaceholderZws(stripRoot);
268
+ if (stripRoot) {
269
+ stripPlaceholderZws(stripRoot);
270
+ // Layout spacers only keep an in-flow element's slot stable while a
271
+ // drag is live. They are editor chrome, not part of the persisted slide.
272
+ stripRoot.querySelectorAll(".fmd-layout-spacer").forEach((spacer) => {
273
+ spacer.remove();
274
+ });
275
+ }
241
276
  cleaned = stripRoot?.innerHTML ?? doc.body.innerHTML;
242
277
  }
243
278
 
@@ -417,7 +452,10 @@ function buildStyleSnapshot(
417
452
  isText:
418
453
  element.tagName !== "IMG" &&
419
454
  (!!textPreview || element.classList.contains("fmd-text-box")),
420
- isImage: element.tagName === "IMG",
455
+ isImage:
456
+ element.tagName === "IMG" ||
457
+ element.classList.contains("fmd-pptx-image") ||
458
+ element.getAttribute("data-pptx-element-kind") === "image",
421
459
  color:
422
460
  selectedColor === null || selectedColor === undefined
423
461
  ? normalizedColor(computed.color)
@@ -488,7 +526,7 @@ function selectionItemForElement(
488
526
  imageSrc:
489
527
  element instanceof HTMLImageElement
490
528
  ? (element.getAttribute("src") ?? undefined)
491
- : undefined,
529
+ : (element.querySelector("img")?.getAttribute("src") ?? undefined),
492
530
  style: snapshot ? { ...snapshot, selector: identity.selector } : undefined,
493
531
  };
494
532
  }
@@ -508,13 +546,22 @@ function syncSelectionToAppState(state: SlidesSelectionState | null) {
508
546
  requestSource: TAB_ID,
509
547
  }).catch(() => {});
510
548
  }
511
- const generic = state ? { items: state.items } : null;
549
+ const generic = state
550
+ ? {
551
+ deckId: state.deckId,
552
+ slideId: state.slideId,
553
+ slideIndex: state.slideIndex,
554
+ slideNumber: state.slideNumber,
555
+ items: state.items,
556
+ }
557
+ : null;
512
558
  for (const key of genericKeys) {
513
559
  setClientAppState(key, generic, {
514
560
  keepalive: true,
515
561
  requestSource: TAB_ID,
516
562
  }).catch(() => {});
517
563
  }
564
+ publishSlidesSelection(state);
518
565
  }
519
566
 
520
567
  interface SlideEditorProps {
@@ -539,12 +586,10 @@ interface SlideEditorProps {
539
586
  onGenerateImage: () => void;
540
587
  onOpenAssetLibrary: (replaceSrc: string) => void;
541
588
  onUploadImage: (replaceSrc: string) => void;
542
- onSearchImage: (replaceSrc: string) => void;
543
- onLogoSearch: (replaceSrc: string) => void;
544
589
  onDropImage?: (
545
590
  replaceSrc: string | null,
546
591
  file: File,
547
- position?: { x: number; y: number },
592
+ position?: SlideImageDropPosition,
548
593
  ) => void;
549
594
  /** Fired when an image is dragged from elsewhere in the app (e.g. a
550
595
  * generated-image preview in the agent chat panel) and dropped on the
@@ -552,7 +597,7 @@ interface SlideEditorProps {
552
597
  onDropImageUrl?: (
553
598
  replaceSrc: string | null,
554
599
  url: string,
555
- position?: { x: number; y: number },
600
+ position?: SlideImageDropPosition,
556
601
  ) => void;
557
602
  onToggleObjectFit: (imgSrc: string, newFit: string) => void;
558
603
  /** Current user display info for cursor caret */
@@ -1003,8 +1048,6 @@ export default function SlideEditor({
1003
1048
  onGenerateImage,
1004
1049
  onOpenAssetLibrary,
1005
1050
  onUploadImage,
1006
- onSearchImage,
1007
- onLogoSearch,
1008
1051
  onDropImage,
1009
1052
  onDropImageUrl,
1010
1053
  onToggleObjectFit,
@@ -1104,6 +1147,10 @@ export default function SlideEditor({
1104
1147
  );
1105
1148
  const [selectedStyleSnapshot, setSelectedStyleSnapshot] =
1106
1149
  useState<SlideStyleSnapshot | null>(null);
1150
+ const [hasCopiedElementStyle, setHasCopiedElementStyle] = useState(
1151
+ () => getCopiedElementStyle() !== null,
1152
+ );
1153
+ const contextMenuTargetRef = useRef<HTMLElement | null>(null);
1107
1154
  const selectionOverlayMeasurementKey = createSelectionOverlayMeasurementKey({
1108
1155
  slideId: slide.id,
1109
1156
  content,
@@ -1144,8 +1191,12 @@ export default function SlideEditor({
1144
1191
  const repairRequestTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
1145
1192
  null,
1146
1193
  );
1147
- const [isOverflowWarningDismissed, setIsOverflowWarningDismissed] =
1148
- useState(false);
1194
+ const [dismissedOverflowWarningKey, setDismissedOverflowWarningKey] =
1195
+ useState<string | null>(null);
1196
+ const overflowWarningKey = `${slide.id}:${hashSlideContent(slide.content)}`;
1197
+ const warningVisible = dismissedOverflowWarningKey !== overflowWarningKey;
1198
+ const dismissWarning = () =>
1199
+ setDismissedOverflowWarningKey(overflowWarningKey);
1149
1200
  const dims = getAspectRatioDims(aspectRatio);
1150
1201
  const [fitCanvasZoom, setFitCanvasZoom] = useState(100);
1151
1202
  const userSetCanvasZoomRef = useRef(false);
@@ -1260,7 +1311,9 @@ export default function SlideEditor({
1260
1311
  }, [dims.width]);
1261
1312
 
1262
1313
  // Reset overflow state whenever the slide changes — the renderer will
1263
- // report the next measurement (or stay null if the new slide fits).
1314
+ // report the next measurement (or stay null if the new slide fits). The
1315
+ // dismissal key is derived from slide content, so a delayed save cannot
1316
+ // make the same warning reappear after the user closes it.
1264
1317
  useEffect(() => {
1265
1318
  if (repairRequestTimerRef.current) {
1266
1319
  clearTimeout(repairRequestTimerRef.current);
@@ -1268,7 +1321,6 @@ export default function SlideEditor({
1268
1321
  }
1269
1322
  setOverflowInfo(null);
1270
1323
  setIsAskingAgentToFix(false);
1271
- setIsOverflowWarningDismissed(false);
1272
1324
  syncOverflowToAppState(null);
1273
1325
  }, [slide.id, slide.content]);
1274
1326
 
@@ -1316,7 +1368,7 @@ export default function SlideEditor({
1316
1368
  horizontalOverflow: info.horizontalOverflow,
1317
1369
  });
1318
1370
  },
1319
- [slide.id, deckId],
1371
+ [slide.id, slide.content, deckId],
1320
1372
  );
1321
1373
 
1322
1374
  const handleAskAgentToFixLayout = useCallback(() => {
@@ -1404,10 +1456,10 @@ export default function SlideEditor({
1404
1456
  useEffect(() => {
1405
1457
  onUpdateSlideRef.current = onUpdateSlide;
1406
1458
  }, [onUpdateSlide]);
1407
- const inlineEditDraftRef = useRef<{
1408
- slideId: string;
1409
- content: string;
1410
- } | null>(null);
1459
+ const inlineEditDraftRef = useRef<InlineEditContentSnapshot | null>(null);
1460
+ const inlineEditInitialContentRef = useRef<InlineEditContentSnapshot | null>(
1461
+ null,
1462
+ );
1411
1463
  const previousSlideIdRef = useRef(slide.id);
1412
1464
 
1413
1465
  const readCurrentSlideContentHtml = useCallback(() => {
@@ -1466,6 +1518,12 @@ export default function SlideEditor({
1466
1518
  (slideId = slide.id) => {
1467
1519
  const html = readCurrentSlideContentHtml();
1468
1520
  if (html !== null) {
1521
+ if (
1522
+ !inlineEditInitialContentRef.current ||
1523
+ inlineEditInitialContentRef.current.slideId !== slideId
1524
+ ) {
1525
+ inlineEditInitialContentRef.current = { slideId, content: html };
1526
+ }
1469
1527
  inlineEditDraftRef.current = { slideId, content: html };
1470
1528
  }
1471
1529
  },
@@ -1483,8 +1541,8 @@ export default function SlideEditor({
1483
1541
 
1484
1542
  /**
1485
1543
  * Turn a normal layout block into a freeform object at its current visual
1486
- * coordinates. A hidden same-size sibling retains the flex/grid slot, so
1487
- * the rest of the slide does not reflow when Escape selects the block.
1544
+ * coordinates once the user starts moving it. A hidden same-size sibling
1545
+ * retains the flex/grid slot, so the rest of the slide does not reflow.
1488
1546
  */
1489
1547
  const freezeElementForFreeformSelection = useCallback(
1490
1548
  (
@@ -1715,7 +1773,7 @@ export default function SlideEditor({
1715
1773
  ],
1716
1774
  );
1717
1775
 
1718
- /** Exit edit mode, saving changes to slide.content */
1776
+ /** Exit edit mode, saving changed content without changing its layout. */
1719
1777
  const exitInlineEdit = useCallback(() => {
1720
1778
  const el = editingElRef.current;
1721
1779
  if (!el) return;
@@ -1724,16 +1782,25 @@ export default function SlideEditor({
1724
1782
  el.contentEditable = "false";
1725
1783
  el.removeAttribute("data-editing-block");
1726
1784
 
1727
- const frozen = freezeElementForFreeformSelection(el);
1728
- const selected = frozen?.element ?? null;
1729
- const selector = selected ? getBuilderSelector(selected) : null;
1785
+ // Selection and freeform promotion are separate operations. Merely ending
1786
+ // text editing must not turn a flow-layout block into an absolutely
1787
+ // positioned object, because that changes its available wrapping width.
1788
+ const selected = el;
1789
+ const selector = getBuilderSelector(selected);
1730
1790
 
1731
1791
  const html = selected ? readCurrentSlideContentHtml() : null;
1732
- frozen?.restoreMarkdownTree?.();
1733
- if (html !== null) {
1792
+ const initial = inlineEditInitialContentRef.current;
1793
+ if (
1794
+ html !== null &&
1795
+ shouldPersistInlineEditContent(initial, {
1796
+ slideId: slide.id,
1797
+ content: html,
1798
+ })
1799
+ ) {
1734
1800
  onUpdateSlideRef.current({ content: html });
1735
1801
  }
1736
1802
  inlineEditDraftRef.current = null;
1803
+ inlineEditInitialContentRef.current = null;
1737
1804
  const escape = slidesCanvasInteractionCore.escape({
1738
1805
  editingObjectId: "inline-editing",
1739
1806
  selectedObjectIds: resolveSelectedElement() ? ["selected"] : [],
@@ -1751,7 +1818,7 @@ export default function SlideEditor({
1751
1818
  readCurrentSlideContentHtml,
1752
1819
  resolveSelectedElement,
1753
1820
  selectElementForStyling,
1754
- freezeElementForFreeformSelection,
1821
+ slide.id,
1755
1822
  clearSelectedElement,
1756
1823
  syncSelectionToAppState,
1757
1824
  ]);
@@ -1805,10 +1872,15 @@ export default function SlideEditor({
1805
1872
  richTextSelectionRef.current = null;
1806
1873
  setEditingEl(null);
1807
1874
 
1808
- if (draft?.slideId === previousSlideId) {
1875
+ const initial = inlineEditInitialContentRef.current;
1876
+ if (
1877
+ draft?.slideId === previousSlideId &&
1878
+ shouldPersistInlineEditContent(initial, draft)
1879
+ ) {
1809
1880
  onUpdateSlideRef.current({ content: draft.content }, previousSlideId);
1810
1881
  inlineEditDraftRef.current = null;
1811
1882
  }
1883
+ inlineEditInitialContentRef.current = null;
1812
1884
 
1813
1885
  previousSlideIdRef.current = slide.id;
1814
1886
  }, [slide.id]);
@@ -2082,6 +2154,7 @@ export default function SlideEditor({
2082
2154
  return;
2083
2155
  setSelectedImg(null);
2084
2156
  setImageOverlay(null);
2157
+ syncSelectionToAppState(null);
2085
2158
  };
2086
2159
  document.addEventListener("mousedown", handleClick);
2087
2160
  return () => document.removeEventListener("mousedown", handleClick);
@@ -2091,6 +2164,7 @@ export default function SlideEditor({
2091
2164
  useEffect(() => {
2092
2165
  setSelectedImg(null);
2093
2166
  setImageOverlay(null);
2167
+ syncSelectionToAppState(null);
2094
2168
  }, [slide.id]);
2095
2169
 
2096
2170
  // Stamp all elements with data-builder-id after render
@@ -2127,15 +2201,22 @@ export default function SlideEditor({
2127
2201
  `[data-builder-id="${id}"]`,
2128
2202
  ) as HTMLElement | null;
2129
2203
  if (!el) return;
2130
- const selector = `[data-builder-id="${id}"]`;
2204
+ const runtimeSelector = `[data-builder-id="${id}"]`;
2131
2205
  const text = (el.textContent || "").trim().slice(0, 200);
2132
- rects.set(id, { rect: el.getBoundingClientRect(), text, selector });
2133
- items.push(selectionItemForElement(el, selector));
2206
+ const identity = getSlideSelectionIdentity(el, runtimeSelector);
2207
+ rects.set(id, {
2208
+ rect: el.getBoundingClientRect(),
2209
+ text,
2210
+ selector: identity.selector,
2211
+ });
2212
+ items.push(selectionItemForElement(el, runtimeSelector));
2134
2213
  });
2135
2214
  }
2136
2215
  setMultiSelection(ids);
2137
2216
  setMultiSelectionRects(rects);
2138
2217
  if (ids.size > 0) clearSelectedElement();
2218
+ setSelectedImg(null);
2219
+ setImageOverlay(null);
2139
2220
  // Anchor the chip to the slide canvas (clickable wrapper)
2140
2221
  const canvas = containerRef.current?.querySelector(
2141
2222
  ".slide-image-clickable",
@@ -2328,7 +2409,8 @@ export default function SlideEditor({
2328
2409
  next.set(id, {
2329
2410
  rect: el.getBoundingClientRect(),
2330
2411
  text: (el.textContent || "").trim().slice(0, 200),
2331
- selector: `[data-builder-id="${id}"]`,
2412
+ selector: getSlideSelectionIdentity(el, `[data-builder-id="${id}"]`)
2413
+ .selector,
2332
2414
  });
2333
2415
  });
2334
2416
  setMultiSelectionRects(next);
@@ -2365,10 +2447,74 @@ export default function SlideEditor({
2365
2447
  syncSelectionToAppState(null);
2366
2448
  }, [clearSelectedElement, slide.id]);
2367
2449
 
2368
- // Delete/Backspace removes the selected shape/text box (single or
2369
- // multi-select) from the slide. Only active when something is selected for
2370
- // styling (not while inline-editing text, where Backspace should delete a
2371
- // character) and not while the browser focus is in an unrelated input.
2450
+ const deleteSelectedElements = useCallback(() => {
2451
+ const slideContent = getSlideContent();
2452
+ if (!slideContent) return false;
2453
+
2454
+ if (multiSelection.size > 0) {
2455
+ const selected = Array.from(multiSelection)
2456
+ .map(
2457
+ (id) =>
2458
+ slideContent.querySelector(
2459
+ `[data-builder-id="${id}"]`,
2460
+ ) as HTMLElement | null,
2461
+ )
2462
+ .filter((element): element is HTMLElement => element !== null);
2463
+ const roots = selected.filter(
2464
+ (element) =>
2465
+ !selected.some(
2466
+ (candidate) => candidate !== element && candidate.contains(element),
2467
+ ),
2468
+ );
2469
+ if (
2470
+ roots.length === 0 ||
2471
+ roots.some((element) => !isDeletableSlideElement(element))
2472
+ ) {
2473
+ return false;
2474
+ }
2475
+ for (const element of roots) {
2476
+ removeSlideObjectAndLayoutSpacer(element);
2477
+ }
2478
+ clearMultiSelection();
2479
+ } else {
2480
+ const element = resolveSelectedElement();
2481
+ if (!element) return false;
2482
+ if (!isDeletableSlideElement(element)) {
2483
+ return false;
2484
+ }
2485
+ if (isDeletableFlowImage(element)) {
2486
+ // Imported images can be nested inside an absolutely-positioned owner;
2487
+ // remove that owner so its persisted metadata cannot become a ghost.
2488
+ const owner = findPersistedImageObject(element, slideContent);
2489
+ if (owner) removeSlideObjectAndLayoutSpacer(owner);
2490
+ else element.remove();
2491
+ setSelectedImg(null);
2492
+ setImageOverlay(null);
2493
+ } else {
2494
+ removeSlideObjectAndLayoutSpacer(element);
2495
+ }
2496
+ clearSelectedElement();
2497
+ }
2498
+
2499
+ const html = readCurrentSlideContentHtml();
2500
+ if (html !== null) {
2501
+ onUpdateSlideRef.current({ content: html, animations: [] });
2502
+ }
2503
+ syncSelectionToAppState(null);
2504
+ return true;
2505
+ }, [
2506
+ clearMultiSelection,
2507
+ clearSelectedElement,
2508
+ getSlideContent,
2509
+ multiSelection,
2510
+ readCurrentSlideContentHtml,
2511
+ resolveSelectedElement,
2512
+ ]);
2513
+
2514
+ // Delete/Backspace removes the selected slide content (single or
2515
+ // multi-select). Only active when something is selected for styling (not
2516
+ // while inline-editing text, where Backspace should delete a character) and
2517
+ // not while the browser focus is in an unrelated input.
2372
2518
  useEffect(() => {
2373
2519
  if (editingEl) return;
2374
2520
  if (multiSelection.size === 0 && !selectedElementSelector) return;
@@ -2378,77 +2524,15 @@ export default function SlideEditor({
2378
2524
  const tag = active?.tagName;
2379
2525
  if (tag === "INPUT" || tag === "TEXTAREA") return;
2380
2526
  if (active instanceof HTMLElement && active.isContentEditable) return;
2381
-
2382
- const slideContent = getSlideContent();
2383
- if (!slideContent) return;
2384
-
2385
- if (multiSelection.size > 0) {
2386
- const selected = Array.from(multiSelection)
2387
- .map(
2388
- (id) =>
2389
- slideContent.querySelector(
2390
- `[data-builder-id="${id}"]`,
2391
- ) as HTMLElement | null,
2392
- )
2393
- .filter((element): element is HTMLElement => element !== null);
2394
- const roots = selected.filter(
2395
- (element) =>
2396
- !selected.some(
2397
- (candidate) =>
2398
- candidate !== element && candidate.contains(element),
2399
- ),
2400
- );
2401
- if (
2402
- roots.length === 0 ||
2403
- roots.some((element) => !isPersistedFreeformObject(element))
2404
- ) {
2405
- return;
2406
- }
2407
- e.preventDefault();
2408
- for (const element of roots) {
2409
- removeSlideObjectAndLayoutSpacer(element);
2410
- }
2411
- clearMultiSelection();
2412
- } else {
2413
- const element = resolveSelectedElement();
2414
- if (!element) return;
2415
- if (isPersistedFreeformObject(element)) {
2416
- e.preventDefault();
2417
- removeSlideObjectAndLayoutSpacer(element);
2418
- } else if (isDeletableFlowImage(element)) {
2419
- e.preventDefault();
2420
- // An imported PPTX/PDF image is an <img> (or placeholder) nested in
2421
- // an absolutely positioned wrapper that carries the persisted object
2422
- // id. Removing only the inner node would leave that wrapper and its
2423
- // durable metadata behind as an invisible ghost object.
2424
- const owner = findPersistedImageObject(element, slideContent);
2425
- if (owner) removeSlideObjectAndLayoutSpacer(owner);
2426
- else element.remove();
2427
- setSelectedImg(null);
2428
- setImageOverlay(null);
2429
- } else {
2430
- return;
2431
- }
2432
- clearSelectedElement();
2433
- }
2434
-
2435
- const html = readCurrentSlideContentHtml();
2436
- if (html !== null) onUpdateSlideRef.current({ content: html });
2437
- syncSelectionToAppState(null);
2527
+ if (deleteSelectedElements()) e.preventDefault();
2438
2528
  };
2439
2529
  window.addEventListener("keydown", onKey);
2440
2530
  return () => window.removeEventListener("keydown", onKey);
2441
2531
  }, [
2532
+ deleteSelectedElements,
2442
2533
  editingEl,
2443
2534
  multiSelection,
2444
2535
  selectedElementSelector,
2445
- getSlideContent,
2446
- clearMultiSelection,
2447
- resolveSelectedElement,
2448
- clearSelectedElement,
2449
- readCurrentSlideContentHtml,
2450
- removeSlideObjectAndLayoutSpacer,
2451
- syncSelectionToAppState,
2452
2536
  ]);
2453
2537
 
2454
2538
  /**
@@ -2461,6 +2545,17 @@ export default function SlideEditor({
2461
2545
  let el: HTMLElement | null = target;
2462
2546
  while (el && slideContent.contains(el) && el !== slideContent) {
2463
2547
  if (el.classList.contains("fmd-layout-spacer")) return null;
2548
+ if (isSlideCanvasShell(el)) {
2549
+ el = el.parentElement;
2550
+ continue;
2551
+ }
2552
+ if (
2553
+ el.tagName === "IMG" ||
2554
+ el.classList.contains("fmd-img-placeholder")
2555
+ ) {
2556
+ const imageObject = findPersistedImageObject(el, slideContent);
2557
+ if (imageObject) return imageObject.getAttribute("data-builder-id");
2558
+ }
2464
2559
  if (isInlineTextElement(el)) {
2465
2560
  el = el.parentElement;
2466
2561
  continue;
@@ -2479,6 +2574,17 @@ export default function SlideEditor({
2479
2574
  let el: HTMLElement | null = target;
2480
2575
  while (el && slideContent.contains(el) && el !== slideContent) {
2481
2576
  if (el.classList.contains("fmd-layout-spacer")) return null;
2577
+ if (isSlideCanvasShell(el)) {
2578
+ el = el.parentElement;
2579
+ continue;
2580
+ }
2581
+ if (
2582
+ el.tagName === "IMG" ||
2583
+ el.classList.contains("fmd-img-placeholder")
2584
+ ) {
2585
+ const imageObject = findPersistedImageObject(el, slideContent);
2586
+ if (imageObject) return imageObject;
2587
+ }
2482
2588
  if (isInlineTextElement(el)) {
2483
2589
  el = el.parentElement;
2484
2590
  continue;
@@ -2634,6 +2740,211 @@ export default function SlideEditor({
2634
2740
  ],
2635
2741
  );
2636
2742
 
2743
+ const getStyleTargets = useCallback((): HTMLElement[] => {
2744
+ const slideContent = getSlideContent();
2745
+ if (!slideContent) return [];
2746
+ if (contextMenuTargetRef.current) {
2747
+ return [contextMenuTargetRef.current];
2748
+ }
2749
+ if (multiSelection.size > 0) {
2750
+ return Array.from(multiSelection)
2751
+ .map(
2752
+ (id) =>
2753
+ slideContent.querySelector(
2754
+ `[data-builder-id="${id}"]`,
2755
+ ) as HTMLElement | null,
2756
+ )
2757
+ .filter((element): element is HTMLElement => element !== null);
2758
+ }
2759
+ const element = editingElRef.current ?? resolveSelectedElement();
2760
+ return element ? [element] : [];
2761
+ }, [getSlideContent, multiSelection, resolveSelectedElement]);
2762
+
2763
+ const styleSnapshotForElement = useCallback(
2764
+ (element: HTMLElement): SlideStyleSnapshot => {
2765
+ const selector =
2766
+ getBuilderSelector(element) ??
2767
+ selectedElementSelector ??
2768
+ element.tagName;
2769
+ const inlineTextStyle =
2770
+ editingElRef.current === element
2771
+ ? getInlineTextStyleSnapshotForRange(
2772
+ element,
2773
+ richTextSelectionRef.current,
2774
+ )
2775
+ : undefined;
2776
+ return buildStyleSnapshot(element, selector, inlineTextStyle);
2777
+ },
2778
+ [selectedElementSelector],
2779
+ );
2780
+
2781
+ const applyStylePatchToElement = useCallback(
2782
+ (
2783
+ element: HTMLElement,
2784
+ patch: CopiedElementStyle,
2785
+ range: Range | null = null,
2786
+ ): Range | null => {
2787
+ const inlinePatch = inlineInspectorStylePatch(patch);
2788
+ let styledRange: Range | null = null;
2789
+ if (
2790
+ range &&
2791
+ restoreEditableTextRange(element, range) &&
2792
+ Object.keys(inlinePatch).length > 0
2793
+ ) {
2794
+ const result = applyInlineTextStyle(element, inlinePatch);
2795
+ if (result.scope === "selection" && result.range) {
2796
+ styledRange = result.range.cloneRange();
2797
+ }
2798
+ }
2799
+
2800
+ if (!styledRange && Object.keys(inlinePatch).length > 0) {
2801
+ applyDescendantTextStyle(element, inlinePatch);
2802
+ }
2803
+
2804
+ for (const [property, value] of Object.entries(patch)) {
2805
+ if (value === undefined) continue;
2806
+ if (
2807
+ styledRange &&
2808
+ INLINE_INSPECTOR_STYLE_KEYS.includes(
2809
+ property as (typeof INLINE_INSPECTOR_STYLE_KEYS)[number],
2810
+ )
2811
+ ) {
2812
+ continue;
2813
+ }
2814
+ element.style.setProperty(stylePropertyName(property), value);
2815
+ }
2816
+
2817
+ if (
2818
+ patch.borderWidth &&
2819
+ patch.borderWidth !== "0" &&
2820
+ patch.borderWidth !== "0px" &&
2821
+ window.getComputedStyle(element).borderStyle === "none"
2822
+ ) {
2823
+ element.style.borderStyle = "solid";
2824
+ }
2825
+
2826
+ return styledRange;
2827
+ },
2828
+ [],
2829
+ );
2830
+
2831
+ const copySelectedElementStyle = useCallback(() => {
2832
+ const source = getStyleTargets()[0];
2833
+ if (!source) return false;
2834
+ setCopiedElementStyle(
2835
+ copiedElementStyleFromSnapshot(styleSnapshotForElement(source)),
2836
+ );
2837
+ setHasCopiedElementStyle(true);
2838
+ return true;
2839
+ }, [getStyleTargets, styleSnapshotForElement]);
2840
+
2841
+ const pasteCopiedElementStyle = useCallback(() => {
2842
+ const copied = getCopiedElementStyle();
2843
+ if (!copied) return false;
2844
+ const targets = getStyleTargets();
2845
+ if (targets.length === 0) return false;
2846
+
2847
+ const editing = editingElRef.current;
2848
+ const savedRange = editing
2849
+ ? (richTextSelectionRef.current ?? snapshotEditableTextRange(editing))
2850
+ : null;
2851
+ const nextRange = editing
2852
+ ? applyStylePatchToElement(editing, copied, savedRange)
2853
+ : null;
2854
+ if (editing && nextRange) richTextSelectionRef.current = nextRange;
2855
+
2856
+ for (const target of editing ? targets.slice(1) : targets) {
2857
+ applyStylePatchToElement(target, copied);
2858
+ }
2859
+
2860
+ if (editing) {
2861
+ captureInlineEditDraft(slide.id);
2862
+ const selector = selectedElementSelector ?? getBuilderSelector(editing);
2863
+ if (selector) {
2864
+ setSelectedStyleSnapshot(
2865
+ buildStyleSnapshot(
2866
+ editing,
2867
+ selector,
2868
+ getInlineTextStyleSnapshotForRange(
2869
+ editing,
2870
+ richTextSelectionRef.current,
2871
+ ),
2872
+ ),
2873
+ );
2874
+ }
2875
+ } else {
2876
+ const html = readCurrentSlideContentHtml();
2877
+ if (html !== null) onUpdateSlideRef.current({ content: html });
2878
+ if (targets.length === 1) {
2879
+ const selector = getBuilderSelector(targets[0]);
2880
+ if (selector) selectElementForStyling(targets[0], selector);
2881
+ }
2882
+ }
2883
+ return true;
2884
+ }, [
2885
+ applyStylePatchToElement,
2886
+ captureInlineEditDraft,
2887
+ getStyleTargets,
2888
+ readCurrentSlideContentHtml,
2889
+ selectedElementSelector,
2890
+ selectElementForStyling,
2891
+ slide.id,
2892
+ ]);
2893
+
2894
+ const copySelectedObjects = useCallback(() => {
2895
+ const selection = getObjectOperationSelection();
2896
+ if (!selection) return false;
2897
+ const copied = copySlideObjects(selection.elements);
2898
+ const sourceObjectId = selection.elements[0].getAttribute(
2899
+ "data-slide-object-id",
2900
+ );
2901
+ if (!sourceObjectId) return false;
2902
+ copiedObjectClipboardRef.current = {
2903
+ copied,
2904
+ deckId,
2905
+ slideId: slide.id,
2906
+ sourceObjectId,
2907
+ };
2908
+ return true;
2909
+ }, [deckId, getObjectOperationSelection, slide.id]);
2910
+
2911
+ const pasteSelectedObjects = useCallback(() => {
2912
+ const clipboard = copiedObjectClipboardRef.current;
2913
+ const selection = getObjectOperationSelection();
2914
+ if (
2915
+ !clipboard ||
2916
+ !selection ||
2917
+ clipboard.deckId !== deckId ||
2918
+ clipboard.slideId !== slide.id
2919
+ ) {
2920
+ return false;
2921
+ }
2922
+ const slideContent = getSlideContent();
2923
+ const source =
2924
+ slideContent &&
2925
+ findSlideObjectById(slideContent, clipboard.sourceObjectId);
2926
+ if (
2927
+ !source ||
2928
+ resolveSlideObjectContainingBlock(source, selection.positioningLayer) !==
2929
+ selection.containingBlock
2930
+ ) {
2931
+ return false;
2932
+ }
2933
+ pasteSlideObjects(clipboard.copied, selection.elements[0]);
2934
+ return true;
2935
+ }, [
2936
+ deckId,
2937
+ getObjectOperationSelection,
2938
+ getSlideContent,
2939
+ pasteSlideObjects,
2940
+ slide.id,
2941
+ ]);
2942
+
2943
+ const duplicateSelectedObjects = useCallback(() => {
2944
+ if (!copySelectedObjects()) return false;
2945
+ return pasteSelectedObjects();
2946
+ }, [copySelectedObjects, pasteSelectedObjects]);
2947
+
2637
2948
  // Object clipboard contents are editor-local. It intentionally does not
2638
2949
  // survive a deck switch, unlike the browser's native text clipboard.
2639
2950
  useEffect(() => {
@@ -2648,6 +2959,7 @@ export default function SlideEditor({
2648
2959
  if (readOnly) return;
2649
2960
  const onKey = (e: KeyboardEvent) => {
2650
2961
  if (!(e.metaKey || e.ctrlKey)) return;
2962
+ if (e.altKey) return;
2651
2963
  const key = e.key.toLowerCase();
2652
2964
  if (key !== "c" && key !== "v" && key !== "d") return;
2653
2965
 
@@ -2730,6 +3042,48 @@ export default function SlideEditor({
2730
3042
  slide.id,
2731
3043
  ]);
2732
3044
 
3045
+ // Appearance clipboard shortcuts are deliberately separate from object
3046
+ // copy/paste. The Alt modifier keeps Cmd/Ctrl+C/V available for duplicating
3047
+ // objects and for native text editing.
3048
+ useEffect(() => {
3049
+ if (readOnly) return;
3050
+ const onKey = (e: KeyboardEvent) => {
3051
+ if (!(e.metaKey || e.ctrlKey) || !e.altKey) return;
3052
+ const key =
3053
+ e.code === "KeyC" ? "c" : e.code === "KeyV" ? "v" : e.key.toLowerCase();
3054
+ if (key !== "c" && key !== "v") return;
3055
+
3056
+ const active = document.activeElement;
3057
+ if (
3058
+ active instanceof HTMLInputElement ||
3059
+ active instanceof HTMLTextAreaElement ||
3060
+ active instanceof HTMLSelectElement
3061
+ ) {
3062
+ return;
3063
+ }
3064
+ if (
3065
+ active instanceof HTMLElement &&
3066
+ active.isContentEditable &&
3067
+ !editingEl?.contains(active)
3068
+ ) {
3069
+ return;
3070
+ }
3071
+ if (getStyleTargets().length === 0) return;
3072
+
3073
+ const handled =
3074
+ key === "c" ? copySelectedElementStyle() : pasteCopiedElementStyle();
3075
+ if (handled) e.preventDefault();
3076
+ };
3077
+ window.addEventListener("keydown", onKey);
3078
+ return () => window.removeEventListener("keydown", onKey);
3079
+ }, [
3080
+ copySelectedElementStyle,
3081
+ editingEl,
3082
+ getStyleTargets,
3083
+ pasteCopiedElementStyle,
3084
+ readOnly,
3085
+ ]);
3086
+
2733
3087
  const placeTextBoxAt = useCallback(
2734
3088
  (clientX: number, clientY: number, target: HTMLElement | null) => {
2735
3089
  const canvas = containerRef.current
@@ -3241,44 +3595,24 @@ export default function SlideEditor({
3241
3595
  ) as HTMLElement | null,
3242
3596
  )
3243
3597
  .filter((el): el is HTMLElement => el !== null);
3244
- if (elements.some((element) => !isPersistedFreeformObject(element))) {
3245
- return;
3246
- }
3247
- const members = collectMovableSlideObjects(elements, getObjectGeometry);
3248
- if (members.length === 0) return;
3598
+ const roots = elements.filter(
3599
+ (element) =>
3600
+ !elements.some(
3601
+ (candidate) => candidate !== element && candidate.contains(element),
3602
+ ),
3603
+ );
3604
+ if (roots.length === 0) return;
3249
3605
 
3250
- // Resolve the slide from a member, the way startElementDrag does.
3251
- // `.fmd-slide` is a DESCENDANT of `.slide-content`, so walking up from
3252
- // the container never finds it and the whole gesture aborts silently.
3253
- const fmdSlide = members[0].element.closest(
3254
- ".fmd-slide",
3606
+ // Capture the viewport before promotion. A normal-flow element becomes
3607
+ // an absolute child only after the drag threshold is crossed, so a
3608
+ // stationary click never mutates the slide's layout or HTML.
3609
+ const slideCanvas = roots[0].closest(
3610
+ ".fmd-slide, [data-slide-canvas]",
3255
3611
  ) as HTMLElement | null;
3256
- if (!fmdSlide) return;
3257
- const positioningLayer =
3258
- Array.from(fmdSlide.children).find(
3259
- (child): child is HTMLElement =>
3260
- child instanceof HTMLElement &&
3261
- child.hasAttribute("data-fmd-autofit-content"),
3262
- ) ?? fmdSlide;
3263
- const containingBlock = resolveSlideObjectContainingBlock(
3264
- members[0].element,
3265
- positioningLayer,
3266
- );
3267
- if (
3268
- members.some(
3269
- (member) =>
3270
- member.element.closest(".fmd-slide") !== fmdSlide ||
3271
- resolveSlideObjectContainingBlock(
3272
- member.element,
3273
- positioningLayer,
3274
- ) !== containingBlock,
3275
- )
3276
- ) {
3277
- return;
3278
- }
3279
- const slideRect = fmdSlide.getBoundingClientRect();
3280
- const slideWidth = fmdSlide.offsetWidth;
3281
- const slideHeight = fmdSlide.offsetHeight;
3612
+ if (!slideCanvas) return;
3613
+ const slideRect = slideCanvas.getBoundingClientRect();
3614
+ const slideWidth = slideCanvas.offsetWidth;
3615
+ const slideHeight = slideCanvas.offsetHeight;
3282
3616
  if (
3283
3617
  !slideRect.width ||
3284
3618
  !slideRect.height ||
@@ -3288,6 +3622,155 @@ export default function SlideEditor({
3288
3622
  return;
3289
3623
  }
3290
3624
 
3625
+ const promotions: Array<{
3626
+ element: HTMLElement;
3627
+ originalClassName: string;
3628
+ originalStyle: string | null;
3629
+ originalObjectId: string | null;
3630
+ originalContentEditable: string | null;
3631
+ originalEditingBlock: string | null;
3632
+ restoreMarkdownTree?: () => void;
3633
+ }> = [];
3634
+ let members: ReturnType<typeof collectMovableSlideObjects> = [];
3635
+ let prepared = false;
3636
+ let promotionsRestored = false;
3637
+
3638
+ const removeFreeformLayoutSpacer = (element: HTMLElement) => {
3639
+ const objectId = element.getAttribute("data-slide-object-id");
3640
+ if (!objectId) return;
3641
+ const spacer = Array.from(
3642
+ element.parentElement?.querySelectorAll<HTMLElement>(
3643
+ "[data-slide-layout-spacer-for]",
3644
+ ) ?? [],
3645
+ ).find(
3646
+ (candidate) =>
3647
+ candidate.getAttribute("data-slide-layout-spacer-for") === objectId,
3648
+ );
3649
+ spacer?.remove();
3650
+ };
3651
+
3652
+ const restoreGroupPromotions = () => {
3653
+ if (promotionsRestored) return;
3654
+ promotionsRestored = true;
3655
+
3656
+ for (const promotion of promotions) {
3657
+ removeFreeformLayoutSpacer(promotion.element);
3658
+ }
3659
+
3660
+ const restoredMarkdownTrees = new Set<() => void>();
3661
+ for (const promotion of promotions) {
3662
+ const restoreMarkdownTree = promotion.restoreMarkdownTree;
3663
+ if (
3664
+ restoreMarkdownTree &&
3665
+ !restoredMarkdownTrees.has(restoreMarkdownTree)
3666
+ ) {
3667
+ restoredMarkdownTrees.add(restoreMarkdownTree);
3668
+ restoreMarkdownTree();
3669
+ }
3670
+ }
3671
+
3672
+ for (const promotion of promotions) {
3673
+ const {
3674
+ element,
3675
+ originalClassName,
3676
+ originalStyle,
3677
+ originalObjectId,
3678
+ originalContentEditable,
3679
+ originalEditingBlock,
3680
+ } = promotion;
3681
+ element.className = originalClassName;
3682
+ if (originalStyle === null) element.removeAttribute("style");
3683
+ else element.setAttribute("style", originalStyle);
3684
+ if (originalContentEditable === null) {
3685
+ element.removeAttribute("contenteditable");
3686
+ } else {
3687
+ element.setAttribute("contenteditable", originalContentEditable);
3688
+ }
3689
+ if (originalEditingBlock === null) {
3690
+ element.removeAttribute("data-editing-block");
3691
+ } else {
3692
+ element.setAttribute("data-editing-block", originalEditingBlock);
3693
+ }
3694
+ if (originalObjectId) {
3695
+ element.setAttribute("data-slide-object-id", originalObjectId);
3696
+ } else {
3697
+ element.removeAttribute("data-slide-object-id");
3698
+ }
3699
+ }
3700
+ };
3701
+
3702
+ const prepareGroup = () => {
3703
+ if (prepared) return members.length > 0;
3704
+ prepared = true;
3705
+
3706
+ for (const element of roots) {
3707
+ const originalClassName = element.className;
3708
+ const originalStyle = element.getAttribute("style");
3709
+ const originalObjectId = element.getAttribute("data-slide-object-id");
3710
+ const originalContentEditable =
3711
+ element.getAttribute("contenteditable");
3712
+ const originalEditingBlock =
3713
+ element.getAttribute("data-editing-block");
3714
+ const frozen = freezeElementForFreeformSelection(element);
3715
+ if (!frozen) {
3716
+ restoreGroupPromotions();
3717
+ return false;
3718
+ }
3719
+ promotions.push({
3720
+ element,
3721
+ originalClassName,
3722
+ originalStyle,
3723
+ originalObjectId,
3724
+ originalContentEditable,
3725
+ originalEditingBlock,
3726
+ restoreMarkdownTree: frozen.restoreMarkdownTree,
3727
+ });
3728
+ }
3729
+
3730
+ const nextMembers = collectMovableSlideObjects(
3731
+ roots,
3732
+ getObjectGeometry,
3733
+ );
3734
+ if (nextMembers.length !== roots.length) {
3735
+ restoreGroupPromotions();
3736
+ return false;
3737
+ }
3738
+
3739
+ const fmdSlide = nextMembers[0].element.closest(
3740
+ ".fmd-slide",
3741
+ ) as HTMLElement | null;
3742
+ if (!fmdSlide) {
3743
+ restoreGroupPromotions();
3744
+ return false;
3745
+ }
3746
+ const positioningLayer =
3747
+ Array.from(fmdSlide.children).find(
3748
+ (child): child is HTMLElement =>
3749
+ child instanceof HTMLElement &&
3750
+ child.hasAttribute("data-fmd-autofit-content"),
3751
+ ) ?? fmdSlide;
3752
+ const containingBlock = resolveSlideObjectContainingBlock(
3753
+ nextMembers[0].element,
3754
+ positioningLayer,
3755
+ );
3756
+ if (
3757
+ nextMembers.some(
3758
+ (member) =>
3759
+ member.element.closest(".fmd-slide") !== fmdSlide ||
3760
+ resolveSlideObjectContainingBlock(
3761
+ member.element,
3762
+ positioningLayer,
3763
+ ) !== containingBlock,
3764
+ )
3765
+ ) {
3766
+ restoreGroupPromotions();
3767
+ return false;
3768
+ }
3769
+
3770
+ members = nextMembers;
3771
+ return true;
3772
+ };
3773
+
3291
3774
  const stop = () => {
3292
3775
  window.removeEventListener("pointermove", onMove);
3293
3776
  window.removeEventListener("pointerup", onUp);
@@ -3299,6 +3782,9 @@ export default function SlideEditor({
3299
3782
 
3300
3783
  const controller = createSlidesCanvasGestureController({
3301
3784
  preview: (gesture) => {
3785
+ if (!prepareGroup()) {
3786
+ return { handled: false, reason: "unhandled" };
3787
+ }
3302
3788
  // Claim the click only once a real drag starts, so a click without
3303
3789
  // movement on a multi-selected object remains normal editor input.
3304
3790
  suppressNextClickRef.current = true;
@@ -3312,18 +3798,49 @@ export default function SlideEditor({
3312
3798
  return { handled: true };
3313
3799
  },
3314
3800
  commit: () => {
3315
- commitMultiObjectChange(members.map((member) => member.objectId));
3801
+ if (members.length === 0) {
3802
+ return { handled: false, reason: "unhandled" };
3803
+ }
3804
+ // Serialize while the promoted elements still live in their fmd
3805
+ // canvas. Markdown promotion restores the React tree below, but
3806
+ // the persisted HTML must retain the canvas and absolute geometry.
3807
+ const html = readCurrentSlideContentHtml();
3808
+ for (const promotion of promotions) {
3809
+ removeFreeformLayoutSpacer(promotion.element);
3810
+ }
3811
+ const restoredMarkdownTrees = new Set<() => void>();
3812
+ for (const promotion of promotions) {
3813
+ const restoreMarkdownTree = promotion.restoreMarkdownTree;
3814
+ if (
3815
+ restoreMarkdownTree &&
3816
+ !restoredMarkdownTrees.has(restoreMarkdownTree)
3817
+ ) {
3818
+ restoredMarkdownTrees.add(restoreMarkdownTree);
3819
+ restoreMarkdownTree();
3820
+ }
3821
+ }
3822
+ if (html !== null) {
3823
+ pendingMultiSelectionResyncRef.current = members.map(
3824
+ (member) => member.objectId,
3825
+ );
3826
+ onUpdateSlideRef.current({ content: html }, undefined, {
3827
+ persistence: "immediate",
3828
+ });
3829
+ }
3316
3830
  return { handled: true };
3317
3831
  },
3318
3832
  cancel: () => {
3319
- applySlideObjectMoveDelta(members, 0, 0, applyObjectGeometry);
3320
- refreshMultiSelectionRects(ids);
3833
+ if (members.length > 0) {
3834
+ applySlideObjectMoveDelta(members, 0, 0, applyObjectGeometry);
3835
+ refreshMultiSelectionRects(ids);
3836
+ }
3837
+ restoreGroupPromotions();
3321
3838
  return { handled: true };
3322
3839
  },
3323
3840
  });
3324
3841
  controller.pointerDown({
3325
3842
  kind: "move",
3326
- objectIds: members.map((member) => member.objectId),
3843
+ objectIds: Array.from(ids),
3327
3844
  pointer: {
3328
3845
  x: e.clientX,
3329
3846
  y: e.clientY,
@@ -3382,10 +3899,11 @@ export default function SlideEditor({
3382
3899
  },
3383
3900
  [
3384
3901
  applyObjectGeometry,
3385
- commitMultiObjectChange,
3902
+ freezeElementForFreeformSelection,
3386
3903
  refreshMultiSelectionRects,
3387
3904
  getObjectGeometry,
3388
3905
  getSlideContent,
3906
+ readCurrentSlideContentHtml,
3389
3907
  readOnly,
3390
3908
  ],
3391
3909
  );
@@ -3404,11 +3922,11 @@ export default function SlideEditor({
3404
3922
  ) {
3405
3923
  return;
3406
3924
  }
3407
- const nudge = slidesCanvasInteractionCore.nudge(e);
3925
+ const nudge = resolveSlidesCanvasNudge(e);
3408
3926
  if (!nudge) return;
3409
3927
  const { x: dx, y: dy } = nudge.delta;
3410
3928
 
3411
- if (multiSelection.size > 0) {
3929
+ if (multiSelection.size > 0 && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
3412
3930
  const slideContent = getSlideContent();
3413
3931
  if (!slideContent) return;
3414
3932
  const elements = Array.from(multiSelection)
@@ -3661,6 +4179,7 @@ export default function SlideEditor({
3661
4179
  candidates.forEach((node) => {
3662
4180
  const el = node as HTMLElement;
3663
4181
  if (el.classList.contains("fmd-layout-spacer")) return;
4182
+ if (isSlideCanvasShell(el)) return;
3664
4183
  if (isInlineTextElement(el)) return;
3665
4184
  const id = el.getAttribute("data-builder-id");
3666
4185
  if (!id) return;
@@ -3689,11 +4208,27 @@ export default function SlideEditor({
3689
4208
  const list = Array.from(multiSelectionRects.values())
3690
4209
  .map((v) => v.selector)
3691
4210
  .join(", ");
3692
- agentChat.prefill(`[Selected: ${list}]\n`);
3693
- }, [multiSelection.size, multiSelectionRects]);
4211
+ agentChat.prefill(
4212
+ `[Current selection on slide ${slideIndex + 1} (${slide.id}): ${list}]\n`,
4213
+ );
4214
+ }, [multiSelection.size, multiSelectionRects, slide.id, slideIndex]);
3694
4215
 
3695
4216
  const showImageOverlay = useCallback(
3696
4217
  (target: HTMLElement) => {
4218
+ const publishImageSelection = (element: HTMLElement) => {
4219
+ const selector =
4220
+ getBuilderSelector(element) ??
4221
+ `[data-builder-id="${ensureBuilderId(element)}"]`;
4222
+ const snapshot = buildStyleSnapshot(element, selector);
4223
+ syncSelectionToAppState(
4224
+ buildSelectionState("image", [
4225
+ selectionItemForElement(element, selector, {
4226
+ ...snapshot,
4227
+ isImage: true,
4228
+ }),
4229
+ ]),
4230
+ );
4231
+ };
3697
4232
  if (target.tagName === "IMG") {
3698
4233
  const img = target as HTMLImageElement;
3699
4234
  const rect = img.getBoundingClientRect();
@@ -3705,6 +4240,7 @@ export default function SlideEditor({
3705
4240
  ) as "cover" | "contain";
3706
4241
  setSelectedImg(img);
3707
4242
  setImageOverlay({ rect, src, objectFit: fit });
4243
+ publishImageSelection(img);
3708
4244
  return;
3709
4245
  }
3710
4246
  // Also handle placeholder divs (dashed border boxes meant for images)
@@ -3719,9 +4255,10 @@ export default function SlideEditor({
3719
4255
  src: getPlaceholderTarget(placeholder),
3720
4256
  objectFit: "cover",
3721
4257
  });
4258
+ publishImageSelection(placeholder);
3722
4259
  }
3723
4260
  },
3724
- [getPlaceholderTarget],
4261
+ [buildSelectionState, getPlaceholderTarget],
3725
4262
  );
3726
4263
 
3727
4264
  // Browsers put the dragged element's outerHTML on the "text/html" data
@@ -3750,6 +4287,29 @@ export default function SlideEditor({
3750
4287
  [],
3751
4288
  );
3752
4289
 
4290
+ const getSlideDropPosition = useCallback(
4291
+ (clientX: number, clientY: number): SlideImageDropPosition | undefined => {
4292
+ const slideCanvas =
4293
+ slideCanvasRef.current?.querySelector<HTMLElement>(".fmd-slide") ??
4294
+ slideCanvasRef.current?.querySelector<HTMLElement>(
4295
+ "[data-slide-canvas]",
4296
+ );
4297
+ if (!slideCanvas) return undefined;
4298
+ const rect = slideCanvas.getBoundingClientRect();
4299
+ const width = slideCanvas.offsetWidth || rect.width;
4300
+ const height = slideCanvas.offsetHeight || rect.height;
4301
+ if (!rect.width || !rect.height || !width || !height) return undefined;
4302
+ return clientPointToSlideCoordinates(
4303
+ clientX,
4304
+ clientY,
4305
+ rect,
4306
+ width,
4307
+ height,
4308
+ );
4309
+ },
4310
+ [],
4311
+ );
4312
+
3753
4313
  const handleSlideDragOver = useCallback((e: React.DragEvent) => {
3754
4314
  const files = Array.from(e.dataTransfer.files ?? []);
3755
4315
  const items = Array.from(e.dataTransfer.items ?? []);
@@ -3781,7 +4341,7 @@ export default function SlideEditor({
3781
4341
  onDropImage?.(
3782
4342
  getImageReplacementTarget(e.target as HTMLElement),
3783
4343
  file,
3784
- { x: e.clientX, y: e.clientY },
4344
+ getSlideDropPosition(e.clientX, e.clientY),
3785
4345
  );
3786
4346
  return;
3787
4347
  }
@@ -3794,15 +4354,13 @@ export default function SlideEditor({
3794
4354
  onDropImageUrl?.(
3795
4355
  getImageReplacementTarget(e.target as HTMLElement),
3796
4356
  url,
3797
- {
3798
- x: e.clientX,
3799
- y: e.clientY,
3800
- },
4357
+ getSlideDropPosition(e.clientX, e.clientY),
3801
4358
  );
3802
4359
  },
3803
4360
  [
3804
4361
  extractDraggedImageUrl,
3805
4362
  getImageReplacementTarget,
4363
+ getSlideDropPosition,
3806
4364
  onDropImage,
3807
4365
  onDropImageUrl,
3808
4366
  ],
@@ -3845,6 +4403,13 @@ export default function SlideEditor({
3845
4403
  e.preventDefault();
3846
4404
  e.stopPropagation();
3847
4405
  const next = new Set(multiSelection);
4406
+ if (next.size === 0) {
4407
+ const selectedElement = resolveSelectedElement();
4408
+ const selectedId =
4409
+ selectedElement?.getAttribute("data-builder-id") ??
4410
+ selectedImg?.getAttribute("data-builder-id");
4411
+ if (selectedId && selectedId !== id) next.add(selectedId);
4412
+ }
3848
4413
  if (next.has(id)) next.delete(id);
3849
4414
  else next.add(id);
3850
4415
  applyMultiSelection(next);
@@ -3909,6 +4474,8 @@ export default function SlideEditor({
3909
4474
  findSelectableElement,
3910
4475
  isSlideWhitespaceTarget,
3911
4476
  multiSelection,
4477
+ resolveSelectedElement,
4478
+ selectedImg,
3912
4479
  applyMultiSelection,
3913
4480
  clearMultiSelection,
3914
4481
  clearCanvasSelection,
@@ -3932,12 +4499,41 @@ export default function SlideEditor({
3932
4499
  const handleSlideContextMenu = useCallback(
3933
4500
  (e: React.MouseEvent) => {
3934
4501
  const target = e.target as HTMLElement;
3935
- if (target.tagName === "IMG" || target.closest(".fmd-img-placeholder")) {
3936
- e.preventDefault();
3937
- showImageOverlay(target);
4502
+ const slideContent = getSlideContent();
4503
+ if (!slideContent) {
4504
+ contextMenuTargetRef.current = null;
4505
+ return;
4506
+ }
4507
+
4508
+ const selectable = findSelectableElement(target, slideContent);
4509
+ if (!selectable) {
4510
+ contextMenuTargetRef.current = null;
4511
+ clearCanvasSelection();
4512
+ return;
4513
+ }
4514
+
4515
+ const builderId = selectable.getAttribute("data-builder-id");
4516
+ const isMultiSelectionTarget = Boolean(
4517
+ builderId && multiSelection.has(builderId),
4518
+ );
4519
+ contextMenuTargetRef.current = isMultiSelectionTarget ? null : selectable;
4520
+ if (!isMultiSelectionTarget) {
4521
+ if (multiSelection.size > 0) clearMultiSelection();
4522
+ const selector = getBuilderSelector(selectable);
4523
+ if (selector) {
4524
+ selectElementForStyling(selectable, selector);
4525
+ enterSelectionMode("agentNative.enterStyleEditing", { selector });
4526
+ }
3938
4527
  }
3939
4528
  },
3940
- [showImageOverlay],
4529
+ [
4530
+ clearCanvasSelection,
4531
+ clearMultiSelection,
4532
+ findSelectableElement,
4533
+ getSlideContent,
4534
+ multiSelection,
4535
+ selectElementForStyling,
4536
+ ],
3941
4537
  );
3942
4538
 
3943
4539
  const preserveRichTextSelection = useCallback(() => {
@@ -4240,6 +4836,8 @@ export default function SlideEditor({
4240
4836
  </div>
4241
4837
  ) : null;
4242
4838
 
4839
+ const hasObjectSelection = getObjectOperationSelection() !== null;
4840
+
4243
4841
  return (
4244
4842
  <div
4245
4843
  className="relative flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-l-lg bg-[var(--slides-editor-surface)]"
@@ -4346,64 +4944,155 @@ export default function SlideEditor({
4346
4944
  className="shrink-0"
4347
4945
  style={{ width: canvasWidth, maxWidth: canvasWidth }}
4348
4946
  >
4349
- <div
4350
- ref={slideCanvasRef}
4351
- className="slide-image-clickable relative"
4352
- data-editable={!readOnly ? "true" : undefined}
4353
- onClick={handleSlideClick}
4354
- onContextMenu={handleSlideContextMenu}
4355
- onDoubleClick={handleSlideDoubleClick}
4356
- onPointerDown={handleSlidePointerDown}
4357
- onPointerMove={handleSlidePointerMove}
4358
- onPointerLeave={clearEdgeMoveCursor}
4359
- onDragStart={(event) => event.preventDefault()}
4360
- onDragOver={handleSlideDragOver}
4361
- onDrop={handleSlideDrop}
4947
+ <ContextMenu
4948
+ onOpenChange={(open) => {
4949
+ if (!open) contextMenuTargetRef.current = null;
4950
+ }}
4362
4951
  >
4363
- <SlideRenderer
4364
- slide={slide}
4365
- className="shadow-2xl shadow-black/40"
4366
- designSystem={designSystem}
4367
- aspectRatio={aspectRatio}
4368
- onOverflowChange={handleOverflowChange}
4369
- onAutofitSettled={handleAutofitSettled}
4370
- />
4371
- {/* Fading "AI edited" ring around the canvas when the
4372
- agent just edited THIS slide (component handles fade). */}
4373
- {activeSlideEdits.length > 0 && (
4374
- <RecentEditHighlights
4375
- edits={activeSlideEdits}
4376
- resolveRect={resolveCanvasRect}
4377
- containerRef={slideCanvasRef}
4378
- outlineOnly
4379
- />
4380
- )}
4381
- {(agentActive || passivePresentUsers.length > 0) && (
4382
- <div className="absolute right-2 top-2 z-10 flex items-center gap-2">
4383
- <AgentPresenceChip active={Boolean(agentActive)} />
4384
- {passivePresentUsers.length > 0 && (
4385
- <SameSlidePresenceIndicator
4386
- users={passivePresentUsers}
4952
+ <ContextMenuTrigger asChild disabled={readOnly}>
4953
+ <div
4954
+ ref={slideCanvasRef}
4955
+ className="slide-image-clickable relative"
4956
+ data-editable={!readOnly ? "true" : undefined}
4957
+ onClick={handleSlideClick}
4958
+ onContextMenu={handleSlideContextMenu}
4959
+ onDoubleClick={handleSlideDoubleClick}
4960
+ onPointerDown={handleSlidePointerDown}
4961
+ onPointerMove={handleSlidePointerMove}
4962
+ onPointerLeave={clearEdgeMoveCursor}
4963
+ onDragStart={(event) => event.preventDefault()}
4964
+ onDragOver={handleSlideDragOver}
4965
+ onDrop={handleSlideDrop}
4966
+ >
4967
+ <SlideRenderer
4968
+ slide={slide}
4969
+ className="shadow-2xl shadow-black/40"
4970
+ designSystem={designSystem}
4971
+ aspectRatio={aspectRatio}
4972
+ onOverflowChange={handleOverflowChange}
4973
+ onAutofitSettled={handleAutofitSettled}
4974
+ />
4975
+ {/* Fading "AI edited" ring around the canvas when the
4976
+ agent just edited THIS slide (component handles fade). */}
4977
+ {activeSlideEdits.length > 0 && (
4978
+ <RecentEditHighlights
4979
+ edits={activeSlideEdits}
4980
+ resolveRect={resolveCanvasRect}
4981
+ containerRef={slideCanvasRef}
4982
+ outlineOnly
4387
4983
  />
4388
4984
  )}
4985
+ {(agentActive || passivePresentUsers.length > 0) && (
4986
+ <div className="absolute right-2 top-2 z-10 flex items-center gap-2">
4987
+ <AgentPresenceChip
4988
+ active={Boolean(agentActive)}
4989
+ />
4990
+ {passivePresentUsers.length > 0 && (
4991
+ <SameSlidePresenceIndicator
4992
+ users={passivePresentUsers}
4993
+ />
4994
+ )}
4995
+ </div>
4996
+ )}
4997
+ {overflowInfo &&
4998
+ !readOnly &&
4999
+ !agentActive &&
5000
+ warningVisible && (
5001
+ <SlideOverflowWarning
5002
+ verticalOverflow={overflowInfo.verticalOverflow}
5003
+ horizontalOverflow={
5004
+ overflowInfo.horizontalOverflow
5005
+ }
5006
+ isAskingAgentToFix={isAskingAgentToFix}
5007
+ dismissLabel={t(
5008
+ "deckEditor.dismissLayoutWarning",
5009
+ )}
5010
+ onFix={handleAskAgentToFixLayout}
5011
+ onDismiss={dismissWarning}
5012
+ />
5013
+ )}
4389
5014
  </div>
4390
- )}
4391
- {overflowInfo &&
4392
- !readOnly &&
4393
- !agentActive &&
4394
- !isOverflowWarningDismissed && (
4395
- <SlideOverflowWarning
4396
- verticalOverflow={overflowInfo.verticalOverflow}
4397
- horizontalOverflow={overflowInfo.horizontalOverflow}
4398
- isAskingAgentToFix={isAskingAgentToFix}
4399
- dismissLabel={t("deckEditor.dismissLayoutWarning")}
4400
- onFix={handleAskAgentToFixLayout}
4401
- onDismiss={() =>
4402
- setIsOverflowWarningDismissed(true)
4403
- }
4404
- />
4405
- )}
4406
- </div>
5015
+ </ContextMenuTrigger>
5016
+ <ContextMenuContent>
5017
+ <ContextMenuItem
5018
+ disabled={!hasObjectSelection}
5019
+ onSelect={copySelectedObjects}
5020
+ >
5021
+ <IconCopy className="me-2 h-4 w-4" />
5022
+ {t("styleInspector.copy")}
5023
+ <ContextMenuShortcut>
5024
+ {shortcutLabel("cmd+c")}
5025
+ </ContextMenuShortcut>
5026
+ </ContextMenuItem>
5027
+ <ContextMenuItem
5028
+ disabled={!hasObjectSelection}
5029
+ onSelect={pasteSelectedObjects}
5030
+ >
5031
+ <IconClipboard className="me-2 h-4 w-4" />
5032
+ {t("styleInspector.paste")}
5033
+ <ContextMenuShortcut>
5034
+ {shortcutLabel("cmd+v")}
5035
+ </ContextMenuShortcut>
5036
+ </ContextMenuItem>
5037
+ <ContextMenuItem
5038
+ disabled={!hasObjectSelection}
5039
+ onSelect={duplicateSelectedObjects}
5040
+ >
5041
+ <IconCopy className="me-2 h-4 w-4" />
5042
+ {t("editorSidebar.duplicate")}
5043
+ <ContextMenuShortcut>
5044
+ {shortcutLabel("cmd+d")}
5045
+ </ContextMenuShortcut>
5046
+ </ContextMenuItem>
5047
+ <ContextMenuItem
5048
+ disabled={!hasObjectSelection}
5049
+ onSelect={deleteSelectedElements}
5050
+ >
5051
+ <IconTrash className="me-2 h-4 w-4" />
5052
+ {t("editorSidebar.delete")}
5053
+ <ContextMenuShortcut>⌫</ContextMenuShortcut>
5054
+ </ContextMenuItem>
5055
+ <ContextMenuSeparator />
5056
+ <ContextMenuItem
5057
+ disabled={getStyleTargets().length === 0}
5058
+ onSelect={copySelectedElementStyle}
5059
+ >
5060
+ <IconBrush className="me-2 h-4 w-4" />
5061
+ {t("styleInspector.copyStyle")}
5062
+ <ContextMenuShortcut>
5063
+ {shortcutLabel("cmd+alt+c")}
5064
+ </ContextMenuShortcut>
5065
+ </ContextMenuItem>
5066
+ <ContextMenuItem
5067
+ disabled={
5068
+ !hasCopiedElementStyle ||
5069
+ getStyleTargets().length === 0
5070
+ }
5071
+ onSelect={pasteCopiedElementStyle}
5072
+ >
5073
+ <IconBrush className="me-2 h-4 w-4" />
5074
+ {t("styleInspector.pasteStyle")}
5075
+ <ContextMenuShortcut>
5076
+ {shortcutLabel("cmd+alt+v")}
5077
+ </ContextMenuShortcut>
5078
+ </ContextMenuItem>
5079
+ <ContextMenuSeparator />
5080
+ <ContextMenuItem
5081
+ disabled={!selectedElementSelector}
5082
+ onSelect={() => handleArrangeSelected("front")}
5083
+ >
5084
+ <IconStackFront className="me-2 h-4 w-4" />
5085
+ {t("styleInspector.bringToFront")}
5086
+ </ContextMenuItem>
5087
+ <ContextMenuItem
5088
+ disabled={!selectedElementSelector}
5089
+ onSelect={() => handleArrangeSelected("back")}
5090
+ >
5091
+ <IconStackBack className="me-2 h-4 w-4" />
5092
+ {t("styleInspector.sendToBack")}
5093
+ </ContextMenuItem>
5094
+ </ContextMenuContent>
5095
+ </ContextMenu>
4407
5096
  </div>
4408
5097
  </div>
4409
5098
  </div>
@@ -4418,7 +5107,7 @@ export default function SlideEditor({
4418
5107
  readOnly={readOnly}
4419
5108
  />
4420
5109
 
4421
- {selectionRect && (
5110
+ {selectionRect && !selectedElementSelector && (
4422
5111
  <ImageSelectionOutline
4423
5112
  rect={selectionRect}
4424
5113
  viewportRect={selectionViewportRect}
@@ -4483,8 +5172,6 @@ export default function SlideEditor({
4483
5172
  onGenerate={onGenerateImage}
4484
5173
  onLibrary={() => onOpenAssetLibrary(imageOverlay.src)}
4485
5174
  onUpload={() => onUploadImage(imageOverlay.src)}
4486
- onSearch={() => onSearchImage(imageOverlay.src)}
4487
- onLogo={() => onLogoSearch(imageOverlay.src)}
4488
5175
  onToggleObjectFit={() => {
4489
5176
  const newFit =
4490
5177
  imageOverlay.objectFit === "cover" ? "contain" : "cover";