@agent-native/core 0.149.0 → 0.149.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 (33) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/slides/actions/patch-deck.ts +18 -2
  3. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +46 -0
  4. package/corpus/templates/slides/app/components/editor/BlockBubbleMenu.tsx +194 -3
  5. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +10 -1
  6. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +5 -3
  7. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +26 -4
  8. package/corpus/templates/slides/app/components/editor/slide-object-interactions.ts +45 -0
  9. package/corpus/templates/slides/app/lib/export-pdf-client.ts +84 -25
  10. package/corpus/templates/slides/app/lib/import-uploaded-deck.ts +11 -0
  11. package/corpus/templates/slides/app/pages/DeckEditor.tsx +12 -0
  12. package/corpus/templates/slides/app/pages/Index.tsx +16 -9
  13. package/corpus/templates/slides/changelog/2026-08-08-google-slides-export-now-tells-you-why-a-deck-fell-back-to-a.md +6 -0
  14. package/corpus/templates/slides/changelog/2026-08-09-delete-key-now-removes-a-selected-image.md +6 -0
  15. package/corpus/templates/slides/changelog/2026-08-09-editing-a-slide-in-an-imported-deck-no-longer-fails-to-save.md +6 -0
  16. package/corpus/templates/slides/changelog/2026-08-09-fixed-pdf-and-custom-size-powerpoint-imports-rendering-with-.md +6 -0
  17. package/corpus/templates/slides/changelog/2026-08-09-images-from-sites-without-cors-no-longer-export-blank.md +6 -0
  18. package/corpus/templates/slides/changelog/2026-08-09-imported-pdf-headings-no-longer-lose-the-space-between-color.md +6 -0
  19. package/corpus/templates/slides/changelog/2026-08-09-imported-slide-text-no-longer-renders-too-large-for-its-box.md +6 -0
  20. package/corpus/templates/slides/changelog/2026-08-09-large-decks-no-longer-render-slowly-or-glitch-in-the-editor.md +6 -0
  21. package/corpus/templates/slides/changelog/2026-08-09-revise-highlighted-slide-text-with-ai.md +6 -0
  22. package/corpus/templates/slides/changelog/2026-08-09-the-deck-editor-no-longer-stays-dimmed-after-the-window-narro.md +6 -0
  23. package/corpus/templates/slides/server/handlers/import/html-converter.ts +115 -22
  24. package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +11 -2
  25. package/corpus/templates/slides/server/lib/fetch-remote-image.ts +192 -0
  26. package/corpus/templates/slides/server/lib/image-proxy-url.ts +192 -0
  27. package/corpus/templates/slides/server/routes/api/image-proxy.get.ts +65 -0
  28. package/dist/client/progress/RunsTray.js +14 -1
  29. package/dist/collab/struct-routes.d.ts +1 -1
  30. package/dist/observability/routes.d.ts +1 -1
  31. package/dist/resources/handlers.d.ts +1 -1
  32. package/dist/secrets/routes.d.ts +3 -3
  33. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 7946
34
+ - template files: 7959
@@ -370,6 +370,18 @@ export function resolveDeckColumnUpdates(
370
370
  };
371
371
  }
372
372
 
373
+ /**
374
+ * The source-preservation guards (`assertSourceImportOperationsPreserved`,
375
+ * `assertSourceSlidePreserved`) exist for one failure mode: an agent asked to
376
+ * "make it prettier" silently dropping the original PDF/PPTX artwork or
377
+ * factual copy. A human editing their own imported deck in the browser isn't
378
+ * that failure mode, and the browser editor has no way to pass
379
+ * `preserveSource` — so these guards must only run for agent callers.
380
+ */
381
+ export function isAgentPatchCaller(caller: string | undefined): boolean {
382
+ return caller === "tool" || caller === "mcp" || caller === "a2a";
383
+ }
384
+
373
385
  // ---------------------------------------------------------------------------
374
386
  // Action definition
375
387
  // ---------------------------------------------------------------------------
@@ -402,8 +414,9 @@ export default defineAction({
402
414
  ),
403
415
  }),
404
416
  agentInputSchema: AgentPatchDeckInputSchema,
405
- run: async ({ deckId, operations, creativeContext }) => {
417
+ run: async ({ deckId, operations, creativeContext }, ctx) => {
406
418
  await assertAccess("deck", deckId, "editor");
419
+ const isAgentCaller = isAgentPatchCaller(ctx?.caller);
407
420
 
408
421
  return withDeckLock(deckId, async () => {
409
422
  const db = getDb();
@@ -435,9 +448,12 @@ export default defineAction({
435
448
  }
436
449
 
437
450
  const sourceImport = sourceImportForDeck(deck.sourceImport);
438
- assertSourceImportOperationsPreserved(sourceImport, operations);
451
+ if (isAgentCaller) {
452
+ assertSourceImportOperationsPreserved(sourceImport, operations);
453
+ }
439
454
  for (const op of operations) {
440
455
  if (
456
+ !isAgentCaller ||
441
457
  op.op !== "patch-slide" ||
442
458
  (op.fields.content === undefined && op.fields.notes === undefined)
443
459
  ) {
@@ -385,6 +385,29 @@ function useSlideAutofit(
385
385
 
386
386
  let raf = 0;
387
387
  let disposed = false;
388
+ // Measuring costs a full-document reflow per slide (every descendant is
389
+ // read with getBoundingClientRect, interleaved with style writes). A deck
390
+ // with dozens of slides mounts that many renderers at once, so off-screen
391
+ // thumbnails are left unmeasured until they scroll into view. Without an
392
+ // IntersectionObserver there is nothing to defer against, so measure
393
+ // eagerly as before.
394
+ const canDefer = typeof IntersectionObserver !== "undefined";
395
+ // Resolved synchronously rather than waiting for the observer's first
396
+ // callback: a slide that is already on screen must be measured on this
397
+ // pass, and nothing may depend on a callback that a given environment
398
+ // might never deliver. This reads one rect, not one per descendant.
399
+ const isNearViewport = () => {
400
+ const rect = root.getBoundingClientRect();
401
+ const margin = 200;
402
+ return (
403
+ rect.bottom >= -margin &&
404
+ rect.right >= -margin &&
405
+ rect.top <= (window.innerHeight || 0) + margin &&
406
+ rect.left <= (window.innerWidth || 0) + margin
407
+ );
408
+ };
409
+ let visible = !canDefer || isNearViewport();
410
+ let measurePending = false;
388
411
 
389
412
  const resetTarget = (target: HTMLElement) => {
390
413
  target.style.setProperty("--fmd-fit-scale", "1");
@@ -482,6 +505,10 @@ function useSlideAutofit(
482
505
 
483
506
  const scheduleMeasure = () => {
484
507
  if (disposed) return;
508
+ if (!visible) {
509
+ measurePending = true;
510
+ return;
511
+ }
485
512
  cancelAnimationFrame(raf);
486
513
  raf = requestAnimationFrame(measureNow);
487
514
  };
@@ -502,11 +529,30 @@ function useSlideAutofit(
502
529
  root.addEventListener("load", scheduleMeasure, true);
503
530
  document.fonts?.ready.then(scheduleMeasure).catch(() => {});
504
531
 
532
+ // `rootMargin` measures a thumbnail just before it scrolls in, so the fit
533
+ // transform is already applied by the time it is on screen.
534
+ const visibilityObserver = canDefer
535
+ ? new IntersectionObserver(
536
+ (entries) => {
537
+ const isVisible = entries.some((entry) => entry.isIntersecting);
538
+ if (isVisible === visible) return;
539
+ visible = isVisible;
540
+ if (visible && measurePending) {
541
+ measurePending = false;
542
+ scheduleMeasure();
543
+ }
544
+ },
545
+ { rootMargin: "200px" },
546
+ )
547
+ : null;
548
+ visibilityObserver?.observe(root);
549
+
505
550
  return () => {
506
551
  disposed = true;
507
552
  cancelAnimationFrame(raf);
508
553
  resizeObserver.disconnect();
509
554
  mutationObserver.disconnect();
555
+ visibilityObserver?.disconnect();
510
556
  root.removeEventListener("load", scheduleMeasure, true);
511
557
  };
512
558
  }, [canvasWidth, canvasHeight, fitKey, ref]);
@@ -1,3 +1,4 @@
1
+ import { sendToAgentChatAndConfirm } from "@agent-native/core/client/agent-chat";
1
2
  import { useT } from "@agent-native/core/client/i18n";
2
3
  import {
3
4
  IconBold,
@@ -6,11 +7,15 @@ import {
6
7
  IconStrikethrough,
7
8
  IconLink,
8
9
  IconPalette,
10
+ IconPencilStar,
9
11
  IconCheck,
10
12
  IconX,
13
+ IconArrowUp,
14
+ IconLoader2,
11
15
  } from "@tabler/icons-react";
12
16
  import { useEffect, useState, useRef } from "react";
13
17
  import { createPortal } from "react-dom";
18
+ import { toast } from "sonner";
14
19
 
15
20
  import {
16
21
  Tooltip,
@@ -22,6 +27,17 @@ import { shortcutLabel } from "@/lib/utils";
22
27
  interface BlockBubbleMenuProps {
23
28
  /** The element currently in contentEditable mode. Menu only shows while selection is inside it. */
24
29
  editingEl: HTMLElement | null;
30
+ /** Slide the edited block belongs to, so an AI revision can target it. */
31
+ slideId?: string;
32
+ /** Deck that owns the slide — pins the revision to the right deck. */
33
+ deckId?: string;
34
+ /**
35
+ * Ends the inline edit session and persists whatever is in the DOM now.
36
+ * Required before handing work to the agent: otherwise the still-open
37
+ * contentEditable serializes its stale text on the user's next click and
38
+ * overwrites the revision the agent just wrote.
39
+ */
40
+ onCommitInlineEdit?: () => void;
25
41
  }
26
42
 
27
43
  interface Position {
@@ -44,32 +60,89 @@ const COLORS = [
44
60
  "#EF4444",
45
61
  ];
46
62
 
63
+ /** Shown above the input so the user can confirm what the agent will rewrite. */
64
+ const AI_TARGET_PREVIEW_LIMIT = 160;
65
+
66
+ const AI_SEND_BUTTON_CLASS =
67
+ // guard:allow-raw-color — same accent as the link Apply button below.
68
+ "rounded p-1.5 text-[#609FF8] hover:bg-accent disabled:pointer-events-none disabled:opacity-40";
69
+
70
+ export function buildReviseSelectionPrompt({
71
+ selectedText,
72
+ instruction,
73
+ slideId,
74
+ deckId,
75
+ }: {
76
+ selectedText: string;
77
+ instruction: string;
78
+ slideId?: string;
79
+ deckId?: string;
80
+ }): string {
81
+ // The deck is named explicitly rather than left to "the current slide": the
82
+ // request is queued, and if the user opens another deck before the agent
83
+ // runs, an implicit target would pair this slide id with the wrong deck.
84
+ const target = [
85
+ deckId ? `Deck id: \`${deckId}\`` : null,
86
+ slideId ? `Slide id: \`${slideId}\`` : null,
87
+ ].filter((line) => line !== null);
88
+
89
+ return [
90
+ `Revise this exact text:`,
91
+ ``,
92
+ `"""`,
93
+ selectedText,
94
+ `"""`,
95
+ ``,
96
+ `How to revise it: ${instruction}`,
97
+ ...(target.length > 0 ? [``, ...target] : []),
98
+ ``,
99
+ `Read that slide first with \`view-screen\`, then make one bounded \`update-slide --fullContent\` edit that replaces only the quoted text. Leave the surrounding HTML, inline styles, and layout untouched, and keep the replacement close to the original length so the slide still fits its canvas.`,
100
+ ]
101
+ .filter((line) => line !== null)
102
+ .join("\n");
103
+ }
104
+
47
105
  /**
48
106
  * Floating formatting toolbar for contentEditable text blocks. Shows on
49
107
  * non-empty selection inside the editing element and applies inline
50
108
  * formatting (bold, italic, underline, strike, link, color) directly to
51
109
  * the DOM. Designed to work with the in-place per-block editing in
52
110
  * SlideEditor — it never mutates anything outside the editing element.
111
+ *
112
+ * The "Revise with AI" action is the exception: it does not touch the DOM.
113
+ * It hands the selected text plus the user's instruction to the agent, which
114
+ * rewrites the slide through `update-slide`.
53
115
  */
54
- export function BlockBubbleMenu({ editingEl }: BlockBubbleMenuProps) {
116
+ export function BlockBubbleMenu({
117
+ editingEl,
118
+ slideId,
119
+ deckId,
120
+ onCommitInlineEdit,
121
+ }: BlockBubbleMenuProps) {
55
122
  const t = useT();
56
123
  const [pos, setPos] = useState<Position | null>(null);
57
124
  const [showColors, setShowColors] = useState(false);
58
125
  const [showLinkInput, setShowLinkInput] = useState(false);
59
126
  const [linkValue, setLinkValue] = useState("");
127
+ const [showAiInput, setShowAiInput] = useState(false);
128
+ const [aiInstruction, setAiInstruction] = useState("");
129
+ const [aiTargetText, setAiTargetText] = useState("");
130
+ const [aiSending, setAiSending] = useState(false);
60
131
  const savedRangeRef = useRef<Range | null>(null);
61
132
  // True while a popup/input has the user's focus — keeps the menu pinned
62
133
  // even when the contentEditable selection collapses behind the scenes.
63
134
  const interactingRef = useRef(false);
64
135
  useEffect(() => {
65
- interactingRef.current = showColors || showLinkInput;
66
- }, [showColors, showLinkInput]);
136
+ interactingRef.current = showColors || showLinkInput || showAiInput;
137
+ }, [showColors, showLinkInput, showAiInput]);
67
138
 
68
139
  // Hide menu when the editing element changes
69
140
  useEffect(() => {
70
141
  setPos(null);
71
142
  setShowColors(false);
72
143
  setShowLinkInput(false);
144
+ setShowAiInput(false);
145
+ setAiInstruction("");
73
146
  }, [editingEl]);
74
147
 
75
148
  // Track selection and position the menu
@@ -158,6 +231,61 @@ export function BlockBubbleMenu({ editingEl }: BlockBubbleMenuProps) {
158
231
  setLinkValue("");
159
232
  };
160
233
 
234
+ const openAiInput = () => {
235
+ if (showAiInput) {
236
+ setShowAiInput(false);
237
+ return;
238
+ }
239
+ // Snapshot the text now: opening the input moves focus out of the
240
+ // contentEditable and the live selection collapses.
241
+ const selected = savedRangeRef.current?.toString().trim() ?? "";
242
+ if (!selected) return;
243
+ setAiTargetText(selected);
244
+ setAiInstruction("");
245
+ interactingRef.current = true;
246
+ setShowAiInput(true);
247
+ setShowColors(false);
248
+ setShowLinkInput(false);
249
+ };
250
+
251
+ const submitAiRevision = async () => {
252
+ const instruction = aiInstruction.trim();
253
+ if (!instruction || !aiTargetText || aiSending) return;
254
+
255
+ // Close the inline edit first. The block is still a live contentEditable
256
+ // session; leaving it open means the next click away serializes the old
257
+ // text over whatever the agent writes.
258
+ onCommitInlineEdit?.();
259
+
260
+ setAiSending(true);
261
+ try {
262
+ const delivery = await sendToAgentChatAndConfirm({
263
+ message: buildReviseSelectionPrompt({
264
+ selectedText: aiTargetText,
265
+ instruction,
266
+ slideId,
267
+ deckId,
268
+ }),
269
+ submit: true,
270
+ chatTarget: "local",
271
+ });
272
+
273
+ if (!delivery.delivered) {
274
+ // Keep the typed instruction so the user can retry without retyping.
275
+ toast.error(t("raw.sendToAgent"), {
276
+ description: delivery.reason ?? "The agent did not receive this.",
277
+ });
278
+ return;
279
+ }
280
+
281
+ toast.success(t("raw.sentToAgent"), { description: instruction });
282
+ setShowAiInput(false);
283
+ setAiInstruction("");
284
+ } finally {
285
+ setAiSending(false);
286
+ }
287
+ };
288
+
161
289
  return createPortal(
162
290
  <div
163
291
  data-block-bubble-menu="true"
@@ -168,6 +296,13 @@ export function BlockBubbleMenu({ editingEl }: BlockBubbleMenuProps) {
168
296
  e.preventDefault();
169
297
  }}
170
298
  >
299
+ <ToolbarButton
300
+ icon={IconPencilStar}
301
+ tooltip="Revise with AI"
302
+ onClick={openAiInput}
303
+ active={showAiInput}
304
+ />
305
+ <div className="w-px h-4 bg-border mx-0.5" />
171
306
  <ToolbarButton
172
307
  icon={IconBold}
173
308
  tooltip={`Bold (${shortcutLabel("cmd+b")})`}
@@ -199,6 +334,7 @@ export function BlockBubbleMenu({ editingEl }: BlockBubbleMenuProps) {
199
334
  if (!showColors) interactingRef.current = true;
200
335
  setShowColors((v) => !v);
201
336
  setShowLinkInput(false);
337
+ setShowAiInput(false);
202
338
  }}
203
339
  active={showColors}
204
340
  />
@@ -229,9 +365,64 @@ export function BlockBubbleMenu({ editingEl }: BlockBubbleMenuProps) {
229
365
  if (!showLinkInput) interactingRef.current = true;
230
366
  setShowLinkInput((v) => !v);
231
367
  setShowColors(false);
368
+ setShowAiInput(false);
232
369
  }}
233
370
  active={showLinkInput}
234
371
  />
372
+ {showAiInput && (
373
+ <div
374
+ data-ai-revise-input="true"
375
+ // Own mousedown handler: the toolbar above blocks the default to keep
376
+ // the contentEditable focused, which would also stop this input from
377
+ // ever receiving a caret.
378
+ onMouseDown={(e) => e.stopPropagation()}
379
+ className="absolute top-full left-1/2 -translate-x-1/2 mt-1 w-80 p-2 rounded-lg bg-popover border border-border shadow-2xl shadow-black/60"
380
+ >
381
+ <p className="mb-1.5 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
382
+ {aiTargetText.length > AI_TARGET_PREVIEW_LIMIT
383
+ ? `${aiTargetText.slice(0, AI_TARGET_PREVIEW_LIMIT)}…`
384
+ : aiTargetText}
385
+ </p>
386
+ <div className="flex items-end gap-1">
387
+ <textarea
388
+ value={aiInstruction}
389
+ onChange={(e) => setAiInstruction(e.target.value)}
390
+ onKeyDown={(e) => {
391
+ if (e.key === "Enter" && !e.shiftKey) {
392
+ e.preventDefault();
393
+ void submitAiRevision();
394
+ } else if (e.key === "Escape") {
395
+ e.preventDefault();
396
+ setShowAiInput(false);
397
+ }
398
+ }}
399
+ placeholder={t("raw.tellAgentDo")}
400
+ rows={2}
401
+ disabled={aiSending}
402
+ className="flex-1 resize-none rounded border border-border bg-muted px-2 py-1.5 text-xs text-foreground outline-none focus:border-ring disabled:opacity-60"
403
+ autoFocus
404
+ />
405
+ <Tooltip>
406
+ <TooltipTrigger asChild>
407
+ <button
408
+ type="button"
409
+ onClick={() => void submitAiRevision()}
410
+ disabled={!aiInstruction.trim() || aiSending}
411
+ aria-label={t("raw.sendToAgent")}
412
+ className={AI_SEND_BUTTON_CLASS}
413
+ >
414
+ {aiSending ? (
415
+ <IconLoader2 className="w-3.5 h-3.5 animate-spin" />
416
+ ) : (
417
+ <IconArrowUp className="w-3.5 h-3.5" />
418
+ )}
419
+ </button>
420
+ </TooltipTrigger>
421
+ <TooltipContent>{t("raw.sendToAgent")}</TooltipContent>
422
+ </Tooltip>
423
+ </div>
424
+ </div>
425
+ )}
235
426
  {showLinkInput && (
236
427
  <div className="absolute top-full left-1/2 -translate-x-1/2 mt-1 flex items-center gap-1 p-1 rounded-lg bg-popover border border-border shadow-2xl shadow-black/60">
237
428
  <input
@@ -29,7 +29,7 @@ import {
29
29
  TooltipTrigger,
30
30
  } from "@/components/ui/tooltip";
31
31
  import type { Slide } from "@/context/DeckContext";
32
- import type { AspectRatio } from "@/lib/aspect-ratios";
32
+ import { getAspectRatioDims, type AspectRatio } from "@/lib/aspect-ratios";
33
33
  import { TAB_ID } from "@/lib/tab-id";
34
34
 
35
35
  import type { DesignSystemData } from "../../../shared/api";
@@ -174,6 +174,8 @@ function SortableSlideThumb({
174
174
  opacity: isDragging ? 0.5 : 1,
175
175
  };
176
176
 
177
+ const thumbDims = getAspectRatioDims(aspectRatio);
178
+
177
179
  return (
178
180
  <div ref={setNodeRef} style={style}>
179
181
  <button
@@ -213,6 +215,11 @@ function SortableSlideThumb({
213
215
 
214
216
  {/* Thumbnail */}
215
217
  <div className="flex-1 min-w-0">
218
+ {/* Each thumbnail paints a full-resolution slide canvas scaled down by
219
+ * transform, so a long rail keeps dozens of large layers live and the
220
+ * browser drops frames or paints stale tiles. `content-visibility`
221
+ * lets it skip everything below the fold; `aspect-ratio` keeps the row
222
+ * the right height while its contents are skipped. */}
216
223
  <div
217
224
  className="w-full overflow-hidden rounded border"
218
225
  style={{
@@ -220,6 +227,8 @@ function SortableSlideThumb({
220
227
  presenceUsers.length > 0
221
228
  ? presenceUsers[0].color + "66"
222
229
  : "rgba(255,255,255,0.06)",
230
+ aspectRatio: `${thumbDims.width} / ${thumbDims.height}`,
231
+ contentVisibility: "auto",
223
232
  }}
224
233
  >
225
234
  <SlideRenderer
@@ -123,11 +123,13 @@ export function ExportMenu({
123
123
  });
124
124
  return;
125
125
  }
126
- console.warn("Google Slides upload unavailable:", result.reason);
127
126
  if (target) target.location.href = GOOGLE_SLIDES_IMPORT_URL;
128
127
  setGoogleSlidesImportOpen(true);
129
- toast.success(t("editorExport.googleSlidesDownloaded"), {
130
- description: t("editorExport.googleSlidesImportHint"),
128
+ // The deck did not reach Drive. Saying "success" here is why users read
129
+ // the .pptx download as the intended result and never learn that their
130
+ // Google account is unconnected or that Drive rejected the upload.
131
+ toast.warning(t("editorExport.googleSlidesDownloaded"), {
132
+ description: `${result.reason} ${t("editorExport.googleSlidesImportHint")}`,
131
133
  });
132
134
  } catch (err) {
133
135
  googleSlidesImportTarget.current = null;
@@ -119,6 +119,8 @@ import {
119
119
  getSlideTextBoxDefaultColor,
120
120
  getSlideSelectionIdentity,
121
121
  getSlideSelectionMode,
122
+ findPersistedImageObject,
123
+ isDeletableFlowImage,
122
124
  removeSlideObjectAndLayoutSpacer,
123
125
  resolveSlideObjectContainingBlock,
124
126
  type CopiedSlideObjects,
@@ -2404,9 +2406,24 @@ export default function SlideEditor({
2404
2406
  clearMultiSelection();
2405
2407
  } else {
2406
2408
  const element = resolveSelectedElement();
2407
- if (!element || !isPersistedFreeformObject(element)) return;
2408
- e.preventDefault();
2409
- removeSlideObjectAndLayoutSpacer(element);
2409
+ if (!element) return;
2410
+ if (isPersistedFreeformObject(element)) {
2411
+ e.preventDefault();
2412
+ removeSlideObjectAndLayoutSpacer(element);
2413
+ } else if (isDeletableFlowImage(element)) {
2414
+ e.preventDefault();
2415
+ // An imported PPTX/PDF image is an <img> (or placeholder) nested in
2416
+ // an absolutely positioned wrapper that carries the persisted object
2417
+ // id. Removing only the inner node would leave that wrapper and its
2418
+ // durable metadata behind as an invisible ghost object.
2419
+ const owner = findPersistedImageObject(element, slideContent);
2420
+ if (owner) removeSlideObjectAndLayoutSpacer(owner);
2421
+ else element.remove();
2422
+ setSelectedImg(null);
2423
+ setImageOverlay(null);
2424
+ } else {
2425
+ return;
2426
+ }
2410
2427
  clearSelectedElement();
2411
2428
  }
2412
2429
 
@@ -4413,7 +4430,12 @@ export default function SlideEditor({
4413
4430
  onSendToAgent={sendSelectionToAgent}
4414
4431
  />
4415
4432
 
4416
- <BlockBubbleMenu editingEl={editingEl} />
4433
+ <BlockBubbleMenu
4434
+ editingEl={editingEl}
4435
+ slideId={slide.id}
4436
+ deckId={deckId}
4437
+ onCommitInlineEdit={exitInlineEdit}
4438
+ />
4417
4439
 
4418
4440
  {pendingUpdateCount > 0 && (
4419
4441
  <div className="absolute top-4 right-4 z-50">
@@ -521,6 +521,51 @@ export function removeSlideObjectAndLayoutSpacer(element: HTMLElement): void {
521
521
  element.remove();
522
522
  }
523
523
 
524
+ /**
525
+ * Whether Delete should remove `element` even though it is not a freeform
526
+ * canvas object.
527
+ *
528
+ * Flow-layout nodes are deliberately excluded from object operations, because
529
+ * deleting an arbitrary one collapses the layout around it. An image is the
530
+ * exception: it is a leaf, users select it directly, and removing it leaves
531
+ * the surrounding grid or card intact. Without this, selecting a picture in a
532
+ * card grid and pressing Delete silently did nothing.
533
+ */
534
+ export function isDeletableFlowImage(element: HTMLElement): boolean {
535
+ return (
536
+ element.tagName === "IMG" ||
537
+ element.classList.contains("fmd-img-placeholder")
538
+ );
539
+ }
540
+
541
+ /**
542
+ * The persisted image object that owns `element`, if any.
543
+ *
544
+ * PPTX/PDF import wraps each picture in an absolutely positioned
545
+ * `.fmd-pptx-image` div carrying the durable `data-slide-object-id`, with the
546
+ * `<img>` (or an empty placeholder) inside it. Deleting the inner node alone
547
+ * leaves that wrapper behind as an invisible object that still occupies its
548
+ * slot and still round-trips through save. Matching on the image wrapper
549
+ * specifically — rather than any positioned ancestor — keeps this from
550
+ * swallowing a whole card or column that merely contains a picture.
551
+ */
552
+ export function findPersistedImageObject(
553
+ element: HTMLElement,
554
+ root: HTMLElement,
555
+ ): HTMLElement | null {
556
+ let current: HTMLElement | null = element;
557
+ while (current && current !== root && root.contains(current)) {
558
+ const isImageWrapper =
559
+ current.classList.contains("fmd-pptx-image") ||
560
+ current.getAttribute("data-pptx-element-kind") === "image";
561
+ if (isImageWrapper && current.getAttribute("data-slide-object-id")) {
562
+ return current;
563
+ }
564
+ current = current.parentElement;
565
+ }
566
+ return null;
567
+ }
568
+
524
569
  /** Convert a viewport click into the unscaled fmd-slide coordinate system. */
525
570
  export function clientPointToSlideCoordinates(
526
571
  clientX: number,