| /** |
| * @module particles-sk |
| * @description <h2><code>particles-sk</code></h2> |
| * |
| * <p> |
| * The main application element for particles. |
| * </p> |
| * |
| * @attr paused - This attribute is only checked on the connectedCallback |
| * and is used to stop the player from starting the animation. This is |
| * only used for tests. |
| * |
| */ |
| import '../particles-player-sk'; |
| import '../particles-config-sk'; |
| import 'elements-sk/checkbox-sk'; |
| import 'elements-sk/error-toast-sk'; |
| import 'elements-sk/styles/buttons'; |
| import { $$ } from 'common-sk/modules/dom'; |
| import { define } from 'elements-sk/define'; |
| import { errorMessage } from 'elements-sk/errorMessage'; |
| import { html } from 'lit-html'; |
| import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow'; |
| import { stateReflector } from 'common-sk/modules/stateReflector'; |
| import JSONEditor, { JSONEditorOptions } from 'jsoneditor'; |
| import { HintableObject } from 'common-sk/modules/hintable'; |
| import { ElementSk } from '../../../infra-sk/modules/ElementSk'; |
| import { ParticlesConfig, ParticlesConfigSk } from '../particles-config-sk/particles-config-sk'; |
| import { ParticlesPlayerSk } from '../particles-player-sk/particles-player-sk'; |
| import '../../../infra-sk/modules/app-sk'; |
| import '../../../infra-sk/modules/theme-chooser-sk'; |
| import { ScrapBody, ScrapID } from '../json'; |
| |
| // It is assumed that this symbol is being provided by a version.js file loaded in before this |
| // file. |
| declare const SKIA_VERSION: string; |
| |
| const defaultParticleDemo = { |
| MaxCount: 1000, |
| Drawable: { |
| Type: 'SkCircleDrawable', |
| Radius: 2, |
| }, |
| Code: [ |
| 'void effectSpawn(inout Effect effect) {', |
| ' effect.rate = 200;', |
| ' effect.color = float4(1, 0, 0, 1);', |
| '}', |
| '', |
| 'void spawn(inout Particle p) {', |
| ' p.lifetime = 3 + rand(p.seed);', |
| ' p.vel.y = -50;', |
| '}', |
| '', |
| 'void update(inout Particle p) {', |
| ' float w = mix(15, 3, p.age);', |
| ' p.pos.x = sin(radians(p.age * 320)) * mix(25, 10, p.age) + mix(-w, w, rand(p.seed));', |
| ' if (rand(p.seed) < 0.5) { p.pos.x = -p.pos.x; }', |
| '', |
| ' p.color.g = (mix(75, 220, p.age) + mix(-30, 30, rand(p.seed))) / 255;', |
| '}', |
| '', |
| ], |
| Bindings: [], |
| }; |
| |
| const DEFAULT_SIZE = 800; |
| |
| // State is the state that's reflected to the URL. |
| interface State { |
| showEditor: boolean; |
| width: number; |
| height: number; |
| nameOrHash: string; |
| } |
| |
| const defaultState: State = { |
| showEditor: true, |
| width: DEFAULT_SIZE, |
| height: DEFAULT_SIZE, |
| nameOrHash: '', |
| }; |
| |
| type stateChangedCallback = ()=> void; |
| |
| export class ParticlesSk extends ElementSk { |
| private state: State = Object.assign({}, defaultState) |
| |
| // The dynamically constructed URL for downloading the JSON. |
| private downloadURL: string = ''; |
| |
| private editor: JSONEditor | null = null; |
| |
| private playing: boolean = false; |
| |
| private hasEdits: boolean = false; |
| |
| private currentNameOrHash: string = ''; |
| |
| private json: any = defaultParticleDemo; |
| |
| private playPauseButton: HTMLButtonElement | null = null; |
| |
| private particlesPlayer: ParticlesPlayerSk | null = null; |
| |
| // stateReflector update function. |
| private stateChanged: stateChangedCallback | null = null; |
| |
| private configEditor: ParticlesConfigSk | null = null; |
| |
| private editorDetails: HTMLDetailsElement | null = null; |
| |
| private widthInput: HTMLInputElement | null = null; |
| |
| private heightInput: HTMLInputElement | null = null; |
| |
| constructor() { |
| super(ParticlesSk.template); |
| } |
| |
| private static template = (ele: ParticlesSk) => html` |
| <app-sk> |
| <header> |
| <h2>Particles</h2> |
| <span> |
| <a |
| id=githash |
| href='https://skia.googlesource.com/skia/+show/${SKIA_VERSION}' |
| > |
| ${SKIA_VERSION.slice(0, 7)} |
| </a> |
| <theme-chooser-sk></theme-chooser-sk> |
| </span> |
| </header> |
| <main> |
| <particles-config-sk></particles-config-sk> |
| <!-- TODO(jcgregorio) Eventually this should be replaced with Scrap Exchange List Names. --> |
| <span @click=${ele.namedDemoLinkClick}> |
| <a href="/?nameOrHash=@fireworks">fireworks</a> |
| <a href="/?nameOrHash=@spiral">spiral</a> |
| <a href="/?nameOrHash=@swirl">swirl</a> |
| <a href="/?nameOrHash=@text">text</a> |
| <a href="/?nameOrHash=@wave">wave</a> |
| <a href="/?nameOrHash=@cube">cube</a> |
| <a href="/?nameOrHash=@confetti">confetti</a> |
| <a href="/?nameOrHash=@uniforms">uniforms</a> |
| </span> |
| |
| <button @click=${ele.openUploadDialog}> |
| Upload |
| </button> |
| <div class=playerAndEditor> |
| <figure> |
| <particles-player-sk width=${ele.state.width} height=${ele.state.height}></particles-player-sk> |
| <figcaption> |
| <div> |
| <button @click=${ele.restartAnimation}>Restart</button> |
| <button id=playpause @click=${ele.togglePlayPause}>Pause</button> |
| <button @click=${ele.resetView}> |
| Reset Pan/Zoom |
| </button> |
| </div> |
| <div> |
| Click to pan. Scroll wheel to zoom. |
| </div> |
| <div class=download> |
| <a target=_blank download="particles.json" href=${ele.downloadURL}> |
| Download JSON |
| </a> |
| ${ele.hasEdits ? '(without edits)' : ''} |
| </div> |
| </figcaption> |
| </figure> |
| <div> |
| <details id=editorDetails |
| ?open=${ele.state.showEditor} |
| @toggle=${ele.toggleEditor}> |
| <summary>Edit</summary> |
| <div id=dimensions> |
| <label> |
| <input |
| id=width |
| type=number |
| .value=${ele.state.width.toFixed(0)} |
| @change=${ele.widthChange}} |
| /> Width (px) |
| </label> |
| <label> |
| <input |
| id=height |
| type=number |
| .value=${ele.state.height.toFixed(0)} |
| @change=${ele.heightChange} |
| /> Height (px) |
| </label> |
| </div> |
| <div id=json_editor></div> |
| </details> |
| </div> |
| <button ?hidden=${!ele.hasEdits} @click=${ele.applyEdits}>Apply Edits</button> |
| </div> |
| </main> |
| <footer> |
| <error-toast-sk></error-toast-sk> |
| </footer> |
| </app-sk> |
| `; |
| |
| connectedCallback(): void { |
| super.connectedCallback(); |
| this._render(); |
| const editorContainer = $$<HTMLDivElement>('#json_editor', this)!; |
| // See https://github.com/josdejong/jsoneditor/blob/master/docs/api.md |
| // for documentation on this editor. |
| const editorOptions: JSONEditorOptions = { |
| sortObjectKeys: true, |
| onChange: () => { |
| this.hasEdits = true; |
| this._render(); |
| }, |
| }; |
| this.editor = new JSONEditor(editorContainer, editorOptions); |
| this.widthInput = $$<HTMLInputElement>('#width', this); |
| this.heightInput = $$<HTMLInputElement>('#height', this); |
| this.particlesPlayer = $$<ParticlesPlayerSk>('particles-player-sk', this); |
| this.playPauseButton = $$<HTMLButtonElement>('#playpause', this); |
| this.configEditor = $$<ParticlesConfigSk>('particles-config-sk', this); |
| this.editorDetails = $$<HTMLDetailsElement>('#editorDetails', this); |
| |
| this.stateChanged = stateReflector( |
| /* getState */() => (this.state as unknown as HintableObject), |
| /* setState */(newState) => { |
| this.state = (newState as unknown as State); |
| this._render(); |
| this.loadParticlesIfNecessary(); |
| }, |
| ); |
| |
| this.setJSON(defaultParticleDemo); |
| this.editor!.expandAll(); |
| if (this.hasAttribute('paused')) { |
| this.pause(); |
| } else { |
| this.play(); |
| } |
| } |
| |
| // We can make the links to named demo pages faster by intercepting the link |
| // click and just changing this.state, which avoids a page load. |
| private namedDemoLinkClick(e: MouseEvent) { |
| const url = (e.target as HTMLLinkElement).href; |
| if (!url) { |
| return; |
| } |
| const parsedURL = new URL(url); |
| const nameOrHash = parsedURL.searchParams.get('nameOrHash'); |
| if (!nameOrHash) { |
| return; |
| } |
| e.stopPropagation(); |
| e.preventDefault(); |
| this.state.nameOrHash = nameOrHash; |
| this.stateChanged!(); |
| this.loadParticlesIfNecessary(); |
| } |
| |
| private widthChange() { |
| this.state.width = +this.widthInput!.value; |
| this.dimensionsChanged(); |
| } |
| |
| private heightChange() { |
| this.state.height = +this.heightInput!.value; |
| this.dimensionsChanged(); |
| } |
| |
| private dimensionsChanged() { |
| this._render(); |
| this.stateChanged!(); |
| this.particlesPlayer!.initialize({ |
| body: this.json, |
| width: this.state.width, |
| height: this.state.height, |
| }); |
| } |
| |
| private setJSON(json: any) { |
| this.json = json; |
| this.editor!.update(json); |
| this.particlesPlayer!.initialize({ |
| body: this.json, |
| width: this.state.width, |
| height: this.state.height, |
| }); |
| if (this.downloadURL) { |
| URL.revokeObjectURL(this.downloadURL); |
| } |
| |
| this.downloadURL = URL.createObjectURL(new Blob([JSON.stringify(this.json, null, ' ')])); |
| this.hasEdits = false; |
| this._render(); |
| } |
| |
| private async openUploadDialog() { |
| try { |
| const cfg: ParticlesConfig = { |
| body: this.json, |
| }; |
| const newConfig = await this.configEditor!.show(cfg); |
| if (!newConfig) { |
| return; |
| } |
| this.setJSON(newConfig.body); |
| await this.upload(); |
| this.stateChanged!(); |
| this._render(); |
| } catch (err) { |
| await errorMessage(err); |
| } |
| } |
| |
| private async applyEdits() { |
| this.setJSON(this.editor!.get()); |
| await this.upload(); |
| } |
| |
| private togglePlayPause() { |
| if (this.playing) { |
| this.pause(); |
| } else { |
| this.play(); |
| } |
| } |
| |
| private play() { |
| this.playPauseButton!.textContent = 'Pause'; |
| this.particlesPlayer!.play(); |
| this.playing = true; |
| } |
| |
| private pause() { |
| this.playPauseButton!.textContent = 'Play'; |
| this.particlesPlayer!.pause(); |
| this.playing = false; |
| } |
| |
| private async loadParticlesIfNecessary() { |
| try { |
| if (this.currentNameOrHash === this.state.nameOrHash) { |
| return; |
| } |
| const resp = await fetch(`/_/j/${this.state.nameOrHash}`, { |
| credentials: 'include', |
| }); |
| const json = await jsonOrThrow(resp) as ScrapBody; |
| this.setJSON(JSON.parse(json.Body) as any); |
| this.play(); |
| this.currentNameOrHash = this.state.nameOrHash; |
| } catch (error) { |
| await errorMessage(error); |
| // Return to the default view. |
| this.state = Object.assign({}, defaultState); |
| this.currentNameOrHash = this.state.nameOrHash; |
| this.stateChanged!(); |
| } |
| } |
| |
| private resetView() { |
| this.particlesPlayer!.resetView(); |
| } |
| |
| private restartAnimation() { |
| this.particlesPlayer!.restartAnimation(); |
| } |
| |
| private toggleEditor() { |
| this.state.showEditor = this.editorDetails!.open; |
| this.stateChanged!(); |
| this._render(); |
| } |
| |
| private async upload() { |
| const body: ScrapBody = { |
| Body: JSON.stringify(this.json), |
| Type: 'particle', |
| }; |
| try { |
| // POST the JSON to /_/upload |
| const resp = await fetch('/_/upload', { |
| credentials: 'include', |
| body: JSON.stringify(body), |
| headers: { |
| 'Content-Type': 'application/json', |
| }, |
| method: 'POST', |
| }); |
| const json = await jsonOrThrow(resp) as ScrapID; |
| |
| this.state.nameOrHash = json.Hash; |
| this.stateChanged!(); |
| } catch (error) { |
| await errorMessage(`${error}`); |
| } |
| } |
| } |
| |
| define('particles-sk', ParticlesSk); |