Trim needless comments and fix the picker width
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>
This commit is contained in:
1 parent
f16801aef3
commit
4d84231b9f
115 files changed
+707
-1564
No files matched your search
+16
-56
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* Getting a picture from the user's machine into the project: pick, drop or paste a file,
|
||||
* shrink it if it is too big to send, upload the bytes, add a texture asset. The three ways in
|
||||
* share every step after the file – so a dropped and a picked image behave identically.
|
||||
*/
|
||||
import { CreateTextureCommand } from '../core/commands/assetCommands';
|
||||
import { nextAssetName } from '../core/commands/naming';
|
||||
import type { TextureAsset } from '../core/model/Asset';
|
||||
@@ -12,16 +7,10 @@ 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 phone photo has no business being a
|
||||
* sprite texture: it costs 48 MB of video memory to draw something a fraction of that size.
|
||||
*/
|
||||
/** 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. The API would take 4 MB, but nginx cuts every request off at
|
||||
* 1 MB and answers with its own HTML page, so nginx is the wall we have to stay under.
|
||||
*/
|
||||
/** 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. */
|
||||
@@ -30,9 +19,8 @@ const WEBP_QUALITIES = [0.9, 0.75, 0.6] as const;
|
||||
export type ImagePlan = { kind: 'keep' } | { kind: 'reencode'; width: number; height: number };
|
||||
|
||||
/**
|
||||
* How to get an image within both limits. Re-encoding is a last resort – it would turn crisp
|
||||
* pixel art into mush – so anything that is already small enough in both senses is sent byte
|
||||
* for byte as it lies on disk. `bytes` is the encoded file size, not the pixel count.
|
||||
* 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));
|
||||
@@ -44,11 +32,7 @@ export function planImageFit(width: number, height: number, bytes: number): Imag
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes at decreasing quality and stops at the first result within `MAX_UPLOAD_BYTES`. When
|
||||
* none fits, the last (lowest quality) attempt comes back, so the caller can report how far
|
||||
* off it was. `encode` is a parameter to keep the ladder testable without a canvas.
|
||||
*/
|
||||
/** 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)) {
|
||||
@@ -66,11 +50,7 @@ export interface PreparedImage {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes to upload for one picked file: the file itself whenever it already fits (see
|
||||
* `planImageFit`), otherwise a downscaled WebP. Rejects when the file is not a decodable image
|
||||
* or stays too large even at the lowest quality.
|
||||
*/
|
||||
/** 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 {
|
||||
@@ -81,7 +61,6 @@ export async function prepareImage(file: File): Promise<PreparedImage> {
|
||||
try {
|
||||
const plan = planImageFit(bitmap.width, bitmap.height, file.size);
|
||||
if (plan.kind === 'keep') {
|
||||
// Untouched on purpose: re-encoding costs quality and would break pixel art.
|
||||
return {
|
||||
blob: file,
|
||||
type: file.type || 'application/octet-stream',
|
||||
@@ -89,8 +68,7 @@ export async function prepareImage(file: File): Promise<PreparedImage> {
|
||||
height: bitmap.height,
|
||||
};
|
||||
}
|
||||
// Only the first frame of an animated GIF survives this, which is all the renderer ever
|
||||
// draws – a texture is one still picture.
|
||||
// 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.');
|
||||
@@ -122,10 +100,8 @@ export interface ImportOutcome {
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the files one after another – prepare, upload, create the asset – selects the last
|
||||
* one that worked and sums the result up as a notice. Sequential on purpose: each asset is
|
||||
* named against the project as it stands *after* the previous one. A file that fails is
|
||||
* reported and the remaining ones are still imported.
|
||||
* 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[],
|
||||
@@ -147,11 +123,8 @@ async function importImage(file: File, deps: TextureImportDeps): Promise<ImportO
|
||||
const image = await prepareImage(file);
|
||||
const uploaded = await deps.textures.upload(image.blob, image.type);
|
||||
|
||||
// The server stores by content hash, so importing a picture the project already has gives
|
||||
// back the file it already references. Making a second asset for it would be clutter with a
|
||||
// trap in it: two assets on one file can disagree about `filter`, while the scale mode
|
||||
// belongs to the single shared source. Reuse instead – and say so, rather than silently
|
||||
// doing nothing visible.
|
||||
// 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,
|
||||
);
|
||||
@@ -169,11 +142,7 @@ async function importImage(file: File, deps: TextureImportDeps): Promise<ImportO
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line for the whole batch: how many arrived, how many were already there, and why the
|
||||
* first casualty did not. Reuse gets its own words – "Imported 1 image" would be a lie about a
|
||||
* picture the project already had, and the user would look for a new asset that never appeared.
|
||||
*/
|
||||
/** 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;
|
||||
@@ -195,7 +164,6 @@ export function importNotice(outcomes: readonly ImportOutcome[]): string {
|
||||
return `Imported ${added + reused} of ${outcomes.length}.${known} ${problem}`;
|
||||
}
|
||||
|
||||
/** "player_ship.png" → "player_ship"; a pasted screenshot arrives as "image.png". */
|
||||
function assetNameFor(file: File): string {
|
||||
return file.name.replace(/\.[^.]+$/, '').trim() || 'Texture';
|
||||
}
|
||||
@@ -206,10 +174,7 @@ function formatBytes(bytes: number): string {
|
||||
|
||||
// --- ways in ---------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Opens the browser's file dialog and resolves with what the user chose – empty when the
|
||||
* dialog was cancelled. The `<input>` lives only for this one call.
|
||||
*/
|
||||
/** 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');
|
||||
@@ -223,13 +188,12 @@ export function pickImageFiles(): Promise<File[]> {
|
||||
resolve(files);
|
||||
};
|
||||
input.addEventListener('change', () => finish([...(input.files ?? [])]));
|
||||
// Without `cancel` a dismissed dialog would leave both the element and this promise behind.
|
||||
// Without `cancel` a dismissed dialog leaves both the element and this promise behind.
|
||||
input.addEventListener('cancel', () => finish([]));
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/** The `+` menu's Texture entry: pick files and import them, reporting anything that goes wrong. */
|
||||
export function pickAndImport(deps: TextureImportDeps): void {
|
||||
void pickImageFiles()
|
||||
.then((files) => importImages(files, deps))
|
||||
@@ -237,12 +201,8 @@ export function pickAndImport(deps: TextureImportDeps): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the two ways of importing an image that need no menu: dropping files onto `target`
|
||||
* (the Pixi canvas) and pasting one from the clipboard. Returns the function that removes both
|
||||
* again.
|
||||
*
|
||||
* Paste is bound to the window because the canvas cannot hold focus, and is ignored while a
|
||||
* DOM text field has it (the `KeyboardBridge` input): Ctrl+V belongs to the text being edited.
|
||||
* 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) => {
|
||||
|
||||
Reference in new issue
Block a user