The scene view and the animation preview share one camera, the three form dialogs one base, and keys are found through one set of helpers (#85)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bf02c9e8b3
commit
2a390e52ff
22 files changed
+418
-452
No files matched your search
@@ -2,7 +2,10 @@ import { newId } from '../ids';
|
||||
import {
|
||||
ANIMATION_ROOT_TYPES,
|
||||
blends,
|
||||
findTrack,
|
||||
hasKey,
|
||||
isKeyInterpolation,
|
||||
type KeyRef,
|
||||
normalizeKeys,
|
||||
targetAssetOf,
|
||||
targetDefs,
|
||||
@@ -16,12 +19,6 @@ import { coerceValue } from '../properties/PropertySchema';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset } from './require';
|
||||
|
||||
/** A key on one row, as the timeline selects it. */
|
||||
export interface KeyRef {
|
||||
trackId: string;
|
||||
frame: number;
|
||||
}
|
||||
|
||||
function requireAnimation(project: Project, assetId: string): AnimationAsset {
|
||||
const asset = requireAsset(project, assetId);
|
||||
if (asset.type !== 'animation') throw new CommandError(`${asset.name} is not an animation`);
|
||||
@@ -29,11 +26,27 @@ function requireAnimation(project: Project, assetId: string): AnimationAsset {
|
||||
}
|
||||
|
||||
function requireTrack(asset: AnimationAsset, trackId: string): AnimationTrack {
|
||||
const track = asset.tracks.find((t) => t.id === trackId);
|
||||
const track = findTrack(asset, trackId);
|
||||
if (!track) throw new CommandError(`${asset.name} has no such row`);
|
||||
return track;
|
||||
}
|
||||
|
||||
function requireKey(asset: AnimationAsset, ref: KeyRef): { track: AnimationTrack; key: AnimationKey } {
|
||||
const track = requireTrack(asset, ref.trackId);
|
||||
const key = track.keys.find((k) => k.frame === ref.frame);
|
||||
if (!key) throw new CommandError(`No key on frame ${ref.frame}`);
|
||||
return { track, key };
|
||||
}
|
||||
|
||||
function requireFrame(frame: number, message: string): void {
|
||||
if (!Number.isInteger(frame) || frame < 0) throw new CommandError(message);
|
||||
}
|
||||
|
||||
/** `Remove key`, `Remove 3 keys` – how a key command names itself. */
|
||||
function keysLabel(verb: string, count: number): string {
|
||||
return count === 1 ? `${verb} key` : `${verb} ${count} keys`;
|
||||
}
|
||||
|
||||
/** The kind of `property` on the object `target` names, refusing what cannot be animated there. */
|
||||
function requireAnimatable(
|
||||
project: Project,
|
||||
@@ -217,8 +230,7 @@ export class SetAnimationKeyCommand extends TimelineCommand {
|
||||
|
||||
protected apply(project: Project, asset: AnimationAsset): void {
|
||||
const track = requireTrack(asset, this.trackId);
|
||||
if (!Number.isInteger(this.frame) || this.frame < 0)
|
||||
throw new CommandError('A key sits on a whole frame from 0');
|
||||
requireFrame(this.frame, 'A key sits on a whole frame from 0');
|
||||
const def = trackDef(project, asset, track);
|
||||
if (!def) throw new CommandError('The row aims at something that is gone');
|
||||
const current = trackValueAt(track.keys, def.kind, this.frame);
|
||||
@@ -250,9 +262,7 @@ export class SetKeyInterpolationCommand extends TimelineCommand {
|
||||
if (!isKeyInterpolation(this.interpolation)) throw new CommandError('No such easing');
|
||||
let changed = 0;
|
||||
for (const ref of this.keys) {
|
||||
const track = requireTrack(asset, ref.trackId);
|
||||
const key = track.keys.find((k) => k.frame === ref.frame);
|
||||
if (!key) throw new CommandError(`No key on frame ${ref.frame}`);
|
||||
const { track, key } = requireKey(asset, ref);
|
||||
const kind = trackDef(project, asset, track)?.kind;
|
||||
if (kind !== undefined && !blends(kind)) continue;
|
||||
if (this.interpolation === 'linear') delete key.interpolation;
|
||||
@@ -271,16 +281,14 @@ export class RemoveAnimationKeysCommand extends TimelineCommand {
|
||||
private readonly keys: readonly KeyRef[],
|
||||
) {
|
||||
super(assetId);
|
||||
this.label = keys.length === 1 ? 'Remove key' : `Remove ${keys.length} keys`;
|
||||
this.label = keysLabel('Remove', keys.length);
|
||||
}
|
||||
|
||||
protected apply(_project: Project, asset: AnimationAsset): void {
|
||||
if (this.keys.length === 0) throw new CommandError('No keys to remove');
|
||||
for (const ref of this.keys) {
|
||||
const track = requireTrack(asset, ref.trackId);
|
||||
const at = track.keys.findIndex((k) => k.frame === ref.frame);
|
||||
if (at < 0) throw new CommandError(`No key on frame ${ref.frame}`);
|
||||
track.keys.splice(at, 1);
|
||||
const { track, key } = requireKey(asset, ref);
|
||||
track.keys.splice(track.keys.indexOf(key), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,8 +307,7 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
|
||||
private readonly copy = false,
|
||||
) {
|
||||
super(assetId);
|
||||
const noun = keys.length === 1 ? 'key' : `${keys.length} keys`;
|
||||
this.label = `${copy ? 'Duplicate' : 'Move'} ${noun}`;
|
||||
this.label = keysLabel(copy ? 'Duplicate' : 'Move', keys.length);
|
||||
}
|
||||
|
||||
protected apply(_project: Project, asset: AnimationAsset): void {
|
||||
@@ -308,9 +315,7 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
|
||||
if (this.copy && this.delta === 0) throw new CommandError('A duplicate has to land somewhere else');
|
||||
const byTrack = new Map<string, Set<number>>();
|
||||
for (const ref of this.keys) {
|
||||
const track = requireTrack(asset, ref.trackId);
|
||||
if (!track.keys.some((k) => k.frame === ref.frame))
|
||||
throw new CommandError(`No key on frame ${ref.frame}`);
|
||||
requireKey(asset, ref);
|
||||
if (ref.frame + this.delta < 0) throw new CommandError('A key cannot move before frame 0');
|
||||
byTrack.set(ref.trackId, (byTrack.get(ref.trackId) ?? new Set()).add(ref.frame));
|
||||
}
|
||||
@@ -342,6 +347,10 @@ export interface KeyClipboard {
|
||||
rows: ClipboardRow[];
|
||||
}
|
||||
|
||||
export function clipboardKeyCount(clipboard: KeyClipboard): number {
|
||||
return clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
|
||||
}
|
||||
|
||||
/** The picked keys as a clipboard, or null when none of them exists. */
|
||||
export function copyAnimationKeys(
|
||||
project: Project,
|
||||
@@ -351,7 +360,7 @@ export function copyAnimationKeys(
|
||||
const picked = asset.tracks
|
||||
.map((track) => ({
|
||||
track,
|
||||
keys: track.keys.filter((k) => refs.some((r) => r.trackId === track.id && r.frame === k.frame)),
|
||||
keys: track.keys.filter((k) => hasKey(refs, { trackId: track.id, frame: k.frame })),
|
||||
}))
|
||||
.filter((p) => p.keys.length > 0);
|
||||
if (picked.length === 0) return null;
|
||||
@@ -391,13 +400,11 @@ export class PasteAnimationKeysCommand extends TimelineCommand {
|
||||
private readonly intoTrackId = '',
|
||||
) {
|
||||
super(assetId);
|
||||
const count = clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
|
||||
this.label = count === 1 ? 'Paste key' : `Paste ${count} keys`;
|
||||
this.label = keysLabel('Paste', clipboardKeyCount(clipboard));
|
||||
}
|
||||
|
||||
protected apply(project: Project, asset: AnimationAsset): void {
|
||||
if (!Number.isInteger(this.frame) || this.frame < 0)
|
||||
throw new CommandError('Keys paste onto a whole frame from 0');
|
||||
requireFrame(this.frame, 'Keys paste onto a whole frame from 0');
|
||||
this.pasted = [];
|
||||
this.skipped = 0;
|
||||
const single = this.clipboard.rows.length === 1 && this.intoTrackId !== '';
|
||||
@@ -434,7 +441,7 @@ export class PasteAnimationKeysCommand extends TimelineCommand {
|
||||
|
||||
private matchingTrack(asset: AnimationAsset, row: ClipboardRow): AnimationTrack | undefined {
|
||||
return asset.id === this.clipboard.assetId
|
||||
? asset.tracks.find((t) => t.id === row.trackId)
|
||||
? findTrack(asset, row.trackId)
|
||||
: asset.tracks.find((t) => t.target === row.target && t.property === row.property);
|
||||
}
|
||||
}
|
||||
@@ -467,8 +474,7 @@ export class ShiftAnimationFramesCommand extends TimelineCommand {
|
||||
}
|
||||
|
||||
protected apply(_project: Project, asset: AnimationAsset): void {
|
||||
if (!Number.isInteger(this.at) || this.at < 0)
|
||||
throw new CommandError('Frames are inserted at a whole frame from 0');
|
||||
requireFrame(this.at, 'Frames are inserted at a whole frame from 0');
|
||||
if (!Number.isInteger(this.count) || this.count === 0)
|
||||
throw new CommandError('Give a whole number of frames');
|
||||
let touched = 0;
|
||||
|
||||
@@ -17,9 +17,9 @@ export type Playback = (typeof PLAYBACKS)[number];
|
||||
export const ANIMATION_ROOT_TYPES: readonly AssetType[] = ['sprite', 'text', 'container', 'scene'];
|
||||
|
||||
/** Position lives on the object, not in its property bag, so the timeline offers it by name. */
|
||||
export const POSITION_KEYS = ['x', 'y'] as const;
|
||||
const POSITION_KEYS = ['x', 'y'] as const;
|
||||
|
||||
export function isPositionKey(key: string): key is (typeof POSITION_KEYS)[number] {
|
||||
function isPositionKey(key: string): key is (typeof POSITION_KEYS)[number] {
|
||||
return (POSITION_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function fpsOf(asset: AnimationAsset): number {
|
||||
}
|
||||
|
||||
/** The frame of the last key over all rows; -1 for an animation without keys. */
|
||||
export function lastKeyFrame(asset: AnimationAsset): number {
|
||||
function lastKeyFrame(asset: AnimationAsset): number {
|
||||
let last = -1;
|
||||
for (const track of asset.tracks) {
|
||||
const key = track.keys[track.keys.length - 1];
|
||||
@@ -133,7 +133,7 @@ export function interpolationOf(key: AnimationKey): KeyInterpolation {
|
||||
* How far along the way to the next key a value is, `t` the share of the time passed. The eases are
|
||||
* quadratic: ease-in reaches 0.25 at half the time, ease-out 0.75, ease-in-out 0.5 but 0.125 at a quarter.
|
||||
*/
|
||||
export function easedShare(interpolation: KeyInterpolation, t: number): number {
|
||||
function easedShare(interpolation: KeyInterpolation, t: number): number {
|
||||
switch (interpolation) {
|
||||
case 'linear':
|
||||
return t;
|
||||
@@ -210,6 +210,16 @@ export function animatedKind(type: AssetType, key: string): string | undefined {
|
||||
return animatableDefs(type).find((d) => d.key === key)?.kind;
|
||||
}
|
||||
|
||||
/** The kind `key` has on an asset holding `bag` – see `animatableDefsOf`. */
|
||||
export function animatedKindOf(
|
||||
project: Project,
|
||||
asset: Asset,
|
||||
bag: PropertyBag,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
return animatableDefsOf(project, asset, bag).find((d) => d.key === key)?.kind;
|
||||
}
|
||||
|
||||
function hasPosition(type: AssetType): boolean {
|
||||
return type !== 'scene' && type !== 'behavior';
|
||||
}
|
||||
@@ -501,6 +511,28 @@ export function pauseAnimation(holder: PlayerHolder, animationId: string): void
|
||||
if (animationId === '' || player.animationId === animationId) player.playing = false;
|
||||
}
|
||||
|
||||
/** A key on one row, as the timeline selects it. */
|
||||
export interface KeyRef {
|
||||
trackId: string;
|
||||
frame: number;
|
||||
}
|
||||
|
||||
export function sameKey(a: KeyRef, b: KeyRef): boolean {
|
||||
return a.trackId === b.trackId && a.frame === b.frame;
|
||||
}
|
||||
|
||||
export function hasKey(keys: readonly KeyRef[], key: KeyRef): boolean {
|
||||
return keys.some((k) => sameKey(k, key));
|
||||
}
|
||||
|
||||
export function findTrack(asset: AnimationAsset, trackId: string): AnimationTrack | undefined {
|
||||
return asset.tracks.find((t) => t.id === trackId);
|
||||
}
|
||||
|
||||
export function findKey(asset: AnimationAsset, ref: KeyRef): AnimationKey | undefined {
|
||||
return findTrack(asset, ref.trackId)?.keys.find((k) => k.frame === ref.frame);
|
||||
}
|
||||
|
||||
/** Sorted by frame, at most one per frame (the later wins), frames whole and not negative. */
|
||||
export function normalizeKeys(keys: readonly AnimationKey[]): AnimationKey[] {
|
||||
const byFrame = new Map<number, AnimationKey>();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import {
|
||||
animatableDefs,
|
||||
animatableDefsOf,
|
||||
animatedKindOf,
|
||||
frameCount,
|
||||
isPositionRow,
|
||||
PLACED_ROOT_ID,
|
||||
@@ -18,9 +18,6 @@ import { instanceChildId, objectRef } from './instance';
|
||||
import { findAsset, type ObjectHolder, type Project } from './Project';
|
||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||
|
||||
/** The one object the preview places the root on – what a row aimed at the root itself writes to. */
|
||||
export const PREVIEW_ROOT_ID = PLACED_ROOT_ID;
|
||||
|
||||
/**
|
||||
* The holder the preview draws at `frame`: a copy of the scene for a scene root, otherwise a holder
|
||||
* of one object placing the root at the origin, its content resolved from the template like any
|
||||
@@ -50,7 +47,7 @@ export function previewHolder(
|
||||
/** The id the preview draws a row's target under – for a check that wants to look at it. */
|
||||
export function previewObjectId(root: Asset | undefined, target: string): string {
|
||||
if (root?.type === 'scene') return target;
|
||||
return target === '' ? PREVIEW_ROOT_ID : instanceChildId(PREVIEW_ROOT_ID, target);
|
||||
return target === '' ? PLACED_ROOT_ID : instanceChildId(PLACED_ROOT_ID, target);
|
||||
}
|
||||
|
||||
/** Deep enough that nothing a row writes – a position, an override, an instance edit – is the document's. */
|
||||
@@ -68,7 +65,7 @@ function sceneCopy(scene: ObjectHolder): ObjectHolder {
|
||||
}
|
||||
|
||||
function placedHolder(root: Asset): ObjectHolder {
|
||||
return { id: PREVIEW_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placedRoot(root)] };
|
||||
return { id: PLACED_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placedRoot(root)] };
|
||||
}
|
||||
|
||||
/** A row on the scene itself: its value goes into the copy's own properties. */
|
||||
@@ -92,7 +89,7 @@ function writeObject(
|
||||
if (!ref || !asset) return;
|
||||
// Resolved, so a shader worn through an override or an instance edit brings its parameters (#104).
|
||||
const props = resolveSceneObjectProps(project, asset, ref.object);
|
||||
const kind = animatableDefsOf(project, asset, props).find((d) => d.key === track.property)?.kind;
|
||||
const kind = animatedKindOf(project, asset, props, track.property);
|
||||
if (!kind) return;
|
||||
const key = track.property;
|
||||
if (isPositionRow(asset.type, key)) {
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { propertyNodeParams } from '../properties/declaredRows';
|
||||
import { flatDef } from '../properties/PropertySchema';
|
||||
import {
|
||||
PARTICLE_PROPERTY,
|
||||
PROPERTY_DEFAULT_PARAM,
|
||||
PROPERTY_MAX_PARAM,
|
||||
PROPERTY_MIN_PARAM,
|
||||
PROPERTY_NAME_PARAM,
|
||||
propertyNameOf,
|
||||
} from '../properties/particles';
|
||||
import { PARTICLE_PROPERTY, PROPERTY_NAME_PARAM, propertyNameOf } from '../properties/particles';
|
||||
|
||||
/** What one particle is, at one moment: everything a graph may read about it. */
|
||||
export interface ParticleEnv {
|
||||
@@ -249,12 +243,7 @@ export const ParticleProperty: ParticleNodeDef = {
|
||||
description: 'A number the source sets: a property row on the placed source, and a value here.',
|
||||
inputs: [],
|
||||
outputs: [numberOut('value')],
|
||||
params: [
|
||||
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
|
||||
{ key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step: NUMBER_STEP },
|
||||
{ key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step: NUMBER_STEP },
|
||||
{ key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step: NUMBER_STEP },
|
||||
],
|
||||
params: propertyNodeParams(NUMBER_STEP),
|
||||
derivePorts: ({ node }) => ({ label: propertyNameOf(node) || ParticleProperty.label }),
|
||||
emit: (_inputs, params, ctx) => {
|
||||
// The slot is bound once, here: reading it per particle would cost a lookup by name each time.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Graph, GraphNode } from '../graph/Graph';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import type { FlatDef } from './PropertySchema';
|
||||
import { flatDef, type FlatDef, type PropertyDef } from './PropertySchema';
|
||||
|
||||
/** The params of a `Property` node in a shader or particle graph: its name and the row it declares. */
|
||||
export const PROPERTY_NAME_PARAM = 'name';
|
||||
@@ -8,6 +8,16 @@ export const PROPERTY_DEFAULT_PARAM = 'default';
|
||||
export const PROPERTY_MIN_PARAM = 'min';
|
||||
export const PROPERTY_MAX_PARAM = 'max';
|
||||
|
||||
/** The params of a `Property` node, the same in both graphs but for the step of their number fields. */
|
||||
export function propertyNodeParams(step: number): PropertyDef[] {
|
||||
return [
|
||||
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
|
||||
{ key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step },
|
||||
{ key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step },
|
||||
{ key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step },
|
||||
];
|
||||
}
|
||||
|
||||
export interface Declaration {
|
||||
isDeclaration(node: GraphNode): boolean;
|
||||
nameOf(node: GraphNode): string;
|
||||
|
||||
@@ -3,12 +3,7 @@ import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { declaredNumberRows, PROPERTY_NAME_PARAM } from './declaredRows';
|
||||
import { type FlatDef, isValidFieldName } from './PropertySchema';
|
||||
|
||||
export {
|
||||
PROPERTY_DEFAULT_PARAM,
|
||||
PROPERTY_MAX_PARAM,
|
||||
PROPERTY_MIN_PARAM,
|
||||
PROPERTY_NAME_PARAM,
|
||||
} from './declaredRows';
|
||||
export { PROPERTY_NAME_PARAM } from './declaredRows';
|
||||
|
||||
/** The node of a particle graph that declares a parameter: a row on every source placed from the asset. */
|
||||
export const PARTICLE_PROPERTY = 'ParticleProperty';
|
||||
|
||||
@@ -3,12 +3,7 @@ import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { declaredNumberRows, PROPERTY_NAME_PARAM } from './declaredRows';
|
||||
import { type FlatDef, isSpriteProp, isValidFieldName } from './PropertySchema';
|
||||
|
||||
export {
|
||||
PROPERTY_DEFAULT_PARAM,
|
||||
PROPERTY_MAX_PARAM,
|
||||
PROPERTY_MIN_PARAM,
|
||||
PROPERTY_NAME_PARAM,
|
||||
} from './declaredRows';
|
||||
export { PROPERTY_NAME_PARAM } from './declaredRows';
|
||||
|
||||
/** The sprite property naming the shader asset it wears; `''` is no shader and no render pass. */
|
||||
export const SHADER_KEY = 'shader';
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { propertyNodeParams } from '../properties/declaredRows';
|
||||
import { flatDef } from '../properties/PropertySchema';
|
||||
import {
|
||||
PROPERTY_DEFAULT_PARAM,
|
||||
PROPERTY_MAX_PARAM,
|
||||
PROPERTY_MIN_PARAM,
|
||||
PROPERTY_NAME_PARAM,
|
||||
propertyName,
|
||||
SHADER_PROPERTY,
|
||||
} from '../properties/shaders';
|
||||
import { PROPERTY_NAME_PARAM, propertyName, SHADER_PROPERTY } from '../properties/shaders';
|
||||
|
||||
export type GlslType = 'float' | 'vec3';
|
||||
|
||||
@@ -232,12 +226,7 @@ export const ShaderProperty: ShaderNodeDef = {
|
||||
description: 'A number the sprite sets: a property row below the Shader dropdown, and a value here.',
|
||||
inputs: [],
|
||||
outputs: [{ key: 'value', label: 'value', type: 'number' }],
|
||||
params: [
|
||||
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
|
||||
{ key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step: NUMBER_STEP },
|
||||
{ key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step: NUMBER_STEP },
|
||||
{ key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step: NUMBER_STEP },
|
||||
],
|
||||
params: propertyNodeParams(NUMBER_STEP),
|
||||
derivePorts: ({ node }) => ({ label: propertyName(node) || ShaderProperty.label }),
|
||||
emit: (_inputs, params) => ({
|
||||
value: { code: uniformName(textOf(params[PROPERTY_NAME_PARAM] ?? '')), type: 'float' },
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button, type ButtonOptions, HStack, Label, Popup, Spacer, Theme, type Widget } from '../ui';
|
||||
|
||||
/** Matches the inspector's label column. */
|
||||
const LABEL_WIDTH = 76;
|
||||
|
||||
/**
|
||||
* A small modal form – the texture's Slice dialog, the timeline's row and frames dialogs: a bold
|
||||
* title, rows led by a label in the inspector's label column, Cancel and a confirming button last.
|
||||
*/
|
||||
export abstract class FormDialog extends Popup {
|
||||
protected constructor(minWidth: number, title: string) {
|
||||
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||||
this.minWidth = minWidth;
|
||||
this.content.add(new Label(title, { style: 'bodyBold' }));
|
||||
}
|
||||
|
||||
protected fieldLabel(text: string): Label {
|
||||
const label = new Label(text, { style: 'muted' });
|
||||
label.minWidth = LABEL_WIDTH;
|
||||
return label;
|
||||
}
|
||||
|
||||
/** A row of `widgets`, left-aligned at the height of an inspector row. */
|
||||
protected formRow(...widgets: Widget[]): HStack {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
for (const widget of widgets) row.add(widget);
|
||||
row.add(new Spacer(), 1);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Cancel and the confirming button, right-aligned; the dialog closes before `onConfirm` runs. */
|
||||
protected addButtons(text: string, variant: ButtonOptions['variant'], onConfirm: () => void): Button {
|
||||
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
buttons.add(new Spacer(), 1);
|
||||
const cancel = new Button('Cancel');
|
||||
cancel.onClick = this.closeThen();
|
||||
buttons.add(cancel);
|
||||
const confirm = new Button(text, { variant });
|
||||
confirm.onClick = this.closeThen(onConfirm);
|
||||
buttons.add(confirm);
|
||||
this.content.add(buttons);
|
||||
return confirm;
|
||||
}
|
||||
}
|
||||
|
||||
/** One open dialog of a kind at a time: opening the next closes the last. `current` is for the check scripts. */
|
||||
export function dialogSlot<T extends Popup>(): { current(): T | null; open(dialog: T): T } {
|
||||
let current: T | null = null;
|
||||
return {
|
||||
current: () => current,
|
||||
open(dialog) {
|
||||
current?.close();
|
||||
current = dialog;
|
||||
dialog.onClose = () => {
|
||||
if (current === dialog) current = null;
|
||||
};
|
||||
dialog.openCentered();
|
||||
return dialog;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,31 +1,23 @@
|
||||
import { shiftedFrame } from '../../core/commands/animationCommands';
|
||||
import type { AnimationAsset } from '../../core/model/Asset';
|
||||
import { Button, HStack, Label, NumberField, Popup, Spacer, Theme } from '../../ui';
|
||||
import { type Button, Label, NumberField } from '../../ui';
|
||||
import { dialogSlot, FormDialog } from '../FormDialog';
|
||||
|
||||
/** Matches the inspector's label column. */
|
||||
const LABEL_WIDTH = 76;
|
||||
const FIELD_WIDTH = 76;
|
||||
|
||||
let current: FramesDialog | null = null;
|
||||
const slot = dialogSlot<FramesDialog>();
|
||||
|
||||
/** The open frames dialog, for the check scripts. */
|
||||
export function currentFramesDialog(): FramesDialog | null {
|
||||
return current;
|
||||
}
|
||||
export const currentFramesDialog = slot.current;
|
||||
|
||||
/** Opens the dialog centred, closing whichever one was open before it. */
|
||||
export function openFramesDialog(dialog: FramesDialog): FramesDialog {
|
||||
current?.close();
|
||||
current = dialog;
|
||||
dialog.openCentered();
|
||||
return dialog;
|
||||
}
|
||||
export const openFramesDialog = slot.open;
|
||||
|
||||
/**
|
||||
* Asks how many frames to insert at the playhead, or to remove from it (#102). The summary says what
|
||||
* that does to the keys before anything happens – removing frames takes keys with it.
|
||||
*/
|
||||
export class FramesDialog extends Popup {
|
||||
export class FramesDialog extends FormDialog {
|
||||
private count = 1;
|
||||
private readonly summary: Label;
|
||||
private readonly field: NumberField;
|
||||
@@ -38,19 +30,7 @@ export class FramesDialog extends Popup {
|
||||
private readonly mode: 'insert' | 'remove',
|
||||
onConfirm: (count: number) => void,
|
||||
) {
|
||||
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||||
this.minWidth = 320;
|
||||
this.content.add(
|
||||
new Label(mode === 'insert' ? `Insert frames at frame ${at}` : `Remove frames from frame ${at}`, {
|
||||
style: 'bodyBold',
|
||||
}),
|
||||
);
|
||||
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
const label = new Label('Frames', { style: 'muted' });
|
||||
label.minWidth = LABEL_WIDTH;
|
||||
row.add(label);
|
||||
super(320, mode === 'insert' ? `Insert frames at frame ${at}` : `Remove frames from frame ${at}`);
|
||||
this.field = new NumberField(this.count, {
|
||||
min: 1,
|
||||
step: 1,
|
||||
@@ -59,32 +39,19 @@ export class FramesDialog extends Popup {
|
||||
onChange: (value) => this.setCount(value),
|
||||
});
|
||||
this.field.minWidth = FIELD_WIDTH;
|
||||
row.add(this.field);
|
||||
row.add(new Spacer(), 1);
|
||||
this.content.add(row);
|
||||
this.content.add(this.formRow(this.fieldLabel('Frames'), this.field));
|
||||
|
||||
this.summary = new Label('', { style: 'muted' });
|
||||
this.content.add(this.summary);
|
||||
|
||||
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
buttons.add(new Spacer(), 1);
|
||||
const cancel = new Button('Cancel');
|
||||
cancel.onClick = this.closeThen();
|
||||
buttons.add(cancel);
|
||||
this.confirm = new Button(mode === 'insert' ? 'Insert' : 'Remove', {
|
||||
variant: mode === 'insert' ? 'primary' : 'danger',
|
||||
});
|
||||
this.confirm.onClick = this.closeThen(() => onConfirm(mode === 'insert' ? this.count : -this.count));
|
||||
buttons.add(this.confirm);
|
||||
this.content.add(buttons);
|
||||
this.confirm = this.addButtons(
|
||||
mode === 'insert' ? 'Insert' : 'Remove',
|
||||
mode === 'insert' ? 'primary' : 'danger',
|
||||
() => onConfirm(mode === 'insert' ? this.count : -this.count),
|
||||
);
|
||||
this.refreshSummary();
|
||||
}
|
||||
|
||||
override close(): void {
|
||||
if (current === this) current = null;
|
||||
super.close();
|
||||
}
|
||||
|
||||
setCount(value: number): void {
|
||||
this.count = Math.max(1, Math.round(value));
|
||||
this.field.setNumber(this.count);
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import type { FederatedPointerEvent, FederatedWheelEvent } from 'pixi.js';
|
||||
import type { FederatedPointerEvent } from 'pixi.js';
|
||||
import { previewHolder } from '../../core/model/animationPreview';
|
||||
import type { AnimationAsset } from '../../core/model/Asset';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { footprintExtent } from '../../core/properties/bounds';
|
||||
import { boundsOf } from '../framing';
|
||||
import type { EditorState } from '../state';
|
||||
import { clamp, Theme, zoomFactor } from '../../ui';
|
||||
import { Theme } from '../../ui';
|
||||
import { collectHolderItems } from '../../viewport/items';
|
||||
import { freeView, screenToWorld, type View } from '../../viewport/view';
|
||||
import { type Frame, WorldViewport } from '../viewport/WorldViewport';
|
||||
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 8;
|
||||
/** Breathing room left around the content by `fit()`, in screen pixels. */
|
||||
const FIT_MARGIN = 40;
|
||||
import { FreeViewport } from '../viewport/FreeViewport';
|
||||
import type { Frame } from '../viewport/WorldViewport';
|
||||
|
||||
/**
|
||||
* The picture above the timeline (#99): the animation's root with every row's value at the
|
||||
@@ -21,10 +14,7 @@ const FIT_MARGIN = 40;
|
||||
* the wheel zooms, nothing is picked. What it draws comes from `previewHolder`, so the document
|
||||
* stays untouched and undo never hears of it.
|
||||
*/
|
||||
export class PreviewCanvas extends WorldViewport {
|
||||
/** World point at the viewport centre, and zoom. */
|
||||
private view = { x: 0, y: 0, zoom: 1 };
|
||||
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
|
||||
export class PreviewCanvas extends FreeViewport {
|
||||
private assetId = '';
|
||||
/** Frames the content on the first draw with a size – a fresh subject gets a whole view. */
|
||||
private fitPending = false;
|
||||
@@ -35,11 +25,12 @@ export class PreviewCanvas extends WorldViewport {
|
||||
private readonly playhead: () => { frame: number; reverse: boolean },
|
||||
) {
|
||||
super(state);
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
this.on('pointerup', () => (this.panning = null));
|
||||
this.on('pointerupoutside', () => (this.panning = null));
|
||||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
if (e.button === 0 || e.button === 1) this.startPan(e);
|
||||
});
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.panTo(e));
|
||||
this.on('pointerup', () => this.endPan());
|
||||
this.on('pointerupoutside', () => this.endPan());
|
||||
state.on('load', () => this.show(''));
|
||||
}
|
||||
|
||||
@@ -50,27 +41,9 @@ export class PreviewCanvas extends WorldViewport {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Frames the content; the origin is always included, since the root is placed there. */
|
||||
fit(): void {
|
||||
const { items } = this.collectFrame();
|
||||
const boxes = items.map((item) => {
|
||||
const e = footprintExtent(item.type, item.props, item.m);
|
||||
return { id: item.id, x: e.left, y: e.top, w: e.right - e.left, h: e.bottom - e.top };
|
||||
});
|
||||
const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!;
|
||||
const width = Math.max(maxX - minX, 1);
|
||||
const height = Math.max(maxY - minY, 1);
|
||||
this.view = {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
zoom: clamp(
|
||||
Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height),
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM,
|
||||
),
|
||||
};
|
||||
override fit(): void {
|
||||
this.fitPending = false;
|
||||
this.redraw();
|
||||
super.fit();
|
||||
}
|
||||
|
||||
/** The values as drawn, for a check: an object's position and property bag at the playhead. */
|
||||
@@ -89,12 +62,8 @@ export class PreviewCanvas extends WorldViewport {
|
||||
super.redraw();
|
||||
}
|
||||
|
||||
protected override currentView(): View {
|
||||
return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h);
|
||||
}
|
||||
|
||||
protected override collectFrame(): Frame {
|
||||
const asset = this.assetId ? findAssetOf(this.state.project, this.assetId, 'animation') : undefined;
|
||||
const asset = this.asset();
|
||||
const head = this.playhead();
|
||||
const holder = asset && previewHolder(this.state.project, asset, head.frame, head.reverse);
|
||||
return collectHolderItems(this.state.project, holder, Theme.colors.viewport);
|
||||
@@ -110,32 +79,6 @@ export class PreviewCanvas extends WorldViewport {
|
||||
|
||||
/** A scene root paints its own background; anything placed on the preview's holder has none. */
|
||||
protected override paintsBackground(): boolean {
|
||||
const asset = this.asset();
|
||||
return findAssetOf(this.state.project, asset?.root ?? '', 'scene') !== undefined;
|
||||
}
|
||||
|
||||
private onPointerDown(e: FederatedPointerEvent): void {
|
||||
if (e.button !== 0 && e.button !== 1) return;
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
if (!this.panning) return;
|
||||
const { zoom } = this.view;
|
||||
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / zoom;
|
||||
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / zoom;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
private onWheel(e: FederatedWheelEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
const before = screenToWorld(this.currentView(), local.x, local.y);
|
||||
this.view.zoom = clamp(this.view.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
|
||||
const after = screenToWorld(this.currentView(), local.x, local.y);
|
||||
this.view.x += before.x - after.x;
|
||||
this.view.y += before.y - after.y;
|
||||
this.redraw();
|
||||
e.preventDefault();
|
||||
return findAssetOf(this.state.project, this.asset()?.root ?? '', 'scene') !== undefined;
|
||||
}
|
||||
}
|
||||
@@ -6,27 +6,19 @@ import { animationTargets, targetDefs, trackDef } from '../../core/model/animati
|
||||
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Popup, Spacer, Theme } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import { type Button, Dropdown, Label, Menu, type MenuItem } from '../../ui';
|
||||
import { dialogSlot, FormDialog } from '../FormDialog';
|
||||
import { type EditorState, trackSelection } from '../state';
|
||||
|
||||
/** Matches the inspector's label column. */
|
||||
const LABEL_WIDTH = 76;
|
||||
const PICKER_WIDTH = 240;
|
||||
|
||||
let current: RowDialog | null = null;
|
||||
const slot = dialogSlot<RowDialog>();
|
||||
|
||||
/** The open row dialog, for the check scripts. */
|
||||
export function currentRowDialog(): RowDialog | null {
|
||||
return current;
|
||||
}
|
||||
export const currentRowDialog = slot.current;
|
||||
|
||||
/** Opens the dialog centred, closing whichever one was open before it. */
|
||||
export function openRowDialog(dialog: RowDialog): RowDialog {
|
||||
current?.close();
|
||||
current = dialog;
|
||||
dialog.openCentered();
|
||||
return dialog;
|
||||
}
|
||||
export const openRowDialog = slot.open;
|
||||
|
||||
/** One choice of the object picker: the root itself (`''`) or an object inside it. */
|
||||
interface TargetChoice {
|
||||
@@ -39,7 +31,7 @@ interface TargetChoice {
|
||||
* asset itself, or one placed inside it when that is a container or scene – then a property of
|
||||
* it. Retargeting offers only properties of the row's kind, since its keys stay (#97).
|
||||
*/
|
||||
export class RowDialog extends Popup {
|
||||
export class RowDialog extends FormDialog {
|
||||
private target: string;
|
||||
private property: string;
|
||||
private readonly targetButton: Dropdown;
|
||||
@@ -52,46 +44,27 @@ export class RowDialog extends Popup {
|
||||
private readonly asset: AnimationAsset,
|
||||
private readonly track: AnimationTrack | null = null,
|
||||
) {
|
||||
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||||
this.minWidth = 360;
|
||||
super(360, track ? 'Change what the row animates' : `Add a row to "${asset.name}"`);
|
||||
this.target = track?.target ?? '';
|
||||
this.property = track?.property ?? '';
|
||||
const root = findAsset(state.project, asset.root);
|
||||
this.content.add(
|
||||
new Label(track ? 'Change what the row animates' : `Add a row to "${asset.name}"`, {
|
||||
style: 'bodyBold',
|
||||
}),
|
||||
);
|
||||
this.content.add(new Label(`Animates ${root ? `"${root.name}"` : 'nothing yet'}`, { style: 'muted' }));
|
||||
|
||||
this.targetButton = new Dropdown('', { width: PICKER_WIDTH, onClick: () => this.openTargets() });
|
||||
this.content.add(this.pair('Object', this.targetButton));
|
||||
this.content.add(this.formRow(this.fieldLabel('Object'), this.targetButton));
|
||||
this.propertyButton = new Dropdown('', { width: PICKER_WIDTH, onClick: () => this.openProperties() });
|
||||
this.content.add(this.pair('Property', this.propertyButton));
|
||||
this.content.add(this.formRow(this.fieldLabel('Property'), this.propertyButton));
|
||||
|
||||
this.summary = new Label('', { style: 'muted' });
|
||||
this.content.add(this.summary);
|
||||
|
||||
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
buttons.add(new Spacer(), 1);
|
||||
const cancel = new Button('Cancel');
|
||||
cancel.onClick = this.closeThen();
|
||||
buttons.add(cancel);
|
||||
this.confirm = new Button(track ? 'Change' : 'Add row', { variant: 'primary' });
|
||||
this.confirm.onClick = this.closeThen(() => this.apply());
|
||||
buttons.add(this.confirm);
|
||||
this.content.add(buttons);
|
||||
this.confirm = this.addButtons(track ? 'Change' : 'Add row', 'primary', () => this.apply());
|
||||
|
||||
// Without a property yet, the first one the object offers is as good a start as any.
|
||||
if (!this.property) this.property = this.properties()[0]?.key ?? '';
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
override close(): void {
|
||||
super.close();
|
||||
if (current === this) current = null;
|
||||
}
|
||||
|
||||
/** What the dialog offers and holds, for the check scripts. */
|
||||
debugInfo(): { target: string; property: string; targets: string[]; properties: string[]; ok: boolean } {
|
||||
return {
|
||||
@@ -110,17 +83,6 @@ export class RowDialog extends Popup {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private pair(text: string, control: Dropdown): HStack {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
const label = new Label(text, { style: 'muted' });
|
||||
label.minWidth = LABEL_WIDTH;
|
||||
row.add(label);
|
||||
row.add(control);
|
||||
row.add(new Spacer(), 1);
|
||||
return row;
|
||||
}
|
||||
|
||||
private targets(): TargetChoice[] {
|
||||
const root = findAsset(this.state.project, this.asset.root);
|
||||
if (!root) return [];
|
||||
@@ -202,7 +164,6 @@ export class RowDialog extends Popup {
|
||||
return;
|
||||
}
|
||||
const command = new AddAnimationTrackCommand(this.asset.id, this.target, this.property);
|
||||
if (this.state.execute(command))
|
||||
this.state.select({ kind: 'track', assetId: this.asset.id, trackId: command.trackId, keys: [] });
|
||||
if (this.state.execute(command)) this.state.select(trackSelection(this.asset.id, [], command.trackId));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||
import {
|
||||
clipboardKeyCount,
|
||||
copyAnimationKeys,
|
||||
type KeyRef,
|
||||
MoveAnimationKeysCommand,
|
||||
PasteAnimationKeysCommand,
|
||||
SetAnimationKeyCommand,
|
||||
@@ -10,8 +10,12 @@ import {
|
||||
} from '../../core/commands/animationCommands';
|
||||
import {
|
||||
animationPlayheadAt,
|
||||
fpsOf,
|
||||
frameCount,
|
||||
hasKey,
|
||||
interpolationOf,
|
||||
type KeyRef,
|
||||
sameKey,
|
||||
trackDef,
|
||||
trackLabel,
|
||||
} from '../../core/model/animation';
|
||||
@@ -28,7 +32,6 @@ import {
|
||||
frameAt,
|
||||
frameCentreX,
|
||||
frameToX,
|
||||
hasKey,
|
||||
HEADER_WIDTH,
|
||||
KEY_SIZE,
|
||||
keyAt,
|
||||
@@ -39,7 +42,6 @@ import {
|
||||
rowTop,
|
||||
RULER_HEIGHT,
|
||||
rulerStep,
|
||||
sameKey,
|
||||
type TimelineView,
|
||||
zoomCells,
|
||||
} from './timelineView';
|
||||
@@ -196,7 +198,7 @@ export class TimelineCanvas extends Widget {
|
||||
|
||||
/** The seconds at which a loop's playhead first reaches `frame` – so play resumes where it was scrubbed to. */
|
||||
private timeOf(asset: AnimationAsset, frame: number): number {
|
||||
const fps = Number(asset.props.fps);
|
||||
const fps = fpsOf(asset);
|
||||
return fps > 0 ? frame / fps : 0;
|
||||
}
|
||||
|
||||
@@ -214,7 +216,7 @@ export class TimelineCanvas extends Widget {
|
||||
const clipboard = copyAnimationKeys(this.state.project, asset, this.state.selectedKeys);
|
||||
if (!clipboard) return false;
|
||||
this.state.keyClipboard = clipboard;
|
||||
const count = clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
|
||||
const count = clipboardKeyCount(clipboard);
|
||||
this.state.setNotice(`Copied ${count} ${count === 1 ? 'key' : 'keys'}`, 'quiet');
|
||||
return true;
|
||||
}
|
||||
@@ -227,8 +229,7 @@ export class TimelineCanvas extends Widget {
|
||||
const asset = this.asset();
|
||||
const clipboard = this.state.keyClipboard;
|
||||
if (!asset || !clipboard) return false;
|
||||
const s = this.state.selection;
|
||||
const into = s?.kind === 'track' && s.assetId === asset.id ? s.trackId : '';
|
||||
const into = this.state.selectedAnimationId === asset.id ? (this.state.selectedTrackId ?? '') : '';
|
||||
const command = new PasteAnimationKeysCommand(asset.id, clipboard, this.playheadFrame, into);
|
||||
if (this.state.execute(command)) {
|
||||
this.state.select(trackSelection(asset.id, command.pasted));
|
||||
@@ -247,7 +248,7 @@ export class TimelineCanvas extends Widget {
|
||||
if (!asset) return false;
|
||||
const at = this.playheadFrame;
|
||||
const picked = this.state.selectedKeys;
|
||||
const primary = this.state.selection?.kind === 'track' ? this.state.selection.trackId : undefined;
|
||||
const primary = this.state.selectedTrackId;
|
||||
if (!this.state.execute(new ShiftAnimationFramesCommand(asset.id, at, count))) return false;
|
||||
if (picked.length > 0) {
|
||||
const kept = picked.flatMap((k) => {
|
||||
@@ -318,9 +319,9 @@ export class TimelineCanvas extends Widget {
|
||||
}
|
||||
|
||||
/** The keys as drawn, with whether each is picked and the easing its shape shows. */
|
||||
keysDrawn(): { trackId: string; frame: number; selected: boolean; interpolation: KeyInterpolation }[] {
|
||||
keysDrawn(): (KeyRef & { selected: boolean; interpolation: KeyInterpolation })[] {
|
||||
const picked = this.state.selectedKeys;
|
||||
const out: { trackId: string; frame: number; selected: boolean; interpolation: KeyInterpolation }[] = [];
|
||||
const out: (KeyRef & { selected: boolean; interpolation: KeyInterpolation })[] = [];
|
||||
for (const track of this.asset()?.tracks ?? [])
|
||||
for (const key of track.keys)
|
||||
out.push({
|
||||
@@ -374,7 +375,7 @@ export class TimelineCanvas extends Widget {
|
||||
if (local.x < HEADER_WIDTH) {
|
||||
const row = rowAt(local.y, asset.tracks.length);
|
||||
const track = row >= 0 ? asset.tracks[row] : undefined;
|
||||
if (track) this.state.select({ kind: 'track', assetId: asset.id, trackId: track.id, keys: [] });
|
||||
if (track) this.state.select(trackSelection(asset.id, [], track.id));
|
||||
return;
|
||||
}
|
||||
const cell = { x: local.x - HEADER_WIDTH, y: local.y };
|
||||
@@ -415,12 +416,7 @@ export class TimelineCanvas extends Widget {
|
||||
return;
|
||||
}
|
||||
if (this.state.execute(new SetAnimationKeyCommand(asset.id, track.id, frame)))
|
||||
this.state.select({
|
||||
kind: 'track',
|
||||
assetId: asset.id,
|
||||
trackId: track.id,
|
||||
keys: [{ trackId: track.id, frame }],
|
||||
});
|
||||
this.state.select(trackSelection(asset.id, [{ trackId: track.id, frame }]));
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
@@ -465,7 +461,7 @@ export class TimelineCanvas extends Widget {
|
||||
if (drag) {
|
||||
if (drag.moved && drag.delta !== 0) {
|
||||
const moved = drag.keys.map((k) => ({ trackId: k.trackId, frame: k.frame + drag.delta }));
|
||||
const primary = this.state.selection?.kind === 'track' ? this.state.selection.trackId : undefined;
|
||||
const primary = this.state.selectedTrackId;
|
||||
if (this.state.execute(new MoveAnimationKeysCommand(asset.id, drag.keys, drag.delta, drag.copy)))
|
||||
this.state.select(trackSelection(asset.id, moved, primary));
|
||||
}
|
||||
@@ -510,7 +506,7 @@ export class TimelineCanvas extends Widget {
|
||||
this.overlay.clear();
|
||||
const asset = this.asset();
|
||||
const tracks = asset?.tracks ?? [];
|
||||
const selectedRow = this.state.selection?.kind === 'track' ? this.state.selection.trackId : '';
|
||||
const selectedRow = this.state.selectedTrackId ?? '';
|
||||
|
||||
// Header column and ruler chrome.
|
||||
this.bg.rect(0, 0, HEADER_WIDTH, this.h).fill(c.panel);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KeyRef } from '../../core/commands/animationCommands';
|
||||
import type { KeyRef } from '../../core/model/animation';
|
||||
import type { AnimationTrack } from '../../core/model/Asset';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { clamp } from '../../ui';
|
||||
@@ -80,11 +80,6 @@ export function clampPan(view: TimelineView): TimelineView {
|
||||
return view.panX > 0 ? { ...view, panX: 0 } : view;
|
||||
}
|
||||
|
||||
/** How many frames the cell area shows at its width. */
|
||||
export function visibleFrames(view: TimelineView, width: number): number {
|
||||
return Math.ceil(width / view.cellWidth);
|
||||
}
|
||||
|
||||
/** Every `n`-th frame gets a number on the ruler; a round step so the numbers read like a scale. */
|
||||
export function rulerStep(view: TimelineView): number {
|
||||
const steps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000];
|
||||
@@ -130,14 +125,6 @@ export function keysWithin(
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sameKey(a: KeyRef, b: KeyRef): boolean {
|
||||
return a.trackId === b.trackId && a.frame === b.frame;
|
||||
}
|
||||
|
||||
export function hasKey(keys: readonly KeyRef[], key: KeyRef): boolean {
|
||||
return keys.some((k) => sameKey(k, key));
|
||||
}
|
||||
|
||||
/**
|
||||
* The frames a drag may move the picked keys by: never before frame 0. `delta` is what the pointer
|
||||
* asked for, in whole frames.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
type KeyRef,
|
||||
RemoveAnimationKeysCommand,
|
||||
RemoveAnimationTracksCommand,
|
||||
SetAnimationKeyCommand,
|
||||
@@ -8,8 +7,11 @@ import {
|
||||
} from '../../core/commands/animationCommands';
|
||||
import {
|
||||
blends,
|
||||
findKey,
|
||||
findTrack,
|
||||
interpolationOf,
|
||||
KEY_INTERPOLATIONS,
|
||||
type KeyRef,
|
||||
trackDef,
|
||||
trackLabel,
|
||||
} from '../../core/model/animation';
|
||||
@@ -28,10 +30,9 @@ export function trackSignature(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
trackId: string,
|
||||
keys: readonly { trackId: string; frame: number }[],
|
||||
keys: readonly KeyRef[],
|
||||
): string {
|
||||
const asset = findAssetOf(project, assetId, 'animation');
|
||||
const track = asset?.tracks.find((t) => t.id === trackId);
|
||||
const { asset, track } = rowOf(project, assetId, trackId);
|
||||
if (!asset || !track) return 'missing';
|
||||
const kind = kindOf(project, asset, track) ?? 'missing';
|
||||
const key = keys.length === 1 ? `${keys[0]!.trackId}@${keys[0]!.frame}` : String(keys.length);
|
||||
@@ -46,10 +47,9 @@ export function buildTrackSection(
|
||||
host: InspectorHost,
|
||||
assetId: string,
|
||||
trackId: string,
|
||||
keys: readonly { trackId: string; frame: number }[],
|
||||
keys: readonly KeyRef[],
|
||||
): void {
|
||||
const asset = findAssetOf(host.state.project, assetId, 'animation');
|
||||
const track = asset?.tracks.find((t) => t.id === trackId);
|
||||
const { asset, track } = rowOf(host.state.project, assetId, trackId);
|
||||
if (!asset || !track) return;
|
||||
const kind = kindOf(host.state.project, asset, track);
|
||||
|
||||
@@ -68,9 +68,8 @@ export function buildTrackSection(
|
||||
host.body.add(
|
||||
new Button('Change target…', {
|
||||
onClick: () => {
|
||||
const current = findAssetOf(host.state.project, assetId, 'animation');
|
||||
const row = current?.tracks.find((t) => t.id === trackId);
|
||||
if (current && row) openRowDialog(new RowDialog(host.state, current, row));
|
||||
const now = rowOf(host.state.project, assetId, trackId);
|
||||
if (now.asset && now.track) openRowDialog(new RowDialog(host.state, now.asset, now.track));
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -114,10 +113,10 @@ export function buildTrackSection(
|
||||
}
|
||||
|
||||
/** The value of the one picked key, editable in the widget its kind calls for. */
|
||||
function buildKey(host: InspectorHost, asset: AnimationAsset, ref: { trackId: string; frame: number }): void {
|
||||
const track = asset.tracks.find((t) => t.id === ref.trackId);
|
||||
const key = track?.keys.find((k) => k.frame === ref.frame);
|
||||
const def = track && defOf(host.state.project, asset, track);
|
||||
function buildKey(host: InspectorHost, asset: AnimationAsset, ref: KeyRef): void {
|
||||
const track = findTrack(asset, ref.trackId);
|
||||
const key = findKey(asset, ref);
|
||||
const def = track && trackDef(host.state.project, asset, track);
|
||||
if (!track || !key || !def) return;
|
||||
addGap(host);
|
||||
addTitle(host, `KEY AT FRAME ${ref.frame}`);
|
||||
@@ -167,10 +166,10 @@ function easingKey(
|
||||
keys: readonly KeyRef[],
|
||||
): AnimationKey | undefined {
|
||||
for (const ref of [...keys].reverse()) {
|
||||
const track = asset.tracks.find((t) => t.id === ref.trackId);
|
||||
const track = findTrack(asset, ref.trackId);
|
||||
const kind = track && kindOf(project, asset, track);
|
||||
if (!kind || !blends(kind)) continue;
|
||||
const key = track.keys.find((k) => k.frame === ref.frame);
|
||||
const key = findKey(asset, ref);
|
||||
if (key) return key;
|
||||
}
|
||||
return undefined;
|
||||
@@ -181,15 +180,14 @@ export function refreshTrackSection(
|
||||
host: InspectorHost,
|
||||
assetId: string,
|
||||
trackId: string,
|
||||
keys: readonly { trackId: string; frame: number }[],
|
||||
keys: readonly KeyRef[],
|
||||
): boolean {
|
||||
const asset = findAssetOf(host.state.project, assetId, 'animation');
|
||||
const track = asset?.tracks.find((t) => t.id === trackId);
|
||||
const { asset, track } = rowOf(host.state.project, assetId, trackId);
|
||||
if (!asset || !track) return false;
|
||||
host.fields.get('relative')?.setValue(Boolean(track.relative));
|
||||
const ref = keys.length === 1 ? keys[0] : undefined;
|
||||
if (ref) {
|
||||
const key = asset.tracks.find((t) => t.id === ref.trackId)?.keys.find((k) => k.frame === ref.frame);
|
||||
const key = findKey(asset, ref);
|
||||
if (!key) return false;
|
||||
host.fields.get('value')?.setValue(key.value);
|
||||
}
|
||||
@@ -199,10 +197,15 @@ export function refreshTrackSection(
|
||||
return true;
|
||||
}
|
||||
|
||||
function defOf(project: Project, asset: AnimationAsset, track: AnimationTrack) {
|
||||
return trackDef(project, asset, track);
|
||||
function rowOf(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
trackId: string,
|
||||
): { asset?: AnimationAsset; track?: AnimationTrack } {
|
||||
const asset = findAssetOf(project, assetId, 'animation');
|
||||
return { asset, track: asset && findTrack(asset, trackId) };
|
||||
}
|
||||
|
||||
function kindOf(project: Project, asset: AnimationAsset, track: AnimationTrack): string | undefined {
|
||||
return defOf(project, asset, track)?.kind;
|
||||
return trackDef(project, asset, track)?.kind;
|
||||
}
|
||||
+11
-8
@@ -1,4 +1,5 @@
|
||||
import type { KeyClipboard, KeyRef } from '../core/commands/animationCommands';
|
||||
import type { KeyClipboard } from '../core/commands/animationCommands';
|
||||
import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation';
|
||||
import { type Command, CommandError } from '../core/commands/Command';
|
||||
import { CommandStack } from '../core/commands/CommandStack';
|
||||
import type { Asset, SceneAsset } from '../core/model/Asset';
|
||||
@@ -126,8 +127,7 @@ export function graphSelection(
|
||||
*/
|
||||
export function trackSelection(assetId: string, keys: readonly KeyRef[], trackId?: string): Selection {
|
||||
const unique: KeyRef[] = [];
|
||||
for (const key of keys)
|
||||
if (!unique.some((k) => k.trackId === key.trackId && k.frame === key.frame)) unique.push(key);
|
||||
for (const key of keys) if (!hasKey(unique, key)) unique.push(key);
|
||||
const last = unique[unique.length - 1];
|
||||
const row = trackId ?? last?.trackId;
|
||||
return row ? { kind: 'track', assetId, trackId: row, keys: unique } : null;
|
||||
@@ -495,10 +495,8 @@ export class EditorState {
|
||||
if (!asset) this.select(null);
|
||||
else {
|
||||
// A key that moved or died leaves the selection; a row that died takes its keys with it.
|
||||
const alive = s.keys.filter((key) =>
|
||||
asset.tracks.some((t) => t.id === key.trackId && t.keys.some((k) => k.frame === key.frame)),
|
||||
);
|
||||
const rowAlive = asset.tracks.some((t) => t.id === s.trackId);
|
||||
const alive = s.keys.filter((key) => findKey(asset, key));
|
||||
const rowAlive = findTrack(asset, s.trackId) !== undefined;
|
||||
if (alive.length !== s.keys.length || !rowAlive)
|
||||
this.select(
|
||||
trackSelection(s.assetId, alive, rowAlive ? s.trackId : undefined) ?? {
|
||||
@@ -525,6 +523,11 @@ export class EditorState {
|
||||
return findAssetOf(this.project, s.assetId, 'animation')?.id ?? null;
|
||||
}
|
||||
|
||||
/** The timeline row the selection is about; undefined when it is about something else. */
|
||||
get selectedTrackId(): string | undefined {
|
||||
return this.selection?.kind === 'track' ? this.selection.trackId : undefined;
|
||||
}
|
||||
|
||||
/** The keys picked in the Animation tab, none when the selection is about something else. */
|
||||
get selectedKeys(): KeyRef[] {
|
||||
return this.selection?.kind === 'track' ? this.selection.keys : [];
|
||||
@@ -627,7 +630,7 @@ export function sameSelection(a: Selection, b: Selection): boolean {
|
||||
a.assetId === b.assetId &&
|
||||
a.trackId === b.trackId &&
|
||||
a.keys.length === b.keys.length &&
|
||||
a.keys.every((key, i) => key.trackId === b.keys[i]!.trackId && key.frame === b.keys[i]!.frame)
|
||||
a.keys.every((key, i) => sameKey(key, b.keys[i]!))
|
||||
);
|
||||
if (a.kind === 'annotation' && b.kind === 'annotation')
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import type { TextureAsset } from '../../core/model/Asset';
|
||||
import { DEFAULT_SLICE, type SliceOptions, sliceFrames } from '../../core/model/texture';
|
||||
import { Button, Checkbox, HStack, Label, NumberField, Popup, Spacer, Theme, type Widget } from '../../ui';
|
||||
import { type Button, Checkbox, Label, NumberField, Spacer, Theme, type Widget } from '../../ui';
|
||||
import { FormDialog } from '../FormDialog';
|
||||
|
||||
/** Matches the inspector's label column. */
|
||||
const LABEL_WIDTH = 76;
|
||||
const FIELD_WIDTH = 76;
|
||||
|
||||
/**
|
||||
* Cuts a sheet into a grid, asked for as columns and rows rather than a cell size: the counts are
|
||||
* what the user knows, and a cell size derived from the picture cannot disagree with them.
|
||||
*/
|
||||
export class SliceDialog extends Popup {
|
||||
export class SliceDialog extends FormDialog {
|
||||
private readonly options: SliceOptions = { ...DEFAULT_SLICE };
|
||||
private replace = true;
|
||||
private readonly summary: Label;
|
||||
@@ -20,37 +19,25 @@ export class SliceDialog extends Popup {
|
||||
private readonly asset: TextureAsset,
|
||||
onSlice: (options: SliceOptions, replace: boolean) => void,
|
||||
) {
|
||||
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||||
this.minWidth = 340;
|
||||
this.content.add(new Label(`Slice "${asset.name}" into a grid`, { style: 'bodyBold' }));
|
||||
super(340, `Slice "${asset.name}" into a grid`);
|
||||
this.content.add(new Label(`Picture is ${asset.width} × ${asset.height} px`, { style: 'muted' }));
|
||||
|
||||
this.content.add(this.pair('Columns', 'columns', 1, 'Rows', 'rows', 1));
|
||||
this.content.add(this.pair('Offset X', 'offsetX', 0, 'Offset Y', 'offsetY', 0));
|
||||
this.content.add(this.pair('Spacing X', 'spacingX', 0, 'Spacing Y', 'spacingY', 0));
|
||||
|
||||
const replaceRow = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
replaceRow.minHeight = Theme.rowHeight;
|
||||
const box = new Checkbox(this.replace);
|
||||
box.onChange = (value) => {
|
||||
this.replace = Boolean(value);
|
||||
};
|
||||
replaceRow.add(box);
|
||||
replaceRow.add(new Label('Replace the frames this texture already has', { style: 'muted' }));
|
||||
this.content.add(replaceRow);
|
||||
this.content.add(
|
||||
this.formRow(box, new Label('Replace the frames this texture already has', { style: 'muted' })),
|
||||
);
|
||||
|
||||
this.summary = new Label('', { style: 'muted' });
|
||||
this.content.add(this.summary);
|
||||
|
||||
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
buttons.add(new Spacer(), 1);
|
||||
const cancel = new Button('Cancel');
|
||||
cancel.onClick = this.closeThen();
|
||||
buttons.add(cancel);
|
||||
this.confirm = new Button('Slice', { variant: 'primary' });
|
||||
this.confirm.onClick = this.closeThen(() => onSlice({ ...this.options }, this.replace));
|
||||
buttons.add(this.confirm);
|
||||
this.content.add(buttons);
|
||||
this.confirm = this.addButtons('Slice', 'primary', () => onSlice({ ...this.options }, this.replace));
|
||||
|
||||
this.refreshSummary();
|
||||
}
|
||||
@@ -63,21 +50,13 @@ export class SliceDialog extends Popup {
|
||||
keyB: keyof SliceOptions,
|
||||
minB: number,
|
||||
): Widget {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
row.add(this.labelFor(labelA));
|
||||
row.add(this.fieldFor(keyA, minA));
|
||||
row.add(new Spacer(Theme.padding));
|
||||
row.add(this.labelFor(labelB));
|
||||
row.add(this.fieldFor(keyB, minB));
|
||||
row.add(new Spacer(), 1);
|
||||
return row;
|
||||
}
|
||||
|
||||
private labelFor(text: string): Label {
|
||||
const label = new Label(text, { style: 'muted' });
|
||||
label.minWidth = LABEL_WIDTH;
|
||||
return label;
|
||||
return this.formRow(
|
||||
this.fieldLabel(labelA),
|
||||
this.fieldFor(keyA, minA),
|
||||
new Spacer(Theme.padding),
|
||||
this.fieldLabel(labelB),
|
||||
this.fieldFor(keyB, minB),
|
||||
);
|
||||
}
|
||||
|
||||
private fieldFor(key: keyof SliceOptions, min: number): NumberField {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { FederatedPointerEvent, FederatedWheelEvent } from 'pixi.js';
|
||||
import { footprintExtent } from '../../core/properties/bounds';
|
||||
import { boundsOf } from '../framing';
|
||||
import type { EditorState } from '../state';
|
||||
import { clamp, zoomFactor } from '../../ui';
|
||||
import { freeView, screenToWorld, type View } from '../../viewport/view';
|
||||
import { WorldViewport } from './WorldViewport';
|
||||
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 8;
|
||||
/** Breathing room left around the content by `fit()`, in screen pixels. */
|
||||
const FIT_MARGIN = 40;
|
||||
|
||||
/**
|
||||
* A world view with a camera of its own – the Scene and Container tabs and the Animation tab's
|
||||
* preview: the wheel zooms about the pointer, a pan drags the world along, `fit()` frames the content.
|
||||
*/
|
||||
export abstract class FreeViewport extends WorldViewport {
|
||||
/** World point at the viewport centre, and zoom. */
|
||||
protected view = { x: 0, y: 0, zoom: 1 };
|
||||
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
|
||||
|
||||
protected constructor(state: EditorState) {
|
||||
super(state);
|
||||
this.on('wheel', (e: FederatedWheelEvent) => this.zoomAt(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* Frames everything drawn. The origin is always included: it is where a container is grabbed when
|
||||
* placed and where the preview places its root, so content far off to one side would hide it.
|
||||
*/
|
||||
fit(): void {
|
||||
const { items } = this.collectFrame();
|
||||
const boxes = items.map((item) => {
|
||||
const e = footprintExtent(item.type, item.props, item.m);
|
||||
return { id: item.id, x: e.left, y: e.top, w: e.right - e.left, h: e.bottom - e.top };
|
||||
});
|
||||
const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!;
|
||||
const width = Math.max(maxX - minX, 1);
|
||||
const height = Math.max(maxY - minY, 1);
|
||||
this.view = {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
zoom: clamp(
|
||||
Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height),
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM,
|
||||
),
|
||||
};
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Centres the view on a world point, keeping the zoom (test hook). */
|
||||
lookAt(x: number, y: number): void {
|
||||
this.view.x = x;
|
||||
this.view.y = y;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
protected resetView(): void {
|
||||
this.view = { x: 0, y: 0, zoom: 1 };
|
||||
}
|
||||
|
||||
protected override currentView(): View {
|
||||
return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h);
|
||||
}
|
||||
|
||||
protected startPan(e: FederatedPointerEvent): void {
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
/** Follows the pointer while a pan is on; false when there is none, so the move is someone else's. */
|
||||
protected panTo(e: FederatedPointerEvent): boolean {
|
||||
if (!this.panning) return false;
|
||||
const { zoom } = this.view;
|
||||
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / zoom;
|
||||
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / zoom;
|
||||
this.redraw();
|
||||
return true;
|
||||
}
|
||||
|
||||
protected endPan(): void {
|
||||
this.panning = null;
|
||||
}
|
||||
|
||||
/** Zooms about the pointer, so the world point under it stays under it. */
|
||||
private zoomAt(e: FederatedWheelEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
const before = screenToWorld(this.currentView(), local.x, local.y);
|
||||
this.view.zoom = clamp(this.view.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
|
||||
const after = screenToWorld(this.currentView(), local.x, local.y);
|
||||
this.view.x += before.x - after.x;
|
||||
this.view.y += before.y - after.y;
|
||||
this.redraw();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* copy, so live positions are visible and stay editable. A container's template is the same
|
||||
* work on the same shape of data, so the Container tab subclasses this and swaps `holder()`.
|
||||
*/
|
||||
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||
import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { hasSceneIcon } from '../../core/model/AssetTypes';
|
||||
@@ -13,25 +13,15 @@ import { applyVector, type Extent, invert, linearOf, type Mat } from '../../core
|
||||
import { placementRejection } from '../../core/model/placement';
|
||||
import type { Position, Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
||||
import { footprintCorners, footprintExtent } from '../../core/properties/bounds';
|
||||
import { footprintCorners } from '../../core/properties/bounds';
|
||||
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
|
||||
import { boundsOf } from '../framing';
|
||||
import { type EditorState, objectSelection } from '../state';
|
||||
import {
|
||||
activeDrag,
|
||||
clamp,
|
||||
drawBand,
|
||||
drawGrid,
|
||||
endDrag,
|
||||
onDragChange,
|
||||
Theme,
|
||||
textStyle,
|
||||
zoomFactor,
|
||||
} from '../../ui';
|
||||
import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui';
|
||||
import { collectHolderItems, type DrawItem } from '../../viewport/items';
|
||||
import { dashedLine, drawSourceIcon } from '../../viewport/symbols';
|
||||
import { freeView, screenToWorld, type View, worldToScreen } from '../../viewport/view';
|
||||
import { type Frame, WorldViewport } from './WorldViewport';
|
||||
import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
|
||||
import { FreeViewport } from './FreeViewport';
|
||||
import type { Frame } from './WorldViewport';
|
||||
|
||||
/** Pixels of travel before a press turns into a drag. */
|
||||
const MARQUEE_THRESHOLD = 3;
|
||||
@@ -39,10 +29,6 @@ const DRAG_THRESHOLD = 2;
|
||||
const GRID_STEP = 100;
|
||||
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
||||
const MIN_GRID_STEP = 12;
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 8;
|
||||
/** Breathing room left around the content by `fit()`, in screen pixels. */
|
||||
const FIT_MARGIN = 40;
|
||||
|
||||
interface ObjectDrag {
|
||||
holderId: string;
|
||||
@@ -81,15 +67,12 @@ const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
|
||||
particles: Theme.colors.particles,
|
||||
};
|
||||
|
||||
export class SceneViewport extends WorldViewport {
|
||||
export class SceneViewport extends FreeViewport {
|
||||
private readonly grid = new Graphics();
|
||||
private readonly links = new Graphics();
|
||||
private readonly gizmos = new Graphics();
|
||||
private readonly labels = new Map<string, Text>();
|
||||
private readonly icons = new Map<string, Graphics>();
|
||||
/** World point at the viewport centre, and zoom. */
|
||||
private view = { x: 0, y: 0, zoom: 1 };
|
||||
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
|
||||
private dragging: ObjectDrag | null = null;
|
||||
private marquee: Marquee | null = null;
|
||||
private dropPreview: DropPreview | null = null;
|
||||
@@ -109,10 +92,9 @@ export class SceneViewport extends WorldViewport {
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
this.on('pointerup', () => this.onPointerUp());
|
||||
this.on('pointerupoutside', () => this.onPointerUp());
|
||||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||||
|
||||
state.on('load', () => {
|
||||
this.view = { x: 0, y: 0, zoom: 1 };
|
||||
this.resetView();
|
||||
this.redraw();
|
||||
});
|
||||
// A drag can end anywhere – over another panel, off the canvas – and this view never hears
|
||||
@@ -139,42 +121,6 @@ export class SceneViewport extends WorldViewport {
|
||||
return p ? { assetId: p.assetId, x: p.at.x, y: p.at.y, rejection: p.rejection } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frames everything the holder contains. The origin is always included: it is where a container
|
||||
* is grabbed when placed, so a template drawn far off to one side would otherwise hide it.
|
||||
*/
|
||||
fit(): void {
|
||||
const { items } = this.collectFrame();
|
||||
const boxes = items.map((item) => {
|
||||
const e = footprintExtent(item.type, item.props, item.m);
|
||||
return { id: item.id, x: e.left, y: e.top, w: e.right - e.left, h: e.bottom - e.top };
|
||||
});
|
||||
const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!;
|
||||
const width = Math.max(maxX - minX, 1);
|
||||
const height = Math.max(maxY - minY, 1);
|
||||
this.view = {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
zoom: clamp(
|
||||
Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height),
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM,
|
||||
),
|
||||
};
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Centres the view on a world point, keeping the zoom (test hook). */
|
||||
lookAt(x: number, y: number): void {
|
||||
this.view.x = x;
|
||||
this.view.y = y;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
protected override currentView(): View {
|
||||
return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h);
|
||||
}
|
||||
|
||||
protected override collectFrame(): Frame {
|
||||
return collectHolderItems(this.state.project, this.holder(), Theme.colors.viewport);
|
||||
}
|
||||
@@ -194,8 +140,7 @@ export class SceneViewport extends WorldViewport {
|
||||
|
||||
private onPointerDown(e: FederatedPointerEvent): void {
|
||||
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
|
||||
e.stopPropagation();
|
||||
this.startPan(e);
|
||||
return;
|
||||
}
|
||||
if (e.button === 0 && e.target === this) {
|
||||
@@ -216,11 +161,8 @@ export class SceneViewport extends WorldViewport {
|
||||
this.trackIncomingAsset(incoming.kind === 'asset' ? incoming.id : '', e);
|
||||
return;
|
||||
}
|
||||
if (this.panning) {
|
||||
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / this.view.zoom;
|
||||
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / this.view.zoom;
|
||||
this.redraw();
|
||||
} else if (this.dragging) {
|
||||
if (this.panTo(e)) return;
|
||||
if (this.dragging) {
|
||||
const local = this.toLocal(e.global);
|
||||
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
|
||||
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
|
||||
@@ -296,7 +238,7 @@ export class SceneViewport extends WorldViewport {
|
||||
this.dropAsset(preview);
|
||||
return;
|
||||
}
|
||||
this.panning = null;
|
||||
this.endPan();
|
||||
const marquee = this.marquee;
|
||||
this.marquee = null;
|
||||
if (marquee) {
|
||||
@@ -321,17 +263,6 @@ export class SceneViewport extends WorldViewport {
|
||||
if (moves.length > 0) this.state.execute(oneStep('Move objects', moves));
|
||||
}
|
||||
|
||||
private onWheel(e: FederatedWheelEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
const before = screenToWorld(this.currentView(), local.x, local.y);
|
||||
this.view.zoom = clamp(this.view.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
|
||||
const after = screenToWorld(this.currentView(), local.x, local.y);
|
||||
this.view.x += before.x - after.x;
|
||||
this.view.y += before.y - after.y;
|
||||
this.redraw();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
|
||||
if (e.button !== 0) return;
|
||||
const holder = this.resolved();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isHandleEnabled } from '../../core/graph';
|
||||
import {
|
||||
type AnimationPlayer,
|
||||
animatableDefsOf,
|
||||
animatedKind,
|
||||
animatedKindOf,
|
||||
animationPlayheadAt,
|
||||
blendShare,
|
||||
blends,
|
||||
@@ -133,7 +133,7 @@ function drop(entity: Entity, player: AnimationPlayer): void {
|
||||
* property, is skipped.
|
||||
*/
|
||||
function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
|
||||
const root = world.project.assets.find((a) => a.id === asset.root);
|
||||
const root = findAsset(world.project, asset.root);
|
||||
if (!root) return [];
|
||||
const bindings: Binding[] = [];
|
||||
for (const track of asset.tracks) {
|
||||
@@ -178,7 +178,7 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] {
|
||||
*/
|
||||
function kindOn(world: World, entity: Entity, key: string): string | undefined {
|
||||
const asset = findAsset(world.project, entity.assetId);
|
||||
return asset && animatableDefsOf(world.project, asset, entity.props).find((d) => d.key === key)?.kind;
|
||||
return asset && animatedKindOf(world.project, asset, entity.props, key);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user