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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user