Remove comments that only restate the code, keeping the section banners and the notes that carry a reason, a measured fact or an external contract. ButtonOptions.width claimed to be a fixed width but only set minWidth, so the Graph and Texture pickers grew with the name they showed. It is now what it promises, with a separate minWidth for the buttons that have two forms. Narrow buttons shrink their padding, otherwise a 24px icon button had 8px left for its glyph and truncate dropped it entirely. __gm.labelsDropped() guards that class: the overflow guard only saw text that was too wide, never a label truncated to nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
import { CreateTextureCommand } from '../core/commands/assetCommands';
|
||
import { nextAssetName } from '../core/commands/naming';
|
||
import type { TextureAsset } from '../core/model/Asset';
|
||
import type { TextureStore } from '../core/store/TextureStore';
|
||
import type { EditorState } from './state';
|
||
|
||
/** What the picker offers and a drop accepts; the server rejects everything else anyway. */
|
||
export const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif';
|
||
|
||
/** Longest side a stored texture may have: a 4000×3000 photo costs 48 MB of video memory. */
|
||
export const MAX_TEXTURE_SIDE = 2048;
|
||
|
||
/** Byte budget for one upload: nginx cuts every request off at 1 MB, below the API's own 4 MB. */
|
||
export const MAX_UPLOAD_BYTES = 950 * 1024;
|
||
|
||
/** Tried in order when an image has to be re-encoded; the first result that fits wins. */
|
||
const WEBP_QUALITIES = [0.9, 0.75, 0.6] as const;
|
||
|
||
export type ImagePlan = { kind: 'keep' } | { kind: 'reencode'; width: number; height: number };
|
||
|
||
/**
|
||
* Re-encoding would turn crisp pixel art into mush, so an image within both limits is kept byte
|
||
* for byte. `bytes` is the encoded file size, not the pixel count.
|
||
*/
|
||
export function planImageFit(width: number, height: number, bytes: number): ImagePlan {
|
||
const scale = Math.min(1, MAX_TEXTURE_SIDE / Math.max(width, height));
|
||
if (scale === 1 && bytes <= MAX_UPLOAD_BYTES) return { kind: 'keep' };
|
||
return {
|
||
kind: 'reencode',
|
||
width: Math.max(1, Math.round(width * scale)),
|
||
height: Math.max(1, Math.round(height * scale)),
|
||
};
|
||
}
|
||
|
||
/** Encodes at decreasing quality; when none fits the budget, the last attempt comes back anyway. */
|
||
export async function encodeWithinBudget(encode: (quality: number) => Promise<Blob>): Promise<Blob> {
|
||
let result = await encode(WEBP_QUALITIES[0]);
|
||
for (const quality of WEBP_QUALITIES.slice(1)) {
|
||
if (result.size <= MAX_UPLOAD_BYTES) return result;
|
||
result = await encode(quality);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
export interface PreparedImage {
|
||
blob: Blob;
|
||
/** Content-Type for the upload; `image/webp` whenever the image had to be re-encoded. */
|
||
type: string;
|
||
width: number;
|
||
height: number;
|
||
}
|
||
|
||
/** The bytes to upload: the file itself when it already fits, otherwise a downscaled WebP. */
|
||
export async function prepareImage(file: File): Promise<PreparedImage> {
|
||
let bitmap: ImageBitmap;
|
||
try {
|
||
bitmap = await createImageBitmap(file);
|
||
} catch {
|
||
throw new Error('The file is not a readable image.');
|
||
}
|
||
try {
|
||
const plan = planImageFit(bitmap.width, bitmap.height, file.size);
|
||
if (plan.kind === 'keep') {
|
||
return {
|
||
blob: file,
|
||
type: file.type || 'application/octet-stream',
|
||
width: bitmap.width,
|
||
height: bitmap.height,
|
||
};
|
||
}
|
||
// Only the first frame of an animated GIF survives this.
|
||
const canvas = new OffscreenCanvas(plan.width, plan.height);
|
||
const context = canvas.getContext('2d');
|
||
if (!context) throw new Error('This browser cannot resize images.');
|
||
context.drawImage(bitmap, 0, 0, plan.width, plan.height);
|
||
const blob = await encodeWithinBudget((quality) => canvas.convertToBlob({ type: 'image/webp', quality }));
|
||
if (blob.size > MAX_UPLOAD_BYTES) {
|
||
throw new Error(
|
||
`The image is too large to upload: still ${formatBytes(blob.size)} at ${plan.width}×${plan.height}. Shrink it before importing.`,
|
||
);
|
||
}
|
||
return { blob, type: 'image/webp', width: plan.width, height: plan.height };
|
||
} finally {
|
||
bitmap.close();
|
||
}
|
||
}
|
||
|
||
export interface TextureImportDeps {
|
||
state: EditorState;
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 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));
|
||
|
||
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));
|
||
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);
|
||
|
||
// 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 === uploaded.file,
|
||
);
|
||
if (existing) {
|
||
return { name: file.name, assetId: existing.id, error: null, reused: true };
|
||
}
|
||
|
||
const name = nextAssetName(deps.state.project, assetNameFor(file));
|
||
const command = new CreateTextureCommand(name, uploaded);
|
||
// A rejected command has already put its reason into the notice; that is the message here.
|
||
if (!deps.state.execute(command)) return { name: file.name, assetId: null, error: deps.state.notice };
|
||
return { name: file.name, assetId: command.asset.id, error: null, reused: false };
|
||
} catch (error) {
|
||
return { name: file.name, assetId: null, error: error instanceof Error ? error.message : String(error) };
|
||
}
|
||
}
|
||
|
||
/** One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie. */
|
||
export function importNotice(outcomes: readonly ImportOutcome[]): 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 image is already in the project.'
|
||
: `Those ${reused} images are already in the project.`;
|
||
return `Imported ${added} ${added === 1 ? 'image' : 'images'}.${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}`;
|
||
}
|
||
|
||
function assetNameFor(file: File): string {
|
||
return file.name.replace(/\.[^.]+$/, '').trim() || 'Texture';
|
||
}
|
||
|
||
function formatBytes(bytes: number): string {
|
||
return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
|
||
}
|
||
|
||
// --- ways in ---------------------------------------------------------------------------------
|
||
|
||
/** 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)));
|
||
}
|
||
|
||
/**
|
||
* Wires drop-onto-`target` and paste, and returns the remover for both. Paste is bound to the
|
||
* window because the canvas cannot hold focus, and skipped while a DOM text field has it.
|
||
*/
|
||
export function installTextureImport(target: HTMLElement, deps: TextureImportDeps): () => void {
|
||
const onDragOver = (event: DragEvent) => {
|
||
if (!hasImageItems(event.dataTransfer)) return;
|
||
// Without this the browser navigates away to the dropped file.
|
||
event.preventDefault();
|
||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||
};
|
||
const onDrop = (event: DragEvent) => {
|
||
const files = imageFilesOf(event.dataTransfer);
|
||
if (files.length === 0) {
|
||
if (!event.dataTransfer?.files.length) return;
|
||
event.preventDefault();
|
||
deps.state.setNotice('Only image files can be dropped into the editor.');
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
run(files, deps);
|
||
};
|
||
const onPaste = (event: ClipboardEvent) => {
|
||
if (isTextFieldFocused()) return;
|
||
const files = imageFilesOf(event.clipboardData);
|
||
if (files.length === 0) return;
|
||
event.preventDefault();
|
||
run(files, deps);
|
||
};
|
||
|
||
target.addEventListener('dragover', onDragOver);
|
||
target.addEventListener('drop', onDrop);
|
||
window.addEventListener('paste', onPaste);
|
||
return () => {
|
||
target.removeEventListener('dragover', onDragOver);
|
||
target.removeEventListener('drop', onDrop);
|
||
window.removeEventListener('paste', onPaste);
|
||
};
|
||
}
|
||
|
||
function run(files: readonly File[], deps: TextureImportDeps): void {
|
||
void importImages(files, deps).catch((error: unknown) => deps.state.setNotice(String(error)));
|
||
}
|
||
|
||
function imageFilesOf(data: DataTransfer | null): File[] {
|
||
return [...(data?.files ?? [])].filter((file) => file.type.startsWith('image/'));
|
||
}
|
||
|
||
/** During a drag the files themselves are not readable yet – only the kinds and types are. */
|
||
function hasImageItems(data: DataTransfer | null): boolean {
|
||
return [...(data?.items ?? [])].some((item) => item.kind === 'file' && item.type.startsWith('image/'));
|
||
}
|
||
|
||
function isTextFieldFocused(): boolean {
|
||
const active = document.activeElement;
|
||
return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement;
|
||
}
|