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 = {}; @property({ attribute: false }) track_ordered: Record = {} @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): 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`
${subdivisions.map(({ seconds, fraction, isMajor}, idx) => { const secondsPercentage = fraction * 100; return html`
${true ? html` ${this.timestamp(seconds)} ` : nothing} `; })}
${repeat( Object.entries(this.keyframes), ([id]) => id, ([id, keyframe]) => { let row = this.track_ordered[keyframe.target]; return html`
this.onKeyframeDrag(id, event), })} @click=${() => this.selectKeyframe(id)} >
`; } )}
`; } }