import { type FederatedPointerEvent, Graphics, Sprite, Text } from 'pixi.js'; import type { StoredTexture } from '../../core/store/TextureStore'; import { Button, drawControlBox, HStack, Label, Popup, ScrollContainer, type Size, Spacer, Theme, textStyle, truncate, Widget, } from '../../ui'; import { pressCount } from '../../ui/clicks'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { importStoredImage, pickAndImport, 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; } /** * 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. */ class ImageGrid extends Widget { private items: PickerItem[] = []; private tiles: { box: 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 = ''; 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(() => { const box = new Graphics(); const sprite = new Sprite(); const caption = new Text({ style: textStyle('small') }); box.addChild(sprite, caption); this.addChild(box); return { box, 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); } select(file: string | null): void { if (file === this.selectedFile) return; this.selectedFile = file; this.redraw(); if (file) this.onPick?.(file); } /** 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(); return { x: p.x + rect.w / 2, y: p.y + this.metrics.tile / 2 }; } override measure(): Size { // The very first measure happens before the scroll container has sized us, so the declared // width stands in – it is the width the dialog gives the grid anyway. const width = this.w || this.minWidth; return { w: this.minWidth, h: Math.max(this.minHeight, gridMetrics(width, this.items.length).height) }; } override layout(): void { this.metrics = gridMetrics(this.w, this.items.length); this.tiles.forEach((tile, index) => { const rect = tileRect(index, this.metrics); tile.box.position.set(rect.x, rect.y); }); } override redraw(): void { const { tile: side } = this.metrics; const inner = side - TILE_PAD * 2; this.empty.text = this.items.length === 0 ? this.emptyText : ''; this.empty.position.set(0, 0); this.tiles.forEach((node, index) => { const item = this.items[index]; if (!item) return; const selected = item.file === this.selectedFile; node.box.clear(); drawControlBox(node.box, 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; 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; a bare hash tells nobody anything. const text = item.width > 0 ? (item.assetName ?? `${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); } } /** * 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; 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[]; selected: string | null } { return { files: this.grid.fileList, used: this.grid.usedFiles, selected: this.grid.selected?.file ?? null, }; } tileGlobal(file: string): { x: number; y: number } | null { return this.grid.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(); } private async loadList(): Promise { try { const stored = await this.deps.textures.list(); const named = new Map(); 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(GRID_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(); } private refreshSelection(): void { const item = this.grid.selected; this.use.setEnabled(item !== null && item.width > 0); this.detail.setText(item ? describe(item) : ''); this.refreshLayout(); } private confirm(): void { const item = this.grid.selected; if (!item || item.width <= 0) return; this.close(); importStoredImage(item, this.deps); } } function describe(item: PickerItem): string { if (item.width <= 0) return 'The server cannot read this file as an image.'; const size = `${item.width} × ${item.height} px, ${formatBytes(item.bytes)}`; return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size; } function formatBytes(bytes: number): string { if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; // A small sprite is a few hundred bytes, and "0 KB" reads like a broken file. return bytes >= 1024 ? `${Math.round(bytes / 1024)} KB` : `${bytes} B`; } let current: ImagePicker | null = null; export function openImagePicker(deps: TextureImportDeps): ImagePicker { current?.close(); current = new ImagePicker(deps); current.openCentered(); return current; } /** The open dialog, for the test hook. */ export function currentImagePicker(): ImagePicker | null { return current; }