From b13b130da7d1aeb445b4da38a63d29779aa56643 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 10 Sep 2026 07:14:23 +0200 Subject: [PATCH] Share the one HTTP envelope and the one byte formatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both stores carried their own ~28-line request, and formatBytes existed twice – the second copy had lost the "B" branch, so a small sprite would have read as 0 KB had its caller ever passed one under a kilobyte. Co-Authored-By: Claude Opus 5 --- src/core/store/HttpProjectStore.ts | 42 +++++++++--------------------- src/core/store/HttpTextureStore.ts | 24 ++--------------- src/core/store/http.ts | 39 +++++++++++++++++++++++++++ src/editor/texture/ImagePicker.ts | 9 ++----- src/editor/textureImport.ts | 7 +---- src/ui/color.ts | 4 --- src/ui/index.ts | 5 ++-- src/ui/math.ts | 6 +++++ src/ui/widgets/ListView.ts | 2 -- src/ui/widgets/TextField.ts | 6 ----- tests/ui/math.test.ts | 14 ++++++++++ 11 files changed, 80 insertions(+), 78 deletions(-) diff --git a/src/core/store/HttpProjectStore.ts b/src/core/store/HttpProjectStore.ts index 70d8b58a..0ce9efc9 100644 --- a/src/core/store/HttpProjectStore.ts +++ b/src/core/store/HttpProjectStore.ts @@ -1,5 +1,5 @@ import type { Project } from '../model/Project'; -import { statusMessage } from './http'; +import { apiRequest } from './http'; import { type LoadedProject, type ProjectStore, @@ -8,7 +8,6 @@ import { } from './ProjectStore'; import { deserializeProject, serializeProject } from './serialize'; -/** Routing goes through the `r` query parameter because the site's nginx config rejects PATH_INFO. */ export class HttpProjectStore implements ProjectStore { constructor(private readonly endpoint: string) {} @@ -46,34 +45,19 @@ export class HttpProjectStore implements ProjectStore { await this.request('DELETE', `projects/${encodeURIComponent(id)}`); } - /** 409 becomes a `StaleRevisionError`, every other failure an `Error` with the server's message. */ private async request(method: string, route: string, payload?: unknown): Promise { - const url = `${this.endpoint}?r=${encodeURIComponent(route)}`; - const response = await fetch(url, { + return apiRequest( + this.endpoint, method, - headers: payload !== undefined ? { 'Content-Type': 'application/json' } : {}, - body: payload !== undefined ? JSON.stringify(payload) : undefined, - }); - if (response.status === 204) return undefined as T; - const text = await response.text(); - let data: unknown; - try { - data = text ? JSON.parse(text) : null; - } catch { - // nginx rejects an oversized body itself and answers with an HTML page, so a failure is - // explained by its status rather than as "invalid JSON". - if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`); - data = null; - } - if (response.status === 409) { - const rev = (data as { revision?: number } | null)?.revision ?? -1; - throw new StaleRevisionError(rev); - } - if (!response.ok) { - const message = - (data as { error?: string } | null)?.error ?? statusMessage(response.status, response.statusText); - throw new Error(`API ${method} ${route}: ${message} (${response.status})`); - } - return data as T; + route, + { + headers: payload !== undefined ? { 'Content-Type': 'application/json' } : {}, + body: payload !== undefined ? JSON.stringify(payload) : undefined, + }, + (status, data) => + status === 409 + ? new StaleRevisionError((data as { revision?: number } | null)?.revision ?? -1) + : undefined, + ); } } diff --git a/src/core/store/HttpTextureStore.ts b/src/core/store/HttpTextureStore.ts index 2fd63b9a..568dffb1 100644 --- a/src/core/store/HttpTextureStore.ts +++ b/src/core/store/HttpTextureStore.ts @@ -1,7 +1,6 @@ -import { statusMessage } from './http'; +import { apiRequest } from './http'; import type { StoredTexture, TextureStore, UploadedTexture } from './TextureStore'; -/** Routing goes through the `r` query parameter because nginx does not pass PATH_INFO to PHP. */ export class HttpTextureStore implements TextureStore { constructor(private readonly endpoint: string) {} @@ -22,28 +21,9 @@ export class HttpTextureStore implements TextureStore { route: string, payload?: { bytes: Blob | ArrayBuffer; type: string }, ): Promise { - const url = `${this.endpoint}?r=${encodeURIComponent(route)}`; - const response = await fetch(url, { - method, + return apiRequest(this.endpoint, method, route, { headers: payload ? { 'Content-Type': payload.type } : {}, body: payload?.bytes, }); - if (response.status === 204) return undefined as T; - const text = await response.text(); - let data: unknown; - try { - data = text ? JSON.parse(text) : null; - } catch { - // An upload is the request nginx refuses before PHP sees it, answering with an HTML page, - // so a failure is explained from the status instead. - if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`); - data = null; - } - if (!response.ok) { - const message = - (data as { error?: string } | null)?.error ?? statusMessage(response.status, response.statusText); - throw new Error(`API ${method} ${route}: ${message} (${response.status})`); - } - return data as T; } } diff --git a/src/core/store/http.ts b/src/core/store/http.ts index c74b0625..5212d7c3 100644 --- a/src/core/store/http.ts +++ b/src/core/store/http.ts @@ -17,3 +17,42 @@ const STATUS_MESSAGES: Record = { export function statusMessage(status: number, statusText: string): string { return STATUS_MESSAGES[status] ?? (statusText || `HTTP ${status}`); } + +/** + * One call against the PHP API. Routing goes through the `r` query parameter because the site's + * nginx config does not pass PATH_INFO to PHP. + * + * `mapError` lets a store turn a particular status into its own error type before the generic + * one is thrown. + */ +export async function apiRequest( + endpoint: string, + method: string, + route: string, + init: Pick = {}, + mapError?: (status: number, data: unknown) => Error | undefined, +): Promise { + const response = await fetch(`${endpoint}?r=${encodeURIComponent(route)}`, { method, ...init }); + if (response.status === 204) return undefined as T; + + const text = await response.text(); + let data: unknown; + try { + data = text ? JSON.parse(text) : null; + } catch { + // A body nginx refused is an HTML page, so a failure is explained by its status rather than + // as "invalid JSON" – which would only be true of a *successful* response. + if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`); + data = null; + } + + const mapped = mapError?.(response.status, data); + if (mapped) throw mapped; + + if (!response.ok) { + const message = + (data as { error?: string } | null)?.error ?? statusMessage(response.status, response.statusText); + throw new Error(`API ${method} ${route}: ${message} (${response.status})`); + } + return data as T; +} diff --git a/src/editor/texture/ImagePicker.ts b/src/editor/texture/ImagePicker.ts index 88e6fe0f..4b7a1d4a 100644 --- a/src/editor/texture/ImagePicker.ts +++ b/src/editor/texture/ImagePicker.ts @@ -3,9 +3,11 @@ import type { StoredTexture } from '../../core/store/TextureStore'; import { Button, drawControlBox, + formatBytes, HStack, Label, Popup, + pressCount, ScrollContainer, type Size, Spacer, @@ -14,7 +16,6 @@ import { 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'; @@ -273,12 +274,6 @@ function describe(item: PickerItem): string { 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 { diff --git a/src/editor/textureImport.ts b/src/editor/textureImport.ts index dd0028bd..2244e8ff 100644 --- a/src/editor/textureImport.ts +++ b/src/editor/textureImport.ts @@ -2,6 +2,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 { EditorState } from './state'; /** What the picker offers and a drop accepts; the server rejects everything else anyway. */ @@ -196,12 +197,6 @@ 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 { return new Promise((resolve) => { diff --git a/src/ui/color.ts b/src/ui/color.ts index 7f0b59f3..5aaa7007 100644 --- a/src/ui/color.ts +++ b/src/ui/color.ts @@ -60,10 +60,6 @@ export function hsvToRgb({ h, s, v }: HSV): number { // Pixi has no 2D gradients worth the trouble, so pickers paint these into a 2D canvas once and // show the result as a texture. -// --- gradient rasterisation ------------------------------------------------------------------ -// Pixi has no 2D gradients worth the trouble, so pickers paint these into a 2D canvas once and -// show the result as a texture. - /** Paints the full hue spectrum left to right, at the context's origin. */ export function paintHueBar(ctx: CanvasRenderingContext2D, width: number, height: number): void { const grad = ctx.createLinearGradient(0, 0, width, 0); diff --git a/src/ui/index.ts b/src/ui/index.ts index 78dd86b8..7c0847ce 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -3,7 +3,7 @@ export { HStack, VStack, Spacer, Stack, type StackOptions } from './layout/Stack export { Theme, textStyle, type TextStyleToken } from './Theme'; export { initUI, ui, type UIContext } from './UIContext'; export { drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw'; -export { clamp, centreY, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math'; +export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math'; export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag'; export { Label, truncate } from './widgets/Label'; export { Button, type ButtonOptions } from './widgets/Button'; @@ -20,4 +20,5 @@ export { TextField } from './widgets/TextField'; export { NumberField } from './widgets/NumberField'; export { ColorField } from './widgets/ColorField'; export { ColorPicker } from './widgets/ColorPicker'; -export * from './color'; +export { type HSV, hsvToRgb, paintHueBar, paintSvSquare, parseHex, rgbToHsv, toHex } from './color'; +export { pressCount } from './clicks'; diff --git a/src/ui/math.ts b/src/ui/math.ts index 9fb2c75a..b7d479a2 100644 --- a/src/ui/math.ts +++ b/src/ui/math.ts @@ -42,3 +42,9 @@ export function snapToStep(value: number, stepSize: number): number { export function zoomFactor(deltaY: number): number { return Math.exp(-deltaY * 0.0015); } + +export 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`; +} diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index 19543809..8b4ea0ec 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -202,8 +202,6 @@ export class ListView extends Widget { this.drawDropMarker(); } - // --- pointer input ------------------------------------------------------------------------- - private onRowPress(row: ListRow, e: FederatedPointerEvent): void { const { item } = row; const modifiers: PickModifiers = { ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey }; diff --git a/src/ui/widgets/TextField.ts b/src/ui/widgets/TextField.ts index 07a9c6db..f1f45345 100644 --- a/src/ui/widgets/TextField.ts +++ b/src/ui/widgets/TextField.ts @@ -115,8 +115,6 @@ export class TextField extends Widget implements TextEditTarget { this.dragSelecting = true; } - // --- focus --------------------------------------------------------------------------------- - override onFocus(): void { super.onFocus(); this.startEditing(); @@ -173,8 +171,6 @@ export class TextField extends Widget implements TextEditTarget { this.invalidate(); } - // --- TextEditTarget ------------------------------------------------------------------------ - /** Adopts the input's value and selection verbatim; fires `onInput` only when the text changed. */ onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void { const changed = value !== this.valueText; @@ -212,8 +208,6 @@ export class TextField extends Widget implements TextEditTarget { if (ui().focus.focused === this) ui().focus.blur(); } - // --- geometry ------------------------------------------------------------------------------ - private textWidth(text: string): number { return CanvasTextMetrics.measureText(text, this.textNode.style).width; } diff --git a/tests/ui/math.test.ts b/tests/ui/math.test.ts index 049567c1..36b11fc3 100644 --- a/tests/ui/math.test.ts +++ b/tests/ui/math.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { clamp, centreY, + formatBytes, formatNumber, NUMBER_DECIMALS, roundTo, @@ -122,3 +123,16 @@ describe('zoomFactor', () => { expect(1 * zoomFactor(-120) * zoomFactor(120)).toBeCloseTo(1, 12); }); }); + +describe('formatBytes', () => { + it('names a small file in bytes, so a sprite never reads as "0 KB"', () => { + expect(formatBytes(400)).toBe('400 B'); + expect(formatBytes(0)).toBe('0 B'); + }); + + it('switches to KB and MB at the round thresholds', () => { + expect(formatBytes(1024)).toBe('1 KB'); + expect(formatBytes(1024 * 1024)).toBe('1.0 MB'); + expect(formatBytes(1536 * 1024)).toBe('1.5 MB'); + }); +});