Files
hyperframes/packages/studio/src/components/panels/slideshowPanelHelpers.ts
T
Vance Ingalls f05b3f9c7c fix(slideshow): finish remaining split-PR review findings (#1594)
* fix(slideshow): address split-PR review findings on #1585

Genuinely-open findings from the #1580/#1590/#1591/#1592 reviews (the rest were
already fixed on this branch: CSP handlers, manifest version, UUID ids, float
keys, presenter 1s-timer):

core (#1580):
- isManifest rejects a non-object/array manifest (e.g. [42,null]) explicitly
- resolveSlideshow flags duplicate slideSequence ids instead of silent overwrite

player (#1590):
- present() window.open uses noopener,noreferrer (audience syncs via channel)
- BroadcastChannel name is per-deck (keyed on pathname) to avoid same-origin
  cross-talk between decks
- add observedAttributes + attributeChangedCallback so runtime sound/mode toggles
  re-render

studio (#1591/#1592):
- persistSlideshowManifest no-op gate (skip write when HTML is unchanged)
- surface persist failures (console.error) instead of silent .catch(()=>{})
- confirm before deleting a branch sequence (data-loss + dangling hotspots)

+ tests for the collision + non-object-manifest rejection. 20 core / 106 player /
53 studio pass; tsc/lint/fmt/fallow clean; deck still renders.

* fix(slideshow): finish remaining split-PR review findings

The larger items from the #1580/#1590/#1591/#1592 reviews (the rest landed in
#1585):

core (#1580):
- dedup isSceneLikeCompositionId — shared slideshow/sceneId.ts, used by both the
  lint rule and the runtime scene-window computation (no more mirror-and-drift)

player (#1590 / #1592):
- onKey: when multiple decks share a page, drop the unfocused-convenience so a
  key drives only the focused deck
- slow-iframe recovery: if the scene timeline posts after the wait times out
  (empty scenes), re-init once so sceneId slides resolve instead of being dropped

studio (#1591):
- persistSlideshowManifest validates the built island round-trips before writing
- reorderBranchSlide helper + BranchTree up/down controls (parallel to main-line
  reorder), with a branch-position indicator

+ tests for reorderBranchSlide. core 228 / player 106 / studio (panel) 46 pass;
tsc/lint/fmt/fallow clean.

* fix(player,cli): use fileURLToPath for path resolution (Windows CI)

new URL(...).pathname yields a leading-slash drive path ("/D:/...") on Windows,
which broke:
- packages/player/vitest.config.ts — the @hyperframes/core/slideshow alias
  resolved to a nonexistent path, failing the player slideshow tests on the
  Windows render-verification CI (passed on macOS/Linux where pathname is clean)
- packages/cli/src/utils/compositionServer.ts helperDir — same bug in the
  play/present bundle-path resolution

fileURLToPath converts file:// URLs to correct OS paths on all platforms.
Player slideshow tests pass; present serves + resolves bundles.

* fix(producer): fileURLToPath for the renders dir (Windows)

DEFAULT_RENDERS_DIR used new URL(import.meta.url).pathname, which is "/D:/..."
on Windows and resolves to a bogus path — affects the Windows render pipeline.
Last of the .pathname -> fileURLToPath fixes (repo-wide src sweep now clean).
2026-06-19 04:31:12 -07:00

233 lines
7.2 KiB
TypeScript

/**
* Pure manifest-transform helpers for SlideshowPanel.
* No React, no side-effects — fully unit-testable.
*/
import type { SlideshowManifest, SlideRef, SlideHotspot } from "@hyperframes/core/slideshow";
// ── Scene shape used by the panel UI ──────────────────────────────────────
export interface SceneInfo {
id: string;
label: string;
start: number;
duration: number;
}
// ── Pure manifest transforms ───────────────────────────────────────────────
/** Toggle a scene in the main-line slide list. */
export function toggleMainLineSlide(
manifest: SlideshowManifest,
sceneId: string,
): SlideshowManifest {
const exists = manifest.slides.some((s) => s.sceneId === sceneId);
const slides: SlideRef[] = exists
? manifest.slides.filter((s) => s.sceneId !== sceneId)
: [...manifest.slides, { sceneId }];
return { ...manifest, slides };
}
// fallow-ignore-next-line complexity
/** Move a main-line slide up or down by one position. */
/** Swap the slide with `sceneId` one step up/down within a slide list. */
function swapSlide(slides: SlideRef[], sceneId: string, direction: "up" | "down"): SlideRef[] {
const idx = slides.findIndex((s) => s.sceneId === sceneId);
if (idx === -1) return slides;
const next = direction === "up" ? idx - 1 : idx + 1;
if (next < 0 || next >= slides.length) return slides;
const out = [...slides];
const a = out[idx];
const b = out[next];
if (!a || !b) return slides;
out[idx] = b;
out[next] = a;
return out;
}
export function reorderMainLineSlide(
manifest: SlideshowManifest,
sceneId: string,
direction: "up" | "down",
): SlideshowManifest {
return mapSlidesIn(manifest, undefined, (slides) => swapSlide(slides, sceneId, direction));
}
/** Reorder a slide within a branch sequence (parallel to reorderMainLineSlide). */
export function reorderBranchSlide(
manifest: SlideshowManifest,
sequenceId: string,
sceneId: string,
direction: "up" | "down",
): SlideshowManifest {
return mapSlidesIn(manifest, sequenceId, (slides) => swapSlide(slides, sceneId, direction));
}
/** Apply fn to a branch's slide list (sequenceId) or the main line (undefined). */
function mapSlidesIn(
manifest: SlideshowManifest,
sequenceId: string | undefined,
fn: (slides: SlideRef[]) => SlideRef[],
): SlideshowManifest {
if (sequenceId === undefined) {
return { ...manifest, slides: fn(manifest.slides) };
}
return {
...manifest,
slideSequences: (manifest.slideSequences ?? []).map((seq) =>
seq.id === sequenceId ? { ...seq, slides: fn(seq.slides) } : seq,
),
};
}
/** Update notes on a main-line slide (adds slide entry if absent). */
export function setSlideNotes(
manifest: SlideshowManifest,
sceneId: string,
notes: string,
sequenceId?: string,
): SlideshowManifest {
return mapSlidesIn(manifest, sequenceId, (slides) => {
const exists = slides.some((s) => s.sceneId === sceneId);
if (exists) return slides.map((s) => (s.sceneId === sceneId ? { ...s, notes } : s));
return sequenceId === undefined ? [...slides, { sceneId, notes }] : slides;
});
}
/** Push a fragment hold-point time onto a main-line slide. Deduplicates + sorts. */
export function addFragment(
manifest: SlideshowManifest,
sceneId: string,
time: number,
sequenceId?: string,
): SlideshowManifest {
return mapSlidesIn(manifest, sequenceId, (slides) => {
const exists = slides.some((s) => s.sceneId === sceneId);
if (exists)
return slides.map((s) => {
if (s.sceneId !== sceneId) return s;
const frags = [...new Set([...(s.fragments ?? []), time])].sort((a, b) => a - b);
return { ...s, fragments: frags };
});
return sequenceId === undefined ? [...slides, { sceneId, fragments: [time] }] : slides;
});
}
/** Remove a fragment hold-point by value from a main-line slide. */
export function removeFragment(
manifest: SlideshowManifest,
sceneId: string,
time: number,
sequenceId?: string,
): SlideshowManifest {
return mapSlidesIn(manifest, sequenceId, (slides) =>
slides.map((s) =>
s.sceneId === sceneId
? { ...s, fragments: (s.fragments ?? []).filter((f) => f !== time) }
: s,
),
);
}
/** Create a new branch sequence. Rejects duplicate ids. */
export function createSequence(
manifest: SlideshowManifest,
id: string,
label: string,
): SlideshowManifest {
const existing = manifest.slideSequences ?? [];
if (existing.some((seq) => seq.id === id)) return manifest;
return {
...manifest,
slideSequences: [...existing, { id, label, slides: [] }],
};
}
/** Rename an existing branch sequence label. */
export function renameSequence(
manifest: SlideshowManifest,
id: string,
label: string,
): SlideshowManifest {
return {
...manifest,
slideSequences: (manifest.slideSequences ?? []).map((seq) =>
seq.id === id ? { ...seq, label } : seq,
),
};
}
function pruneHotspots(slides: SlideRef[], targetId: string): SlideRef[] {
return slides.map((s) => {
if (!s.hotspots) return s;
const hotspots = s.hotspots.filter((h) => h.target !== targetId);
return hotspots.length === s.hotspots.length ? s : { ...s, hotspots };
});
}
/** Delete a branch sequence by id, removing any hotspot targeting it. */
export function deleteSequence(manifest: SlideshowManifest, id: string): SlideshowManifest {
const remainingSequences = (manifest.slideSequences ?? []).filter((seq) => seq.id !== id);
return {
...manifest,
slides: pruneHotspots(manifest.slides, id),
slideSequences: remainingSequences.map((seq) => ({
...seq,
slides: pruneHotspots(seq.slides, id),
})),
};
}
/** Add or remove a scene slide from a branch sequence. */
export function assignToBranch(
manifest: SlideshowManifest,
sequenceId: string,
sceneId: string,
assign: boolean,
): SlideshowManifest {
return {
...manifest,
slideSequences: (manifest.slideSequences ?? []).map((seq) => {
if (seq.id !== sequenceId) return seq;
if (assign) {
if (seq.slides.some((s) => s.sceneId === sceneId)) return seq;
return { ...seq, slides: [...seq.slides, { sceneId }] };
}
return { ...seq, slides: seq.slides.filter((s) => s.sceneId !== sceneId) };
}),
};
}
/** Add a hotspot to a main-line slide. */
export function addHotspot(
manifest: SlideshowManifest,
sceneId: string,
hotspot: SlideHotspot,
sequenceId?: string,
): SlideshowManifest {
return mapSlidesIn(manifest, sequenceId, (slides) =>
slides.map((s) => {
if (s.sceneId !== sceneId) return s;
const existing = s.hotspots ?? [];
if (existing.some((h) => h.id === hotspot.id)) return s;
return { ...s, hotspots: [...existing, hotspot] };
}),
);
}
/** Remove a hotspot by id from a main-line slide. */
export function removeHotspot(
manifest: SlideshowManifest,
sceneId: string,
hotspotId: string,
sequenceId?: string,
): SlideshowManifest {
return mapSlidesIn(manifest, sequenceId, (slides) =>
slides.map((s) =>
s.sceneId === sceneId
? { ...s, hotspots: (s.hotspots ?? []).filter((h) => h.id !== hotspotId) }
: s,
),
);
}