@agent-native/core 0.161.7 → 0.161.8
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/content/server/plugins/agent-chat.ts +2 -0
- package/corpus/templates/content/server/plugins/feature-flags.ts +5 -0
- package/corpus/templates/content/shared/feature-flags.ts +15 -0
- package/corpus/templates/slides/.agents/skills/slide-editing/SKILL.md +9 -0
- package/corpus/templates/slides/actions/patch-deck.ts +8 -0
- package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +188 -66
- package/corpus/templates/slides/app/components/presentation/PresentationView.tsx +102 -22
- package/corpus/templates/slides/app/components/presentation/PresenterView.tsx +84 -2
- package/corpus/templates/slides/app/context/DeckContext.tsx +94 -3
- package/corpus/templates/slides/app/i18n/en-US.ts +5 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +122 -8
- package/corpus/templates/videos/package.json +9 -0
- package/dist/a2a/correlation.d.ts +6 -6
- package/dist/a2a/correlation.js +8 -6
- package/dist/a2a/types.d.ts +7 -5
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/provider-api/actions/custom-provider-registration.d.ts +13 -13
- package/dist/provider-api/actions/provider-api.d.ts +11 -11
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/scripts/call-agent.js +6 -5
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-chat/action-filters-a2a.d.ts +6 -1
- package/dist/server/agent-chat/action-filters-a2a.js +29 -2
- package/dist/server/agent-chat/plugin-options.d.ts +6 -0
- package/dist/server/agent-chat-plugin.d.ts +2 -1
- package/dist/server/agent-chat-plugin.js +20 -4
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
package/corpus/README.md
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "@agent-native/core/server";
|
|
6
6
|
|
|
7
7
|
import actionsRegistry from "../../.generated/actions-registry.js";
|
|
8
|
+
import { A2A_RECEIVER_OWNERSHIP_FLAG } from "../../shared/feature-flags.js";
|
|
8
9
|
import {
|
|
9
10
|
publicDocumentExtraContext,
|
|
10
11
|
resolvePublicViewerOwner,
|
|
@@ -32,6 +33,7 @@ const INITIAL_TOOL_NAMES = [
|
|
|
32
33
|
export default createAgentChatPlugin({
|
|
33
34
|
appId: "content",
|
|
34
35
|
durableBackgroundRuns: true,
|
|
36
|
+
a2aReceiverOwnershipFlag: A2A_RECEIVER_OWNERSHIP_FLAG,
|
|
35
37
|
actions: loadActionsFromStaticRegistry(actionsRegistry),
|
|
36
38
|
initialToolNames: INITIAL_TOOL_NAMES,
|
|
37
39
|
mcp: {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineFeatureFlag,
|
|
3
|
+
defineFeatureFlags,
|
|
4
|
+
} from "@agent-native/core/feature-flags";
|
|
5
|
+
|
|
6
|
+
export const A2A_RECEIVER_OWNERSHIP_FLAG = defineFeatureFlag({
|
|
7
|
+
key: "content.a2a-receiver-ownership",
|
|
8
|
+
displayName: "A2A receiver ownership",
|
|
9
|
+
description:
|
|
10
|
+
"Prefer Content's declared local capabilities when another app delegates an objective to Content.",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const CONTENT_FEATURE_FLAGS = defineFeatureFlags([
|
|
14
|
+
A2A_RECEIVER_OWNERSHIP_FLAG,
|
|
15
|
+
]);
|
|
@@ -90,6 +90,15 @@ To edit a slide's content:
|
|
|
90
90
|
preserve quote, speaker, date, metric, and uncertainty status. Existing HTML
|
|
91
91
|
or visual similarity is not proof of source fidelity.
|
|
92
92
|
|
|
93
|
+
## Skipping a Slide
|
|
94
|
+
|
|
95
|
+
Set a slide's `skipped: true` via a `patch-deck` `patch-slide` operation to
|
|
96
|
+
exclude it from Present/Presenter playback without deleting it — the slide
|
|
97
|
+
stays in the deck, editor, and exports. Set `skipped: false` (or omit it) to
|
|
98
|
+
include it again. The rail's right-click menu on each slide thumbnail offers
|
|
99
|
+
Cut, Copy, Paste, Delete, New slide, Duplicate slide, and Skip slide as the
|
|
100
|
+
same operations.
|
|
101
|
+
|
|
93
102
|
## Click-to-reveal animations
|
|
94
103
|
|
|
95
104
|
Animations are metadata over the final slide HTML, not alternate slide markup.
|
|
@@ -113,6 +113,12 @@ const SlideFieldsSchema = z.object({
|
|
|
113
113
|
.describe(
|
|
114
114
|
"Complete ordered on-click reveal list. Include every intended target in order; unlisted elements remain visible. Use elementPath from the final HTML and 0-based indexes.",
|
|
115
115
|
),
|
|
116
|
+
skipped: z
|
|
117
|
+
.boolean()
|
|
118
|
+
.optional()
|
|
119
|
+
.describe(
|
|
120
|
+
"Exclude this slide from Present/Presenter playback without deleting it.",
|
|
121
|
+
),
|
|
116
122
|
});
|
|
117
123
|
|
|
118
124
|
/** Update fields on a single existing slide */
|
|
@@ -165,6 +171,7 @@ const AddSlideOp = z.object({
|
|
|
165
171
|
.optional(),
|
|
166
172
|
animations: z.array(z.unknown()).optional(),
|
|
167
173
|
splitByParagraph: z.boolean().optional(),
|
|
174
|
+
skipped: z.boolean().optional(),
|
|
168
175
|
})
|
|
169
176
|
.passthrough(),
|
|
170
177
|
});
|
|
@@ -346,6 +353,7 @@ export function applyOperation(deck: any, op: Operation): void {
|
|
|
346
353
|
slide.excalidrawData = fields.excalidrawData;
|
|
347
354
|
if (fields.transition !== undefined) slide.transition = fields.transition;
|
|
348
355
|
if (fields.animations !== undefined) slide.animations = fields.animations;
|
|
356
|
+
if (fields.skipped !== undefined) slide.skipped = fields.skipped;
|
|
349
357
|
break;
|
|
350
358
|
}
|
|
351
359
|
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { CSS } from "@dnd-kit/utilities";
|
|
18
18
|
import { appStateKeyForBrowserTab } from "@shared/app-state-tabs";
|
|
19
19
|
import { hashSlideContent, type DeckFitState } from "@shared/slide-fit";
|
|
20
|
+
import { IconEyeOff } from "@tabler/icons-react";
|
|
20
21
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
21
22
|
import { toast } from "sonner";
|
|
22
23
|
|
|
@@ -25,6 +26,14 @@ import type { SlideOverflowInfo } from "@/components/deck/SlideRenderer";
|
|
|
25
26
|
import { AddSlidePopover } from "@/components/editor/AddSlidePopover";
|
|
26
27
|
import { AiEditingMarker } from "@/components/editor/AiEditingMarker";
|
|
27
28
|
import GeneratingSlidePreview from "@/components/editor/GeneratingSlidePreview";
|
|
29
|
+
import {
|
|
30
|
+
ContextMenu,
|
|
31
|
+
ContextMenuContent,
|
|
32
|
+
ContextMenuItem,
|
|
33
|
+
ContextMenuSeparator,
|
|
34
|
+
ContextMenuShortcut,
|
|
35
|
+
ContextMenuTrigger,
|
|
36
|
+
} from "@/components/ui/context-menu";
|
|
28
37
|
import {
|
|
29
38
|
Tooltip,
|
|
30
39
|
TooltipContent,
|
|
@@ -33,6 +42,7 @@ import {
|
|
|
33
42
|
import { defaultSlideContent, type Slide } from "@/context/DeckContext";
|
|
34
43
|
import { getAspectRatioDims, type AspectRatio } from "@/lib/aspect-ratios";
|
|
35
44
|
import { TAB_ID } from "@/lib/tab-id";
|
|
45
|
+
import { shortcutLabel } from "@/lib/utils";
|
|
36
46
|
|
|
37
47
|
import type { DesignSystemData } from "../../../shared/api";
|
|
38
48
|
import { isSlideTextEditingTarget } from "./slide-text-targets";
|
|
@@ -78,6 +88,19 @@ interface EditorSidebarProps {
|
|
|
78
88
|
* this component's own useAgentGenerating() call) so the run stays
|
|
79
89
|
* correctly scoped and trackable across a sidebar remount. */
|
|
80
90
|
addSlideAgentSubmit: (message: string, context: string) => void;
|
|
91
|
+
/** Whether a slide is currently on the cut/copy clipboard, so the rail's
|
|
92
|
+
* right-click menu can disable Paste when there's nothing to paste. */
|
|
93
|
+
hasSlideClipboard?: boolean;
|
|
94
|
+
onCutSlide?: (slideId: string) => void;
|
|
95
|
+
onCopySlide?: (slideId: string) => void;
|
|
96
|
+
/** Pastes the clipboard slide directly after this slide. */
|
|
97
|
+
onPasteSlide?: (slideId: string) => void;
|
|
98
|
+
onDeleteSlide?: (slideId: string) => void;
|
|
99
|
+
/** Inserts a new blank slide directly after this slide. */
|
|
100
|
+
onNewSlideAfter?: (slideId: string) => void;
|
|
101
|
+
onDuplicateSlide?: (slideId: string) => void;
|
|
102
|
+
/** Toggles whether this slide is excluded from Present/Presenter mode. */
|
|
103
|
+
onToggleSkipSlide?: (slideId: string) => void;
|
|
81
104
|
}
|
|
82
105
|
|
|
83
106
|
const DECK_FIT_STATE_KEYS = [
|
|
@@ -176,6 +199,15 @@ function SortableSlideThumb({
|
|
|
176
199
|
onOverflowChange,
|
|
177
200
|
readOnly = false,
|
|
178
201
|
aiEditing = false,
|
|
202
|
+
canDelete = true,
|
|
203
|
+
hasSlideClipboard = false,
|
|
204
|
+
onCutSlide,
|
|
205
|
+
onCopySlide,
|
|
206
|
+
onPasteSlide,
|
|
207
|
+
onDeleteSlide,
|
|
208
|
+
onNewSlideAfter,
|
|
209
|
+
onDuplicateSlide,
|
|
210
|
+
onToggleSkipSlide,
|
|
179
211
|
}: {
|
|
180
212
|
slide: Slide;
|
|
181
213
|
index: number;
|
|
@@ -188,6 +220,16 @@ function SortableSlideThumb({
|
|
|
188
220
|
designSystem?: DesignSystemData;
|
|
189
221
|
onOverflowChange: (info: SlideOverflowInfo) => void;
|
|
190
222
|
aiEditing?: boolean;
|
|
223
|
+
/** False when this is the deck's last remaining slide — Cut/Delete stay enabled elsewhere but must not remove it. */
|
|
224
|
+
canDelete?: boolean;
|
|
225
|
+
hasSlideClipboard?: boolean;
|
|
226
|
+
onCutSlide?: (slideId: string) => void;
|
|
227
|
+
onCopySlide?: (slideId: string) => void;
|
|
228
|
+
onPasteSlide?: (slideId: string) => void;
|
|
229
|
+
onDeleteSlide?: (slideId: string) => void;
|
|
230
|
+
onNewSlideAfter?: (slideId: string) => void;
|
|
231
|
+
onDuplicateSlide?: (slideId: string) => void;
|
|
232
|
+
onToggleSkipSlide?: (slideId: string) => void;
|
|
191
233
|
}) {
|
|
192
234
|
const t = useT();
|
|
193
235
|
const {
|
|
@@ -217,76 +259,139 @@ function SortableSlideThumb({
|
|
|
217
259
|
|
|
218
260
|
return (
|
|
219
261
|
<div ref={setNodeRef} style={style}>
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
{showAiMarker && (
|
|
249
|
-
<
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
262
|
+
<ContextMenu>
|
|
263
|
+
<ContextMenuTrigger asChild disabled={readOnly}>
|
|
264
|
+
<button
|
|
265
|
+
ref={(node) => registerButtonRef(slide.id, node)}
|
|
266
|
+
type="button"
|
|
267
|
+
{...(readOnly ? {} : attributes)}
|
|
268
|
+
{...(readOnly ? {} : listeners)}
|
|
269
|
+
onClick={(event) => {
|
|
270
|
+
// Safari does not focus a button on click, and the slide copy/paste
|
|
271
|
+
// and delete shortcuts are scoped to a focused thumbnail.
|
|
272
|
+
event.currentTarget.focus();
|
|
273
|
+
onSelect();
|
|
274
|
+
}}
|
|
275
|
+
onFocus={onSelect}
|
|
276
|
+
aria-label={t("editorSidebar.selectSlide", { number: index + 1 })}
|
|
277
|
+
aria-current={isActive ? "true" : undefined}
|
|
278
|
+
data-slide-thumbnail-id={slide.id}
|
|
279
|
+
className={`w-full text-left flex items-start gap-1.5 p-1.5 rounded-lg transition-[background-color,box-shadow] duration-150 ${
|
|
280
|
+
isActive ? "bg-accent" : ""
|
|
281
|
+
} ${
|
|
282
|
+
readOnly ? "" : "cursor-grab active:cursor-grabbing"
|
|
283
|
+
} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1`}
|
|
284
|
+
>
|
|
285
|
+
{/* Index and slide presence share the fixed rail so presence does not resize the row. */}
|
|
286
|
+
<div className="relative flex-shrink-0 w-4 self-stretch">
|
|
287
|
+
<span className="block text-center text-[10px] font-medium leading-5 text-muted-foreground/70">
|
|
288
|
+
{index + 1}
|
|
289
|
+
</span>
|
|
290
|
+
{(showAiMarker || humanPresenceUsers.length > 0) && (
|
|
291
|
+
<div className="absolute left-1/2 top-5 z-10 flex -translate-x-1/2 flex-col items-center gap-1">
|
|
292
|
+
{showAiMarker && (
|
|
293
|
+
<AiEditingMarker className="size-4 text-[8px]" />
|
|
294
|
+
)}
|
|
295
|
+
{humanPresenceUsers.slice(0, 4).map((u, i) => (
|
|
296
|
+
<PresenceAvatarTip key={i} user={u} size={14} />
|
|
297
|
+
))}
|
|
298
|
+
{humanPresenceUsers.length > 4 && (
|
|
299
|
+
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-muted px-1 text-[8px] font-medium leading-none text-muted-foreground ring-1 ring-black/40">
|
|
300
|
+
+{humanPresenceUsers.length - 4}
|
|
301
|
+
</span>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
258
304
|
)}
|
|
259
305
|
</div>
|
|
260
|
-
)}
|
|
261
|
-
</div>
|
|
262
306
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
307
|
+
{/* Thumbnail */}
|
|
308
|
+
<div className="flex-1 min-w-0">
|
|
309
|
+
{/* Each thumbnail paints a full-resolution slide canvas scaled down by
|
|
310
|
+
* transform, so a long rail keeps dozens of large layers live and the
|
|
311
|
+
* browser drops frames or paints stale tiles. `content-visibility`
|
|
312
|
+
* lets it skip everything below the fold; `aspect-ratio` keeps the row
|
|
313
|
+
* the right height while its contents are skipped. */}
|
|
314
|
+
<div
|
|
315
|
+
className={`relative w-full overflow-hidden rounded border ${
|
|
316
|
+
slide.skipped ? "opacity-40" : ""
|
|
317
|
+
}`}
|
|
318
|
+
style={{
|
|
319
|
+
borderColor:
|
|
320
|
+
humanPresenceUsers.length > 0
|
|
321
|
+
? humanPresenceUsers[0].color + "66"
|
|
322
|
+
: // guard:allow-raw-color — thumbnail border sits on an arbitrary-colored slide render, not app chrome
|
|
323
|
+
"rgba(255,255,255,0.06)",
|
|
324
|
+
aspectRatio: `${thumbDims.width} / ${thumbDims.height}`,
|
|
325
|
+
contentVisibility: "auto",
|
|
326
|
+
}}
|
|
327
|
+
>
|
|
328
|
+
<SlideRenderer
|
|
329
|
+
slide={slide}
|
|
330
|
+
aspectRatio={aspectRatio}
|
|
331
|
+
designSystem={designSystem}
|
|
332
|
+
onOverflowChange={onOverflowChange}
|
|
333
|
+
/>
|
|
334
|
+
{slide.skipped && (
|
|
335
|
+
// guard:allow-raw-color — dims an arbitrary-colored slide render, not app chrome; must stay black regardless of theme
|
|
336
|
+
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
|
337
|
+
{/* guard:allow-raw-color — icon sits on the black scrim above, not app chrome */}
|
|
338
|
+
<IconEyeOff className="size-6 text-white/80" />
|
|
339
|
+
</div>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
</button>
|
|
344
|
+
</ContextMenuTrigger>
|
|
345
|
+
<ContextMenuContent
|
|
346
|
+
onCloseAutoFocus={(event) => {
|
|
347
|
+
// Radix restores focus to the trigger (this slide's thumbnail
|
|
348
|
+
// button) when the menu closes. That button's onFocus reselects
|
|
349
|
+
// its own slide, which clobbers New/Duplicate/Paste's deliberate
|
|
350
|
+
// selection of the newly created slide. Our handlers already set
|
|
351
|
+
// the right active slide explicitly, so skip the auto-focus.
|
|
352
|
+
event.preventDefault();
|
|
353
|
+
}}
|
|
354
|
+
>
|
|
355
|
+
<ContextMenuItem
|
|
356
|
+
disabled={!canDelete}
|
|
357
|
+
onSelect={() => onCutSlide?.(slide.id)}
|
|
280
358
|
>
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
</
|
|
288
|
-
|
|
289
|
-
|
|
359
|
+
{t("editorSidebar.cut")}
|
|
360
|
+
<ContextMenuShortcut>{shortcutLabel("cmd+x")}</ContextMenuShortcut>
|
|
361
|
+
</ContextMenuItem>
|
|
362
|
+
<ContextMenuItem onSelect={() => onCopySlide?.(slide.id)}>
|
|
363
|
+
{t("editorSidebar.copy")}
|
|
364
|
+
<ContextMenuShortcut>{shortcutLabel("cmd+c")}</ContextMenuShortcut>
|
|
365
|
+
</ContextMenuItem>
|
|
366
|
+
<ContextMenuItem
|
|
367
|
+
disabled={!hasSlideClipboard}
|
|
368
|
+
onSelect={() => onPasteSlide?.(slide.id)}
|
|
369
|
+
>
|
|
370
|
+
{t("editorSidebar.paste")}
|
|
371
|
+
<ContextMenuShortcut>{shortcutLabel("cmd+v")}</ContextMenuShortcut>
|
|
372
|
+
</ContextMenuItem>
|
|
373
|
+
<ContextMenuSeparator />
|
|
374
|
+
<ContextMenuItem onSelect={() => onNewSlideAfter?.(slide.id)}>
|
|
375
|
+
{t("editorSidebar.newSlide")}
|
|
376
|
+
</ContextMenuItem>
|
|
377
|
+
<ContextMenuItem onSelect={() => onDuplicateSlide?.(slide.id)}>
|
|
378
|
+
{t("editorSidebar.duplicateSlide")}
|
|
379
|
+
</ContextMenuItem>
|
|
380
|
+
<ContextMenuItem onSelect={() => onToggleSkipSlide?.(slide.id)}>
|
|
381
|
+
{slide.skipped
|
|
382
|
+
? t("editorSidebar.unskipSlide")
|
|
383
|
+
: t("editorSidebar.skipSlide")}
|
|
384
|
+
</ContextMenuItem>
|
|
385
|
+
<ContextMenuSeparator />
|
|
386
|
+
<ContextMenuItem
|
|
387
|
+
disabled={!canDelete}
|
|
388
|
+
onSelect={() => onDeleteSlide?.(slide.id)}
|
|
389
|
+
className="text-destructive focus:text-destructive"
|
|
390
|
+
>
|
|
391
|
+
{t("editorSidebar.deleteSlide")}
|
|
392
|
+
</ContextMenuItem>
|
|
393
|
+
</ContextMenuContent>
|
|
394
|
+
</ContextMenu>
|
|
290
395
|
</div>
|
|
291
396
|
);
|
|
292
397
|
}
|
|
@@ -352,6 +457,14 @@ export default function EditorSidebar({
|
|
|
352
457
|
onAwaitAddSlidePersisted,
|
|
353
458
|
onRemoveFailedSlide,
|
|
354
459
|
addSlideAgentSubmit,
|
|
460
|
+
hasSlideClipboard,
|
|
461
|
+
onCutSlide,
|
|
462
|
+
onCopySlide,
|
|
463
|
+
onPasteSlide,
|
|
464
|
+
onDeleteSlide,
|
|
465
|
+
onNewSlideAfter,
|
|
466
|
+
onDuplicateSlide,
|
|
467
|
+
onToggleSkipSlide,
|
|
355
468
|
}: EditorSidebarProps) {
|
|
356
469
|
const t = useT();
|
|
357
470
|
const [describeAnchorEl, setDescribeAnchorEl] =
|
|
@@ -549,6 +662,15 @@ export default function EditorSidebar({
|
|
|
549
662
|
aspectRatio={aspectRatio}
|
|
550
663
|
designSystem={designSystem}
|
|
551
664
|
aiEditing={aiEditedSlideIds.has(slide.id)}
|
|
665
|
+
canDelete={slides.length > 1}
|
|
666
|
+
hasSlideClipboard={hasSlideClipboard}
|
|
667
|
+
onCutSlide={onCutSlide}
|
|
668
|
+
onCopySlide={onCopySlide}
|
|
669
|
+
onPasteSlide={onPasteSlide}
|
|
670
|
+
onDeleteSlide={onDeleteSlide}
|
|
671
|
+
onNewSlideAfter={onNewSlideAfter}
|
|
672
|
+
onDuplicateSlide={onDuplicateSlide}
|
|
673
|
+
onToggleSkipSlide={onToggleSkipSlide}
|
|
552
674
|
onOverflowChange={(info) =>
|
|
553
675
|
handleSlideOverflowChange(slide, info)
|
|
554
676
|
}
|
|
@@ -202,7 +202,7 @@ export default function PresentationView({
|
|
|
202
202
|
const safeSlides = useMemo(
|
|
203
203
|
() =>
|
|
204
204
|
(Array.isArray(slides) ? slides : [])
|
|
205
|
-
.filter(Boolean)
|
|
205
|
+
.filter((slide): slide is Slide => Boolean(slide) && !slide.skipped)
|
|
206
206
|
.map((slide, index) => ({
|
|
207
207
|
...slide,
|
|
208
208
|
id: slide.id || `slide-${index}`,
|
|
@@ -212,6 +212,27 @@ export default function PresentationView({
|
|
|
212
212
|
})),
|
|
213
213
|
[slides],
|
|
214
214
|
);
|
|
215
|
+
// `startIndex` is a raw index into the full (unfiltered) deck.slides array —
|
|
216
|
+
// e.g. from the editor's current slide or a `?slide=N` deep link. Skipped
|
|
217
|
+
// slides are absent from safeSlides, so translate it to the nearest visible
|
|
218
|
+
// slide's position within safeSlides rather than clamping the raw index
|
|
219
|
+
// directly, which would land on the wrong slide whenever a skip precedes it.
|
|
220
|
+
const initialIndex = useMemo(() => {
|
|
221
|
+
const rawSlides = (Array.isArray(slides) ? slides : []).filter(Boolean);
|
|
222
|
+
if (rawSlides.length === 0) return 0;
|
|
223
|
+
const clampedRaw = Math.max(0, Math.min(startIndex, rawSlides.length - 1));
|
|
224
|
+
for (let i = clampedRaw; i < rawSlides.length; i++) {
|
|
225
|
+
if (!rawSlides[i]?.skipped) {
|
|
226
|
+
return rawSlides.slice(0, i).filter((s) => !s?.skipped).length;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
for (let i = clampedRaw - 1; i >= 0; i--) {
|
|
230
|
+
if (!rawSlides[i]?.skipped) {
|
|
231
|
+
return rawSlides.slice(0, i).filter((s) => !s?.skipped).length;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return 0;
|
|
235
|
+
}, [slides, startIndex]);
|
|
215
236
|
const clampIndex = useCallback(
|
|
216
237
|
(index: number) => {
|
|
217
238
|
if (safeSlides.length === 0) return 0;
|
|
@@ -221,7 +242,7 @@ export default function PresentationView({
|
|
|
221
242
|
[safeSlides.length],
|
|
222
243
|
);
|
|
223
244
|
const [currentIndex, setCurrentIndex] = useState(() =>
|
|
224
|
-
clampIndex(
|
|
245
|
+
clampIndex(initialIndex),
|
|
225
246
|
);
|
|
226
247
|
const [prevIndex, setPrevIndex] = useState<number | null>(null);
|
|
227
248
|
const [direction, setDirection] = useState<"next" | "prev">("next");
|
|
@@ -239,24 +260,36 @@ export default function PresentationView({
|
|
|
239
260
|
|
|
240
261
|
const isShared = deckId.startsWith("__shared__/");
|
|
241
262
|
|
|
263
|
+
// `safeSlides` excludes skipped slides, so its index isn't the deck index
|
|
264
|
+
// DeckEditor's `?slide=N` param expects. Map back to the raw position so
|
|
265
|
+
// exiting/opening Presenter lands on the same slide it's currently showing.
|
|
266
|
+
const visibleRawIndices = useMemo(() => {
|
|
267
|
+
const rawSlides = (Array.isArray(slides) ? slides : []).filter(Boolean);
|
|
268
|
+
const rawIndices: number[] = [];
|
|
269
|
+
rawSlides.forEach((slide, i) => {
|
|
270
|
+
if (!slide?.skipped) rawIndices.push(i);
|
|
271
|
+
});
|
|
272
|
+
return rawIndices;
|
|
273
|
+
}, [slides]);
|
|
274
|
+
const toRawIndex = useCallback(
|
|
275
|
+
(filteredIndex: number) =>
|
|
276
|
+
visibleRawIndices[filteredIndex] ?? filteredIndex,
|
|
277
|
+
[visibleRawIndices],
|
|
278
|
+
);
|
|
279
|
+
|
|
242
280
|
// Exit handlers read these instead of closing over `currentIndex`/`deckId`/
|
|
243
281
|
// `isShared` so the mount-only fullscreenchange listener still lands on the
|
|
244
282
|
// right deck and slide even if this component is reused for a different
|
|
245
283
|
// deck without remounting (e.g. an agent-driven navigation).
|
|
246
284
|
const currentIndexRef = useRef(currentIndex);
|
|
247
285
|
currentIndexRef.current = currentIndex;
|
|
286
|
+
const visibleRawIndicesRef = useRef(visibleRawIndices);
|
|
287
|
+
visibleRawIndicesRef.current = visibleRawIndices;
|
|
248
288
|
const deckIdRef = useRef(deckId);
|
|
249
289
|
deckIdRef.current = deckId;
|
|
250
290
|
const isSharedRef = useRef(isShared);
|
|
251
291
|
isSharedRef.current = isShared;
|
|
252
292
|
|
|
253
|
-
useEffect(() => {
|
|
254
|
-
setCurrentIndex((prev) => clampIndex(prev));
|
|
255
|
-
setPrevIndex((prev) =>
|
|
256
|
-
prev !== null && prev >= safeSlides.length ? null : prev,
|
|
257
|
-
);
|
|
258
|
-
}, [clampIndex, safeSlides.length]);
|
|
259
|
-
|
|
260
293
|
const clearTransitionTimer = useCallback(() => {
|
|
261
294
|
if (transitionTimerRef.current !== null) {
|
|
262
295
|
window.clearTimeout(transitionTimerRef.current);
|
|
@@ -266,14 +299,57 @@ export default function PresentationView({
|
|
|
266
299
|
|
|
267
300
|
useEffect(() => clearTransitionTimer, [clearTransitionTimer]);
|
|
268
301
|
|
|
302
|
+
// One atomic effect handles both cases so they can't race each other:
|
|
303
|
+
// - A genuine deep link or deck switch (startIndex/deckId changed) reseeds
|
|
304
|
+
// from initialIndex. This component is reused across deck navigation
|
|
305
|
+
// (see the exit-handler refs above), so a new deck at the same `?slide=`
|
|
306
|
+
// must still reset instead of inheriting the previous deck's position.
|
|
307
|
+
// - Otherwise, a skip toggle or reorder changed safeSlides without a new
|
|
308
|
+
// deep link. A length-only clamp would silently swap in a different
|
|
309
|
+
// slide at the same index, so follow the previously-shown slide's id to
|
|
310
|
+
// its new position, falling back to a raw clamp only when it's gone.
|
|
311
|
+
const prevDeepLinkKeyRef = useRef({ startIndex, deckId });
|
|
312
|
+
const prevSafeSlideIdsRef = useRef<string[]>(safeSlides.map((s) => s.id));
|
|
269
313
|
useEffect(() => {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
314
|
+
const prevKey = prevDeepLinkKeyRef.current;
|
|
315
|
+
const isDeepLinkChange =
|
|
316
|
+
prevKey.startIndex !== startIndex || prevKey.deckId !== deckId;
|
|
317
|
+
prevDeepLinkKeyRef.current = { startIndex, deckId };
|
|
318
|
+
|
|
319
|
+
if (isDeepLinkChange) {
|
|
320
|
+
prevSafeSlideIdsRef.current = safeSlides.map((s) => s.id);
|
|
321
|
+
clearTransitionTimer();
|
|
322
|
+
queuedNavigationRef.current = null;
|
|
323
|
+
setCurrentIndex(clampIndex(initialIndex));
|
|
324
|
+
setCurrentStep(0);
|
|
325
|
+
setPrevIndex(null);
|
|
326
|
+
setAnimating(false);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Read the prior ids before overwriting the ref below — the lookup needs
|
|
331
|
+
// the slide order from before this update, not the one it's producing.
|
|
332
|
+
const activeId = prevSafeSlideIdsRef.current[currentIndexRef.current];
|
|
333
|
+
const newIds = safeSlides.map((s) => s.id);
|
|
334
|
+
const followedIndex = activeId ? newIds.indexOf(activeId) : -1;
|
|
335
|
+
prevSafeSlideIdsRef.current = newIds;
|
|
336
|
+
setCurrentIndex(
|
|
337
|
+
followedIndex >= 0 ? followedIndex : clampIndex(currentIndexRef.current),
|
|
338
|
+
);
|
|
339
|
+
setPrevIndex((prev) =>
|
|
340
|
+
prev !== null && prev >= safeSlides.length ? null : prev,
|
|
341
|
+
);
|
|
342
|
+
if (followedIndex < 0) {
|
|
343
|
+
// The active slide is gone (e.g. just skipped) and we fell back to a
|
|
344
|
+
// different one — its reveal/transition state doesn't apply here.
|
|
345
|
+
clearTransitionTimer();
|
|
346
|
+
queuedNavigationRef.current = null;
|
|
347
|
+
setCurrentStep(0);
|
|
348
|
+
setPrevIndex(null);
|
|
349
|
+
setAnimating(false);
|
|
350
|
+
}
|
|
351
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
352
|
+
}, [safeSlides, startIndex, deckId]);
|
|
277
353
|
|
|
278
354
|
const currentSlide = safeSlides[currentIndex];
|
|
279
355
|
const animSteps = currentSlide ? getAnimationSteps(currentSlide) : null;
|
|
@@ -359,7 +435,10 @@ export default function PresentationView({
|
|
|
359
435
|
const token = deckId.replace("__shared__/", "");
|
|
360
436
|
navigate(`/share/${token}`);
|
|
361
437
|
} else {
|
|
362
|
-
|
|
438
|
+
const rawIndex =
|
|
439
|
+
visibleRawIndicesRef.current[currentIndexRef.current] ??
|
|
440
|
+
currentIndexRef.current;
|
|
441
|
+
navigate(`/deck/${deckId}?slide=${rawIndex + 1}`);
|
|
363
442
|
}
|
|
364
443
|
}, [navigate, deckId, isShared]);
|
|
365
444
|
|
|
@@ -405,13 +484,13 @@ export default function PresentationView({
|
|
|
405
484
|
const openPresenterWindow = useCallback(() => {
|
|
406
485
|
const url = new URL(window.location.href);
|
|
407
486
|
url.searchParams.set("presenter", "1");
|
|
408
|
-
url.searchParams.set("slide", String(currentIndex + 1));
|
|
487
|
+
url.searchParams.set("slide", String(toRawIndex(currentIndex) + 1));
|
|
409
488
|
window.open(
|
|
410
489
|
url.toString(),
|
|
411
490
|
`slides-presenter-${deckId}`,
|
|
412
491
|
"width=1200,height=760",
|
|
413
492
|
);
|
|
414
|
-
}, [currentIndex, deckId]);
|
|
493
|
+
}, [currentIndex, deckId, toRawIndex]);
|
|
415
494
|
|
|
416
495
|
useEffect(() => {
|
|
417
496
|
const handleKey = (e: KeyboardEvent) => {
|
|
@@ -480,9 +559,10 @@ export default function PresentationView({
|
|
|
480
559
|
const token = deckIdRef.current.replace("__shared__/", "");
|
|
481
560
|
navigate(`/share/${token}`);
|
|
482
561
|
} else {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
562
|
+
const rawIndex =
|
|
563
|
+
visibleRawIndicesRef.current[currentIndexRef.current] ??
|
|
564
|
+
currentIndexRef.current;
|
|
565
|
+
navigate(`/deck/${deckIdRef.current}?slide=${rawIndex + 1}`);
|
|
486
566
|
}
|
|
487
567
|
}
|
|
488
568
|
};
|