From 17b459ea5c143108165d049ee63c52631d62e6cd Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Wed, 13 Aug 2025 17:24:16 +0000 Subject: [PATCH] feat(runtime): add toolbar component Adds a basic state machine, hands it down via context, and subsequently gets consumed in the toolbar compononent to provide action buttons. --- deno.json | 6 +- deno.lock | 55 +++++- runtime/actor/machine.ts | 40 ++++ runtime/actor/setup.ts | 74 ++++++++ runtime/actor/types.ts | 33 ++++ runtime/components/presentation.ts | 69 ------- runtime/components/presentation/wc.ts | 96 ++++++++++ runtime/components/slide.ts | 18 +- runtime/components/toolbar.ts | 260 ++++++++++++++++++++++++++ runtime/context.ts | 7 + runtime/element.ts | 88 +++++++++ runtime/morkdeck.ts | 18 +- 12 files changed, 685 insertions(+), 79 deletions(-) create mode 100644 runtime/actor/machine.ts create mode 100644 runtime/actor/setup.ts create mode 100644 runtime/actor/types.ts delete mode 100644 runtime/components/presentation.ts create mode 100644 runtime/components/presentation/wc.ts create mode 100644 runtime/components/toolbar.ts create mode 100644 runtime/context.ts create mode 100644 runtime/element.ts diff --git a/deno.json b/deno.json index dcba451..3037376 100644 --- a/deno.json +++ b/deno.json @@ -15,7 +15,9 @@ "imports": { "@cliffy/command": "jsr:@cliffy/command@1.0.0-rc.8", "@deno/esbuild-plugin": "jsr:@deno/esbuild-plugin@^1.1.5", + "@es-toolkit/es-toolkit": "jsr:@es-toolkit/es-toolkit@^1.39.9", "@eta-dev/eta": "jsr:@eta-dev/eta@^3.5.0", + "@lit/context": "npm:@lit/context@^1.1.6", "@markdoc/markdoc": "https://esm.sh/@markdoc/markdoc@0.5.2", "@std/assert": "jsr:@std/assert@1", "@std/async": "jsr:@std/async@^1.0.14", @@ -25,6 +27,8 @@ "hast": "npm:@types/hast@^3.0.4", "hast-util-is-element": "npm:hast-util-is-element@^3.0.0", "lit": "npm:lit@^3.3.1", - "shiki": "npm:shiki@^3.8.1" + "motion": "npm:motion@^12.23.12", + "shiki": "npm:shiki@^3.8.1", + "xstate": "npm:xstate@^5.20.2" } } diff --git a/deno.lock b/deno.lock index 04c773b..00b52ac 100644 --- a/deno.lock +++ b/deno.lock @@ -7,6 +7,7 @@ "jsr:@cliffy/table@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@deno/esbuild-plugin@^1.1.5": "1.1.5", "jsr:@deno/loader@~0.3.3": "0.3.4", + "jsr:@es-toolkit/es-toolkit@^1.39.9": "1.39.9", "jsr:@eta-dev/eta@^3.5.0": "3.5.0", "jsr:@std/assert@1": "1.0.13", "jsr:@std/async@^1.0.14": "1.0.14", @@ -24,13 +25,17 @@ "jsr:@std/path@^1.1.1": "1.1.1", "jsr:@std/streams@^1.0.10": "1.0.10", "jsr:@std/text@~1.0.7": "1.0.15", + "npm:@lit/context@^1.1.6": "1.1.6", "npm:@markdoc/markdoc@*": "0.5.2", "npm:@types/hast@^3.0.4": "3.0.4", "npm:@types/node@*": "22.15.15", + "npm:esbuild@~0.25.5": "0.25.8", "npm:esbuild@~0.25.8": "0.25.8", "npm:hast-util-is-element@3": "3.0.0", "npm:lit@^3.3.1": "3.3.1", - "npm:shiki@^3.8.1": "3.8.1" + "npm:motion@^12.23.12": "12.23.12", + "npm:shiki@^3.8.1": "3.8.1", + "npm:xstate@^5.20.2": "5.20.2" }, "jsr": { "@cliffy/command@1.0.0-rc.8": { @@ -62,12 +67,16 @@ "integrity": "c9cde95990b97802a0da6c73c26ab4a48f30d286818845e365dddcd8297abd7d", "dependencies": [ "jsr:@deno/loader", - "jsr:@std/path" + "jsr:@std/path", + "npm:esbuild@~0.25.5" ] }, "@deno/loader@0.3.4": { "integrity": "c56003bc7027606301c3fe62704723b207a9e508c9fb154cf5131abb9d4d2673" }, + "@es-toolkit/es-toolkit@1.39.9": { + "integrity": "bce5ff73fe5e6e481c3ec6fd0da05e7a90cc7895cbee788441b47ebc280f680e" + }, "@eta-dev/eta@3.5.0": { "integrity": "6b70827efc14c7cbf08498ac7a922ecab003641caf3852a6cb5b1b12ee58fb37" }, @@ -265,6 +274,12 @@ "@lit-labs/ssr-dom-shim@1.4.0": { "integrity": "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==" }, + "@lit/context@1.1.6": { + "integrity": "sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==", + "dependencies": [ + "@lit/reactive-element" + ] + }, "@lit/reactive-element@2.1.1": { "integrity": "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==", "dependencies": [ @@ -418,6 +433,14 @@ "scripts": true, "bin": true }, + "framer-motion@12.23.12": { + "integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==", + "dependencies": [ + "motion-dom", + "motion-utils", + "tslib" + ] + }, "hast-util-is-element@3.0.0": { "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", "dependencies": [ @@ -509,6 +532,22 @@ "micromark-util-types@2.0.2": { "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==" }, + "motion-dom@12.23.12": { + "integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==", + "dependencies": [ + "motion-utils" + ] + }, + "motion-utils@12.23.6": { + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==" + }, + "motion@12.23.12": { + "integrity": "sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng==", + "dependencies": [ + "framer-motion", + "tslib" + ] + }, "oniguruma-parser@0.12.1": { "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==" }, @@ -564,6 +603,9 @@ "trim-lines@3.0.1": { "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==" }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "undici-types@6.21.0": { "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, @@ -614,6 +656,9 @@ "vfile-message" ] }, + "xstate@5.20.2": { + "integrity": "sha512-GZmLmc+WPKfFRxuTDAxCg0cUhS/ZnWaRD86DO8MKizeK4a050jd5k7UNnIQ2jJDWRig2/r0tmVXeezUNIhoz5Q==" + }, "zwitch@2.0.4": { "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==" } @@ -630,16 +675,20 @@ "dependencies": [ "jsr:@cliffy/command@1.0.0-rc.8", "jsr:@deno/esbuild-plugin@^1.1.5", + "jsr:@es-toolkit/es-toolkit@^1.39.9", "jsr:@eta-dev/eta@^3.5.0", "jsr:@std/assert@1", "jsr:@std/async@^1.0.14", "jsr:@std/http@^1.0.20", "jsr:@std/path@^1.1.1", + "npm:@lit/context@^1.1.6", "npm:@types/hast@^3.0.4", "npm:esbuild@~0.25.8", "npm:hast-util-is-element@3", "npm:lit@^3.3.1", - "npm:shiki@^3.8.1" + "npm:motion@^12.23.12", + "npm:shiki@^3.8.1", + "npm:xstate@^5.20.2" ] } } diff --git a/runtime/actor/machine.ts b/runtime/actor/machine.ts new file mode 100644 index 0000000..7eddf7b --- /dev/null +++ b/runtime/actor/machine.ts @@ -0,0 +1,40 @@ +import { machineBase } from "./setup.ts"; + +export const presentation = machineBase.createMachine({ + id: "presentation", + initial: "initializing", + context: { + presentationId: "", + currentIndex: 0, + slides: [], + sessionId: crypto.randomUUID(), + channel: new BroadcastChannel(""), + }, + states: { + initializing: { + on: { + "presentation.start": { + actions: ["registerSlides"], + target: "independent", + }, + }, + }, + independent: { + entry: ["scrollToSlide"], + on: { + "navigate.next": { + actions: ["nextSlide", "updateUrl"], + }, + "navigate.previous": { + actions: ["previousSlide", "updateUrl"], + }, + "navigate.scroll": { + actions: ["updateCurrentSlide", "updateUrl"], + }, + }, + }, + // TODO: add presenter mode + presenting: {}, + following: {}, + }, +}); diff --git a/runtime/actor/setup.ts b/runtime/actor/setup.ts new file mode 100644 index 0000000..6e3e0c1 --- /dev/null +++ b/runtime/actor/setup.ts @@ -0,0 +1,74 @@ +import { assign, setup } from "xstate"; +import { Context, Events } from "./types.ts"; + +export const machineBase = setup({ + types: { + context: {} as Context, + events: {} as Events, + }, + actions: { + registerSlides: assign(({ context, event }) => { + if (event.type === "presentation.start") { + const currentIndex = event.slides.findIndex((s) => + s.id === event.currentSlide + ); + + return { + ...context, + presentationId: event.presentationId, + slides: event.slides, + currentIndex: Math.max(currentIndex, 0), + channel: new BroadcastChannel(event.presentationId), + }; + } else { + return context; + } + }), + scrollToSlide({ context }) { + if (context.currentIndex > 0) { + const slide = context.slides[context.currentIndex]; + + document.querySelector(`#${slide.id}`)?.scrollIntoView({ + behavior: "instant", + }); + } + }, + updateUrl({ context }) { + const url = new URL(document.URL); + const hash = `#${context.slides[context.currentIndex].id}`; + + url.hash = hash; + + location.replace(url); + }, + updateCurrentSlide: assign({ + currentIndex: ({ context, event }) => { + if (event.type === "navigate.scroll") { + const index = context.slides.findIndex((s) => s.id === event.slideId); + + return Math.max(index, 0); + } + + return context.currentIndex; + }, + }), + nextSlide: assign({ + currentIndex: ({ context }) => { + if (context.currentIndex + 1 > context.slides.length - 1) { + return context.currentIndex; + } + + return context.currentIndex + 1; + }, + }), + previousSlide: assign({ + currentIndex: ({ context }) => { + if (context.currentIndex - 1 < 0) { + return context.currentIndex; + } + + return context.currentIndex - 1; + }, + }), + }, +}); diff --git a/runtime/actor/types.ts b/runtime/actor/types.ts new file mode 100644 index 0000000..cd4121b --- /dev/null +++ b/runtime/actor/types.ts @@ -0,0 +1,33 @@ +import type { ActorRefFrom } from "xstate"; +import type { Slide } from "../components/slide.ts"; +import { presentation } from "./machine.ts"; + +export interface Context { + presentationId: string; + sessionId: string; + currentIndex: number; + slides: Slide[]; + channel: BroadcastChannel; + // slideObserver: IntersectionObserver; +} + +type NavigationEvent = { + type: "navigate.next"; +} | { + type: "navigate.previous"; +} | { + type: "navigate.scroll"; + slideId: string; +}; + +type PresentationEvent = { + type: "presentation.start"; + presentationId: string; + currentSlide?: string; + slides: Slide[]; + // observer: IntersectionObserver; +}; + +export type Events = NavigationEvent | PresentationEvent; + +export type Presentation = ActorRefFrom; diff --git a/runtime/components/presentation.ts b/runtime/components/presentation.ts deleted file mode 100644 index f17b365..0000000 --- a/runtime/components/presentation.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { css, html, LitElement } from "lit"; - -export class Presentation extends LitElement { - observer: IntersectionObserver; - - static override properties = { - uuid: { type: String }, - }; - - static override styles = css` - :host { - display: block; - width: 100vw; - height: 100vh; - overflow-y: scroll; - scroll-behavior: smooth; - scroll-snap-type: y mandatory; - } - `; - - constructor() { - super(); - - this.observer = new IntersectionObserver(this.updateUrl, { - root: this, - threshold: 1, - }); - } - - override connectedCallback() { - super.connectedCallback(); - document.addEventListener("readystatechange", () => { - if (document.readyState === "complete") { - const url = new URL(document.URL); - - if (url.hash) { - document.querySelector(url.hash)?.scrollIntoView({ - behavior: "instant", - }); - } - - document.querySelectorAll("morkdeck-slide").forEach((e) => - this.observer.observe(e) - ); - } - }); - } - - updateUrl(entries: IntersectionObserverEntry[]) { - for (const entry of entries) { - if (entry.isIntersecting) { - const url = new URL(document.URL); - const id = entry.target.getAttribute("id"); - - url.hash = `#${id}`; - - location.replace(url.toString()); - - break; - } - } - } - - override render() { - return html` - - `; - } -} diff --git a/runtime/components/presentation/wc.ts b/runtime/components/presentation/wc.ts new file mode 100644 index 0000000..225f357 --- /dev/null +++ b/runtime/components/presentation/wc.ts @@ -0,0 +1,96 @@ +import { css, html, LitElement, unsafeCSS } from "lit"; +import { + customElement, + property, + queryAssignedElements, + state, +} from "lit/decorators.js"; +import { provide } from "@lit/context"; +import { createActor } from "xstate"; +import { spring } from "motion"; +import { presentation } from "../../actor/machine.ts"; +import type { Presentation } from "../../actor/types.ts"; +import { Slide } from "../slide.ts"; +import { context, observerContext } from "../../context.ts"; + +@customElement("morkdeck-presentation") +export class PresentationWC extends LitElement { + @provide({ context }) + accessor presentation: Presentation = createActor(presentation).start(); + + @provide({ context: observerContext }) + accessor observer: IntersectionObserver = new IntersectionObserver( + this.fireUpdates.bind(this), + { + root: this, + threshold: 0.7, + }, + ); + + @state() + accessor currentIndex = 0; + + @property({ type: String }) + accessor uuid: string = ""; + + @queryAssignedElements({ selector: "morkdeck-slide" }) + accessor slides: Array = []; + + static override styles = css` + :host { + display: block; + width: 100vw; + height: 100vh; + overflow-y: scroll; + scroll-behavior: smooth; + scroll-snap-type: y mandatory; + + --presentation-spring: ${unsafeCSS(spring(0.2, 0.1))}; + } + `; + + override connectedCallback() { + super.connectedCallback(); + + document.addEventListener("readystatechange", () => { + // Delay this until page load so that children have been parsed + if (document.readyState === "complete") { + const url = new URL(document.URL); + const currentSlide = url.hash.substring(1); + + this.presentation.send({ + presentationId: this.uuid, + type: "presentation.start", + slides: this.slides, + currentSlide: currentSlide || undefined, + }); + } + }); + } + + // startPresentation() { + // const actor = createActor(presentation); + + // return actor.start(); + // } + + fireUpdates(entries: IntersectionObserverEntry[]) { + for (const entry of entries) { + if (entry.isIntersecting) { + this.presentation.send({ + type: "navigate.scroll", + slideId: entry.target.id, + }); + + break; + } + } + } + + override render() { + return html` + + + `; + } +} diff --git a/runtime/components/slide.ts b/runtime/components/slide.ts index 0df89e9..6248dd5 100644 --- a/runtime/components/slide.ts +++ b/runtime/components/slide.ts @@ -1,6 +1,11 @@ -import { css, html, LitElement } from "lit"; +import { css, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { consume } from "@lit/context"; +import { observerContext } from "../context.ts"; +import { MorkdeckElement } from "../element.ts"; -export class Slide extends LitElement { +@customElement("morkdeck-slide") +export class Slide extends MorkdeckElement { static override styles = css` :host { display: block; @@ -30,6 +35,15 @@ export class Slide extends LitElement { } `; + @consume({ context: observerContext }) + accessor slideObserver = new IntersectionObserver(() => {}); + + override connectedCallback(): void { + super.connectedCallback(); + + this.slideObserver.observe(this); + } + override render() { return html`
diff --git a/runtime/components/toolbar.ts b/runtime/components/toolbar.ts new file mode 100644 index 0000000..438c139 --- /dev/null +++ b/runtime/components/toolbar.ts @@ -0,0 +1,260 @@ +import { customElement } from "lit/decorators.js"; +import { MorkdeckElement, subscribe } from "../element.ts"; +import { css, html } from "lit"; + +@customElement("morkdeck-toolbar") +export class Toolbar extends MorkdeckElement { + static override styles = css` + :host * { + box-sizing: border-box; + } + + dialog { + all: unset; + } + + button { + all: unset; + } + + .toolbar-container { + position: fixed; + inset: auto 0 0 0; + display: grid; + grid-template-rows: 1fr 2fr; + } + + .toolbar-container > .toolbar { + transform: translateY(100%); + } + + .toolbar-container:hover > .toolbar { + transform: translateY(0); + } + + .toolbar { + display: flex; + grid-row-start: 2; + align-items: center; + justify-content: space-between; + background-color: #26233a; + border-top: 1px solid #403d52; + transition: transform var(--presentation-spring); + } + + .toolset { + border-left: 1px solid; + border-right: 1px solid; + border-color: #403d52; + display: grid; + grid-auto-columns: auto; + grid-auto-flow: column; + align-items: center; + justify-content: center; + } + + .toolset > * + * { + border-left: 1px solid; + border-color: #403d52; + } + + .tool { + display: flex; + align-items: center; + justify-content: center; + color: inherit; + padding: 0.5em; + } + + .info { + flex: 1; + padding: 0 1em; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 0.25em; + } + `; + + @subscribe("currentIndex") + accessor currentIndex = 0; + + @subscribe("slides") + accessor slides = []; + + toggleFullscreen() { + if (!document.fullscreenElement) { + document.body.requestFullscreen(); + } else { + document.exitFullscreen(); + } + } + + toggleOverview() { + // TODO: Display all slides in overview + } + + goPrev() { + this.deck.send({ type: "navigate.previous" }); + } + + goNext() { + this.deck.send({ type: "navigate.next" }); + } + + toggleShare() { + // TODO: display share menu + } + + togglePresenterMode() { + // TODO: toggle presenter mode + } + + override render() { + return html` +
+ +
+ + +
+
+ +
+ + ${this.currentIndex + 1} of ${this.slides.length} + +
+ +
+
+ + +
+
+
+ `; + } +} diff --git a/runtime/context.ts b/runtime/context.ts new file mode 100644 index 0000000..83e7047 --- /dev/null +++ b/runtime/context.ts @@ -0,0 +1,7 @@ +import { createContext } from "@lit/context"; + +export const context = createContext(Symbol("morkdeck-presentation")); + +export const observerContext = createContext( + Symbol("morkdeck-presentation-observer"), +); diff --git a/runtime/element.ts b/runtime/element.ts new file mode 100644 index 0000000..42584c1 --- /dev/null +++ b/runtime/element.ts @@ -0,0 +1,88 @@ +import { LitElement } from "lit"; +import { consume } from "@lit/context"; +import { context } from "./context.ts"; +import { isEqual } from "@es-toolkit/es-toolkit"; +import { Context, Presentation } from "./actor/types.ts"; +import { Subscription } from "xstate"; + +interface ContextSetter { + (value: V): void; +} +type SubscribedKeys< + C = Context, + K extends keyof C = keyof C, + V extends C[K] = C[K], +> = { + [K in keyof C]?: ContextSetter; +}; + +export class MorkdeckElement extends LitElement { + @consume({ context }) + accessor deck: Presentation; + + #subscription?: Subscription; + #subscribedKeys: SubscribedKeys = {}; + + constructor() { + super(); + + this.deck = {} as unknown as Presentation; + } + + subscribeTo( + key: keyof Context, + setter: ContextSetter, + ) { + if (key in this.#subscribedKeys) return; + + this.#subscribedKeys[key] = setter; + this.subscribe(); + } + + subscribe() { + if (!this.#subscription) { + this.#subscription = this.deck.subscribe(({ context }) => { + for (const [key, setter] of Object.entries(this.#subscribedKeys)) { + setter(context[key as keyof Context]); + } + }); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + + this.#subscription?.unsubscribe(); + } +} + +export function subscribe< + C extends Context = Context, + K extends keyof C = keyof C, + V extends C[K] = C[K], +>( + key: K, +): ( + target: ClassAccessorDecoratorTarget, + context: ClassAccessorDecoratorContext, +) => ClassAccessorDecoratorResult { + return (target, context) => { + const { name } = context; + + return { + get(this) { + this.subscribeTo(key as keyof Context, (value) => { + const oldValue = target.get.call(this); + target.set.call(this, value as V); + + this.requestUpdate(name, oldValue, { + attribute: false, + hasChanged: isEqual, + }); + }); + + return target.get.call(this); + }, + }; + }; +} diff --git a/runtime/morkdeck.ts b/runtime/morkdeck.ts index 4118d18..5e20299 100644 --- a/runtime/morkdeck.ts +++ b/runtime/morkdeck.ts @@ -1,5 +1,15 @@ -import { Presentation } from "./components/presentation.ts"; -import { Slide } from "./components/slide.ts"; +import type { PresentationWC } from "./components/presentation/wc.ts"; +import type { Toolbar } from "./components/toolbar.ts"; +import type { Slide } from "./components/slide.ts"; -customElements.define("morkdeck-presentation", Presentation); -customElements.define("morkdeck-slide", Slide); +import "./components/presentation/wc.ts"; +import "./components/toolbar.ts"; +import "./components/slide.ts"; + +declare global { + interface HTMLElementTagNameMap { + "morkdeck-presentation": PresentationWC; + "morkdeck-slide": Slide; + "morkdeck-toolbar": Toolbar; + } +} -- 2.51.2