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
+5
-6
@@ -1,8 +1,7 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import { HttpFontStore } from '../core/store/HttpFontStore';
|
||||
import { HttpFileStore } from '../core/store/HttpFileStore';
|
||||
import { HttpProjectStore } from '../core/store/HttpProjectStore';
|
||||
import { HttpSoundStore } from '../core/store/HttpSoundStore';
|
||||
import { HttpTextureStore } from '../core/store/HttpTextureStore';
|
||||
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
|
||||
import { requestRender, type VStack } from '../ui';
|
||||
import { fontLibrary } from '../viewport/FontLibrary';
|
||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
@@ -32,9 +31,9 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
root,
|
||||
state,
|
||||
new HttpProjectStore(API_ENDPOINT),
|
||||
new HttpTextureStore(API_ENDPOINT),
|
||||
new HttpFontStore(API_ENDPOINT),
|
||||
new HttpSoundStore(API_ENDPOINT),
|
||||
new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'),
|
||||
new HttpFileStore(API_ENDPOINT, 'fonts'),
|
||||
new HttpFileStore(API_ENDPOINT, 'sounds'),
|
||||
);
|
||||
// An image or a font arrives long after the click that asked for it, and the widget waiting for
|
||||
// it repaints without telling anyone: both libraries are a wake-up of their own.
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* The dialog behind "+ → Texture / Font / Sound": everything the server holds of one kind, with
|
||||
* what the project already calls it. Offering the server's files rather than the machine's is the
|
||||
* point – uploading the same file twice is wasteful, and nothing collects the files a project
|
||||
* stopped referencing. Uploading is still one button away.
|
||||
*
|
||||
* The list differs per kind (tiles for pictures, rows with a sample line for typefaces, rows with a
|
||||
* ▶ for sounds); the dialog around it does not.
|
||||
*/
|
||||
import { type Container, type FederatedPointerEvent, type Graphics, Text } from 'pixi.js';
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import type { Project } from '../core/model/Project';
|
||||
import type { StoredFile } from '../core/store/FileStore';
|
||||
import {
|
||||
Button,
|
||||
drawControlBox,
|
||||
formatBytes,
|
||||
HStack,
|
||||
Label,
|
||||
Popup,
|
||||
pressCount,
|
||||
ScrollContainer,
|
||||
Spacer,
|
||||
Theme,
|
||||
textStyle,
|
||||
Widget,
|
||||
} from '../ui';
|
||||
|
||||
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
|
||||
export const PICKER_WIDTH = 560;
|
||||
export const PICKER_HEIGHT = 340;
|
||||
|
||||
/** One offered file, with the asset that already wears it (if any). */
|
||||
export type PickerItem<S extends StoredFile = StoredFile> = S & { assetName: string | null };
|
||||
|
||||
/** Pairs what the server lists with what the project calls it – the asset name is the caption worth showing. */
|
||||
export function withAssetNames<S extends StoredFile>(
|
||||
stored: S[],
|
||||
project: Project,
|
||||
type: AssetType,
|
||||
): PickerItem<S>[] {
|
||||
const named = new Map<string, string>();
|
||||
for (const asset of project.assets) {
|
||||
if (asset.type === type && 'file' in asset) named.set(asset.file, asset.name);
|
||||
}
|
||||
return stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null }));
|
||||
}
|
||||
|
||||
/** The line under the list for a picked file: its upload name and size, and who wears it. */
|
||||
export function describeStored(item: PickerItem): string {
|
||||
const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes);
|
||||
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
|
||||
}
|
||||
|
||||
export interface PickerRow {
|
||||
/** A Container holding the rest, not a Graphics with children: Pixi v8 only lets containers have them. */
|
||||
box: Container;
|
||||
background: Graphics;
|
||||
caption: Text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selectable part of the dialog: one entry per file, one of them picked, a double press
|
||||
* confirms. How entries are built, placed and drawn is the subclass's.
|
||||
*/
|
||||
export abstract class PickerList<Item extends PickerItem, Row extends PickerRow> extends Widget {
|
||||
protected items: Item[] = [];
|
||||
protected rows: Row[] = [];
|
||||
private selectedFile: string | null = null;
|
||||
private readonly empty = new Text({ text: '', style: textStyle('muted') });
|
||||
private readonly unwatch: (() => void)[] = [];
|
||||
onPick?: (file: string) => void;
|
||||
onActivate?: (file: string) => void;
|
||||
emptyText = '';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addChild(this.empty);
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
|
||||
}
|
||||
|
||||
/** Redraws whenever a library reports an arrival, until the list is destroyed. */
|
||||
protected watch(library: { onChange(fn: () => void): () => void }, onArrive: () => void): void {
|
||||
this.unwatch.push(library.onChange(onArrive));
|
||||
}
|
||||
|
||||
setItems(items: Item[]): void {
|
||||
this.items = items;
|
||||
for (const row of this.rows) row.box.destroy({ children: true });
|
||||
this.rows = items.map(() => {
|
||||
const row = this.buildRow();
|
||||
this.addChild(row.box);
|
||||
return row;
|
||||
});
|
||||
if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null;
|
||||
this.layout();
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
get selected(): Item | null {
|
||||
return this.items.find((item) => item.file === this.selectedFile) ?? null;
|
||||
}
|
||||
|
||||
get fileList(): string[] {
|
||||
return this.items.map((item) => item.file);
|
||||
}
|
||||
|
||||
get usedFiles(): string[] {
|
||||
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
|
||||
}
|
||||
|
||||
/** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */
|
||||
get captions(): string[] {
|
||||
return this.rows.map((row) => row.caption.text);
|
||||
}
|
||||
|
||||
select(file: string | null): void {
|
||||
if (file === this.selectedFile) return;
|
||||
this.selectedFile = file;
|
||||
this.redraw();
|
||||
if (file) this.onPick?.(file);
|
||||
}
|
||||
|
||||
/** The row of a file, with its index; null when the file is not offered. */
|
||||
protected rowOf(file: string): { row: Row; index: number } | null {
|
||||
const index = this.items.findIndex((item) => item.file === file);
|
||||
const row = this.rows[index];
|
||||
return row ? { row, index } : null;
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
this.empty.text = this.items.length === 0 ? this.emptyText : '';
|
||||
this.empty.position.set(0, 0);
|
||||
this.rows.forEach((row, index) => {
|
||||
const item = this.items[index];
|
||||
if (item) this.redrawRow(row, item, item.file === this.selectedFile);
|
||||
});
|
||||
}
|
||||
|
||||
override destroy(): void {
|
||||
for (const stop of this.unwatch) stop();
|
||||
super.destroy({ children: true });
|
||||
}
|
||||
|
||||
protected drawBox(row: Row, w: number, h: number, selected: boolean): void {
|
||||
row.background.clear();
|
||||
drawControlBox(row.background, w, h, {
|
||||
fill: selected ? Theme.colors.selection : Theme.colors.control,
|
||||
border: selected ? Theme.colors.accent : Theme.colors.border,
|
||||
});
|
||||
}
|
||||
|
||||
protected onPress(e: FederatedPointerEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
const item = this.items[this.indexAt(local.x, local.y)];
|
||||
if (!item) return;
|
||||
this.select(item.file);
|
||||
if (pressCount(e, item.file) === 2) this.onActivate?.(item.file);
|
||||
}
|
||||
|
||||
protected abstract buildRow(): Row;
|
||||
/** Which entry a local point is on; any index without an item means none. */
|
||||
protected abstract indexAt(x: number, y: number): number;
|
||||
protected abstract redrawRow(row: Row, item: Item, selected: boolean): void;
|
||||
}
|
||||
|
||||
/** A list of equally tall rows, one under the other. */
|
||||
export abstract class RowList<Item extends PickerItem, Row extends PickerRow> extends PickerList<Item, Row> {
|
||||
protected abstract readonly rowHeight: number;
|
||||
protected readonly rowGap = 4;
|
||||
|
||||
/** Global centre of a row (test hook); `fromX` keeps clear of a column that does something else. */
|
||||
rowGlobal(file: string, fromX = 0): { x: number; y: number } | null {
|
||||
const found = this.rowOf(file);
|
||||
if (!found) return null;
|
||||
const p = found.row.box.getGlobalPosition();
|
||||
return { x: p.x + fromX + (this.w - fromX) / 2, y: p.y + this.rowHeight / 2 };
|
||||
}
|
||||
|
||||
heightFor(count: number): number {
|
||||
return count === 0 ? 0 : count * this.rowHeight + (count - 1) * this.rowGap;
|
||||
}
|
||||
|
||||
override measure(): { w: number; h: number } {
|
||||
return { w: this.minWidth, h: Math.max(this.minHeight, this.heightFor(this.items.length)) };
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
this.rows.forEach((row, index) => row.box.position.set(0, index * (this.rowHeight + this.rowGap)));
|
||||
}
|
||||
|
||||
protected override indexAt(x: number, y: number): number {
|
||||
return x >= 0 && x <= this.w ? Math.floor(y / (this.rowHeight + this.rowGap)) : -1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PickerWords {
|
||||
title: string;
|
||||
loading: string;
|
||||
/** Under the list while nothing is picked; a hint for what the rows can do. */
|
||||
idle?: string;
|
||||
empty: string;
|
||||
use: string;
|
||||
/** "image", "font", "sound" – for "3 fonts on the server". */
|
||||
noun: string;
|
||||
}
|
||||
|
||||
let current: FilePicker<PickerItem> | null = null;
|
||||
|
||||
/**
|
||||
* Opens the dialog centred, closing whichever picker was open before it, and starts the listing.
|
||||
* The listing starts here and not in the constructor: it asks the subclass, and a subclass's own
|
||||
* fields are not assigned until its constructor has run past `super()`.
|
||||
*/
|
||||
export function openPicker<P extends FilePicker<PickerItem>>(picker: P): P {
|
||||
current?.close();
|
||||
current = picker;
|
||||
picker.openCentered();
|
||||
void picker.load();
|
||||
return picker;
|
||||
}
|
||||
|
||||
/** The open dialog if it is one of `kind`, for the test hooks. */
|
||||
export function currentPicker<P extends FilePicker<PickerItem>>(
|
||||
kind: abstract new (...args: never[]) => P,
|
||||
): P | null {
|
||||
return current instanceof kind ? current : null;
|
||||
}
|
||||
|
||||
export abstract class FilePicker<Item extends PickerItem> extends Popup {
|
||||
protected readonly scroll = new ScrollContainer();
|
||||
private readonly status: Label;
|
||||
private readonly detail: Label;
|
||||
private readonly use: Button;
|
||||
|
||||
constructor(
|
||||
protected readonly list: PickerList<Item, PickerRow>,
|
||||
private readonly words: PickerWords,
|
||||
upload: () => void,
|
||||
) {
|
||||
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||||
this.content.add(new Label(words.title, { style: 'bodyBold' }));
|
||||
this.status = new Label(words.loading, { style: 'muted' });
|
||||
this.content.add(this.status);
|
||||
|
||||
list.minWidth = PICKER_WIDTH;
|
||||
list.emptyText = words.empty;
|
||||
list.onPick = () => this.refreshSelection();
|
||||
list.onActivate = () => this.confirm();
|
||||
this.scroll.minWidth = PICKER_WIDTH;
|
||||
this.scroll.minHeight = PICKER_HEIGHT;
|
||||
this.scroll.setContent(list);
|
||||
this.content.add(this.scroll);
|
||||
|
||||
this.detail = new Label(words.idle ?? '', { style: 'muted' });
|
||||
this.content.add(this.detail);
|
||||
|
||||
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
const uploadButton = new Button('Upload…');
|
||||
uploadButton.onClick = this.closeThen(upload);
|
||||
buttons.add(uploadButton);
|
||||
buttons.add(new Spacer(), 1);
|
||||
const cancel = new Button('Cancel');
|
||||
cancel.onClick = this.closeThen();
|
||||
buttons.add(cancel);
|
||||
this.use = new Button(words.use, { variant: 'primary' });
|
||||
this.use.onClick = () => this.confirm();
|
||||
this.use.setEnabled(false);
|
||||
buttons.add(this.use);
|
||||
this.content.add(buttons);
|
||||
}
|
||||
|
||||
/** What the dialog offers, which of it the project already wears, and what is picked (test hook). */
|
||||
debugInfo(): { files: string[]; used: string[]; captions: string[]; selected: string | null } {
|
||||
return {
|
||||
files: this.list.fileList,
|
||||
used: this.list.usedFiles,
|
||||
captions: this.list.captions,
|
||||
selected: this.list.selected?.file ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
override close(): void {
|
||||
super.close();
|
||||
if (current === this) current = null;
|
||||
// Escape, Cancel and a confirmed pick all land here, and `Popup.close` tolerates a second
|
||||
// call; the list's listeners must be dropped exactly once.
|
||||
if (!this.list.destroyed) this.list.destroy();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
try {
|
||||
const items = await this.fetchItems();
|
||||
this.list.setItems(items);
|
||||
// Only as tall as the entries need, up to the cap: a handful of files should not leave half
|
||||
// a dialog of empty box under them.
|
||||
const { unit, total } = this.contentHeight(items.length);
|
||||
this.scroll.minHeight = Math.min(this.roomFor(this.scroll, unit), Math.max(unit, total));
|
||||
const { noun } = this.words;
|
||||
this.status.setText(
|
||||
items.length === 1 ? `1 ${noun} on the server` : `${items.length} ${noun}s on the server`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.status.setText(
|
||||
`Could not list the ${this.words.noun}s: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
this.refreshSelection();
|
||||
}
|
||||
|
||||
private refreshSelection(): void {
|
||||
const item = this.list.selected;
|
||||
this.use.setEnabled(item !== null && this.canUse(item));
|
||||
this.detail.setText(item ? this.describe(item) : (this.words.idle ?? ''));
|
||||
this.refreshLayout();
|
||||
}
|
||||
|
||||
private confirm(): void {
|
||||
const item = this.list.selected;
|
||||
if (!item || !this.canUse(item)) return;
|
||||
this.close();
|
||||
this.adopt(item);
|
||||
}
|
||||
|
||||
/** The server's list, paired with the project's names (`withAssetNames`). */
|
||||
protected abstract fetchItems(): Promise<Item[]>;
|
||||
/** How tall the list is for `count` entries, and the height of one – the least the scroll shows. */
|
||||
protected abstract contentHeight(count: number): { unit: number; total: number };
|
||||
protected abstract adopt(item: Item): void;
|
||||
|
||||
protected canUse(_item: Item): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected describe(item: Item): string {
|
||||
return describeStored(item);
|
||||
}
|
||||
}
|
||||
@@ -58,16 +58,6 @@ export function removeNodes(state: EditorState, assetId: string, nodeIds: readon
|
||||
return commands.length > 0 && state.execute(oneStep(`Delete ${commands.length} nodes`, commands));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a group or comment and leaves every node alone – which is the point, so the caller does
|
||||
* not have to know which of the two lists holds it either.
|
||||
*/
|
||||
export function removeAnnotation(state: EditorState, assetId: string, annotationId: string): boolean {
|
||||
const graph = findGraphAsset(state.project, assetId)?.graph;
|
||||
const found = graph && findAnnotation(graph, annotationId);
|
||||
return Boolean(found) && state.execute(new RemoveAnnotationCommand(assetId, found!.kind, annotationId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes everything a graph selection holds – nodes and boxes alike – as one undo step, since one
|
||||
* band caught them and one keypress asked for them to go. Boxes still take none of their nodes.
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Pictures, typefaces and sounds all arrive the same way: a file goes up to the server, comes back
|
||||
* as a content-addressed name, and an asset is made for it – or the asset already naming those
|
||||
* bytes is used. What differs between them is an `ImportKind`; the pipeline is this file.
|
||||
*/
|
||||
import type { Command } from '../core/commands/Command';
|
||||
import { nextAssetName } from '../core/commands/naming';
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import type { FileStore, UploadedFile } from '../core/store/FileStore';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
/** One file's outcome; exactly one of `assetId` and `error` is set. */
|
||||
export interface ImportOutcome {
|
||||
name: string;
|
||||
assetId: string | null;
|
||||
error: string | null;
|
||||
/** The project already had this exact file, so its existing asset was used instead. */
|
||||
reused?: boolean;
|
||||
}
|
||||
|
||||
export interface ImportDeps {
|
||||
state: EditorState;
|
||||
}
|
||||
|
||||
export interface ImportKind<U extends UploadedFile, D extends ImportDeps> {
|
||||
type: AssetType;
|
||||
/** What the notices call one: "image", "font", "sound". */
|
||||
noun: string;
|
||||
/** What an asset is called when the file name gives nothing to go on. */
|
||||
fallbackName: string;
|
||||
store(deps: D): FileStore<U>;
|
||||
/** The bytes to send, and their Content-Type; throws with the reason when the file cannot go up. */
|
||||
prepare(file: File): Promise<{ blob: Blob; type: string }>;
|
||||
create(name: string, uploaded: U): Command & { asset: { id: string } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the files sequentially, so each asset is named against the project as the previous one
|
||||
* left it. A file that fails is reported in the notice; the remaining ones are still imported.
|
||||
*/
|
||||
export async function importFiles<U extends UploadedFile, D extends ImportDeps>(
|
||||
kind: ImportKind<U, D>,
|
||||
files: readonly File[],
|
||||
deps: D,
|
||||
): Promise<ImportOutcome[]> {
|
||||
if (files.length === 0) return [];
|
||||
const outcomes: ImportOutcome[] = [];
|
||||
for (const file of files) outcomes.push(await importFile(kind, file, deps));
|
||||
// The asset the batch created last, so the panel shows what just arrived.
|
||||
const created = outcomes.filter((outcome) => outcome.assetId !== null);
|
||||
const last = created[created.length - 1];
|
||||
if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId });
|
||||
deps.state.setNotice(importNotice(outcomes, kind.noun));
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
async function importFile<U extends UploadedFile, D extends ImportDeps>(
|
||||
kind: ImportKind<U, D>,
|
||||
file: File,
|
||||
deps: D,
|
||||
): Promise<ImportOutcome> {
|
||||
try {
|
||||
const { blob, type } = await kind.prepare(file);
|
||||
const uploaded = await kind.store(deps).upload(blob, type, file.name);
|
||||
return adopt(kind, uploaded, assetNameForFile(file.name, kind.fallbackName), file.name, deps);
|
||||
} catch (error) {
|
||||
return { name: file.name, assetId: null, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an asset for a file the server holds. `label` names the source in messages, `nameBase` is
|
||||
* what the asset is called. The server stores by content hash, so a re-imported file comes back
|
||||
* as the one the project already references; two assets on one file would disagree about it.
|
||||
*/
|
||||
function adopt<U extends UploadedFile, D extends ImportDeps>(
|
||||
kind: ImportKind<U, D>,
|
||||
uploaded: U,
|
||||
nameBase: string,
|
||||
label: string,
|
||||
deps: D,
|
||||
): ImportOutcome {
|
||||
const existing = deps.state.project.assets.find(
|
||||
(a) => a.type === kind.type && 'file' in a && a.file === uploaded.file,
|
||||
);
|
||||
if (existing) return { name: label, assetId: existing.id, error: null, reused: true };
|
||||
|
||||
const command = kind.create(nextAssetName(deps.state.project, nameBase), uploaded);
|
||||
// A rejected command has already put its reason into the notice; that is the message here.
|
||||
if (!deps.state.execute(command)) return { name: label, assetId: null, error: deps.state.notice };
|
||||
return { name: label, assetId: command.asset.id, error: null, reused: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopts a file the server already holds – no upload, the bytes are there. Nothing collects
|
||||
* unreferenced files, so one project's dropped picture is still there for the next to pick up.
|
||||
* Chosen in the picker, so this is "+ → Texture": the tab opens like for any created asset (a kind
|
||||
* without a tab is just selected). A dropped file only selects – it usually goes into the scene.
|
||||
*/
|
||||
export function importStored<U extends UploadedFile, D extends ImportDeps>(
|
||||
kind: ImportKind<U, D>,
|
||||
uploaded: U,
|
||||
deps: D,
|
||||
): ImportOutcome {
|
||||
// Named after the file it was uploaded as, like a dropped one; a file from before names were
|
||||
// kept has none and falls back to the kind's own word.
|
||||
const nameBase = assetNameForFile(uploaded.name, kind.fallbackName);
|
||||
const outcome = adopt(kind, uploaded, nameBase, uploaded.name || uploaded.file, deps);
|
||||
if (outcome.assetId) deps.state.openAsset(outcome.assetId);
|
||||
deps.state.setNotice(
|
||||
outcome.error ??
|
||||
(outcome.reused
|
||||
? `That ${kind.noun} is already in the project.`
|
||||
: `Added the ${kind.noun} to the project.`),
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie. */
|
||||
export function importNotice(outcomes: readonly ImportOutcome[], noun = 'image'): string {
|
||||
const added = outcomes.filter((outcome) => outcome.assetId !== null && !outcome.reused).length;
|
||||
const reused = outcomes.filter((outcome) => outcome.reused).length;
|
||||
const failed = outcomes.filter((outcome) => outcome.error !== null);
|
||||
const first = failed[0];
|
||||
|
||||
const known = reused === 0 ? '' : ` ${reused === 1 ? 'One was' : `${reused} were`} already in the project.`;
|
||||
|
||||
if (!first) {
|
||||
if (added === 0 && reused > 0)
|
||||
return reused === 1
|
||||
? `That ${noun} is already in the project.`
|
||||
: `Those ${reused} ${noun}s are already in the project.`;
|
||||
return `Imported ${added} ${added === 1 ? noun : `${noun}s`}.${known}`;
|
||||
}
|
||||
const more = failed.length > 1 ? ` (and ${failed.length - 1} more)` : '';
|
||||
const problem = `Could not import "${first.name}": ${first.error ?? ''}${more}`;
|
||||
if (added + reused === 0) return problem;
|
||||
return `Imported ${added + reused} of ${outcomes.length}.${known} ${problem}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file name without its extension, as an asset is called: "hero-idle.png" reads better than
|
||||
* "Texture 3". The picker gets the same name back from the server.
|
||||
*/
|
||||
export function assetNameForFile(fileName: string, fallback: string): string {
|
||||
return fileName.replace(/\.[^.]+$/, '').trim() || fallback;
|
||||
}
|
||||
|
||||
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
|
||||
export function pickFiles(accept: string): Promise<File[]> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = accept;
|
||||
input.multiple = true;
|
||||
input.style.display = 'none';
|
||||
document.body.appendChild(input);
|
||||
const finish = (files: File[]) => {
|
||||
input.remove();
|
||||
resolve(files);
|
||||
};
|
||||
input.addEventListener('change', () => finish([...(input.files ?? [])]));
|
||||
// Without `cancel` a dismissed dialog leaves both the element and this promise behind.
|
||||
input.addEventListener('cancel', () => finish([]));
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/** The *Upload…* button: the file dialog, then the batch import of whatever it returns. */
|
||||
export function pickAndImport<U extends UploadedFile, D extends ImportDeps>(
|
||||
kind: ImportKind<U, D>,
|
||||
accept: string,
|
||||
deps: D,
|
||||
): void {
|
||||
void pickFiles(accept)
|
||||
.then((files) => importFiles(kind, files, deps))
|
||||
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
|
||||
}
|
||||
+64
-250
@@ -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
@@ -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
@@ -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];
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user