function toFiniteInt(value: unknown): number { const asInt = Math.round(Number(value)); if (Number.isFinite(asInt) && !Number.isNaN(asInt)) { return asInt; } return 0; } function toPositiveFiniteInt(value: unknown): number { return Math.abs(toFiniteInt(value)); } function parseKeyframesAttributeValue(value: unknown): number[] { if (value) { return String(value) .split(/\s+/) .map(toPositiveFiniteInt) .sort((a, b) => a - b); } return []; } export class CodeMovieRuntime extends HTMLElement { // The template function must be public to allow users to replace it static _template(): Element[] { const tmp = document.createElement("div"); tmp.innerHTML = `
`; return Array.from(tmp.children); } // Shadow DOM must be open to allow users to mess with its contents _shadow = this.attachShadow({ mode: "open" }); // Controls aux content visibility. This should _not_ be messed with manually. #auxStyles = new CSSStyleSheet(); // ElementInternals must NOT be accessible, the element relies on having // control over its custom states #internals = this.attachInternals(); // List of the keyframe indices, sorted in ascending order #keyframes: number[] = []; // Current index in the array of keyframes. The public getter "current" // is derived from #keyframes and #keyframeIdx #keyframeIdx = 0; // Next index in the array of keyframes. Always null except when the event // `cm-beforeframechange` fires. #nextKeyframeIdx: null | number = null; constructor() { super(); const content = CodeMovieRuntime._template(); this._shadow.append(...content); this._shadow.addEventListener("click", this._handleClick); const defaultSlot = this._shadow.querySelector("slot:not([name])"); if (!defaultSlot) { throw new Error("Template does not contain a default slot"); } defaultSlot.addEventListener("slotchange", () => this._goToCurrent()); this._shadow.adoptedStyleSheets.push(this.#auxStyles); } static with(length: number): CodeMovieRuntime { const instance = new this(); instance.keyframes = Array.from(Array.from({ length }).keys()); return instance; } // Of the three existing attributes, "controls" does not need to be observed, // because its effects are handled by CSS alone. static get observedAttributes() { return ["keyframes", "current"]; } attributeChangedCallback(name: string, oldValue: unknown, newValue: string) { if (oldValue === newValue) { return; } if (name === "keyframes") { this.#keyframes = parseKeyframesAttributeValue(newValue); this.#updateAuxStyles(); this._goToCurrent(); } else if (name === "current") { this.#keyframeIdx = this._toKeyframeIdx(newValue); this._goToCurrent(); } } get controls(): boolean { return this.hasAttribute("controls"); } set controls(value) { if (value) { this.setAttribute("controls", "controls"); } else { this.removeAttribute("controls"); } } get keyframes() { return this.#keyframes; } set keyframes(value: any[]) { if (Array.isArray(value)) { value = Array.from( new Set(value.map(toPositiveFiniteInt).sort((a, b) => a - b)), ); this.setAttribute("keyframes", value.join(" ")); this.#keyframes = value; } else { this.removeAttribute("keyframes"); this.#keyframes = []; } this.#updateAuxStyles(); this._goToCurrent(); } _toKeyframeIdx(inputValue: unknown): number { let value = toFiniteInt(inputValue); if (value < 0) { value = Math.abs(value) - 1; } if (value > this.maxFrame) { value = this.maxFrame; } return this.#keyframes.indexOf(value); } get current(): number { return this.#keyframes[this.#keyframeIdx] || 0; } set current(inputValue: unknown) { const newKeyframeIdx = this._toKeyframeIdx(inputValue); if (newKeyframeIdx !== -1) { this.#keyframeIdx = newKeyframeIdx; this.setAttribute("current", String(this.#keyframes[newKeyframeIdx])); } else { this.#keyframeIdx = 0; this.setAttribute("current", "0"); } } get nextCurrent(): number | null { if (this.#nextKeyframeIdx) { return this.#keyframes[this.#keyframeIdx] || null; } return null; } get maxFrame(): number { return Math.max(...this.keyframes); } _goToCurrent(): boolean { let targetKeyframeIdx = this.#keyframeIdx; if (!(targetKeyframeIdx in this.#keyframes)) { if (this.#keyframes.length >= 1) { if (targetKeyframeIdx < 0) { targetKeyframeIdx = this.#keyframes.length - 1; } else { targetKeyframeIdx = 0; } } else { targetKeyframeIdx = 0; } } this.#nextKeyframeIdx = targetKeyframeIdx; const proceed = this.dispatchEvent( new Event("cm-beforeframechange", { bubbles: true, cancelable: true }), ); this.#nextKeyframeIdx = null; if (!proceed) { return false; } this._setClassesAndStates(this.#keyframes[targetKeyframeIdx]); if (targetKeyframeIdx !== this.#keyframeIdx) { this.#keyframeIdx = targetKeyframeIdx; } this.dispatchEvent(new Event("cm-afterframechange", { bubbles: true })); return true; } _setClassesAndStates(targetIdx: number): void { for (const state of this.#internals.states) { if (/^frame[0-9]+$/.test(state)) { this.#internals.states.delete(state); } } this.#internals.states.add(`frame${targetIdx}`); this.#internals.states.add(`hasNext`); this.#internals.states.add(`hasPrev`); if (targetIdx === 0) { this.#internals.states.delete(`hasPrev`); } if (targetIdx === this.#keyframes.at(-1)) { this.#internals.states.delete(`hasNext`); } const defaultSlot = this._shadow.querySelector("slot:not([name])"); const targetNode = defaultSlot?.assignedElements()[0]; if (!targetNode) { return; } for (const className of targetNode.classList) { if (/^frame[0-9]+$/.test(className)) { targetNode.classList.remove(className); } } targetNode.classList.add(`frame${targetIdx}`); } #updateAuxStyles(): void { let css = `slot[name="aux"]::slotted(*){ display: none }`; for (const frameIdx of this.#keyframes) { css += `:host(:state(frame${frameIdx})) slot[name="aux"]::slotted(.frame${frameIdx}) { display: block; }`; } this.#auxStyles.replaceSync(css); } next(): number { const before = this.#keyframeIdx; this.#keyframeIdx += 1; const success = this._goToCurrent(); if (!success) { this.#keyframeIdx = before; return before; } return this.current; } prev(): number { const before = this.#keyframeIdx; this.#keyframeIdx -= 1; const success = this._goToCurrent(); if (!success) { this.#keyframeIdx = before; return before; } return this.current; } go(inputValue: number): number { const before = this.#keyframeIdx; this.#keyframeIdx = this._toKeyframeIdx(inputValue); const success = this._goToCurrent(); if (!success) { this.#keyframeIdx = before; return before; } return this.current; } _handleClick = (evt: Event) => { if (evt.type === "click") { for (const object of evt.composedPath()) { if (object instanceof HTMLElement) { if (object.getAttribute("data-command") === "next") { this.next(); return; } if (object.getAttribute("data-command") === "prev") { this.prev(); return; } } } } }; }