Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 81 additions & 12 deletions packages/studio/src/components/panels/SlideshowPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,13 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
const [expandedSections, setExpandedSections] = useState<Set<SectionKey>>(
() => new Set<SectionKey>(["slides", "inspector"]),
);
// Persist failure surfacing: edits keep working in memory, but the user must
// know the file write failed (and be able to retry) — never silent data loss.
const [persistError, setPersistError] = useState(false);
const [retrying, setRetrying] = useState(false);
// In-panel undo history of manifest snapshots (discrete edits only).
const undoStackRef = useRef<SlideshowManifest[]>([]);
const [undoDepth, setUndoDepth] = useState(0);

const currentTime = usePlayerStore((s) => s.currentTime);
const { domEditSelection } = useDomEditSelectionContext();
Expand All @@ -209,26 +216,56 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
setManifest(parsed);
manifestRef.current = parsed;
setSelectedSequenceId(null);
// History belongs to one composition — a switch starts fresh.
undoStackRef.current = [];
setUndoDepth(0);
setPersistError(false);
}, [compHtml]);

/** Discrete actions (toggle, reorder, add/delete, hotspot): persist immediately. */
const applyManifest = useCallback(
async (next: SlideshowManifest) => {
async (next: SlideshowManifest, opts?: { skipUndo?: boolean }) => {
// Fold any in-flight typed notes into the discrete manifest so they are
// not silently dropped when the debounce timer would have fired later.
const merged = notesCtrlRef.current.mergeIntoDiscrete(next);
if (!opts?.skipUndo) {
undoStackRef.current.push(manifestRef.current);
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
setUndoDepth(undoStackRef.current.length);
}
setManifest(merged);
manifestRef.current = merged;
// Surface persist failures instead of swallowing them at each call site.
try {
await onPersist(merged);
setPersistError(false);
} catch (err) {
console.error("[slideshow] failed to persist manifest edit:", err);
setPersistError(true);
}
},
[onPersist],
);

const handleUndo = useCallback(() => {
const prev = undoStackRef.current.pop();
if (!prev) return;
setUndoDepth(undoStackRef.current.length);
applyManifest(prev, { skipUndo: true }).catch(() => {});
}, [applyManifest]);

const handleRetryPersist = useCallback(async () => {
setRetrying(true);
try {
await onPersist(manifestRef.current);
setPersistError(false);
} catch (err) {
console.error("[slideshow] retry persist failed:", err);
} finally {
setRetrying(false);
}
}, [onPersist]);

/**
* Notes path: update in-memory state immediately for a responsive UI, but
* debounce the disk persist to ~450 ms after the last keystroke. The pending
Expand Down Expand Up @@ -338,18 +375,10 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
[applyManifest],
);

// Confirmation is inline in BranchItem (consistent with the FileTree/assets
// delete pattern); by the time this fires the user has already confirmed.
const handleDeleteSequence = useCallback(
(id: string) => {
// Deleting a branch removes its slides and orphans any hotspot targeting it —
// confirm first to prevent accidental data loss.
const seq = (manifestRef.current.slideSequences ?? []).find((s) => s.id === id);
const count = seq?.slides.length ?? 0;
const label = seq?.label ?? id;
const ok = window.confirm(
`Delete branch "${label}"${count ? ` and its ${count} slide${count === 1 ? "" : "s"}` : ""}? ` +
`Hotspots pointing to it will no longer resolve.`,
);
if (!ok) return;
applyManifest(deleteSequence(manifestRef.current, id)).catch(() => {});
},
[applyManifest],
Expand Down Expand Up @@ -383,7 +412,47 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
);

return (
<div className="flex flex-col h-full overflow-y-auto text-white">
<div
className="flex flex-col h-full overflow-y-auto text-white"
onKeyDown={(e) => {
// In-panel undo — scoped so it never fights the app-level file undo.
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "z") {
const target = e.target instanceof HTMLElement ? e.target.tagName : "";
if (target === "TEXTAREA" || target === "INPUT") return;
e.preventDefault();
e.stopPropagation();
handleUndo();
}
}}
>
{persistError && (
<div
role="alert"
className="flex items-center justify-between gap-2 px-3 py-2 bg-red-950/40 border-b border-red-500/40"
>
<span className="text-[11px] text-red-300">Changes not saved</span>
<button
type="button"
disabled={retrying}
onClick={handleRetryPersist}
className="px-2 py-0.5 text-[10px] rounded bg-red-600 text-white enabled:hover:bg-red-500 enabled:active:scale-[0.97] disabled:opacity-50 transition-colors"
>
{retrying ? "Retrying…" : "Retry"}
</button>
</div>
)}
{undoDepth > 0 && (
<div className="flex items-center justify-end px-3 py-1 border-b border-neutral-800/60">
<button
type="button"
onClick={handleUndo}
title="Undo last slideshow edit (⌘Z)"
className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 active:scale-[0.97] transition-colors"
>
Undo ({undoDepth})
</button>
</div>
)}
<SectionHeader
expanded={expandedSections.has("slides")}
onToggle={() => toggleSection("slides")}
Expand Down
68 changes: 61 additions & 7 deletions packages/studio/src/components/panels/SlideshowSubPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function SectionHeader({
return (
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-[11px] font-medium text-neutral-400 hover:text-neutral-200 border-b border-neutral-800 transition-colors"
className="flex w-full items-center justify-between px-3 py-2 text-[11px] font-medium text-neutral-400 hover:text-neutral-200 active:bg-neutral-800/60 border-b border-neutral-800 transition-colors"
onClick={onToggle}
aria-expanded={expanded}
>
Expand Down Expand Up @@ -64,6 +64,7 @@ export function SlideList({
{rows.map((scene) => {
const isSlide = slideIds.has(scene.id);
const isSelected = selectedSceneId === scene.id;
const slideIndex = slides.findIndex((s) => s.sceneId === scene.id);
return (
<div
key={scene.id}
Expand Down Expand Up @@ -98,7 +99,8 @@ export function SlideList({
type="button"
aria-label="Move slide up"
title="Move up"
className="px-1 py-0.5 text-[10px] text-neutral-400 hover:text-white disabled:opacity-30"
disabled={slideIndex <= 0}
className="px-1 py-0.5 text-[10px] text-neutral-400 enabled:hover:text-white enabled:active:scale-[0.95] disabled:opacity-30 disabled:cursor-not-allowed"
onClick={(e) => {
e.stopPropagation();
onReorder(scene.id, "up");
Expand All @@ -110,7 +112,8 @@ export function SlideList({
type="button"
aria-label="Move slide down"
title="Move down"
className="px-1 py-0.5 text-[10px] text-neutral-400 hover:text-white disabled:opacity-30"
disabled={slideIndex === slides.length - 1}
className="px-1 py-0.5 text-[10px] text-neutral-400 enabled:hover:text-white enabled:active:scale-[0.95] disabled:opacity-30 disabled:cursor-not-allowed"
onClick={(e) => {
e.stopPropagation();
onReorder(scene.id, "down");
Expand Down Expand Up @@ -310,6 +313,7 @@ function BranchItem({
}: BranchItemProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(seq.label);
const [confirmingDelete, setConfirmingDelete] = useState(false);

const commitRename = useCallback(() => {
const label = draft.trim();
Expand Down Expand Up @@ -341,7 +345,10 @@ function BranchItem({
title="Click to rename"
onClick={() => setEditing(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setEditing(true);
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEditing(true);
}
}}
>
{seq.label}
Expand All @@ -350,12 +357,42 @@ function BranchItem({
<button
type="button"
aria-label={`Delete branch ${seq.label}`}
className="text-[10px] text-neutral-500 hover:text-red-400 transition-colors px-1"
onClick={() => onDelete(seq.id)}
className="text-[10px] text-neutral-500 hover:text-red-400 active:scale-[0.95] transition-colors px-1"
onClick={() => setConfirmingDelete(true)}
>
</button>
</div>
{confirmingDelete && (
<div className="px-2 py-1.5 bg-red-950/30 border-l-2 border-red-500 flex flex-col gap-1 rounded-sm">
<span className="text-[10px] text-red-400">
Delete branch &ldquo;{seq.label}&rdquo;
{seq.slides.length > 0
? ` and its ${seq.slides.length} slide${seq.slides.length === 1 ? "" : "s"}`
: ""}
? Hotspots pointing to it will no longer resolve.
</span>
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => {
setConfirmingDelete(false);
onDelete(seq.id);
}}
className="px-2 py-0.5 text-[10px] rounded bg-red-600 text-white hover:bg-red-500 active:bg-red-700 transition-colors"
>
Delete
</button>
<button
type="button"
onClick={() => setConfirmingDelete(false)}
className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
<div className="flex flex-col gap-px pl-2">
{scenes.map((scene) => {
const assigned = seq.slides.some((s) => s.sceneId === scene.id);
Expand Down Expand Up @@ -447,6 +484,16 @@ export function HotspotTool({
Selected element:{" "}
<span className="text-neutral-200 font-mono">{elementKey ?? "none"}</span>
</p>
{!elementKey && (
<p className="text-[10px] text-neutral-500 italic">
Click an element on the canvas to choose the hotspot target.
</p>
)}
{sequences.length === 0 && (
<p className="text-[10px] text-neutral-500 italic">
Create a branch in the Branches section first — hotspots jump to a branch.
</p>
)}
<label className="text-[11px] text-neutral-400">Hotspot label</label>
<input
type="text"
Expand All @@ -473,7 +520,14 @@ export function HotspotTool({
<button
type="button"
disabled={!elementKey || !targetSequenceId}
className="px-3 py-1.5 rounded bg-studio-accent/80 hover:bg-studio-accent text-white text-[11px] font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title={
!elementKey
? "Select an element on the canvas first"
: !targetSequenceId
? "Choose a target branch first"
: undefined
}
className="px-3 py-1.5 rounded bg-studio-accent/80 enabled:hover:bg-studio-accent enabled:active:scale-[0.98] text-white text-[11px] font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={handleMakeHotspot}
>
Make hotspot
Expand Down
4 changes: 2 additions & 2 deletions packages/studio/src/components/sidebar/AssetCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe("AssetCard click behavior", () => {
const cardProps = {
projectId: "p1",
onCopy: vi.fn(),
isCopied: false,
copyFeedback: null,
};

it("clears an open preview overlay when clicking an already-added asset (reveal branch)", () => {
Expand Down Expand Up @@ -81,7 +81,7 @@ describe("AudioRow click behavior", () => {
const rowProps = {
projectId: "p1",
onCopy: vi.fn(),
isCopied: false,
copyFeedback: null,
};

it("clears an open preview overlay when clicking an already-added audio asset (reveal branch)", () => {
Expand Down
Loading
Loading