@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.0",
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.0",
20
- "@cosmicdrift/kumiko-headless": "0.193.0",
21
- "@cosmicdrift/kumiko-renderer": "0.193.0",
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", 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";