@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/slides/actions/patch-deck.ts +18 -2
- package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +46 -0
- package/corpus/templates/slides/app/components/editor/BlockBubbleMenu.tsx +194 -3
- package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +10 -1
- package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +5 -3
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +26 -4
- package/corpus/templates/slides/app/components/editor/slide-object-interactions.ts +45 -0
- package/corpus/templates/slides/app/lib/export-pdf-client.ts +84 -25
- package/corpus/templates/slides/app/lib/import-uploaded-deck.ts +11 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +12 -0
- package/corpus/templates/slides/app/pages/Index.tsx +16 -9
- package/corpus/templates/slides/changelog/2026-08-08-google-slides-export-now-tells-you-why-a-deck-fell-back-to-a.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-delete-key-now-removes-a-selected-image.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-editing-a-slide-in-an-imported-deck-no-longer-fails-to-save.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-fixed-pdf-and-custom-size-powerpoint-imports-rendering-with-.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-images-from-sites-without-cors-no-longer-export-blank.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-imported-pdf-headings-no-longer-lose-the-space-between-color.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-imported-slide-text-no-longer-renders-too-large-for-its-box.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-large-decks-no-longer-render-slowly-or-glitch-in-the-editor.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-revise-highlighted-slide-text-with-ai.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-the-deck-editor-no-longer-stays-dimmed-after-the-window-narro.md +6 -0
- package/corpus/templates/slides/server/handlers/import/html-converter.ts +115 -22
- package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +11 -2
- package/corpus/templates/slides/server/lib/fetch-remote-image.ts +192 -0
- package/corpus/templates/slides/server/lib/image-proxy-url.ts +192 -0
- package/corpus/templates/slides/server/routes/api/image-proxy.get.ts +65 -0
- package/dist/client/progress/RunsTray.js +14 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/package.json +1 -1
package/corpus/README.md
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
2408
|
-
|
|
2409
|
-
|
|
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
|
|
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,
|