mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(slideshow): address code-review findings #1580-1584 (#1585)
* fix(slideshow): address code-review findings #1580-1584
- player: bundle @hyperframes/core into the IIFE/global build (noExternal)
- player: resolve audience mode from ?mode=audience URL query, not just attr
- player: event-driven waitForScenes + loud failure when no slides resolve
- player: scope window keydown so Space/Backspace don't hijack the host page
- player: audience mirrors full position (branch + fragment) via syncTo
- player: next() reveals remaining fragments even at slide end; enterBranch ignores empty sequences
- core: harden extractScenes against null/non-object scene entries
- core: strict manifest validation; error on inverted ranges & empty hotspot targets; dedup fragments
- core/lint: accept data-end/timeline-derived scene durations (match runtime)
- core+studio: share ISLAND_TYPE + island regex from @hyperframes/core/slideshow
- studio: SlideList reflects manifest slide order; branch-slide authoring (notes/fragments/hotspots)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(player): slideshow fullscreen + presenter-view rework
- fullscreen toggle in the nav chrome (button + 'F' key); standard Fullscreen
API on the <hyperframes-slideshow> element, icon reflects state
- presenter console: live slide on top, speaker-notes panel below, with the nav
controls shown in-view; Present button hides once presenting (harness)
- audience (viewer) window: chrome reduced to a fullscreen-only control, no nav
- fix: audience / back() / backToMain() mirror stayed frozen on the first frame —
a bare paused seek does not repaint some compositions. resumeSlide now plays a
brief render-nudge (RENDER_NUDGE) past the target so the composition paints,
then onTime pauses at the hold
- refactor: extract reusable buildNavCluster() + wireChromeButtons(); rework
buildPresenterLayout into the bottom notes panel
- example: airbnb-deck presenter-test.html harness (Present button + 'F')
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(player): slideshow no auto-progress + presenter slide fits/pins
- navigation jumps to a static frame instead of auto-playing the timeline:
playTo() seeks to the hold (+ a brief RENDER_NUDGE to repaint) rather than
sustaining playback, so slides hold until the user advances
- presenter view: pin the live slide to the top and confine the player to the
region above the notes panel, so the player CONTAINS the composition — the
full slide stays visible (letterboxed) at any width and re-fits on resize;
its bottom is no longer cut off by the notes panel
- tests: seek targets updated for the render-nudge offset
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): presenter nav flash, slide-1 boundary, branch buttons
Three presenter-mode fixes from testing the airbnb deck: (1) navigation flash — seek to the exact target then play forward to repaint, instead of seeking backward (t-0.2) which painted the previous scene at boundaries; split hold into holdTarget (logical) and holdAt (target+nudge, clamped to slide.end). (2) slide-1 boundary — no-fragment slides rest at the slide midpoint, not slide.end. (3) presenter branch buttons — surface hotspots as buttons in the presenter console (the on-slide pill is lost in the letterboxed view). Also extract paintChrome() to dedupe the three chrome-render sites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): stop presenter nav buttons flickering / dropping clicks
The presenter elapsed clock called render() every second, which rebuilt the
entire chrome (innerHTML) including the nav buttons — they flickered and any
click landing mid-rebuild was lost. The 1s tick now updates only the elapsed
text node; the nav buttons are rebuilt only on actual navigation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): CSP-safe nav hover, UUID editor ids, manifest version
Addresses review feedback on the split stack:
- CSP: replace the 8 inline onmouseover/onmouseout handlers on the nav
buttons with a [data-hf-nav-cluster] button:hover CSS rule (injected once
per document). No inline event handlers → works under strict CSP.
- IDs: studio sequence/hotspot id generation used Date.now() (sub-ms
collision on rapid clicks) — now crypto.randomUUID().
- Versioning: stamp version on the persisted manifest island (preserving an
existing one); add the optional version field + SLIDESHOW_MANIFEST_VERSION
to the core schema so future schema changes can migrate older islands.
These live on the review-fixes tip (consistent with the stack's fixup-on-tip
model); the touched code belongs to ss-player-b (#1590), ss-studio-a/b
(#1591/#1592), and ss-core (#1580).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(ci): fix format + fallow gates for slideshow stack
- .prettierignore: exclude generated demo compositions (registry/examples/**/*.html)
from oxfmt — large video-pipeline output (GSAP/Three/WebGL), not hand-authored
source. Was failing 'Format' repo-wide (pre-existing on main via #1584).
- .fallowrc: exempt SlideshowPanel.tsx (health/complexity — section fan-out) and
the slideshowPanelHelpers.ts / SlideshowPanel.test.ts parallel-structure clones
(duplicates.ignore). File-level config, not inline comments — inline shifts line
numbers and breaks fallow's inherited-finding fingerprint (per existing rc note).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): address PR review + CodeQL findings
- CodeQL #638 (parseSlideshow): complete the regex metachar escape in
slideshowIslandRegex (was missing backslash); add JSDoc on the factory +
lastIndex caveat (reviewer 5a/16).
- CodeQL #639/#640 + review items 13/17: remove registry/examples/airbnb-deck/
presenter-test.html — a generated test harness (postMessage w/o origin check,
proto-pollution) that was scope-creep into a fix PR and a 3rd duplicate island.
Regenerate locally via the scratchpad script when testing.
- Review item 15 (docs drift in skills/slideshow/SKILL.md): lint resolves scenes
by data-composition-id only (not .clip[id]); fragments are valid INCLUSIVE of
[start,end], not 'strictly inside'.
IIFE bundles core confirmed (0 external @hyperframes/core refs in the slideshow
global build). format/lint/fallow green.
* feat(cli): add 'present' command — serve a deck in presenter mode
hyperframes present [dir] starts a lightweight HTTP server, wraps the
composition in <hyperframes-slideshow> with its island inlined, and opens
the browser. A real HTTP origin is required for presenter mode: present()
opens the audience window via window.open(?mode=audience) and the two sync
over BroadcastChannel — neither works from file://.
- New utils/compositionServer.ts factors the server scaffolding shared with
'play' (resolve runtime/player/slideshow bundles, inject runtime, asset
content-types, bind to a free port); play.ts now uses it too.
- Errors clearly if the deck has no slideshow island.
- .fallowrc: exempt the play/present command entrypoints (validation + server
wiring) and the per-command startup/logging block from the complexity /
duplication gates.
Verified end-to-end against registry/examples/airbnb-deck: server serves the
wrapper + assets, the component binds and renders (counter 1 / 11).
* fix(cli): present renders the deck (player sizing + self-driving serve)
Two bugs caused a black slide area:
- The <hyperframes-player> had no positioning, so its iframe collapsed to
zero size — the (absolutely-positioned) chrome showed but the composition
didn't. Add position:absolute; inset:0 (matches demo.html).
- The composition was served with the engine runtime injected, which leaves
its timelines engine-paused (blank). Slideshow decks self-drive their own
timelines (like demo.html / the standalone harness), so serve them raw.
Verified end-to-end on registry/examples/airbnb-deck: cover renders, Next
advances 1/11 -> 2/11 and slide 2 paints.
* fix(cli): present plays slideshow sound effects
The composition (in the player's sandboxed iframe) posts
{ type: 'hf-sfx', name } to the parent on nav, but the iframe is
autoplay-blocked — audio must play in the parent that owns the user gesture.
Add the parent-side hf-sfx handler (the 4 standard clips advance/fragment/
branch-enter/back, served from the deck's sfx/ under /composition/sfx/),
gesture-unlocked and mute-aware, in both presenter and audience windows.
Verified: sfx serve 200 (audio/mpeg) and Next delivers [advance, fragment]
to the parent handler.
* feat(examples): softer mellow slideshow sfx for airbnb-deck
Replace the aggressive percussive pops with gentle sine-tone cues (warm
pitches C5/G4/E5/F4, 12ms attack + exponential decay, lowpassed) — advance/
fragment/branch-enter/back. Much lighter; fragment is the most subtle.
* feat(examples): whoosh + sparkle slideshow sfx for airbnb-deck
Replace the sine-tone cues with airy, designed sounds:
- advance: a soft whoosh (band-limited pink noise, bell-shaped swell)
- back: that whoosh reversed and darkened
- fragment: a light sparkle (staggered high chime blips)
- branch-enter: whoosh + a trailing sparkle (magical entry)
* feat(examples): directional whoosh + richer branch-enter cue (airbnb-deck)
- Going backward a slide now plays the reverse whoosh (back), not advance —
the sfx logic detects nav direction by scene order instead of firing advance
for every scene change.
- branch-enter is now a more interesting magical cue: a faint whoosh + an
ascending C5-E5-G5-C6 chime arpeggio + a trailing sparkle.
Verified: next then prev fires [advance, fragment, back]; no page errors.
* fix(cli): harden present sfx handler + mute-hover affordance (R2 review)
Addresses Rames R2 items 19-21:
- 20: the present audio handler reintroduced the CodeQL classes removed with
presenter-test.html — add an origin check (same-origin composition iframe)
and an own-property guard so a 'name' like __proto__ can't resolve to and
mutate Object.prototype.
- 21: assetContentType used a bare index lookup (ext='__proto__' -> prototype);
guard with Object.hasOwn.
- 19: the CSP hover rule erased the speaker button's muted color; add a
higher-specificity [data-hf-muted] [data-hf-mute]:hover override.
Verified: hf-sfx origin matches location.origin (guard passes), advance/fragment
still fire, deck renders + advances. Items 14/18/22 deferred (minor, pre-existing).
* fix(slideshow): address remaining R2 items (14/18/22) + re-remove harness
- 14: resumeSlide now mirrors enterSlide — a no-fragment slide resumes at its
midpoint (visible-at-rest), not frame-0; fragmented slides still resume to the
saved fragment or slide.start. Added a dedicated test naming the heuristic.
- 18: fullscreenchange swaps only the fullscreen glyph + aria (hoisted SVGs to
module consts) instead of re-rendering the whole chrome.
- 22: .prettierignore lists the specific generated demo compositions instead of
blanket registry/examples/**/*.html, so hand-authored example HTML still formats.
- presenter-test.html: a stray 54a4460 git add -A had re-added the deleted
harness (reviving CodeQL #639/#640); remove it again.
106 slideshow tests pass; tsc/lint/fallow/format clean; deck still renders.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ae40498433
commit
cc2220e59e
@@ -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),
|
||||
|
||||
@@ -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<string, unknown>)["id"] === "string" &&
|
||||
typeof (s as Record<string, unknown>)["start"] === "number" &&
|
||||
typeof (s as Record<string, unknown>)["duration"] === "number",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,25 +63,35 @@ describe("<hyperframes-slideshow>", () => {
|
||||
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("<hyperframes-slideshow> 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("<hyperframes-slideshow> presenter mode", () => {
|
||||
currentSlide: { hotspots: [] },
|
||||
nextSlide: null,
|
||||
});
|
||||
return { el, getGotoIndex: () => gotoIndex };
|
||||
return { el, getLastSync: () => lastSync };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,9 +523,9 @@ describe("<hyperframes-slideshow> presenter mode", () => {
|
||||
|
||||
const tick = () => new Promise<void>((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("<hyperframes-slideshow> 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();
|
||||
|
||||
@@ -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 = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3"/></svg>`;
|
||||
const EXIT_FS_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3v3a2 2 0 0 1-2 2H3M21 8h-3a2 2 0 0 1-2-2V3M3 16h3a2 2 0 0 1 2 2v3M16 21v-3a2 2 0 0 1 2-2h3"/></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 <hyperframes-player>. 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 = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>`;
|
||||
const speakerMutedSvg = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>`;
|
||||
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}</button>`
|
||||
: "";
|
||||
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)';"
|
||||
>‹</button>`
|
||||
style="${btnStyle}" >‹</button>`
|
||||
: "";
|
||||
const nextBtnHtml = showNext
|
||||
? `<button
|
||||
data-hf-next
|
||||
type="button"
|
||||
aria-label="Next 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)';"
|
||||
>›</button>`
|
||||
style="${btnStyle}" >›</button>`
|
||||
: "";
|
||||
// Counter padding adjusts so the pill looks centered when one button is absent.
|
||||
const counterPadLeft = showPrev ? "4px" : "10px";
|
||||
const counterPadRight = showNext ? "4px" : "10px";
|
||||
const navClusterHtml = `
|
||||
const isFs = document.fullscreenElement === this;
|
||||
const fsBtnHtml = `<button
|
||||
data-hf-fullscreen
|
||||
type="button"
|
||||
aria-label="${isFs ? "Exit full screen" : "Full screen"}"
|
||||
aria-pressed="${isFs ? "true" : "false"}"
|
||||
style="${btnStyle}" >${isFs ? EXIT_FS_SVG : ENTER_FS_SVG}</button>`;
|
||||
// Audience/viewer: only the fullscreen control (no navigation).
|
||||
if (variant === "fs-only") {
|
||||
return `
|
||||
<div
|
||||
data-hf-nav-cluster
|
||||
style="position:absolute;bottom:28px;right:32px;display:inline-flex;align-items:center;gap:2px;background:rgba(20,20,22,0.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,0.12);border-radius:999px;box-shadow:0 4px 24px rgba(0,0,0,0.45);padding:4px;pointer-events:auto;"
|
||||
style="position:absolute;bottom:${bottomCss};right:32px;display:inline-flex;align-items:center;background:rgba(20,20,22,0.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,0.12);border-radius:999px;box-shadow:0 4px 24px rgba(0,0,0,0.45);padding:4px;pointer-events:auto;"
|
||||
>${fsBtnHtml}</div>`;
|
||||
}
|
||||
const counterPadLeft = showPrev ? "4px" : "10px";
|
||||
const counterPadRight = showNext ? "4px" : "10px";
|
||||
return `
|
||||
<div
|
||||
data-hf-nav-cluster
|
||||
style="position:absolute;bottom:${bottomCss};right:32px;display:inline-flex;align-items:center;gap:2px;background:rgba(20,20,22,0.55);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,0.12);border-radius:999px;box-shadow:0 4px 24px rgba(0,0,0,0.45);padding:4px;pointer-events:auto;"
|
||||
>
|
||||
${muteBtnHtml}
|
||||
${showSound ? `<span aria-hidden="true" style="width:1px;height:20px;background:rgba(255,255,255,0.12);margin:0 2px;flex-shrink:0;"></span>` : ""}
|
||||
@@ -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}</span>
|
||||
${nextBtnHtml}
|
||||
</div>
|
||||
`;
|
||||
<span aria-hidden="true" style="width:1px;height:20px;background:rgba(255,255,255,0.12);margin:0 2px;flex-shrink:0;"></span>
|
||||
${fsBtnHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
this.chrome.innerHTML = hotspotsHtml + navClusterHtml;
|
||||
|
||||
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<typeof setTimeout> | null = null;
|
||||
let iterations = 0;
|
||||
const poll = (): void => {
|
||||
if (isCancelled()) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const current = readScenes(player);
|
||||
if (current.length > 0) {
|
||||
resolve(current);
|
||||
return;
|
||||
}
|
||||
iterations += 1;
|
||||
if (iterations >= maxIterations) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 100);
|
||||
|
||||
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);
|
||||
};
|
||||
setTimeout(poll, 100);
|
||||
const onScenes = (): void => {
|
||||
if (isCancelled()) return finish([]);
|
||||
const s = readScenes(player);
|
||||
if (s.length > 0) finish(s);
|
||||
};
|
||||
const poll = (): void => {
|
||||
if (done) return;
|
||||
if (isCancelled()) return finish([]);
|
||||
const cur = readScenes(player);
|
||||
if (cur.length > 0) return finish(cur);
|
||||
iterations += 1;
|
||||
if (iterations >= maxIterations) return finish([]);
|
||||
timer = setTimeout(poll, 100);
|
||||
};
|
||||
|
||||
player.addEventListener("scenes", onScenes);
|
||||
timer = setTimeout(poll, 100);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "<").replace(/>/g, ">");
|
||||
|
||||
const escAttr = (s: string) => esc(s).replace(/"/g, """);
|
||||
const notes = opts.notes
|
||||
? esc(opts.notes)
|
||||
: `<span style="opacity:.4">No notes for this slide</span>`;
|
||||
// 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
|
||||
? `<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
<div style="font-size:12px;text-transform:uppercase;letter-spacing:.12em;opacity:.55;">Branches</div>
|
||||
${opts.hotspots
|
||||
.map(
|
||||
(h) =>
|
||||
`<button data-hotspot-id="${escAttr(h.id)}" data-hotspot-target="${escAttr(h.target)}" type="button" style="text-align:left;background:rgba(244,183,64,0.14);color:#f4b740;border:1px solid rgba(244,183,64,0.4);border-radius:8px;padding:8px 12px;font-size:15px;cursor:pointer;pointer-events:auto;font-family:inherit;">↳ ${esc(h.label)}</button>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>`
|
||||
: "";
|
||||
return `
|
||||
<div data-hf-presenter style="display:grid;grid-template-columns:2fr 1fr;grid-template-rows:auto 1fr;gap:12px;padding:12px;height:100%;box-sizing:border-box;background:#1a1a1a;color:#fff;font-family:sans-serif;">
|
||||
<div data-hf-presenter-current style="grid-column:1;grid-row:1/3;border:2px solid #444;border-radius:6px;overflow:hidden;position:relative;">
|
||||
${opts.currentSlideHtml}
|
||||
</div>
|
||||
<div style="grid-column:2;grid-row:1;display:flex;flex-direction:column;gap:8px;">
|
||||
<div style="font-size:11px;text-transform:uppercase;letter-spacing:.08em;opacity:.6;">Next</div>
|
||||
<div data-hf-presenter-next style="border:1px solid #333;border-radius:4px;overflow:hidden;opacity:.7;">
|
||||
${opts.nextSlideHtml}
|
||||
<div data-hf-presenter style="position:absolute;left:0;right:0;bottom:0;height:32%;display:flex;background:#11151f;color:#fff;border-top:2px solid rgba(255,255,255,0.12);box-sizing:border-box;font-family:sans-serif;pointer-events:auto;">
|
||||
<div data-hf-presenter-notes style="flex:1;min-width:0;padding:24px 36px;overflow-y:auto;font-size:21px;line-height:1.55;">${notes}</div>
|
||||
<div style="width:380px;flex-shrink:0;border-left:1px solid rgba(255,255,255,0.12);padding:24px 28px;display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="font-size:12px;text-transform:uppercase;letter-spacing:.12em;opacity:.55;">Up next</div>
|
||||
<div data-hf-presenter-next style="font-size:17px;opacity:.9;line-height:1.4;">${esc(opts.nextText)}</div>
|
||||
${branches}
|
||||
<div style="display:flex;gap:34px;margin-top:auto;">
|
||||
<div><div style="font-size:11px;text-transform:uppercase;letter-spacing:.1em;opacity:.5;margin-bottom:3px;">Slide</div><div data-hf-presenter-counter style="font-size:23px;font-variant-numeric:tabular-nums;">${esc(opts.counterText)}</div></div>
|
||||
<div><div style="font-size:11px;text-transform:uppercase;letter-spacing:.1em;opacity:.5;margin-bottom:3px;">Elapsed</div><div data-hf-presenter-elapsed style="font-size:23px;font-variant-numeric:tabular-nums;">${esc(opts.elapsedText)}</div></div>
|
||||
</div>
|
||||
<div data-hf-presenter-counter style="font-size:13px;opacity:.8;">${esc(opts.counterText)}</div>
|
||||
<div data-hf-presenter-elapsed style="font-size:13px;font-variant-numeric:tabular-nums;">${esc(opts.elapsedText)}</div>
|
||||
</div>
|
||||
<div data-hf-presenter-notes style="grid-column:2;grid-row:2;overflow-y:auto;font-size:13px;line-height:1.5;opacity:.9;border-top:1px solid #333;padding-top:8px;">${esc(opts.notes)}</div>
|
||||
</div>`.trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user