├── .editorconfig ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .release-it.json ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── demo └── index.html ├── dist ├── index.d.ts ├── index.js └── lib.d.ts ├── eslint.config.js ├── package-lock.json ├── package.json ├── src ├── index.ts └── lib.ts ├── test └── test.test.ts ├── tsconfig.json ├── tsconfig.types.json └── web-test-runner.config.mjs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | *.js.map 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | demo 3 | test 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/release-it@17/schema/release-it.json", 3 | "git": { 4 | "commitMessage": "release v${version}" 5 | }, 6 | "github": { 7 | "release": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.3.0 4 | 5 | - **Feature:** `` now tracks whether it is at its first or last keyframe in its [custom state set](https://developer.mozilla.org/en-US/docs/Web/API/CustomStateSet) via the states `hasNext` and `hasPrev`. 6 | 7 | ## 2.2.0 8 | 9 | - **Feature:** `` now uses its [custom state set](https://developer.mozilla.org/en-US/docs/Web/API/CustomStateSet) to track the current frame. If the current frame is 7 for example, the CSS selector `code-movie-runtime:state(frame7)` will match. 10 | - **Feature**: support auxiliary content via the `aux` slot 11 | 12 | ## 2.1.0 13 | 14 | - **Feature:** the new DOM method: `go(targetKeyframe)` can be used as an alternative way to navigate to specific keyframes. The setter `current` does the same thing, but if you'd rather use a method, `go()` has you covered. 15 | - **Improvement:** the element now keeps its slotted content's class names up to date even if that content changes. This should make the element overall more reliable. 16 | - **Feature:** add a changelog! 17 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2022 Peter Kröner 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `` - Web runtime for [Code.Movie](https://code.movie/) 2 | 3 | Convenient wrapper for [Code.Movie](https://code.movie/) animations that provides a basic high-level UI and DOM API via the custom element ``. 4 | 5 | ## Setup 6 | 7 | You can install the library as `@codemovie/code-movie-runtime` from NPM, [download the latest release from GitHub](https://github.com/CodeMovie/code-movie-runtime/releases) or just grab `dist/index.js` [from the source code](https://github.com/CodeMovie/code-movie-runtime/tree/main/dist). The library exports the component class and auto-registers the tag name `code-movie-runtime`. You can throw the module into any web page without doing anything else and it will just work. 8 | 9 | The element works by [slotting](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot) another element (the animation) and switching classes on it. The element is extremely basic and meant to be used by other tools or hacked and extended by you, the user. 10 | 11 | ## HTML API 12 | 13 | ### General usage 14 | 15 | The element `` is a custom HTML element with several slots, attributes, and DOM properties for customization. To get it working, just load `dist/index.js` as module in your web page! A minimal example: 16 | 17 | ```html 18 | 19 | 20 | 21 |
Switch classes on me!
22 |
23 | ``` 24 | 25 | This will render twe buttons below the `
`. Clicking on them cycles classes on the `
` from `frame0` to `frame1` to `frame2` to `frame3`. The keyframes are defined as a whitespace-separated list of numbers in the `keyframes` attribute while the existence of the `controls` attribute provides basic forwards/backwards buttons. 26 | 27 | Attribute summary: 28 | 29 | - `controls`: Boolean attribute. When present, shows controls UI (by default just a pair of forwards/backwards buttons). Reflected by the DOM property `controls`. 30 | - `keyframes`: Defines the list of keyframes with a value of whitespace-separated positive integers. Values that are anything but a list of whitespace-separated integers are equal to the attribute missing (eg. there are no keyframes at all in this case). The list of keyframes is internally sorted in ascending order and cleared of any duplicates or non-numbers. Negative numbers are interpreted as positive numbers. 31 | - `current`: Indicates the current frame index. Can be set to another number to change the current frame. Reflected by the DOM property `current`. Values that are anything but a positive integer are treated as `0`. 32 | 33 | ### Custom controls 34 | 35 | The default control UI for the element is basic and ugly. There are three options to remedy this: 36 | 37 | #### 1. Wrap the element 38 | 39 | You can wrap a `` element _without_ a `controls` attribute and add your own custom logic that uses the JavaScript API described below. This is probably the way to go for integration in frameworks like React. 40 | 41 | #### 2. Style the controls 42 | 43 | If you just want to reposition and re-style the existing controls, you can use the following CSS [`::part()` selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/::part): 44 | 45 | - `code-movie-runtime::part(controls)`: The container element for the buttons 46 | - `code-movie-runtime::part(controls-prevBtn)`: The "previous" button 47 | - `code-movie-runtime::part(controls-nextBtn)`: The "next" button 48 | 49 | The buttons are ` 97 | 98 | 99 | / 100 | 101 | 102 | 103 |
104 | 105 | 106 | 130 | 131 | 159 | 160 | 161 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | import { CodeMovieRuntime } from "./lib"; 2 | declare global { 3 | interface HTMLElementTagNameMap { 4 | "code-movie-runtime": CodeMovieRuntime; 5 | } 6 | } 7 | export { CodeMovieRuntime }; 8 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | var S=Object.defineProperty;var w=r=>{throw TypeError(r)};var A=(r,n,t)=>n in r?S(r,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):r[n]=t;var b=(r,n,t)=>A(r,typeof n!="symbol"?n+"":n,t),y=(r,n,t)=>n.has(r)||w("Cannot "+t);var s=(r,n,t)=>(y(r,n,"read from private field"),t?t.call(r):n.get(r)),l=(r,n,t)=>n.has(r)?w("Cannot add the same private member more than once"):n instanceof WeakSet?n.add(r):n.set(r,t),a=(r,n,t,e)=>(y(r,n,"write to private field"),e?e.call(r,t):n.set(r,t),t),g=(r,n,t)=>(y(r,n,"access private method"),t);function _(r){let n=Math.round(Number(r));return Number.isFinite(n)&&!Number.isNaN(n)?n:0}function k(r){return Math.abs(_(r))}function C(r){return r?String(r).split(/\s+/).map(k).sort((n,t)=>n-t):[]}var d,h,o,i,c,f,x,v=class v extends HTMLElement{constructor(){super();l(this,f);b(this,"_shadow",this.attachShadow({mode:"open"}));l(this,d,new CSSStyleSheet);l(this,h,this.attachInternals());l(this,o,[]);l(this,i,0);l(this,c,null);b(this,"_handleClick",t=>{if(t.type==="click"){for(let e of t.composedPath())if(e instanceof HTMLElement){if(e.getAttribute("data-command")==="next"){this.next();return}if(e.getAttribute("data-command")==="prev"){this.prev();return}}}});let t=v._template();this._shadow.append(...t),this._shadow.addEventListener("click",this._handleClick);let e=this._shadow.querySelector("slot:not([name])");if(!e)throw new Error("Template does not contain a default slot");e.addEventListener("slotchange",()=>this._goToCurrent()),this._shadow.adoptedStyleSheets.push(s(this,d))}static _template(){let t=document.createElement("div");return t.innerHTML=`
2 | 3 |
4 | 5 |
6 | 9 | 12 |
13 |
14 |
15 |
16 | 17 |
18 |
19 | `,Array.from(t.children)}static get observedAttributes(){return["keyframes","current"]}attributeChangedCallback(t,e,u){e!==u&&(t==="keyframes"?(a(this,o,C(u)),g(this,f,x).call(this),this._goToCurrent()):t==="current"&&(a(this,i,this._toKeyframeIdx(u)),this._goToCurrent()))}get controls(){return this.hasAttribute("controls")}set controls(t){t?this.setAttribute("controls","controls"):this.removeAttribute("controls")}get keyframes(){return s(this,o)}set keyframes(t){Array.isArray(t)?(t=Array.from(new Set(t.map(k).sort((e,u)=>e-u))),this.setAttribute("keyframes",t.join(" ")),a(this,o,t)):(this.removeAttribute("keyframes"),a(this,o,[])),g(this,f,x).call(this),this._goToCurrent()}_toKeyframeIdx(t){let e=_(t);return e<0&&(e=Math.abs(e)-1),e>this.maxFrame&&(e=this.maxFrame),s(this,o).indexOf(e)}get current(){return s(this,o)[s(this,i)]||0}set current(t){let e=this._toKeyframeIdx(t);e!==-1?(a(this,i,e),this.setAttribute("current",String(s(this,o)[e]))):(a(this,i,0),this.setAttribute("current","0"))}get nextCurrent(){return s(this,c)&&s(this,o)[s(this,i)]||null}get maxFrame(){return Math.max(...this.keyframes)}_goToCurrent(){let t=s(this,i);t in s(this,o)||(s(this,o).length>=1&&t<0?t=s(this,o).length-1:t=0),a(this,c,t);let e=this.dispatchEvent(new Event("cm-beforeframechange",{bubbles:!0,cancelable:!0}));return a(this,c,null),e?(this._setClassesAndStates(s(this,o)[t]),t!==s(this,i)&&a(this,i,t),this.dispatchEvent(new Event("cm-afterframechange",{bubbles:!0})),!0):!1}_setClassesAndStates(t){for(let m of s(this,h).states)/^frame[0-9]+$/.test(m)&&s(this,h).states.delete(m);s(this,h).states.add(`frame${t}`),s(this,h).states.add("hasNext"),s(this,h).states.add("hasPrev"),t===0&&s(this,h).states.delete("hasPrev"),t===s(this,o).at(-1)&&s(this,h).states.delete("hasNext");let u=this._shadow.querySelector("slot:not([name])")?.assignedElements()[0];if(u){for(let m of u.classList)/^frame[0-9]+$/.test(m)&&u.classList.remove(m);u.classList.add(`frame${t}`)}}next(){let t=s(this,i);return a(this,i,s(this,i)+1),this._goToCurrent()?this.current:(a(this,i,t),t)}prev(){let t=s(this,i);return a(this,i,s(this,i)-1),this._goToCurrent()?this.current:(a(this,i,t),t)}go(t){let e=s(this,i);return a(this,i,this._toKeyframeIdx(t)),this._goToCurrent()?this.current:(a(this,i,e),e)}};d=new WeakMap,h=new WeakMap,o=new WeakMap,i=new WeakMap,c=new WeakMap,f=new WeakSet,x=function(){let t='slot[name="aux"]::slotted(*){ display: none }';for(let e of s(this,o))t+=`:host(:state(frame${e})) slot[name="aux"]::slotted(.frame${e}) { display: block; }`;s(this,d).replaceSync(t)};var p=v;window.customElements.define("code-movie-runtime",p);export{p as CodeMovieRuntime}; 24 | -------------------------------------------------------------------------------- /dist/lib.d.ts: -------------------------------------------------------------------------------- 1 | export declare class CodeMovieRuntime extends HTMLElement { 2 | #private; 3 | static _template(): Element[]; 4 | _shadow: ShadowRoot; 5 | constructor(); 6 | static get observedAttributes(): string[]; 7 | attributeChangedCallback(name: string, oldValue: unknown, newValue: string): void; 8 | get controls(): boolean; 9 | set controls(value: boolean); 10 | get keyframes(): any[]; 11 | set keyframes(value: any[]); 12 | _toKeyframeIdx(inputValue: unknown): number; 13 | get current(): number; 14 | set current(inputValue: unknown); 15 | get nextCurrent(): number | null; 16 | get maxFrame(): number; 17 | _goToCurrent(): boolean; 18 | _setClassesAndStates(targetIdx: number): void; 19 | next(): number; 20 | prev(): number; 21 | go(inputValue: number): number; 22 | _handleClick: (evt: Event) => void; 23 | } 24 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import globals from "globals"; 4 | import eslint from "@eslint/js"; 5 | import tseslint from "typescript-eslint"; 6 | import eslintConfigPrettier from "eslint-config-prettier"; 7 | 8 | export default tseslint.config( 9 | eslint.configs.recommended, 10 | ...tseslint.configs.strict, 11 | ...tseslint.configs.stylistic, 12 | eslintConfigPrettier, 13 | { 14 | languageOptions: { 15 | globals: { 16 | ...globals.browser, 17 | }, 18 | }, 19 | rules: { 20 | "@typescript-eslint/no-explicit-any": "off", 21 | }, 22 | }, 23 | ); 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codemovie/code-movie-runtime", 3 | "description": "Web runtime element for Code.Movie animations", 4 | "keywords": [ 5 | "highlight", 6 | "syntax", 7 | "animation", 8 | "morph", 9 | "diff", 10 | "code" 11 | ], 12 | "version": "2.3.0", 13 | "type": "module", 14 | "main": "dist/index.js", 15 | "types": "dist/index.d.ts", 16 | "scripts": { 17 | "lint": "prettier . --check && eslint src test", 18 | "types": "tsc -p tsconfig.types.json", 19 | "build": "rm -rf dist && npm run types && esbuild src/index.ts --bundle --minify --format=esm --target=es2020 --outfile=dist/index.js ", 20 | "build-dev": "esbuild src/index.ts --bundle --sourcemap --format=esm --target=es2020 --outfile=dist/index.js --watch", 21 | "test": "NODE_ENV=test wtr test/**/*.test.ts --playwright --browsers firefox chromium webkit", 22 | "test-dev": "NODE_ENV=test wtr test/**/*.test.ts --playwright --browsers chromium", 23 | "prepublishOnly": "npm run types && npm run lint && npm run test && npm run build", 24 | "release": "release-it" 25 | }, 26 | "author": "peter@peterkroener.de", 27 | "license": "MIT", 28 | "devDependencies": { 29 | "@eslint/js": "^9.5.0", 30 | "@esm-bundle/chai": "^4.3.4-fix.0", 31 | "@types/mocha": "^10.0.10", 32 | "@types/sinon": "^17.0.0", 33 | "@web/dev-server-esbuild": "^1.0.3", 34 | "@web/test-runner": "^0.20.0", 35 | "@web/test-runner-playwright": "^0.11.0", 36 | "esbuild": "^0.25.0", 37 | "eslint": "^9.0.0", 38 | "eslint-config-prettier": "^10.0.0", 39 | "prettier": "^3.3.2", 40 | "release-it": "^18.0.0", 41 | "sinon": "^20.0.0", 42 | "typescript": "^5.5.2", 43 | "typescript-eslint": "^8.8.0" 44 | }, 45 | "repository": { 46 | "type": "git", 47 | "url": "git://github.com/CodeMovie/code-movie-runtime.git" 48 | }, 49 | "homepage": "https://code.movie/", 50 | "bugs": { 51 | "url": "https://github.com/CodeMovie/code-movie-runtime/issues" 52 | }, 53 | "publishConfig": { 54 | "access": "public" 55 | }, 56 | "volta": { 57 | "node": "22.14.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { CodeMovieRuntime } from "./lib"; 2 | 3 | declare global { 4 | interface HTMLElementTagNameMap { 5 | "code-movie-runtime": CodeMovieRuntime; 6 | } 7 | } 8 | 9 | window.customElements.define("code-movie-runtime", CodeMovieRuntime); 10 | 11 | export { CodeMovieRuntime }; 12 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | function toFiniteInt(value: unknown): number { 2 | const asInt = Math.round(Number(value)); 3 | if (Number.isFinite(asInt) && !Number.isNaN(asInt)) { 4 | return asInt; 5 | } 6 | return 0; 7 | } 8 | 9 | function toPositiveFiniteInt(value: unknown): number { 10 | return Math.abs(toFiniteInt(value)); 11 | } 12 | 13 | function parseKeyframesAttributeValue(value: unknown): number[] { 14 | if (value) { 15 | return String(value) 16 | .split(/\s+/) 17 | .map(toPositiveFiniteInt) 18 | .sort((a, b) => a - b); 19 | } 20 | return []; 21 | } 22 | 23 | export class CodeMovieRuntime extends HTMLElement { 24 | // The template function must be public to allow users to replace it 25 | static _template(): Element[] { 26 | const tmp = document.createElement("div"); 27 | tmp.innerHTML = `
28 | 29 |
30 | 31 |
32 | 35 | 38 |
39 |
40 |
41 |
42 | 43 |
44 |
45 | `; 50 | return Array.from(tmp.children); 51 | } 52 | 53 | // Shadow DOM must be open to allow users to mess with its contents 54 | _shadow = this.attachShadow({ mode: "open" }); 55 | 56 | // Controls aux content visibility. This should _not_ be messed with manually. 57 | #auxStyles = new CSSStyleSheet(); 58 | 59 | // ElementInternals must NOT be accessible, the element relies on having 60 | // control over its custom states 61 | #internals = this.attachInternals(); 62 | 63 | // List of the keyframe indices, sorted in ascending order 64 | #keyframes: number[] = []; 65 | 66 | // Current index in the array of keyframes. The public getter "current" 67 | // is derived from #keyframes and #keyframeIdx 68 | #keyframeIdx = 0; 69 | 70 | // Next index in the array of keyframes. Always null except when the event 71 | // `cm-beforeframechange` fires. 72 | #nextKeyframeIdx: null | number = null; 73 | 74 | constructor() { 75 | super(); 76 | const content = CodeMovieRuntime._template(); 77 | this._shadow.append(...content); 78 | this._shadow.addEventListener("click", this._handleClick); 79 | const defaultSlot = this._shadow.querySelector("slot:not([name])"); 80 | if (!defaultSlot) { 81 | throw new Error("Template does not contain a default slot"); 82 | } 83 | defaultSlot.addEventListener("slotchange", () => this._goToCurrent()); 84 | this._shadow.adoptedStyleSheets.push(this.#auxStyles); 85 | } 86 | 87 | // Of the three existing attributes, "controls" does not need to be observed, 88 | // because its effects are handled by CSS alone. 89 | static get observedAttributes() { 90 | return ["keyframes", "current"]; 91 | } 92 | 93 | attributeChangedCallback(name: string, oldValue: unknown, newValue: string) { 94 | if (oldValue === newValue) { 95 | return; 96 | } 97 | if (name === "keyframes") { 98 | this.#keyframes = parseKeyframesAttributeValue(newValue); 99 | this.#updateAuxStyles(); 100 | this._goToCurrent(); 101 | } else if (name === "current") { 102 | this.#keyframeIdx = this._toKeyframeIdx(newValue); 103 | this._goToCurrent(); 104 | } 105 | } 106 | 107 | get controls(): boolean { 108 | return this.hasAttribute("controls"); 109 | } 110 | 111 | set controls(value) { 112 | if (value) { 113 | this.setAttribute("controls", "controls"); 114 | } else { 115 | this.removeAttribute("controls"); 116 | } 117 | } 118 | 119 | get keyframes() { 120 | return this.#keyframes; 121 | } 122 | 123 | set keyframes(value: any[]) { 124 | if (Array.isArray(value)) { 125 | value = Array.from( 126 | new Set(value.map(toPositiveFiniteInt).sort((a, b) => a - b)), 127 | ); 128 | this.setAttribute("keyframes", value.join(" ")); 129 | this.#keyframes = value; 130 | } else { 131 | this.removeAttribute("keyframes"); 132 | this.#keyframes = []; 133 | } 134 | this.#updateAuxStyles(); 135 | this._goToCurrent(); 136 | } 137 | 138 | _toKeyframeIdx(inputValue: unknown): number { 139 | let value = toFiniteInt(inputValue); 140 | if (value < 0) { 141 | value = Math.abs(value) - 1; 142 | } 143 | if (value > this.maxFrame) { 144 | value = this.maxFrame; 145 | } 146 | return this.#keyframes.indexOf(value); 147 | } 148 | 149 | get current(): number { 150 | return this.#keyframes[this.#keyframeIdx] || 0; 151 | } 152 | 153 | set current(inputValue: unknown) { 154 | const newKeyframeIdx = this._toKeyframeIdx(inputValue); 155 | if (newKeyframeIdx !== -1) { 156 | this.#keyframeIdx = newKeyframeIdx; 157 | this.setAttribute("current", String(this.#keyframes[newKeyframeIdx])); 158 | } else { 159 | this.#keyframeIdx = 0; 160 | this.setAttribute("current", "0"); 161 | } 162 | } 163 | 164 | get nextCurrent(): number | null { 165 | if (this.#nextKeyframeIdx) { 166 | return this.#keyframes[this.#keyframeIdx] || null; 167 | } 168 | return null; 169 | } 170 | 171 | get maxFrame(): number { 172 | return Math.max(...this.keyframes); 173 | } 174 | 175 | _goToCurrent(): boolean { 176 | let targetKeyframeIdx = this.#keyframeIdx; 177 | if (!(targetKeyframeIdx in this.#keyframes)) { 178 | if (this.#keyframes.length >= 1) { 179 | if (targetKeyframeIdx < 0) { 180 | targetKeyframeIdx = this.#keyframes.length - 1; 181 | } else { 182 | targetKeyframeIdx = 0; 183 | } 184 | } else { 185 | targetKeyframeIdx = 0; 186 | } 187 | } 188 | this.#nextKeyframeIdx = targetKeyframeIdx; 189 | 190 | const proceed = this.dispatchEvent( 191 | new Event("cm-beforeframechange", { bubbles: true, cancelable: true }), 192 | ); 193 | this.#nextKeyframeIdx = null; 194 | if (!proceed) { 195 | return false; 196 | } 197 | this._setClassesAndStates(this.#keyframes[targetKeyframeIdx]); 198 | if (targetKeyframeIdx !== this.#keyframeIdx) { 199 | this.#keyframeIdx = targetKeyframeIdx; 200 | } 201 | this.dispatchEvent(new Event("cm-afterframechange", { bubbles: true })); 202 | return true; 203 | } 204 | 205 | _setClassesAndStates(targetIdx: number): void { 206 | for (const state of this.#internals.states) { 207 | if (/^frame[0-9]+$/.test(state)) { 208 | this.#internals.states.delete(state); 209 | } 210 | } 211 | this.#internals.states.add(`frame${targetIdx}`); 212 | this.#internals.states.add(`hasNext`); 213 | this.#internals.states.add(`hasPrev`); 214 | if (targetIdx === 0) { 215 | this.#internals.states.delete(`hasPrev`); 216 | } 217 | if (targetIdx === this.#keyframes.at(-1)) { 218 | this.#internals.states.delete(`hasNext`); 219 | } 220 | const defaultSlot = 221 | this._shadow.querySelector("slot:not([name])"); 222 | const targetNode = defaultSlot?.assignedElements()[0]; 223 | if (!targetNode) { 224 | return; 225 | } 226 | for (const className of targetNode.classList) { 227 | if (/^frame[0-9]+$/.test(className)) { 228 | targetNode.classList.remove(className); 229 | } 230 | } 231 | targetNode.classList.add(`frame${targetIdx}`); 232 | } 233 | 234 | #updateAuxStyles(): void { 235 | let css = `slot[name="aux"]::slotted(*){ display: none }`; 236 | for (const frameIdx of this.#keyframes) { 237 | css += `:host(:state(frame${frameIdx})) slot[name="aux"]::slotted(.frame${frameIdx}) { display: block; }`; 238 | } 239 | this.#auxStyles.replaceSync(css); 240 | } 241 | 242 | next(): number { 243 | const before = this.#keyframeIdx; 244 | this.#keyframeIdx += 1; 245 | const success = this._goToCurrent(); 246 | if (!success) { 247 | this.#keyframeIdx = before; 248 | return before; 249 | } 250 | return this.current; 251 | } 252 | 253 | prev(): number { 254 | const before = this.#keyframeIdx; 255 | this.#keyframeIdx -= 1; 256 | const success = this._goToCurrent(); 257 | if (!success) { 258 | this.#keyframeIdx = before; 259 | return before; 260 | } 261 | return this.current; 262 | } 263 | 264 | go(inputValue: number): number { 265 | const before = this.#keyframeIdx; 266 | this.#keyframeIdx = this._toKeyframeIdx(inputValue); 267 | const success = this._goToCurrent(); 268 | if (!success) { 269 | this.#keyframeIdx = before; 270 | return before; 271 | } 272 | return this.current; 273 | } 274 | 275 | _handleClick = (evt: Event) => { 276 | if (evt.type === "click") { 277 | for (const object of evt.composedPath()) { 278 | if (object instanceof HTMLElement) { 279 | if (object.getAttribute("data-command") === "next") { 280 | this.next(); 281 | return; 282 | } 283 | if (object.getAttribute("data-command") === "prev") { 284 | this.prev(); 285 | return; 286 | } 287 | } 288 | } 289 | } 290 | }; 291 | } 292 | -------------------------------------------------------------------------------- /test/test.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "@esm-bundle/chai"; 2 | import { spy } from "sinon"; 3 | import { CodeMovieRuntime } from "../src/lib"; 4 | const test = it; 5 | 6 | const wait = () => new Promise((r) => setTimeout(r, 0)); 7 | 8 | function $( 9 | attributes: Record = {}, 10 | innerHTML = "", 11 | ): CodeMovieRuntime { 12 | const instance = document.createElement("code-movie-runtime"); 13 | for (const name in attributes) { 14 | instance.setAttribute(name, attributes[name]); 15 | } 16 | if (innerHTML) { 17 | instance.innerHTML = innerHTML; 18 | } 19 | return instance; 20 | } 21 | 22 | describe("", () => { 23 | before(() => { 24 | if (window.customElements.get("code-movie-runtime")) { 25 | return; 26 | } 27 | window.customElements.define("code-movie-runtime", CodeMovieRuntime); 28 | return window.customElements.whenDefined("code-movie-runtime"); 29 | }); 30 | 31 | afterEach(() => { 32 | document 33 | .querySelectorAll("code-movie-runtime") 34 | .forEach((el) => el.remove()); 35 | }); 36 | 37 | describe("attributes and properties", () => { 38 | test("controls", () => { 39 | const instance = $(); 40 | // defaults 41 | expect(instance.controls).to.equal(false); 42 | expect(instance.hasAttribute("controls")).to.equal(false); 43 | // setting via setter 44 | instance.controls = true; 45 | expect(instance.controls).to.equal(true); 46 | expect(instance.getAttribute("controls")).to.equal("controls"); 47 | // setting via setter to falsy garbage REMOVES controls 48 | instance.controls = null as any; 49 | expect(instance.controls).to.equal(false); 50 | expect(instance.hasAttribute("controls")).to.equal(false); 51 | // setting via setter to truthy garbage ADDS controls 52 | instance.controls = { foo: 42 } as any; 53 | expect(instance.controls).to.equal(true); 54 | expect(instance.getAttribute("controls")).to.equal("controls"); 55 | // Removal via attributes 56 | instance.removeAttribute("controls"); 57 | expect(instance.controls).to.equal(false); 58 | expect(instance.hasAttribute("controls")).to.equal(false); 59 | // Addition via attributes 60 | instance.setAttribute("controls", "whatever"); 61 | expect(instance.controls).to.equal(true); 62 | expect(instance.getAttribute("controls")).to.equal("whatever"); 63 | }); 64 | 65 | test("keyframes", () => { 66 | const instance = $(); 67 | // defaults 68 | expect(instance.keyframes).to.eql([]); 69 | expect(instance.hasAttribute("keyframes")).to.equal(false); 70 | // setting via setter 71 | instance.keyframes = [0, 1, 2, 3]; 72 | expect(instance.keyframes).to.eql([0, 1, 2, 3]); 73 | expect(instance.getAttribute("keyframes")).to.equal("0 1 2 3"); 74 | // setting via setter to unordered values 75 | instance.keyframes = [1, 2, 0, 3]; 76 | expect(instance.keyframes).to.eql([0, 1, 2, 3]); 77 | expect(instance.getAttribute("keyframes")).to.equal("0 1 2 3"); 78 | // setting to non-array values 79 | instance.keyframes = { foo: 42 } as any; 80 | expect(instance.keyframes).to.eql([]); 81 | expect(instance.hasAttribute("keyframes")).to.equal(false); 82 | // setting to an array with negative numbers inside 83 | instance.keyframes = [0, 1, -2, 3] as any; 84 | expect(instance.keyframes).to.eql([0, 1, 2, 3]); 85 | expect(instance.getAttribute("keyframes")).to.equal("0 1 2 3"); 86 | // setting to an array with non-numbers inside 87 | instance.keyframes = [0, 1, "a", 2] as any; 88 | expect(instance.keyframes).to.eql([0, 1, 2]); 89 | expect(instance.getAttribute("keyframes")).to.equal("0 1 2"); 90 | // setting to an array with numeric non-numbers inside 91 | instance.keyframes = [0, 1, "3", 2] as any; 92 | expect(instance.keyframes).to.eql([0, 1, 2, 3]); 93 | expect(instance.getAttribute("keyframes")).to.equal("0 1 2 3"); 94 | // addition via attributes 95 | instance.setAttribute("keyframes", "0 1 2\n\n3"); 96 | expect(instance.keyframes).to.eql([0, 1, 2, 3]); 97 | expect(instance.getAttribute("keyframes")).to.equal("0 1 2\n\n3"); 98 | // removal via attributes 99 | instance.removeAttribute("keyframes"); 100 | expect(instance.keyframes).to.eql([]); 101 | expect(instance.hasAttribute("keyframes")).to.equal(false); 102 | }); 103 | 104 | test("current", () => { 105 | const instance = $({ keyframes: "0 1 2 3" }); 106 | // defaults 107 | expect(instance.current).to.equal(0); 108 | expect(instance.hasAttribute("current")).to.equal(false); 109 | // setting via setter 110 | instance.current = 1; 111 | expect(instance.current).to.equal(1); 112 | expect(instance.getAttribute("current")).to.equal("1"); 113 | // setting to non-number garbage 114 | instance.current = "hello"; 115 | expect(instance.current).to.equal(0); 116 | expect(instance.getAttribute("current")).to.equal("0"); 117 | // setting to out-of bounds number 118 | instance.current = 7; 119 | expect(instance.current).to.equal(3); 120 | expect(instance.getAttribute("current")).to.equal("3"); 121 | // setting to negative number 122 | instance.current = -1; 123 | expect(instance.current).to.equal(0); 124 | expect(instance.getAttribute("current")).to.equal("0"); 125 | // set via attributes 126 | instance.setAttribute("current", "3"); 127 | expect(instance.current).to.equal(3); 128 | expect(instance.getAttribute("current")).to.equal("3"); 129 | // remove attribute 130 | instance.removeAttribute("current"); 131 | expect(instance.current).to.equal(0); 132 | expect(instance.hasAttribute("current")).to.equal(false); 133 | }); 134 | }); 135 | 136 | describe("toggle classes", () => { 137 | test("with go(), next(), prev()", () => { 138 | const instance = $({ keyframes: "0 1 2 3" }, "
"); 139 | const child = instance.firstChild as HTMLDivElement; 140 | instance.go(2); 141 | expect(instance.current).to.equal(2); 142 | expect(child.classList.contains("frame2")).to.equal(true); 143 | expect(instance.matches(":state(frame2)")).to.equal(true); 144 | expect(instance.matches(":state(hasNext)")).to.equal(true); 145 | expect(instance.matches(":state(hasPrev)")).to.equal(true); 146 | instance.go(0); 147 | expect(instance.current).to.equal(0); 148 | expect(child.classList.contains("frame2")).to.equal(false); 149 | expect(child.classList.contains("frame0")).to.equal(true); 150 | expect(instance.matches(":state(hasNext)")).to.equal(true); 151 | expect(instance.matches(":state(hasPrev)")).to.equal(false); 152 | instance.next(); 153 | expect(instance.current).to.equal(1); 154 | expect(child.classList.contains("frame0")).to.equal(false); 155 | expect(child.classList.contains("frame1")).to.equal(true); 156 | expect(instance.matches(":state(hasNext)")).to.equal(true); 157 | expect(instance.matches(":state(hasPrev)")).to.equal(true); 158 | instance.prev(); 159 | expect(instance.current).to.equal(0); 160 | expect(child.classList.contains("frame1")).to.equal(false); 161 | expect(child.classList.contains("frame0")).to.equal(true); 162 | expect(instance.matches(":state(hasNext)")).to.equal(true); 163 | expect(instance.matches(":state(hasPrev)")).to.equal(false); 164 | // Wrap around 165 | instance.prev(); 166 | expect(instance.current).to.equal(3); 167 | expect(child.classList.contains("frame0")).to.equal(false); 168 | expect(child.classList.contains("frame3")).to.equal(true); 169 | expect(instance.matches(":state(hasNext)")).to.equal(false); 170 | expect(instance.matches(":state(hasPrev)")).to.equal(true); 171 | }); 172 | 173 | test("on slotchange", async () => { 174 | const instance = $({ keyframes: "0 1 2 3" }, "
"); 175 | const child = instance.firstChild as HTMLDivElement; 176 | await wait(); // allow the async slotchange event to fire 177 | expect(child.classList.contains("frame0")).to.equal(true); 178 | }); 179 | 180 | test("events fire", () => { 181 | const fn = spy(); 182 | const instance = $({ keyframes: "0 1 2 3" }, "
"); 183 | instance.addEventListener("cm-beforeframechange", () => fn("before")); 184 | instance.addEventListener("cm-afterframechange", () => fn("after")); 185 | instance.go(2); 186 | expect(fn.args[0]).to.eql(["before"]); 187 | expect(fn.args[1]).to.eql(["after"]); 188 | }); 189 | 190 | test("cm-beforeframechange event cancels change", () => { 191 | const instance = $({ keyframes: "0 1 2 3" }, "
"); 192 | const child = instance.firstChild as HTMLDivElement; 193 | instance.addEventListener("cm-beforeframechange", (evt) => 194 | evt.preventDefault(), 195 | ); 196 | instance.go(2); 197 | expect(instance.current).to.equal(0); 198 | expect(child.classList.contains("frame2")).to.equal(false); 199 | }); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "moduleResolution": "node", 7 | "esModuleInterop": true 8 | }, 9 | "include": ["src/**/*", "test/**/*"], 10 | "exclude": ["demo", "dist", "node_modules"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationDir": "dist", 6 | "emitDeclarationOnly": true 7 | }, 8 | "include": ["src/index.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /web-test-runner.config.mjs: -------------------------------------------------------------------------------- 1 | import { esbuildPlugin } from "@web/dev-server-esbuild"; 2 | 3 | export default { 4 | browserStartTimeout: 90000, 5 | nodeResolve: true, 6 | plugins: [esbuildPlugin({ ts: true, target: "auto" })], 7 | }; 8 | --------------------------------------------------------------------------------