Three stores, three importers and three pickers that differ only in their type are one of each (#85)
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m46s

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:
adminandClaude Fable 5.1 committed 2026-09-16 17:38:54 +02:00
1 parent 15bdfdf07a
commit cc6db5f350
42 files changed
+1342 -1617

No files matched your search

+11 -5
View File
@@ -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 |
+5 -17
View File
@@ -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 {
+7 -1
View File
@@ -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);
+12
View File
@@ -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';
-5
View File
@@ -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';
}
-5
View File
@@ -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';
}
-12
View File
@@ -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
-4
View File
@@ -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);
}
+57
View File
@@ -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;
}
+14 -42
View File
@@ -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,
});
}
+14 -43
View File
@@ -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` … */
-4
View File
@@ -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;
-12
View File
@@ -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) => {
+28
View File
@@ -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>;
}
+6 -23
View File
@@ -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>;
+31
View File
@@ -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)}`, {});
}
}
-30
View 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,
});
}
}
-30
View File
@@ -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,
});
}
}
-30
View File
@@ -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,
});
}
}
+5 -23
View File
@@ -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>;
+7 -27
View File
@@ -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
View File
@@ -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.
+338
View File
@@ -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);
}
}
-10
View File
@@ -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.
+178
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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];
+161
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+79 -230
View File
@@ -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
View File
@@ -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);
}
+3 -6
View File
@@ -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;
+3 -2
View File
@@ -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
View File
@@ -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;
}
+8 -8
View File
@@ -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');
});
+13 -9
View File
@@ -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,
+6 -5
View File
@@ -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);
});
});
+88
View File
@@ -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);
});
});
+1 -1
View File
@@ -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 };
+1 -3
View File
@@ -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,