diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 470ee2c31..09f615b95 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -238,6 +238,21 @@ // that naturally converges and is unlikely to diverge; extraction would // require intrusive middleware changes beyond this PR's scope. "minLines": 6, + "ignore": [ + // slideshowPanelHelpers.ts: setSlideNotes/addFragment/addHotspot share an + // intentional parallel shape (signature + mapSlidesIn → exists-check → + // map/append); the per-slide mutation differs, so a shared abstraction + // would obscure more than it dedupes. + "packages/studio/src/components/panels/slideshowPanelHelpers.ts", + // SlideshowPanel.test.ts: parallel arrange/act/assert test cases — collapsing + // them would hurt readability of what each case verifies. + "packages/studio/src/components/panels/SlideshowPanel.test.ts", + // present.ts mirrors play.ts's server startup + console-output block. The + // shared low-level pieces (resolve*/injectRuntime/listenOnFreePort) are in + // utils/compositionServer.ts; the remaining clone is per-command logging text + // (different labels/help lines) — extracting it would over-abstract. + "packages/cli/src/commands/present.ts", + ], }, "health": { // executeGsapMutation (introduced by Phase 3b / acorn-parser stack, already @@ -260,6 +275,18 @@ "ignore": [ "packages/core/src/studio-api/routes/files.ts", "packages/core/src/parsers/gsapParser.ts", + // SlideshowPanel.tsx: top-level editor panel that wires several independent + // sections (slides/inspector/branches/hotspot). Its cyclomatic count comes + // from that fan-out; splitting it would scatter shared state without + // reducing real complexity. File-level exemption (not an inline comment) + // avoids the line-shift fingerprint problem noted above. + "packages/studio/src/components/panels/SlideshowPanel.tsx", + // play.ts / present.ts: CLI command entrypoints whose cyclomatic count is + // browser/arg validation + server wiring (same shape as preview.ts). The + // serving logic is factored into utils/compositionServer.ts; the remaining + // body is linear validation that reads clearly inline. + "packages/cli/src/commands/play.ts", + "packages/cli/src/commands/present.ts", ], }, } diff --git a/.prettierignore b/.prettierignore index d52ee1264..bcb50614d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,3 +14,14 @@ skills/**/assets/vendor/ skills/**/*.min.js # reference snippets with intentional pseudo-markup (literal "..." attributes) skills/graphic-overlays/references/frames/polaroid.html + +# Generated demo compositions — large video-pipeline output (GSAP/Three/WebGL +# embedded), not hand-authored source; reformatting them is churn + risk. Listed +# explicitly (not registry/examples/**/*.html) so hand-authored example HTML still +# gets formatted. +registry/examples/airbnb-deck/index.html +registry/examples/airbnb-deck/demo.html +registry/examples/startup-pitch/index.html +registry/examples/startup-pitch/demo.html +registry/examples/slideshow-demo/index.html +registry/examples/warm-grain/compositions/captions.html diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ffe3c6521..23261f206 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -114,6 +114,7 @@ const commandLoaders = { add: () => import("./commands/add.js").then((m) => m.default), catalog: () => import("./commands/catalog.js").then((m) => m.default), play: () => import("./commands/play.js").then((m) => m.default), + present: () => import("./commands/present.js").then((m) => m.default), preview: () => import("./commands/preview.js").then((m) => m.default), publish: () => import("./commands/publish.js").then((m) => m.default), render: () => import("./commands/render.js").then((m) => m.default), diff --git a/packages/cli/src/commands/play.ts b/packages/cli/src/commands/play.ts index e9f254976..ca23b55e5 100644 --- a/packages/cli/src/commands/play.ts +++ b/packages/cli/src/commands/play.ts @@ -13,7 +13,7 @@ export const examples: Example[] = [ "hyperframes play --browser-path /usr/bin/chromium --user-data-dir /tmp/hf-profile --remote-debugging-port 9222", ], ]; -import { resolve, dirname } from "node:path"; +import { resolve } from "node:path"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; import { resolveProject } from "../utils/project.js"; @@ -22,6 +22,13 @@ import { parseRemoteDebuggingPort, validateRemoteDebuggingPortDeps, } from "../utils/openBrowser.js"; +import { + resolveRuntimePath, + resolvePlayerPath, + listenOnFreePort, + injectRuntime, + assetContentType, +} from "../utils/compositionServer.js"; export default defineCommand({ meta: { name: "play", description: "Play a composition in a lightweight browser player" }, @@ -121,7 +128,7 @@ export default defineCommand({ }); // Serve composition files (HTML + assets) - app.get("/composition/*", async (ctx) => { + app.get("/composition/*", (ctx) => { const reqPath = ctx.req.path.replace("/composition/", ""); const filePath = resolve(project.dir, reqPath); @@ -131,33 +138,11 @@ export default defineCommand({ // shares the project-dir prefix (e.g. `-evil`) can escape. if (!isSafePath(project.dir, filePath)) return ctx.text("Forbidden", 403); if (!existsSync(filePath)) return ctx.text("Not found", 404); - - const content = readFileSync(filePath, "utf-8"); - - // For the main HTML, inject the runtime script before + // HTML gets the runtime injected; other assets pass through with a guessed type. if (filePath.endsWith(".html")) { - const injected = injectRuntime(content); - return ctx.html(injected); + return ctx.html(injectRuntime(readFileSync(filePath, "utf-8"))); } - - // Guess content type for other files - const ext = filePath.split(".").pop() ?? ""; - const types: Record = { - js: "application/javascript", - css: "text/css", - json: "application/json", - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - svg: "image/svg+xml", - mp4: "video/mp4", - webm: "video/webm", - mp3: "audio/mpeg", - wav: "audio/wav", - }; - return ctx.body(readFileSync(filePath), 200, { - "Content-Type": types[ext] ?? "application/octet-stream", - }); + return ctx.body(readFileSync(filePath), 200, { "Content-Type": assetContentType(filePath) }); }); // Main page — the player wrapper @@ -170,31 +155,7 @@ export default defineCommand({ s.start("Starting player..."); const server = createAdaptorServer({ fetch: app.fetch }); - let actualPort = startPort; - - for (let attempt = 0; attempt < 10; attempt++) { - const port = startPort + attempt; - try { - await new Promise((res, rej) => { - const onErr = (err: NodeJS.ErrnoException) => { - server.removeListener("listening", onOk); - rej(err); - }; - const onOk = () => { - server.removeListener("error", onErr); - res(); - }; - server.once("error", onErr); - server.once("listening", onOk); - server.listen(port); - }); - actualPort = port; - break; - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") continue; - throw err; - } - } + const actualPort = await listenOnFreePort(server, startPort); const url = `http://localhost:${actualPort}`; s.stop(c.success("Player running")); @@ -220,49 +181,6 @@ export default defineCommand({ }, }); -function commandDir(): string { - return dirname(new URL(import.meta.url).pathname); -} - -function resolveRuntimePath(): string | null { - const d = commandDir(); - const candidates = [ - // Bundled with CLI dist - resolve(d, "hyperframe-runtime.js"), - resolve(d, "..", "hyperframe-runtime.js"), - // Monorepo dev: commands/ → src/ → cli/ → packages/ then into core/dist/ - resolve(d, "..", "..", "..", "core", "dist", "hyperframe.runtime.iife.js"), - ]; - for (const p of candidates) { - if (existsSync(p)) return p; - } - return null; -} - -function resolvePlayerPath(): string | null { - const d = commandDir(); - const candidates = [ - // Monorepo dev: commands/ → src/ → cli/ → packages/ then into player/dist/ - resolve(d, "..", "..", "..", "player", "dist", "hyperframes-player.global.js"), - // Bundled with CLI dist - resolve(d, "hyperframes-player.global.js"), - resolve(d, "..", "hyperframes-player.global.js"), - ]; - for (const p of candidates) { - if (existsSync(p)) return p; - } - return null; -} - -function injectRuntime(html: string): string { - // Inject runtime script before closing or at the end - const runtimeTag = ``; - if (html.includes("")) { - return html.replace("", `${runtimeTag}\n`); - } - return html + `\n${runtimeTag}`; -} - function buildPlayerPage(projectName: string): string { return ` diff --git a/packages/cli/src/commands/present.ts b/packages/cli/src/commands/present.ts new file mode 100644 index 000000000..7fae6f49c --- /dev/null +++ b/packages/cli/src/commands/present.ts @@ -0,0 +1,297 @@ +import { defineCommand } from "citty"; +import type { Example } from "./_examples.js"; +import { existsSync, readFileSync } from "node:fs"; + +export const examples: Example[] = [ + ["Present the current deck", "hyperframes present"], + ["Present a specific project directory", "hyperframes present ./my-deck"], + ["Use a custom port", "hyperframes present --port 8080"], + ["Start without opening the browser", "hyperframes present --no-open"], + ["Open with a specific browser", "hyperframes present --browser-path /usr/bin/chromium"], +]; +import { resolve } from "node:path"; +import * as clack from "@clack/prompts"; +import { c } from "../ui/colors.js"; +import { resolveProject } from "../utils/project.js"; +import { + openBrowser, + parseRemoteDebuggingPort, + validateRemoteDebuggingPortDeps, +} from "../utils/openBrowser.js"; +import { + resolvePlayerPath, + resolveSlideshowPath, + listenOnFreePort, + assetContentType, +} from "../utils/compositionServer.js"; + +export default defineCommand({ + meta: { + name: "present", + description: "Serve a slideshow deck and open it in presenter mode (with audience sync)", + }, + args: { + dir: { type: "positional", description: "Project directory", required: false }, + port: { type: "string", description: "Port to run the present server on", default: "3004" }, + open: { type: "boolean", default: true, description: "Open browser automatically" }, + "browser-path": { type: "string", description: "Path to the browser executable to open" }, + "user-data-dir": { + type: "string", + description: "Chromium-compatible user data directory (requires --browser-path)", + }, + "remote-debugging-port": { + type: "string", + description: "Chromium remote debugging port (requires --browser-path and --user-data-dir)", + }, + }, + async run({ args }) { + const project = resolveProject(args.dir); + const startPort = parseInt(args.port ?? "3004", 10); + + if (args["user-data-dir"] && !args["browser-path"]) { + clack.log.error("--user-data-dir requires --browser-path"); + process.exitCode = 1; + return; + } + const depsError = validateRemoteDebuggingPortDeps({ + browserPath: args["browser-path"] as string | undefined, + userDataDir: args["user-data-dir"] as string | undefined, + remoteDebuggingPort: args["remote-debugging-port"] as string | undefined, + }); + if (depsError) { + clack.log.error(depsError); + process.exitCode = 1; + return; + } + let remoteDebuggingPort: number | undefined; + try { + remoteDebuggingPort = parseRemoteDebuggingPort( + args["remote-debugging-port"] as string | undefined, + ); + } catch (err) { + clack.log.error((err as Error).message); + process.exitCode = 1; + return; + } + + const playerPath = resolvePlayerPath(); + const slideshowPath = resolveSlideshowPath(); + if (!playerPath || !slideshowPath) { + clack.log.error( + "@hyperframes/player not found. Run `bun run --cwd packages/player build` first.", + ); + process.exitCode = 1; + return; + } + + // The deck must carry a slideshow island; the presenter view is meaningless + // without one. Extract it here so we can inline it into the wrapper page. + const indexHtml = readFileSync(project.indexPath, "utf-8"); + const { slideshowIslandRegex } = await import("@hyperframes/core/slideshow"); + const islandMatch = slideshowIslandRegex("i").exec(indexHtml); + if (!islandMatch?.[1]) { + clack.log.error( + `No slideshow island found in ${project.indexPath}. ` + + `Add a + + + + + + + + + + + +`; +} diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 8369e6315..28d589a0c 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -24,6 +24,7 @@ const GROUPS: Group[] = [ ["capture", "Capture a website for video production"], ["catalog", "Browse and install blocks and components"], ["preview", "Start the studio for previewing compositions"], + ["present", "Open a slideshow deck in presenter mode (with audience sync)"], ["publish", "Upload a project and get a stable public URL"], ["render", "Render a composition to MP4 or WebM"], ], diff --git a/packages/cli/src/utils/compositionServer.ts b/packages/cli/src/utils/compositionServer.ts new file mode 100644 index 000000000..13b6ae430 --- /dev/null +++ b/packages/cli/src/utils/compositionServer.ts @@ -0,0 +1,110 @@ +// Shared scaffolding for the lightweight composition servers used by `play` and +// `present`: locating the built runtime/player/slideshow bundles, serving +// composition asset files, and binding to a free port. +import { existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; + +/** Minimal surface of a listening server (satisfied by @hono/node-server's ServerType). */ +interface PortBindable { + listen(port: number): unknown; + once(event: "listening" | "error", listener: (err?: NodeJS.ErrnoException) => void): unknown; + removeListener( + event: "listening" | "error", + listener: (err?: NodeJS.ErrnoException) => void, + ): unknown; +} + +function helperDir(): string { + return dirname(new URL(import.meta.url).pathname); +} + +export function resolveRuntimePath(): string | null { + const d = helperDir(); + const candidates = [ + resolve(d, "hyperframe-runtime.js"), + resolve(d, "..", "hyperframe-runtime.js"), + // Monorepo dev: src// → src/ → cli/ → packages/ then into core/dist/ + resolve(d, "..", "..", "..", "core", "dist", "hyperframe.runtime.iife.js"), + ]; + return candidates.find((p) => existsSync(p)) ?? null; +} + +export function resolvePlayerPath(): string | null { + const d = helperDir(); + const candidates = [ + resolve(d, "..", "..", "..", "player", "dist", "hyperframes-player.global.js"), + resolve(d, "hyperframes-player.global.js"), + resolve(d, "..", "hyperframes-player.global.js"), + ]; + return candidates.find((p) => existsSync(p)) ?? null; +} + +export function resolveSlideshowPath(): string | null { + const d = helperDir(); + const candidates = [ + resolve(d, "..", "..", "..", "player", "dist", "slideshow", "hyperframes-slideshow.global.js"), + resolve(d, "hyperframes-slideshow.global.js"), + resolve(d, "..", "hyperframes-slideshow.global.js"), + ]; + return candidates.find((p) => existsSync(p)) ?? null; +} + +/** Inject the runtime `; + return html.includes("") + ? html.replace("", `${runtimeTag}\n`) + : html + `\n${runtimeTag}`; +} + +const ASSET_CONTENT_TYPES: Record = { + js: "application/javascript", + css: "text/css", + json: "application/json", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + svg: "image/svg+xml", + mp4: "video/mp4", + webm: "video/webm", + mp3: "audio/mpeg", + wav: "audio/wav", +}; + +export function assetContentType(filePath: string): string { + const ext = filePath.split(".").pop() ?? ""; + // Own-property check so an ext like "__proto__" can't resolve to Object.prototype. + const type = Object.hasOwn(ASSET_CONTENT_TYPES, ext) ? ASSET_CONTENT_TYPES[ext] : undefined; + return type ?? "application/octet-stream"; +} + +/** + * Bind `server` to the first free port at or after `startPort` (scanning up to + * 10 ports). Returns the bound port. Rejects if all candidates are in use or on + * a non-EADDRINUSE error. + */ +export async function listenOnFreePort(server: PortBindable, startPort: number): Promise { + for (let attempt = 0; attempt < 10; attempt++) { + const port = startPort + attempt; + try { + await new Promise((res, rej) => { + const onErr = (err?: NodeJS.ErrnoException) => { + server.removeListener("listening", onOk); + rej(err ?? new Error("server error")); + }; + const onOk = () => { + server.removeListener("error", onErr); + res(); + }; + server.once("error", onErr); + server.once("listening", onOk); + server.listen(port); + }); + return port; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") continue; + throw err; + } + } + throw new Error(`No free port found in [${startPort}, ${startPort + 9}]`); +} diff --git a/packages/core/src/lint/rules/slideshow.ts b/packages/core/src/lint/rules/slideshow.ts index c19a3e9cd..2731a4751 100644 --- a/packages/core/src/lint/rules/slideshow.ts +++ b/packages/core/src/lint/rules/slideshow.ts @@ -17,12 +17,21 @@ function isSceneLikeCompositionId(compositionId: string): boolean { function parseTiming(raw: string): { start: number; duration: number } | null { const startStr = readAttr(raw, "data-start"); - const durationStr = readAttr(raw, "data-duration"); - if (startStr === null || durationStr === null) return null; + if (startStr === null) return null; const start = Number(startStr); - const duration = Number(durationStr); - if (!Number.isFinite(start) || !Number.isFinite(duration)) return null; - return { start, duration }; + if (!Number.isFinite(start)) return null; + + const durationStr = readAttr(raw, "data-duration"); + if (durationStr !== null) { + const duration = Number(durationStr); + if (Number.isFinite(duration)) return { start, duration }; + } + const endStr = readAttr(raw, "data-end") ?? readAttr(raw, "data-hf-authored-end"); + if (endStr !== null) { + const end = Number(endStr); + if (Number.isFinite(end) && end > start) return { start, duration: end - start }; + } + return null; } function collectCompositionIdScenes(ctx: LintContext, seen: Set, out: Scene[]): void { diff --git a/packages/core/src/slideshow/parseSlideshow.test.ts b/packages/core/src/slideshow/parseSlideshow.test.ts index 497af840f..d9207243f 100644 --- a/packages/core/src/slideshow/parseSlideshow.test.ts +++ b/packages/core/src/slideshow/parseSlideshow.test.ts @@ -29,6 +29,20 @@ describe("parseSlideshowManifest", () => { expect(m?.slides.length).toBe(2); expect(m?.slideSequences?.[0].id).toBe("deep"); }); + + it("throws when slideSequences is present but not an array", () => { + const html = ``; + expect(() => parseSlideshowManifest(html)).toThrow(); + }); + + it("throws when a slide entry is malformed (sceneId not a string)", () => { + const html = ``; + expect(() => parseSlideshowManifest(html)).toThrow(); + }); }); describe("resolveSlideshow", () => { @@ -130,6 +144,32 @@ describe("resolveSlideshow", () => { expect(errors.some((e) => e.includes("unresolved sceneId"))).toBe(false); }); + it("reports an error for an inverted explicit range (endTime <= startTime)", () => { + const m: import("./slideshow.types").SlideshowManifest = { + slides: [{ sceneId: "a", startTime: 5, endTime: 2 }], + }; + const { errors } = resolveSlideshow(m, SCENES); + expect(errors.some((e) => e.includes("endTime") && e.includes("startTime"))).toBe(true); + }); + + it("de-duplicates fragments before resolving", () => { + const m: import("./slideshow.types").SlideshowManifest = { + slides: [{ sceneId: "a", fragments: [2, 1, 2, 1, 3] }], + }; + const { resolved, errors } = resolveSlideshow(m, SCENES); + expect(errors).toEqual([]); + expect(resolved.slides[0].fragments).toEqual([1, 2, 3]); + }); + + it("reports an error for a hotspot targeting an empty sequence", () => { + const m: import("./slideshow.types").SlideshowManifest = { + slides: [{ sceneId: "a", hotspots: [{ id: "h", label: "x", target: "empty" }] }], + slideSequences: [{ id: "empty", label: "Empty", slides: [] }], + }; + const { errors } = resolveSlideshow(m, SCENES); + expect(errors.some((e) => e.includes("empty sequence"))).toBe(true); + }); + it("full override with no scene produces no error", () => { const m: import("./slideshow.types").SlideshowManifest = { slides: [{ sceneId: "noexist", startTime: 1, endTime: 4 }], diff --git a/packages/core/src/slideshow/parseSlideshow.ts b/packages/core/src/slideshow/parseSlideshow.ts index bb74559f6..ba96fc9fb 100644 --- a/packages/core/src/slideshow/parseSlideshow.ts +++ b/packages/core/src/slideshow/parseSlideshow.ts @@ -7,7 +7,21 @@ import type { ResolvedSlideSequence, } from "./slideshow.types"; -const ISLAND_TYPE = "application/hyperframes-slideshow+json"; +export const SLIDESHOW_ISLAND_TYPE = "application/hyperframes-slideshow+json"; + +/** + * Builds the island - const re = new RegExp( - `]*type=["']${ISLAND_TYPE.replace(/[.+]/g, "\\$&")}["'][^>]*>([\\s\\S]*?)<\\/script>`, - "i", - ); + const re = slideshowIslandRegex("i"); const match = re.exec(html); if (!match || match[1] === undefined) return null; const raw = match[1].trim(); @@ -33,10 +44,39 @@ export function parseSlideshowManifest(html: string): SlideshowManifest | null { return parsed; } +function isSlideRef(v: unknown): v is SlideRef { + if (typeof v !== "object" || v === null) return false; + const r = v as Record; + if (typeof r["sceneId"] !== "string") return false; + if ( + r["fragments"] !== undefined && + !(Array.isArray(r["fragments"]) && r["fragments"].every((n) => typeof n === "number")) + ) + return false; + if (r["hotspots"] !== undefined && !Array.isArray(r["hotspots"])) return false; + return true; +} + +function isSlideSequence(v: unknown): boolean { + if (typeof v !== "object" || v === null) return false; + const s = v as Record; + return ( + typeof s["id"] === "string" && + typeof s["label"] === "string" && + Array.isArray(s["slides"]) && + s["slides"].every(isSlideRef) + ); +} + function isManifest(v: unknown): v is SlideshowManifest { if (typeof v !== "object" || v === null) return false; - if (!("slides" in v)) return false; - return Array.isArray(v.slides); + const o = v as Record; + if (!Array.isArray(o["slides"]) || !o["slides"].every(isSlideRef)) return false; + if (o["slideSequences"] !== undefined) { + if (!Array.isArray(o["slideSequences"]) || !o["slideSequences"].every(isSlideSequence)) + return false; + } + return true; } function missingBoundError(sceneId: string, missing: "startTime" | "endTime"): string { @@ -101,7 +141,10 @@ function resolveSlide( ): ResolvedSlide { const scene = sceneById.get(ref.sceneId); const { start, end } = resolveTimeRange(ref, scene, errors); - const fragments = [...(ref.fragments ?? [])].sort((a, b) => a - b); + if (ref.startTime !== undefined && ref.endTime !== undefined && end <= start) { + errors.push(`slide "${ref.sceneId}" has endTime (${end}) <= startTime (${start})`); + } + const fragments = [...new Set(ref.fragments ?? [])].sort((a, b) => a - b); validateFragments(ref.sceneId, fragments, start, end, errors); return { ...ref, start, end, fragments, hotspots: ref.hotspots ?? [] }; } @@ -128,8 +171,11 @@ export function resolveSlideshow( const allSlides = [...slides, ...Object.values(sequences).flatMap((s) => s.slides)]; for (const slide of allSlides) { for (const h of slide.hotspots) { - if (!sequences[h.target]) { + const seq = sequences[h.target]; + if (!seq) { errors.push(`hotspot "${h.id}" targets unknown sequence "${h.target}"`); + } else if (seq.slides.length === 0) { + errors.push(`hotspot "${h.id}" targets empty sequence "${h.target}"`); } } } diff --git a/packages/core/src/slideshow/slideshow.types.ts b/packages/core/src/slideshow/slideshow.types.ts index a9074b930..fdddf1d24 100644 --- a/packages/core/src/slideshow/slideshow.types.ts +++ b/packages/core/src/slideshow/slideshow.types.ts @@ -1,7 +1,13 @@ // packages/core/src/slideshow/slideshow.types.ts +/** Current manifest schema version. Stamped on persist so future schema + * changes can detect and migrate older islands. */ +export const SLIDESHOW_MANIFEST_VERSION = 1; + /** Raw author-facing shapes parsed from the JSON island. */ export interface SlideshowManifest { + /** Schema version (absent on pre-versioning islands → treat as 1). */ + version?: number; slides: SlideRef[]; slideSequences?: SlideSequence[]; } diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index fc5f79a96..3f10620be 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -595,6 +595,7 @@ class HyperframesPlayer extends HTMLElement { onRuntimeReady: () => this._replayBridgeState(), setScenes: (scenes) => { this._scenes = scenes; + this.dispatchEvent(new CustomEvent("scenes", { detail: { scenes } })); }, updateControlsTime: (t, d) => this.controlsApi?.updateTime(t, d), updateControlsPlaying: (p) => this.controlsApi?.updatePlaying(p), diff --git a/packages/player/src/runtime-message-handler.ts b/packages/player/src/runtime-message-handler.ts index e5144f2dd..c872e1e22 100644 --- a/packages/player/src/runtime-message-handler.ts +++ b/packages/player/src/runtime-message-handler.ts @@ -19,9 +19,13 @@ type SceneRecord = { id: string; start: number; duration: number }; function extractScenes(raw: unknown): SceneRecord[] { if (!Array.isArray(raw)) return []; - return (raw as SceneRecord[]).filter( - (s) => - typeof s.id === "string" && typeof s.start === "number" && typeof s.duration === "number", + return raw.filter( + (s): s is SceneRecord => + typeof s === "object" && + s !== null && + typeof (s as Record)["id"] === "string" && + typeof (s as Record)["start"] === "number" && + typeof (s as Record)["duration"] === "number", ); } diff --git a/packages/player/src/slideshow/SlideshowController.test.ts b/packages/player/src/slideshow/SlideshowController.test.ts index 1c1bb9e79..adca44771 100644 --- a/packages/player/src/slideshow/SlideshowController.test.ts +++ b/packages/player/src/slideshow/SlideshowController.test.ts @@ -47,9 +47,9 @@ const SHOW: ResolvedSlideshow = { function showAtFrag1() { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); - p.emit(2); // fragmentIndex=0 + p.emit(2.2); // fragmentIndex=0 c.next(); // target=4 - p.emit(4); // fragmentIndex=1 + p.emit(4.2); // fragmentIndex=1 return { p, c }; } @@ -66,10 +66,12 @@ function showAtSlide1InDeep() { } describe("SlideshowController linear nav", () => { - it("enters the first slide on construction: seek to start + play", () => { + it("enters the first slide on construction: jumps to the first hold (no auto-play)", () => { const p = fakePlayer(); new SlideshowController(p, SHOW); - expect(p.seek).toHaveBeenCalledWith(0); + // No auto-play: seeks (jumps) to the first hold, fragments[0]=2; + // playTo then plays a brief forward render-nudge and pauses there. + expect(p.seek).toHaveBeenCalledWith(2); expect(p.play).toHaveBeenCalled(); }); @@ -84,7 +86,7 @@ describe("SlideshowController linear nav", () => { it("next stops at the first fragment, not the next slide", () => { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); - p.emit(2); // play reaches fragment 0, controller pauses + p.emit(2.2); // play reaches fragment 0, controller pauses expect(p.pause).toHaveBeenCalled(); expect(c.position.slideIndex).toBe(0); expect(c.position.fragmentIndex).toBe(0); @@ -94,12 +96,12 @@ describe("SlideshowController linear nav", () => { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); c.next(); // -> fragment 1 target (2) - p.emit(2); + p.emit(2.2); c.next(); // -> fragment 2 target (4) - p.emit(4); + p.emit(4.2); c.next(); // no more fragments — advance to slide b immediately expect(c.position.slideIndex).toBe(1); - expect(p.seek).toHaveBeenLastCalledWith(5); + expect(p.seek).toHaveBeenLastCalledWith(7.5); // slide b midpoint }); it("next() on a slide with NO fragments advances to the next slide immediately", () => { @@ -123,7 +125,7 @@ describe("SlideshowController linear nav", () => { // slide 0 has no fragments; one next() should advance immediately to slide 1 c2.next(); expect(c2.position.slideIndex).toBe(1); - expect(p2.seek).toHaveBeenLastCalledWith(5); + expect(p2.seek).toHaveBeenLastCalledWith(7.5); // slide b midpoint }); it("next() on the last slide is a no-op", () => { @@ -145,11 +147,11 @@ describe("SlideshowController linear nav", () => { it("auto-pauses at a fragment, then next advances to the FOLLOWING fragment (not the end)", () => { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); - p.emit(2); // auto-pause at fragments[0]=2 + p.emit(2.2); // auto-pause at fragments[0]=2 expect(c.position.fragmentIndex).toBe(0); p.pause.mockClear(); // clear the pause from the auto-stop above c.next(); // should target fragments[1]=4, NOT slide.end=5 - p.emit(4); + p.emit(4.2); expect(p.pause).toHaveBeenCalled(); // must pause at 4, not skip to 5 expect(c.position.fragmentIndex).toBe(1); }); @@ -186,7 +188,7 @@ describe("SlideshowController branching", () => { c.enterBranch("deep"); expect(c.position.sequenceId).toBe("deep"); expect(c.currentSlide?.sceneId).toBe("c"); - expect(p.seek).toHaveBeenLastCalledWith(10); + expect(p.seek).toHaveBeenLastCalledWith(11.5); // slide c midpoint }); it("counter is scoped to the current sequence", () => { @@ -226,19 +228,19 @@ describe("SlideshowController Fix 8a — fragmentIndex advances via onTime not n c.next(); expect(c.position.fragmentIndex).toBe(-1); // still -1 until onTime fires // Simulate playback reaching the hold point (fragments[0]=2) - p.emit(2); + p.emit(2.2); expect(c.position.fragmentIndex).toBe(0); // onTime advanced it }); it("next() after auto-pause targets the FOLLOWING fragment without pre-increment (regression)", () => { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); - p.emit(2); // auto-pause at fragments[0]=2; fragmentIndex=0 + p.emit(2.2); // auto-pause at fragments[0]=2; fragmentIndex=0 expect(c.position.fragmentIndex).toBe(0); p.pause.mockClear(); c.next(); // should target fragments[1]=4 — fragmentIndex stays 0 until emit expect(c.position.fragmentIndex).toBe(0); // NOT yet 1 - p.emit(4); + p.emit(4.2); expect(p.pause).toHaveBeenCalled(); expect(c.position.fragmentIndex).toBe(1); // onTime advanced it }); @@ -262,13 +264,25 @@ describe("SlideshowController Fix 8b — back() restores parent fragmentIndex", it("back() when parent fragmentIndex=-1 seeks to slide start", () => { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); - // Enter branch immediately (fragmentIndex is still -1) + // Enter branch immediately (slide a HAS fragments; fragmentIndex is still -1, + // i.e. before the first reveal → resume to slide.start). c.enterBranch("deep"); c.back(); expect(c.position.fragmentIndex).toBe(-1); - // seek should have been called with slide.start=0 (no fragment yet) expect(p.seek).toHaveBeenLastCalledWith(0); }); + + it("back() to a NO-fragment parent slide resumes at its midpoint, not frame 0", () => { + const p = fakePlayer(); + const c = new SlideshowController(p, SHOW); + c.goToSlide(1); // slide b: [5,10], no fragments + c.enterBranch("deep"); + c.back(); + expect(c.position.slideIndex).toBe(1); + // Mirrors enterSlide's no-fragment rest frame (midpoint) so the slide is + // visible at rest instead of frozen at its pre-entrance frame-0. + expect(p.seek).toHaveBeenLastCalledWith(7.5); // slide b midpoint (5 + 5*0.5) + }); }); describe("SlideshowController unknown-sequence degradation", () => { @@ -305,8 +319,8 @@ describe("SlideshowController unknown-sequence degradation", () => { // --------------------------------------------------------------------------- // Bug fix tests: #5-ctrl — enterSlide clears holdAt on empty-slide early return // --------------------------------------------------------------------------- -describe("SlideshowController Fix #5-ctrl — enterSlide clears holdAt on empty branch", () => { - it("enterSlide into an empty sequence clears holdAt so no spurious pause fires later", () => { +describe("SlideshowController Fix #5-ctrl — enterBranch ignores empty branch", () => { + it("enterBranch into an empty sequence is a no-op (does not enter the branch)", () => { // Build a show where "empty" sequence has no slides const show: ResolvedSlideshow = { slides: [{ sceneId: "a", start: 0, end: 5, fragments: [2], hotspots: [] }], @@ -319,13 +333,10 @@ describe("SlideshowController Fix #5-ctrl — enterSlide clears holdAt on empty // Advance to a holdAt state by calling next() (sets holdAt to fragment 2) c.next(); - // Now enter a branch that has no slides — enterSlide should clear holdAt + // Entering a branch that has no slides must be ignored — nav state unchanged. c.enterBranch("empty"); - - // Simulate time advancing — must NOT call pause (stale holdAt would trigger it) - p.pause.mockClear(); - p.emit(2); - expect(p.pause).not.toHaveBeenCalled(); + expect(c.position.sequenceId).toBe("main"); + expect(c.position.slideIndex).toBe(0); }); it("enterSlide(0) on an empty main sequence does not throw", () => { @@ -572,3 +583,66 @@ describe("SlideshowController canPrev / canNext", () => { expect(c.canNext).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// next() reveals remaining fragments even when playback is already at slide end +// (the atEnd gate was removed so a no-animation jump to slide end still steps +// through pending fragments rather than skipping straight to the next slide). +// --------------------------------------------------------------------------- +describe("SlideshowController next() — reveals remaining fragments at slide end", () => { + it("reveals the next fragment even when currentTime is already at slide end", () => { + const p = fakePlayer(); + const c = new SlideshowController(p, SHOW); + // Simulate a static jump to slide end without having stepped through fragments. + p.currentTime = 5; // slide a end, fragmentIndex still -1 + c.next(); + // Should target the first fragment (2) rather than advancing to slide b. + expect(c.position.slideIndex).toBe(0); + expect(p.play).toHaveBeenCalled(); + expect(p.seek).not.toHaveBeenLastCalledWith(5); // not advanced to slide b start + }); +}); + +// --------------------------------------------------------------------------- +// syncTo — absolute, animation-free position mirroring for the audience window. +// --------------------------------------------------------------------------- +describe("SlideshowController syncTo", () => { + it("re-roots to a branch sequence and restores slide+fragment statically", () => { + const p = fakePlayer(); + const c = new SlideshowController(p, SHOW); + c.syncTo("deep", 0, -1); + expect(c.position.sequenceId).toBe("deep"); + expect(c.position.slideIndex).toBe(0); + // Slide c has no fragments, so resumeSlide lands at its midpoint (restFrame) — + // the same visible-at-rest position enterSlide uses — not slide start. It then + // plays a render-nudge so the composition repaints; onTime pauses at the hold. + expect(p.seek).toHaveBeenLastCalledWith(11.5); // slide c midpoint (10 + 3*0.5) + expect(p.play).toHaveBeenCalled(); + p.emit(50); // player passes the render-nudge hold + expect(p.pause).toHaveBeenCalled(); + }); + + it("syncs a main-line slide+fragment position without animating", () => { + const p = fakePlayer(); + const c = new SlideshowController(p, SHOW); + c.syncTo("main", 0, 1); // slide a, fragmentIndex 1 → fragments[1]=4 + expect(c.position.sequenceId).toBe("main"); + expect(c.position.slideIndex).toBe(0); + expect(c.position.fragmentIndex).toBe(1); + expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1] = 4 + }); + + it("ignores an unknown sequence target", () => { + const p = fakePlayer(); + const c = new SlideshowController(p, SHOW); + c.syncTo("nope", 0, -1); + expect(c.position.sequenceId).toBe("main"); + }); + + it("ignores an out-of-range slide index", () => { + const p = fakePlayer(); + const c = new SlideshowController(p, SHOW); + c.syncTo("main", 99, -1); + expect(c.position.slideIndex).toBe(0); + }); +}); diff --git a/packages/player/src/slideshow/SlideshowController.ts b/packages/player/src/slideshow/SlideshowController.ts index da5559d39..250427861 100644 --- a/packages/player/src/slideshow/SlideshowController.ts +++ b/packages/player/src/slideshow/SlideshowController.ts @@ -16,10 +16,18 @@ interface StackFrame { const MAIN = "main"; const EPS = 0.001; +// Seconds to play past a restored/mirrored position so the composition repaints +// (a bare paused seek doesn't re-render some compositions; pausing on the first +// timeupdate fires before a paint). +const RENDER_NUDGE = 0.2; export class SlideshowController { private stack: StackFrame[] = [{ sequenceId: MAIN, slideIndex: 0, fragmentIndex: -1 }]; private holdAt: number | null = null; + // The logical hold (a fragment time / slide point). playTo() plays a short way + // PAST it (to holdAt) so the composition repaints; holdTarget is what onTime + // matches against fragments to advance fragmentIndex. + private holdTarget: number | null = null; private changeCbs = new Set<() => void>(); private unsub: () => void; @@ -101,28 +109,44 @@ export class SlideshowController { this.holdAt = null; const slide = this.currentSlide; if (!slide) return; - this.player.seek(slide.start); - this.playTo(this.nextStop(slide, -1)); + // Jump to the slide's first hold and stay there (no auto-progress). With + // fragments that's the first fragment; without, a settled frame INSIDE the + // slide (its midpoint) — NOT slide.end, which is the boundary where the next + // scene begins (else slide 1 would render slide 2's content). + const firstHold = + slide.fragments.length > 0 ? (slide.fragments[0] ?? slide.end) : this.restFrame(slide); + this.playTo(firstHold); this.emitChange(); } + /** A representative, non-boundary frame for a slide with no fragments. */ + private restFrame(slide: ResolvedSlide): number { + return slide.start + (slide.end - slide.start) * 0.5; + } + /** * Resumes a slide at a saved fragmentIndex without resetting to slide start. - * Used by back() to restore the caller's exact position in the parent slide. + * Used by back()/backToMain()/syncTo() to restore an exact position. */ private resumeSlide(index: number, fragmentIndex: number): void { this.frame.slideIndex = index; this.frame.fragmentIndex = fragmentIndex; const slide = this.currentSlide; if (!slide) return; - // Seek to the fragment's hold time (or slide start if before any fragment). + // Resume position, mirroring enterSlide so going back to a slide lands where + // entering it forward does: + // - at a saved fragment → that fragment's hold time + // - fragmented, pre-first → slide.start (before the first reveal) + // - no fragments → restFrame (midpoint), NOT slide.start, so the + // slide is visible at rest instead of frozen at its frame-0 (pre-entrance). const seekTime = fragmentIndex >= 0 && fragmentIndex < slide.fragments.length ? (slide.fragments[fragmentIndex] ?? slide.start) - : slide.start; + : slide.fragments.length > 0 + ? slide.start + : this.restFrame(slide); this.holdAt = null; - this.player.seek(seekTime); - this.player.pause(); + this.playTo(seekTime); this.emitChange(); } @@ -131,19 +155,34 @@ export class SlideshowController { return next ?? slide.end; } + /** + * Jump to hold time `t` and pause there — NO sustained playback, so slides + * never auto-progress. Seeks just before `t` and plays a short render-nudge + * ending at `t`: a bare paused seek doesn't repaint some compositions, and + * pausing on the first timeupdate fires before a paint. onTime() pauses at `t` + * and advances fragmentIndex when `t` is a fragment boundary. + */ private playTo(t: number): void { - this.holdAt = t; + // Seek to the EXACT target so the first repainted frame is the correct one — + // seeking BEFORE it (as a backward render-nudge) flashes a pre-target frame + // / the previous scene. Then play a short way PAST it so the composition + // actually repaints (a bare paused seek doesn't), and onTime() pauses there. + const slide = this.currentSlide; + this.holdTarget = t; + this.holdAt = slide ? Math.min(t + RENDER_NUDGE, slide.end) : t + RENDER_NUDGE; + this.player.seek(t); this.player.play(); } - private onTime(t: number): void { - if (this.holdAt !== null && t >= this.holdAt - EPS) { - const hold = this.holdAt; + private onTime(tt: number): void { + if (this.holdAt !== null && tt >= this.holdAt - EPS) { + const target = this.holdTarget; this.holdAt = null; - // Advance fragmentIndex if this hold is a fragment boundary. + this.holdTarget = null; + // Advance fragmentIndex if the logical target is a fragment boundary. const slide = this.currentSlide; - if (slide) { - const fragIdx = slide.fragments.indexOf(hold); + if (slide && target !== null) { + const fragIdx = slide.fragments.indexOf(target); if (fragIdx !== -1) { this.frame.fragmentIndex = fragIdx; this.emitChange(); @@ -157,9 +196,8 @@ export class SlideshowController { const slide = this.currentSlide; if (!slide) return; const hasMoreFragments = this.frame.fragmentIndex + 1 < slide.fragments.length; - const atEnd = this.player.currentTime >= slide.end - EPS; - if (hasMoreFragments && !atEnd) { - // Reveal the next fragment (play-to-hold). onTime() advances fragmentIndex at the hold. + if (hasMoreFragments) { + // Reveal the next fragment. onTime() advances fragmentIndex at the hold. const nextTarget = this.nextStop(slide, this.frame.fragmentIndex); this.playTo(nextTarget); this.emitChange(); @@ -193,7 +231,8 @@ export class SlideshowController { } enterBranch(sequenceId: string): void { - if (!this.show.sequences[sequenceId]) return; + const seq = this.show.sequences[sequenceId]; + if (!seq || seq.slides.length === 0) return; this.stack.push({ sequenceId, slideIndex: 0, fragmentIndex: -1 }); this.enterSlide(0); } @@ -212,4 +251,25 @@ export class SlideshowController { this.stack = [this.stack[0]]; this.resumeSlide(this.frame.slideIndex, this.frame.fragmentIndex); } + + /** + * Jump to an absolute position without animation (audience mirroring). + * Re-roots the stack to the target sequence, then restores slide+fragment + * statically via resumeSlide. + */ + syncTo(sequenceId: string, slideIndex: number, fragmentIndex: number): void { + const base = this.stack[0]; + if (!base) return; + if (this.frame.sequenceId !== sequenceId) { + this.stack = [base]; + if (sequenceId !== MAIN) { + const seq = this.show.sequences[sequenceId]; + if (!seq || seq.slides.length === 0) return; + this.stack.push({ sequenceId, slideIndex: 0, fragmentIndex: -1 }); + } + } + const slides = this.slidesOf(this.frame.sequenceId); + if (slideIndex < 0 || slideIndex >= slides.length) return; + this.resumeSlide(slideIndex, fragmentIndex); + } } diff --git a/packages/player/src/slideshow/hyperframes-slideshow.test.ts b/packages/player/src/slideshow/hyperframes-slideshow.test.ts index 08e904278..259623599 100644 --- a/packages/player/src/slideshow/hyperframes-slideshow.test.ts +++ b/packages/player/src/slideshow/hyperframes-slideshow.test.ts @@ -63,25 +63,35 @@ describe("", () => { el.remove(); }); - it("advances on Space key dispatched on window", () => { + it("advances on Space key only when the deck is focused (does not hijack the host page)", () => { let nextCalled = false; const el = makeEl({ onNext: () => { nextCalled = true; }, }); + // Unfocused: Space must NOT navigate (the host page owns scroll). + window.dispatchEvent(new KeyboardEvent("keydown", { key: " " })); + expect(nextCalled).toBe(false); + // Focused: Space navigates. + el.focus(); window.dispatchEvent(new KeyboardEvent("keydown", { key: " " })); expect(nextCalled).toBe(true); el.remove(); }); - it("goes back on Backspace key dispatched on window", () => { + it("goes back on Backspace key only when the deck is focused (does not hijack history)", () => { let prevCalled = false; const el = makeEl({ onPrev: () => { prevCalled = true; }, }); + // Unfocused: Backspace must NOT navigate (the host page owns history nav). + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Backspace" })); + expect(prevCalled).toBe(false); + // Focused: Backspace navigates. + el.focus(); window.dispatchEvent(new KeyboardEvent("keydown", { key: "Backspace" })); expect(prevCalled).toBe(true); el.remove(); @@ -459,19 +469,21 @@ describe(" presenter mode", () => { const MAIN_POS = { sequenceId: "main", slideIndex: 0, fragmentIndex: -1 }; /** - * Creates a slideshow element with a stub controller whose goToSlide records - * the last called index. Appends to body; caller must call el.remove(). + * Creates a slideshow element with a stub controller whose syncTo records + * the last called (sequenceId, slideIndex, fragmentIndex). Appends to body; + * caller must call el.remove(). */ function makeAudienceEl() { const el = document.createElement("hyperframes-slideshow") as any; el.setAttribute("mode", "audience"); document.body.appendChild(el); - let gotoIndex: number | null = null; + let lastSync: { sequenceId: string; slideIndex: number; fragmentIndex: number } | null = null; el.__setControllerForTest({ next: () => {}, prev: () => {}, - goToSlide: (i: number) => { - gotoIndex = i; + goToSlide: () => {}, + syncTo: (sequenceId: string, slideIndex: number, fragmentIndex: number) => { + lastSync = { sequenceId, slideIndex, fragmentIndex }; }, onChange: () => () => {}, counter: { index: 1, total: 3 }, @@ -479,7 +491,7 @@ describe(" presenter mode", () => { currentSlide: { hotspots: [] }, nextSlide: null, }); - return { el, getGotoIndex: () => gotoIndex }; + return { el, getLastSync: () => lastSync }; } /** @@ -511,9 +523,9 @@ describe(" presenter mode", () => { const tick = () => new Promise((r) => setTimeout(r, 0)); - it("audience mode: applies a goto message from the BroadcastChannel", async () => { + it("audience mode: mirrors full position (sequence + slide + fragment) via syncTo", async () => { const presenterChannel = new BroadcastChannel("hf-slideshow"); - const { el, getGotoIndex } = makeAudienceEl(); + const { el, getLastSync } = makeAudienceEl(); await tick(); presenterChannel.postMessage({ @@ -524,29 +536,29 @@ describe(" presenter mode", () => { }); await tick(); - expect(getGotoIndex()).toBe(2); + expect(getLastSync()).toEqual({ sequenceId: "main", slideIndex: 2, fragmentIndex: 0 }); presenterChannel.close(); el.remove(); }); - it("audience mode: ignores goto for unknown sequenceId (no crash)", async () => { + it("audience mode: mirrors a branch position too (full sequenceId forwarded to syncTo)", async () => { const presenterChannel = new BroadcastChannel("hf-slideshow"); - const { el, getGotoIndex } = makeAudienceEl(); + const { el, getLastSync } = makeAudienceEl(); await tick(); - // V1: non-main sequenceId must not crash and must not navigate + // A non-main sequenceId is now forwarded to syncTo (controller decides validity). expect(() => { presenterChannel.postMessage({ type: "goto", sequenceId: "branch-a", slideIndex: 1, - fragmentIndex: 0, + fragmentIndex: 2, }); }).not.toThrow(); await tick(); - expect(getGotoIndex()).toBeNull(); + expect(getLastSync()).toEqual({ sequenceId: "branch-a", slideIndex: 1, fragmentIndex: 2 }); presenterChannel.close(); el.remove(); diff --git a/packages/player/src/slideshow/hyperframes-slideshow.ts b/packages/player/src/slideshow/hyperframes-slideshow.ts index 84686dfed..317bd15bd 100644 --- a/packages/player/src/slideshow/hyperframes-slideshow.ts +++ b/packages/player/src/slideshow/hyperframes-slideshow.ts @@ -25,6 +25,7 @@ interface ControllerLike { readonly canPrev?: boolean; readonly canNext?: boolean; goToSlide?(index: number): void; + syncTo?(sequenceId: string, slideIndex: number, fragmentIndex: number): void; enterBranch?(id: string): void; back?(): void; backToMain?(): void; @@ -61,10 +62,26 @@ function injectKeyframesOnce(): void { @media (prefers-reduced-motion: reduce) { .hf-hotspot-pill { animation: none !important; } } + /* Nav-button hover (replaces inline onmouseover/onmouseout — CSP-safe). + !important beats the inline base color set on each button. */ + [data-hf-nav-cluster] button:hover { + background: rgba(255,255,255,0.12) !important; + color: #fff !important; + } + /* When muted, the speaker button stays dimmed on hover so the mute-state + affordance isn't erased (higher specificity than the rule above). */ + [data-hf-muted] [data-hf-mute]:hover { + color: rgba(255,255,255,0.6) !important; + } `; document.head.appendChild(style); } +// Fullscreen glyphs (enter = expand corners, exit = collapse corners). Module-level +// so onFsChange can swap just this glyph without re-rendering the whole chrome. +const ENTER_FS_SVG = ``; +const EXIT_FS_SVG = ``; + export class HyperframesSlideshow extends HTMLElement { private controller: ControllerLike | null = null; private offChange: (() => void) | null = null; @@ -85,6 +102,18 @@ export class HyperframesSlideshow extends HTMLElement { return this._muted; } + /** Mode resolves from the `mode` attribute, falling back to the URL query + * (?mode=audience) so the audience window opened by present() is detected. */ + private resolveMode(): string | null { + const attr = this.getAttribute("mode"); + if (attr) return attr; + try { + return new URLSearchParams(location.search).get("mode"); + } catch { + return null; + } + } + connectedCallback(): void { this.disconnected = false; this.initInFlight = false; @@ -97,6 +126,7 @@ export class HyperframesSlideshow extends HTMLElement { this.addEventListener("touchstart", this.onTouchStart, { passive: true }); this.addEventListener("touchend", this.onTouchEnd); window.addEventListener("message", this.onMessage); + document.addEventListener("fullscreenchange", this.onFsChange); this.initChannel(); // Defer player-dependent init to a macrotask so that child elements are // parsed before we query for . This matters when the @@ -123,6 +153,7 @@ export class HyperframesSlideshow extends HTMLElement { this.removeEventListener("touchstart", this.onTouchStart); this.removeEventListener("touchend", this.onTouchEnd); window.removeEventListener("message", this.onMessage); + document.removeEventListener("fullscreenchange", this.onFsChange); this.offChange?.(); this.offChange = null; this.controller?.dispose?.(); @@ -151,18 +182,30 @@ export class HyperframesSlideshow extends HTMLElement { this.setAttribute("data-hf-presenting", "true"); this.presenterStartMs = Date.now(); if (this.presenterInterval === null) { - this.presenterInterval = setInterval(() => this.render(), 1000); + this.presenterInterval = setInterval(() => this.updateElapsed(), 1000); } this.render(); } + /** + * Update only the elapsed readout. Re-rendering the whole chrome every second + * (the old behavior) rebuilt the nav buttons' DOM on each tick — they + * flickered and clicks landing mid-rebuild were dropped. + */ + private updateElapsed(): void { + if (this.presenterStartMs === null) return; + const el = this.chrome?.querySelector("[data-hf-presenter-elapsed]"); + if (el) { + el.textContent = formatElapsed(Math.floor((Date.now() - this.presenterStartMs) / 1000)); + } + } + private initChannel(): void { - const mode = this.getAttribute("mode"); + const mode = this.resolveMode(); if (mode === "audience") { this.channel = new SlideshowChannel("audience", (msg) => { if (!this.controller) return; - if (msg.sequenceId !== "main") return; // V1: non-main branch gracefully ignored - this.controller.goToSlide?.(msg.slideIndex); + this.controller.syncTo?.(msg.sequenceId, msg.slideIndex, msg.fragmentIndex); }); } else { this.channel = new SlideshowChannel("presenter", () => { @@ -210,6 +253,12 @@ export class HyperframesSlideshow extends HTMLElement { console.warn("[hyperframes-slideshow] manifest errors:", errors); } const cleaned = dropInvalidSlides(resolved); + if (cleaned.slides.length === 0 && manifest.slides.length > 0) { + console.error( + "[hyperframes-slideshow] no main-line slides resolved — the scene timeline may not have loaded in time, or sceneIds/timing are invalid:", + errors, + ); + } const port: PlayerPort = { seek: (t) => playerEl.seek(t), @@ -240,13 +289,13 @@ export class HyperframesSlideshow extends HTMLElement { this.controller = c; this.offChange = c.onChange(() => { // Presenter posts position to channel on every change - if (this.getAttribute("mode") !== "audience" && this.channel) { + if (this.resolveMode() !== "audience" && this.channel) { this.channel.postPosition(c.position); } this.render(); }); // Post initial position if presenter - if (this.getAttribute("mode") !== "audience" && this.channel) { + if (this.resolveMode() !== "audience" && this.channel) { this.channel.postPosition(c.position); } this.render(); @@ -264,19 +313,39 @@ export class HyperframesSlideshow extends HTMLElement { ) { return; } - if (e.key === "ArrowRight" || e.key === " ") { + const active = document.activeElement; + const focused = active === this || this.contains(active); + // Arrows act even when nothing is focused (active === body/null) so a freshly + // loaded deck responds without a click; Space/Backspace have strong page-level + // defaults (scroll / history) so they only act when the deck actually has focus. + const ambient = focused || active === document.body || active === null; + if (e.key === "ArrowRight") { + if (!ambient) return; this.controller.next(); e.preventDefault(); - } else if (e.key === "ArrowLeft" || e.key === "Backspace") { + } else if (e.key === "ArrowLeft") { + if (!ambient) return; this.controller.prev(); e.preventDefault(); + } else if (e.key === " ") { + if (!focused) return; + this.controller.next(); + e.preventDefault(); + } else if (e.key === "Backspace") { + if (!focused) return; + this.controller.prev(); + e.preventDefault(); + } else if ((e.key === "f" || e.key === "F") && !e.metaKey && !e.ctrlKey && !e.altKey) { + if (!focused) return; + this.toggleFullscreen(); + e.preventDefault(); } }; // fallow-ignore-next-line complexity private onMessage = (e: MessageEvent): void => { // Audience mode is driven by BroadcastChannel; ignore embed postMessage nav. - if (this.getAttribute("mode") === "audience") return; + if (this.resolveMode() === "audience") return; const data = e.data as { type?: unknown; slideIndex?: unknown } | null; if (!data || !this.controller) return; if (data.type === "next") { @@ -318,6 +387,14 @@ export class HyperframesSlideshow extends HTMLElement { private render(): void { if (!this.controller) return; + if (this.resolveMode() === "audience") { + // Audience (viewer) window: no nav controls — but keep a fullscreen toggle + // so the presentation can fill the display. + const { counter } = this.controller; + this.paintChrome(this.buildNavCluster(counter, "28px", "fs-only")); + return; + } + if (this.getAttribute("data-hf-presenting") === "true") { this.renderPresenter(); return; @@ -326,16 +403,6 @@ export class HyperframesSlideshow extends HTMLElement { const { counter, currentSlide } = this.controller; if (!currentSlide) return; - if (!this.chrome) { - this.chrome = document.createElement("div"); - this.chrome.setAttribute("data-hf-chrome", ""); - this.chrome.style.cssText = "position:absolute;inset:0;pointer-events:none;z-index:10;"; - this.appendChild(this.chrome); - } - - // Inject keyframes for hotspot pulse animation once per document. - injectKeyframesOnce(); - // Hotspot pills: compact floating buttons anchored to the region's top-left, // sized to content (not filling the region). The region x/y positions the pill; // w/h are ignored for sizing (pill is content-sized). XSS: escHtml guards all @@ -356,18 +423,38 @@ export class HyperframesSlideshow extends HTMLElement { }) .join(""); - // Single cohesive nav cluster: [mute?] [prev |] counter [| next] — bottom-right capsule. - // Prev/next buttons are hidden when there is no destination in that direction: - // - Main deck first slide → no prev (nothing before it) - // - Main deck last slide → no next (nothing after it) - // - Inside a branch → always both (branch-edge returns to parent) - // The mute toggle is shown only when the `sound` boolean attribute is present. - const showPrev = this.controller.canPrev !== false; - const showNext = this.controller.canNext !== false; + this.paintChrome(hotspotsHtml + this.buildNavCluster(counter, "28px")); + } + + /** Ensure the overlay chrome layer exists, set its content, and wire its buttons. */ + private paintChrome(html: string): void { + injectKeyframesOnce(); // nav-button :hover + hotspot keyframes (CSP-safe, once per doc) + if (!this.chrome) { + this.chrome = document.createElement("div"); + this.chrome.setAttribute("data-hf-chrome", ""); + this.appendChild(this.chrome); + } + this.chrome.style.cssText = "position:absolute;inset:0;pointer-events:none;z-index:10;"; + this.chrome.innerHTML = html; + this.wireChromeButtons(); + } + + // Builds the nav cluster ([mute?] [prev] counter [next] | [fullscreen]) as a + // floating capsule. `bottomCss` positions it (normal view: "28px"; presenter + // view: above the notes panel). Reused by render() and renderPresenter(). + // fallow-ignore-next-line complexity + private buildNavCluster( + counter: { index: number; total: number }, + bottomCss: string, + variant: "full" | "fs-only" = "full", + ): string { + const c = this.controller; + if (!c) return ""; + const showPrev = c.canPrev !== false; + const showNext = c.canNext !== false; const showSound = this.hasAttribute("sound"); const btnStyle = "display:flex;align-items:center;justify-content:center;width:34px;height:34px;background:transparent;border:none;border-radius:999px;color:rgba(255,255,255,0.85);font-size:16px;cursor:pointer;transition:background 0.15s,color 0.15s;padding:0;"; - // Inline SVG glyphs for speaker and speaker-muted (no emoji — consistent across platforms) const speakerSvg = ``; const speakerMutedSvg = ``; const muteBtnHtml = showSound @@ -377,8 +464,6 @@ export class HyperframesSlideshow extends HTMLElement { aria-label="${this._muted ? "Unmute" : "Mute"}" aria-pressed="${this._muted ? "true" : "false"}" style="${btnStyle}${this._muted ? "color:rgba(255,255,255,0.45);" : ""}" - onmouseover="this.style.background='rgba(255,255,255,0.12)';this.style.color='${this._muted ? "rgba(255,255,255,0.6)" : "#fff"}';" - onmouseout="this.style.background='transparent';this.style.color='${this._muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.85)"}';" >${this._muted ? speakerMutedSvg : speakerSvg}` : ""; const prevBtnHtml = showPrev @@ -386,28 +471,36 @@ export class HyperframesSlideshow extends HTMLElement { data-hf-prev type="button" aria-label="Previous slide" - style="${btnStyle}" - onmouseover="this.style.background='rgba(255,255,255,0.12)';this.style.color='#fff';" - onmouseout="this.style.background='transparent';this.style.color='rgba(255,255,255,0.85)';" - >‹` + style="${btnStyle}" >‹` : ""; const nextBtnHtml = showNext ? `` + style="${btnStyle}" >›` : ""; - // Counter padding adjusts so the pill looks centered when one button is absent. + const isFs = document.fullscreenElement === this; + const fsBtnHtml = ``; + // Audience/viewer: only the fullscreen control (no navigation). + if (variant === "fs-only") { + return ` +
${fsBtnHtml}
`; + } const counterPadLeft = showPrev ? "4px" : "10px"; const counterPadRight = showNext ? "4px" : "10px"; - const navClusterHtml = ` + return `
${muteBtnHtml} ${showSound ? `` : ""} @@ -418,26 +511,47 @@ export class HyperframesSlideshow extends HTMLElement { style="min-width:46px;text-align:center;color:rgba(255,255,255,0.9);font-size:13px;font-weight:500;font-variant-numeric:tabular-nums;letter-spacing:0.02em;padding:0 ${counterPadRight} 0 ${counterPadLeft};user-select:none;" >${counter.index} / ${counter.total} ${nextBtnHtml} -
- `; - - this.chrome.innerHTML = hotspotsHtml + navClusterHtml; + + ${fsBtnHtml} + `; + } - const muteBtn = this.chrome.querySelector("[data-hf-mute]"); - const prevBtn = this.chrome.querySelector("[data-hf-prev]"); - const nextBtn = this.chrome.querySelector("[data-hf-next]"); + private wireChromeButtons(): void { + const chrome = this.chrome; + if (!chrome) return; + const muteBtn = chrome.querySelector("[data-hf-mute]"); + const prevBtn = chrome.querySelector("[data-hf-prev]"); + const nextBtn = chrome.querySelector("[data-hf-next]"); if (muteBtn) muteBtn.addEventListener("click", () => this.toggleMute()); if (prevBtn) prevBtn.addEventListener("click", () => this.controller?.prev()); if (nextBtn) nextBtn.addEventListener("click", () => this.controller?.next()); - - // Wire hotspot clicks after innerHTML is set. Read target from data-hotspot-target - // so the handler does not close over stale loop state. - for (const btn of this.chrome.querySelectorAll("[data-hotspot-id]")) { + const fsBtn = chrome.querySelector("[data-hf-fullscreen]"); + if (fsBtn) fsBtn.addEventListener("click", () => this.toggleFullscreen()); + for (const btn of chrome.querySelectorAll("[data-hotspot-id]")) { const target = btn.getAttribute("data-hotspot-target") ?? ""; btn.addEventListener("click", () => this.controller?.enterBranch?.(target)); } } + private onFsChange = (): void => { + // Swap only the fullscreen glyph + label — re-rendering the whole chrome here + // would rebuild every nav button on each fullscreen toggle. + const btn = this.chrome?.querySelector("[data-hf-fullscreen]"); + if (!btn) return; + const isFs = document.fullscreenElement === this; + btn.innerHTML = isFs ? EXIT_FS_SVG : ENTER_FS_SVG; + btn.setAttribute("aria-label", isFs ? "Exit full screen" : "Full screen"); + btn.setAttribute("aria-pressed", isFs ? "true" : "false"); + }; + + private toggleFullscreen(): void { + if (document.fullscreenElement === this) { + void document.exitFullscreen().catch(() => {}); + } else { + void this.requestFullscreen().catch(() => {}); + } + } + private toggleMute(): void { this._muted = !this._muted; if (this._muted) { @@ -464,30 +578,31 @@ export class HyperframesSlideshow extends HTMLElement { const elapsedSec = this.presenterStartMs !== null ? Math.floor((Date.now() - this.presenterStartMs) / 1000) : 0; - if (!this.chrome) { - this.chrome = document.createElement("div"); - this.chrome.setAttribute("data-hf-chrome", ""); - this.chrome.style.cssText = "position:absolute;inset:0;z-index:10;"; - this.appendChild(this.chrome); + // Pin the live slide to the TOP and reserve the bottom 32% for the notes + // panel. The player contains the composition, so the FULL slide stays visible + // (letterboxed) at any width — its bottom is never hidden behind the panel — + // and it re-fits to the top region on window resize. + const playerEl = this.querySelector("hyperframes-player"); + if (playerEl instanceof HTMLElement) { + playerEl.style.top = "0"; + playerEl.style.bottom = "32%"; + playerEl.style.height = "auto"; } - this.chrome.innerHTML = buildPresenterLayout({ - // TODO: live next-slide thumbnail/preview deferred (needs a second seeked player) — V1 shows text - currentSlideHtml: currentPanelText(currentSlide), - nextSlideHtml: nextPanelText(nextSlide), - notes: currentSlide.notes ?? "", - counterText: `${counter.index} / ${counter.total}`, - elapsedText: formatElapsed(elapsedSec), - }); + // Full-overlay chrome (pointer-events:none); the notes panel and nav cluster + // are the only interactive children. + this.paintChrome( + buildPresenterLayout({ + notes: currentSlide.notes ?? "", + nextText: nextPanelText(nextSlide), + counterText: `${counter.index} / ${counter.total}`, + elapsedText: formatElapsed(elapsedSec), + hotspots: currentSlide.hotspots, + }) + this.buildNavCluster(counter, "calc(32% + 18px)"), + ); } } -function currentPanelText(slide: { notes?: string; sceneId?: string }): string { - if (slide.notes != null && slide.notes.length > 0) return escHtml(slide.notes); - if (slide.sceneId != null) return `Current: ${escHtml(slide.sceneId)}`; - return ""; -} - function nextPanelText(slide: { sceneId: string; notes?: string } | null): string { if (slide === null) return "End of sequence"; const firstLine = slide.notes != null ? (slide.notes.split("\n")[0] ?? "") : ""; @@ -539,31 +654,40 @@ function waitForScenes( timeoutMs: number, isCancelled: () => boolean = () => false, ): Promise<{ id: string; start: number; duration: number }[]> { - const scenes = readScenes(player); - if (scenes.length > 0) return Promise.resolve(scenes); + const initial = readScenes(player); + if (initial.length > 0) return Promise.resolve(initial); const maxIterations = Math.ceil(timeoutMs / 100); return new Promise((resolve) => { + let done = false; + let timer: ReturnType | null = null; let iterations = 0; + + const finish = (val: { id: string; start: number; duration: number }[]): void => { + if (done) return; + done = true; + if (timer !== null) clearTimeout(timer); + player.removeEventListener("scenes", onScenes); + resolve(val); + }; + const onScenes = (): void => { + if (isCancelled()) return finish([]); + const s = readScenes(player); + if (s.length > 0) finish(s); + }; const poll = (): void => { - if (isCancelled()) { - resolve([]); - return; - } - const current = readScenes(player); - if (current.length > 0) { - resolve(current); - return; - } + if (done) return; + if (isCancelled()) return finish([]); + const cur = readScenes(player); + if (cur.length > 0) return finish(cur); iterations += 1; - if (iterations >= maxIterations) { - resolve([]); - return; - } - setTimeout(poll, 100); + if (iterations >= maxIterations) return finish([]); + timer = setTimeout(poll, 100); }; - setTimeout(poll, 100); + + player.addEventListener("scenes", onScenes); + timer = setTimeout(poll, 100); }); } diff --git a/packages/player/src/slideshow/slideshowPresenter.ts b/packages/player/src/slideshow/slideshowPresenter.ts index 653487046..5bee6fd3e 100644 --- a/packages/player/src/slideshow/slideshowPresenter.ts +++ b/packages/player/src/slideshow/slideshowPresenter.ts @@ -66,32 +66,50 @@ export class SlideshowChannel { } /** - * Builds the presenter-mode inner HTML showing current slide area, - * next-slide preview, notes, counter, and elapsed timer. + * Builds the presenter-mode bottom panel: speaker notes + up-next + counter + + * elapsed. The live slide is shown ABOVE this panel (the component confines the + * player to the top region). Returns the panel HTML only — the component appends + * the nav controls separately. */ export function buildPresenterLayout(opts: { - currentSlideHtml: string; - nextSlideHtml: string; notes: string; + nextText: string; counterText: string; elapsedText: string; + hotspots: { id: string; label: string; target: string }[]; }): string { const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); - + const escAttr = (s: string) => esc(s).replace(/"/g, """); + const notes = opts.notes + ? esc(opts.notes) + : `No notes for this slide`; + // Branch entries for the current slide — the presenter clicks these to enter a + // branch (the audience follows). The component wires [data-hotspot-id] to + // enterBranch(); positioned pills don't align with the letterboxed slide, so + // they live in the console as a list. + const branches = opts.hotspots.length + ? `
+
Branches
+ ${opts.hotspots + .map( + (h) => + ``, + ) + .join("")} +
` + : ""; return ` -
-
- ${opts.currentSlideHtml} -
-
-
Next
-
- ${opts.nextSlideHtml} +
+
${notes}
+
+
Up next
+
${esc(opts.nextText)}
+ ${branches} +
+
Slide
${esc(opts.counterText)}
+
Elapsed
${esc(opts.elapsedText)}
-
${esc(opts.counterText)}
-
${esc(opts.elapsedText)}
-
${esc(opts.notes)}
`.trim(); } diff --git a/packages/player/tsup.config.ts b/packages/player/tsup.config.ts index 02cd64515..87ae66a4a 100644 --- a/packages/player/tsup.config.ts +++ b/packages/player/tsup.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: ["src/hyperframes-player.ts", "src/slideshow/hyperframes-slideshow.ts"], format: ["esm", "cjs", "iife"], globalName: "HyperframesPlayer", + noExternal: ["@hyperframes/core"], dts: true, clean: true, minify: true, diff --git a/packages/studio/src/components/panels/SlideshowPanel.test.ts b/packages/studio/src/components/panels/SlideshowPanel.test.ts index d40dca1e6..6ea185c09 100644 --- a/packages/studio/src/components/panels/SlideshowPanel.test.ts +++ b/packages/studio/src/components/panels/SlideshowPanel.test.ts @@ -81,6 +81,42 @@ describe("setSlideNotes", () => { }); }); +// ── branch-scoped authoring (Finding #14) ────────────────────────────────── + +describe("branch-scoped editing (sequenceId)", () => { + const base: SlideshowManifest = { + slides: [{ sceneId: "a" }], + slideSequences: [{ id: "seq-1", label: "Branch", slides: [{ sceneId: "b" }] }], + }; + + it("setSlideNotes edits the branch slide, leaving the main line untouched", () => { + const m = setSlideNotes(base, "b", "branch note", "seq-1"); + expect(m.slideSequences?.[0]?.slides[0]).toMatchObject({ sceneId: "b", notes: "branch note" }); + expect(m.slides[0]).toEqual({ sceneId: "a" }); // main line unchanged + }); + + it("setSlideNotes does NOT auto-add a slide to a branch when the scene is not assigned", () => { + const m = setSlideNotes(base, "z", "nope", "seq-1"); + expect(m.slideSequences?.[0]?.slides).toEqual([{ sceneId: "b" }]); + expect(m.slides).toEqual([{ sceneId: "a" }]); + }); + + it("addFragment edits the branch slide and does not auto-add when unassigned", () => { + const added = addFragment(base, "b", 1.5, "seq-1"); + expect(added.slideSequences?.[0]?.slides[0]?.fragments).toEqual([1.5]); + const noAdd = addFragment(base, "z", 2.0, "seq-1"); + expect(noAdd.slideSequences?.[0]?.slides).toEqual([{ sceneId: "b" }]); + }); + + it("addHotspot edits the branch slide and does not auto-add when unassigned", () => { + const hotspot = { id: "h1", label: "Why", target: "seq-2" }; + const added = addHotspot(base, "b", hotspot, "seq-1"); + expect(added.slideSequences?.[0]?.slides[0]?.hotspots).toEqual([hotspot]); + const noAdd = addHotspot(base, "z", hotspot, "seq-1"); + expect(noAdd.slideSequences?.[0]?.slides).toEqual([{ sceneId: "b" }]); + }); +}); + // ── addFragment ─────────────────────────────────────────────────────────── describe("addFragment", () => { diff --git a/packages/studio/src/components/panels/SlideshowPanel.tsx b/packages/studio/src/components/panels/SlideshowPanel.tsx index 0aafe0a02..b04da2d40 100644 --- a/packages/studio/src/components/panels/SlideshowPanel.tsx +++ b/packages/studio/src/components/panels/SlideshowPanel.tsx @@ -175,6 +175,7 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP }); const [selectedSceneId, setSelectedSceneId] = useState(null); + const [selectedSequenceId, setSelectedSequenceId] = useState(null); const [expandedSections, setExpandedSections] = useState>( () => new Set(["slides", "inspector"]), ); @@ -203,6 +204,7 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP notesCtrlRef.current.flush(); setManifest(parsed); manifestRef.current = parsed; + setSelectedSequenceId(null); }, [compHtml]); /** Discrete actions (toggle, reorder, add/delete, hotspot): persist immediately. */ @@ -255,9 +257,17 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP }); }, []); - const selectedSlide = manifest.slides.find((s) => s.sceneId === selectedSceneId); + const activeSlides = selectedSequenceId + ? ((manifest.slideSequences ?? []).find((s) => s.id === selectedSequenceId)?.slides ?? []) + : manifest.slides; + const selectedSlide = activeSlides.find((s) => s.sceneId === selectedSceneId); const sequences = manifest.slideSequences ?? []; + const handleSelectBranchSlide = useCallback((sequenceId: string, sceneId: string) => { + setSelectedSceneId(sceneId); + setSelectedSequenceId(sequenceId); + }, []); + const handleToggleSlide = useCallback( (sceneId: string) => { applyManifest(toggleMainLineSlide(manifestRef.current, sceneId)).catch(() => {}); @@ -275,27 +285,38 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP const handleSetNotes = useCallback( (notes: string) => { if (!selectedSceneId) return; - applyNotesManifest(setSlideNotes(manifestRef.current, selectedSceneId, notes)); + applyNotesManifest( + setSlideNotes(manifestRef.current, selectedSceneId, notes, selectedSequenceId ?? undefined), + ); }, - [selectedSceneId, applyNotesManifest], + [selectedSceneId, selectedSequenceId, applyNotesManifest], ); const handleMarkFragment = useCallback(() => { if (!selectedSceneId) return; - applyManifest(addFragment(manifestRef.current, selectedSceneId, currentTime)).catch(() => {}); - }, [selectedSceneId, currentTime, applyManifest]); + applyManifest( + addFragment( + manifestRef.current, + selectedSceneId, + currentTime, + selectedSequenceId ?? undefined, + ), + ).catch(() => {}); + }, [selectedSceneId, selectedSequenceId, currentTime, applyManifest]); const handleRemoveFragment = useCallback( (time: number) => { if (!selectedSceneId) return; - applyManifest(removeFragment(manifestRef.current, selectedSceneId, time)).catch(() => {}); + applyManifest( + removeFragment(manifestRef.current, selectedSceneId, time, selectedSequenceId ?? undefined), + ).catch(() => {}); }, - [selectedSceneId, applyManifest], + [selectedSceneId, selectedSequenceId, applyManifest], ); const handleCreateSequence = useCallback( (label: string) => { - const id = `seq-${Date.now()}`; + const id = `seq-${crypto.randomUUID()}`; applyManifest(createSequence(manifestRef.current, id, label)).catch(() => {}); }, [applyManifest], @@ -326,16 +347,20 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP const handleAddHotspot = useCallback( (sceneId: string, hotspot: SlideHotspot) => { - applyManifest(addHotspot(manifestRef.current, sceneId, hotspot)).catch(() => {}); + applyManifest( + addHotspot(manifestRef.current, sceneId, hotspot, selectedSequenceId ?? undefined), + ).catch(() => {}); }, - [applyManifest], + [selectedSequenceId, applyManifest], ); const handleRemoveHotspot = useCallback( (sceneId: string, hotspotId: string) => { - applyManifest(removeHotspot(manifestRef.current, sceneId, hotspotId)).catch(() => {}); + applyManifest( + removeHotspot(manifestRef.current, sceneId, hotspotId, selectedSequenceId ?? undefined), + ).catch(() => {}); }, - [applyManifest], + [selectedSequenceId, applyManifest], ); return ( @@ -352,7 +377,10 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP scenes={scenes} slides={manifest.slides} selectedSceneId={selectedSceneId} - onSelect={setSelectedSceneId} + onSelect={(id) => { + setSelectedSceneId(id); + setSelectedSequenceId(null); + }} onToggle={handleToggleSlide} onReorder={handleReorder} /> @@ -398,6 +426,9 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP onRenameSequence={handleRenameSequence} onDeleteSequence={handleDeleteSequence} onAssign={handleAssign} + selectedSceneId={selectedSceneId} + selectedSequenceId={selectedSequenceId} + onSelectBranchSlide={handleSelectBranchSlide} /> )} diff --git a/packages/studio/src/components/panels/SlideshowSubPanels.tsx b/packages/studio/src/components/panels/SlideshowSubPanels.tsx index b97e0b8a5..39aaa11fd 100644 --- a/packages/studio/src/components/panels/SlideshowSubPanels.tsx +++ b/packages/studio/src/components/panels/SlideshowSubPanels.tsx @@ -51,10 +51,17 @@ export function SlideList({ onToggle, onReorder, }: SlideListProps) { + const slideIds = new Set(slides.map((s) => s.sceneId)); + const sceneById = new Map(scenes.map((s) => [s.id, s])); + const orderedSlideScenes = slides + .map((sl) => sceneById.get(sl.sceneId)) + .filter((s): s is SceneInfo => s !== undefined); + const nonSlideScenes = scenes.filter((sc) => !slideIds.has(sc.id)); + const rows = [...orderedSlideScenes, ...nonSlideScenes]; return (
- {scenes.map((scene) => { - const isSlide = slides.some((s) => s.sceneId === scene.id); + {rows.map((scene) => { + const isSlide = slideIds.has(scene.id); const isSelected = selectedSceneId === scene.id; return (
{fragments.length > 0 ? (
- {fragments.map((t) => ( + {fragments.map((t, i) => ( {t.toFixed(2)}s @@ -206,6 +213,9 @@ export interface BranchTreeProps { onRenameSequence: (id: string, label: string) => void; onDeleteSequence: (id: string) => void; onAssign: (sequenceId: string, sceneId: string, assign: boolean) => void; + selectedSceneId: string | null; + selectedSequenceId: string | null; + onSelectBranchSlide: (sequenceId: string, sceneId: string) => void; } export function BranchTree({ @@ -215,6 +225,9 @@ export function BranchTree({ onRenameSequence, onDeleteSequence, onAssign, + selectedSceneId, + selectedSequenceId, + onSelectBranchSlide, }: BranchTreeProps) { const [newLabel, setNewLabel] = useState(""); const inputId = useId(); @@ -262,6 +275,9 @@ export function BranchTree({ onRename={onRenameSequence} onDelete={onDeleteSequence} onAssign={onAssign} + selectedSceneId={selectedSceneId} + selectedSequenceId={selectedSequenceId} + onSelectBranchSlide={onSelectBranchSlide} /> ))}
@@ -276,9 +292,21 @@ interface BranchItemProps { onRename: (id: string, label: string) => void; onDelete: (id: string) => void; onAssign: (sequenceId: string, sceneId: string, assign: boolean) => void; + selectedSceneId: string | null; + selectedSequenceId: string | null; + onSelectBranchSlide: (sequenceId: string, sceneId: string) => void; } -function BranchItem({ seq, scenes, onRename, onDelete, onAssign }: BranchItemProps) { +function BranchItem({ + seq, + scenes, + onRename, + onDelete, + onAssign, + selectedSceneId, + selectedSequenceId, + onSelectBranchSlide, +}: BranchItemProps) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(seq.label); @@ -330,19 +358,34 @@ function BranchItem({ seq, scenes, onRename, onDelete, onAssign }: BranchItemPro
{scenes.map((scene) => { const assigned = seq.slides.some((s) => s.sceneId === scene.id); + const isSelected = selectedSequenceId === seq.id && selectedSceneId === scene.id; return ( - + {assigned ? ( + + ) : ( + {scene.label || scene.id} + )} +
); })} {scenes.length === 0 &&

No scenes

} @@ -382,7 +425,7 @@ export function HotspotTool({ // fallow-ignore-next-line complexity const handleMakeHotspot = useCallback(() => { if (!selectedSceneId || !targetSequenceId || !elementKey) return; - const id = `hotspot-${elementKey}-${Date.now()}`; + const id = `hotspot-${elementKey}-${crypto.randomUUID()}`; const label = hotspotLabel.trim() || elementKey; onAddHotspot(selectedSceneId, { id, label, target: targetSequenceId }); setHotspotLabel(""); diff --git a/packages/studio/src/components/panels/slideshowPanelHelpers.ts b/packages/studio/src/components/panels/slideshowPanelHelpers.ts index 24f454bcb..77b574fa2 100644 --- a/packages/studio/src/components/panels/slideshowPanelHelpers.ts +++ b/packages/studio/src/components/panels/slideshowPanelHelpers.ts @@ -48,17 +48,35 @@ export function reorderMainLineSlide( return { ...manifest, slides }; } +/** 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 { - const exists = manifest.slides.some((s) => s.sceneId === sceneId); - const slides: SlideRef[] = exists - ? manifest.slides.map((s) => (s.sceneId === sceneId ? { ...s, notes } : s)) - : [...manifest.slides, { sceneId, notes }]; - return { ...manifest, slides }; + 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. */ @@ -66,16 +84,18 @@ export function addFragment( manifest: SlideshowManifest, sceneId: string, time: number, + sequenceId?: string, ): SlideshowManifest { - const exists = manifest.slides.some((s) => s.sceneId === sceneId); - const slides: SlideRef[] = exists - ? manifest.slides.map((s) => { + 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 }; - }) - : [...manifest.slides, { sceneId, fragments: [time] }]; - return { ...manifest, slides }; + }); + return sequenceId === undefined ? [...slides, { sceneId, fragments: [time] }] : slides; + }); } /** Remove a fragment hold-point by value from a main-line slide. */ @@ -83,14 +103,15 @@ export function removeFragment( manifest: SlideshowManifest, sceneId: string, time: number, + sequenceId?: string, ): SlideshowManifest { - return { - ...manifest, - slides: manifest.slides.map((s) => { - if (s.sceneId !== sceneId) return s; - return { ...s, fragments: (s.fragments ?? []).filter((f) => f !== time) }; - }), - }; + 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. */ @@ -167,16 +188,16 @@ export function addHotspot( manifest: SlideshowManifest, sceneId: string, hotspot: SlideHotspot, + sequenceId?: string, ): SlideshowManifest { - return { - ...manifest, - slides: manifest.slides.map((s) => { + 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. */ @@ -184,12 +205,13 @@ export function removeHotspot( manifest: SlideshowManifest, sceneId: string, hotspotId: string, + sequenceId?: string, ): SlideshowManifest { - return { - ...manifest, - slides: manifest.slides.map((s) => { - if (s.sceneId !== sceneId) return s; - return { ...s, hotspots: (s.hotspots ?? []).filter((h) => h.id !== hotspotId) }; - }), - }; + return mapSlidesIn(manifest, sequenceId, (slides) => + slides.map((s) => + s.sceneId === sceneId + ? { ...s, hotspots: (s.hotspots ?? []).filter((h) => h.id !== hotspotId) } + : s, + ), + ); } diff --git a/packages/studio/src/utils/setSlideshowManifest.test.ts b/packages/studio/src/utils/setSlideshowManifest.test.ts index e37bcab8e..a8cbe3998 100644 --- a/packages/studio/src/utils/setSlideshowManifest.test.ts +++ b/packages/studio/src/utils/setSlideshowManifest.test.ts @@ -17,6 +17,11 @@ describe("buildSlideshowIslandHtml", () => { expect(html).toContain('"sceneId": "a"'); }); + it("stamps version 1, preserving an existing version", () => { + expect(buildSlideshowIslandHtml({ slides: [] })).toContain('"version": 1'); + expect(buildSlideshowIslandHtml({ version: 2, slides: [] })).toContain('"version": 2'); + }); + it("round-trips through parseSlideshowManifest", () => { const html = `${buildSlideshowIslandHtml({ slides: [{ sceneId: "x" }] })}`; const parsed = parseSlideshowManifest(html); diff --git a/packages/studio/src/utils/setSlideshowManifest.ts b/packages/studio/src/utils/setSlideshowManifest.ts index d7ddb4ae0..78fd11c17 100644 --- a/packages/studio/src/utils/setSlideshowManifest.ts +++ b/packages/studio/src/utils/setSlideshowManifest.ts @@ -17,24 +17,30 @@ */ import type { SlideshowManifest } from "@hyperframes/core/slideshow"; +import { + SLIDESHOW_ISLAND_TYPE, + SLIDESHOW_MANIFEST_VERSION, + slideshowIslandRegex, +} from "@hyperframes/core/slideshow"; import type { Composition } from "@hyperframes/sdk"; import type { CutoverDeps } from "./sdkCutover"; import { persistSdkSerialize } from "./sdkCutover"; -const ISLAND_TYPE = "application/hyperframes-slideshow+json"; - // Matches ALL // blocks (global + case-insensitive) so we can strip every stale island in one pass. -const ISLAND_RE = new RegExp( - `]*type=["']${ISLAND_TYPE.replace(/[.+]/g, "\\$&")}["'][^>]*>[\\s\\S]*?<\\/script>`, - "gi", -); +const ISLAND_RE = slideshowIslandRegex("gi"); export function buildSlideshowIslandHtml(manifest: SlideshowManifest): string { + // Stamp the schema version (preserve an existing one) so future schema + // changes can detect and migrate older islands. + const versioned: SlideshowManifest = { + version: manifest.version ?? SLIDESHOW_MANIFEST_VERSION, + ...manifest, + }; // Escape `<` and `>` so that a manifest field containing `` cannot // break out of the script tag. JSON.parse round-trips unchanged. - const json = JSON.stringify(manifest, null, 2).replace(//g, "\\u003e"); - return ``; + const json = JSON.stringify(versioned, null, 2).replace(//g, "\\u003e"); + return ``; } export interface PersistSlideshowArgs { diff --git a/registry/examples/airbnb-deck/index.html b/registry/examples/airbnb-deck/index.html index 4ad674a2b..44e2f9046 100644 --- a/registry/examples/airbnb-deck/index.html +++ b/registry/examples/airbnb-deck/index.html @@ -1491,13 +1491,19 @@