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

+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);
}