Offer the pictures the server already holds when adding a texture

"+ → Texture" opened the machine's file dialog, so a picture that was uploaded once
but lost its asset – an undone import, a deleted asset – was unreachable from the
editor while still sitting on the server. Nothing collects those files, by design.

It now opens a grid of every picture the server holds, with thumbnails: pick one and
"Use image" makes a texture asset on that file, "Upload…" is the way in for bytes the
server does not have yet and lands in the same import as a drop. A tile captioned with
an asset name is one this project already wears – picking it selects that asset instead
of making a second one, since two assets on one file would fight over `filter`.

- `GET ?r=textures` now reports `width`/`height`; a texture asset holds the pixel size,
  and reading it off the GPU texture would tie the asset's correctness to load timing.
- `TextureLibrary.load(file)` loads without touching `scaleMode` – a thumbnail must not
  restage the filter of a picture the scene is drawing with.
- The grid geometry lives in `texture/imageGrid.ts` as pure functions; inside the widget
  no test could reach it.
- The part after an upload is now one function shared by the drop import and the picker,
  so "one file = one asset" is stated in a single place.

12 new E2E checks; the strongest one undoes an import, leaving the file on the server
with no asset naming it, and picks exactly that file back out of the dialog.
This commit is contained in:
root committed 2026-09-09 15:55:15 +02:00
1 parent 2f4dc843d7
commit 88ec367ab0
16 files changed
+774 -56

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
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<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(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;
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Geometry of the image picker's tile grid: how many tiles fit a width, where each one sits, and
* which one a point hits. Pure, so the arithmetic is testable without a canvas.
*/
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
/** Narrowest a tile may get before a column is dropped, and widest it may grow. */
export const TILE_MIN = 92;
export const TILE_MAX = 132;
export const TILE_GAP = 8;
/** Strip under the picture holding the caption. */
export const CAPTION_HEIGHT = 16;
export interface GridMetrics {
columns: number;
/** Side of a tile's picture area; a tile is this plus the caption strip. */
tile: number;
rows: number;
/** Total height of the grid, which is what the scroll container has to give it. */
height: number;
}
export function gridMetrics(width: number, count: number, gap = TILE_GAP): GridMetrics {
const columns = Math.max(1, Math.floor((width + gap) / (TILE_MIN + gap)));
const spread = (width - gap * (columns - 1)) / columns;
const tile = Math.max(TILE_MIN, Math.min(TILE_MAX, Math.floor(spread)));
const rows = Math.ceil(count / columns);
const height = rows * (tile + CAPTION_HEIGHT) + Math.max(0, rows - 1) * gap;
return { columns, tile, rows, height };
}
export function tileRect(index: number, metrics: GridMetrics, gap = TILE_GAP): Rect {
const column = index % metrics.columns;
const row = Math.floor(index / metrics.columns);
const h = metrics.tile + CAPTION_HEIGHT;
return { x: column * (metrics.tile + gap), y: row * (h + gap), w: metrics.tile, h };
}
/** Index of the tile under a point, or -1 for the gaps between them and everything past the last. */
export function tileAt(x: number, y: number, count: number, metrics: GridMetrics, gap = TILE_GAP): number {
if (x < 0 || y < 0) return -1;
const h = metrics.tile + CAPTION_HEIGHT;
const column = Math.floor(x / (metrics.tile + gap));
const row = Math.floor(y / (h + gap));
if (column >= metrics.columns) return -1;
if (x - column * (metrics.tile + gap) > metrics.tile) return -1;
if (y - row * (h + gap) > h) return -1;
const index = row * metrics.columns + column;
return index < count ? index : -1;
}
/**
* The picture centred in the box, scaled to fit either way. Small pictures are blown up: an icon
* shown at its own 16 px would be unrecognisable next to a full-size sheet.
*/
export function containRect(imageW: number, imageH: number, boxW: number, boxH: number): Rect {
if (imageW <= 0 || imageH <= 0) return { x: 0, y: 0, w: 0, h: 0 };
const scale = Math.min(boxW / imageW, boxH / imageH);
const w = Math.max(1, Math.round(imageW * scale));
const h = Math.max(1, Math.round(imageH * scale));
return { x: Math.round((boxW - w) / 2), y: Math.round((boxH - h) / 2), w, h };
}