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 <noreply@anthropic.com>
This commit is contained in:
1 parent
15bdfdf07a
commit
cc6db5f350
42 files changed
+1342
-1617
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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<string> = 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<string>();
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<string>();
|
||||
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` … */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
const problem = (node: GraphNode, text: string) => {
|
||||
|
||||
@@ -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<U extends UploadedFile = UploadedFile, S extends StoredFile = StoredFile> {
|
||||
upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<U>;
|
||||
list(): Promise<S[]>;
|
||||
delete(file: string): Promise<void>;
|
||||
}
|
||||
@@ -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<UploadedFont>;
|
||||
list(): Promise<StoredFont[]>;
|
||||
delete(file: string): Promise<void>;
|
||||
}
|
||||
export type UploadedFont = UploadedFile;
|
||||
export type StoredFont = StoredFile;
|
||||
export type FontStore = FileStore<UploadedFont, StoredFont>;
|
||||
@@ -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<U, S> {
|
||||
constructor(
|
||||
private readonly endpoint: string,
|
||||
private readonly route: FileRoute,
|
||||
) {}
|
||||
|
||||
async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise<U> {
|
||||
return apiRequest<U>(this.endpoint, 'POST', this.route, {
|
||||
headers: { 'Content-Type': type },
|
||||
body: bytes,
|
||||
query: { name },
|
||||
});
|
||||
}
|
||||
|
||||
async list(): Promise<S[]> {
|
||||
return apiRequest<S[]>(this.endpoint, 'GET', this.route, {});
|
||||
}
|
||||
|
||||
async delete(file: string): Promise<void> {
|
||||
await apiRequest<void>(this.endpoint, 'DELETE', `${this.route}/${encodeURIComponent(file)}`, {});
|
||||
}
|
||||
}
|
||||
@@ -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<UploadedFont> {
|
||||
return this.request<UploadedFont>('POST', 'fonts', { bytes, type, name });
|
||||
}
|
||||
|
||||
async list(): Promise<StoredFont[]> {
|
||||
return this.request<StoredFont[]>('GET', 'fonts');
|
||||
}
|
||||
|
||||
async delete(file: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `fonts/${encodeURIComponent(file)}`);
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
route: string,
|
||||
payload?: { bytes: Blob | ArrayBuffer; type: string; name: string },
|
||||
): Promise<T> {
|
||||
return apiRequest<T>(this.endpoint, method, route, {
|
||||
headers: payload ? { 'Content-Type': payload.type } : {},
|
||||
body: payload?.bytes,
|
||||
query: payload ? { name: payload.name } : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<UploadedSound> {
|
||||
return this.request<UploadedSound>('POST', 'sounds', { bytes, type, name });
|
||||
}
|
||||
|
||||
async list(): Promise<StoredSound[]> {
|
||||
return this.request<StoredSound[]>('GET', 'sounds');
|
||||
}
|
||||
|
||||
async delete(file: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `sounds/${encodeURIComponent(file)}`);
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
route: string,
|
||||
payload?: { bytes: Blob | ArrayBuffer; type: string; name: string },
|
||||
): Promise<T> {
|
||||
return apiRequest<T>(this.endpoint, method, route, {
|
||||
headers: payload ? { 'Content-Type': payload.type } : {},
|
||||
body: payload?.bytes,
|
||||
query: payload ? { name: payload.name } : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<UploadedTexture> {
|
||||
return this.request<UploadedTexture>('POST', 'textures', { bytes, type, name });
|
||||
}
|
||||
|
||||
async list(): Promise<StoredTexture[]> {
|
||||
return this.request<StoredTexture[]>('GET', 'textures');
|
||||
}
|
||||
|
||||
async delete(file: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `textures/${encodeURIComponent(file)}`);
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
route: string,
|
||||
payload?: { bytes: Blob | ArrayBuffer; type: string; name: string },
|
||||
): Promise<T> {
|
||||
return apiRequest<T>(this.endpoint, method, route, {
|
||||
headers: payload ? { 'Content-Type': payload.type } : {},
|
||||
body: payload?.bytes,
|
||||
query: payload ? { name: payload.name } : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<UploadedSound>;
|
||||
list(): Promise<StoredSound[]>;
|
||||
delete(file: string): Promise<void>;
|
||||
}
|
||||
export type UploadedSound = UploadedFile;
|
||||
export type StoredSound = StoredFile;
|
||||
export type SoundStore = FileStore<UploadedSound, StoredSound>;
|
||||
@@ -1,38 +1,18 @@
|
||||
export interface UploadedTexture {
|
||||
/** Content-addressed name in `data/textures/`: `{sha256}.{png|jpg|webp|gif}` (see `isTextureFile`). */
|
||||
file: string;
|
||||
import type { FileStore, StoredFile, UploadedFile } from './FileStore';
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* 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`.
|
||||
*/
|
||||
name: string;
|
||||
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<UploadedTexture>;
|
||||
list(): Promise<StoredTexture[]>;
|
||||
delete(file: string): Promise<void>;
|
||||
}
|
||||
export type TextureStore = FileStore<UploadedTexture, StoredTexture>;
|
||||
+5
-6
@@ -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<void>
|
||||
root,
|
||||
state,
|
||||
new HttpProjectStore(API_ENDPOINT),
|
||||
new HttpTextureStore(API_ENDPOINT),
|
||||
new HttpFontStore(API_ENDPOINT),
|
||||
new HttpSoundStore(API_ENDPOINT),
|
||||
new HttpFileStore<UploadedTexture, StoredTexture>(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.
|
||||
|
||||
@@ -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 extends StoredFile = StoredFile> = 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<S extends StoredFile>(
|
||||
stored: S[],
|
||||
project: Project,
|
||||
type: AssetType,
|
||||
): PickerItem<S>[] {
|
||||
const named = new Map<string, string>();
|
||||
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<Item extends PickerItem, Row extends PickerRow> 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<Item extends PickerItem, Row extends PickerRow> extends PickerList<Item, Row> {
|
||||
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<PickerItem> | 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<P extends FilePicker<PickerItem>>(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<P extends FilePicker<PickerItem>>(
|
||||
kind: abstract new (...args: never[]) => P,
|
||||
): P | null {
|
||||
return current instanceof kind ? current : null;
|
||||
}
|
||||
|
||||
export abstract class FilePicker<Item extends PickerItem> extends Popup {
|
||||
protected readonly scroll = new ScrollContainer();
|
||||
private readonly status: Label;
|
||||
private readonly detail: Label;
|
||||
private readonly use: Button;
|
||||
|
||||
constructor(
|
||||
protected readonly list: PickerList<Item, PickerRow>,
|
||||
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<void> {
|
||||
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<Item[]>;
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<U extends UploadedFile, D extends ImportDeps> {
|
||||
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<U>;
|
||||
/** 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<U extends UploadedFile, D extends ImportDeps>(
|
||||
kind: ImportKind<U, D>,
|
||||
files: readonly File[],
|
||||
deps: D,
|
||||
): Promise<ImportOutcome[]> {
|
||||
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<U extends UploadedFile, D extends ImportDeps>(
|
||||
kind: ImportKind<U, D>,
|
||||
file: File,
|
||||
deps: D,
|
||||
): Promise<ImportOutcome> {
|
||||
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<U extends UploadedFile, D extends ImportDeps>(
|
||||
kind: ImportKind<U, D>,
|
||||
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<U extends UploadedFile, D extends ImportDeps>(
|
||||
kind: ImportKind<U, D>,
|
||||
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<File[]> {
|
||||
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<U extends UploadedFile, D extends ImportDeps>(
|
||||
kind: ImportKind<U, D>,
|
||||
accept: string,
|
||||
deps: D,
|
||||
): void {
|
||||
void pickFiles(accept)
|
||||
.then((files) => importFiles(kind, files, deps))
|
||||
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
|
||||
}
|
||||
+52
-238
@@ -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<StoredFont>;
|
||||
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<FontItem, FontRow> {
|
||||
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`).
|
||||
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 }),
|
||||
});
|
||||
// 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();
|
||||
}
|
||||
|
||||
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; 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,
|
||||
});
|
||||
|
||||
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.
|
||||
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);
|
||||
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);
|
||||
|
||||
// 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(
|
||||
row.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
|
||||
row.caption.text = truncate(
|
||||
item.assetName ?? (item.name || formatBytes(item.bytes)),
|
||||
node.caption.style,
|
||||
row.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);
|
||||
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<FontItem> {
|
||||
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<FontItem[]> {
|
||||
return withAssetNames(await this.deps.fonts.list(), this.deps.state.project, 'font');
|
||||
}
|
||||
|
||||
private async loadList(): Promise<void> {
|
||||
try {
|
||||
const stored = await this.deps.fonts.list();
|
||||
const named = new Map<string, string>();
|
||||
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);
|
||||
}
|
||||
+16
-75
@@ -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<ImportOutcome[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
async function importFont(file: File, deps: FontImportDeps): Promise<ImportOutcome> {
|
||||
try {
|
||||
if (!isFontFileName(file.name)) throw new Error('Not a font file – use TTF, OTF, WOFF or WOFF2.');
|
||||
const FONT_KIND: ImportKind<UploadedFont, FontImportDeps> = {
|
||||
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.
|
||||
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),
|
||||
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<ImportOutcome[]> {
|
||||
return importFiles(FONT_KIND, files, deps);
|
||||
}
|
||||
|
||||
/** 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<File[]> {
|
||||
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);
|
||||
}
|
||||
+50
-208
@@ -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<string, { nodeId: string; start: { x: number; y: number } }>();
|
||||
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));
|
||||
}
|
||||
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];
|
||||
|
||||
@@ -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<string, NodeSeed>();
|
||||
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;
|
||||
}
|
||||
+67
-243
@@ -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<StoredSound>;
|
||||
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<SoundItem, SoundRow> {
|
||||
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));
|
||||
/** 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 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 };
|
||||
}
|
||||
|
||||
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`).
|
||||
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);
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
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)) };
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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<SoundItem> {
|
||||
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<FilePicker<SoundItem>['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<void> {
|
||||
try {
|
||||
const stored = await this.deps.sounds.list();
|
||||
const named = new Map<string, string>();
|
||||
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<SoundItem[]> {
|
||||
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);
|
||||
}
|
||||
+16
-74
@@ -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<ImportOutcome[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
async function importSound(file: File, deps: SoundImportDeps): Promise<ImportOutcome> {
|
||||
try {
|
||||
if (!isSoundFileName(file.name)) throw new Error('Not a sound file – use MP3, OGG, WAV or M4A.');
|
||||
const SOUND_KIND: ImportKind<UploadedSound, SoundImportDeps> = {
|
||||
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.
|
||||
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),
|
||||
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<ImportOutcome[]> {
|
||||
return importFiles(SOUND_KIND, files, deps);
|
||||
}
|
||||
|
||||
/** 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<File[]> {
|
||||
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);
|
||||
}
|
||||
@@ -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<StoredTexture>;
|
||||
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<ImageItem, Tile> {
|
||||
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;
|
||||
const inner = side - TILE_PAD * 2;
|
||||
this.empty.text = this.items.length === 0 ? this.emptyText : '';
|
||||
this.empty.position.set(0, 0);
|
||||
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 };
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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.drawBox(tile, side, side, selected);
|
||||
|
||||
const texture = item.width > 0 ? textureLibrary.load(item.file) : null;
|
||||
node.sprite.visible = texture !== null;
|
||||
tile.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;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
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<ImageItem> {
|
||||
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<ImageItem[]> {
|
||||
return withAssetNames(await this.deps.textures.list(), this.deps.state.project, 'texture');
|
||||
}
|
||||
|
||||
private async loadList(): Promise<void> {
|
||||
try {
|
||||
const stored = await this.deps.textures.list();
|
||||
const named = new Map<string, string>();
|
||||
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();
|
||||
importStoredImage(item, this.deps);
|
||||
}
|
||||
}
|
||||
|
||||
function describe(item: PickerItem): string {
|
||||
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;
|
||||
}
|
||||
|
||||
let current: ImagePicker | null = null;
|
||||
protected adopt(item: ImageItem): void {
|
||||
importStoredImage(item, this.deps);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
+17
-140
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ImportOutcome[]> {
|
||||
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<ImportOutcome> {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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<File[]> {
|
||||
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);
|
||||
const IMAGE_KIND: ImportKind<UploadedTexture, TextureImportDeps> = {
|
||||
type: 'texture',
|
||||
noun: 'image',
|
||||
fallbackName: 'Texture',
|
||||
store: (deps) => deps.textures,
|
||||
prepare: prepareImage,
|
||||
create: (name, image) => new CreateTextureCommand(name, image),
|
||||
};
|
||||
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 importImages(files: readonly File[], deps: TextureImportDeps): Promise<ImportOutcome[]> {
|
||||
return importFiles(IMAGE_KIND, files, deps);
|
||||
}
|
||||
|
||||
export function pickAndImport(deps: TextureImportDeps): void {
|
||||
void pickImageFiles()
|
||||
.then((files) => importImages(files, deps))
|
||||
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
|
||||
export function importStoredImage(image: UploadedTexture, deps: TextureImportDeps): ImportOutcome {
|
||||
return importStored(IMAGE_KIND, image, deps);
|
||||
}
|
||||
|
||||
export function pickAndImportImages(deps: TextureImportDeps): void {
|
||||
pickAndImport(IMAGE_KIND, IMAGE_ACCEPT, deps);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+43
-46
@@ -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<string, number> | 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<string, unknown>,
|
||||
) {
|
||||
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<string, number> | 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}`,
|
||||
}),
|
||||
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 – but `Shader.resources` only accepts the slots its
|
||||
// constructor declared, so both have to be here, empty, for Pixi to fill them.
|
||||
resources: {
|
||||
// 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<f32>' },
|
||||
},
|
||||
...(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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user