Open clips and frames from the asset panel by double click (#430)
A double click on a clip row did not open its tab. The first click of the double click had already selected the clip for the inspector, so the second click selected the same thing again and the editor saw no change. Frames of textures and pixel art had no rows at all. - A double click now asks for the tab with openSelection, even when the selection is already right. - Textures with frames and pixel art with more than one frame get a chevron and one row per frame, like sounds and videos. Which rows an asset has is decided in one place, src/core/model/assetParts.ts, used by the panel and the inspector link. - A texture frame shows in the inspector on click and opens the texture with the frame selected on double click. - A pixel art frame selects the pixel art on click, so an open tab paints on that frame. A double click opens the tab on the frame. - A frame can be dragged onto a composition track like a video clip. The item carries the sprite of the whole image, the frame becomes the texture of its object, and it is 3 s long. The agent can do the same with add_composition_item. - A texture label that names a frame now points at the frame row. Found on the way: the e2e check from #425 only double clicked the texture label, where the first click is a ping, so the bug never showed there. Tests: - Unit: asset-parts (new, 6), composition-sprites and references extended, related tests 303/303, agent tests 21/21 - 5 mutations, all red in the matching test - e2e 69 (atx): ALL PASSED, 26 checks, 69t new - Gate (atx): CHECK EXIT 0, 325 files, 5017 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
37779a2070
commit
2e4c0a09ea
15 files changed
+405
-77
No files matched your search
@@ -712,7 +712,7 @@ export class Editor implements PlayHost {
|
||||
});
|
||||
state.on('open', () => {
|
||||
const s = state.selection;
|
||||
if (s?.kind !== 'asset') return;
|
||||
if (s?.kind !== 'asset' && s?.kind !== 'clip' && s?.kind !== 'frame') return;
|
||||
if (findAsset(state.project, s.assetId)?.type === 'scene') this.showTab('scene');
|
||||
else this.openDocument(`asset:${s.assetId}`);
|
||||
});
|
||||
|
||||
+28
-7
@@ -21,6 +21,8 @@ import { RemoveFrameCommand } from '../core/commands/frameCommands';
|
||||
import { DisconnectCommand, FoldEdgeCommand, RemoveNodeCommand } from '../core/commands/graphCommands';
|
||||
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
|
||||
import { RemoveVectorShapeCommand, RemoveVectorShapesCommand } from '../core/commands/vectorArtCommands';
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
import { partOwner } from '../core/model/assetParts';
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import { curveUsages } from '../core/model/curve';
|
||||
import { structTypeUsages } from '../core/model/structTypes';
|
||||
@@ -32,7 +34,6 @@ import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../c
|
||||
import { ownerOf } from '../core/music/embedded';
|
||||
import type { SceneObject } from '../core/model/SceneObject';
|
||||
import { pixelArtRefIds } from '../core/model/pixelart';
|
||||
import { clipOwner } from '../core/model/sound';
|
||||
import { textureRefIds } from '../core/model/texture';
|
||||
import { videoRefIds } from '../core/model/video';
|
||||
import {
|
||||
@@ -44,7 +45,7 @@ import {
|
||||
} from '../core/model/usages';
|
||||
import { Dialog } from '../ui';
|
||||
import { removeSongItems, removeTrack } from './song/songActions';
|
||||
import { type EditorState, graphSelection, objectSelection } from './state';
|
||||
import { type EditorState, graphSelection, objectSelection, type Selection } from './state';
|
||||
|
||||
/**
|
||||
* What a double-click on an asset does, wherever it happens (asset panel, "Open …" entries, a link
|
||||
@@ -55,11 +56,31 @@ export function activateAsset(state: EditorState, assetId: string): void {
|
||||
state.openAsset(assetId);
|
||||
}
|
||||
|
||||
/** The same for an id that may name a clip of a sound or a video (#425): its tab opens with the clip picked. */
|
||||
export function activateAssetOrClip(state: EditorState, id: string): void {
|
||||
const owner = clipOwner(state.project, id);
|
||||
if (owner) state.select({ kind: 'clip', assetId: owner.id, clipId: id });
|
||||
else activateAsset(state, id);
|
||||
/**
|
||||
* The same for an id that may name a part of an asset – a clip (#425), a frame (#430): its tab opens
|
||||
* with the part picked, also when one click on its row has picked it already.
|
||||
*/
|
||||
export function activateAssetOrPart(state: EditorState, id: string): void {
|
||||
const owner = partOwner(state.project, id);
|
||||
if (!owner) {
|
||||
activateAsset(state, id);
|
||||
return;
|
||||
}
|
||||
if (owner.type === 'pixelart') state.showPixelFrame(owner.id, id);
|
||||
state.openSelection(partSelection(owner, id));
|
||||
}
|
||||
|
||||
/** One click on a part's row: a look at the inspector, its tab left as it is (#425). */
|
||||
export function glancePart(state: EditorState, owner: Asset, id: string): void {
|
||||
if (owner.type === 'pixelart') state.showPixelFrame(owner.id, id);
|
||||
state.glance(partSelection(owner, id));
|
||||
}
|
||||
|
||||
/** A frame of pixel art is painted on rather than picked (#430), so it selects its asset. */
|
||||
function partSelection(owner: Asset, id: string): Selection {
|
||||
if (owner.type === 'texture') return { kind: 'frame', assetId: owner.id, frameId: id };
|
||||
if (owner.type === 'pixelart') return { kind: 'asset', assetId: owner.id };
|
||||
return { kind: 'clip', assetId: owner.id, clipId: id };
|
||||
}
|
||||
|
||||
/** Groups the objects into a new container asset placed in their stead; selects the container. */
|
||||
|
||||
@@ -129,8 +129,9 @@ export const COMPOSITION_COMMANDS: readonly CatalogCommand[] = [
|
||||
name: 'add_composition_item',
|
||||
description:
|
||||
'Lays an asset on a track of a composition, from a frame. A placeable asset (a sprite, a container, a text, ...) ' +
|
||||
'becomes an object of the composition, living as long as the item; a texture, pixel art, vector art, a video or a ' +
|
||||
'clip of one gets a sprite of the composition to wear it; a sound, a sound clip, a pattern or a song becomes an ' +
|
||||
'becomes an object of the composition, living as long as the item; a texture, pixel art, vector art, a video, a ' +
|
||||
'clip of a video or a frame of a picture gets a sprite of the composition to wear it, the clip or frame as its ' +
|
||||
'texture; a sound, a sound clip, a pattern or a song becomes an ' +
|
||||
'audio item. Without a length it is as long as its video or what it plays, else 3 s, and keeps following it. ' +
|
||||
"An item's object is moved and set with the commands of area objects, in = the composition. Items of a track may " +
|
||||
'overlap. Animations go on a track with add_composition_clip.',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import type { FederatedPointerEvent } from 'pixi.js';
|
||||
import { Label, Panel, pressCount, ScrollContainer, ui, type Widget } from '../../ui';
|
||||
import { activateAssetOrClip } from '../actions';
|
||||
import { activateAssetOrPart } from '../actions';
|
||||
import { type EditorState, sameSelection, type Selection } from '../state';
|
||||
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
|
||||
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
|
||||
@@ -66,12 +66,12 @@ export class Inspector extends Panel {
|
||||
}
|
||||
|
||||
/**
|
||||
* A link in a reference row (#221): a click pings the asset or clip (#425) in the asset panel, a
|
||||
* double-click opens it. Pinging leaves the selection alone, so this panel keeps showing the object
|
||||
* and both presses land on the same link.
|
||||
* A link in a reference row (#221): a click pings the asset or a part of it (#425, #430) in the
|
||||
* asset panel, a double-click opens it. Pinging leaves the selection alone, so this panel keeps
|
||||
* showing the object and both presses land on the same link.
|
||||
*/
|
||||
private followReference(id: string, e: FederatedPointerEvent): void {
|
||||
if (pressCount(e, `inspector-link:${id}`) === 2) activateAssetOrClip(this.state, id);
|
||||
if (pressCount(e, `inspector-link:${id}`) === 2) activateAssetOrPart(this.state, id);
|
||||
else this.state.pingAsset(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import { generatesPictures } from '../../core/model/generated';
|
||||
import { PALETTE_KEY, pixelArtWornId } from '../../core/model/pixelart';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { clipOwner, clipRefName, clipsOf, hasClips } from '../../core/model/sound';
|
||||
import { partOwner, partsOf } from '../../core/model/assetParts';
|
||||
import { clipRefName } from '../../core/model/sound';
|
||||
import { listedAssets, ownerOf } from '../../core/music/embedded';
|
||||
import { spriteFromTexture } from '../../core/properties/aspect';
|
||||
import { assetDeviates } from '../../core/properties/deviation';
|
||||
@@ -43,7 +44,7 @@ import { type AssetCategory, type AssetCategoryGroup, ASSET_GROUPS, groupAssets
|
||||
import { ASSET_ICON_SVG } from './assetIcons';
|
||||
import { assetMenuCategories } from './assetMenu';
|
||||
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
|
||||
import { activateAssetOrClip, confirmDeleteAsset } from '../actions';
|
||||
import { activateAssetOrPart, confirmDeleteAsset, glancePart } from '../actions';
|
||||
import { openReferenceItems } from '../objectMenu';
|
||||
import { generalSettings, onGeneralSettings } from '../generalSettings';
|
||||
import { scrollTween } from '../viewTween';
|
||||
@@ -77,8 +78,16 @@ export class AssetPanel extends Panel {
|
||||
private readonly unfolded = new Set<AssetType>();
|
||||
/** Categories folded away, for the session; every category starts unfolded, showing its groups. */
|
||||
private readonly foldedCategories = new Set<AssetCategory>();
|
||||
/** Sounds and videos whose clips are shown under them (#425), for the session; all start folded. */
|
||||
private readonly unfoldedClips = new Set<string>();
|
||||
/**
|
||||
* Assets whose clips or frames are shown under them (#425, #430), for the session; all start
|
||||
* folded.
|
||||
*/
|
||||
private readonly unfoldedParts = new Set<string>();
|
||||
/**
|
||||
* The frame of pixel art last picked here (#430). It is not selected – its asset is – so the list
|
||||
* remembers which row to light up while that asset stays selected.
|
||||
*/
|
||||
private pickedPixelFrame: string | null = null;
|
||||
/** Ids of the rows as shown, headings included. */
|
||||
private rows: string[] = [];
|
||||
/**
|
||||
@@ -114,7 +123,7 @@ export class AssetPanel extends Panel {
|
||||
new Menu(this.addMenuEntries(headingId), { title: ADD_MENU_TITLE }).open(x, y);
|
||||
this.list.onDragOut = (id) => this.startPlacementDrag(id);
|
||||
this.list.onToggle = (id, expand) => {
|
||||
if (findAsset(state.project, id)) this.setClipsFolded(id, !expand);
|
||||
if (findAsset(state.project, id)) this.setPartsFolded(id, !expand);
|
||||
else if (id.startsWith(CATEGORY))
|
||||
this.setCategoryFolded(id.slice(CATEGORY.length) as AssetCategory, !expand);
|
||||
else this.setFolded(id.slice(HEADING.length) as AssetType, !expand);
|
||||
@@ -132,7 +141,8 @@ export class AssetPanel extends Panel {
|
||||
state.on('load', () => {
|
||||
this.unfolded.clear();
|
||||
this.foldedCategories.clear();
|
||||
this.unfoldedClips.clear();
|
||||
this.unfoldedParts.clear();
|
||||
this.pickedPixelFrame = null;
|
||||
this.scrolledTo = null;
|
||||
this.refresh();
|
||||
});
|
||||
@@ -153,24 +163,27 @@ export class AssetPanel extends Panel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfolds and scrolls to the pinged asset or clip (#425) and lets its row light up; the selection
|
||||
* stays. What a song or a composition keeps to itself has no row, so its owner lights up (#355,
|
||||
* #419).
|
||||
* Unfolds and scrolls to the pinged asset or part (#425, #430) and lets its row light up; the
|
||||
* selection stays. What a song or a composition keeps to itself has no row, so its owner lights up
|
||||
* (#355, #419).
|
||||
*/
|
||||
private ping(): void {
|
||||
const pinged = this.state.pingedAssetId ?? '';
|
||||
const asset = findAsset(this.state.project, pinged) ?? clipOwner(this.state.project, pinged);
|
||||
const asset = findAsset(this.state.project, pinged) ?? partOwner(this.state.project, pinged);
|
||||
if (!asset) return;
|
||||
const id = ownerOf(this.state.project, asset)?.id ?? pinged;
|
||||
this.reveal(id);
|
||||
this.list.ping(id);
|
||||
}
|
||||
|
||||
/** A row picked in the list: an asset, or a clip (#425), looked at in the inspector without its tab. */
|
||||
/** A row picked in the list: an asset or a part (#425, #430), looked at in the inspector, not its tab. */
|
||||
private pick(id: string | null): void {
|
||||
const owner = id ? clipOwner(this.state.project, id) : undefined;
|
||||
if (owner && id) this.state.glance({ kind: 'clip', assetId: owner.id, clipId: id });
|
||||
const owner = id ? partOwner(this.state.project, id) : undefined;
|
||||
this.pickedPixelFrame = owner?.type === 'pixelart' ? id : null;
|
||||
if (owner && id) glancePart(this.state, owner, id);
|
||||
else this.state.select(id ? { kind: 'asset', assetId: id } : null);
|
||||
// Its asset may have been selected already, so nothing else says the row changed.
|
||||
this.syncSelection();
|
||||
}
|
||||
|
||||
/** The row lighting up after a ping and how brightly, or null (test hook). */
|
||||
@@ -239,17 +252,17 @@ export class AssetPanel extends Panel {
|
||||
});
|
||||
if (!expanded) continue;
|
||||
for (const asset of assets) {
|
||||
const clips = hasClips(asset) ? clipsOf<{ id: string; name: string }>(asset) : [];
|
||||
const clipsShown = this.unfoldedClips.has(asset.id);
|
||||
const parts = partsOf(asset);
|
||||
const partsShown = this.unfoldedParts.has(asset.id);
|
||||
items.push({
|
||||
id: asset.id,
|
||||
label: asset.name,
|
||||
marks: asset.type === 'scene' ? this.sceneMarks(asset.id) : undefined,
|
||||
depth: 2,
|
||||
overridden: assetDeviates(project, asset),
|
||||
expanded: clips.length > 0 ? clipsShown : undefined,
|
||||
expanded: parts.length > 0 ? partsShown : undefined,
|
||||
});
|
||||
if (clipsShown) for (const clip of clips) items.push({ id: clip.id, label: clip.name, depth: 3 });
|
||||
if (partsShown) for (const part of parts) items.push({ id: part.id, label: part.name, depth: 3 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,10 +287,10 @@ export class AssetPanel extends Panel {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** Unlike a group, folding keeps a picked clip: it may be the one being worked on in its tab. */
|
||||
private setClipsFolded(assetId: string, fold: boolean): void {
|
||||
if (fold) this.unfoldedClips.delete(assetId);
|
||||
else this.unfoldedClips.add(assetId);
|
||||
/** Unlike a group, folding keeps a picked part: it may be the one being worked on in its tab. */
|
||||
private setPartsFolded(assetId: string, fold: boolean): void {
|
||||
if (fold) this.unfoldedParts.delete(assetId);
|
||||
else this.unfoldedParts.add(assetId);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
@@ -299,7 +312,10 @@ export class AssetPanel extends Panel {
|
||||
|
||||
private syncSelection(): void {
|
||||
const s = this.state.selection;
|
||||
const id = s?.kind === 'asset' ? s.assetId : s?.kind === 'clip' ? s.clipId : null;
|
||||
if (s?.kind !== 'asset' || partOwner(this.state.project, this.pickedPixelFrame ?? '')?.id !== s.assetId)
|
||||
this.pickedPixelFrame = null;
|
||||
const part = s?.kind === 'clip' ? s.clipId : s?.kind === 'frame' ? s.frameId : this.pickedPixelFrame;
|
||||
const id = part ?? (s?.kind === 'asset' ? s.assetId : null);
|
||||
// A selection made anywhere else – a graph node, a picker, a fresh asset – has to be reachable
|
||||
// in the list: its category and group unfold, and with many assets the row is often below the fold.
|
||||
const type = id ? findAsset(this.state.project, id)?.type : undefined;
|
||||
@@ -307,8 +323,8 @@ export class AssetPanel extends Panel {
|
||||
this.setFolded(type, false);
|
||||
return;
|
||||
}
|
||||
// Only a clip picked here unfolds its sound or video: one picked in its tab leaves the list alone.
|
||||
if (s?.kind === 'clip' && !this.rows.includes(s.clipId)) {
|
||||
// Only a part picked here unfolds its asset: one picked in its tab leaves the list alone.
|
||||
if (part && !this.rows.includes(part)) {
|
||||
this.list.select(null, true);
|
||||
this.scrolledTo = null;
|
||||
return;
|
||||
@@ -453,11 +469,11 @@ export class AssetPanel extends Panel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Double-click / Enter opens the asset's tab; a scene also becomes the active one, a clip (#425) is
|
||||
* picked in its tab. Placing is a drag.
|
||||
* Double-click / Enter opens the asset's tab; a scene also becomes the active one, a part (#425,
|
||||
* #430) is picked in its tab. Placing is a drag.
|
||||
*/
|
||||
private activate(id: string): void {
|
||||
activateAssetOrClip(this.state, id);
|
||||
activateAssetOrPart(this.state, id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,14 +486,14 @@ export class AssetPanel extends Panel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfolds the asset's group – and for a clip (#425) its sound or video – and scrolls to its row:
|
||||
* Unfolds the asset's group – and for a part (#425, #430) its asset – and scrolls to its row:
|
||||
* before a ping, and as a user would before clicking it (test hooks).
|
||||
*/
|
||||
private reveal(id: string): void {
|
||||
const owner = clipOwner(this.state.project, id);
|
||||
const owner = partOwner(this.state.project, id);
|
||||
const type = findAsset(this.state.project, owner?.id ?? id)?.type;
|
||||
if (type && !this.shows(type)) this.setFolded(type, false);
|
||||
if (owner && !this.unfoldedClips.has(owner.id)) this.setClipsFolded(owner.id, false);
|
||||
if (owner && !this.unfoldedParts.has(owner.id)) this.setPartsFolded(owner.id, false);
|
||||
this.scrollTo(id);
|
||||
}
|
||||
|
||||
@@ -501,13 +517,14 @@ export class AssetPanel extends Panel {
|
||||
* Hands the asset to the view under the pointer: a scene places it, a song lays out a pattern or a
|
||||
* sound (#334), a composition an animation as a clip (#417), a picture or video as an item (#419)
|
||||
* and a sound, pattern or song as an audio item (#418). A scene has nowhere to go. A clip of a sound
|
||||
* or a video goes where its asset would, to a song or a composition (#425).
|
||||
* or a video goes where its asset would, to a song or a composition (#425), a frame of a picture to
|
||||
* a composition (#430).
|
||||
*/
|
||||
private startPlacementDrag(assetId: string): void {
|
||||
const owner = clipOwner(this.state.project, assetId);
|
||||
const owner = partOwner(this.state.project, assetId);
|
||||
if (owner) {
|
||||
const clip = clipsOf<{ id: string; name: string }>(owner).find((c) => c.id === assetId)!;
|
||||
startDrag({ kind: 'asset', id: assetId }, clipRefName(owner.name, clip.name));
|
||||
const part = partsOf(owner).find((p) => p.id === assetId)!;
|
||||
startDrag({ kind: 'asset', id: assetId }, clipRefName(owner.name, part.name));
|
||||
return;
|
||||
}
|
||||
const asset = findAsset(this.state.project, assetId);
|
||||
|
||||
@@ -128,6 +128,17 @@ export class PixelArtCanvas extends SheetCanvas<PixelArtAsset> {
|
||||
this.redraw();
|
||||
});
|
||||
state.on('project', () => this.redraw());
|
||||
state.on('pixelFrame', () => {
|
||||
const shown = state.shownPixelFrame;
|
||||
if (!shown) return;
|
||||
if (shown.assetId === this.assetId) this.selectFrame(shown.frameId);
|
||||
else
|
||||
this.places.set(shown.assetId, {
|
||||
layerId: '',
|
||||
...this.places.get(shown.assetId),
|
||||
frameId: shown.frameId,
|
||||
});
|
||||
});
|
||||
state.on('load', () => {
|
||||
this.places.clear();
|
||||
this.needsFit = true;
|
||||
|
||||
+23
-2
@@ -242,6 +242,8 @@ export type EditorEvent =
|
||||
| 'open'
|
||||
/** An asset should light up where it is listed (`pingedAssetId`), without being selected. */
|
||||
| 'ping'
|
||||
/** A frame of pixel art should be painted on (`shownPixelFrame`). */
|
||||
| 'pixelFrame'
|
||||
| 'mode'
|
||||
| 'save'
|
||||
| 'notice'
|
||||
@@ -298,7 +300,9 @@ export class EditorState {
|
||||
* working rather than something the project remembers.
|
||||
*/
|
||||
tilemapPaint: TilemapPaint = { tool: 'select', tileId: '', layerId: '', focus: 'all' };
|
||||
/** The asset or clip (#425) the last `pingAsset` pointed at. */
|
||||
/** The frame of pixel art the last `showPixelFrame` asked for. */
|
||||
shownPixelFrame: { assetId: string; frameId: string } | null = null;
|
||||
/** The asset or part of one (#425, #430) the last `pingAsset` pointed at. */
|
||||
pingedAssetId: string | null = null;
|
||||
/** Set while `glance` selects: the selection stays where it is shown. */
|
||||
glancing = false;
|
||||
@@ -581,10 +585,27 @@ export class EditorState {
|
||||
* there, and like `openValues` it works without knowing the `Editor`.
|
||||
*/
|
||||
openAsset(assetId: string): void {
|
||||
this.select({ kind: 'asset', assetId });
|
||||
this.openSelection({ kind: 'asset', assetId });
|
||||
}
|
||||
|
||||
/**
|
||||
* The same for an asset or a part of one, a clip or a frame (#430). Asked for even when the selection
|
||||
* is already this one, as after one click on its row: selecting it again would bring up nothing.
|
||||
*/
|
||||
openSelection(selection: Selection): void {
|
||||
this.select(selection);
|
||||
this.emit('open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the Pixel art tab to paint on this frame of the asset (#430), now or once it shows the asset:
|
||||
* a frame is not selected, as it has nothing of its own to show in the inspector.
|
||||
*/
|
||||
showPixelFrame(assetId: string, frameId: string): void {
|
||||
this.shownPixelFrame = { assetId, frameId };
|
||||
this.emit('pixelFrame');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the graph showing `assetId` to bring these nodes into its picture (#429): what the agent
|
||||
* just added far to one side is otherwise selected out of sight.
|
||||
|
||||
Reference in new issue
Block a user