Files
openmaic/OpenMAIC/components/edit/ActionsBar/ActionsBar.tsx
2026-08-16 14:58:47 +08:00

1425 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
/**
* ActionsBar — Pro-mode "讲解脚本" bottom bar, a horizontal film-editing timeline
* that is also a light editor for the scene's playback `actions`.
*
* The scene's `actions` ARE the timeline: walked left→right, each `speech`
* becomes an editable clip block (one spoken line, numbered) and every non-speech
* cue (spotlight / laser / board) becomes a compact card pinned at its place in
* the flow. Hovering a cue replays the REAL playback effect on its bound element
* (setLaser → LaserPointerOverlay, setSpotlight → SpotlightOverlay).
*
* Editing (persisted via useStageStore.updateScene → actions-edit ops):
* - speech clip text is editable inline (commit on blur);
* - the header "添加动作" pill opens ActionPicker to insert a new action;
* - existing items drag to reorder; each card carries a delete button;
* - clicking an element-bound cue arms canvas pick mode (useCanvasStore.pickTarget),
* so the target is chosen by clicking the element directly on the slide.
*
* Collapsible; height-resizable from the top edge; reactive to the stage store.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Flag,
FoldVertical,
GripVertical,
Play,
Plus,
RefreshCw,
Trash2,
UnfoldVertical,
Volume2,
} from 'lucide-react';
import { motion, useReducedMotion } from 'motion/react';
import { cn } from '@/lib/utils/cn';
import { useI18n } from '@/lib/hooks/use-i18n';
import { flushStageSave, useStageStore } from '@/lib/store/stage';
import { useCanvasStore } from '@/lib/store/canvas';
import { useSettingsStore } from '@/lib/store/settings';
import { useAgentRegistry } from '@/lib/orchestration/registry/store';
import { AvatarDisplay } from '@/components/ui/avatar-display';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Action, DiscussionAction } from '@/lib/types/action';
import type { SceneType } from '@/lib/types/stage';
import { ELEMENT_BOUND, cueLabel, cueMeta, elementLabel } from './cue-meta';
import { applyCuePreview, clearCuePreview, cuePreviewFor } from './cue-preview';
import {
appendDiscussion,
clampInsertSlot,
hasDiscussion,
insertAt,
makeAction,
moveById,
moveByIdDir,
removeById,
setAudioIdById,
setDiscussionAgentById,
setDiscussionPromptById,
setDiscussionTopicById,
setSpeechTextClearAudioById,
} from './actions-edit';
import { ActionPicker } from './ActionPicker';
import type { PickerType } from './picker-options';
import {
audioExists,
audioObjectUrl,
regenerateSpeechAudio,
resolveLegacySpeechAudioId,
resolveSpeechAudioId,
} from '@/lib/audio/regenerate-speech-tts';
const EMPTY: Action[] = [];
const EMPTY_ELEMENTS: { id?: string; type: string; content?: string }[] = [];
// Stable empty set for the "no lines regenerating" state (avoids re-allocating
// on every reset and keeps a constant identity between batch runs).
const NO_IDS: ReadonlySet<string> = new Set();
/**
* Clear the canvas spotlight/laser preview when a cue glyph unmounts while it is
* being hovered — most importantly when the user deletes the cue. React does not
* fire `onMouseLeave` on unmount, so without this the previewed effect would stay
* stuck on the slide after its cue is gone.
*/
function useClearCuePreviewOnUnmount() {
useEffect(() => () => clearCuePreview(), []);
}
/**
* Soft amber dashed border marking a still-incomplete clip card — an empty
* narration line, a cue bound to no element, a discussion with no topic. A clip
* is a card, so a dashed frame reads as "draft / unfinished" better than a dot;
* the calmer amber stays clear of the blue interactive controls and is dropped
* the moment the clip is filled.
*/
const INCOMPLETE_CLIP = 'border-dashed border-amber-400/70';
const MIN_H = 168;
const MAX_H = 520;
const DEFAULT_H = 224;
const LINE_H = 86; // height when collapsed to just the axis line of node icons (fits the chips)
const AXIS_FROM_TOP = 20; // px from track top to the axis center (nodes hang below it)
// Radix Select forbids an empty-string item value, so the discussion's
// "unspecified agent" choice rides a sentinel that maps back to '' on change.
const DISCUSSION_AGENT_NONE = '__none__';
type DragPayload = { kind: 'move'; id: string };
interface TooltipState {
action: Action;
anchor: DOMRect;
}
type TFn = (key: string, options?: Record<string, unknown>) => string;
function propsOf(a: Action, t: TFn): Array<[string, string]> {
const rows: Array<[string, string]> = [[t('edit.timeline.fieldAction'), cueLabel(a.type, t)]];
const el = (a as { elementId?: string }).elementId;
if (el) rows.push([t('edit.timeline.fieldElement'), el]);
const content = (a as { content?: string }).content;
if (content)
rows.push([
t('edit.timeline.fieldContent'),
content.length > 48 ? `${content.slice(0, 48)}…` : content,
]);
return rows;
}
function CueTooltip({ tip }: { tip: TooltipState }) {
const { t } = useI18n();
if (typeof document === 'undefined') return null;
return createPortal(
<div
style={{
position: 'fixed',
left: Math.max(8, tip.anchor.left + tip.anchor.width / 2),
top: tip.anchor.top - 8,
transform: 'translate(-50%, -100%)',
maxWidth: 280,
zIndex: 60,
}}
className="pointer-events-none rounded-lg border border-border/80 bg-popover px-2.5 py-1.5 text-popover-foreground shadow-lg shadow-black/5"
>
{propsOf(tip.action, t).map(([k, v]) => (
<div key={k} className="flex gap-2 text-[11px] leading-relaxed">
<span className="shrink-0 text-muted-foreground">{k}</span>
<span className="font-mono [overflow-wrap:anywhere]">{v}</span>
</div>
))}
</div>,
document.body,
);
}
// Native HTML5 drag snapshots the element's square bounding box, so a round
// icon chip drags with white corners ("白边"). Suppress the ghost with a 1×1
// transparent image — the violet drop indicator carries the feedback instead.
let blankDragImg: HTMLImageElement | null = null;
function setBlankDragImage(e: React.DragEvent) {
if (typeof document === 'undefined') return;
if (!blankDragImg) {
blankDragImg = new Image();
blankDragImg.src =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}
try {
e.dataTransfer.setDragImage(blankDragImg, 0, 0);
} catch {
/* not supported — fall back to the default ghost */
}
}
/** Shared delete button — prominent, top-right of a card. */
function DeleteButton({ onDelete }: { onDelete: () => void }) {
const { t } = useI18n();
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="grid size-5 place-items-center rounded-md text-muted-foreground/55 transition-colors hover:bg-rose-50 hover:text-rose-500 dark:hover:bg-rose-500/15"
aria-label={t('edit.delete')}
>
<Trash2 className="size-3.5" />
</button>
);
}
/** ‹ › buttons to nudge a node left/right along the timeline. */
function MoveButtons({
onLeft,
onRight,
canLeft,
canRight,
}: {
onLeft: () => void;
onRight: () => void;
canLeft: boolean;
canRight: boolean;
}) {
const { t } = useI18n();
const cls =
'grid size-5 place-items-center rounded text-muted-foreground/55 transition-colors hover:bg-muted hover:text-foreground disabled:opacity-25 disabled:hover:bg-transparent';
return (
<>
<button
type="button"
disabled={!canLeft}
onClick={(e) => {
e.stopPropagation();
onLeft();
}}
className={cls}
aria-label={t('edit.timeline.moveLeft')}
title={t('edit.timeline.moveLeft')}
>
<ChevronLeft className="size-3.5" />
</button>
<button
type="button"
disabled={!canRight}
onClick={(e) => {
e.stopPropagation();
onRight();
}}
className={cls}
aria-label={t('edit.timeline.moveRight')}
title={t('edit.timeline.moveRight')}
>
<ChevronRight className="size-3.5" />
</button>
</>
);
}
type TtsStatus = 'none' | 'ready' | 'generating' | 'error';
/** Audio status + 试听 / 重新生成 row, shown when managed TTS is on. */
function SpeechTtsBar({
actionId,
audioId,
audioInvalidated,
sceneOrder,
language,
text,
audioUrl,
refreshKey,
regenerating,
onGenerated,
}: {
actionId: string;
audioId?: string;
audioInvalidated?: boolean;
sceneOrder: number;
language?: string;
text: string;
audioUrl?: string;
refreshKey?: number;
regenerating?: boolean;
onGenerated: (audioId: string) => Promise<void>;
}) {
const { t } = useI18n();
const [status, setStatus] = useState<TtsStatus>('none');
// Holds this line in 生成中 across a batch ("全部配音") run and — crucially —
// until its OWN audio re-check resolves, so it can't briefly flash back to
// 未配音 in the window between the batch clearing `regenerating` and the async
// audioExists effect landing. Latched on the rising edge of `regenerating`,
// cleared inside that re-check effect (which the batch always re-triggers via
// `refreshKey`).
const [batchPending, setBatchPending] = useState(false);
const [prevRegenerating, setPrevRegenerating] = useState(regenerating);
if (regenerating !== prevRegenerating) {
// Adjust state during render (per React's "you might not need an effect"),
// not in an effect — avoids a cascading render on the batch's hot path.
setPrevRegenerating(regenerating);
if (regenerating) setBatchPending(true);
}
const audioRef = useRef<HTMLAudioElement | null>(null);
const objUrlRef = useRef<string | null>(null);
const lookupId = resolveSpeechAudioId(sceneOrder, { id: actionId, audioId });
const [readAudioId, setReadAudioId] = useState<string | undefined>(lookupId);
const [seededLookupId, setSeededLookupId] = useState(lookupId);
if (lookupId !== seededLookupId) {
setSeededLookupId(lookupId);
setReadAudioId(lookupId);
}
const stopPreview = useCallback(() => {
audioRef.current?.pause();
audioRef.current = null;
if (objUrlRef.current) {
URL.revokeObjectURL(objUrlRef.current);
objUrlRef.current = null;
}
}, []);
useEffect(() => {
let alive = true;
(async () => {
try {
if (audioUrl) {
if (alive) setStatus('ready');
return;
}
// A missing stamped id means "not generated" for new documents. Probe
// the deterministic key only to preserve pre-allocation Dexie rows.
const legacyId = lookupId
? undefined
: await resolveLegacySpeechAudioId(sceneOrder, { id: actionId, audioInvalidated });
const candidateId = lookupId ?? legacyId;
const has = candidateId ? await audioExists(candidateId) : false;
if (alive) {
setReadAudioId(has ? candidateId : undefined);
setStatus((s) => (s === 'generating' ? s : has ? 'ready' : 'none'));
}
} catch {
/* IndexedDB read failed — leave status as-is (as before this change) */
} finally {
// Clear the batch latch only once the batch itself is over — its
// end-of-batch re-check runs with regenerating=false. A *stale*
// pre-batch check that resolves mid-batch must NOT clear it (adding
// regenerating to the deps also cancels such a check at batch start via
// the cleanup below). Runs even if the read threw, so the row can never
// wedge in 生成中.
if (alive && !regenerating) setBatchPending(false);
}
})();
return () => {
alive = false;
};
}, [lookupId, actionId, sceneOrder, audioUrl, audioInvalidated, refreshKey, regenerating]);
useEffect(() => () => stopPreview(), [stopPreview]);
const preview = async () => {
stopPreview();
let src = audioUrl ?? null;
if (!src && readAudioId) {
src = await audioObjectUrl(readAudioId);
objUrlRef.current = src;
}
if (!src) return;
const a = new Audio(src);
audioRef.current = a;
a.addEventListener('ended', stopPreview);
void a.play().catch(() => stopPreview());
};
const regenerate = async () => {
setStatus('generating');
try {
const previousAudioId = audioId;
const id = await regenerateSpeechAudio(
sceneOrder,
{ id: actionId, text, audioId: previousAudioId },
language,
);
if (id) {
setReadAudioId(id);
await onGenerated(id);
setStatus('ready');
} else {
setStatus('none');
}
} catch {
setStatus('error');
}
};
const STATUS: Record<TtsStatus, { label: string; cls: string }> = {
ready: { label: t('edit.tts.statusReady'), cls: 'text-emerald-600 dark:text-emerald-400' },
none: { label: t('edit.tts.statusNone'), cls: 'text-muted-foreground' },
generating: {
label: t('edit.tts.statusGenerating'),
cls: 'text-amber-600 dark:text-amber-400',
},
error: { label: t('edit.tts.statusError'), cls: 'text-rose-500' },
};
// A batch "全部配音" run drives this line's loading state from the parent
// (regenerating) — independent of the local single-line status. `batchPending`
// extends 生成中 past the prop clearing, until this line's own audio re-check
// resolves to 已配音 / 未配音, so the batch end shows a clean 生成中 → 已配音
// transition with no intermediate flash.
const effStatus: TtsStatus = regenerating || batchPending ? 'generating' : status;
const s = STATUS[effStatus];
return (
<div className="flex items-center gap-1 border-t border-border/60 px-2 py-1">
<Volume2 className="size-3 shrink-0 text-muted-foreground/40" />
<span className={cn('text-[10px] font-medium', s.cls)}>{s.label}</span>
<span className="ml-auto" />
<button
type="button"
onClick={preview}
disabled={effStatus !== 'ready'}
className="grid size-5 place-items-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30 disabled:hover:bg-transparent"
aria-label={t('edit.tts.preview')}
title={t('edit.tts.preview')}
>
<Play className="size-3" />
</button>
<button
type="button"
onClick={regenerate}
disabled={effStatus === 'generating' || !text.trim()}
className="grid size-5 place-items-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30 disabled:hover:bg-transparent"
aria-label={t('edit.tts.regenerate')}
title={t('edit.tts.regenerate')}
>
<RefreshCw className={cn('size-3', effStatus === 'generating' && 'animate-spin')} />
</button>
</div>
);
}
/** One spoken line — a numbered, editable clip block. */
function SpeechClip({
text,
index,
actionId,
audioId,
audioInvalidated,
sceneOrder,
language,
autoFocus,
ttsActive,
audioUrl,
ttsRefresh,
regenerating,
onCommit,
onGenerated,
onDelete,
onMoveLeft,
onMoveRight,
canMoveLeft,
canMoveRight,
onDragStart,
onDragEnd,
onFocused,
}: {
text: string;
index: number;
actionId: string;
audioId?: string;
audioInvalidated?: boolean;
sceneOrder: number;
language?: string;
autoFocus: boolean;
ttsActive: boolean;
audioUrl?: string;
ttsRefresh?: number;
regenerating?: boolean;
onCommit: (text: string) => void;
onGenerated: (audioId: string) => Promise<void>;
onDelete: () => void;
onMoveLeft: () => void;
onMoveRight: () => void;
canMoveLeft: boolean;
canMoveRight: boolean;
onDragStart: (e: React.DragEvent) => void;
onDragEnd: () => void;
onFocused: () => void;
}) {
const { t } = useI18n();
const ref = useRef<HTMLTextAreaElement>(null);
const [val, setVal] = useState(text);
// Has the user typed since the last external sync? If not, external text
// changes (e.g. an agent regeneration mid-edit) are adopted even while
// focused — so a stale draft can't clobber regenerated narration on blur.
const dirtyRef = useRef(false);
useEffect(() => {
if (document.activeElement !== ref.current || !dirtyRef.current) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync external text in only when not mid-edit
setVal(text);
dirtyRef.current = false;
}
}, [text]);
useEffect(() => {
if (autoFocus) {
ref.current?.focus();
onFocused();
}
}, [autoFocus, onFocused]);
const commit = () => {
if (dirtyRef.current && val !== text) onCommit(val);
dirtyRef.current = false;
};
const SpeechIcon = cueMeta('speech').icon;
const needsText = !text.trim();
return (
<div
className={cn(
'group/clip relative flex h-full w-[228px] shrink-0 flex-col overflow-hidden rounded-xl border border-border/85 bg-white/75 shadow-sm transition-colors focus-within:border-violet-400 hover:border-violet-300/70 dark:bg-slate-800/50 dark:hover:border-violet-500/40',
needsText && INCOMPLETE_CLIP,
)}
>
<span className="absolute inset-x-0 top-0 h-[3px] bg-primary/35" />
<div className="flex items-center gap-1.5 border-b border-border/60 bg-muted/40 px-2 py-1">
<span
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
className="cursor-grab text-muted-foreground/50 transition-colors hover:text-muted-foreground active:cursor-grabbing"
aria-label={t('edit.timeline.reorder')}
>
<GripVertical className="size-3.5" />
</span>
<span className="font-mono text-[10px] font-semibold tabular-nums text-muted-foreground/55">
{String(index).padStart(2, '0')}
</span>
<SpeechIcon className="size-3 text-primary/45" />
<span className="ml-auto mr-0.5 text-[8.5px] font-medium uppercase tracking-[0.12em] text-muted-foreground/50">
{t('edit.cue.speech')}
</span>
<MoveButtons
onLeft={onMoveLeft}
onRight={onMoveRight}
canLeft={canMoveLeft}
canRight={canMoveRight}
/>
<DeleteButton onDelete={onDelete} />
</div>
<textarea
ref={ref}
value={val}
onChange={(e) => {
dirtyRef.current = true;
setVal(e.target.value);
}}
onBlur={commit}
placeholder={t('edit.timeline.speechPlaceholder')}
className="flex-1 resize-none bg-transparent px-3 py-2 text-[12.5px] leading-[1.7] text-foreground/90 outline-none placeholder:text-muted-foreground/40 [scrollbar-width:thin]"
/>
{ttsActive && (
<SpeechTtsBar
actionId={actionId}
audioId={audioId}
audioInvalidated={audioInvalidated}
sceneOrder={sceneOrder}
language={language}
text={val}
audioUrl={audioUrl}
refreshKey={ttsRefresh}
regenerating={regenerating}
onGenerated={onGenerated}
/>
)}
</div>
);
}
/**
* A discussion node — the scene's terminal roundtable trigger. Unlike the other
* cues it isn't drag-addable or movable: a discussion must be the LAST action and
* there is at most one per scene (mirrors the action-parser invariant), so it's
* appended via the toolbar and pinned at the end. Inline-edits topic (required),
* prompt (optional) and the initiating agent. Topic/prompt commit on blur.
*/
function DiscussionClip({
topic,
prompt,
agentId,
agents,
onTopicChange,
onPromptChange,
onAgentChange,
onDelete,
}: {
topic: string;
prompt: string;
agentId: string;
agents: Array<{ id: string; name: string; avatar?: string }>;
onTopicChange: (v: string) => void;
onPromptChange: (v: string) => void;
onAgentChange: (v: string) => void;
onDelete: () => void;
}) {
const { t } = useI18n();
const m = cueMeta('discussion');
const needsTopic = !topic.trim();
// Local drafts committed on blur; synced from props when not mid-edit so a
// concurrent store update can't clobber an in-flight edit (mirrors SpeechClip).
const [topicVal, setTopicVal] = useState(topic);
const [promptVal, setPromptVal] = useState(prompt);
const topicDirty = useRef(false);
const promptDirty = useRef(false);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- adopt external topic only when not mid-edit
if (!topicDirty.current) setTopicVal(topic);
}, [topic]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- adopt external prompt only when not mid-edit
if (!promptDirty.current) setPromptVal(prompt);
}, [prompt]);
return (
<div
className={cn(
'group/disc relative flex h-full w-[228px] shrink-0 flex-col overflow-hidden rounded-xl border border-gray-200/80 bg-white/70 shadow-sm transition-colors focus-within:border-yellow-400 hover:border-yellow-300/70 dark:border-gray-700/60 dark:bg-slate-800/50 dark:hover:border-yellow-500/40',
needsTopic && INCOMPLETE_CLIP,
)}
>
{/* The empty state reads from the amber dashed frame + the "topic
(required)" placeholder; the discussion keeps its Flag glyph identity. */}
<span className={cn('absolute inset-x-0 top-0 h-[3px]', m.accent)} />
<div className="flex items-center gap-1.5 border-b border-yellow-300/50 bg-yellow-400/15 px-2 py-1 dark:border-yellow-500/25 dark:bg-yellow-500/10">
<span className="flex size-4 items-center justify-center rounded-md bg-yellow-400 text-yellow-950 dark:bg-yellow-500 dark:text-slate-900">
<Flag className="size-2.5" />
</span>
<span className="text-[8.5px] font-semibold uppercase tracking-[0.12em] text-yellow-700 dark:text-yellow-400">
{t('edit.cue.discussion')}
</span>
<span
className="ml-auto text-[8.5px] font-medium uppercase tracking-[0.1em] text-yellow-600/70 dark:text-yellow-500/60"
title={t('edit.timeline.discussionTerminalHint')}
>
{t('edit.timeline.discussionTerminal')}
</span>
<DeleteButton onDelete={onDelete} />
</div>
<textarea
value={topicVal}
onChange={(e) => {
topicDirty.current = true;
setTopicVal(e.target.value);
}}
onBlur={() => {
if (topicDirty.current) onTopicChange(topicVal);
topicDirty.current = false;
}}
placeholder={t('edit.timeline.discussionTopicPlaceholder')}
className="h-[46px] shrink-0 resize-none bg-transparent px-3 pt-2 text-[12.5px] font-medium leading-[1.55] text-foreground/85 outline-none placeholder:font-normal placeholder:text-amber-500/60 [scrollbar-width:thin]"
/>
<textarea
value={promptVal}
onChange={(e) => {
promptDirty.current = true;
setPromptVal(e.target.value);
}}
onBlur={() => {
if (promptDirty.current) onPromptChange(promptVal);
promptDirty.current = false;
}}
placeholder={t('edit.timeline.discussionPromptPlaceholder')}
className="min-h-0 flex-1 resize-none bg-transparent px-3 text-[11px] leading-[1.6] text-muted-foreground outline-none placeholder:text-muted-foreground/35 [scrollbar-width:thin]"
/>
<div className="flex items-center gap-1 border-t border-gray-100 px-2 py-1 dark:border-gray-700/50">
<span className="shrink-0 text-[9px] text-muted-foreground/50">
{t('edit.timeline.discussionAgent')}
</span>
<Select
value={agentId || DISCUSSION_AGENT_NONE}
onValueChange={(v) => onAgentChange(v === DISCUSSION_AGENT_NONE ? '' : v)}
>
<SelectTrigger
size="sm"
className="ml-auto h-6 max-w-[150px] gap-1 rounded border-border px-1.5 py-0 text-[10px] shadow-none focus-visible:ring-yellow-400/40 [&_svg]:size-3"
>
<SelectValue placeholder={t('edit.timeline.discussionAgentUnset')} />
</SelectTrigger>
<SelectContent className="max-h-56">
<SelectItem value={DISCUSSION_AGENT_NONE} className="text-[11px]">
<span className="text-muted-foreground">
{t('edit.timeline.discussionAgentUnset')}
</span>
</SelectItem>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id} className="text-[11px]">
<span className="flex items-center gap-1.5">
{a.avatar && (
<span className="size-4 shrink-0 overflow-hidden rounded-full bg-muted">
<AvatarDisplay src={a.avatar} alt={a.name} />
</span>
)}
{a.name}
</span>
</SelectItem>
))}
{/* keep a set agent visible even if it's no longer in the scene roster */}
{agentId && !agents.some((a) => a.id === agentId) && (
<SelectItem value={agentId} className="text-[11px]">
{agentId}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
);
}
/**
* A non-speech cue — its own compact card on the timeline (the action's implicit
* container, made explicit). Carries a delete button; clicking an element-bound
* cue arms canvas pick mode so the target is chosen on the slide itself.
*/
function CueMarker({
action,
elements,
onTip,
onDelete,
onPick,
onMoveLeft,
onMoveRight,
canMoveLeft,
canMoveRight,
onDragStart,
onDragEnd,
}: {
action: Action;
elements: { id?: string; type: string; content?: string }[];
onTip: (t: TooltipState | null) => void;
onDelete: () => void;
onPick: () => void;
onMoveLeft: () => void;
onMoveRight: () => void;
canMoveLeft: boolean;
canMoveRight: boolean;
onDragStart: (e: React.DragEvent) => void;
onDragEnd: () => void;
}) {
const { t } = useI18n();
useClearCuePreviewOnUnmount();
const m = cueMeta(action.type);
const label = cueLabel(action.type, t);
const Icon = m.icon;
const bound = ELEMENT_BOUND.has(action.type);
const elementId = (action as { elementId?: string }).elementId ?? '';
const needsTarget = bound && !elementId;
// Bound cue → show what it's actually pointing at, not a generic "bound";
// the element may have been deleted since binding, so fall back gracefully.
const boundEl = elementId ? elements.find((e) => e.id === elementId) : undefined;
return (
<div
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onMouseEnter={(e) => {
onTip({ action, anchor: e.currentTarget.getBoundingClientRect() });
applyCuePreview(cuePreviewFor(action));
}}
onMouseLeave={() => {
onTip(null);
clearCuePreview();
}}
onClick={() => {
if (bound) onPick();
}}
className={cn(
'group/cue relative flex h-full w-[108px] shrink-0 flex-col overflow-hidden rounded-xl border border-gray-200/80 bg-white/65 shadow-sm transition-colors dark:border-gray-700/60 dark:bg-slate-800/40',
bound
? 'cursor-pointer hover:border-violet-300/70 dark:hover:border-violet-500/40'
: 'cursor-grab active:cursor-grabbing',
needsTarget && cn('border-dashed', m.dash),
)}
aria-label={label}
>
<span className={cn('absolute inset-x-0 top-0 h-[3px]', m.accent)} />
<div className="flex items-center gap-0.5 px-1 pt-1">
<span
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={(e) => e.stopPropagation()}
className="cursor-grab text-muted-foreground/35 transition-colors hover:text-muted-foreground active:cursor-grabbing"
aria-label={t('edit.timeline.reorder')}
>
<GripVertical className="size-3.5" />
</span>
<span className="ml-auto flex items-center">
<MoveButtons
onLeft={onMoveLeft}
onRight={onMoveRight}
canLeft={canMoveLeft}
canRight={canMoveRight}
/>
<DeleteButton onDelete={onDelete} />
</span>
</div>
<div className="flex flex-1 flex-col items-center justify-center gap-1.5 px-1 pb-1">
<span className={cn('flex size-8 items-center justify-center rounded-full', m.glyph)}>
<Icon className="size-4" />
</span>
<span className="text-[10px] font-medium text-foreground/70">{label}</span>
{bound && (
<span
className={cn(
'text-[9px]',
needsTarget
? 'font-medium text-amber-600 dark:text-amber-400'
: 'text-muted-foreground/45',
)}
>
{needsTarget
? t('edit.timeline.pickElement')
: `→ ${boundEl ? elementLabel(boundEl, t) : t('edit.timeline.bound')}`}
</span>
)}
</div>
</div>
);
}
/** A node anchored on the axis — drag handle; for cues, hover preview + click-to-pick. */
function NodeDot({
action,
onTip,
onPick,
onDragStart,
onDragEnd,
canDrag = true,
}: {
action: Action;
onTip: (t: TooltipState | null) => void;
onPick: () => void;
onDragStart: (e: React.DragEvent) => void;
onDragEnd: () => void;
canDrag?: boolean;
}) {
const { t } = useI18n();
useClearCuePreviewOnUnmount();
const isSpeech = action.type === 'speech';
// A discussion is the scene's terminal anchor — give its node a distinct
// marker (square, filled yellow) so it reads as the end stop, not a regular
// cue. Its flag glyph comes from cue-meta like every other type's.
const isDiscussion = action.type === 'discussion';
const bound = ELEMENT_BOUND.has(action.type);
const elementId = (action as { elementId?: string }).elementId ?? '';
const needsTarget = bound && !elementId;
const m = cueMeta(action.type);
const label = cueLabel(action.type, t);
const Icon = m.icon;
return (
<span
draggable={canDrag}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
title={isSpeech ? (action as { text?: string }).text?.slice(0, 60) : label}
onMouseEnter={(e) => {
if (isSpeech) return;
onTip({ action, anchor: e.currentTarget.getBoundingClientRect() });
applyCuePreview(cuePreviewFor(action));
}}
onMouseLeave={() => {
if (isSpeech) return;
onTip(null);
clearCuePreview();
}}
onClick={() => {
if (bound) onPick();
}}
className={cn(
'grid size-6 place-items-center ring-2 ring-white transition-transform hover:scale-110 dark:ring-slate-900',
isDiscussion ? 'rounded-[7px]' : 'rounded-full',
needsTarget
? 'text-amber-600 bg-amber-100 ring-amber-200 animate-pulse dark:bg-amber-500/20 dark:text-amber-400'
: isDiscussion
? 'bg-yellow-400 text-yellow-900 ring-yellow-200 dark:bg-yellow-500 dark:text-slate-900 dark:ring-yellow-500/30'
: m.glyph,
bound
? 'cursor-pointer'
: canDrag
? 'cursor-grab active:cursor-grabbing'
: 'cursor-default',
)}
aria-label={label}
>
<Icon className="size-3.5" />
</span>
);
}
/** Slim insertion slot between items; widens + glows while a drag hovers it. */
function DropZone({
active,
slot,
onEnter,
onDrop,
onInsert,
insertLabel,
flex,
}: {
active: boolean;
slot: number;
onEnter: () => void;
onDrop: () => void;
onInsert: (slot: number, rect: DOMRect) => void;
insertLabel: string;
flex?: boolean;
}) {
return (
<div
onDragOver={(e) => {
e.preventDefault();
onEnter();
}}
onDrop={(e) => {
e.preventDefault();
onDrop();
}}
className={cn(
'group/ins relative flex h-full shrink-0 items-start justify-center pt-2 transition-all',
flex ? 'flex-1' : active ? 'w-10' : 'w-4',
)}
>
<span
className={cn(
'pointer-events-none absolute inset-y-3 left-1/2 w-0.5 -translate-x-1/2 rounded-full transition-colors',
active ? 'bg-primary' : 'bg-transparent',
)}
/>
{!active && (
<button
type="button"
aria-label={insertLabel}
title={insertLabel}
onClick={(e) => onInsert(slot, e.currentTarget.getBoundingClientRect())}
className="relative z-[1] grid size-[22px] scale-90 place-items-center rounded-full border border-dashed border-primary/40 bg-background text-primary/70 opacity-30 transition-all hover:scale-100 hover:border-solid hover:border-primary hover:bg-primary/5 hover:text-primary hover:opacity-100 group-hover/ins:opacity-90"
>
<Plus className="size-3" />
</button>
)}
</div>
);
}
export function ActionsBar({ sceneId }: { sceneId: string }) {
const { t } = useI18n();
const scene = useStageStore((s) => s.scenes.find((x) => x.id === sceneId));
const actions = scene?.actions ?? EMPTY;
const sceneOrder = scene?.order ?? 0;
// Element-bound cues (spotlight / laser) point at slide elements, so they only
// make sense on SLIDE scenes. While the scene hasn't loaded yet, fall back to
// a non-slide type so the picker doesn't briefly offer unsupported cues.
const sceneType: SceneType = scene?.type ?? 'quiz';
// Slide-scene canvas elements — feeds CueMarker's bound-cue label lookup
// ("→ <element name>" instead of a generic "bound"). Non-slide scenes'
// `content` has no `canvas`, so this is always [] there.
const sceneElements =
(
scene?.content as
| { canvas?: { elements?: { id?: string; type: string; content?: string }[] } }
| undefined
)?.canvas?.elements ?? EMPTY_ELEMENTS;
const language = useStageStore((s) => s.stage?.languageDirective);
// Managed TTS on → speech clips show audio status + 试听 / 重新生成.
const ttsActive = useSettingsStore(
(s) => s.ttsEnabled && s.ttsProviderId !== 'browser-native-tts',
);
// Agents a discussion can be initiated by — sourced from the user's currently
// SELECTED agents, the exact set the playback engine gates on: it skips (and
// consumes) any discussion whose `agentId` isn't selected. Offering the same
// set here means whatever the author picks will actually fire at playback;
// anything else (scene/stage roster) could let them save an initiator that the
// engine silently drops. With nothing selected only "unspecified" remains,
// which is correct since an unset `agentId` is never skipped.
const agentsRecord = useAgentRegistry((s) => s.agents);
const selectedAgentIds = useSettingsStore((s) => s.selectedAgentIds);
const discussionAgents = useMemo(
() =>
selectedAgentIds
.map((id) => agentsRecord[id])
.filter(Boolean)
.map((a) => ({ id: a.id, name: a.name, avatar: a.avatar })),
[selectedAgentIds, agentsRecord],
);
const [lineMode, setLineMode] = useState(false); // collapse to just the axis line of node icons
const [tip, setTip] = useState<TooltipState | null>(null);
const [dragOver, setDragOver] = useState<number | null>(null);
const [focusId, setFocusId] = useState<string | null>(null);
const [regenAll, setRegenAll] = useState(false);
const [pickerAt, setPickerAt] = useState<{ slot: number; rect: DOMRect } | null>(null);
// Ids of speech lines currently being (re)generated by "全部配音", so each
// line's status row shows 生成中 for the duration of the batch.
const [regeneratingIds, setRegeneratingIds] = useState<ReadonlySet<string>>(NO_IDS);
const [ttsRefresh, setTtsRefresh] = useState(0); // bump → speech clips re-check audio status
const reduce = useReducedMotion();
const dragRef = useRef<DragPayload | null>(null);
// Apply an edit to the LATEST actions from the store (not the render-time
// snapshot), so a concurrent agent/TTS update isn't reverted by a later UI
// commit (drag / reorder / blur / delete).
const commit = useCallback(
(updater: (cur: Action[]) => Action[]) => {
const cur = useStageStore.getState().scenes.find((s) => s.id === sceneId)?.actions ?? [];
useStageStore.getState().updateScene(sceneId, { actions: updater(cur) });
},
[sceneId],
);
// Regenerate TTS for every speech line in the scene, then stamp audioIds.
// Reads the latest actions from the store at each step so a concurrent edit
// isn't clobbered, and stamps by id (index-stale-safe).
const regenerateAllAudio = useCallback(async () => {
if (regenAll) return;
const latest = () => useStageStore.getState().scenes.find((s) => s.id === sceneId);
const speeches = (latest()?.actions ?? []).filter(
(a) => a.type === 'speech' && ((a as { text?: string }).text ?? '').trim(),
);
if (!speeches.length) return;
const order = latest()?.order ?? 0;
setRegenAll(true);
// Light up every queued line's status row up front (they're all about to be
// synthesized), cleared together in the finally once the batch settles.
setRegeneratingIds(new Set(speeches.map((a) => a.id).filter(Boolean) as string[]));
try {
for (const queued of speeches) {
if (!queued.id) continue;
try {
const liveAction = latest()?.actions?.find(
(action) => action.type === 'speech' && action.id === queued.id,
);
if (!liveAction || liveAction.type !== 'speech') continue;
const previousAudioId = liveAction.audioId;
const id = await regenerateSpeechAudio(
latest()?.order ?? order,
{
id: liveAction.id,
text: liveAction.text,
audioId: previousAudioId,
},
language,
);
if (id) {
commit((cur) => setAudioIdById(cur, liveAction.id!, id));
await flushStageSave();
}
} catch {
/* skip a failed line, keep going */
}
}
} finally {
setRegenAll(false);
setRegeneratingIds(NO_IDS);
// Always re-check every line's audio at batch end — even when nothing
// synthesized — so each SpeechTtsBar resolves its status (and clears its
// batchPending flag) instead of getting stuck in 生成中.
setTtsRefresh((n) => n + 1);
}
}, [regenAll, sceneId, language, commit]);
// Height drag-resize (top edge).
const sectionRef = useRef<HTMLElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const panViewport = (dir: -1 | 1) =>
scrollRef.current?.scrollBy({ left: dir * 280, behavior: 'smooth' });
const [height, setHeight] = useState(DEFAULT_H);
const resizeRef = useRef<{
startY: number;
startH: number;
lastH: number;
pointerId: number;
} | null>(null);
const onResizeStart = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const startH = sectionRef.current?.getBoundingClientRect().height ?? height;
resizeRef.current = { startY: e.clientY, startH, lastH: startH, pointerId: e.pointerId };
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
/* best effort */
}
document.body.style.cursor = 'row-resize';
},
[height],
);
const onResizeMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const d = resizeRef.current;
if (!d || e.pointerId !== d.pointerId) return;
const next = Math.min(MAX_H, Math.max(MIN_H, d.startH + (d.startY - e.clientY)));
d.lastH = next;
if (sectionRef.current) sectionRef.current.style.height = `${next}px`;
}, []);
const onResizeEnd = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const d = resizeRef.current;
if (!d || e.pointerId !== d.pointerId) return;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
/* may already be released */
}
setHeight(d.lastH);
resizeRef.current = null;
document.body.style.cursor = '';
}, []);
const newId = () =>
typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `a-${Date.now()}`;
// Insert path for the ActionPicker (header pill / inline "+" drop-zone
// buttons): appends a discussion (terminal, at-most-one) or inserts an
// ordinary action at a slot, capped before any existing discussion so it
// always stays last.
const insertActionAt = useCallback(
(type: PickerType, slot: number) => {
const id = newId();
if (type === 'discussion') {
commit((cur) => appendDiscussion(cur, id));
return;
}
const action = makeAction(type, id);
commit((cur) => insertAt(cur, clampInsertSlot(cur, slot), action));
if (type === 'speech') setFocusId(id);
},
[commit],
);
const handleDrop = useCallback(
(slot: number) => {
const p = dragRef.current;
dragRef.current = null;
setDragOver(null);
if (!p) return;
commit((cur) => moveById(cur, p.id, clampInsertSlot(cur, slot)));
},
[commit],
);
const speechCount = actions.filter((a) => a.type === 'speech').length;
const cueCount = actions.length - speechCount;
// A discussion is pinned at the end (at most one), so ordinary actions can move
// right only up to the slot before it; the discussion node itself can't move.
const discussionPresent = hasDiscussion(actions);
const lastMovableIndex = discussionPresent ? actions.length - 2 : actions.length - 1;
let speechIndex = 0;
const items = actions.map((action, index) => {
if (action.type === 'speech') speechIndex += 1;
return { action, index, key: (action.id ?? `a-${index}`) as string, speechIndex };
});
return (
<section
ref={sectionRef}
style={{ height: lineMode ? LINE_H : height }}
className="relative flex flex-col border-t border-gray-100 bg-white/80 backdrop-blur-xl dark:border-gray-800 dark:bg-slate-900/80"
>
{!lineMode && (
<div
onPointerDown={onResizeStart}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
className="group absolute inset-x-0 top-0 z-10 h-1.5 cursor-row-resize touch-none transition-colors hover:bg-violet-400/30 active:bg-violet-500/50 dark:hover:bg-violet-500/30"
>
<div className="absolute left-1/2 top-[3px] h-0.5 w-9 -translate-x-1/2 rounded-full bg-gray-300 transition-colors group-hover:bg-violet-400 dark:bg-gray-600 dark:group-hover:bg-violet-500" />
</div>
)}
<div className="flex h-10 shrink-0 items-center gap-2.5 px-6">
<button
type="button"
onClick={() => setLineMode((v) => !v)}
className="flex items-center gap-2.5"
>
<span className="size-1.5 rounded-full bg-primary" />
<span className="text-[12px] font-medium tracking-[0.18em] text-foreground/80">
{t('edit.timeline.title')}
</span>
</button>
{!lineMode && (
<button
type="button"
onClick={(e) => {
const slot = discussionPresent ? actions.length - 1 : actions.length;
setPickerAt({ slot, rect: e.currentTarget.getBoundingClientRect() });
}}
className="ml-3 inline-flex items-center gap-1 rounded-full border border-primary/25 bg-primary/10 px-2.5 py-0.5 text-[11px] font-medium text-primary transition-colors hover:bg-primary/15"
>
<Plus className="size-3" />
{t('edit.timeline.addAction')}
<ChevronDown className="size-3 opacity-70" />
</button>
)}
{!lineMode && ttsActive && (
<button
type="button"
onClick={regenerateAllAudio}
disabled={regenAll}
title={t('edit.timeline.regenAllTts')}
aria-label={t('edit.timeline.regenAllTts')}
className="ml-1.5 inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:border-primary/30 hover:text-foreground disabled:opacity-50"
>
<RefreshCw className={cn('size-3', regenAll && 'animate-spin')} />
{t('edit.timeline.voiceAll')}
</button>
)}
<span className="ml-auto font-mono text-[11px] tabular-nums text-muted-foreground/60">
{t('edit.timeline.counts', { speech: speechCount, cue: cueCount })}
</span>
{/* pan the timeline viewport left/right */}
{!lineMode && (
<div className="ml-1 flex items-center border-l border-gray-200/70 pl-1 dark:border-gray-700/60">
<button
type="button"
onClick={() => panViewport(-1)}
title={t('edit.timeline.panLeft')}
aria-label={t('edit.timeline.panLeft')}
className="grid size-7 place-items-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
>
<ChevronsLeft className="size-4" />
</button>
<button
type="button"
onClick={() => panViewport(1)}
title={t('edit.timeline.panRight')}
aria-label={t('edit.timeline.panRight')}
className="grid size-7 place-items-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
>
<ChevronsRight className="size-4" />
</button>
</div>
)}
<button
type="button"
onClick={() => setLineMode((v) => !v)}
title={lineMode ? t('edit.timeline.expandTrack') : t('edit.timeline.collapseAxis')}
aria-label={lineMode ? t('edit.timeline.expandTrack') : t('edit.timeline.collapseAxis')}
className="ml-1 grid size-7 place-items-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
>
{lineMode ? <UnfoldVertical className="size-4" /> : <FoldVertical className="size-4" />}
</button>
</div>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-x-auto overflow-y-hidden">
<div className="relative h-full min-w-max">
{/* the timeline axis (top) — nodes hang below it; hidden when empty
so the placeholder hint doesn't collide with the line */}
{actions.length > 0 && (
<div
className="pointer-events-none absolute inset-x-3 bg-gradient-to-r from-border/30 via-border to-border/30"
style={{ top: AXIS_FROM_TOP - 1, height: 2 }}
/>
)}
<div className="relative flex h-full items-stretch px-3.5">
{actions.length === 0 && (
<span className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-[12px] text-muted-foreground/60">
{t('edit.timeline.emptyHint')}
</span>
)}
<DropZone
active={dragOver === 0}
slot={0}
flex={actions.length === 0}
onEnter={() => setDragOver(0)}
onDrop={() => handleDrop(0)}
onInsert={(slot, rect) => setPickerAt({ slot, rect })}
insertLabel={t('edit.timeline.addAction')}
/>
{items.map(({ action, index, key, speechIndex: si }) => {
// A discussion is pinned terminal, so it can't be drag-reordered.
const isDiscussion = action.type === 'discussion';
const onDragStart = isDiscussion
? () => {}
: (e: React.DragEvent) => {
dragRef.current = { kind: 'move', id: key };
setBlankDragImage(e);
};
const onDragEnd = () => {
dragRef.current = null;
setDragOver(null);
};
const onPick = () =>
useCanvasStore
.getState()
.setPickTarget({ sceneId, actionId: key, cueType: action.type });
const dot = (
<NodeDot
action={action}
onTip={setTip}
onPick={onPick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
canDrag={!isDiscussion}
/>
);
return (
<div key={key} className="relative flex h-full items-stretch">
<motion.div
initial={reduce ? false : { opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.22,
delay: reduce ? 0 : Math.min(index * 0.02, 0.24),
ease: 'easeOut',
}}
className="flex h-full flex-col items-center"
style={{ paddingTop: AXIS_FROM_TOP - 12 }}
>
{lineMode ? (
<div className="w-9">{dot}</div>
) : (
<>
{dot}
<div className="my-1 h-2.5 w-px bg-border" />
<div className="min-h-0 w-full flex-1">
{action.type === 'speech' ? (
<SpeechClip
text={(action as { text?: string }).text ?? ''}
index={si}
actionId={key}
audioId={(action as { audioId?: string }).audioId}
audioInvalidated={
(action as { audioInvalidated?: boolean }).audioInvalidated
}
sceneOrder={sceneOrder}
language={language}
ttsActive={ttsActive}
audioUrl={(action as { audioUrl?: string }).audioUrl}
ttsRefresh={ttsRefresh}
regenerating={regeneratingIds.has(key)}
autoFocus={key === focusId}
onFocused={() => setFocusId(null)}
onCommit={(text) => {
// Editing invalidates the document reference but leaves the old
// pool entry for the document-truth sweep introduced in part 3.
commit((cur) => setSpeechTextClearAudioById(cur, key, text));
setTtsRefresh((n) => n + 1);
}}
onGenerated={async (assetId) => {
commit((cur) => setAudioIdById(cur, key, assetId));
await flushStageSave();
}}
onDelete={() => {
commit((cur) => removeById(cur, key));
}}
onMoveLeft={() => commit((cur) => moveByIdDir(cur, key, -1))}
onMoveRight={() => commit((cur) => moveByIdDir(cur, key, 1))}
canMoveLeft={index > 0}
canMoveRight={index < lastMovableIndex}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
/>
) : isDiscussion ? (
<DiscussionClip
topic={(action as DiscussionAction).topic ?? ''}
prompt={(action as DiscussionAction).prompt ?? ''}
agentId={(action as DiscussionAction).agentId ?? ''}
agents={discussionAgents}
onTopicChange={(v) =>
commit((cur) => setDiscussionTopicById(cur, key, v))
}
onPromptChange={(v) =>
commit((cur) => setDiscussionPromptById(cur, key, v))
}
onAgentChange={(v) =>
commit((cur) => setDiscussionAgentById(cur, key, v))
}
onDelete={() => commit((cur) => removeById(cur, key))}
/>
) : (
<CueMarker
action={action}
elements={sceneElements}
onTip={setTip}
onDelete={() => commit((cur) => removeById(cur, key))}
onPick={onPick}
onMoveLeft={() => commit((cur) => moveByIdDir(cur, key, -1))}
onMoveRight={() => commit((cur) => moveByIdDir(cur, key, 1))}
canMoveLeft={index > 0}
canMoveRight={index < lastMovableIndex}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
/>
)}
</div>
</>
)}
</motion.div>
<DropZone
active={dragOver === index + 1}
slot={index + 1}
onEnter={() => setDragOver(index + 1)}
onDrop={() => handleDrop(index + 1)}
onInsert={(slot, rect) => setPickerAt({ slot, rect })}
insertLabel={t('edit.timeline.addAction')}
/>
</div>
);
})}
</div>
</div>
</div>
{tip && <CueTooltip tip={tip} />}
{pickerAt && (
<ActionPicker
anchor={pickerAt.rect}
sceneType={sceneType}
actions={actions}
onSelect={(type) => insertActionAt(type, pickerAt.slot)}
onClose={() => setPickerAt(null)}
/>
)}
</section>
);
}