blob: 06e2d6c0d2c635d6bbe475ef829fbd0bd803e7ae [file] [log] [blame]
/**
* @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);