mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-17 00:13:17 +00:00
371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
import * as studio from "@storyteller/studio";
|
|
import { type EntitySelectEvent } from "@storyteller/studio";
|
|
import { drag, on } from "@storyteller/framework";
|
|
import { LitElement, PropertyValues, html, nothing, unsafeCSS } from "lit";
|
|
import { customElement, property, state } from "lit/decorators.js";
|
|
import { classMap } from "lit/directives/class-map.js";
|
|
import { repeat } from "lit/directives/repeat.js";
|
|
import { styleMap } from "lit/directives/style-map.js";
|
|
|
|
import styles from "./anim-timeline.element.scss?inline";
|
|
|
|
// TODO: This is basically a [signal](https://www.solidjs.com/tutorial/introduction_signals)
|
|
// that requires manual dependency tracking. Given that requirement, it's
|
|
// questionable whether this is even a valuable abstraction to have.
|
|
//
|
|
// We should either make it more obviously valuable (e.g. by coming up
|
|
// with a way to automatically handle dependencies) and move it to
|
|
// `@storyteller/framework`, or just get rid of it.
|
|
//
|
|
// We could maybe add a decorator that could automatically override the
|
|
// `update` method to invalidate the cache when dependencies are changed,
|
|
// which would look something like:
|
|
// ```
|
|
// @memo(["rangeStart", "rangeEnd", "_renderWidth"])
|
|
// secondsPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth);
|
|
// ```
|
|
// But that feels a little obtuse and overengineered. In this particular
|
|
// case we're probably only saving on the order of nanoseconds, so I'm
|
|
// leaning toward just nuking the idea.
|
|
class Computed extends Number {
|
|
#value?: number;
|
|
#compute: () => number;
|
|
|
|
constructor (compute: () => number) {
|
|
const value = compute();
|
|
super(value);
|
|
this.#compute = compute;
|
|
}
|
|
|
|
override valueOf(): number {
|
|
return this.#value ??= this.#compute();
|
|
}
|
|
|
|
get(): number {
|
|
return this.valueOf();
|
|
}
|
|
|
|
invalidate(): void {
|
|
this.#value = undefined;
|
|
}
|
|
}
|
|
|
|
@customElement("sts-anim-timeline")
|
|
export class AnimTimelineElement extends LitElement {
|
|
static override styles = unsafeCSS(styles);
|
|
|
|
/** The time (in seconds) at the left edge of the timeline view */
|
|
@property({ type: Number }) rangeStart = 0;
|
|
/** The time (in seconds) at the right edge of the timeline view */
|
|
@property({ type: Number }) rangeEnd = 5;
|
|
|
|
|
|
/** The time (in seconds) at the start of the clip */
|
|
@property({ type: Number }) clipStart = 0;
|
|
/** The time (in seconds) at the end of the clip */
|
|
@property({ type: Number }) clipEnd = 5;
|
|
|
|
/** The time (in seconds) of the current play-head position */
|
|
@property({ type: Number }) playHead = 0;
|
|
/** Keyframe positions (in seconds) */
|
|
@property({ attribute: false }) keyframes: Record<string, studio.Keyframe> = {};
|
|
@property({ attribute: false }) track_ordered: Record<string, number> = {}
|
|
|
|
@state() _selectedKeyframe: string | null = null;
|
|
|
|
/** The width (in px) of the timeline view */
|
|
@state() _renderWidth!: number;
|
|
|
|
/**
|
|
* `true` when the timeline is being mouse-panned. Primarily useful for
|
|
* setting the cursor style to the grabby hand.
|
|
*/
|
|
@state() _panning = false;
|
|
|
|
/** How many seconds are represented by each px of horizontal space. */
|
|
secondsPerPx = new Computed(() => (this.rangeEnd - this.rangeStart) / this._renderWidth);
|
|
|
|
#resizeObserver: ResizeObserver;
|
|
|
|
constructor () {
|
|
super();
|
|
this.#resizeObserver = new ResizeObserver(() => this.#updateRenderWidth());
|
|
this.#resizeObserver.observe(this);
|
|
}
|
|
|
|
override connectedCallback(): void {
|
|
super.connectedCallback();
|
|
this.#updateRenderWidth();
|
|
}
|
|
|
|
override disconnectedCallback(): void {
|
|
super.disconnectedCallback();
|
|
this.#resizeObserver.disconnect();
|
|
}
|
|
|
|
protected override update(changes: PropertyValues<this>): void {
|
|
console.log("Changes: ", changes);
|
|
if (
|
|
changes.has("rangeStart")
|
|
|| changes.has("rangeEnd")
|
|
|| changes.has("_renderWidth")
|
|
) {
|
|
this.secondsPerPx.invalidate();
|
|
}
|
|
|
|
super.update(changes);
|
|
}
|
|
|
|
@on("wheel")
|
|
onWheel(event: WheelEvent): void {
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
|
|
if (event.ctrlKey) {
|
|
this.#zoomTimeline(event.clientX, event.deltaY);
|
|
} else if (event.deltaX) {
|
|
this.#pan(-event.deltaX);
|
|
} else if (event.shiftKey && event.deltaY) {
|
|
this.#pan(-event.deltaY);
|
|
}
|
|
}
|
|
|
|
@on("window:entity-select")
|
|
onEntitySelected({ detail: id }: EntitySelectEvent): void {
|
|
if (!id) {
|
|
this._selectedKeyframe = null;
|
|
return;
|
|
}
|
|
|
|
if (Array.from(Object.keys(this.keyframes)).includes(id)) {
|
|
let keyframe = this.keyframes[id];
|
|
this._selectedKeyframe = id;
|
|
studio.seekTime(keyframe.time);
|
|
} else {
|
|
this._selectedKeyframe = null;
|
|
}
|
|
}
|
|
|
|
selectKeyframe(id: string): void {
|
|
studio.select(id);
|
|
}
|
|
|
|
#zoomTimeline(origin: number, delta: number): void {
|
|
const currentRange = this.rangeEnd - this.rangeStart;
|
|
const elementRect = this.getBoundingClientRect();
|
|
const focusPoint = (origin - elementRect.x) / elementRect.width;
|
|
|
|
const deltaTimeScale = delta * 0.001 * currentRange;
|
|
|
|
const rangeStart = Math.max(this.rangeStart - deltaTimeScale * focusPoint, 0.);
|
|
const rangeEnd = Math.max(this.rangeEnd + deltaTimeScale * (1 - focusPoint), rangeStart + 0.01);
|
|
|
|
this.dispatchEvent(new CustomEvent("rangeStart-change", { detail: rangeStart }));
|
|
this.dispatchEvent(new CustomEvent("rangeEnd-change", { detail: rangeEnd }));
|
|
}
|
|
|
|
onDragStart(): void {
|
|
this._panning = true;
|
|
}
|
|
|
|
onDragEnd(): void {
|
|
this._panning = false;
|
|
}
|
|
|
|
onDrag(event: PointerEvent): void {
|
|
this.#pan(event.movementX);
|
|
}
|
|
|
|
#pan(deltaPx: number): void {
|
|
const deltaChange = deltaPx * 0.001;
|
|
const scaledChange = (this.rangeEnd - this.rangeStart) * deltaChange;
|
|
|
|
let rangeStart = this.rangeStart - scaledChange;
|
|
let rangeEnd = Math.max(this.rangeEnd - scaledChange, rangeStart + 0.01);
|
|
|
|
if (rangeStart < 0) {
|
|
rangeEnd = this.rangeEnd - this.rangeStart;
|
|
rangeStart = 0;
|
|
}
|
|
|
|
this.dispatchEvent(new CustomEvent("rangeStart-change", { detail: rangeStart }));
|
|
this.dispatchEvent(new CustomEvent("rangeEnd-change", { detail: rangeEnd }));
|
|
}
|
|
|
|
onPlayheadDrag(event: PointerEvent): void {
|
|
let elementBounds = this.getBoundingClientRect();
|
|
let relativeX = event.clientX - elementBounds.left;
|
|
let ratioX = Math.max(0, Math.min(relativeX / elementBounds.width, 1));
|
|
const seconds = (this.rangeEnd - this.rangeStart) * ratioX + this.rangeStart;
|
|
const updated = seconds;
|
|
|
|
this.dispatchEvent(new CustomEvent("playHead-change", { detail: updated }));
|
|
}
|
|
|
|
onKeyframeDrag(id: string, event: PointerEvent): void {
|
|
let elementBounds = this.getBoundingClientRect();
|
|
let relativeX = event.clientX - elementBounds.left;
|
|
let ratioX = Math.max(0, Math.min(relativeX / elementBounds.width, 1));
|
|
const time = (this.rangeEnd - this.rangeStart) * ratioX + this.rangeStart;
|
|
|
|
studio.moveKeyframeTime(id, time);
|
|
}
|
|
|
|
#updateRenderWidth(): void {
|
|
this._renderWidth = this.getBoundingClientRect().width;
|
|
}
|
|
|
|
/**
|
|
* Format a time value (in seconds) for displaying in the timeline view
|
|
*/
|
|
timestamp(seconds: number) {
|
|
const sign = seconds < 0 ? "-" : "";
|
|
seconds = Math.abs(seconds);
|
|
|
|
let minutes = Math.floor(seconds / 60);
|
|
let secondsInMinute = seconds % 60;
|
|
let flooredSeconds = Math.floor(secondsInMinute);
|
|
let fraction = Math.floor(100 * (secondsInMinute - flooredSeconds));
|
|
|
|
return html`${
|
|
sign
|
|
}${
|
|
minutes.toString(10).padStart(2, "0")
|
|
}:${
|
|
flooredSeconds.toString(10).padStart(2, "0")
|
|
}.${
|
|
fraction.toString(10).padStart(2, "0")
|
|
}`;
|
|
}
|
|
|
|
protected override render() {
|
|
const range = this.rangeEnd - this.rangeStart;
|
|
|
|
let subdivisionScale = 0.5;
|
|
let majorSubdivisionScale = 1;
|
|
|
|
if (range < 0.5) {
|
|
subdivisionScale = 0.05;
|
|
majorSubdivisionScale = 0.1;
|
|
} else if (range < 2) {
|
|
subdivisionScale = 1 / 4;
|
|
majorSubdivisionScale = 0.5;
|
|
} else if (range > 120) {
|
|
subdivisionScale = 30;
|
|
majorSubdivisionScale = 60;
|
|
} else if (range > 60) {
|
|
subdivisionScale = 10;
|
|
majorSubdivisionScale = 30;
|
|
} else if (range > 10) {
|
|
subdivisionScale = 5;
|
|
majorSubdivisionScale = 10;
|
|
} else if (range > 5) {
|
|
subdivisionScale = 1;
|
|
majorSubdivisionScale = 5;
|
|
}
|
|
|
|
const scaledStart = this.rangeStart / subdivisionScale;
|
|
const scaledEnd = this.rangeEnd / subdivisionScale;
|
|
|
|
console.log("Scaled Start", scaledStart, "Scaled End", scaledEnd, "Range Start", this.rangeStart, "Range End", this.rangeEnd, "Range", range, "Playhead", this.playHead);
|
|
|
|
let subdivisions : Array<{seconds: number, fraction: number, isMajor: boolean}> = [];
|
|
let currentOffset = scaledStart;
|
|
let currentOffsetSeconds = this.rangeStart;
|
|
|
|
while (currentOffset < scaledEnd) {
|
|
const currentSeconds = currentOffsetSeconds;
|
|
const isMajor = currentOffsetSeconds % majorSubdivisionScale === 0;
|
|
subdivisions.push({ seconds: currentSeconds, fraction: (currentSeconds - this.rangeStart) / range, isMajor });
|
|
let nextSubdiv = Math.ceil(currentOffset);
|
|
if (Math.abs(nextSubdiv - currentOffset) < 0.1) {
|
|
nextSubdiv += 1;
|
|
}
|
|
currentOffset = nextSubdiv;
|
|
currentOffsetSeconds = currentOffset * subdivisionScale;
|
|
}
|
|
|
|
console.log("Subdivisions", subdivisions.length, subdivisions);
|
|
|
|
return html`
|
|
<div
|
|
class=${classMap({
|
|
wrapper: true,
|
|
panning: this._panning,
|
|
})}
|
|
${drag({
|
|
button: 1,
|
|
start: this.onDragStart,
|
|
move: this.onDrag,
|
|
end: this.onDragEnd,
|
|
})}
|
|
tabindex="-1"
|
|
>
|
|
|
|
${subdivisions.map(({ seconds, fraction, isMajor}, idx) => {
|
|
const secondsPercentage = fraction * 100;
|
|
|
|
return html`
|
|
<div
|
|
class=${classMap({
|
|
hash: true,
|
|
strong: isMajor,
|
|
})}
|
|
style=${styleMap({
|
|
left: `${secondsPercentage}%`
|
|
})}
|
|
></div>
|
|
${true ? html`
|
|
<span class="timestamp"
|
|
style=${styleMap({
|
|
left: `calc(${secondsPercentage}% + 8px)`
|
|
})}
|
|
>
|
|
${this.timestamp(seconds)}
|
|
</span>
|
|
` : nothing}
|
|
`;
|
|
})}
|
|
|
|
<div class="playhead"
|
|
${drag({
|
|
move: this.onPlayheadDrag
|
|
})}
|
|
style=${styleMap({
|
|
left: `${100 * (this.playHead - this.rangeStart) / range}%`
|
|
})}
|
|
tabindex="-1"
|
|
>
|
|
<div class="head"></div>
|
|
<div class="bar"></div>
|
|
</div>
|
|
|
|
${repeat(
|
|
Object.entries(this.keyframes),
|
|
([id]) => id,
|
|
([id, keyframe]) => {
|
|
let row = this.track_ordered[keyframe.target];
|
|
return html`
|
|
<div id=${id}
|
|
class=${classMap({
|
|
keyframe: true,
|
|
selected: this._selectedKeyframe === id
|
|
})}
|
|
style=${styleMap({
|
|
left: `${100 * (keyframe.time - this.rangeStart) / range}%`,
|
|
gridRow: row + 2,
|
|
})}
|
|
tabindex="-1"
|
|
${drag({
|
|
move: event => this.onKeyframeDrag(id, event),
|
|
})}
|
|
@click=${() => this.selectKeyframe(id)}
|
|
></div>
|
|
`;
|
|
}
|
|
)}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|