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
+1375 -1650

No files matched your search

+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>;
+9 -29
View File
@@ -1,38 +1,18 @@
export interface UploadedTexture {
/** Content-addressed name in `data/textures/`: `{sha256}.{png|jpg|webp|gif}` (see `isTextureFile`). */
file: string;
/**
* The name the file was uploaded under, '' when the server knows none. A picture may be
* re-encoded on the way, so this can be a `.png` name on a file stored as `.webp`.
*/
name: string;
import type { FileStore, StoredFile, UploadedFile } from './FileStore';
/**
* Stored in `data/textures/` as `{sha256}.{png|jpg|webp|gif}`. A picture may be re-encoded on the
* way, so `name` can be a `.png` name on a file stored as `.webp`.
*/
export interface UploadedTexture extends UploadedFile {
width: number;
height: number;
bytes: number;
}
/** A file the server holds, whether any project references it or not. */
export interface StoredTexture {
file: string;
name: string;
export interface StoredTexture extends StoredFile {
/** 0 × 0 when the server could not read the header; such a file cannot become an asset. */
width: number;
height: number;
bytes: number;
updatedAt: string;
}
/**
* An upload is raw bytes rather than a multipart form: name, size and format are derived from the
* image itself on the server, so there is nothing else to send.
*/
export interface TextureStore {
/**
* `type` only fills the Content-Type header – the server trusts the bytes, not the label – and
* uploading the same picture twice yields the same file, the hash of its content. `name` is the
* caption kept beside it; uploading the same bytes under a new name renames what pickers show.
*/
upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<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'));
}
+64 -250
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`).
const box = new Container();
const background = new Graphics();
// Its own style object, never `textStyle('body')`: those are shared across the whole
// editor, so writing a family into one would restyle every label in the window.
const sample = new Text({
style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }),
});
const caption = new Text({ style: textStyle('small') });
box.addChild(background, sample, caption);
this.addChild(box);
return { box, background, sample, caption };
});
if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null;
this.layout();
this.redraw();
protected buildRow(): FontRow {
const box = new Container();
const background = new Graphics();
// Its own style object, never `textStyle('body')`: those are shared across the whole editor,
// so writing a family into one would restyle every label in the window.
const sample = new Text({ style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }) });
const caption = new Text({ style: textStyle('small') });
box.addChild(background, sample, caption);
return { box, background, sample, caption };
}
get selected(): PickerItem | null {
return this.items.find((item) => item.file === this.selectedFile) ?? null;
}
protected redrawRow(row: FontRow, item: FontItem, selected: boolean): void {
this.drawBox(row, this.w, ROW_HEIGHT, selected);
// Empty family = not loaded yet; the row then reads in the editor's own typeface and the
// library's change event brings it back here once the face has arrived.
row.sample.style.fontFamily = fontLibrary.family(item.file) || Theme.font;
row.sample.text = truncate(SAMPLE_TEXT, row.sample.style, this.w - ROW_PAD * 2);
row.sample.position.set(ROW_PAD, ROW_PAD - 2);
get fileList(): string[] {
return this.items.map((item) => item.file);
}
get usedFiles(): string[] {
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
}
/** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */
get captions(): string[] {
return this.rows.map((row) => row.caption.text);
}
select(file: string | null): void {
if (file === this.selectedFile) return;
this.selectedFile = file;
this.redraw();
if (file) this.onPick?.(file);
}
/** Global centre of a row; null when the file is not offered (test hook). */
rowGlobal(file: string): { x: number; y: number } | null {
const index = this.items.findIndex((item) => item.file === file);
const row = this.rows[index];
if (!row) return null;
const p = row.box.getGlobalPosition();
return { x: p.x + this.w / 2, y: p.y + ROW_HEIGHT / 2 };
}
override measure(): Size {
return { w: this.minWidth, h: Math.max(this.minHeight, heightFor(this.items.length)) };
}
override layout(): void {
this.rows.forEach((row, index) => row.box.position.set(0, index * (ROW_HEIGHT + ROW_GAP)));
}
override redraw(): void {
this.empty.text = this.items.length === 0 ? this.emptyText : '';
this.empty.position.set(0, 0);
this.rows.forEach((node, index) => {
const item = this.items[index];
if (!item) return;
const selected = item.file === this.selectedFile;
node.background.clear();
drawControlBox(node.background, this.w, ROW_HEIGHT, {
fill: selected ? Theme.colors.selection : Theme.colors.control,
border: selected ? Theme.colors.accent : Theme.colors.border,
});
// Empty family = not loaded yet; the row then reads in the editor's own typeface and the
// library's change event brings it back here once the face has arrived.
node.sample.style.fontFamily = fontLibrary.family(item.file) || Theme.font;
node.sample.text = truncate(SAMPLE_TEXT, node.sample.style, this.w - ROW_PAD * 2);
node.sample.position.set(ROW_PAD, ROW_PAD - 2);
// The asset name wins where there is one – it is what the project calls this face. Otherwise
// the name it was uploaded under, which is all a bare hash leaves to go on.
node.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
node.caption.text = truncate(
item.assetName ?? (item.name || formatBytes(item.bytes)),
node.caption.style,
this.w - ROW_PAD * 2,
);
node.caption.position.set(ROW_PAD, ROW_HEIGHT - 16);
});
}
override destroy(): void {
this.stopWatching();
super.destroy({ children: true });
}
private onPress(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const index = Math.floor(local.y / (ROW_HEIGHT + ROW_GAP));
const item = local.x >= 0 && local.x <= this.w ? this.items[index] : undefined;
if (!item) return;
this.select(item.file);
if (pressCount(e, item.file) === 2) this.onActivate?.(item.file);
// The asset name wins where there is one – it is what the project calls this face. Otherwise
// the name it was uploaded under, which is all a bare hash leaves to go on.
row.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
row.caption.text = truncate(
item.assetName ?? (item.name || formatBytes(item.bytes)),
row.caption.style,
this.w - ROW_PAD * 2,
);
row.caption.position.set(ROW_PAD, ROW_HEIGHT - 16);
}
}
function heightFor(count: number): number {
return count === 0 ? 0 : count * ROW_HEIGHT + (count - 1) * ROW_GAP;
}
/**
* Offers every typeface the server holds, the way the image picker offers pictures: uploading the
* same font twice is wasteful, and nothing collects the files a project stopped referencing.
*/
export class FontPicker extends Popup {
private readonly list = new FontList();
private readonly scroll = new ScrollContainer();
private readonly status: Label;
private readonly detail: Label;
private readonly use: Button;
export class FontPicker extends FilePicker<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);
}
+18 -77
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;
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.
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);
}
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.');
// The bytes go up untouched: unlike a picture, a typeface cannot be re-encoded smaller here.
const uploaded = await deps.fonts.upload(file, file.type || 'application/octet-stream', file.name);
return adopt(uploaded, assetNameForFile(file.name, 'Font'), file.name, deps);
} catch (error) {
return {
name: file.name,
assetId: null,
error: error instanceof Error ? error.message : String(error),
};
}
}
/** Makes an asset for a typeface the server holds, or reuses the one already naming those bytes. */
function adopt(font: UploadedFont, nameBase: string, label: string, deps: FontImportDeps): ImportOutcome {
const existing = deps.state.project.assets.find(
(asset): asset is FontAsset => asset.type === 'font' && asset.file === font.file,
);
if (existing) return { name: label, assetId: existing.id, error: null, reused: true };
const name = nextAssetName(deps.state.project, nameBase);
const command = new CreateFontCommand(name, font.file);
// A rejected command has already put its reason into the notice; that is the message here.
if (!deps.state.execute(command)) return { name: label, assetId: null, error: deps.state.notice };
return { name: label, assetId: command.asset.id, error: null, reused: false };
}
/**
* Adopts a typeface the server already holds – no upload, the bytes are there. Chosen in the
* picker, so this is "+ → Font" and the asset is selected the way a created one would be.
*/
export function importStoredFont(font: UploadedFont, deps: FontImportDeps): ImportOutcome {
// Named after the file it was uploaded as, like a dropped one; "Font" when the server knows none.
const outcome = adopt(font, assetNameForFile(font.name, 'Font'), font.name || font.file, deps);
if (outcome.assetId) deps.state.select({ kind: 'asset', assetId: outcome.assetId });
deps.state.setNotice(
outcome.error ??
(outcome.reused ? 'That font is already in the project.' : 'Added the font to the project.'),
);
return outcome;
}
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
export function pickFontFiles(): Promise<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);
}
+51 -209
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));
}
const graph = this.asset()?.graph;
if (drag?.moved && graph && this.assetId) {
// The boxes that came along belong in the same step: one gesture, one undo.
for (const box of drag.boxes) {
const found = graph && findAnnotation(graph, box.id)?.annotation;
if (!found) continue;
const next = boxOf(found);
Object.assign(found, box.start);
moves.push(new SetAnnotationBoxCommand(this.assetId, box.kind, box.id, next));
}
const moves = recordTravellers(graph, this.assetId, drag.along);
if (moves.length > 0) this.state.execute(oneStep('Move nodes', moves));
}
const connecting = this.connecting;
@@ -975,7 +820,8 @@ export class GraphCanvas extends Widget {
};
const ids = boxesInBand(this.nodeBoxes(), band);
// A box counts only when the band holds all of it – see `boxesWithinBand`.
const boxIds = boxesWithinBand(this.annotationBoxes(), band);
const graph = this.asset()?.graph;
const boxIds = graph ? boxesWithinBand(annotationBoxes(graph), band) : [];
// An empty band clears the selection but keeps the behavior, as a plain click does.
this.state.select(graphSelection(this.assetId, ids, boxIds) ?? { kind: 'asset', assetId: this.assetId });
}
@@ -1512,10 +1358,6 @@ function globalRect(target: Container, w: number, h: number): Rect {
return { x: topLeft.x, y: topLeft.y, w: bottomRight.x - topLeft.x, h: bottomRight.y - topLeft.y };
}
function boxOf(annotation: GraphAnnotation): AnnotationBox {
return { x: annotation.x, y: annotation.y, w: annotation.w, h: annotation.h };
}
/** A drag may start at either end; the command wants the output first. */
function connectSides(assetId: string, a: PortSide, b: PortSide): ConnectCommand {
const [out, inp] = a.isOutput ? [a, b] : [b, a];
+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;
}
+71 -247
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));
}
setItems(items: PickerItem[]): void {
this.items = items;
for (const row of this.rows) row.box.destroy({ children: true });
this.rows = items.map(() => {
// A Container holding the background, not a Graphics with children: Pixi v8 only lets
// containers have them (`addChild: Only Containers will be allowed to add children`).
const box = new Container();
const background = new Graphics();
const play = new Text({ text: '▶', style: textStyle('accent') });
const caption = new Text({ style: textStyle('body') });
const detail = new Text({ style: textStyle('small') });
box.addChild(background, play, caption, detail);
this.addChild(box);
return { box, background, play, caption, detail };
});
if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null;
this.layout();
this.redraw();
}
get selected(): PickerItem | null {
return this.items.find((item) => item.file === this.selectedFile) ?? null;
}
get fileList(): string[] {
return this.items.map((item) => item.file);
}
get usedFiles(): string[] {
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
}
/** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */
get captions(): string[] {
return this.rows.map((row) => row.caption.text);
}
select(file: string | null): void {
if (file === this.selectedFile) return;
this.selectedFile = file;
this.redraw();
if (file) this.onPick?.(file);
}
/** Global centre of a row, clear of the ▶ column; null when the file is not offered (test hook). */
rowGlobal(file: string): { x: number; y: number } | null {
const index = this.items.findIndex((item) => item.file === file);
const row = this.rows[index];
if (!row) return null;
const p = row.box.getGlobalPosition();
return { x: p.x + PLAY_WIDTH + (this.w - PLAY_WIDTH) / 2, y: p.y + ROW_HEIGHT / 2 };
}
/** Global centre of a row's ▶ – what a check presses to hear one without choosing it. */
playGlobal(file: string): { x: number; y: number } | null {
const index = this.items.findIndex((item) => item.file === file);
const row = this.rows[index];
if (!row) return null;
const p = row.box.getGlobalPosition();
const found = this.rowOf(file);
if (!found) return null;
const p = found.row.box.getGlobalPosition();
return { x: p.x + PLAY_WIDTH / 2, y: p.y + ROW_HEIGHT / 2 };
}
override measure(): Size {
return { w: this.minWidth, h: Math.max(this.minHeight, heightFor(this.items.length)) };
protected buildRow(): SoundRow {
const box = new Container();
const background = new Graphics();
const play = new Text({ text: '▶', style: textStyle('accent') });
const caption = new Text({ style: textStyle('body') });
const detail = new Text({ style: textStyle('small') });
box.addChild(background, play, caption, detail);
return { box, background, play, caption, detail };
}
override layout(): void {
this.rows.forEach((row, index) => row.box.position.set(0, index * (ROW_HEIGHT + ROW_GAP)));
protected redrawRow(row: SoundRow, item: SoundItem, selected: boolean): void {
this.drawBox(row, this.w, ROW_HEIGHT, selected);
row.play.position.set(ROW_PAD + 2, (ROW_HEIGHT - row.play.height) / 2);
const width = this.w - PLAY_WIDTH - ROW_PAD;
row.caption.style = textStyle(item.assetName ? 'accent' : 'body');
row.caption.text = truncate(item.assetName ?? item.name, row.caption.style, width);
row.caption.position.set(PLAY_WIDTH, 6);
const detail =
item.assetName && item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes);
row.detail.text = truncate(detail, row.detail.style, width);
row.detail.position.set(PLAY_WIDTH, ROW_HEIGHT - 16);
}
override redraw(): void {
this.empty.text = this.items.length === 0 ? this.emptyText : '';
this.empty.position.set(0, 0);
this.rows.forEach((node, index) => {
const item = this.items[index];
if (!item) return;
const selected = item.file === this.selectedFile;
node.background.clear();
drawControlBox(node.background, this.w, ROW_HEIGHT, {
fill: selected ? Theme.colors.selection : Theme.colors.control,
border: selected ? Theme.colors.accent : Theme.colors.border,
});
node.play.position.set(ROW_PAD + 2, (ROW_HEIGHT - node.play.height) / 2);
// The asset name wins where there is one – it is what the project calls this sound. Otherwise
// the name it was uploaded under, which is all a bare hash leaves to go on.
node.caption.style = textStyle(item.assetName ? 'accent' : 'body');
node.caption.text = truncate(
item.assetName ?? item.name,
node.caption.style,
this.w - PLAY_WIDTH - ROW_PAD,
);
node.caption.position.set(PLAY_WIDTH, 6);
node.detail.text = truncate(
item.assetName && item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes),
node.detail.style,
this.w - PLAY_WIDTH - ROW_PAD,
);
node.detail.position.set(PLAY_WIDTH, ROW_HEIGHT - 16);
});
}
private onPress(e: FederatedPointerEvent): void {
protected override onPress(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const index = Math.floor(local.y / (ROW_HEIGHT + ROW_GAP));
const item = local.x >= 0 && local.x <= this.w ? this.items[index] : undefined;
if (!item) return;
if (local.x < PLAY_WIDTH) {
const item = this.items[this.indexAt(local.x, local.y)];
if (item && local.x < PLAY_WIDTH) {
this.lastPlayed = item.file;
soundLibrary.play(item.file, 1, false);
return;
}
this.select(item.file);
if (pressCount(e, item.file) === 2) this.onActivate?.(item.file);
super.onPress(e);
}
}
function heightFor(count: number): number {
return count === 0 ? 0 : count * ROW_HEIGHT + (count - 1) * ROW_GAP;
}
/**
* Offers every sound the server holds, the way the font picker offers typefaces: uploading the same
* file twice is wasteful, and nothing collects the files a project stopped referencing.
*/
export class SoundPicker extends Popup {
private readonly list = new SoundList();
private readonly scroll = new ScrollContainer();
private readonly status: Label;
private readonly detail: Label;
private readonly use: Button;
export class SoundPicker extends FilePicker<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);
}
+18 -76
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;
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.
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);
}
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.');
// The bytes go up untouched: nothing here could encode audio, and a re-encode would be a loss.
const uploaded = await deps.sounds.upload(file, file.type || 'application/octet-stream', file.name);
return adopt(uploaded, assetNameForFile(file.name, 'Sound'), file.name, deps);
} catch (error) {
return {
name: file.name,
assetId: null,
error: error instanceof Error ? error.message : String(error),
};
}
}
/** Makes an asset for a sound the server holds, or reuses the one already naming those bytes. */
function adopt(sound: UploadedSound, nameBase: string, label: string, deps: SoundImportDeps): ImportOutcome {
const existing = deps.state.project.assets.find(
(asset): asset is SoundAsset => asset.type === 'sound' && asset.file === sound.file,
);
if (existing) return { name: label, assetId: existing.id, error: null, reused: true };
const name = nextAssetName(deps.state.project, nameBase);
const command = new CreateSoundCommand(name, sound.file);
// A rejected command has already put its reason into the notice; that is the message here.
if (!deps.state.execute(command)) return { name: label, assetId: null, error: deps.state.notice };
return { name: label, assetId: command.asset.id, error: null, reused: false };
}
/**
* Adopts a sound the server already holds – no upload, the bytes are there. Chosen in the picker,
* so this is "+ → Sound" and the asset is selected the way a created one would be.
*/
export function importStoredSound(sound: UploadedSound, deps: SoundImportDeps): ImportOutcome {
const outcome = adopt(sound, assetNameForFile(sound.name, 'Sound'), sound.name || sound.file, deps);
if (outcome.assetId) deps.state.select({ kind: 'asset', assetId: outcome.assetId });
deps.state.setNotice(
outcome.error ??
(outcome.reused ? 'That sound is already in the project.' : 'Added the sound to the project.'),
);
return outcome;
}
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
export function pickSoundFiles(): Promise<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);
}
+90 -241
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;
protected buildRow(): Tile {
const box = new Container();
const background = new Graphics();
const sprite = new Sprite();
const caption = new Text({ style: textStyle('small') });
box.addChild(background, sprite, caption);
return { box, background, sprite, caption };
}
protected indexAt(x: number, y: number): number {
return tileAt(x, y, this.items.length, this.metrics);
}
protected redrawRow(tile: Tile, item: ImageItem, selected: boolean): void {
const side = this.metrics.tile;
const inner = side - TILE_PAD * 2;
this.empty.text = this.items.length === 0 ? this.emptyText : '';
this.empty.position.set(0, 0);
this.drawBox(tile, side, side, selected);
this.tiles.forEach((node, index) => {
const item = this.items[index];
if (!item) return;
const selected = item.file === this.selectedFile;
node.background.clear();
drawControlBox(node.background, side, side, {
fill: selected ? Theme.colors.selection : Theme.colors.control,
border: selected ? Theme.colors.accent : Theme.colors.border,
});
const texture = item.width > 0 ? textureLibrary.load(item.file) : null;
tile.sprite.visible = texture !== null;
if (texture) {
const fitted = containRect(item.width, item.height, inner, inner);
tile.sprite.texture = texture;
tile.sprite.position.set(TILE_PAD + fitted.x, TILE_PAD + fitted.y);
tile.sprite.width = fitted.w;
tile.sprite.height = fitted.h;
}
const texture = item.width > 0 ? textureLibrary.load(item.file) : null;
node.sprite.visible = texture !== null;
if (texture) {
const fitted = containRect(item.width, item.height, inner, inner);
node.sprite.texture = texture;
node.sprite.position.set(TILE_PAD + fitted.x, TILE_PAD + fitted.y);
node.sprite.width = fitted.w;
node.sprite.height = fitted.h;
}
// The asset name is the useful caption where there is one, then the name the file was
// uploaded under; a bare hash tells nobody anything, and the size is the last resort.
const text =
item.width > 0 ? (item.assetName ?? (item.name || `${item.width} × ${item.height}`)) : 'unreadable';
node.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
node.caption.text = truncate(text, node.caption.style, side);
node.caption.position.set(0, side + 1);
});
}
override destroy(): void {
this.stopWatching();
super.destroy({ children: true });
}
private onPress(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const index = tileAt(local.x, local.y, this.items.length, this.metrics);
const item = this.items[index];
if (!item) return;
this.select(item.file);
if (pressCount(e, item.file) === 2) this.onActivate?.(item.file);
// The asset name is the useful caption where there is one, then the name the file was
// uploaded under; a bare hash tells nobody anything, and the size is the last resort.
const text =
item.width > 0 ? (item.assetName ?? (item.name || `${item.width} × ${item.height}`)) : 'unreadable';
tile.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
tile.caption.text = truncate(text, tile.caption.style, side);
tile.caption.position.set(0, side + 1);
}
}
/**
* Offers every picture the server holds, rather than the machine's file system: uploading the
* same image twice is wasteful, and nothing collects the files a project stopped referencing.
* Uploading is still one button away, for a picture the server does not have yet.
*/
export class ImagePicker extends Popup {
private readonly grid = new ImageGrid();
private readonly scroll = new ScrollContainer();
private readonly status: Label;
private readonly detail: Label;
private readonly use: Button;
export class ImagePicker extends FilePicker<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();
protected override describe(item: ImageItem): string {
if (item.width <= 0) return 'The server cannot read this file as an image.';
const measurements = `${item.width} × ${item.height} px, ${formatBytes(item.bytes)}`;
const size = item.name ? `${item.name} – ${measurements}` : measurements;
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
}
protected adopt(item: ImageItem): void {
importStoredImage(item, this.deps);
}
}
function describe(item: PickerItem): string {
if (item.width <= 0) return 'The server cannot read this file as an image.';
const measurements = `${item.width} × ${item.height} px, ${formatBytes(item.bytes)}`;
const size = item.name ? `${item.name} – ${measurements}` : measurements;
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
}
let current: ImagePicker | null = null;
export function openImagePicker(deps: TextureImportDeps): ImagePicker {
current?.close();
current = new ImagePicker(deps);
current.openCentered();
return current;
return openPicker(new ImagePicker(deps));
}
/** The open dialog, for the test hook. */
export function currentImagePicker(): ImagePicker | null {
return current;
return currentPicker(ImagePicker);
}
+15 -138
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;
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),
};
export function importImages(files: readonly File[], deps: TextureImportDeps): Promise<ImportOutcome[]> {
return importFiles(IMAGE_KIND, files, deps);
}
/**
* Imports the files sequentially, so each asset is named against the project as the previous one
* left it. A file that fails is reported in the notice; the remaining ones are still imported.
*/
export async function importImages(
files: readonly File[],
deps: TextureImportDeps,
): Promise<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;
return importStored(IMAGE_KIND, image, deps);
}
/** Selects the asset the batch created last, so the panel shows what just arrived. */
function selectLast(outcomes: readonly ImportOutcome[], deps: TextureImportDeps): void {
const created = outcomes.filter((outcome) => outcome.assetId !== null);
const last = created[created.length - 1];
if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId });
}
/**
* One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie.
* `noun` names what was imported – fonts come through the same batch reporting.
*/
export function importNotice(outcomes: readonly ImportOutcome[], noun = 'image'): string {
const added = outcomes.filter((outcome) => outcome.assetId !== null && !outcome.reused).length;
const reused = outcomes.filter((outcome) => outcome.reused).length;
const failed = outcomes.filter((outcome) => outcome.error !== null);
const first = failed[0];
const known = reused === 0 ? '' : ` ${reused === 1 ? 'One was' : `${reused} were`} already in the project.`;
if (!first) {
if (added === 0 && reused > 0)
return reused === 1
? `That ${noun} is already in the project.`
: `Those ${reused} ${noun}s are already in the project.`;
return `Imported ${added} ${added === 1 ? noun : `${noun}s`}.${known}`;
}
const more = failed.length > 1 ? ` (and ${failed.length - 1} more)` : '';
const problem = `Could not import "${first.name}": ${first.error ?? ''}${more}`;
if (added + reused === 0) return problem;
return `Imported ${added + reused} of ${outcomes.length}.${known} ${problem}`;
}
/**
* A file name without its extension, as an asset is called: "hero-idle.png" reads better than
* "Texture 3". Shared with the picker, which gets the same name back from the server.
*/
export function assetNameForFile(fileName: string, fallback: string): string {
return fileName.replace(/\.[^.]+$/, '').trim() || fallback;
}
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
export function pickImageFiles(): Promise<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);
};
input.addEventListener('change', () => finish([...(input.files ?? [])]));
// Without `cancel` a dismissed dialog leaves both the element and this promise behind.
input.addEventListener('cancel', () => finish([]));
input.click();
});
}
export function pickAndImport(deps: TextureImportDeps): void {
void pickImageFiles()
.then((files) => importImages(files, deps))
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
export function pickAndImportImages(deps: TextureImportDeps): void {
pickAndImport(IMAGE_KIND, IMAGE_ACCEPT, deps);
}
+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;
+44 -47
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}`,
}),
// `GlParticleContainerAdaptor` writes the field's texture and the pipe's own uniforms into
// whichever shader the container names – but `Shader.resources` only accepts the slots its
// constructor declared, so both have to be here, empty, for Pixi to fill them.
resources: {
class ParticleGraphShader extends CompiledGraphShader {
constructor(assetId: string, compiled: CompiledShader) {
// `GlParticleContainerAdaptor` writes the field's texture and the pipe's own uniforms into
// whichever shader the container names, so both slots have to exist, empty, for Pixi to fill.
super(
assetId,
compiled,
{ vertex: PARTICLE_VERTEX, name: `particle-shader-${assetId}` },
{
uTexture: Texture.WHITE.source,
uSampler: new TextureStyle({}),
uniforms: {
@@ -223,16 +228,8 @@ class ParticleGraphShader extends Shader {
uRound: { value: 1, type: 'f32' },
uResolution: { value: [0, 0], type: 'vec2<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;
}