Three stores, three importers and three pickers that differ only in their type are one of each (#85)
Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts, and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list, check count 958, the two-job CI, the Scenes wiki page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
15bdfdf07a
commit
cc6db5f350
42 files changed
+1375
-1650
No files matched your search
+18
-77
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user