From cc6db5f35026f495cbc479538144a58daff016f2 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 16 Sep 2026 17:38:54 +0200 Subject: [PATCH] Three stores, three importers and three pickers that differ only in their type are one of each (#85) Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts, and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list, check count 958, the two-job CI, the Scenes wiki page. Co-Authored-By: Claude Fable 5.1 --- README.md | 16 +- src/core/commands/graphCommands.ts | 22 +- src/core/commands/require.ts | 8 +- src/core/graph/Graph.ts | 12 + src/core/model/font.ts | 5 - src/core/model/sound.ts | 5 - src/core/particles/compile.ts | 12 - src/core/particles/emission.ts | 4 - src/core/properties/declaredRows.ts | 57 +++++ src/core/properties/particles.ts | 56 ++--- src/core/properties/shaders.ts | 57 ++--- src/core/properties/transform.ts | 4 - src/core/shader/compile.ts | 12 - src/core/store/FileStore.ts | 28 +++ src/core/store/FontStore.ts | 29 +-- src/core/store/HttpFileStore.ts | 31 +++ src/core/store/HttpFontStore.ts | 30 --- src/core/store/HttpSoundStore.ts | 30 --- src/core/store/HttpTextureStore.ts | 30 --- src/core/store/SoundStore.ts | 28 +-- src/core/store/TextureStore.ts | 38 +-- src/editor/App.ts | 11 +- src/editor/FilePicker.ts | 338 +++++++++++++++++++++++++++ src/editor/actions.ts | 10 - src/editor/assetImport.ts | 178 ++++++++++++++ src/editor/font/FontPicker.ts | 314 +++++-------------------- src/editor/fontImport.ts | 95 ++------ src/editor/graph/GraphCanvas.ts | 260 ++++----------------- src/editor/graph/annotationDrag.ts | 161 +++++++++++++ src/editor/sound/SoundPicker.ts | 318 ++++++------------------- src/editor/soundImport.ts | 94 ++------ src/editor/texture/ImagePicker.ts | 331 +++++++------------------- src/editor/textureImport.ts | 153 ++---------- src/viewport/ParticleLibrary.ts | 9 +- src/viewport/ShaderLibrary.ts | 5 +- src/viewport/shaders.ts | 91 ++++---- tests/core/http-store.test.ts | 16 +- tests/core/particles.test.ts | 22 +- tests/core/shaders.test.ts | 11 +- tests/editor/annotation-drag.test.ts | 88 +++++++ tests/editor/font-import.test.ts | 2 +- tests/editor/texture-import.test.ts | 4 +- 42 files changed, 1375 insertions(+), 1650 deletions(-) create mode 100644 src/core/properties/declaredRows.ts create mode 100644 src/core/store/FileStore.ts create mode 100644 src/core/store/HttpFileStore.ts delete mode 100644 src/core/store/HttpFontStore.ts delete mode 100644 src/core/store/HttpSoundStore.ts delete mode 100644 src/core/store/HttpTextureStore.ts create mode 100644 src/editor/FilePicker.ts create mode 100644 src/editor/assetImport.ts create mode 100644 src/editor/graph/annotationDrag.ts create mode 100644 tests/editor/annotation-drag.test.ts diff --git a/README.md b/README.md index 55965ffc..928b1cc9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ way in. ## In one screen - An **asset** is a named, typed thing (scene, camera, sprite, text, behavior, collider, container, - texture, animation, state machine) with properties and no position. + texture, font, sound, animation, state machine, shader, particles, variables) with properties and + no position. - A **scene object** places an asset: position, sparse **overrides** of its properties, an optional parent. Only what differs from the asset is stored; the inspector paints it gold and offers ↺ / ↑. - A **container** is a reusable group edited once and placed many times; its content is resolved @@ -37,14 +38,14 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 862 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 958 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs ``` -Every push runs all of it on the Gitea runner, so locally take the gate plus the step your change -touches and leave the full run to CI. Details, the GPU requirement and the detached runner are in +Every push runs all of it on the Gitea runner – the gate as one job, build and suite as another +beside it – so locally take the gate plus the step your change touches and leave the full run to CI. Details, the GPU requirement and the detached runner are in [Development](https://git.nockal.com/meleki/game-maker/wiki/Development). ## Documentation @@ -52,11 +53,16 @@ touches and leave the full run to CI. Details, the GPU requirement and the detac | Page | What it covers | |---|---| | [Concepts](https://git.nockal.com/meleki/game-maker/wiki/Concepts) | Assets, scene objects, overrides, play mode, the camera, the console | +| [Scenes](https://git.nockal.com/meleki/game-maker/wiki/Scenes) | The edited scene, the start scene, and switching scenes from a graph | | [Scene editing](https://git.nockal.com/meleki/game-maker/wiki/Scene-editing) | Placing, z-order, hierarchy, the editor offset | | [Behaviors](https://git.nockal.com/meleki/game-maker/wiki/Behaviors) | Node graphs: variables, the node set, structs and lists, loops, nesting, editing | | [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Collision events and blocking movement | -| [Textures](https://git.nockal.com/meleki/game-maker/wiki/Textures) | Images, frames, the filter, upload limits | +| [Textures](https://git.nockal.com/meleki/game-maker/wiki/Textures) | Images, frames, nine-slice, the filter, upload limits | +| [Fonts](https://git.nockal.com/meleki/game-maker/wiki/Fonts) | Font assets, the picker, what a text falls back to | +| [Sounds](https://git.nockal.com/meleki/game-maker/wiki/Sounds) | Sound assets and `Play sound` | | [Animations](https://git.nockal.com/meleki/game-maker/wiki/Animations) | Animation assets and the Animation tab | +| [Shaders](https://git.nockal.com/meleki/game-maker/wiki/Shaders) | Shader graphs a sprite wears, their node set, the properties they declare | +| [Particles](https://git.nockal.com/meleki/game-maker/wiki/Particles) | Particle graphs: a particle as a function of its age | | [State machines](https://git.nockal.com/meleki/game-maker/wiki/State-machines) | The asset, named transitions, `State branch`, `Set state` and `State transition` | | [Containers](https://git.nockal.com/meleki/game-maker/wiki/Containers) | Templates, instances, screens | | [Keyboard](https://git.nockal.com/meleki/game-maker/wiki/Keyboard) | Every shortcut | diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index 8fe4aca2..ac2ea0ef 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -15,7 +15,7 @@ import type { Project } from '../model/Project'; import { coerceValue, defaultsOf } from '../properties/PropertySchema'; import { type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; -import { requireGraph, requireNode, requireNodeDef } from './require'; +import { requireEdge, requireGraph, requireNode, requireNodeDef } from './require'; import { SetBagValueCommand } from './SetBagValueCommand'; export class AddNodeCommand implements Command { @@ -221,19 +221,13 @@ export class FoldEdgeCommand implements Command { } execute(project: Project): void { - const edge = this.edge(project); + const edge = requireEdge(project, this.assetId, this.edgeId); this.previous = edge.folded === true; this.apply(edge, this.folded); } undo(project: Project): void { - this.apply(this.edge(project), this.previous); - } - - private edge(project: Project): GraphEdge { - const edge = requireGraph(project, this.assetId).edges.find((e) => e.id === this.edgeId); - if (!edge) throw new CommandError(`Edge ${this.edgeId} not found`); - return edge; + this.apply(requireEdge(project, this.assetId, this.edgeId), this.previous); } private apply(edge: GraphEdge, folded: boolean): void { @@ -254,19 +248,13 @@ export class NameTransitionCommand implements Command { ) {} execute(project: Project): void { - const edge = this.edge(project); + const edge = requireEdge(project, this.assetId, this.edgeId); this.previous = edge.label ?? ''; this.apply(edge, this.name.trim()); } undo(project: Project): void { - this.apply(this.edge(project), this.previous); - } - - private edge(project: Project): GraphEdge { - const edge = requireGraph(project, this.assetId).edges.find((e) => e.id === this.edgeId); - if (!edge) throw new CommandError(`Edge ${this.edgeId} not found`); - return edge; + this.apply(requireEdge(project, this.assetId, this.edgeId), this.previous); } private apply(edge: GraphEdge, name: string): void { diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index b8780696..15a5ce51 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -1,5 +1,5 @@ /** Lookups that throw a `CommandError` when the target does not exist. */ -import { findNode, type Graph, type GraphNode } from '../graph/Graph'; +import { findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph'; import { type NodeDef, nodeDef } from '../graph/NodeRegistry'; import type { Asset, BehaviorAsset, StateMachineAsset, VariablesAsset } from '../model/Asset'; import { findObject, type ObjectTree } from '../model/hierarchy'; @@ -98,6 +98,12 @@ export function requireNode(project: Project, assetId: string, nodeId: string): return node; } +export function requireEdge(project: Project, assetId: string, edgeId: string): GraphEdge { + const edge = requireGraph(project, assetId).edges.find((e) => e.id === edgeId); + if (!edge) throw new CommandError(`Edge ${edgeId} not found`); + return edge; +} + /** A `CommandError`, so an unknown type becomes a notice instead of a crash. */ export function requireNodeDef(type: string): NodeDef { const def = nodeDef(type); diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index 8fb2fd0c..7f3580e2 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -139,6 +139,18 @@ export function edgesOfNode(graph: Graph, nodeId: string): GraphEdge[] { return graph.edges.filter((e) => e.from.node === nodeId || e.to.node === nodeId); } +/** + * What a compiled graph (shader, particles) depends on and nothing else: node ids, types, params + * and declared ports, and which port feeds which. Positions, collapsing, folding and the boxes are + * the editor's; they stay out so a moved node does not compile again (#65). + */ +export function graphFingerprint(graph: Graph): string { + return JSON.stringify({ + nodes: graph.nodes.map((n) => [n.id, n.type, n.params, n.ports ?? null]), + edges: graph.edges.map((e) => [e.from.node, e.from.port, e.to.node, e.to.port]), + }); +} + export function portsCompatible(from: PortType, to: PortType): boolean { if (from === 'exec' || to === 'exec') return from === to; return from === to || from === 'any' || to === 'any'; diff --git a/src/core/model/font.ts b/src/core/model/font.ts index 176cbba0..7e0adb78 100644 --- a/src/core/model/font.ts +++ b/src/core/model/font.ts @@ -1,4 +1,3 @@ -import type { FontAsset } from './Asset'; import { findAsset, type Project } from './Project'; /** Mirrors `FontStore::isValidName` on the server, where the name is derived from the bytes. */ @@ -23,7 +22,3 @@ export function fontFileOf(project: Project, id: string): string { const asset = findAsset(project, id); return asset?.type === 'font' ? asset.file : ''; } - -export function isFontAsset(asset: { type: string }): asset is FontAsset { - return asset.type === 'font'; -} diff --git a/src/core/model/sound.ts b/src/core/model/sound.ts index 6d806dce..e5d90baf 100644 --- a/src/core/model/sound.ts +++ b/src/core/model/sound.ts @@ -1,4 +1,3 @@ -import type { SoundAsset } from './Asset'; import { findAsset, type Project } from './Project'; /** Mirrors `SoundStore::isValidName` on the server, where the name is derived from the bytes. */ @@ -14,7 +13,3 @@ export function soundFileOf(project: Project, id: string): string { const asset = findAsset(project, id); return asset?.type === 'sound' ? asset.file : ''; } - -export function isSoundAsset(asset: { type: string }): asset is SoundAsset { - return asset.type === 'sound'; -} diff --git a/src/core/particles/compile.ts b/src/core/particles/compile.ts index 8bd241c2..2b0ce4cc 100644 --- a/src/core/particles/compile.ts +++ b/src/core/particles/compile.ts @@ -38,18 +38,6 @@ export interface ParticlesBuild { reason: string; } -/** - * What the motion depends on and nothing else: node ids, types, params and declared ports, and - * which port feeds which. Positions and folding are the editor's, so a moved node does not compile - * again – the same rule the shader fingerprint follows (#65). - */ -export function particlesFingerprint(graph: Graph): string { - return JSON.stringify({ - nodes: graph.nodes.map((n) => [n.id, n.type, n.params, n.ports ?? null]), - edges: graph.edges.map((e) => [e.from.node, e.from.port, e.to.node, e.to.port]), - }); -} - /** * A particle graph to one function per output: every node reachable from the Output becomes one * local per output, in dependency order, so a node feeding two others is evaluated once per diff --git a/src/core/particles/emission.ts b/src/core/particles/emission.ts index ed9e826a..f8465d26 100644 --- a/src/core/particles/emission.ts +++ b/src/core/particles/emission.ts @@ -74,7 +74,3 @@ export function liveRange(settings: EmitterSettings, time: number, emitUntil = t if (oldest > newest) return NO_PARTICLES; return { from: Math.max(oldest, newest - Math.floor(max) + 1), to: newest }; } - -export function liveCount(range: LiveRange): number { - return Math.max(0, range.to - range.from + 1); -} diff --git a/src/core/properties/declaredRows.ts b/src/core/properties/declaredRows.ts new file mode 100644 index 00000000..ae46d01a --- /dev/null +++ b/src/core/properties/declaredRows.ts @@ -0,0 +1,57 @@ +import type { Graph, GraphNode } from '../graph/Graph'; +import type { PropertyBag } from '../model/Properties'; +import type { FlatDef } 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'; +export const PROPERTY_DEFAULT_PARAM = 'default'; +export const PROPERTY_MIN_PARAM = 'min'; +export const PROPERTY_MAX_PARAM = 'max'; + +export interface Declaration { + isDeclaration(node: GraphNode): boolean; + nameOf(node: GraphNode): string; + isValidName(name: string): boolean; +} + +/** + * The number rows a graph declares, in node order: one per declaring node with a valid name. A + * second node of the same name declares nothing new – both read the one value. + */ +export function declaredNumberRows(graph: Graph, of: Declaration): FlatDef<'number'>[] { + const rows: FlatDef<'number'>[] = []; + const seen = new Set(); + for (const node of graph.nodes) { + if (!of.isDeclaration(node)) continue; + const name = of.nameOf(node); + if (!of.isValidName(name) || seen.has(name)) continue; + seen.add(name); + rows.push(numberRow(name, node.params)); + } + return rows; +} + +function numberRow(name: string, params: PropertyBag): FlatDef<'number'> { + const min = numberParam(params, PROPERTY_MIN_PARAM, 0); + const max = numberParam(params, PROPERTY_MAX_PARAM, 1); + const row: FlatDef<'number'> = { + key: name, + label: name, + kind: 'number', + default: numberParam(params, PROPERTY_DEFAULT_PARAM, 0), + min, + step: niceStep(max > min ? max - min : 1), + }; + // A max at or below the min would pin the value; treated as "no max" instead. + return max > min ? { ...row, max } : row; +} + +function numberParam(params: PropertyBag, key: string, fallback: number): number { + const n = Number(params[key]); + return Number.isFinite(n) ? n : fallback; +} + +/** A twentieth of the range's power of ten: 0.05 for 0-4, 0.05 for 0-1, 5 for 0-100. */ +function niceStep(range: number): number { + return 10 ** Math.floor(Math.log10(range)) / 20; +} diff --git a/src/core/properties/particles.ts b/src/core/properties/particles.ts index 1041a4df..534cf65a 100644 --- a/src/core/properties/particles.ts +++ b/src/core/properties/particles.ts @@ -1,13 +1,17 @@ import type { Graph, GraphNode } from '../graph/Graph'; 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'; + /** The node of a particle graph that declares a parameter: a row on every source placed from the asset. */ export const PARTICLE_PROPERTY = 'ParticleProperty'; -export const PROPERTY_NAME_PARAM = 'name'; -export const PROPERTY_DEFAULT_PARAM = 'default'; -export const PROPERTY_MIN_PARAM = 'min'; -export const PROPERTY_MAX_PARAM = 'max'; /** The rows a particle source has of its own, before the ones its graph declares. */ export const EMITTING_KEY = 'emitting'; @@ -65,43 +69,11 @@ const RESERVED_KEYS: ReadonlySet = new Set([ MOVE_WITH_SOURCE_KEY, ]); -/** - * The parameters a particle graph declares, in node order: one number row per `Property` node with - * a usable name. A second node of the same name declares nothing new – both read the one value. - */ +/** The parameters a particle graph declares: a row on every source placed from the asset. */ export function particleParamDefs(graph: Graph): FlatDef<'number'>[] { - const rows: FlatDef<'number'>[] = []; - const seen = new Set(); - for (const node of graph.nodes) { - if (!isParticlePropertyNode(node)) continue; - const name = propertyNameOf(node); - if (!isValidParticlePropertyName(name) || seen.has(name)) continue; - seen.add(name); - rows.push(propertyRow(name, node.params)); - } - return rows; -} - -function propertyRow(name: string, params: PropertyBag): FlatDef<'number'> { - const min = numberParam(params, PROPERTY_MIN_PARAM, 0); - const max = numberParam(params, PROPERTY_MAX_PARAM, 1); - const row: FlatDef<'number'> = { - key: name, - label: name, - kind: 'number', - default: numberParam(params, PROPERTY_DEFAULT_PARAM, 0), - min, - step: niceStep(max > min ? max - min : 1), - }; - // A max at or below the min would pin the value; treated as "no max" instead. - return max > min ? { ...row, max } : row; -} - -function numberParam(params: PropertyBag, key: string, fallback: number): number { - const n = Number(params[key]); - return Number.isFinite(n) ? n : fallback; -} - -function niceStep(range: number): number { - return 10 ** Math.floor(Math.log10(range)) / 20; + return declaredNumberRows(graph, { + isDeclaration: isParticlePropertyNode, + nameOf: propertyNameOf, + isValidName: isValidParticlePropertyName, + }); } diff --git a/src/core/properties/shaders.ts b/src/core/properties/shaders.ts index a4583f4d..01bf8c94 100644 --- a/src/core/properties/shaders.ts +++ b/src/core/properties/shaders.ts @@ -1,7 +1,15 @@ import type { Graph, GraphNode } from '../graph/Graph'; 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'; + /** The sprite property naming the shader asset it wears; `''` is no shader and no render pass. */ export const SHADER_KEY = 'shader'; @@ -10,10 +18,6 @@ export const SHADER_KEY = 'shader'; * GLSL uniform, `default`/`min`/`max` are the row the inspector shows. */ export const SHADER_PROPERTY = 'ShaderProperty'; -export const PROPERTY_NAME_PARAM = 'name'; -export const PROPERTY_DEFAULT_PARAM = 'default'; -export const PROPERTY_MIN_PARAM = 'min'; -export const PROPERTY_MAX_PARAM = 'max'; export function shaderOf(bag: PropertyBag): string { return textOf(bag[SHADER_KEY]); @@ -36,46 +40,13 @@ export function isValidPropertyName(name: string): boolean { return isValidFieldName(name) && !isSpriteProp(name); } -/** - * The properties a shader graph declares, in node order: one number row per `Property` node with - * a valid name. A second node with the same name declares nothing new – both read one uniform. - */ +/** The properties a shader graph declares: one row on the sprite and one uniform each. */ export function shaderParamDefs(graph: Graph): FlatDef<'number'>[] { - const rows: FlatDef<'number'>[] = []; - const seen = new Set(); - for (const node of graph.nodes) { - if (!isPropertyNode(node)) continue; - const name = propertyName(node); - if (!isValidPropertyName(name) || seen.has(name)) continue; - seen.add(name); - rows.push(propertyRow(name, node.params)); - } - return rows; -} - -function propertyRow(name: string, params: PropertyBag): FlatDef<'number'> { - const min = numberParam(params, PROPERTY_MIN_PARAM, 0); - const max = numberParam(params, PROPERTY_MAX_PARAM, 1); - const row: FlatDef<'number'> = { - key: name, - label: name, - kind: 'number', - default: numberParam(params, PROPERTY_DEFAULT_PARAM, 0), - min, - step: niceStep(max > min ? max - min : 1), - }; - // A max at or below the min would pin the value; treated as "no max" instead. - return max > min ? { ...row, max } : row; -} - -function numberParam(params: PropertyBag, key: string, fallback: number): number { - const n = Number(params[key]); - return Number.isFinite(n) ? n : fallback; -} - -/** A twentieth of the range's power of ten: 0.05 for 0-4, 0.05 for 0-1, 5 for 0-100. */ -function niceStep(range: number): number { - return 10 ** Math.floor(Math.log10(range)) / 20; + return declaredNumberRows(graph, { + isDeclaration: isPropertyNode, + nameOf: propertyName, + isValidName: isValidPropertyName, + }); } /** What a property placed from the palette is called: `value`, then `value2`, `value3` … */ diff --git a/src/core/properties/transform.ts b/src/core/properties/transform.ts index 5146d959..a2ea01a6 100644 --- a/src/core/properties/transform.ts +++ b/src/core/properties/transform.ts @@ -99,10 +99,6 @@ export function worldPosition(project: Project, tree: ObjectTree, object: SceneO return positionOf(worldTransform(project, tree, object)); } -export function editorPosition(project: Project, tree: ObjectTree, object: SceneObject): Position { - return positionOf(editorTransform(project, tree, object)); -} - /** The world transform of a parent ('' = top level, the identity). */ export function parentTransform(project: Project, tree: ObjectTree, parentId: string): ChainTransform { const parent = parentId ? findObject(tree, parentId) : undefined; diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index b99826b0..83bd25f8 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -54,18 +54,6 @@ export interface ShaderBuild { * becomes one local per output, in dependency order, and every Property a float uniform. The texel * arrives premultiplied, so the prelude unpremultiplies and the epilogue multiplies back. */ -/** - * What the fragment depends on and nothing else: node ids, types, params and declared ports, and - * which port feeds which. Positions, collapsing and folding are the editor's; they stay out so a - * moved node does not compile again (#65). - */ -export function shaderFingerprint(graph: Graph): string { - return JSON.stringify({ - nodes: graph.nodes.map((n) => [n.id, n.type, n.params, n.ports ?? null]), - edges: graph.edges.map((e) => [e.from.node, e.from.port, e.to.node, e.to.port]), - }); -} - export function compileShader(graph: Graph): ShaderBuild { const problems: Record = {}; const problem = (node: GraphNode, text: string) => { diff --git a/src/core/store/FileStore.ts b/src/core/store/FileStore.ts new file mode 100644 index 00000000..a607a5d5 --- /dev/null +++ b/src/core/store/FileStore.ts @@ -0,0 +1,28 @@ +/** + * What the server answers for an upload: the content-addressed name it stored the bytes under + * (`{sha256}.{ext}`, see `isTextureFile`/`isFontFile`/`isSoundFile`) and the caption kept beside it. + */ +export interface UploadedFile { + file: string; + /** The name the file was uploaded under, '' when the server knows none. Never a path. */ + name: string; + bytes: number; +} + +/** A file the server holds, whether any project references it or not. */ +export interface StoredFile extends UploadedFile { + updatedAt: string; +} + +/** + * Raw bytes rather than a multipart form: the server derives name and format from the file itself, + * so there is nothing else to send. `type` only fills the Content-Type header – the server trusts + * the bytes, not the label – and uploading the same bytes twice yields the same file, the hash of + * its content. `name` is the caption kept beside it; uploading the same bytes under a new name + * renames what the pickers show. + */ +export interface FileStore { + upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise; + list(): Promise; + delete(file: string): Promise; +} diff --git a/src/core/store/FontStore.ts b/src/core/store/FontStore.ts index 5975fbd3..c2c4ac5e 100644 --- a/src/core/store/FontStore.ts +++ b/src/core/store/FontStore.ts @@ -1,26 +1,9 @@ -export interface UploadedFont { - /** Content-addressed name in `data/fonts/`: `{sha256}.{ttf|otf|woff|woff2}` (see `isFontFile`). */ - file: string; - /** The name the file was uploaded under, '' when the server knows none. Never a path. */ - name: string; - bytes: number; -} - -/** A typeface the server holds, whether any project references it or not. */ -export interface StoredFont { - file: string; - name: string; - bytes: number; - updatedAt: string; -} +import type { FileStore, StoredFile, UploadedFile } from './FileStore'; /** - * Raw bytes, like an image upload: the server derives name and format from the file itself. Unlike - * an image a font is never re-encoded on the way, so what the server stores is what was picked. + * Stored in `data/fonts/` as `{sha256}.{ttf|otf|woff|woff2}`. Unlike an image a font is never + * re-encoded on the way, so what the server stores is what was picked. */ -export interface FontStore { - /** `name` is kept beside the file as its caption; the stored file is always the hash. */ - upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise; - list(): Promise; - delete(file: string): Promise; -} +export type UploadedFont = UploadedFile; +export type StoredFont = StoredFile; +export type FontStore = FileStore; diff --git a/src/core/store/HttpFileStore.ts b/src/core/store/HttpFileStore.ts new file mode 100644 index 00000000..18eaf59f --- /dev/null +++ b/src/core/store/HttpFileStore.ts @@ -0,0 +1,31 @@ +import type { FileStore, StoredFile, UploadedFile } from './FileStore'; +import { apiRequest } from './http'; + +/** The API's three file collections, one route each; textures, fonts and sounds differ in nothing else. */ +export type FileRoute = 'textures' | 'fonts' | 'sounds'; + +export class HttpFileStore< + U extends UploadedFile = UploadedFile, + S extends StoredFile = StoredFile, +> implements FileStore { + constructor( + private readonly endpoint: string, + private readonly route: FileRoute, + ) {} + + async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise { + return apiRequest(this.endpoint, 'POST', this.route, { + headers: { 'Content-Type': type }, + body: bytes, + query: { name }, + }); + } + + async list(): Promise { + return apiRequest(this.endpoint, 'GET', this.route, {}); + } + + async delete(file: string): Promise { + await apiRequest(this.endpoint, 'DELETE', `${this.route}/${encodeURIComponent(file)}`, {}); + } +} diff --git a/src/core/store/HttpFontStore.ts b/src/core/store/HttpFontStore.ts deleted file mode 100644 index 1d12feb5..00000000 --- a/src/core/store/HttpFontStore.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { FontStore, StoredFont, UploadedFont } from './FontStore'; -import { apiRequest } from './http'; - -export class HttpFontStore implements FontStore { - constructor(private readonly endpoint: string) {} - - async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise { - return this.request('POST', 'fonts', { bytes, type, name }); - } - - async list(): Promise { - return this.request('GET', 'fonts'); - } - - async delete(file: string): Promise { - await this.request('DELETE', `fonts/${encodeURIComponent(file)}`); - } - - private async request( - method: string, - route: string, - payload?: { bytes: Blob | ArrayBuffer; type: string; name: string }, - ): Promise { - return apiRequest(this.endpoint, method, route, { - headers: payload ? { 'Content-Type': payload.type } : {}, - body: payload?.bytes, - query: payload ? { name: payload.name } : undefined, - }); - } -} diff --git a/src/core/store/HttpSoundStore.ts b/src/core/store/HttpSoundStore.ts deleted file mode 100644 index cb622be3..00000000 --- a/src/core/store/HttpSoundStore.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { apiRequest } from './http'; -import type { SoundStore, StoredSound, UploadedSound } from './SoundStore'; - -export class HttpSoundStore implements SoundStore { - constructor(private readonly endpoint: string) {} - - async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise { - return this.request('POST', 'sounds', { bytes, type, name }); - } - - async list(): Promise { - return this.request('GET', 'sounds'); - } - - async delete(file: string): Promise { - await this.request('DELETE', `sounds/${encodeURIComponent(file)}`); - } - - private async request( - method: string, - route: string, - payload?: { bytes: Blob | ArrayBuffer; type: string; name: string }, - ): Promise { - return apiRequest(this.endpoint, method, route, { - headers: payload ? { 'Content-Type': payload.type } : {}, - body: payload?.bytes, - query: payload ? { name: payload.name } : undefined, - }); - } -} diff --git a/src/core/store/HttpTextureStore.ts b/src/core/store/HttpTextureStore.ts deleted file mode 100644 index 15907957..00000000 --- a/src/core/store/HttpTextureStore.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { apiRequest } from './http'; -import type { StoredTexture, TextureStore, UploadedTexture } from './TextureStore'; - -export class HttpTextureStore implements TextureStore { - constructor(private readonly endpoint: string) {} - - async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise { - return this.request('POST', 'textures', { bytes, type, name }); - } - - async list(): Promise { - return this.request('GET', 'textures'); - } - - async delete(file: string): Promise { - await this.request('DELETE', `textures/${encodeURIComponent(file)}`); - } - - private async request( - method: string, - route: string, - payload?: { bytes: Blob | ArrayBuffer; type: string; name: string }, - ): Promise { - return apiRequest(this.endpoint, method, route, { - headers: payload ? { 'Content-Type': payload.type } : {}, - body: payload?.bytes, - query: payload ? { name: payload.name } : undefined, - }); - } -} diff --git a/src/core/store/SoundStore.ts b/src/core/store/SoundStore.ts index 916d0b60..e4ebfc1d 100644 --- a/src/core/store/SoundStore.ts +++ b/src/core/store/SoundStore.ts @@ -1,27 +1,9 @@ -export interface UploadedSound { - /** Content-addressed name in `data/sounds/`: `{sha256}.{mp3|ogg|wav|m4a}` (see `isSoundFile`). */ - file: string; - /** The name the file was uploaded under, '' when the server knows none. Never a path. */ - name: string; - bytes: number; -} - -/** A sound the server holds, whether any project references it or not. */ -export interface StoredSound { - file: string; - name: string; - bytes: number; - updatedAt: string; -} +import type { FileStore, StoredFile, UploadedFile } from './FileStore'; /** - * Raw bytes, like a font upload: the server derives name and format from the file itself. A sound - * is never re-encoded on the way, so what the server stores is what was picked – and how long it + * Stored in `data/sounds/` as `{sha256}.{mp3|ogg|wav|m4a}`, never re-encoded. How long a sound * plays is nowhere in here, because only a decoder knows that. */ -export interface SoundStore { - /** `name` is kept beside the file as its caption; the stored file is always the hash. */ - upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise; - list(): Promise; - delete(file: string): Promise; -} +export type UploadedSound = UploadedFile; +export type StoredSound = StoredFile; +export type SoundStore = FileStore; diff --git a/src/core/store/TextureStore.ts b/src/core/store/TextureStore.ts index da94325e..128bb789 100644 --- a/src/core/store/TextureStore.ts +++ b/src/core/store/TextureStore.ts @@ -1,38 +1,18 @@ -export interface UploadedTexture { - /** Content-addressed name in `data/textures/`: `{sha256}.{png|jpg|webp|gif}` (see `isTextureFile`). */ - file: string; - /** - * The name the file was uploaded under, '' when the server knows none. A picture may be - * re-encoded on the way, so this can be a `.png` name on a file stored as `.webp`. - */ - name: string; +import type { FileStore, StoredFile, UploadedFile } from './FileStore'; + +/** + * Stored in `data/textures/` as `{sha256}.{png|jpg|webp|gif}`. A picture may be re-encoded on the + * way, so `name` can be a `.png` name on a file stored as `.webp`. + */ +export interface UploadedTexture extends UploadedFile { width: number; height: number; - bytes: number; } -/** A file the server holds, whether any project references it or not. */ -export interface StoredTexture { - file: string; - name: string; +export interface StoredTexture extends StoredFile { /** 0 × 0 when the server could not read the header; such a file cannot become an asset. */ width: number; height: number; - bytes: number; - updatedAt: string; } -/** - * An upload is raw bytes rather than a multipart form: name, size and format are derived from the - * image itself on the server, so there is nothing else to send. - */ -export interface TextureStore { - /** - * `type` only fills the Content-Type header – the server trusts the bytes, not the label – and - * uploading the same picture twice yields the same file, the hash of its content. `name` is the - * caption kept beside it; uploading the same bytes under a new name renames what pickers show. - */ - upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise; - list(): Promise; - delete(file: string): Promise; -} +export type TextureStore = FileStore; diff --git a/src/editor/App.ts b/src/editor/App.ts index f14fcec6..12229817 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -1,8 +1,7 @@ import type { Application } from 'pixi.js'; -import { HttpFontStore } from '../core/store/HttpFontStore'; +import { HttpFileStore } from '../core/store/HttpFileStore'; import { HttpProjectStore } from '../core/store/HttpProjectStore'; -import { HttpSoundStore } from '../core/store/HttpSoundStore'; -import { HttpTextureStore } from '../core/store/HttpTextureStore'; +import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore'; import { requestRender, type VStack } from '../ui'; import { fontLibrary } from '../viewport/FontLibrary'; import { soundLibrary } from '../viewport/SoundLibrary'; @@ -32,9 +31,9 @@ export async function startEditor(app: Application, root: VStack): Promise root, state, new HttpProjectStore(API_ENDPOINT), - new HttpTextureStore(API_ENDPOINT), - new HttpFontStore(API_ENDPOINT), - new HttpSoundStore(API_ENDPOINT), + new HttpFileStore(API_ENDPOINT, 'textures'), + new HttpFileStore(API_ENDPOINT, 'fonts'), + new HttpFileStore(API_ENDPOINT, 'sounds'), ); // An image or a font arrives long after the click that asked for it, and the widget waiting for // it repaints without telling anyone: both libraries are a wake-up of their own. diff --git a/src/editor/FilePicker.ts b/src/editor/FilePicker.ts new file mode 100644 index 00000000..c4a44b44 --- /dev/null +++ b/src/editor/FilePicker.ts @@ -0,0 +1,338 @@ +/** + * The dialog behind "+ → Texture / Font / Sound": everything the server holds of one kind, with + * what the project already calls it. Offering the server's files rather than the machine's is the + * point – uploading the same file twice is wasteful, and nothing collects the files a project + * stopped referencing. Uploading is still one button away. + * + * The list differs per kind (tiles for pictures, rows with a sample line for typefaces, rows with a + * ▶ for sounds); the dialog around it does not. + */ +import { type Container, type FederatedPointerEvent, type Graphics, Text } from 'pixi.js'; +import type { AssetType } from '../core/model/AssetTypes'; +import type { Project } from '../core/model/Project'; +import type { StoredFile } from '../core/store/FileStore'; +import { + Button, + drawControlBox, + formatBytes, + HStack, + Label, + Popup, + pressCount, + ScrollContainer, + Spacer, + Theme, + textStyle, + Widget, +} from '../ui'; + +/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */ +export const PICKER_WIDTH = 560; +export const PICKER_HEIGHT = 340; + +/** One offered file, with the asset that already wears it (if any). */ +export type PickerItem = S & { assetName: string | null }; + +/** Pairs what the server lists with what the project calls it – the asset name is the caption worth showing. */ +export function withAssetNames( + stored: S[], + project: Project, + type: AssetType, +): PickerItem[] { + const named = new Map(); + for (const asset of project.assets) { + if (asset.type === type && 'file' in asset) named.set(asset.file, asset.name); + } + return stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null })); +} + +/** The line under the list for a picked file: its upload name and size, and who wears it. */ +export function describeStored(item: PickerItem): string { + const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes); + return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size; +} + +export interface PickerRow { + /** A Container holding the rest, not a Graphics with children: Pixi v8 only lets containers have them. */ + box: Container; + background: Graphics; + caption: Text; +} + +/** + * The selectable part of the dialog: one entry per file, one of them picked, a double press + * confirms. How entries are built, placed and drawn is the subclass's. + */ +export abstract class PickerList extends Widget { + protected items: Item[] = []; + protected rows: Row[] = []; + private selectedFile: string | null = null; + private readonly empty = new Text({ text: '', style: textStyle('muted') }); + private readonly unwatch: (() => void)[] = []; + onPick?: (file: string) => void; + onActivate?: (file: string) => void; + emptyText = ''; + + constructor() { + super(); + this.addChild(this.empty); + this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e)); + } + + /** Redraws whenever a library reports an arrival, until the list is destroyed. */ + protected watch(library: { onChange(fn: () => void): () => void }, onArrive: () => void): void { + this.unwatch.push(library.onChange(onArrive)); + } + + setItems(items: Item[]): void { + this.items = items; + for (const row of this.rows) row.box.destroy({ children: true }); + this.rows = items.map(() => { + const row = this.buildRow(); + this.addChild(row.box); + return row; + }); + if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null; + this.layout(); + this.redraw(); + } + + get selected(): Item | null { + return this.items.find((item) => item.file === this.selectedFile) ?? null; + } + + get fileList(): string[] { + return this.items.map((item) => item.file); + } + + get usedFiles(): string[] { + return this.items.filter((item) => item.assetName !== null).map((item) => item.file); + } + + /** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */ + get captions(): string[] { + return this.rows.map((row) => row.caption.text); + } + + select(file: string | null): void { + if (file === this.selectedFile) return; + this.selectedFile = file; + this.redraw(); + if (file) this.onPick?.(file); + } + + /** The row of a file, with its index; null when the file is not offered. */ + protected rowOf(file: string): { row: Row; index: number } | null { + const index = this.items.findIndex((item) => item.file === file); + const row = this.rows[index]; + return row ? { row, index } : null; + } + + override redraw(): void { + this.empty.text = this.items.length === 0 ? this.emptyText : ''; + this.empty.position.set(0, 0); + this.rows.forEach((row, index) => { + const item = this.items[index]; + if (item) this.redrawRow(row, item, item.file === this.selectedFile); + }); + } + + override destroy(): void { + for (const stop of this.unwatch) stop(); + super.destroy({ children: true }); + } + + protected drawBox(row: Row, w: number, h: number, selected: boolean): void { + row.background.clear(); + drawControlBox(row.background, w, h, { + fill: selected ? Theme.colors.selection : Theme.colors.control, + border: selected ? Theme.colors.accent : Theme.colors.border, + }); + } + + protected onPress(e: FederatedPointerEvent): void { + const local = this.toLocal(e.global); + const item = this.items[this.indexAt(local.x, local.y)]; + if (!item) return; + this.select(item.file); + if (pressCount(e, item.file) === 2) this.onActivate?.(item.file); + } + + protected abstract buildRow(): Row; + /** Which entry a local point is on; any index without an item means none. */ + protected abstract indexAt(x: number, y: number): number; + protected abstract redrawRow(row: Row, item: Item, selected: boolean): void; +} + +/** A list of equally tall rows, one under the other. */ +export abstract class RowList extends PickerList { + protected abstract readonly rowHeight: number; + protected readonly rowGap = 4; + + /** Global centre of a row (test hook); `fromX` keeps clear of a column that does something else. */ + rowGlobal(file: string, fromX = 0): { x: number; y: number } | null { + const found = this.rowOf(file); + if (!found) return null; + const p = found.row.box.getGlobalPosition(); + return { x: p.x + fromX + (this.w - fromX) / 2, y: p.y + this.rowHeight / 2 }; + } + + heightFor(count: number): number { + return count === 0 ? 0 : count * this.rowHeight + (count - 1) * this.rowGap; + } + + override measure(): { w: number; h: number } { + return { w: this.minWidth, h: Math.max(this.minHeight, this.heightFor(this.items.length)) }; + } + + override layout(): void { + this.rows.forEach((row, index) => row.box.position.set(0, index * (this.rowHeight + this.rowGap))); + } + + protected override indexAt(x: number, y: number): number { + return x >= 0 && x <= this.w ? Math.floor(y / (this.rowHeight + this.rowGap)) : -1; + } +} + +export interface PickerWords { + title: string; + loading: string; + /** Under the list while nothing is picked; a hint for what the rows can do. */ + idle?: string; + empty: string; + use: string; + /** "image", "font", "sound" – for "3 fonts on the server". */ + noun: string; +} + +let current: FilePicker | null = null; + +/** + * Opens the dialog centred, closing whichever picker was open before it, and starts the listing. + * The listing starts here and not in the constructor: it asks the subclass, and a subclass's own + * fields are not assigned until its constructor has run past `super()`. + */ +export function openPicker

>(picker: P): P { + current?.close(); + current = picker; + picker.openCentered(); + void picker.load(); + return picker; +} + +/** The open dialog if it is one of `kind`, for the test hooks. */ +export function currentPicker

>( + kind: abstract new (...args: never[]) => P, +): P | null { + return current instanceof kind ? current : null; +} + +export abstract class FilePicker extends Popup { + protected readonly scroll = new ScrollContainer(); + private readonly status: Label; + private readonly detail: Label; + private readonly use: Button; + + constructor( + protected readonly list: PickerList, + private readonly words: PickerWords, + upload: () => void, + ) { + super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap }); + this.content.add(new Label(words.title, { style: 'bodyBold' })); + this.status = new Label(words.loading, { style: 'muted' }); + this.content.add(this.status); + + list.minWidth = PICKER_WIDTH; + list.emptyText = words.empty; + list.onPick = () => this.refreshSelection(); + list.onActivate = () => this.confirm(); + this.scroll.minWidth = PICKER_WIDTH; + this.scroll.minHeight = PICKER_HEIGHT; + this.scroll.setContent(list); + this.content.add(this.scroll); + + this.detail = new Label(words.idle ?? '', { style: 'muted' }); + this.content.add(this.detail); + + const buttons = new HStack({ gap: Theme.gap, align: 'center' }); + const uploadButton = new Button('Upload…'); + uploadButton.onClick = this.closeThen(upload); + buttons.add(uploadButton); + buttons.add(new Spacer(), 1); + const cancel = new Button('Cancel'); + cancel.onClick = this.closeThen(); + buttons.add(cancel); + this.use = new Button(words.use, { variant: 'primary' }); + this.use.onClick = () => this.confirm(); + this.use.setEnabled(false); + buttons.add(this.use); + this.content.add(buttons); + } + + /** What the dialog offers, which of it the project already wears, and what is picked (test hook). */ + debugInfo(): { files: string[]; used: string[]; captions: string[]; selected: string | null } { + return { + files: this.list.fileList, + used: this.list.usedFiles, + captions: this.list.captions, + selected: this.list.selected?.file ?? null, + }; + } + + override close(): void { + super.close(); + if (current === this) current = null; + // Escape, Cancel and a confirmed pick all land here, and `Popup.close` tolerates a second + // call; the list's listeners must be dropped exactly once. + if (!this.list.destroyed) this.list.destroy(); + } + + async load(): Promise { + try { + const items = await this.fetchItems(); + this.list.setItems(items); + // Only as tall as the entries need, up to the cap: a handful of files should not leave half + // a dialog of empty box under them. + const { unit, total } = this.contentHeight(items.length); + this.scroll.minHeight = Math.min(this.roomFor(this.scroll, unit), Math.max(unit, total)); + const { noun } = this.words; + this.status.setText( + items.length === 1 ? `1 ${noun} on the server` : `${items.length} ${noun}s on the server`, + ); + } catch (error) { + this.status.setText( + `Could not list the ${this.words.noun}s: ${error instanceof Error ? error.message : String(error)}`, + ); + } + this.refreshSelection(); + } + + private refreshSelection(): void { + const item = this.list.selected; + this.use.setEnabled(item !== null && this.canUse(item)); + this.detail.setText(item ? this.describe(item) : (this.words.idle ?? '')); + this.refreshLayout(); + } + + private confirm(): void { + const item = this.list.selected; + if (!item || !this.canUse(item)) return; + this.close(); + this.adopt(item); + } + + /** The server's list, paired with the project's names (`withAssetNames`). */ + protected abstract fetchItems(): Promise; + /** How tall the list is for `count` entries, and the height of one – the least the scroll shows. */ + protected abstract contentHeight(count: number): { unit: number; total: number }; + protected abstract adopt(item: Item): void; + + protected canUse(_item: Item): boolean { + return true; + } + + protected describe(item: Item): string { + return describeStored(item); + } +} diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 6fa44e39..6f7228c2 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -58,16 +58,6 @@ export function removeNodes(state: EditorState, assetId: string, nodeIds: readon return commands.length > 0 && state.execute(oneStep(`Delete ${commands.length} nodes`, commands)); } -/** - * Deletes a group or comment and leaves every node alone – which is the point, so the caller does - * not have to know which of the two lists holds it either. - */ -export function removeAnnotation(state: EditorState, assetId: string, annotationId: string): boolean { - const graph = findGraphAsset(state.project, assetId)?.graph; - const found = graph && findAnnotation(graph, annotationId); - return Boolean(found) && state.execute(new RemoveAnnotationCommand(assetId, found!.kind, annotationId)); -} - /** * Deletes everything a graph selection holds – nodes and boxes alike – as one undo step, since one * band caught them and one keypress asked for them to go. Boxes still take none of their nodes. diff --git a/src/editor/assetImport.ts b/src/editor/assetImport.ts new file mode 100644 index 00000000..16b215b1 --- /dev/null +++ b/src/editor/assetImport.ts @@ -0,0 +1,178 @@ +/** + * Pictures, typefaces and sounds all arrive the same way: a file goes up to the server, comes back + * as a content-addressed name, and an asset is made for it – or the asset already naming those + * bytes is used. What differs between them is an `ImportKind`; the pipeline is this file. + */ +import type { Command } from '../core/commands/Command'; +import { nextAssetName } from '../core/commands/naming'; +import type { AssetType } from '../core/model/AssetTypes'; +import type { FileStore, UploadedFile } from '../core/store/FileStore'; +import type { EditorState } from './state'; + +/** One file's outcome; exactly one of `assetId` and `error` is set. */ +export interface ImportOutcome { + name: string; + assetId: string | null; + error: string | null; + /** The project already had this exact file, so its existing asset was used instead. */ + reused?: boolean; +} + +export interface ImportDeps { + state: EditorState; +} + +export interface ImportKind { + type: AssetType; + /** What the notices call one: "image", "font", "sound". */ + noun: string; + /** What an asset is called when the file name gives nothing to go on. */ + fallbackName: string; + store(deps: D): FileStore; + /** The bytes to send, and their Content-Type; throws with the reason when the file cannot go up. */ + prepare(file: File): Promise<{ blob: Blob; type: string }>; + create(name: string, uploaded: U): Command & { asset: { id: string } }; +} + +/** + * Imports the files sequentially, so each asset is named against the project as the previous one + * left it. A file that fails is reported in the notice; the remaining ones are still imported. + */ +export async function importFiles( + kind: ImportKind, + files: readonly File[], + deps: D, +): Promise { + if (files.length === 0) return []; + const outcomes: ImportOutcome[] = []; + for (const file of files) outcomes.push(await importFile(kind, file, deps)); + // The asset the batch created last, so the panel shows what just arrived. + const created = outcomes.filter((outcome) => outcome.assetId !== null); + const last = created[created.length - 1]; + if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId }); + deps.state.setNotice(importNotice(outcomes, kind.noun)); + return outcomes; +} + +async function importFile( + kind: ImportKind, + file: File, + deps: D, +): Promise { + try { + const { blob, type } = await kind.prepare(file); + const uploaded = await kind.store(deps).upload(blob, type, file.name); + return adopt(kind, uploaded, assetNameForFile(file.name, kind.fallbackName), file.name, deps); + } catch (error) { + return { name: file.name, assetId: null, error: error instanceof Error ? error.message : String(error) }; + } +} + +/** + * Makes an asset for a file the server holds. `label` names the source in messages, `nameBase` is + * what the asset is called. The server stores by content hash, so a re-imported file comes back + * as the one the project already references; two assets on one file would disagree about it. + */ +function adopt( + kind: ImportKind, + uploaded: U, + nameBase: string, + label: string, + deps: D, +): ImportOutcome { + const existing = deps.state.project.assets.find( + (a) => a.type === kind.type && 'file' in a && a.file === uploaded.file, + ); + if (existing) return { name: label, assetId: existing.id, error: null, reused: true }; + + const command = kind.create(nextAssetName(deps.state.project, nameBase), uploaded); + // A rejected command has already put its reason into the notice; that is the message here. + if (!deps.state.execute(command)) return { name: label, assetId: null, error: deps.state.notice }; + return { name: label, assetId: command.asset.id, error: null, reused: false }; +} + +/** + * Adopts a file the server already holds – no upload, the bytes are there. Nothing collects + * unreferenced files, so one project's dropped picture is still there for the next to pick up. + * Chosen in the picker, so this is "+ → Texture": the tab opens like for any created asset (a kind + * without a tab is just selected). A dropped file only selects – it usually goes into the scene. + */ +export function importStored( + kind: ImportKind, + uploaded: U, + deps: D, +): ImportOutcome { + // Named after the file it was uploaded as, like a dropped one; a file from before names were + // kept has none and falls back to the kind's own word. + const nameBase = assetNameForFile(uploaded.name, kind.fallbackName); + const outcome = adopt(kind, uploaded, nameBase, uploaded.name || uploaded.file, deps); + if (outcome.assetId) deps.state.openAsset(outcome.assetId); + deps.state.setNotice( + outcome.error ?? + (outcome.reused + ? `That ${kind.noun} is already in the project.` + : `Added the ${kind.noun} to the project.`), + ); + return outcome; +} + +/** One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie. */ +export function importNotice(outcomes: readonly ImportOutcome[], noun = 'image'): string { + const added = outcomes.filter((outcome) => outcome.assetId !== null && !outcome.reused).length; + const reused = outcomes.filter((outcome) => outcome.reused).length; + const failed = outcomes.filter((outcome) => outcome.error !== null); + const first = failed[0]; + + const known = reused === 0 ? '' : ` ${reused === 1 ? 'One was' : `${reused} were`} already in the project.`; + + if (!first) { + if (added === 0 && reused > 0) + return reused === 1 + ? `That ${noun} is already in the project.` + : `Those ${reused} ${noun}s are already in the project.`; + return `Imported ${added} ${added === 1 ? noun : `${noun}s`}.${known}`; + } + const more = failed.length > 1 ? ` (and ${failed.length - 1} more)` : ''; + const problem = `Could not import "${first.name}": ${first.error ?? ''}${more}`; + if (added + reused === 0) return problem; + return `Imported ${added + reused} of ${outcomes.length}.${known} ${problem}`; +} + +/** + * A file name without its extension, as an asset is called: "hero-idle.png" reads better than + * "Texture 3". The picker gets the same name back from the server. + */ +export function assetNameForFile(fileName: string, fallback: string): string { + return fileName.replace(/\.[^.]+$/, '').trim() || fallback; +} + +/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */ +export function pickFiles(accept: string): Promise { + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = accept; + input.multiple = true; + input.style.display = 'none'; + document.body.appendChild(input); + const finish = (files: File[]) => { + input.remove(); + resolve(files); + }; + input.addEventListener('change', () => finish([...(input.files ?? [])])); + // Without `cancel` a dismissed dialog leaves both the element and this promise behind. + input.addEventListener('cancel', () => finish([])); + input.click(); + }); +} + +/** The *Upload…* button: the file dialog, then the batch import of whatever it returns. */ +export function pickAndImport( + kind: ImportKind, + accept: string, + deps: D, +): void { + void pickFiles(accept) + .then((files) => importFiles(kind, files, deps)) + .catch((error: unknown) => deps.state.setNotice(String(error), 'error')); +} diff --git a/src/editor/font/FontPicker.ts b/src/editor/font/FontPicker.ts index 8a192876..2dae0804 100644 --- a/src/editor/font/FontPicker.ts +++ b/src/editor/font/FontPicker.ts @@ -1,296 +1,110 @@ -import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js'; +import { Container, Graphics, Text, TextStyle } from 'pixi.js'; import type { StoredFont } from '../../core/store/FontStore'; -import { - Button, - drawControlBox, - formatBytes, - HStack, - Label, - Popup, - pressCount, - ScrollContainer, - type Size, - Spacer, - Theme, - textStyle, - truncate, - Widget, -} from '../../ui'; +import { formatBytes, Theme, textStyle, truncate } from '../../ui'; import { fontLibrary } from '../../viewport/FontLibrary'; +import { + currentPicker, + FilePicker, + openPicker, + type PickerItem, + type PickerRow, + RowList, + withAssetNames, +} from '../FilePicker'; import { type FontImportDeps, importStoredFont, pickAndImportFonts } from '../fontImport'; -/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */ -const LIST_WIDTH = 560; -const LIST_HEIGHT = 340; const ROW_HEIGHT = 52; -const ROW_GAP = 4; const ROW_PAD = 8; /** Big enough to tell two typefaces apart at a glance, small enough for a long line to fit. */ const SAMPLE_SIZE = 22; const SAMPLE_TEXT = 'Aa Bb Cc 0123 The quick brown fox'; -/** One offered typeface, with the asset that already wears it (if any). */ -interface PickerItem extends StoredFont { - assetName: string | null; +type FontItem = PickerItem; +interface FontRow extends PickerRow { + sample: Text; } /** - * The rows. A typeface has no thumbnail to show, so each row *is* the preview: the sample line is - * drawn in the face itself, and reflows from the fallback once the library has loaded it. + * A typeface has no thumbnail to show, so each row *is* the preview: the sample line is drawn in + * the face itself, and reflows from the fallback once the library has loaded it. */ -class FontList extends Widget { - private items: PickerItem[] = []; - private rows: { box: Container; background: Graphics; sample: Text; caption: Text }[] = []; - private selectedFile: string | null = null; - private readonly empty = new Text({ text: '', style: textStyle('muted') }); - private readonly stopWatching: () => void; - onPick?: (file: string) => void; - onActivate?: (file: string) => void; - emptyText = ''; +class FontList extends RowList { + protected readonly rowHeight = ROW_HEIGHT; constructor() { super(); - this.addChild(this.empty); - this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e)); - this.stopWatching = fontLibrary.onChange(() => this.redraw()); + this.watch(fontLibrary, () => this.redraw()); } - setItems(items: PickerItem[]): void { - this.items = items; - for (const row of this.rows) row.box.destroy({ children: true }); - this.rows = items.map(() => { - // A Container holding the background, not a Graphics with children: Pixi v8 only lets - // containers have them (`addChild: Only Containers will be allowed to add children`). - const box = new Container(); - const background = new Graphics(); - // Its own style object, never `textStyle('body')`: those are shared across the whole - // editor, so writing a family into one would restyle every label in the window. - const sample = new Text({ - style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }), - }); - const caption = new Text({ style: textStyle('small') }); - box.addChild(background, sample, caption); - this.addChild(box); - return { box, background, sample, caption }; - }); - if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null; - this.layout(); - this.redraw(); + protected buildRow(): FontRow { + const box = new Container(); + const background = new Graphics(); + // Its own style object, never `textStyle('body')`: those are shared across the whole editor, + // so writing a family into one would restyle every label in the window. + const sample = new Text({ style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }) }); + const caption = new Text({ style: textStyle('small') }); + box.addChild(background, sample, caption); + return { box, background, sample, caption }; } - get selected(): PickerItem | null { - return this.items.find((item) => item.file === this.selectedFile) ?? null; - } + protected redrawRow(row: FontRow, item: FontItem, selected: boolean): void { + this.drawBox(row, this.w, ROW_HEIGHT, selected); + // Empty family = not loaded yet; the row then reads in the editor's own typeface and the + // library's change event brings it back here once the face has arrived. + row.sample.style.fontFamily = fontLibrary.family(item.file) || Theme.font; + row.sample.text = truncate(SAMPLE_TEXT, row.sample.style, this.w - ROW_PAD * 2); + row.sample.position.set(ROW_PAD, ROW_PAD - 2); - get fileList(): string[] { - return this.items.map((item) => item.file); - } - - get usedFiles(): string[] { - return this.items.filter((item) => item.assetName !== null).map((item) => item.file); - } - - /** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */ - get captions(): string[] { - return this.rows.map((row) => row.caption.text); - } - - select(file: string | null): void { - if (file === this.selectedFile) return; - this.selectedFile = file; - this.redraw(); - if (file) this.onPick?.(file); - } - - /** Global centre of a row; null when the file is not offered (test hook). */ - rowGlobal(file: string): { x: number; y: number } | null { - const index = this.items.findIndex((item) => item.file === file); - const row = this.rows[index]; - if (!row) return null; - const p = row.box.getGlobalPosition(); - return { x: p.x + this.w / 2, y: p.y + ROW_HEIGHT / 2 }; - } - - override measure(): Size { - return { w: this.minWidth, h: Math.max(this.minHeight, heightFor(this.items.length)) }; - } - - override layout(): void { - this.rows.forEach((row, index) => row.box.position.set(0, index * (ROW_HEIGHT + ROW_GAP))); - } - - override redraw(): void { - this.empty.text = this.items.length === 0 ? this.emptyText : ''; - this.empty.position.set(0, 0); - - this.rows.forEach((node, index) => { - const item = this.items[index]; - if (!item) return; - const selected = item.file === this.selectedFile; - node.background.clear(); - drawControlBox(node.background, this.w, ROW_HEIGHT, { - fill: selected ? Theme.colors.selection : Theme.colors.control, - border: selected ? Theme.colors.accent : Theme.colors.border, - }); - - // Empty family = not loaded yet; the row then reads in the editor's own typeface and the - // library's change event brings it back here once the face has arrived. - node.sample.style.fontFamily = fontLibrary.family(item.file) || Theme.font; - node.sample.text = truncate(SAMPLE_TEXT, node.sample.style, this.w - ROW_PAD * 2); - node.sample.position.set(ROW_PAD, ROW_PAD - 2); - - // The asset name wins where there is one – it is what the project calls this face. Otherwise - // the name it was uploaded under, which is all a bare hash leaves to go on. - node.caption.style = textStyle(item.assetName ? 'accent' : 'muted'); - node.caption.text = truncate( - item.assetName ?? (item.name || formatBytes(item.bytes)), - node.caption.style, - this.w - ROW_PAD * 2, - ); - node.caption.position.set(ROW_PAD, ROW_HEIGHT - 16); - }); - } - - override destroy(): void { - this.stopWatching(); - super.destroy({ children: true }); - } - - private onPress(e: FederatedPointerEvent): void { - const local = this.toLocal(e.global); - const index = Math.floor(local.y / (ROW_HEIGHT + ROW_GAP)); - const item = local.x >= 0 && local.x <= this.w ? this.items[index] : undefined; - if (!item) return; - this.select(item.file); - if (pressCount(e, item.file) === 2) this.onActivate?.(item.file); + // The asset name wins where there is one – it is what the project calls this face. Otherwise + // the name it was uploaded under, which is all a bare hash leaves to go on. + row.caption.style = textStyle(item.assetName ? 'accent' : 'muted'); + row.caption.text = truncate( + item.assetName ?? (item.name || formatBytes(item.bytes)), + row.caption.style, + this.w - ROW_PAD * 2, + ); + row.caption.position.set(ROW_PAD, ROW_HEIGHT - 16); } } -function heightFor(count: number): number { - return count === 0 ? 0 : count * ROW_HEIGHT + (count - 1) * ROW_GAP; -} - -/** - * Offers every typeface the server holds, the way the image picker offers pictures: uploading the - * same font twice is wasteful, and nothing collects the files a project stopped referencing. - */ -export class FontPicker extends Popup { - private readonly list = new FontList(); - private readonly scroll = new ScrollContainer(); - private readonly status: Label; - private readonly detail: Label; - private readonly use: Button; +export class FontPicker extends FilePicker { + declare protected readonly list: FontList; constructor(private readonly deps: FontImportDeps) { - super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap }); - this.content.add(new Label('Choose a font', { style: 'bodyBold' })); - this.status = new Label('Loading fonts…', { style: 'muted' }); - this.content.add(this.status); - - this.list.minWidth = LIST_WIDTH; - this.list.emptyText = 'No fonts on the server yet – upload one.'; - this.list.onPick = () => this.refreshSelection(); - this.list.onActivate = () => this.confirm(); - this.scroll.minWidth = LIST_WIDTH; - this.scroll.minHeight = LIST_HEIGHT; - this.scroll.setContent(this.list); - this.content.add(this.scroll); - - this.detail = new Label('', { style: 'muted' }); - this.content.add(this.detail); - - const buttons = new HStack({ gap: Theme.gap, align: 'center' }); - const upload = new Button('Upload…'); - upload.onClick = this.closeThen(() => pickAndImportFonts(this.deps)); - buttons.add(upload); - buttons.add(new Spacer(), 1); - const cancel = new Button('Cancel'); - cancel.onClick = this.closeThen(); - buttons.add(cancel); - this.use = new Button('Use font', { variant: 'primary' }); - this.use.onClick = () => this.confirm(); - this.use.setEnabled(false); - buttons.add(this.use); - this.content.add(buttons); - - void this.loadList(); - } - - /** What the dialog offers, which of it the project already wears, and what is picked (test hook). */ - debugInfo(): { files: string[]; used: string[]; captions: string[]; selected: string | null } { - return { - files: this.list.fileList, - used: this.list.usedFiles, - captions: this.list.captions, - selected: this.list.selected?.file ?? null, - }; + super( + new FontList(), + { + title: 'Choose a font', + loading: 'Loading fonts…', + empty: 'No fonts on the server yet – upload one.', + use: 'Use font', + noun: 'font', + }, + () => pickAndImportFonts(deps), + ); } rowGlobal(file: string): { x: number; y: number } | null { return this.list.rowGlobal(file); } - override close(): void { - super.close(); - if (current === this) current = null; - // Escape, Cancel and a confirmed pick all land here, and `Popup.close` tolerates a second - // call; the list's listener must be dropped exactly once. - if (!this.list.destroyed) this.list.destroy(); + protected async fetchItems(): Promise { + return withAssetNames(await this.deps.fonts.list(), this.deps.state.project, 'font'); } - private async loadList(): Promise { - try { - const stored = await this.deps.fonts.list(); - const named = new Map(); - for (const asset of this.deps.state.project.assets) { - if (asset.type === 'font') named.set(asset.file, asset.name); - } - this.list.setItems(stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null }))); - this.scroll.minHeight = Math.min( - this.roomFor(this.scroll, ROW_HEIGHT), - Math.max(ROW_HEIGHT, heightFor(stored.length)), - ); - this.status.setText( - stored.length === 1 ? '1 font on the server' : `${stored.length} fonts on the server`, - ); - } catch (error) { - this.status.setText( - `Could not list the fonts: ${error instanceof Error ? error.message : String(error)}`, - ); - } - this.refreshSelection(); + protected contentHeight(count: number): { unit: number; total: number } { + return { unit: ROW_HEIGHT, total: this.list.heightFor(count) }; } - private refreshSelection(): void { - const item = this.list.selected; - this.use.setEnabled(item !== null); - this.detail.setText(item ? describe(item) : ''); - this.refreshLayout(); - } - - private confirm(): void { - const item = this.list.selected; - if (!item) return; - this.close(); + protected adopt(item: FontItem): void { importStoredFont(item, this.deps); } } -function describe(item: PickerItem): string { - const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes); - return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size; -} - -let current: FontPicker | null = null; - export function openFontPicker(deps: FontImportDeps): FontPicker { - current?.close(); - current = new FontPicker(deps); - current.openCentered(); - return current; + return openPicker(new FontPicker(deps)); } -/** The open dialog, for the test hook. */ export function currentFontPicker(): FontPicker | null { - return current; + return currentPicker(FontPicker); } diff --git a/src/editor/fontImport.ts b/src/editor/fontImport.ts index 5af83918..e34b1820 100644 --- a/src/editor/fontImport.ts +++ b/src/editor/fontImport.ts @@ -1,9 +1,7 @@ import { CreateFontCommand } from '../core/commands/assetCommands'; -import { nextAssetName } from '../core/commands/naming'; -import type { FontAsset } from '../core/model/Asset'; import type { FontStore, UploadedFont } from '../core/store/FontStore'; +import { type ImportKind, type ImportOutcome, importFiles, importStored, pickAndImport } from './assetImport'; import type { EditorState } from './state'; -import { type ImportOutcome, assetNameForFile, importNotice } from './textureImport'; /** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */ export const FONT_ACCEPT = '.ttf,.otf,.woff,.woff2'; @@ -23,84 +21,27 @@ export interface FontImportDeps { fonts: FontStore; } -/** Sequential like `importImages`, so each asset is named against the project as it then stands. */ -export async function importFonts(files: readonly File[], deps: FontImportDeps): Promise { - if (files.length === 0) return []; - const outcomes: ImportOutcome[] = []; - for (const file of files) outcomes.push(await importFont(file, deps)); - const created = outcomes.filter((outcome) => outcome.assetId !== null); - const last = created[created.length - 1]; - if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId }); - deps.state.setNotice(importNotice(outcomes, 'font')); - return outcomes; +const FONT_KIND: ImportKind = { + type: 'font', + noun: 'font', + fallbackName: 'Font', + store: (deps) => deps.fonts, + // The bytes go up untouched: unlike a picture, a typeface cannot be re-encoded smaller here. + prepare: (file) => + isFontFileName(file.name) + ? Promise.resolve({ blob: file, type: file.type || 'application/octet-stream' }) + : Promise.reject(new Error('Not a font file – use TTF, OTF, WOFF or WOFF2.')), + create: (name, font) => new CreateFontCommand(name, font.file), +}; + +export function importFonts(files: readonly File[], deps: FontImportDeps): Promise { + return importFiles(FONT_KIND, files, deps); } -async function importFont(file: File, deps: FontImportDeps): Promise { - try { - if (!isFontFileName(file.name)) throw new Error('Not a font file – use TTF, OTF, WOFF or WOFF2.'); - // The bytes go up untouched: unlike a picture, a typeface cannot be re-encoded smaller here. - const uploaded = await deps.fonts.upload(file, file.type || 'application/octet-stream', file.name); - return adopt(uploaded, assetNameForFile(file.name, 'Font'), file.name, deps); - } catch (error) { - return { - name: file.name, - assetId: null, - error: error instanceof Error ? error.message : String(error), - }; - } -} - -/** Makes an asset for a typeface the server holds, or reuses the one already naming those bytes. */ -function adopt(font: UploadedFont, nameBase: string, label: string, deps: FontImportDeps): ImportOutcome { - const existing = deps.state.project.assets.find( - (asset): asset is FontAsset => asset.type === 'font' && asset.file === font.file, - ); - if (existing) return { name: label, assetId: existing.id, error: null, reused: true }; - - const name = nextAssetName(deps.state.project, nameBase); - const command = new CreateFontCommand(name, font.file); - // A rejected command has already put its reason into the notice; that is the message here. - if (!deps.state.execute(command)) return { name: label, assetId: null, error: deps.state.notice }; - return { name: label, assetId: command.asset.id, error: null, reused: false }; -} - -/** - * Adopts a typeface the server already holds – no upload, the bytes are there. Chosen in the - * picker, so this is "+ → Font" and the asset is selected the way a created one would be. - */ export function importStoredFont(font: UploadedFont, deps: FontImportDeps): ImportOutcome { - // Named after the file it was uploaded as, like a dropped one; "Font" when the server knows none. - const outcome = adopt(font, assetNameForFile(font.name, 'Font'), font.name || font.file, deps); - if (outcome.assetId) deps.state.select({ kind: 'asset', assetId: outcome.assetId }); - deps.state.setNotice( - outcome.error ?? - (outcome.reused ? 'That font is already in the project.' : 'Added the font to the project.'), - ); - return outcome; -} - -/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */ -export function pickFontFiles(): Promise { - return new Promise((resolve) => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = FONT_ACCEPT; - input.multiple = true; - input.style.display = 'none'; - document.body.appendChild(input); - const finish = (files: File[]) => { - input.remove(); - resolve(files); - }; - input.addEventListener('change', () => finish([...(input.files ?? [])])); - // Without `cancel` a dismissed dialog leaves both the element and this promise behind. - input.addEventListener('cancel', () => finish([])); - input.click(); - }); + return importStored(FONT_KIND, font, deps); } export function pickAndImportFonts(deps: FontImportDeps): void { - void pickFontFiles() - .then((files) => importFonts(files, deps)) - .catch((error: unknown) => deps.state.setNotice(String(error), 'error')); + pickAndImport(FONT_KIND, FONT_ACCEPT, deps); } diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 9fa853e8..b1cb01d7 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -2,19 +2,13 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphi import { textOf } from '../../core/model/Properties'; import type { Command } from '../../core/commands/Command'; import { oneStep } from '../../core/commands/CompoundCommand'; -import { - AddNodeCommand, - CollapseNodesCommand, - ConnectCommand, - MoveNodeCommand, -} from '../../core/commands/graphCommands'; +import { AddNodeCommand, CollapseNodesCommand, ConnectCommand } from '../../core/commands/graphCommands'; import { SetMachineStartCommand } from '../../core/commands/assetCommands'; import { AddAnnotationCommand, type AnnotationBox, annotationNoun, normalizeBox, - SetAnnotationBoxCommand, } from '../../core/commands/annotationCommands'; import { type AnnotationKind, @@ -32,7 +26,6 @@ import { firstPortFor, ghostProblem, type Graph, - type GraphAnnotation, type GraphNode, type GraphLookup, MIN_ANNOTATION_SIZE, @@ -75,13 +68,22 @@ import { annotationSelection, type EditorState, graphSelection, nodeSelection } import { type Box, boundsOf, - boxesCentredIn, boxesInBand, boxesWithinBand, dragOffset, frameBoxes, resizeSize, } from '../framing'; +import { + annotationBoxes, + boxOf, + type BoxSeed, + boxSeeds, + placeTravellers, + recordTravellers, + travellers, + type Travellers, +} from './annotationDrag'; import { ANNOTATION_TITLE_HEIGHT, AnnotationView, RESIZE_HANDLE } from './AnnotationView'; import { arrowHead, bezier, EdgeLayer } from './EdgeLayer'; import { arrowShaft, type Point, transitionPreview } from './transitions'; @@ -141,25 +143,17 @@ export class GraphCanvas extends Widget { private pan = { x: 0, y: 0 }; private zoom = 1; private panning: { startX: number; startY: number; panX: number; panY: number } | null = null; - private dragging: { - items: { nodeId: string; start: { x: number; y: number } }[]; - /** Boxes selected alongside the nodes: a band caught them together, so they travel together. */ - boxes: { id: string; kind: AnnotationKind; start: AnnotationBox }[]; - pointer: { x: number; y: number }; - moved: boolean; - } | null = null; + /** Nodes being dragged, with the boxes a band caught alongside them – they travel together. */ + private dragging: { along: Travellers; pointer: { x: number; y: number }; moved: boolean } | null = null; /** - * A group or comment being moved or resized. `members` is fixed when the drag begins: which - * nodes a group carries is a question about where they were when it was grabbed, not a running - * one – otherwise a node would join the group halfway through the gesture that passes over it. + * A group or comment being moved or resized. `along` is fixed when the drag begins: which nodes a + * group carries is a question about where they were when it was grabbed, not a running one – + * otherwise a node would join the group halfway through the gesture that passes over it. */ private annotationDrag: { - id: string; - kind: AnnotationKind; + grabbed: BoxSeed; mode: 'move' | 'resize'; - start: AnnotationBox; - members: { nodeId: string; start: { x: number; y: number } }[]; - nested: { id: string; kind: AnnotationKind; start: AnnotationBox }[]; + along: Travellers; pointer: { x: number; y: number }; moved: boolean; } | null = null; @@ -380,106 +374,27 @@ export class GraphCanvas extends Widget { return; } if (e.button !== 0) return; + const graph = this.asset()?.graph; + if (!graph) return; const local = view.toLocal(e.global); const p = this.toLocal(e.global); - const pointer = this.screenToGraph(p.x, p.y); - const start = boxOf(view.annotation); + const grabbed: BoxSeed = { id, kind: view.kind, start: boxOf(view.annotation) }; const resizing = view.handleAt(local.x, local.y); // A resize carries nothing: membership is re-derived from where the box ends up, so a node the // corner sweeps over simply belongs to it after. + const seeds = [grabbed, ...boxSeeds(graph, this.state.selectedAnnotationIds, id)]; const along = resizing - ? { boxes: [], nodes: [] } - : this.travellers( - [{ id, kind: view.kind, start }, ...this.selectedBoxes(id)], - this.state.selectedNodeIds, - ); + ? { boxes: [grabbed], nodes: [] } + : travellers(graph, this.nodeBoxes(), seeds, this.state.selectedNodeIds); this.annotationDrag = { - id, - kind: view.kind, + grabbed, mode: resizing ? 'resize' : 'move', - start, - members: along.nodes, - nested: along.boxes.filter((b) => b.id !== id), - pointer, + along, + pointer: this.screenToGraph(p.x, p.y), moved: false, }; } - /** The selected groups and comments as drag seeds, `exceptId` left out (it is the grabbed one). */ - private selectedBoxes(exceptId?: string): { id: string; kind: AnnotationKind; start: AnnotationBox }[] { - const graph = this.asset()?.graph; - if (!graph) return []; - const seeds: { id: string; kind: AnnotationKind; start: AnnotationBox }[] = []; - for (const selected of this.state.selectedAnnotationIds) { - if (selected === exceptId) continue; - const found = findAnnotation(graph, selected); - if (found) seeds.push({ id: selected, kind: found.kind, start: boxOf(found.annotation) }); - } - return seeds; - } - - /** - * Everything one move gesture carries: the boxes it starts from, the boxes those hold, and every - * node beneath any of them – each node exactly once, however many groups claim it. Without that - * last part a node inside a group that is itself selected would be offset twice. - */ - private travellers( - seedBoxes: readonly { id: string; kind: AnnotationKind; start: AnnotationBox }[], - seedNodeIds: readonly string[], - ): { - boxes: { id: string; kind: AnnotationKind; start: AnnotationBox }[]; - nodes: { nodeId: string; start: { x: number; y: number } }[]; - } { - const boxes = new Map(seedBoxes.map((b) => [b.id, b])); - for (const seed of seedBoxes) { - if (seed.kind !== 'group') continue; - for (const nested of this.nestedMembers(seed.id, seed.start)) - if (!boxes.has(nested.id)) boxes.set(nested.id, nested); - } - const nodes = new Map(); - const graph = this.asset()?.graph; - for (const id of seedNodeIds) { - const node = graph?.nodes.find((n) => n.id === id); - if (node) nodes.set(id, { nodeId: id, start: { x: node.x, y: node.y } }); - } - for (const box of boxes.values()) { - if (box.kind !== 'group') continue; - for (const member of this.nodeMembers(box.start)) - if (!nodes.has(member.nodeId)) nodes.set(member.nodeId, member); - } - return { boxes: [...boxes.values()], nodes: [...nodes.values()] }; - } - - /** The nodes a group at this box carries – the centre rule, against what is drawn right now. */ - private nodeMembers(box: AnnotationBox): { nodeId: string; start: { x: number; y: number } }[] { - const graph = this.asset()?.graph; - const ids = new Set(boxesCentredIn(this.nodeBoxes(), box)); - return (graph?.nodes ?? []) - .filter((n) => ids.has(n.id)) - .map((n) => ({ nodeId: n.id, start: { x: n.x, y: n.y } })); - } - - /** Groups and comments inside a group: the outer one takes them, and with them their own nodes. */ - private nestedMembers( - selfId: string, - box: AnnotationBox, - ): { id: string; kind: AnnotationKind; start: AnnotationBox }[] { - const graph = this.asset()?.graph; - if (!graph) return []; - const inside: { id: string; kind: AnnotationKind; start: AnnotationBox }[] = []; - for (const kind of ANNOTATION_KINDS) { - const list = annotationsOf(graph, kind).filter((a) => a.id !== selfId); - const ids = new Set( - boxesCentredIn( - list.map((a) => ({ id: a.id, ...boxOf(a) })), - box, - ), - ); - for (const a of list) if (ids.has(a.id)) inside.push({ id: a.id, kind, start: boxOf(a) }); - } - return inside; - } - /** Moves or resizes the dragged box in the model, for the preview; committed on release. */ private previewAnnotationDrag(g: { x: number; y: number }): void { const drag = this.annotationDrag; @@ -489,29 +404,16 @@ export class GraphCanvas extends Widget { const dy = g.y - drag.pointer.y; if (!drag.moved && Math.hypot(dx, dy) * this.zoom < DRAG_THRESHOLD) return; drag.moved = true; - const self = findAnnotation(graph, drag.id)?.annotation; - if (!self) return; const step = this.snapping ? GRID_STEP : 0; if (drag.mode === 'resize') { - const size = resizeSize(drag.start, dx, dy, step, MIN_ANNOTATION_SIZE); - Object.assign(self, normalizeBox({ ...drag.start, ...size })); - this.state.preview(); - return; - } - const offset = dragOffset(drag.start, dx, dy, step); - self.x = drag.start.x + offset.x; - self.y = drag.start.y + offset.y; - for (const member of drag.members) { - const node = graph.nodes.find((n) => n.id === member.nodeId); - if (!node) continue; - node.x = member.start.x + offset.x; - node.y = member.start.y + offset.y; - } - for (const member of drag.nested) { - const nested = findAnnotation(graph, member.id)?.annotation; - if (!nested) continue; - nested.x = member.start.x + offset.x; - nested.y = member.start.y + offset.y; + const self = findAnnotation(graph, drag.grabbed.id)?.annotation; + if (!self) return; + const size = resizeSize(drag.grabbed.start, dx, dy, step, MIN_ANNOTATION_SIZE); + Object.assign(self, normalizeBox({ ...drag.grabbed.start, ...size })); + } else { + // One offset for all of it, the grabbed box's: what travels with a group keeps its distance. + const offset = dragOffset(drag.grabbed.start, dx, dy, step); + placeTravellers(graph, drag.along, () => offset); } this.state.preview(); } @@ -522,30 +424,10 @@ export class GraphCanvas extends Widget { this.annotationDrag = null; const graph = this.asset()?.graph; if (!drag?.moved || !graph || !this.assetId) return; - const self = findAnnotation(graph, drag.id)?.annotation; - if (!self) return; - const commands: Command[] = []; - const take = (a: GraphAnnotation, kind: AnnotationKind, id: string, start: AnnotationBox) => { - const next = boxOf(a); - Object.assign(a, start); - commands.push(new SetAnnotationBoxCommand(this.assetId, kind, id, next, label)); - }; - const label = drag.mode === 'resize' ? `Resize ${annotationNoun(drag.kind)}` : undefined; - take(self, drag.kind, drag.id, drag.start); - for (const member of drag.nested) { - const nested = findAnnotation(graph, member.id)?.annotation; - if (nested) take(nested, member.kind, member.id, member.start); - } - for (const member of drag.members) { - const node = graph.nodes.find((n) => n.id === member.nodeId); - if (!node) continue; - const next = { x: node.x, y: node.y }; - node.x = member.start.x; - node.y = member.start.y; - commands.push(new MoveNodeCommand(this.assetId, member.nodeId, next.x, next.y)); - } - const verb = drag.mode === 'resize' ? 'Resize' : 'Move'; - this.state.execute(oneStep(`${verb} ${annotationNoun(drag.kind)}`, commands)); + const noun = annotationNoun(drag.grabbed.kind); + const label = drag.mode === 'resize' ? `Resize ${noun}` : undefined; + const commands = recordTravellers(graph, this.assetId, drag.along, label); + this.state.execute(oneStep(`${drag.mode === 'resize' ? 'Resize' : 'Move'} ${noun}`, commands)); } /** Adds a group or comment and selects it, so its title can be typed straight away. */ @@ -625,7 +507,7 @@ export class GraphCanvas extends Widget { const asset = this.asset(); // Groups and comments are part of the picture: a fit that framed only the nodes would cut the // box drawn around them, and a graph holding nothing but a comment would frame the origin. - const boxes = asset ? [...this.nodeBoxes(), ...this.annotationBoxes()] : []; + const boxes = asset ? [...this.nodeBoxes(), ...annotationBoxes(asset.graph)] : []; if (boxes.length === 0) { this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 }; this.zoom = 1; @@ -641,15 +523,6 @@ export class GraphCanvas extends Widget { this.applyView(); } - /** Every group and comment as the rectangle it is drawn as – a box states its own size. */ - private annotationBoxes(): Box[] { - const graph = this.asset()?.graph; - if (!graph) return []; - return ANNOTATION_KINDS.flatMap((kind) => - annotationsOf(graph, kind).map((a) => ({ id: a.id, ...boxOf(a) })), - ); - } - /** Each node as the rectangle it is actually drawn as (a view knows its own height). */ private nodeBoxes(): Box[] { return (this.asset()?.graph.nodes ?? []).map((node) => { @@ -786,11 +659,12 @@ export class GraphCanvas extends Widget { // Clicking a selected node keeps the selection so the whole group can be dragged. if (!this.state.selectedNodeIds.includes(view.node.id)) this.state.select(nodeSelection(this.assetId, [view.node.id])); + const graph = this.asset()?.graph; + if (!graph) return; const p = this.toLocal(e.global); - const along = this.travellers(this.selectedBoxes(), this.state.selectedNodeIds); + const seeds = boxSeeds(graph, this.state.selectedAnnotationIds); this.dragging = { - items: along.nodes, - boxes: along.boxes, + along: travellers(graph, this.nodeBoxes(), seeds, this.state.selectedNodeIds), pointer: this.screenToGraph(p.x, p.y), moved: false, }; @@ -837,21 +711,8 @@ export class GraphCanvas extends Widget { const graph = this.asset()?.graph; if (!graph) return; const step = this.snapping ? GRID_STEP : 0; - for (const item of this.dragging.items) { - const node = graph.nodes.find((n) => n.id === item.nodeId); - if (!node) continue; - const offset = dragOffset(item.start, dx, dy, step); - node.x = item.start.x + offset.x; - node.y = item.start.y + offset.y; - } - // Boxes caught by the same band travel with the nodes, each snapping for itself as a node does. - for (const box of this.dragging.boxes) { - const found = findAnnotation(graph, box.id)?.annotation; - if (!found) continue; - const offset = dragOffset(box.start, dx, dy, step); - found.x = box.start.x + offset.x; - found.y = box.start.y + offset.y; - } + // Each thing snaps for itself, node or box, so what lands on the grid is what was grabbed. + placeTravellers(graph, this.dragging.along, (start) => dragOffset(start, dx, dy, step)); this.state.preview(); } else if (this.connecting) { this.connecting.pointer = g; @@ -928,26 +789,10 @@ export class GraphCanvas extends Widget { } const drag = this.dragging; this.dragging = null; - if (drag?.moved && this.assetId) { - const graph = this.asset()?.graph; - const moves: Command[] = []; - for (const item of drag.items) { - const node = graph?.nodes.find((n) => n.id === item.nodeId); - if (!node) continue; - // The preview moved the node already; put it back so the command records the start. - const next = { x: node.x, y: node.y }; - node.x = item.start.x; - node.y = item.start.y; - moves.push(new MoveNodeCommand(this.assetId, item.nodeId, next.x, next.y)); - } + const graph = this.asset()?.graph; + if (drag?.moved && graph && this.assetId) { // The boxes that came along belong in the same step: one gesture, one undo. - for (const box of drag.boxes) { - const found = graph && findAnnotation(graph, box.id)?.annotation; - if (!found) continue; - const next = boxOf(found); - Object.assign(found, box.start); - moves.push(new SetAnnotationBoxCommand(this.assetId, box.kind, box.id, next)); - } + const moves = recordTravellers(graph, this.assetId, drag.along); if (moves.length > 0) this.state.execute(oneStep('Move nodes', moves)); } const connecting = this.connecting; @@ -975,7 +820,8 @@ export class GraphCanvas extends Widget { }; const ids = boxesInBand(this.nodeBoxes(), band); // A box counts only when the band holds all of it – see `boxesWithinBand`. - const boxIds = boxesWithinBand(this.annotationBoxes(), band); + const graph = this.asset()?.graph; + const boxIds = graph ? boxesWithinBand(annotationBoxes(graph), band) : []; // An empty band clears the selection but keeps the behavior, as a plain click does. this.state.select(graphSelection(this.assetId, ids, boxIds) ?? { kind: 'asset', assetId: this.assetId }); } @@ -1512,10 +1358,6 @@ function globalRect(target: Container, w: number, h: number): Rect { return { x: topLeft.x, y: topLeft.y, w: bottomRight.x - topLeft.x, h: bottomRight.y - topLeft.y }; } -function boxOf(annotation: GraphAnnotation): AnnotationBox { - return { x: annotation.x, y: annotation.y, w: annotation.w, h: annotation.h }; -} - /** A drag may start at either end; the command wants the output first. */ function connectSides(assetId: string, a: PortSide, b: PortSide): ConnectCommand { const [out, inp] = a.isOutput ? [a, b] : [b, a]; diff --git a/src/editor/graph/annotationDrag.ts b/src/editor/graph/annotationDrag.ts new file mode 100644 index 00000000..4dcfd381 --- /dev/null +++ b/src/editor/graph/annotationDrag.ts @@ -0,0 +1,161 @@ +/** + * What a drag in the graph carries and how it is written back. A group takes the nodes whose centre + * lies in it and the boxes inside it, a band may have caught nodes and boxes together, and either + * way a thing must move exactly once – a node inside a selected group is claimed twice and offset + * once. Pure over the graph and the drawn node boxes, so the rule is testable without a canvas. + */ +import { type AnnotationBox, SetAnnotationBoxCommand } from '../../core/commands/annotationCommands'; +import type { Command } from '../../core/commands/Command'; +import { MoveNodeCommand } from '../../core/commands/graphCommands'; +import { + type AnnotationKind, + ANNOTATION_KINDS, + annotationsOf, + findAnnotation, + type Graph, + type GraphAnnotation, +} from '../../core/graph'; +import { type Box, boxesCentredIn, type Point } from '../framing'; + +/** A box as the drag found it. */ +export interface BoxSeed { + id: string; + kind: AnnotationKind; + start: AnnotationBox; +} + +/** A node as the drag found it. */ +export interface NodeSeed { + nodeId: string; + start: Point; +} + +export interface Travellers { + boxes: BoxSeed[]; + nodes: NodeSeed[]; +} + +export function boxOf(annotation: GraphAnnotation): AnnotationBox { + return { x: annotation.x, y: annotation.y, w: annotation.w, h: annotation.h }; +} + +/** Every group and comment as the rectangle it is drawn as – a box states its own size. */ +export function annotationBoxes(graph: Graph): Box[] { + return ANNOTATION_KINDS.flatMap((kind) => + annotationsOf(graph, kind).map((a) => ({ id: a.id, ...boxOf(a) })), + ); +} + +/** The boxes with these ids as drag seeds, `exceptId` left out (it is the grabbed one). */ +export function boxSeeds(graph: Graph, ids: readonly string[], exceptId?: string): BoxSeed[] { + const seeds: BoxSeed[] = []; + for (const id of ids) { + if (id === exceptId) continue; + const found = findAnnotation(graph, id); + if (found) seeds.push({ id, kind: found.kind, start: boxOf(found.annotation) }); + } + return seeds; +} + +/** + * Everything one move gesture carries: the boxes it starts from, the boxes those hold, and every + * node beneath any of them – each node exactly once, however many groups claim it. `nodeBoxes` + * are the nodes as drawn, since a node's height is the view's to know. + */ +export function travellers( + graph: Graph, + nodeBoxes: readonly Box[], + seedBoxes: readonly BoxSeed[], + seedNodeIds: readonly string[], +): Travellers { + const boxes = new Map(seedBoxes.map((b) => [b.id, b])); + for (const seed of seedBoxes) { + if (seed.kind !== 'group') continue; + for (const nested of nestedMembers(graph, seed.id, seed.start)) + if (!boxes.has(nested.id)) boxes.set(nested.id, nested); + } + const nodes = new Map(); + for (const id of seedNodeIds) { + const node = graph.nodes.find((n) => n.id === id); + if (node) nodes.set(id, { nodeId: id, start: { x: node.x, y: node.y } }); + } + for (const box of boxes.values()) { + if (box.kind !== 'group') continue; + for (const member of nodeMembers(graph, nodeBoxes, box.start)) + if (!nodes.has(member.nodeId)) nodes.set(member.nodeId, member); + } + return { boxes: [...boxes.values()], nodes: [...nodes.values()] }; +} + +/** The nodes a group at this box carries – the centre rule, against what is drawn right now. */ +function nodeMembers(graph: Graph, nodeBoxes: readonly Box[], box: AnnotationBox): NodeSeed[] { + const ids = new Set(boxesCentredIn(nodeBoxes, box)); + return graph.nodes.filter((n) => ids.has(n.id)).map((n) => ({ nodeId: n.id, start: { x: n.x, y: n.y } })); +} + +/** Groups and comments inside a group: the outer one takes them, and with them their own nodes. */ +function nestedMembers(graph: Graph, selfId: string, box: AnnotationBox): BoxSeed[] { + const inside: BoxSeed[] = []; + for (const kind of ANNOTATION_KINDS) { + const list = annotationsOf(graph, kind).filter((a) => a.id !== selfId); + const ids = new Set( + boxesCentredIn( + list.map((a) => ({ id: a.id, ...boxOf(a) })), + box, + ), + ); + for (const a of list) if (ids.has(a.id)) inside.push({ id: a.id, kind, start: boxOf(a) }); + } + return inside; +} + +/** + * Puts every traveller where the gesture has it now, for the preview. `offsetFor` answers per start + * position, because with snapping on each thing lands on the grid for itself, as a node does. + */ +export function placeTravellers(graph: Graph, along: Travellers, offsetFor: (start: Point) => Point): void { + for (const member of along.nodes) { + const node = graph.nodes.find((n) => n.id === member.nodeId); + if (!node) continue; + const offset = offsetFor(member.start); + node.x = member.start.x + offset.x; + node.y = member.start.y + offset.y; + } + for (const member of along.boxes) { + const box = findAnnotation(graph, member.id)?.annotation; + if (!box) continue; + const offset = offsetFor(member.start); + box.x = member.start.x + offset.x; + box.y = member.start.y + offset.y; + } +} + +/** + * The commands that take everything from where the drag found it to where the preview left it. + * The preview has already moved the model; each thing is put back first so the command records the + * start, and the caller runs them as one step. `label` names a box command that is not a plain move. + */ +export function recordTravellers( + graph: Graph, + assetId: string, + along: Travellers, + label?: string, +): Command[] { + const commands: Command[] = []; + for (const member of along.boxes) { + const box = findAnnotation(graph, member.id)?.annotation; + if (!box) continue; + const next = boxOf(box); + Object.assign(box, member.start); + commands.push(new SetAnnotationBoxCommand(assetId, member.kind, member.id, next, label)); + } + for (const member of along.nodes) { + const node = graph.nodes.find((n) => n.id === member.nodeId); + if (!node) continue; + const next = { x: node.x, y: node.y }; + node.x = member.start.x; + node.y = member.start.y; + commands.push(new MoveNodeCommand(assetId, member.nodeId, next.x, next.y)); + } + return commands; +} diff --git a/src/editor/sound/SoundPicker.ts b/src/editor/sound/SoundPicker.ts index 480d068d..943efdf1 100644 --- a/src/editor/sound/SoundPicker.ts +++ b/src/editor/sound/SoundPicker.ts @@ -1,247 +1,108 @@ import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; import type { StoredSound } from '../../core/store/SoundStore'; -import { - Button, - drawControlBox, - formatBytes, - HStack, - Label, - Popup, - pressCount, - ScrollContainer, - type Size, - Spacer, - Theme, - textStyle, - truncate, - Widget, -} from '../../ui'; +import { formatBytes, textStyle, truncate } from '../../ui'; import { soundLibrary } from '../../viewport/SoundLibrary'; +import { + currentPicker, + FilePicker, + openPicker, + type PickerItem, + type PickerRow, + RowList, + withAssetNames, +} from '../FilePicker'; import { type SoundImportDeps, importStoredSound, pickAndImportSounds } from '../soundImport'; -/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */ -const LIST_WIDTH = 560; -const LIST_HEIGHT = 340; const ROW_HEIGHT = 44; -const ROW_GAP = 4; const ROW_PAD = 8; /** The play glyph's column at the left of a row: a press in here plays instead of selecting. */ const PLAY_WIDTH = 34; +const IDLE_HINT = 'Press ▶ to hear one.'; -/** One offered sound, with the asset that already uses it (if any). */ -interface PickerItem extends StoredSound { - assetName: string | null; +type SoundItem = PickerItem; +interface SoundRow extends PickerRow { + play: Text; + detail: Text; } /** - * The rows. A sound has nothing to show, so each row shows what is known about the file and plays - * it on demand: the ▶ at its left is the preview, the rest of the row is the pick. + * A sound has nothing to show, so each row shows what is known about the file and plays it on + * demand: the ▶ at its left is the preview, the rest of the row is the pick. */ -class SoundList extends Widget { - private items: PickerItem[] = []; - private rows: { box: Container; background: Graphics; play: Text; caption: Text; detail: Text }[] = []; - private selectedFile: string | null = null; - private readonly empty = new Text({ text: '', style: textStyle('muted') }); - onPick?: (file: string) => void; - onActivate?: (file: string) => void; - emptyText = ''; +class SoundList extends RowList { + protected readonly rowHeight = ROW_HEIGHT; /** The last file a ▶ started, so a check can tell a preview from a pick (test hook). */ lastPlayed: string | null = null; - constructor() { - super(); - this.addChild(this.empty); - this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e)); - } - - setItems(items: PickerItem[]): void { - this.items = items; - for (const row of this.rows) row.box.destroy({ children: true }); - this.rows = items.map(() => { - // A Container holding the background, not a Graphics with children: Pixi v8 only lets - // containers have them (`addChild: Only Containers will be allowed to add children`). - const box = new Container(); - const background = new Graphics(); - const play = new Text({ text: '▶', style: textStyle('accent') }); - const caption = new Text({ style: textStyle('body') }); - const detail = new Text({ style: textStyle('small') }); - box.addChild(background, play, caption, detail); - this.addChild(box); - return { box, background, play, caption, detail }; - }); - if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null; - this.layout(); - this.redraw(); - } - - get selected(): PickerItem | null { - return this.items.find((item) => item.file === this.selectedFile) ?? null; - } - - get fileList(): string[] { - return this.items.map((item) => item.file); - } - - get usedFiles(): string[] { - return this.items.filter((item) => item.assetName !== null).map((item) => item.file); - } - - /** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */ - get captions(): string[] { - return this.rows.map((row) => row.caption.text); - } - - select(file: string | null): void { - if (file === this.selectedFile) return; - this.selectedFile = file; - this.redraw(); - if (file) this.onPick?.(file); - } - - /** Global centre of a row, clear of the ▶ column; null when the file is not offered (test hook). */ - rowGlobal(file: string): { x: number; y: number } | null { - const index = this.items.findIndex((item) => item.file === file); - const row = this.rows[index]; - if (!row) return null; - const p = row.box.getGlobalPosition(); - return { x: p.x + PLAY_WIDTH + (this.w - PLAY_WIDTH) / 2, y: p.y + ROW_HEIGHT / 2 }; - } - /** Global centre of a row's ▶ – what a check presses to hear one without choosing it. */ playGlobal(file: string): { x: number; y: number } | null { - const index = this.items.findIndex((item) => item.file === file); - const row = this.rows[index]; - if (!row) return null; - const p = row.box.getGlobalPosition(); + const found = this.rowOf(file); + if (!found) return null; + const p = found.row.box.getGlobalPosition(); return { x: p.x + PLAY_WIDTH / 2, y: p.y + ROW_HEIGHT / 2 }; } - override measure(): Size { - return { w: this.minWidth, h: Math.max(this.minHeight, heightFor(this.items.length)) }; + protected buildRow(): SoundRow { + const box = new Container(); + const background = new Graphics(); + const play = new Text({ text: '▶', style: textStyle('accent') }); + const caption = new Text({ style: textStyle('body') }); + const detail = new Text({ style: textStyle('small') }); + box.addChild(background, play, caption, detail); + return { box, background, play, caption, detail }; } - override layout(): void { - this.rows.forEach((row, index) => row.box.position.set(0, index * (ROW_HEIGHT + ROW_GAP))); + protected redrawRow(row: SoundRow, item: SoundItem, selected: boolean): void { + this.drawBox(row, this.w, ROW_HEIGHT, selected); + row.play.position.set(ROW_PAD + 2, (ROW_HEIGHT - row.play.height) / 2); + + const width = this.w - PLAY_WIDTH - ROW_PAD; + row.caption.style = textStyle(item.assetName ? 'accent' : 'body'); + row.caption.text = truncate(item.assetName ?? item.name, row.caption.style, width); + row.caption.position.set(PLAY_WIDTH, 6); + + const detail = + item.assetName && item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes); + row.detail.text = truncate(detail, row.detail.style, width); + row.detail.position.set(PLAY_WIDTH, ROW_HEIGHT - 16); } - override redraw(): void { - this.empty.text = this.items.length === 0 ? this.emptyText : ''; - this.empty.position.set(0, 0); - - this.rows.forEach((node, index) => { - const item = this.items[index]; - if (!item) return; - const selected = item.file === this.selectedFile; - node.background.clear(); - drawControlBox(node.background, this.w, ROW_HEIGHT, { - fill: selected ? Theme.colors.selection : Theme.colors.control, - border: selected ? Theme.colors.accent : Theme.colors.border, - }); - node.play.position.set(ROW_PAD + 2, (ROW_HEIGHT - node.play.height) / 2); - - // The asset name wins where there is one – it is what the project calls this sound. Otherwise - // the name it was uploaded under, which is all a bare hash leaves to go on. - node.caption.style = textStyle(item.assetName ? 'accent' : 'body'); - node.caption.text = truncate( - item.assetName ?? item.name, - node.caption.style, - this.w - PLAY_WIDTH - ROW_PAD, - ); - node.caption.position.set(PLAY_WIDTH, 6); - - node.detail.text = truncate( - item.assetName && item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes), - node.detail.style, - this.w - PLAY_WIDTH - ROW_PAD, - ); - node.detail.position.set(PLAY_WIDTH, ROW_HEIGHT - 16); - }); - } - - private onPress(e: FederatedPointerEvent): void { + protected override onPress(e: FederatedPointerEvent): void { const local = this.toLocal(e.global); - const index = Math.floor(local.y / (ROW_HEIGHT + ROW_GAP)); - const item = local.x >= 0 && local.x <= this.w ? this.items[index] : undefined; - if (!item) return; - if (local.x < PLAY_WIDTH) { + const item = this.items[this.indexAt(local.x, local.y)]; + if (item && local.x < PLAY_WIDTH) { this.lastPlayed = item.file; soundLibrary.play(item.file, 1, false); return; } - this.select(item.file); - if (pressCount(e, item.file) === 2) this.onActivate?.(item.file); + super.onPress(e); } } -function heightFor(count: number): number { - return count === 0 ? 0 : count * ROW_HEIGHT + (count - 1) * ROW_GAP; -} - -/** - * Offers every sound the server holds, the way the font picker offers typefaces: uploading the same - * file twice is wasteful, and nothing collects the files a project stopped referencing. - */ -export class SoundPicker extends Popup { - private readonly list = new SoundList(); - private readonly scroll = new ScrollContainer(); - private readonly status: Label; - private readonly detail: Label; - private readonly use: Button; +export class SoundPicker extends FilePicker { + declare protected readonly list: SoundList; constructor(private readonly deps: SoundImportDeps) { - super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap }); - this.content.add(new Label('Choose a sound', { style: 'bodyBold' })); - this.status = new Label('Loading sounds…', { style: 'muted' }); - this.content.add(this.status); - - this.list.minWidth = LIST_WIDTH; - this.list.emptyText = 'No sounds on the server yet – upload one.'; - this.list.onPick = () => this.refreshSelection(); - this.list.onActivate = () => this.confirm(); - this.scroll.minWidth = LIST_WIDTH; - this.scroll.minHeight = LIST_HEIGHT; - this.scroll.setContent(this.list); - this.content.add(this.scroll); - - this.detail = new Label('Press ▶ to hear one.', { style: 'muted' }); - this.content.add(this.detail); - - const buttons = new HStack({ gap: Theme.gap, align: 'center' }); - const upload = new Button('Upload…'); - upload.onClick = this.closeThen(() => pickAndImportSounds(this.deps)); - buttons.add(upload); - buttons.add(new Spacer(), 1); - const cancel = new Button('Cancel'); - cancel.onClick = this.closeThen(); - buttons.add(cancel); - this.use = new Button('Use sound', { variant: 'primary' }); - this.use.onClick = () => this.confirm(); - this.use.setEnabled(false); - buttons.add(this.use); - this.content.add(buttons); - - void this.loadList(); + super( + new SoundList(), + { + title: 'Choose a sound', + loading: 'Loading sounds…', + idle: IDLE_HINT, + empty: 'No sounds on the server yet – upload one.', + use: 'Use sound', + noun: 'sound', + }, + () => pickAndImportSounds(deps), + ); } - /** What the dialog offers, which of it the project already uses, and what is picked (test hook). */ - debugInfo(): { - files: string[]; - used: string[]; - captions: string[]; - selected: string | null; - played: string | null; - } { - return { - files: this.list.fileList, - used: this.list.usedFiles, - captions: this.list.captions, - selected: this.list.selected?.file ?? null, - played: this.list.lastPlayed, - }; + override debugInfo(): ReturnType['debugInfo']> & { played: string | null } { + return { ...super.debugInfo(), played: this.list.lastPlayed }; } rowGlobal(file: string): { x: number; y: number } | null { - return this.list.rowGlobal(file); + return this.list.rowGlobal(file, PLAY_WIDTH); } playGlobal(file: string): { x: number; y: number } | null { @@ -250,64 +111,27 @@ export class SoundPicker extends Popup { override close(): void { super.close(); - if (current === this) current = null; // A preview left running would outlive the dialog that started it, with nothing left to stop it. soundLibrary.stopAll(); } - private async loadList(): Promise { - try { - const stored = await this.deps.sounds.list(); - const named = new Map(); - for (const asset of this.deps.state.project.assets) { - if (asset.type === 'sound') named.set(asset.file, asset.name); - } - this.list.setItems(stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null }))); - this.scroll.minHeight = Math.min( - this.roomFor(this.scroll, ROW_HEIGHT), - Math.max(ROW_HEIGHT, heightFor(stored.length)), - ); - this.status.setText( - stored.length === 1 ? '1 sound on the server' : `${stored.length} sounds on the server`, - ); - } catch (error) { - this.status.setText( - `Could not list the sounds: ${error instanceof Error ? error.message : String(error)}`, - ); - } - this.refreshSelection(); + protected async fetchItems(): Promise { + return withAssetNames(await this.deps.sounds.list(), this.deps.state.project, 'sound'); } - private refreshSelection(): void { - const item = this.list.selected; - this.use.setEnabled(item !== null); - this.detail.setText(item ? describe(item) : 'Press ▶ to hear one.'); - this.refreshLayout(); + protected contentHeight(count: number): { unit: number; total: number } { + return { unit: ROW_HEIGHT, total: this.list.heightFor(count) }; } - private confirm(): void { - const item = this.list.selected; - if (!item) return; - this.close(); + protected adopt(item: SoundItem): void { importStoredSound(item, this.deps); } } -function describe(item: PickerItem): string { - const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes); - return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size; -} - -let current: SoundPicker | null = null; - export function openSoundPicker(deps: SoundImportDeps): SoundPicker { - current?.close(); - current = new SoundPicker(deps); - current.openCentered(); - return current; + return openPicker(new SoundPicker(deps)); } -/** The open dialog, for the test hook. */ export function currentSoundPicker(): SoundPicker | null { - return current; + return currentPicker(SoundPicker); } diff --git a/src/editor/soundImport.ts b/src/editor/soundImport.ts index 0eb58c56..939282e8 100644 --- a/src/editor/soundImport.ts +++ b/src/editor/soundImport.ts @@ -1,9 +1,7 @@ import { CreateSoundCommand } from '../core/commands/assetCommands'; -import { nextAssetName } from '../core/commands/naming'; -import type { SoundAsset } from '../core/model/Asset'; import type { SoundStore, UploadedSound } from '../core/store/SoundStore'; +import { type ImportKind, type ImportOutcome, importFiles, importStored, pickAndImport } from './assetImport'; import type { EditorState } from './state'; -import { type ImportOutcome, assetNameForFile, importNotice } from './textureImport'; /** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */ export const SOUND_ACCEPT = '.mp3,.ogg,.wav,.m4a'; @@ -23,83 +21,27 @@ export interface SoundImportDeps { sounds: SoundStore; } -/** Sequential like `importImages`, so each asset is named against the project as it then stands. */ -export async function importSounds(files: readonly File[], deps: SoundImportDeps): Promise { - if (files.length === 0) return []; - const outcomes: ImportOutcome[] = []; - for (const file of files) outcomes.push(await importSound(file, deps)); - const created = outcomes.filter((outcome) => outcome.assetId !== null); - const last = created[created.length - 1]; - if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId }); - deps.state.setNotice(importNotice(outcomes, 'sound')); - return outcomes; +const SOUND_KIND: ImportKind = { + type: 'sound', + noun: 'sound', + fallbackName: 'Sound', + store: (deps) => deps.sounds, + // The bytes go up untouched: nothing here could encode audio, and a re-encode would be a loss. + prepare: (file) => + isSoundFileName(file.name) + ? Promise.resolve({ blob: file, type: file.type || 'application/octet-stream' }) + : Promise.reject(new Error('Not a sound file – use MP3, OGG, WAV or M4A.')), + create: (name, sound) => new CreateSoundCommand(name, sound.file), +}; + +export function importSounds(files: readonly File[], deps: SoundImportDeps): Promise { + return importFiles(SOUND_KIND, files, deps); } -async function importSound(file: File, deps: SoundImportDeps): Promise { - try { - if (!isSoundFileName(file.name)) throw new Error('Not a sound file – use MP3, OGG, WAV or M4A.'); - // The bytes go up untouched: nothing here could encode audio, and a re-encode would be a loss. - const uploaded = await deps.sounds.upload(file, file.type || 'application/octet-stream', file.name); - return adopt(uploaded, assetNameForFile(file.name, 'Sound'), file.name, deps); - } catch (error) { - return { - name: file.name, - assetId: null, - error: error instanceof Error ? error.message : String(error), - }; - } -} - -/** Makes an asset for a sound the server holds, or reuses the one already naming those bytes. */ -function adopt(sound: UploadedSound, nameBase: string, label: string, deps: SoundImportDeps): ImportOutcome { - const existing = deps.state.project.assets.find( - (asset): asset is SoundAsset => asset.type === 'sound' && asset.file === sound.file, - ); - if (existing) return { name: label, assetId: existing.id, error: null, reused: true }; - - const name = nextAssetName(deps.state.project, nameBase); - const command = new CreateSoundCommand(name, sound.file); - // A rejected command has already put its reason into the notice; that is the message here. - if (!deps.state.execute(command)) return { name: label, assetId: null, error: deps.state.notice }; - return { name: label, assetId: command.asset.id, error: null, reused: false }; -} - -/** - * Adopts a sound the server already holds – no upload, the bytes are there. Chosen in the picker, - * so this is "+ → Sound" and the asset is selected the way a created one would be. - */ export function importStoredSound(sound: UploadedSound, deps: SoundImportDeps): ImportOutcome { - const outcome = adopt(sound, assetNameForFile(sound.name, 'Sound'), sound.name || sound.file, deps); - if (outcome.assetId) deps.state.select({ kind: 'asset', assetId: outcome.assetId }); - deps.state.setNotice( - outcome.error ?? - (outcome.reused ? 'That sound is already in the project.' : 'Added the sound to the project.'), - ); - return outcome; -} - -/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */ -export function pickSoundFiles(): Promise { - return new Promise((resolve) => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = SOUND_ACCEPT; - input.multiple = true; - input.style.display = 'none'; - document.body.appendChild(input); - const finish = (files: File[]) => { - input.remove(); - resolve(files); - }; - input.addEventListener('change', () => finish([...(input.files ?? [])])); - // Without `cancel` a dismissed dialog leaves both the element and this promise behind. - input.addEventListener('cancel', () => finish([])); - input.click(); - }); + return importStored(SOUND_KIND, sound, deps); } export function pickAndImportSounds(deps: SoundImportDeps): void { - void pickSoundFiles() - .then((files) => importSounds(files, deps)) - .catch((error: unknown) => deps.state.setNotice(String(error), 'error')); + pickAndImport(SOUND_KIND, SOUND_ACCEPT, deps); } diff --git a/src/editor/texture/ImagePicker.ts b/src/editor/texture/ImagePicker.ts index 6f04f93c..f312caee 100644 --- a/src/editor/texture/ImagePicker.ts +++ b/src/editor/texture/ImagePicker.ts @@ -1,108 +1,46 @@ -import { Container, type FederatedPointerEvent, Graphics, Sprite, Text } from 'pixi.js'; +import { Container, Graphics, Sprite, Text } from 'pixi.js'; import type { StoredTexture } from '../../core/store/TextureStore'; -import { - Button, - drawControlBox, - formatBytes, - HStack, - Label, - Popup, - pressCount, - ScrollContainer, - type Size, - Spacer, - Theme, - textStyle, - truncate, - Widget, -} from '../../ui'; +import { formatBytes, type Size, textStyle, truncate } from '../../ui'; import { textureLibrary } from '../../viewport/TextureLibrary'; -import { importStoredImage, pickAndImport, type TextureImportDeps } from '../textureImport'; +import { + currentPicker, + FilePicker, + openPicker, + PICKER_WIDTH, + PickerList, + type PickerItem, + type PickerRow, + withAssetNames, +} from '../FilePicker'; +import { importStoredImage, pickAndImportImages, type TextureImportDeps } from '../textureImport'; import { CAPTION_HEIGHT, containRect, gridMetrics, type GridMetrics, tileAt, tileRect } from './imageGrid'; -/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */ -const GRID_WIDTH = 560; -const GRID_HEIGHT = 340; /** Inset of the picture inside its tile, so the selection frame stays visible around it. */ const TILE_PAD = 4; -/** One offered picture, with the asset that already wears it (if any). */ -interface PickerItem extends StoredTexture { - assetName: string | null; +type ImageItem = PickerItem; +interface Tile extends PickerRow { + sprite: Sprite; } /** - * The tiles. Pictures come from the shared `textureLibrary`, which keys by file name, so a - * thumbnail of a picture the scene already draws costs no second upload to the GPU. + * Pictures come from the shared `textureLibrary`, which keys by file name, so a thumbnail of a + * picture the scene already draws costs no second upload to the GPU. */ -class ImageGrid extends Widget { - private items: PickerItem[] = []; - private tiles: { box: Container; background: Graphics; sprite: Sprite; caption: Text }[] = []; - private metrics: GridMetrics = gridMetrics(GRID_WIDTH, 0); - private selectedFile: string | null = null; - private readonly empty = new Text({ text: '', style: textStyle('muted') }); - private readonly stopWatching: () => void; - onPick?: (file: string) => void; - onActivate?: (file: string) => void; - emptyText = ''; +class ImageGrid extends PickerList { + private metrics: GridMetrics = gridMetrics(PICKER_WIDTH, 0); constructor() { super(); - this.addChild(this.empty); - this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e)); - this.stopWatching = textureLibrary.onChange(() => this.invalidate()); - } - - setItems(items: PickerItem[]): void { - this.items = items; - for (const tile of this.tiles) tile.box.destroy({ children: true }); - this.tiles = items.map(() => { - // A Container holding the background, not a Graphics with children: Pixi v8 only lets - // containers have them (`addChild: Only Containers will be allowed to add children`). - const box = new Container(); - const background = new Graphics(); - const sprite = new Sprite(); - const caption = new Text({ style: textStyle('small') }); - box.addChild(background, sprite, caption); - this.addChild(box); - return { box, background, sprite, caption }; - }); - if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null; - this.layout(); - this.redraw(); - } - - get selected(): PickerItem | null { - return this.items.find((item) => item.file === this.selectedFile) ?? null; - } - - get fileList(): string[] { - return this.items.map((item) => item.file); - } - - get usedFiles(): string[] { - return this.items.filter((item) => item.assetName !== null).map((item) => item.file); - } - - /** The captions as drawn, read off the tiles themselves rather than recomputed (test hook). */ - get captions(): string[] { - return this.tiles.map((tile) => tile.caption.text); - } - - select(file: string | null): void { - if (file === this.selectedFile) return; - this.selectedFile = file; - this.redraw(); - if (file) this.onPick?.(file); + this.watch(textureLibrary, () => this.invalidate()); } /** Global centre of a tile; null when the file is not offered (test hook). */ tileGlobal(file: string): { x: number; y: number } | null { - const index = this.items.findIndex((item) => item.file === file); - const tile = this.tiles[index]; - if (!tile) return null; - const rect = tileRect(index, this.metrics); - const p = tile.box.getGlobalPosition(); + const found = this.rowOf(file); + if (!found) return null; + const rect = tileRect(found.index, this.metrics); + const p = found.row.box.getGlobalPosition(); return { x: p.x + rect.w / 2, y: p.y + this.metrics.tile / 2 }; } @@ -115,190 +53,101 @@ class ImageGrid extends Widget { override layout(): void { this.metrics = gridMetrics(this.w, this.items.length); - this.tiles.forEach((tile, index) => { + this.rows.forEach((tile, index) => { const rect = tileRect(index, this.metrics); tile.box.position.set(rect.x, rect.y); }); } - override redraw(): void { - const { tile: side } = this.metrics; + protected buildRow(): Tile { + const box = new Container(); + const background = new Graphics(); + const sprite = new Sprite(); + const caption = new Text({ style: textStyle('small') }); + box.addChild(background, sprite, caption); + return { box, background, sprite, caption }; + } + + protected indexAt(x: number, y: number): number { + return tileAt(x, y, this.items.length, this.metrics); + } + + protected redrawRow(tile: Tile, item: ImageItem, selected: boolean): void { + const side = this.metrics.tile; const inner = side - TILE_PAD * 2; - this.empty.text = this.items.length === 0 ? this.emptyText : ''; - this.empty.position.set(0, 0); + this.drawBox(tile, side, side, selected); - this.tiles.forEach((node, index) => { - const item = this.items[index]; - if (!item) return; - const selected = item.file === this.selectedFile; - node.background.clear(); - drawControlBox(node.background, side, side, { - fill: selected ? Theme.colors.selection : Theme.colors.control, - border: selected ? Theme.colors.accent : Theme.colors.border, - }); + const texture = item.width > 0 ? textureLibrary.load(item.file) : null; + tile.sprite.visible = texture !== null; + if (texture) { + const fitted = containRect(item.width, item.height, inner, inner); + tile.sprite.texture = texture; + tile.sprite.position.set(TILE_PAD + fitted.x, TILE_PAD + fitted.y); + tile.sprite.width = fitted.w; + tile.sprite.height = fitted.h; + } - const texture = item.width > 0 ? textureLibrary.load(item.file) : null; - node.sprite.visible = texture !== null; - if (texture) { - const fitted = containRect(item.width, item.height, inner, inner); - node.sprite.texture = texture; - node.sprite.position.set(TILE_PAD + fitted.x, TILE_PAD + fitted.y); - node.sprite.width = fitted.w; - node.sprite.height = fitted.h; - } - - // The asset name is the useful caption where there is one, then the name the file was - // uploaded under; a bare hash tells nobody anything, and the size is the last resort. - const text = - item.width > 0 ? (item.assetName ?? (item.name || `${item.width} × ${item.height}`)) : 'unreadable'; - node.caption.style = textStyle(item.assetName ? 'accent' : 'muted'); - node.caption.text = truncate(text, node.caption.style, side); - node.caption.position.set(0, side + 1); - }); - } - - override destroy(): void { - this.stopWatching(); - super.destroy({ children: true }); - } - - private onPress(e: FederatedPointerEvent): void { - const local = this.toLocal(e.global); - const index = tileAt(local.x, local.y, this.items.length, this.metrics); - const item = this.items[index]; - if (!item) return; - this.select(item.file); - if (pressCount(e, item.file) === 2) this.onActivate?.(item.file); + // The asset name is the useful caption where there is one, then the name the file was + // uploaded under; a bare hash tells nobody anything, and the size is the last resort. + const text = + item.width > 0 ? (item.assetName ?? (item.name || `${item.width} × ${item.height}`)) : 'unreadable'; + tile.caption.style = textStyle(item.assetName ? 'accent' : 'muted'); + tile.caption.text = truncate(text, tile.caption.style, side); + tile.caption.position.set(0, side + 1); } } -/** - * Offers every picture the server holds, rather than the machine's file system: uploading the - * same image twice is wasteful, and nothing collects the files a project stopped referencing. - * Uploading is still one button away, for a picture the server does not have yet. - */ -export class ImagePicker extends Popup { - private readonly grid = new ImageGrid(); - private readonly scroll = new ScrollContainer(); - private readonly status: Label; - private readonly detail: Label; - private readonly use: Button; +export class ImagePicker extends FilePicker { + declare protected readonly list: ImageGrid; constructor(private readonly deps: TextureImportDeps) { - super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap }); - this.content.add(new Label('Choose an image', { style: 'bodyBold' })); - this.status = new Label('Loading images…', { style: 'muted' }); - this.content.add(this.status); - - this.grid.minWidth = GRID_WIDTH; - this.grid.emptyText = 'No images on the server yet – upload one.'; - this.grid.onPick = () => this.refreshSelection(); - this.grid.onActivate = () => this.confirm(); - this.scroll.minWidth = GRID_WIDTH; - this.scroll.minHeight = GRID_HEIGHT; - this.scroll.setContent(this.grid); - this.content.add(this.scroll); - - this.detail = new Label('', { style: 'muted' }); - this.content.add(this.detail); - - const buttons = new HStack({ gap: Theme.gap, align: 'center' }); - const upload = new Button('Upload…'); - upload.onClick = this.closeThen(() => pickAndImport(this.deps)); - buttons.add(upload); - buttons.add(new Spacer(), 1); - const cancel = new Button('Cancel'); - cancel.onClick = this.closeThen(); - buttons.add(cancel); - this.use = new Button('Use image', { variant: 'primary' }); - this.use.onClick = () => this.confirm(); - this.use.setEnabled(false); - buttons.add(this.use); - this.content.add(buttons); - - void this.loadList(); - } - - /** What the dialog offers, which of it the project already wears, and what is picked (test hook). */ - debugInfo(): { files: string[]; used: string[]; captions: string[]; selected: string | null } { - return { - files: this.grid.fileList, - used: this.grid.usedFiles, - captions: this.grid.captions, - selected: this.grid.selected?.file ?? null, - }; + super( + new ImageGrid(), + { + title: 'Choose an image', + loading: 'Loading images…', + empty: 'No images on the server yet – upload one.', + use: 'Use image', + noun: 'image', + }, + () => pickAndImportImages(deps), + ); } tileGlobal(file: string): { x: number; y: number } | null { - return this.grid.tileGlobal(file); + return this.list.tileGlobal(file); } - override close(): void { - super.close(); - if (current === this) current = null; - // Escape, Cancel and a confirmed pick all land here, and `Popup.close` tolerates a second - // call; the grid's listener must be dropped exactly once. - if (!this.grid.destroyed) this.grid.destroy(); + protected async fetchItems(): Promise { + return withAssetNames(await this.deps.textures.list(), this.deps.state.project, 'texture'); } - private async loadList(): Promise { - try { - const stored = await this.deps.textures.list(); - const named = new Map(); - for (const asset of this.deps.state.project.assets) { - if (asset.type === 'texture') named.set(asset.file, asset.name); - } - this.grid.setItems(stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null }))); - // Only as tall as the tiles need, up to the cap: a handful of pictures should not leave - // half a dialog of empty box under them. - const metrics = gridMetrics(GRID_WIDTH, stored.length); - this.scroll.minHeight = Math.min( - this.roomFor(this.scroll, metrics.tile + CAPTION_HEIGHT), - Math.max(metrics.tile + CAPTION_HEIGHT, metrics.height), - ); - this.status.setText( - stored.length === 1 ? '1 image on the server' : `${stored.length} images on the server`, - ); - } catch (error) { - this.status.setText( - `Could not list the images: ${error instanceof Error ? error.message : String(error)}`, - ); - } - this.refreshSelection(); + protected contentHeight(count: number): { unit: number; total: number } { + const metrics = gridMetrics(PICKER_WIDTH, count); + return { unit: metrics.tile + CAPTION_HEIGHT, total: metrics.height }; } - private refreshSelection(): void { - const item = this.grid.selected; - this.use.setEnabled(item !== null && item.width > 0); - this.detail.setText(item ? describe(item) : ''); - this.refreshLayout(); + /** A file the server could not read as an image has no size and cannot become an asset. */ + protected override canUse(item: ImageItem): boolean { + return item.width > 0; } - private confirm(): void { - const item = this.grid.selected; - if (!item || item.width <= 0) return; - this.close(); + protected override describe(item: ImageItem): string { + if (item.width <= 0) return 'The server cannot read this file as an image.'; + const measurements = `${item.width} × ${item.height} px, ${formatBytes(item.bytes)}`; + const size = item.name ? `${item.name} – ${measurements}` : measurements; + return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size; + } + + protected adopt(item: ImageItem): void { importStoredImage(item, this.deps); } } -function describe(item: PickerItem): string { - if (item.width <= 0) return 'The server cannot read this file as an image.'; - const measurements = `${item.width} × ${item.height} px, ${formatBytes(item.bytes)}`; - const size = item.name ? `${item.name} – ${measurements}` : measurements; - return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size; -} - -let current: ImagePicker | null = null; - export function openImagePicker(deps: TextureImportDeps): ImagePicker { - current?.close(); - current = new ImagePicker(deps); - current.openCentered(); - return current; + return openPicker(new ImagePicker(deps)); } -/** The open dialog, for the test hook. */ export function currentImagePicker(): ImagePicker | null { - return current; + return currentPicker(ImagePicker); } diff --git a/src/editor/textureImport.ts b/src/editor/textureImport.ts index 2315cf92..36c3862f 100644 --- a/src/editor/textureImport.ts +++ b/src/editor/textureImport.ts @@ -1,8 +1,7 @@ import { CreateTextureCommand } from '../core/commands/assetCommands'; -import { nextAssetName } from '../core/commands/naming'; -import type { TextureAsset } from '../core/model/Asset'; import type { TextureStore, UploadedTexture } from '../core/store/TextureStore'; import { formatBytes } from '../ui'; +import { type ImportKind, type ImportOutcome, importFiles, importStored, pickAndImport } from './assetImport'; import type { EditorState } from './state'; /** What the picker offers and a drop accepts; the server rejects everything else anyway. */ @@ -91,145 +90,23 @@ export interface TextureImportDeps { textures: TextureStore; } -/** One file's outcome; exactly one of `assetId` and `error` is set. */ -export interface ImportOutcome { - name: string; - assetId: string | null; - error: string | null; - /** The project already had this exact picture, so its existing asset was used instead. */ - reused?: boolean; +const IMAGE_KIND: ImportKind = { + type: 'texture', + noun: 'image', + fallbackName: 'Texture', + store: (deps) => deps.textures, + prepare: prepareImage, + create: (name, image) => new CreateTextureCommand(name, image), +}; + +export function importImages(files: readonly File[], deps: TextureImportDeps): Promise { + return importFiles(IMAGE_KIND, files, deps); } -/** - * Imports the files sequentially, so each asset is named against the project as the previous one - * left it. A file that fails is reported in the notice; the remaining ones are still imported. - */ -export async function importImages( - files: readonly File[], - deps: TextureImportDeps, -): Promise { - if (files.length === 0) return []; - const outcomes: ImportOutcome[] = []; - for (const file of files) outcomes.push(await importImage(file, deps)); - selectLast(outcomes, deps); - deps.state.setNotice(importNotice(outcomes)); - return outcomes; -} - -async function importImage(file: File, deps: TextureImportDeps): Promise { - try { - const image = await prepareImage(file); - const uploaded = await deps.textures.upload(image.blob, image.type, file.name); - return adopt(uploaded, assetNameForFile(file.name, 'Texture'), file.name, deps); - } catch (error) { - return { name: file.name, assetId: null, error: error instanceof Error ? error.message : String(error) }; - } -} - -/** - * Makes an asset for a file the server holds. `label` names the source in messages, `nameBase` is - * what the asset is called. - */ -function adopt( - image: UploadedTexture, - nameBase: string, - label: string, - deps: TextureImportDeps, -): ImportOutcome { - // The server stores by content hash, so a re-imported picture comes back as the file the - // project already references; two assets on one file would disagree about `filter`. - const existing = deps.state.project.assets.find( - (asset): asset is TextureAsset => asset.type === 'texture' && asset.file === image.file, - ); - if (existing) return { name: label, assetId: existing.id, error: null, reused: true }; - - const name = nextAssetName(deps.state.project, nameBase); - const command = new CreateTextureCommand(name, image); - // A rejected command has already put its reason into the notice; that is the message here. - if (!deps.state.execute(command)) return { name: label, assetId: null, error: deps.state.notice }; - return { name: label, assetId: command.asset.id, error: null, reused: false }; -} - -/** - * Adopts a picture the server already holds – no upload, the bytes are there. Nothing collects - * unreferenced files, so a picture one project dropped is still there for the next to pick up. - */ export function importStoredImage(image: UploadedTexture, deps: TextureImportDeps): ImportOutcome { - // The name it was uploaded under is what the asset is called, the same as for a dropped file; - // a file from before names were kept has none, and falls back to "Texture" as it always did. - const outcome = adopt(image, assetNameForFile(image.name, 'Texture'), image.name || image.file, deps); - // Chosen in the picker, so this is "+ → Texture": the tab opens like for any created asset. A - // pasted or dropped picture (`importImages`) only selects – it usually goes into the scene. - if (outcome.assetId) deps.state.openAsset(outcome.assetId); - deps.state.setNotice( - outcome.error ?? - (outcome.reused ? 'That image is already in the project.' : 'Added the image to the project.'), - ); - return outcome; + return importStored(IMAGE_KIND, image, deps); } -/** Selects the asset the batch created last, so the panel shows what just arrived. */ -function selectLast(outcomes: readonly ImportOutcome[], deps: TextureImportDeps): void { - const created = outcomes.filter((outcome) => outcome.assetId !== null); - const last = created[created.length - 1]; - if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId }); -} - -/** - * One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie. - * `noun` names what was imported – fonts come through the same batch reporting. - */ -export function importNotice(outcomes: readonly ImportOutcome[], noun = 'image'): string { - const added = outcomes.filter((outcome) => outcome.assetId !== null && !outcome.reused).length; - const reused = outcomes.filter((outcome) => outcome.reused).length; - const failed = outcomes.filter((outcome) => outcome.error !== null); - const first = failed[0]; - - const known = reused === 0 ? '' : ` ${reused === 1 ? 'One was' : `${reused} were`} already in the project.`; - - if (!first) { - if (added === 0 && reused > 0) - return reused === 1 - ? `That ${noun} is already in the project.` - : `Those ${reused} ${noun}s are already in the project.`; - return `Imported ${added} ${added === 1 ? noun : `${noun}s`}.${known}`; - } - const more = failed.length > 1 ? ` (and ${failed.length - 1} more)` : ''; - const problem = `Could not import "${first.name}": ${first.error ?? ''}${more}`; - if (added + reused === 0) return problem; - return `Imported ${added + reused} of ${outcomes.length}.${known} ${problem}`; -} - -/** - * A file name without its extension, as an asset is called: "hero-idle.png" reads better than - * "Texture 3". Shared with the picker, which gets the same name back from the server. - */ -export function assetNameForFile(fileName: string, fallback: string): string { - return fileName.replace(/\.[^.]+$/, '').trim() || fallback; -} - -/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */ -export function pickImageFiles(): Promise { - return new Promise((resolve) => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = IMAGE_ACCEPT; - input.multiple = true; - input.style.display = 'none'; - document.body.appendChild(input); - const finish = (files: File[]) => { - input.remove(); - resolve(files); - }; - input.addEventListener('change', () => finish([...(input.files ?? [])])); - // Without `cancel` a dismissed dialog leaves both the element and this promise behind. - input.addEventListener('cancel', () => finish([])); - input.click(); - }); -} - -export function pickAndImport(deps: TextureImportDeps): void { - void pickImageFiles() - .then((files) => importImages(files, deps)) - .catch((error: unknown) => deps.state.setNotice(String(error), 'error')); +export function pickAndImportImages(deps: TextureImportDeps): void { + pickAndImport(IMAGE_KIND, IMAGE_ACCEPT, deps); } diff --git a/src/viewport/ParticleLibrary.ts b/src/viewport/ParticleLibrary.ts index 8cd76ebf..d6bee1a9 100644 --- a/src/viewport/ParticleLibrary.ts +++ b/src/viewport/ParticleLibrary.ts @@ -1,5 +1,6 @@ import type { ParticlesAsset } from '../core/model/Asset'; -import { compileParticles, type ParticlesBuild, particlesFingerprint } from '../core/particles/compile'; +import { graphFingerprint } from '../core/graph/Graph'; +import { compileParticles, type ParticlesBuild } from '../core/particles/compile'; import { gameClock } from '../core/time'; /** @@ -22,12 +23,8 @@ export class ParticleLibrary { this.frozenAt = at; } - get frozen(): number | null { - return this.frozenAt; - } - buildFor(asset: ParticlesAsset): ParticlesBuild { - const fingerprint = particlesFingerprint(asset.graph); + const fingerprint = graphFingerprint(asset.graph); const cached = this.builds.get(asset.id); if (cached && cached.fingerprint === fingerprint) return cached.build; this.compiles += 1; diff --git a/src/viewport/ShaderLibrary.ts b/src/viewport/ShaderLibrary.ts index 0f478a0e..a2e9e9a1 100644 --- a/src/viewport/ShaderLibrary.ts +++ b/src/viewport/ShaderLibrary.ts @@ -1,6 +1,7 @@ import type { ShaderAsset } from '../core/model/Asset'; import { findAssetOf, type Project } from '../core/model/Project'; -import { compileShader, shaderFingerprint, type ShaderBuild } from '../core/shader/compile'; +import { graphFingerprint } from '../core/graph/Graph'; +import { compileShader, type ShaderBuild } from '../core/shader/compile'; import { gameClock } from '../core/time'; import type { ShaderSource } from './shaders'; @@ -25,7 +26,7 @@ export class ShaderLibrary { } buildFor(asset: ShaderAsset): ShaderBuild { - const fingerprint = shaderFingerprint(asset.graph); + const fingerprint = graphFingerprint(asset.graph); const cached = this.builds.get(asset.id); if (cached && cached.fingerprint === fingerprint) return cached.build; this.compiles += 1; diff --git a/src/viewport/shaders.ts b/src/viewport/shaders.ts index 64407626..5257d27c 100644 --- a/src/viewport/shaders.ts +++ b/src/viewport/shaders.ts @@ -110,42 +110,58 @@ function readUniforms( } /** - * A shader asset worn by one sprite mesh: the fragment replaces Pixi's in the sprite's own draw, - * so there is no filter and no extra pass (#77). The program is shared (`GlProgram.from` caches - * by source), the uniforms are this sprite's; `read` takes them from the item's bag. + * A compiled graph as a Pixi program. The program is shared (`GlProgram.from` caches by source), + * the uniforms are this wearer's; `read` takes them from the item's bag. `resources` are the slots + * Pixi fills for the vertex stage in question – `Shader.resources` only accepts the slots its + * constructor declared, so a subclass names them all up front. */ -class GraphShader extends Shader { +abstract class CompiledGraphShader extends Shader { private readonly uniforms: Record | null; private readonly compiled: CompiledShader; readonly fragment: string; - /** What `Mesh.shader` asks of a shader; kept in step with the mesh's own by `wear`. */ - texture: Texture = Texture.WHITE; constructor( readonly assetId: string, compiled: CompiledShader, + program: { vertex: string; name: string }, + resources: Record, ) { const group = uniformGroupOf(compiled); super({ - glProgram: GlProgram.from({ - vertex: SPRITE_VERTEX, - fragment: compiled.fragment, - name: `shader-${assetId}`, - }), - resources: { - uTexture: Texture.WHITE.source, - uSampler: Texture.WHITE.source.style, - ...(group ? { shaderUniforms: group } : {}), - }, + glProgram: GlProgram.from({ vertex: program.vertex, fragment: compiled.fragment, name: program.name }), + resources: { ...resources, ...(group ? { shaderUniforms: group } : {}) }, }); this.fragment = compiled.fragment; this.compiled = compiled; this.uniforms = group ? group.uniforms : null; } + /** True while this is still the shader `props` ask for, so it can be updated instead of rebuilt. */ + wears(id: string, compiled: CompiledShader): boolean { + return this.assetId === id && this.fragment === compiled.fragment; + } + read(props: PropertyBag, now: number): void { if (this.uniforms) readUniforms(this.uniforms, this.compiled, props, now); } +} + +/** + * A shader asset worn by one sprite mesh: the fragment replaces Pixi's in the sprite's own draw, + * so there is no filter and no extra pass (#77). + */ +class GraphShader extends CompiledGraphShader { + /** What `Mesh.shader` asks of a shader; kept in step with the mesh's own by `wear`. */ + texture: Texture = Texture.WHITE; + + constructor(assetId: string, compiled: CompiledShader) { + super( + assetId, + compiled, + { vertex: SPRITE_VERTEX, name: `shader-${assetId}` }, + { uTexture: Texture.WHITE.source, uSampler: Texture.WHITE.source.style }, + ); + } /** Pixi binds a mesh's texture only for its own shader, so a custom one takes it here. */ wear(texture: Texture): void { @@ -168,7 +184,7 @@ export function syncShader(mesh: Mesh, props: PropertyBag, shaders: ShaderSource const id = shaderOf(props); const compiled = id ? shaders.compiled(id) : null; const worn = wornBy(mesh); - if (worn && compiled && worn.assetId === id && worn.fragment === compiled.fragment) { + if (worn && compiled && worn.wears(id, compiled)) { worn.read(props, shaders.now); worn.wear(mesh.texture); return; @@ -195,26 +211,15 @@ export function syncShader(mesh: Mesh, props: PropertyBag, shaders: ShaderSource * The pipe breaks the batch for every field anyway, so unlike a shaded sprite (#77) this costs no * draw call at all: five hundred shaded particles are the one call five hundred plain ones were. */ -class ParticleGraphShader extends Shader { - private readonly uniforms: Record | null; - private readonly compiled: CompiledShader; - readonly fragment: string; - - constructor( - readonly assetId: string, - compiled: CompiledShader, - ) { - const group = uniformGroupOf(compiled); - super({ - glProgram: GlProgram.from({ - vertex: PARTICLE_VERTEX, - fragment: compiled.fragment, - name: `particle-shader-${assetId}`, - }), - // `GlParticleContainerAdaptor` writes the field's texture and the pipe's own uniforms into - // whichever shader the container names – but `Shader.resources` only accepts the slots its - // constructor declared, so both have to be here, empty, for Pixi to fill them. - resources: { +class ParticleGraphShader extends CompiledGraphShader { + constructor(assetId: string, compiled: CompiledShader) { + // `GlParticleContainerAdaptor` writes the field's texture and the pipe's own uniforms into + // whichever shader the container names, so both slots have to exist, empty, for Pixi to fill. + super( + assetId, + compiled, + { vertex: PARTICLE_VERTEX, name: `particle-shader-${assetId}` }, + { uTexture: Texture.WHITE.source, uSampler: new TextureStyle({}), uniforms: { @@ -223,16 +228,8 @@ class ParticleGraphShader extends Shader { uRound: { value: 1, type: 'f32' }, uResolution: { value: [0, 0], type: 'vec2' }, }, - ...(group ? { shaderUniforms: group } : {}), }, - }); - this.fragment = compiled.fragment; - this.compiled = compiled; - this.uniforms = group ? group.uniforms : null; - } - - read(props: PropertyBag, now: number): void { - if (this.uniforms) readUniforms(this.uniforms, this.compiled, props, now); + ); } } @@ -249,7 +246,7 @@ export function syncParticleShader( const id = shaderOf(props); const compiled = id ? shaders.compiled(id) : null; const worn = wornByField(container); - if (worn && compiled && worn.assetId === id && worn.fragment === compiled.fragment) { + if (worn && compiled && worn.wears(id, compiled)) { worn.read(props, shaders.now); return; } diff --git a/tests/core/http-store.test.ts b/tests/core/http-store.test.ts index 539f9347..79512b9f 100644 --- a/tests/core/http-store.test.ts +++ b/tests/core/http-store.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { HttpProjectStore } from '../../src/core/store/HttpProjectStore'; -import { HttpTextureStore } from '../../src/core/store/HttpTextureStore'; +import { HttpFileStore } from '../../src/core/store/HttpFileStore'; import { StaleRevisionError } from '../../src/core/store/ProjectStore'; import { makeProject } from '../helpers'; @@ -46,11 +46,11 @@ describe('HttpProjectStore', () => { }); }); -describe('HttpTextureStore', () => { +describe('HttpFileStore', () => { it('uploads the raw bytes and returns what the server stored', async () => { const mock = stubFetch(JSON.stringify({ file: FILE, width: 2, height: 3, bytes: 4 }), { status: 201 }); const bytes = new Blob([new Uint8Array([1, 2, 3, 4])]); - const stored = await new HttpTextureStore('/api').upload(bytes, 'image/png'); + const stored = await new HttpFileStore('/api', 'textures').upload(bytes, 'image/png'); expect(stored).toEqual({ file: FILE, width: 2, height: 3, bytes: 4 }); const [url, init] = mock.mock.calls[0]!; @@ -66,19 +66,19 @@ describe('HttpTextureStore', () => { const bytes = new Blob([new Uint8Array(4)]); let mock = stubFetch(answer, { status: 201 }); - await new HttpTextureStore('/api').upload(bytes, 'image/png', 'hero idle.png'); + await new HttpFileStore('/api', 'textures').upload(bytes, 'image/png', 'hero idle.png'); // The body stays the raw image, so the name travels in the query – encoded, not as a path. expect(mock.mock.calls[0]![0]).toBe('/api?r=textures&name=hero%20idle.png'); mock = stubFetch(answer, { status: 201 }); - await new HttpTextureStore('/api').upload(bytes, 'image/png'); + await new HttpFileStore('/api', 'textures').upload(bytes, 'image/png'); expect(mock.mock.calls[0]![0]).toBe('/api?r=textures'); }); it('reports the nginx size limit as a size problem', async () => { stubFetch(NGINX_413, { status: 413, statusText: 'Request Entity Too Large' }); const bytes = new Blob([new Uint8Array(8)]); - await expect(new HttpTextureStore('/api').upload(bytes, 'image/png')).rejects.toThrow( + await expect(new HttpFileStore('/api', 'textures').upload(bytes, 'image/png')).rejects.toThrow( /too large \(413\)/i, ); }); @@ -87,12 +87,12 @@ describe('HttpTextureStore', () => { stubFetch(JSON.stringify([{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' }]), { status: 200, }); - await expect(new HttpTextureStore('/api').list()).resolves.toEqual([ + await expect(new HttpFileStore('/api', 'textures').list()).resolves.toEqual([ { file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' }, ]); const mock = stubFetch(null, { status: 204 }); - await new HttpTextureStore('/api').delete(FILE); + await new HttpFileStore('/api', 'textures').delete(FILE); expect(mock.mock.calls[0]![0]).toBe(`/api?r=${encodeURIComponent(`textures/${FILE}`)}`); expect(mock.mock.calls[0]![1]?.method).toBe('DELETE'); }); diff --git a/tests/core/particles.test.ts b/tests/core/particles.test.ts index b5f4d384..139b6904 100644 --- a/tests/core/particles.test.ts +++ b/tests/core/particles.test.ts @@ -8,13 +8,15 @@ import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/comm import { AddNodeCommand, ConnectCommand, MoveNodeCommand } from '../../src/core/commands/graphCommands'; import { isPlaceableType } from '../../src/core/model/AssetTypes'; import { findAssetOf } from '../../src/core/model/Project'; +import { graphFingerprint } from '../../src/core/graph/Graph'; +import { compileParticles, emptyParticleOut, type ParticleOut } from '../../src/core/particles/compile'; import { - compileParticles, - emptyParticleOut, - type ParticleOut, - particlesFingerprint, -} from '../../src/core/particles/compile'; -import { birthTime, fieldTime, liveCount, liveRange, prewarmOffset } from '../../src/core/particles/emission'; + birthTime, + fieldTime, + type LiveRange, + liveRange, + prewarmOffset, +} from '../../src/core/particles/emission'; import { hashId, mixColor, @@ -222,11 +224,11 @@ describe('a particle asset', () => { const output = p.add(PARTICLE_OUTPUT); const n = p.add('ParticleNumber', { value: 2 }); p.link(n, 'value', output, 'x'); - const before = particlesFingerprint(p.asset().graph); + const before = graphFingerprint(p.asset().graph); f.stack.execute(new MoveNodeCommand(p.id, n, 120, 80)); - expect(particlesFingerprint(p.asset().graph)).toBe(before); + expect(graphFingerprint(p.asset().graph)).toBe(before); f.stack.execute(new AddNodeCommand(p.id, 'ParticleNumber', 0, 0, { value: 5 })); - expect(particlesFingerprint(p.asset().graph)).not.toBe(before); + expect(graphFingerprint(p.asset().graph)).not.toBe(before); }); it('survives a round trip through the project file, graph and rows intact', () => { @@ -270,6 +272,8 @@ describe('a particle asset', () => { }); }); +const liveCount = (range: LiveRange): number => Math.max(0, range.to - range.from + 1); + describe('when particles are alive', () => { const settings = { mode: 'continuous' as const, diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 8103720c..7900f65a 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -34,7 +34,8 @@ import { shaderParamDefs, } from '../../src/core/properties/shaders'; import { inlineInputs } from '../../src/core/graph/NodeRegistry'; -import { compileShader, shaderFingerprint } from '../../src/core/shader/compile'; +import { graphFingerprint } from '../../src/core/graph/Graph'; +import { compileShader } from '../../src/core/shader/compile'; import { DEFAULT_EDGE, EDGE_PARAM, @@ -720,16 +721,16 @@ describe('the fingerprint the shader library caches on', () => { const f = makeProject(); const s = shaderEditor(f); const n = buildAdjustColors(s); - const before = shaderFingerprint(s.asset().graph); + const before = graphFingerprint(s.asset().graph); f.stack.execute(new MoveNodeCommand(s.id, n.bright, 300, 200)); f.stack.execute(new CollapseNodesCommand(s.id, [n.bright, n.luma], true)); f.stack.execute(new FoldEdgeCommand(s.id, s.asset().graph.edges[0]!.id, true)); expect(s.asset().graph.nodes.find((x) => x.id === n.bright)).toMatchObject({ x: 300, collapsed: true }); - expect(shaderFingerprint(s.asset().graph)).toBe(before); + expect(graphFingerprint(s.asset().graph)).toBe(before); f.stack.execute(new SetNodeParamCommand(s.id, n.shifted, 'b', 0.25)); - const withParam = shaderFingerprint(s.asset().graph); + const withParam = graphFingerprint(s.asset().graph); expect(withParam).not.toBe(before); s.link(n.brightness, 'value', n.output, 'alpha'); - expect(shaderFingerprint(s.asset().graph)).not.toBe(withParam); + expect(graphFingerprint(s.asset().graph)).not.toBe(withParam); }); }); diff --git a/tests/editor/annotation-drag.test.ts b/tests/editor/annotation-drag.test.ts new file mode 100644 index 00000000..0e1792b7 --- /dev/null +++ b/tests/editor/annotation-drag.test.ts @@ -0,0 +1,88 @@ +/** + * A drag in the graph moves everything it carries exactly once. A group carries the nodes centred + * in it and the boxes inside it; a band may have caught a group *and* one of its nodes – that node + * is claimed twice and must move once, or it would end up twice as far as the rest. + */ +import { describe, expect, it } from 'vitest'; +import type { Graph, GraphNode } from '../../src/core/graph'; +import { + annotationBoxes, + boxSeeds, + placeTravellers, + recordTravellers, + travellers, +} from '../../src/editor/graph/annotationDrag'; +import type { Box } from '../../src/editor/framing'; +import { makeBehavior, makeProject } from '../helpers'; + +const node = (id: string, x: number, y: number): GraphNode => ({ id, type: 'Add', x, y, params: {} }); +const NODE = { w: 100, h: 50 }; +const drawn = (graph: Graph): Box[] => graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y, ...NODE })); + +/** An outer group holding an inner group and a comment; `in` sits in both groups, `far` in neither. */ +function graph(): Graph { + return { + nodes: [node('in', 120, 120), node('edge', 20, 20), node('far', 900, 900)], + edges: [], + groups: [ + { id: 'outer', x: 0, y: 0, w: 400, h: 300, text: 'Outer' }, + { id: 'inner', x: 100, y: 100, w: 150, h: 100, text: 'Inner' }, + ], + notes: [{ id: 'note', x: 300, y: 200, w: 80, h: 60, text: 'Careful' }], + }; +} + +describe('what a group drag carries', () => { + it('takes the nodes centred in it, the boxes inside it, and their nodes – each once', () => { + const g = graph(); + const along = travellers(g, drawn(g), boxSeeds(g, ['outer']), []); + expect(along.boxes.map((b) => b.id).sort()).toEqual(['inner', 'note', 'outer']); + // `edge` is centred at (70, 45), inside; `in` is inside both groups but listed once; `far` is not. + expect(along.nodes.map((n) => n.nodeId).sort()).toEqual(['edge', 'in']); + }); + + it('claims a node once even when the band caught both it and its group', () => { + const g = graph(); + const along = travellers(g, drawn(g), boxSeeds(g, ['outer']), ['in', 'far']); + expect(along.nodes.map((n) => n.nodeId).sort()).toEqual(['edge', 'far', 'in']); + placeTravellers(g, along, () => ({ x: 10, y: 0 })); + expect(g.nodes.map((n) => n.x)).toEqual([130, 30, 910]); + expect(g.groups?.map((b) => b.x)).toEqual([10, 110]); + expect(g.notes?.map((b) => b.x)).toEqual([310]); + }); + + it('carries nothing along for a comment, which frames nothing', () => { + const g = graph(); + const along = travellers(g, drawn(g), boxSeeds(g, ['note']), []); + expect(along.boxes.map((b) => b.id)).toEqual(['note']); + expect(along.nodes).toEqual([]); + }); + + it('leaves out the grabbed box when asked, so it is not seeded twice', () => { + const g = graph(); + expect(boxSeeds(g, ['outer', 'note'], 'outer').map((b) => b.id)).toEqual(['note']); + expect(annotationBoxes(g).map((b) => b.id)).toEqual(['outer', 'inner', 'note']); + }); +}); + +describe('writing a drag back', () => { + it('records one command per traveller from the start to where the preview left it', () => { + const f = makeProject(); + const behavior = makeBehavior(f, 'B'); + const g = behavior.graph; + g.nodes.push(node('a', 0, 0)); + g.groups = [{ id: 'grp', x: -50, y: -50, w: 200, h: 200, text: 'G' }]; + const along = travellers(g, drawn(g), boxSeeds(g, ['grp']), []); + placeTravellers(g, along, () => ({ x: 24, y: 48 })); + + const commands = recordTravellers(g, behavior.id, along); + // The preview is undone in the model, so the commands own the whole change… + expect(g.nodes[0]).toMatchObject({ x: 0, y: 0 }); + expect(g.groups[0]).toMatchObject({ x: -50, y: -50 }); + // …and running them lands everything where the drag ended. + for (const command of commands) command.execute(f.project); + expect(g.nodes[0]).toMatchObject({ x: 24, y: 48 }); + expect(g.groups[0]).toMatchObject({ x: -26, y: -2, w: 200, h: 200 }); + expect(commands).toHaveLength(2); + }); +}); diff --git a/tests/editor/font-import.test.ts b/tests/editor/font-import.test.ts index ddf193d0..c6bfef2b 100644 --- a/tests/editor/font-import.test.ts +++ b/tests/editor/font-import.test.ts @@ -8,7 +8,7 @@ import { createEmptyProject } from '../../src/core/model/Project'; import type { FontStore } from '../../src/core/store/FontStore'; import { importFonts, importStoredFont, isFontFileName } from '../../src/editor/fontImport'; import { EditorState } from '../../src/editor/state'; -import { importNotice } from '../../src/editor/textureImport'; +import { importNotice } from '../../src/editor/assetImport'; const FILE = `${'a'.repeat(64)}.ttf`; const font = { file: FILE, name: '', bytes: 380_660 }; diff --git a/tests/editor/texture-import.test.ts b/tests/editor/texture-import.test.ts index 7b37fa54..2c13a775 100644 --- a/tests/editor/texture-import.test.ts +++ b/tests/editor/texture-import.test.ts @@ -3,11 +3,9 @@ import type { TextureAsset } from '../../src/core/model/Asset'; import { createEmptyProject } from '../../src/core/model/Project'; import type { TextureStore } from '../../src/core/store/TextureStore'; import { EditorState } from '../../src/editor/state'; +import { assetNameForFile, type ImportOutcome, importNotice } from '../../src/editor/assetImport'; import { - assetNameForFile, encodeWithinBudget, - type ImportOutcome, - importNotice, importStoredImage, MAX_TEXTURE_SIDE, MAX_UPLOAD_BYTES,