@cosmicdrift/kumiko-renderer-web 0.193.0 → 0.193.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.193.
|
|
3
|
+
"version": "0.193.1",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.193.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.193.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.193.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.193.1",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.193.1",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.193.1",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { resizeImageBeforeUpload } from "../resize-image";
|
|
3
|
+
|
|
4
|
+
describe("resizeImageBeforeUpload", () => {
|
|
5
|
+
test("lässt Nicht-Bilder unverändert", async () => {
|
|
6
|
+
const file = new File(["hi"], "report.pdf", { type: "application/pdf" });
|
|
7
|
+
const result = await resizeImageBeforeUpload(file);
|
|
8
|
+
expect(result).toBe(file);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("fehlt OffscreenCanvas, bleibt das Bild unverändert", async () => {
|
|
12
|
+
// @ts-expect-error simulate a browser without OffscreenCanvas support
|
|
13
|
+
globalThis.OffscreenCanvas = undefined;
|
|
14
|
+
const file = new File(["hi"], "photo.jpg", { type: "image/jpeg" });
|
|
15
|
+
const result = await resizeImageBeforeUpload(file);
|
|
16
|
+
expect(result).toBe(file);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("lässt SVGs unverändert (Vektor würde beim Re-Encode zerstört)", async () => {
|
|
20
|
+
const file = new File(["<svg/>"], "logo.svg", { type: "image/svg+xml" });
|
|
21
|
+
const result = await resizeImageBeforeUpload(file);
|
|
22
|
+
expect(result).toBe(file);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("lässt GIFs unverändert (Animation würde auf ein Frame kollabieren)", async () => {
|
|
26
|
+
const file = new File(["gif"], "anim.gif", { type: "image/gif" });
|
|
27
|
+
const result = await resizeImageBeforeUpload(file);
|
|
28
|
+
expect(result).toBe(file);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("skaliert auf maxEdge herunter und behält das Seitenverhältnis", async () => {
|
|
32
|
+
const bitmap = { close: mock(() => {}), height: 2000, width: 4000 };
|
|
33
|
+
let capturedWidth = 0;
|
|
34
|
+
let capturedHeight = 0;
|
|
35
|
+
class FakeOffscreenCanvas {
|
|
36
|
+
constructor(w: number, h: number) {
|
|
37
|
+
capturedWidth = w;
|
|
38
|
+
capturedHeight = h;
|
|
39
|
+
}
|
|
40
|
+
getContext() {
|
|
41
|
+
return { drawImage: mock(() => {}) };
|
|
42
|
+
}
|
|
43
|
+
convertToBlob() {
|
|
44
|
+
return Promise.resolve(new Blob(["x"], { type: "image/jpeg" }));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// @ts-expect-error test stub for a browser-only API missing in jsdom
|
|
48
|
+
globalThis.OffscreenCanvas = FakeOffscreenCanvas;
|
|
49
|
+
globalThis.createImageBitmap = mock(async () => bitmap);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const file = new File(["x"], "photo.jpg", { type: "image/jpeg" });
|
|
53
|
+
const result = await resizeImageBeforeUpload(file, 2560);
|
|
54
|
+
expect(capturedWidth).toBe(2560);
|
|
55
|
+
expect(capturedHeight).toBe(1280);
|
|
56
|
+
expect(result.name).toBe("photo.jpg");
|
|
57
|
+
expect(result.type).toBe("image/jpeg");
|
|
58
|
+
} finally {
|
|
59
|
+
// @ts-expect-error restore missing-API baseline
|
|
60
|
+
globalThis.OffscreenCanvas = undefined;
|
|
61
|
+
// @ts-expect-error restore missing-API baseline
|
|
62
|
+
globalThis.createImageBitmap = undefined;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("konvertiert nicht-erhaltbare Formate zu jpeg und passt die Extension an", async () => {
|
|
67
|
+
class FakeOffscreenCanvas {
|
|
68
|
+
getContext() {
|
|
69
|
+
return { drawImage: mock(() => {}) };
|
|
70
|
+
}
|
|
71
|
+
convertToBlob(opts: { type: string }) {
|
|
72
|
+
return Promise.resolve(new Blob(["x"], { type: opts.type }));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// @ts-expect-error test stub for a browser-only API missing in jsdom
|
|
76
|
+
globalThis.OffscreenCanvas = FakeOffscreenCanvas;
|
|
77
|
+
globalThis.createImageBitmap = mock(async () => ({
|
|
78
|
+
close: mock(() => {}),
|
|
79
|
+
height: 100,
|
|
80
|
+
width: 100,
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const file = new File(["x"], "photo.heic", { type: "image/heic" });
|
|
85
|
+
const result = await resizeImageBeforeUpload(file);
|
|
86
|
+
expect(result.name).toBe("photo.jpg");
|
|
87
|
+
expect(result.type).toBe("image/jpeg");
|
|
88
|
+
} finally {
|
|
89
|
+
// @ts-expect-error restore missing-API baseline
|
|
90
|
+
globalThis.OffscreenCanvas = undefined;
|
|
91
|
+
// @ts-expect-error restore missing-API baseline
|
|
92
|
+
globalThis.createImageBitmap = undefined;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("benennt nach dem tatsächlich erzeugten Blob-Typ, nicht dem angeforderten", async () => {
|
|
97
|
+
// A browser without webp encoding support silently falls back to png
|
|
98
|
+
// (spec behavior of convertToBlob) — naming must follow the blob, or a
|
|
99
|
+
// ".webp" file ends up holding png bytes with a matching-looking mimeType.
|
|
100
|
+
class FakeOffscreenCanvas {
|
|
101
|
+
getContext() {
|
|
102
|
+
return { drawImage: mock(() => {}) };
|
|
103
|
+
}
|
|
104
|
+
convertToBlob() {
|
|
105
|
+
return Promise.resolve(new Blob(["x"], { type: "image/png" }));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// @ts-expect-error test stub for a browser-only API missing in jsdom
|
|
109
|
+
globalThis.OffscreenCanvas = FakeOffscreenCanvas;
|
|
110
|
+
globalThis.createImageBitmap = mock(async () => ({
|
|
111
|
+
close: mock(() => {}),
|
|
112
|
+
height: 100,
|
|
113
|
+
width: 100,
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const file = new File(["x"], "photo.webp", { type: "image/webp" });
|
|
118
|
+
const result = await resizeImageBeforeUpload(file);
|
|
119
|
+
expect(result.name).toBe("photo.png");
|
|
120
|
+
expect(result.type).toBe("image/png");
|
|
121
|
+
} finally {
|
|
122
|
+
// @ts-expect-error restore missing-API baseline
|
|
123
|
+
globalThis.OffscreenCanvas = undefined;
|
|
124
|
+
// @ts-expect-error restore missing-API baseline
|
|
125
|
+
globalThis.createImageBitmap = undefined;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("fällt bei Decode-Fehlern auf die Originaldatei zurück", async () => {
|
|
130
|
+
class FakeOffscreenCanvas {
|
|
131
|
+
getContext() {
|
|
132
|
+
return { drawImage: mock(() => {}) };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// @ts-expect-error test stub for a browser-only API missing in jsdom
|
|
136
|
+
globalThis.OffscreenCanvas = FakeOffscreenCanvas;
|
|
137
|
+
globalThis.createImageBitmap = mock(async () => {
|
|
138
|
+
throw new Error("unsupported format");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const file = new File(["x"], "photo.heic", { type: "image/heic" });
|
|
143
|
+
const result = await resizeImageBeforeUpload(file);
|
|
144
|
+
expect(result).toBe(file);
|
|
145
|
+
} finally {
|
|
146
|
+
// @ts-expect-error restore missing-API baseline
|
|
147
|
+
globalThis.OffscreenCanvas = undefined;
|
|
148
|
+
// @ts-expect-error restore missing-API baseline
|
|
149
|
+
globalThis.createImageBitmap = undefined;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const DEFAULT_MAX_EDGE = 2560;
|
|
2
|
+
|
|
3
|
+
// Raster formats a canvas can losslessly re-encode as themselves; anything
|
|
4
|
+
// else (heic, bmp, tiff, ...) falls back to jpeg. svg/gif are excluded
|
|
5
|
+
// upstream (vector/animation would be destroyed by a canvas re-encode).
|
|
6
|
+
const PRESERVED_TYPES = new Set(["image/png", "image/webp"]);
|
|
7
|
+
const OUTPUT_EXTENSIONS: Record<string, string> = {
|
|
8
|
+
"image/jpeg": "jpg",
|
|
9
|
+
"image/png": "png",
|
|
10
|
+
"image/webp": "webp",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function pickOutputType(inputType: string): string {
|
|
14
|
+
return PRESERVED_TYPES.has(inputType) ? inputType : "image/jpeg";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The server validates the upload's extension against its mimeType
|
|
18
|
+
// (mime_mismatch), so a re-encode that changes type must rename the file too.
|
|
19
|
+
function withMatchingExtension(fileName: string, outputType: string): string {
|
|
20
|
+
const ext = OUTPUT_EXTENSIONS[outputType] ?? "jpg";
|
|
21
|
+
const dot = fileName.lastIndexOf(".");
|
|
22
|
+
const base = dot > 0 ? fileName.slice(0, dot) : fileName;
|
|
23
|
+
return `${base}.${ext}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Downscales images before upload to a reasonable edge length (bandwidth)
|
|
27
|
+
// and drops EXIF/GPS as a side effect (canvas re-encode carries no
|
|
28
|
+
// metadata). Missing OffscreenCanvas/createImageBitmap (older browser), or a
|
|
29
|
+
// decode failure, leaves the file unchanged — no hard error before upload.
|
|
30
|
+
export async function resizeImageBeforeUpload(
|
|
31
|
+
file: File,
|
|
32
|
+
maxEdge = DEFAULT_MAX_EDGE,
|
|
33
|
+
): Promise<File> {
|
|
34
|
+
if (!file.type.startsWith("image/")) return file;
|
|
35
|
+
if (file.type === "image/svg+xml" || file.type === "image/gif") return file;
|
|
36
|
+
if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap !== "function") {
|
|
37
|
+
return file;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
|
|
41
|
+
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
|
|
42
|
+
const width = Math.round(bitmap.width * scale);
|
|
43
|
+
const height = Math.round(bitmap.height * scale);
|
|
44
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
45
|
+
const ctx = canvas.getContext("2d");
|
|
46
|
+
if (ctx === null) return file;
|
|
47
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
48
|
+
bitmap.close();
|
|
49
|
+
const blob = await canvas.convertToBlob({ type: pickOutputType(file.type), quality: 0.85 });
|
|
50
|
+
// Name off the blob's actual type, not the requested one — a browser
|
|
51
|
+
// without webp encoding support silently falls back to png, and naming
|
|
52
|
+
// by the request would then store a lie (extension/mimeType both "webp"
|
|
53
|
+
// over png bytes).
|
|
54
|
+
const actualType = blob.type || file.type;
|
|
55
|
+
const fileName =
|
|
56
|
+
actualType === file.type ? file.name : withMatchingExtension(file.name, actualType);
|
|
57
|
+
return new File([blob], fileName, { type: actualType });
|
|
58
|
+
} catch {
|
|
59
|
+
return file;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
7
7
|
import { ImageIcon, Loader2, Upload } from "lucide-react";
|
|
8
8
|
import { type ChangeEvent, type ReactNode, useRef, useState } from "react";
|
|
9
|
+
import { resizeImageBeforeUpload } from "../lib/resize-image";
|
|
9
10
|
import { Button as UiButton } from "../ui/button";
|
|
10
11
|
|
|
11
12
|
export type FileUploadInputProps = {
|
|
@@ -47,8 +48,9 @@ export function FileUploadInput({
|
|
|
47
48
|
setUploading(true);
|
|
48
49
|
setError(null);
|
|
49
50
|
try {
|
|
51
|
+
const uploadFile = await resizeImageBeforeUpload(file);
|
|
50
52
|
const fd = new FormData();
|
|
51
|
-
fd.append("file",
|
|
53
|
+
fd.append("file", uploadFile);
|
|
52
54
|
if (entityType !== undefined) fd.append("entityType", entityType);
|
|
53
55
|
if (fieldName !== undefined) fd.append("fieldName", fieldName);
|
|
54
56
|
// Double-Submit-CSRF wie der Dispatcher: kumiko_csrf-Cookie → Header.
|
|
@@ -54,4 +54,39 @@ describe("UploadZone", () => {
|
|
|
54
54
|
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
|
55
55
|
expect(input.value).toBe("");
|
|
56
56
|
});
|
|
57
|
+
|
|
58
|
+
test("gibt Bilder verkleinert an onUpload weiter (nicht die Originaldatei)", async () => {
|
|
59
|
+
class FakeOffscreenCanvas {
|
|
60
|
+
getContext() {
|
|
61
|
+
return { drawImage: mock(() => {}) };
|
|
62
|
+
}
|
|
63
|
+
convertToBlob() {
|
|
64
|
+
return Promise.resolve(new Blob(["resized-bytes"], { type: "image/jpeg" }));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// @ts-expect-error test stub for a browser-only API missing in happy-dom
|
|
68
|
+
globalThis.OffscreenCanvas = FakeOffscreenCanvas;
|
|
69
|
+
globalThis.createImageBitmap = mock(async () => ({
|
|
70
|
+
close: mock(() => {}),
|
|
71
|
+
height: 100,
|
|
72
|
+
width: 100,
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const onUpload = mock(async (_uploaded: File) => {});
|
|
77
|
+
render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
|
|
78
|
+
const file = new File(["x"], "photo.jpg", { type: "image/jpeg" });
|
|
79
|
+
pick(screen.getByTestId("zone-input"), [file]);
|
|
80
|
+
|
|
81
|
+
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
|
82
|
+
const [uploaded] = onUpload.mock.calls[0] ?? [];
|
|
83
|
+
expect(uploaded?.size).toBe("resized-bytes".length);
|
|
84
|
+
expect(uploaded?.size).not.toBe(file.size);
|
|
85
|
+
} finally {
|
|
86
|
+
// @ts-expect-error restore missing-API baseline
|
|
87
|
+
globalThis.OffscreenCanvas = undefined;
|
|
88
|
+
// @ts-expect-error restore missing-API baseline
|
|
89
|
+
globalThis.createImageBitmap = undefined;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
57
92
|
});
|
|
@@ -2,6 +2,7 @@ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
|
2
2
|
import { CheckCircle2, FileUp, Loader2, TriangleAlert, Upload } from "lucide-react";
|
|
3
3
|
import { type DragEvent, type ReactNode, useId, useRef, useState } from "react";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
|
+
import { resizeImageBeforeUpload } from "../lib/resize-image";
|
|
5
6
|
|
|
6
7
|
type UploadRowStatus = "uploading" | "done" | "error";
|
|
7
8
|
|
|
@@ -68,7 +69,7 @@ export function UploadZone({
|
|
|
68
69
|
const rowId = crypto.randomUUID();
|
|
69
70
|
setRows((prev) => [...prev, { id: rowId, fileName: file.name, status: "uploading" }]);
|
|
70
71
|
try {
|
|
71
|
-
await onUpload(file);
|
|
72
|
+
await onUpload(await resizeImageBeforeUpload(file));
|
|
72
73
|
setRows((prev) => prev.map((row) => (row.id === rowId ? { ...row, status: "done" } : row)));
|
|
73
74
|
} catch (cause) {
|
|
74
75
|
const message = cause instanceof Error ? cause.message : "upload_failed";
|