diff --git a/src/core/commands/compositionSprites.ts b/src/core/commands/compositionSprites.ts index fd7dc714..23e583c6 100644 --- a/src/core/commands/compositionSprites.ts +++ b/src/core/commands/compositionSprites.ts @@ -9,9 +9,10 @@ import { newId } from '../ids'; import type { Asset, CompositionItem, SpriteAsset } from '../model/Asset'; import { isCompositionSprite } from '../model/composition'; import { generatesPictures } from '../model/generated'; -import { pixelArtWornId } from '../model/pixelart'; +import { findPixelFrame, pixelArtWornId } from '../model/pixelart'; import type { PropertyBag } from '../model/Properties'; import { findAsset, objectHolders, type Project } from '../model/Project'; +import { findFrame } from '../model/texture'; import { findVideo } from '../model/video'; import { spriteFromTexture } from '../properties/aspect'; import { type Removed, removeWhere, restoreAt } from '../util/list'; @@ -32,27 +33,38 @@ export function wornPicture(asset: Asset): string | null { : null; } -/** What an item dropped on a track wears: the picture its sprite is made of, and a clip of it (#425). */ +/** What an item dropped on a track wears: the picture its sprite is made of, and a part (#425, #430). */ export interface DroppedPicture { picture: string; - /** The clip of a video the item plays, as an override on its object; null for the whole picture. */ - clip: string | null; + /** + * The clip of a video the item plays or the frame it shows, as an override on its object; null for + * what its sprite wears. + */ + part: string | null; /** The name a new sprite for the picture gets. */ name: string; } /** - * What an asset or a clip of a video (#425) dragged onto a track wears. A clip shares the sprite of - * its video, so one composition keeps one sprite per video. Null for anything that is no picture. + * What an asset, a clip of a video (#425) or a frame of a picture (#430) dragged onto a track wears. A + * part shares the sprite of its asset, so one composition keeps one sprite per video or picture. Null + * for anything that is no picture. */ export function droppedPicture(project: Project, id: string): DroppedPicture | null { const asset = findAsset(project, id); if (asset) { const picture = wornPicture(asset); - return picture === null ? null : { picture, clip: null, name: asset.name }; + return picture === null ? null : { picture, part: null, name: asset.name }; } const video = findVideo(project, id); - return video?.clip ? { picture: video.asset.id, clip: video.clip.id, name: video.asset.name } : null; + if (video?.clip) return { picture: video.asset.id, part: video.clip.id, name: video.asset.name }; + const frame = findFrame(project, id); + if (frame) return { picture: frame.asset.id, part: id, name: frame.asset.name }; + const pixel = findPixelFrame(project, id); + if (!pixel) return null; + // Its first frame is what the sprite of a sequence wears already. + const picture = pixelArtWornId(pixel.asset); + return { picture, part: id === picture ? null : id, name: pixel.asset.name }; } /** The sprite the composition already keeps for this picture: one serves every item wearing it. */ @@ -74,8 +86,8 @@ function newCompositionSprite( } /** - * A texture, a drawn picture, a video or a clip of one laid on a track: an item wearing the sprite the - * composition keeps for that picture, made first when it has none yet, in one undo step. A clip is + * A texture, a drawn picture, a video or a part of one laid on a track: an item wearing the sprite the + * composition keeps for that picture, made first when it has none yet, in one undo step. A part is * the texture of the item's object. */ export function addPictureItem( @@ -90,7 +102,7 @@ export function addPictureItem( const steps = [ ...(kept ? [] : [new AddSpriteCommand(sprite)]), command, - ...(dropped.clip ? [new SetOverrideCommand(compId, objectId, 'texture', dropped.clip)] : []), + ...(dropped.part ? [new SetOverrideCommand(compId, objectId, 'texture', dropped.part)] : []), ]; return { command: steps.length === 1 ? command : oneStep('Add item', steps), objectId }; } diff --git a/src/core/model/assetParts.ts b/src/core/model/assetParts.ts new file mode 100644 index 00000000..bee6a92a --- /dev/null +++ b/src/core/model/assetParts.ts @@ -0,0 +1,28 @@ +import type { Asset } from './Asset'; +import { frameLabel } from './pixelart'; +import { findAsset, type Project } from './Project'; +import { clipsOf, hasClips } from './sound'; + +/** A named piece of an asset with an id of its own: a clip of a sound or video, a frame of a picture. */ +export interface AssetPart { + id: string; + name: string; +} + +/** + * What the asset panel lists under an asset (#425, #430): the clips of a sound or a video, the frames + * of a texture, and those of pixel art once it has more than one – the frames a texture field offers. + */ +export function partsOf(asset: Asset): AssetPart[] { + if (hasClips(asset)) return clipsOf(asset).map(({ id, name }) => ({ id, name })); + if (asset.type === 'texture') return asset.frames.map(({ id, name }) => ({ id, name })); + if (asset.type === 'pixelart' && asset.frames.length > 1) + return asset.frames.map((frame, index) => ({ id: frame.id, name: frameLabel(index) })); + return []; +} + +/** The asset `id` is a listed part of; undefined for an asset's own id or anything else. */ +export function partOwner(project: Project, id: string): Asset | undefined { + if (id === '' || findAsset(project, id)) return undefined; + return project.assets.find((asset) => partsOf(asset).some((part) => part.id === id)); +} diff --git a/src/core/model/sound.ts b/src/core/model/sound.ts index 1cc2c625..96113917 100644 --- a/src/core/model/sound.ts +++ b/src/core/model/sound.ts @@ -55,14 +55,6 @@ export function assetNamedBy(assets: readonly Asset[], id: string): Asset | unde ); } -/** The sound or video `id` is a clip of; undefined for an asset's own id or anything else. */ -export function clipOwner(project: Project, id: string): ClipOwner | undefined { - if (id === '' || findAsset(project, id)) return undefined; - return project.assets.find( - (a): a is ClipOwner => hasClips(a) && clipsOf<{ id: string }>(a).some((c) => c.id === id), - ); -} - /** * Gives the clips of a copy fresh ids, since a clip's id is named across the document, and notes * each old id with its new one in `ids` so the places that named a clip can follow. diff --git a/src/core/properties/references.ts b/src/core/properties/references.ts index 0f54cbaa..f1354234 100644 --- a/src/core/properties/references.ts +++ b/src/core/properties/references.ts @@ -1,7 +1,8 @@ import { type PropertyBag, type PropertyValue, textOf } from '../model/Properties'; import { findPixelFrame } from '../model/pixelart'; import { findAsset, type Project } from '../model/Project'; -import { clipOwner, findSound } from '../model/sound'; +import { partOwner } from '../model/assetParts'; +import { findSound } from '../model/sound'; import { findFrame } from '../model/texture'; import { findVideo } from '../model/video'; import { isKindWith } from './kinds'; @@ -28,13 +29,14 @@ export function referencedAssetId(project: Project, def: PropertyDef, value: Pro } /** - * What the label of a reference row links to: the asset it names, or the clip of a sound or a video - * itself (#425) – a clip has a row of its own in the asset panel and is picked in its asset's tab. + * What the label of a reference row links to: the asset it names, or the part of it named itself – a + * clip (#425), a frame (#430) – which has a row of its own in the asset panel and is picked in its + * asset's tab. */ export function referencedTarget(project: Project, def: PropertyDef, value: PropertyValue): string | null { const assetId = referencedAssetId(project, def, value); const id = textOf(value); - return assetId && clipOwner(project, id)?.id === assetId ? id : assetId; + return assetId && partOwner(project, id)?.id === assetId ? id : assetId; } export interface Reference { diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index ec976026..3c027744 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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}`); }); diff --git a/src/editor/actions.ts b/src/editor/actions.ts index c004b3b6..bbb00b0c 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -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. */ diff --git a/src/editor/agent/catalog/composition.ts b/src/editor/agent/catalog/composition.ts index 7067469a..d8230b5f 100644 --- a/src/editor/agent/catalog/composition.ts +++ b/src/editor/agent/catalog/composition.ts @@ -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.', diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 502a83bb..88eec999 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -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); } diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 0875f4f2..f4ee22cf 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -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(); /** Categories folded away, for the session; every category starts unfolded, showing its groups. */ private readonly foldedCategories = new Set(); - /** Sounds and videos whose clips are shown under them (#425), for the session; all start folded. */ - private readonly unfoldedClips = new Set(); + /** + * Assets whose clips or frames are shown under them (#425, #430), for the session; all start + * folded. + */ + private readonly unfoldedParts = new Set(); + /** + * 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); diff --git a/src/editor/pixelart/PixelArtCanvas.ts b/src/editor/pixelart/PixelArtCanvas.ts index 08bf75f1..9652df9a 100644 --- a/src/editor/pixelart/PixelArtCanvas.ts +++ b/src/editor/pixelart/PixelArtCanvas.ts @@ -128,6 +128,17 @@ export class PixelArtCanvas extends SheetCanvas { 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; diff --git a/src/editor/state.ts b/src/editor/state.ts index 7dc0ea2e..05c2b06d 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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. diff --git a/tests/core/composition-sprites.test.ts b/tests/core/composition-sprites.test.ts index 31b5a24d..d71653bf 100644 --- a/tests/core/composition-sprites.test.ts +++ b/tests/core/composition-sprites.test.ts @@ -6,12 +6,14 @@ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, + CreateTextureCommand, CreateVideoCommand, DeleteAssetCommand, DuplicateAssetCommand, MakeProjectAssetCommand, } from '../../src/core/commands/assetCommands'; import { AddClipCommand } from '../../src/core/commands/clipCommands'; +import { AddFrameCommand } from '../../src/core/commands/frameCommands'; import type { Command } from '../../src/core/commands/Command'; import { pasteCompositionItems, removeFromComposition } from '../../src/core/commands/compositionCommands'; import { @@ -150,7 +152,7 @@ describe('a clip of a video laid on a track (#425)', () => { const film = withClip(); expect(droppedPicture(film.project, film.clipId)).toEqual({ picture: film.videoId, - clip: film.clipId, + part: film.clipId, name: 'Intro', }); expect(droppedLength(film.project, film.comp(), film.clipId)).toBe(120); @@ -185,6 +187,47 @@ describe('a clip of a video laid on a track (#425)', () => { }); }); +describe('a frame of a picture laid on a track (#430)', () => { + const withFrames = () => { + const film = makeFilm(); + const texture = new CreateTextureCommand('Sheet', { + file: `${'ab'.repeat(32)}.png`, + width: 64, + height: 32, + }); + const art = new CreateAssetCommand('pixelart', 'Art'); + film.run(texture); + film.run(art); + const frame = new AddFrameCommand(texture.asset.id, { x: 16, y: 0, w: 16, h: 16 }, 'Run'); + film.run(frame); + const pixel = findAssetOf(film.project, art.asset.id, 'pixelart')!; + pixel.frames.push({ id: 'second', cels: {} }); + return { ...film, textureId: texture.asset.id, frameId: frame.frameId, art: pixel }; + }; + + it('wears the sprite of its texture with the frame as the texture, 3 s long', () => { + const film = withFrames(); + expect(droppedPicture(film.project, film.frameId)).toEqual({ + picture: film.textureId, + part: film.frameId, + name: 'Sheet', + }); + expect(droppedLength(film.project, film.comp(), film.frameId)).toBe(180); + const whole = film.drop(0, film.filmId, film.textureId); + const cut = film.drop(200, film.filmId, film.frameId); + expect(film.sprites()).toHaveLength(1); + expect(film.wornBy(film.comp(), cut)).toBe(film.wornBy(film.comp(), whole)); + expect(film.comp().objects.find((o) => o.id === cut)!.overrides).toEqual({ texture: film.frameId }); + }); + + it('of pixel art wears the sprite of its first frame, and the first frame needs no override', () => { + const film = withFrames(); + const first = film.art.frames[0]!.id; + expect(droppedPicture(film.project, 'second')).toEqual({ picture: first, part: 'second', name: 'Art' }); + expect(droppedPicture(film.project, first)).toEqual({ picture: first, part: null, name: 'Art' }); + }); +}); + describe('a copied item', () => { it('pasted after a cut wears its sprite again', () => { const film = makeFilm(); diff --git a/tests/core/references.test.ts b/tests/core/references.test.ts index 5eafe8c4..295281e7 100644 --- a/tests/core/references.test.ts +++ b/tests/core/references.test.ts @@ -110,15 +110,24 @@ describe('referencedAssetId', () => { }); }); -describe('referencedTarget (#425)', () => { +describe('referencedTarget (#425, #430)', () => { it('is the clip itself for a clip of a video or a sound, the asset for anything else', () => { const f = withClips(); expect(referencedTarget(f.project, TEXTURE, f.videoClip)).toBe(f.videoClip); expect(referencedTarget(f.project, SOUND, f.soundClip)).toBe(f.soundClip); expect(referencedTarget(f.project, TEXTURE, f.videoId)).toBe(f.videoId); - expect(referencedTarget(f.project, TEXTURE, f.frameId)).toBe(f.textureId); expect(referencedTarget(f.project, SHADER, f.videoClip)).toBeNull(); }); + + it('is the frame itself for a frame of a texture, and for one of pixel art with more than one', () => { + const f = setup(); + expect(referencedTarget(f.project, TEXTURE, f.frameId)).toBe(f.frameId); + const only = f.art.frames[0]!.id; + expect(referencedTarget(f.project, TEXTURE, only)).toBe(f.art.id); + f.art.frames.push({ id: 'second', cels: {} }); + expect(referencedTarget(f.project, TEXTURE, 'second')).toBe('second'); + expect(referencedTarget(f.project, TEXTURE, only)).toBe(only); + }); }); describe('referencesOf', () => { diff --git a/tests/editor/asset-parts.test.ts b/tests/editor/asset-parts.test.ts new file mode 100644 index 00000000..4a1dad93 --- /dev/null +++ b/tests/editor/asset-parts.test.ts @@ -0,0 +1,108 @@ +/** + * The parts of an asset the asset panel lists under it (#425, #430) – clips, frames of a texture and + * of pixel art – and what a double-click on one does after the click before it has picked it. + */ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + CreateSoundCommand, + CreateTextureCommand, +} from '../../src/core/commands/assetCommands'; +import { AddClipCommand } from '../../src/core/commands/clipCommands'; +import { AddFrameCommand } from '../../src/core/commands/frameCommands'; +import type { PixelArtAsset } from '../../src/core/model/Asset'; +import { partOwner, partsOf } from '../../src/core/model/assetParts'; +import { createEmptyProject, findAsset } from '../../src/core/model/Project'; +import { activateAssetOrPart, glancePart } from '../../src/editor/actions'; +import { EditorState } from '../../src/editor/state'; + +function setup() { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const sound = new CreateSoundCommand('Kick', `${'ef'.repeat(32)}.ogg`); + const texture = new CreateTextureCommand('Sheet', { + file: `${'ab'.repeat(32)}.png`, + width: 64, + height: 32, + }); + const art = new CreateAssetCommand('pixelart', 'Art'); + for (const c of [sound, texture, art]) expect(state.execute(c)).toBe(true); + const clip = new AddClipCommand(sound.asset.id, { start: 0.1, end: 0.2 }); + const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 16, h: 16 }, 'Idle'); + for (const c of [clip, frame]) expect(state.execute(c)).toBe(true); + const events: string[] = []; + for (const event of ['selection', 'open', 'pixelFrame'] as const) state.on(event, () => events.push(event)); + const pixelArt = () => findAsset(state.project, art.asset.id) as PixelArtAsset; + return { + state, + events, + soundId: sound.asset.id, + textureId: texture.asset.id, + artId: art.asset.id, + clipId: clip.clipId, + frameId: frame.frameId, + pixelArt, + }; +} + +describe('the parts of an asset', () => { + it('are the clips of a sound, the frames of a texture, and those of pixel art from the second on', () => { + const f = setup(); + const art = f.pixelArt(); + expect(partsOf(findAsset(f.state.project, f.soundId)!)).toEqual([{ id: f.clipId, name: 'Clip 1' }]); + expect(partsOf(findAsset(f.state.project, f.textureId)!)).toEqual([{ id: f.frameId, name: 'Idle' }]); + expect(partsOf(art)).toEqual([]); + expect(partOwner(f.state.project, art.frames[0]!.id)).toBeUndefined(); + art.frames.push({ id: 'second', cels: {} }); + expect(partsOf(art)).toEqual([ + { id: art.frames[0]!.id, name: 'Frame 1' }, + { id: 'second', name: 'Frame 2' }, + ]); + }); + + it('name their asset, and an asset or a stranger names none', () => { + const f = setup(); + expect(partOwner(f.state.project, f.clipId)?.id).toBe(f.soundId); + expect(partOwner(f.state.project, f.frameId)?.id).toBe(f.textureId); + expect(partOwner(f.state.project, f.textureId)).toBeUndefined(); + expect(partOwner(f.state.project, '')).toBeUndefined(); + expect(partOwner(f.state.project, 'gone')).toBeUndefined(); + }); +}); + +describe('a double-click on a part (#430)', () => { + it('opens the tab of a clip the click before picked, which selecting it again would not', () => { + const f = setup(); + const owner = findAsset(f.state.project, f.soundId)!; + glancePart(f.state, owner, f.clipId); + expect(f.state.selection).toEqual({ kind: 'clip', assetId: f.soundId, clipId: f.clipId }); + f.events.length = 0; + activateAssetOrPart(f.state, f.clipId); + expect(f.events).toEqual(['open']); + }); + + it('picks a frame of a texture as the frame, and opens its tab', () => { + const f = setup(); + glancePart(f.state, findAsset(f.state.project, f.textureId)!, f.frameId); + expect(f.state.selection).toEqual({ kind: 'frame', assetId: f.textureId, frameId: f.frameId }); + f.events.length = 0; + activateAssetOrPart(f.state, f.frameId); + expect(f.events).toEqual(['open']); + }); + + it('opens pixel art at a frame of it: the asset is selected, the frame shown', () => { + const f = setup(); + f.pixelArt().frames.push({ id: 'second', cels: {} }); + activateAssetOrPart(f.state, 'second'); + expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.artId }); + expect(f.state.shownPixelFrame).toEqual({ assetId: f.artId, frameId: 'second' }); + expect(f.events).toEqual(['pixelFrame', 'selection', 'open']); + }); + + it('opens an asset as before', () => { + const f = setup(); + activateAssetOrPart(f.state, f.textureId); + expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.textureId }); + expect(f.events).toEqual(['selection', 'open']); + }); +}); diff --git a/tools/e2e/69-composition-tab.mjs b/tools/e2e/69-composition-tab.mjs index 0d1067cf..fbd05c26 100644 --- a/tools/e2e/69-composition-tab.mjs +++ b/tools/e2e/69-composition-tab.mjs @@ -830,6 +830,69 @@ export async function compositionTab(t) { JSON.stringify({ refolded, textureLink, clipPing, clipOpened }), ); + // 69t (#430). A double-click on a clip's row opens its video, though its first press has picked the + // clip already. The frames of a texture, and of pixel art with more than one, have rows too: a + // double-click opens the texture with the frame picked, or the pixel art painting on that frame, and + // one click there switches the frame. A frame dragged onto a track is an item wearing the texture's + // sprite with the frame as its texture. + await mutate(` + const p = __gm.state.project; + p.assets.push({ id: 'ct69-sheet', type: 'texture', name: 'Sheet 69', props: {}, file: '', width: 64, height: 32, + frames: [{ id: 'ct69-f1', name: 'Run', x: 16, y: 0, w: 16, h: 16 }] }); + p.assets.push({ id: 'ct69-art', type: 'pixelart', name: 'Art 69', props: {}, width: 8, height: 8, + layers: [{ id: 'ct69-layer', name: 'Layer 1', opacity: 1 }], + frames: [{ id: 'ct69-p1', cels: {} }, { id: 'ct69-p2', cels: {} }] });`); + /** Double-clicks a part's row from the scene tab, its asset unfolded first as a user would. */ + const openRow = async (id) => { + await showTab('scene'); + await sleep(200); + const row = await json(`__gm.assetRow(${JSON.stringify(id)})`); + if (row) await t.doubleClick(row.x, row.y); + await sleep(300); + return { selection: await json('__gm.state.selection'), active: (await json('__gm.documents()')).active }; + }; + const clipRow = await openRow('ct69-vclip'); + const frameRow = await openRow('ct69-f1'); + const pixelRow = await openRow('ct69-p2'); + const paintedOn = await json('__gm.pixelArt.frameId()'); + const firstRow = await json(`__gm.assetRow('ct69-p1')`); + if (firstRow) await t.click(firstRow.x, firstRow.y); + await sleep(300); + const switched = { + frame: await json('__gm.pixelArt.frameId()'), + active: (await json('__gm.documents()')).active, + }; + await evaluate(`__gm.state.openAsset('${CUT}')`); + await sleep(300); + const frameDrop = await dragClip('ct69-f1', await json(`__gm.composition.laneAt('ct69-t1', 100)`)); + const framed = await json(`(() => { + const c = __gm.state.project.assets.find((a) => a.id === '${CUT}'); + const item = c.items.find((i) => i.start === 100); + const object = c.objects.find((o) => o.id === item?.id); + const sprite = object && __gm.state.project.assets.find((a) => a.id === object.assetId); + return { overrides: object?.overrides ?? null, texture: sprite?.props.texture ?? null }; + })()`); + check( + 'a double-click on a clip or frame row opens its asset with it picked, and a frame lands on a track', + clipRow.selection?.kind === 'clip' && + clipRow.selection.clipId === 'ct69-vclip' && + clipRow.active === `asset:${VIDEO}` && + frameRow.selection?.kind === 'frame' && + frameRow.selection.frameId === 'ct69-f1' && + frameRow.active === 'asset:ct69-sheet' && + pixelRow.selection?.kind === 'asset' && + pixelRow.selection.assetId === 'ct69-art' && + pixelRow.active === 'asset:ct69-art' && + paintedOn === 'ct69-p2' && + switched.frame === 'ct69-p1' && + switched.active === 'asset:ct69-art' && + frameDrop?.what === 'item' && + frameDrop.rejection === '' && + framed.overrides?.texture === 'ct69-f1' && + framed.texture === 'ct69-sheet', + JSON.stringify({ clipRow, frameRow, pixelRow, paintedOn, switched, frameDrop, framed }), + ); + await showTab('scene'); await mutate(` const p = __gm.state.project;