@gradio/imageeditor 0.20.0 → 0.20.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @gradio/imageeditor
2
2
 
3
+ ## 0.20.2
4
+
5
+ ### Fixes
6
+
7
+ - [#13787](https://github.com/gradio-app/gradio/pull/13787) [`2ae6f97`](https://github.com/gradio-app/gradio/commit/2ae6f97d0f5284c8f351ffe6d45da61944284d5a) - Fix Image and ImageEditor UI/UX issues. Thanks @abidlabs!
8
+
9
+ ### Dependency updates
10
+
11
+ - @gradio/client@2.6.0
12
+ - @gradio/atoms@0.26.2
13
+ - @gradio/utils@0.14.1
14
+ - @gradio/upload@0.18.3
15
+ - @gradio/image@0.28.3
16
+
17
+ ## 0.20.1
18
+
19
+ ### Dependency updates
20
+
21
+ - @gradio/client@2.5.1
22
+
23
+ ## 0.20.1
24
+
25
+ ### Dependency updates
26
+
27
+ - @gradio/client@2.5.0
28
+
29
+ ## 0.20.1
30
+
31
+ ### Dependency updates
32
+
33
+ - @gradio/client@2.4.0
34
+ - @gradio/utils@0.14.0
35
+ - @gradio/atoms@0.26.1
36
+ - @gradio/statustracker@0.15.2
37
+ - @gradio/upload@0.18.2
38
+ - @gradio/image@0.28.1
39
+
3
40
  ## 0.20.0
4
41
 
5
42
  ### Features
@@ -0,0 +1,129 @@
1
+ import { afterEach, describe, expect, test } from "vitest";
2
+ import {
3
+ cleanup,
4
+ fireEvent,
5
+ mock_client,
6
+ render,
7
+ TEST_PNG,
8
+ waitFor
9
+ } from "@self/tootils/render";
10
+ import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
11
+
12
+ import es from "../core/src/lang/es.json";
13
+ import ImageEditor from "./Index.svelte";
14
+ import ImageEditorTestWrapper from "./ImageEditorTestWrapper.svelte";
15
+
16
+ const default_props = {
17
+ sources: ["upload"] as const,
18
+ interactive: true,
19
+ label: "Image Editor",
20
+ show_label: true,
21
+ value: null,
22
+ canvas_size: [800, 600] as [number, number],
23
+ transforms: [] as const,
24
+ layers: { allow_additional_layers: true, layers: ["Layer 1"] },
25
+ brush: {
26
+ default_size: "auto" as const,
27
+ colors: ["#000000"],
28
+ default_color: "#000000",
29
+ color_mode: "defaults" as const
30
+ },
31
+ eraser: { default_size: "auto" as const },
32
+ webcam_options: { mirror: true, constraints: {} },
33
+ buttons: []
34
+ };
35
+
36
+ const spanish_i18n = (key: string): string => {
37
+ const [namespace, message] = key.split(".");
38
+ return (
39
+ (es as Record<string, Record<string, string>>)[namespace]?.[message] ?? key
40
+ );
41
+ };
42
+
43
+ run_shared_prop_tests({
44
+ component: ImageEditor,
45
+ name: "ImageEditor",
46
+ base_props: { ...default_props, interactive: false },
47
+ has_label: false,
48
+ has_validation_error: true
49
+ });
50
+
51
+ describe("get_data / set_data", () => {
52
+ afterEach(() => cleanup());
53
+
54
+ test("an untouched editor uploads the original image bytes", async () => {
55
+ const background = { ...TEST_PNG, orig_name: "background.png" };
56
+ const layer = { ...TEST_PNG, orig_name: "layer.png" };
57
+ const composite = { ...TEST_PNG, orig_name: "composite.png" };
58
+ const value = { background, layers: [layer], composite };
59
+ const { getByRole, get_data, listen } = await render(ImageEditor, {
60
+ ...default_props,
61
+ client: mock_client(),
62
+ value
63
+ });
64
+ const uploaded = listen("upload", { retrospective: true });
65
+ await waitFor(
66
+ () => {
67
+ expect(getByRole("button", { name: "image_editor.pan" })).toBeVisible();
68
+ expect(uploaded).toHaveBeenCalled();
69
+ },
70
+ { timeout: 5000 }
71
+ );
72
+
73
+ const result = (await get_data()).value;
74
+ const original_bytes = await (await fetch(TEST_PNG.url!)).arrayBuffer();
75
+
76
+ expect(result.background.orig_name).toBe("background.png");
77
+ expect(result.layers[0].orig_name).toBe("layer.png");
78
+ expect(result.composite.orig_name).toBe("composite.png");
79
+ expect(await result.background.blob.arrayBuffer()).toEqual(original_bytes);
80
+ expect(await result.layers[0].blob.arrayBuffer()).toEqual(original_bytes);
81
+ expect(await result.composite.blob.arrayBuffer()).toEqual(original_bytes);
82
+ });
83
+ });
84
+
85
+ describe("Internationalization", () => {
86
+ afterEach(() => cleanup());
87
+
88
+ test("canvas controls use translated accessible names", async () => {
89
+ const { getByRole } = await render(ImageEditorTestWrapper, {
90
+ kind: "controls",
91
+ i18n: spanish_i18n
92
+ });
93
+
94
+ expect(getByRole("button", { name: "Descargar" })).toBeVisible();
95
+ expect(getByRole("button", { name: "Desplazar" })).toBeVisible();
96
+ expect(getByRole("button", { name: "Alejar" })).toBeVisible();
97
+ expect(getByRole("button", { name: "Deshacer" })).toBeVisible();
98
+ expect(getByRole("button", { name: "Guardar cambios" })).toBeVisible();
99
+ });
100
+
101
+ test("editing tools use translated accessible names", async () => {
102
+ const { getByRole } = await render(ImageEditorTestWrapper, {
103
+ kind: "toolbar",
104
+ i18n: spanish_i18n
105
+ });
106
+
107
+ expect(getByRole("button", { name: "Imagen" })).toBeVisible();
108
+ expect(getByRole("button", { name: "Pincel" })).toBeVisible();
109
+ expect(getByRole("button", { name: "Borrar" })).toBeVisible();
110
+ expect(getByRole("button", { name: "Cargar" })).toBeVisible();
111
+ expect(getByRole("button", { name: "Pegar" })).toBeVisible();
112
+ });
113
+
114
+ test("layer controls use translated accessible names", async () => {
115
+ const { getByRole, getByText } = await render(ImageEditorTestWrapper, {
116
+ kind: "layers",
117
+ i18n: spanish_i18n
118
+ });
119
+ const show_layers = getByRole("button", {
120
+ name: "Mostrar capas"
121
+ });
122
+ expect(getByText("Capa 1")).toBeVisible();
123
+
124
+ await fireEvent.click(show_layers);
125
+
126
+ expect(getByText("Capas")).toBeVisible();
127
+ expect(getByRole("button", { name: "Añadir capa" })).toBeVisible();
128
+ });
129
+ });
@@ -0,0 +1,50 @@
1
+ <script lang="ts">
2
+ import type { I18nFormatter } from "@gradio/utils";
3
+ import { writable } from "svelte/store";
4
+ import Controls from "./shared/Controls.svelte";
5
+ import SecondaryToolbar from "./shared/SecondaryToolbar.svelte";
6
+ import Toolbar from "./shared/Toolbar.svelte";
7
+
8
+ let {
9
+ props
10
+ }: {
11
+ props: {
12
+ kind: "controls" | "toolbar" | "layers";
13
+ i18n: I18nFormatter;
14
+ };
15
+ } = $props();
16
+
17
+ const layers = writable({
18
+ active_layer: "layer-1",
19
+ layers: [
20
+ { name: "Layer 1", id: "layer-1", user_created: false, visible: true }
21
+ ]
22
+ });
23
+ </script>
24
+
25
+ {#if props.kind === "controls"}
26
+ <Controls
27
+ i18n={props.i18n}
28
+ tool="image"
29
+ can_undo={false}
30
+ can_redo={false}
31
+ enable_download={true}
32
+ changeable={true}
33
+ />
34
+ {:else if props.kind === "toolbar"}
35
+ <Toolbar
36
+ i18n={props.i18n}
37
+ brush_options={{
38
+ default_size: "auto",
39
+ colors: ["#000000"],
40
+ default_color: "#000000",
41
+ color_mode: "defaults"
42
+ }}
43
+ eraser_options={{ default_size: "auto" }}
44
+ sources={["upload", "clipboard", "webcam"]}
45
+ transforms={[]}
46
+ background={false}
47
+ />
48
+ {:else}
49
+ <SecondaryToolbar i18n={props.i18n} {layers} />
50
+ {/if}
package/Index.svelte CHANGED
@@ -1,5 +1,3 @@
1
- <svelte:options accessors={true} immutable={true} />
2
-
3
1
  <script lang="ts">
4
2
  import type { ImageBlobs } from "./InteractiveImageEditor.svelte";
5
3
 
@@ -1,4 +1,4 @@
1
- <script lang="ts" context="module">
1
+ <script module lang="ts">
2
2
  export interface EditorData {
3
3
  background: FileData | null;
4
4
  layers: FileData[] | null;
@@ -115,11 +115,53 @@
115
115
  return !!o;
116
116
  }
117
117
 
118
+ async function upload_original(
119
+ file_data: FileData | null,
120
+ fallback_name: string
121
+ ): Promise<FileData | null> {
122
+ if (!file_data) return null;
123
+
124
+ let file = file_data.blob;
125
+ if (!file) {
126
+ if (!file_data.url) throw new Error("Image file has no URL");
127
+ const response = await fetch(file_data.url);
128
+ if (!response.ok) throw new Error("Image file could not be fetched");
129
+ const blob = await response.blob();
130
+ file = new File([blob], file_data.orig_name || fallback_name, {
131
+ type: file_data.mime_type || blob.type
132
+ });
133
+ }
134
+
135
+ const uploaded = await upload(await prepare_files([file]), root);
136
+ return Array.isArray(uploaded) ? uploaded[0] : uploaded;
137
+ }
138
+
139
+ async function upload_original_data(): Promise<ImageBlobs> {
140
+ const [uploaded_background, uploaded_composite, ...uploaded_layers] =
141
+ await Promise.all([
142
+ upload_original(background, "background.png"),
143
+ upload_original(composite, "composite.png"),
144
+ ...layers.map((layer, i) => upload_original(layer, `layer_${i}.png`))
145
+ ]);
146
+
147
+ return {
148
+ background: uploaded_background,
149
+ layers: uploaded_layers.filter(is_file_data),
150
+ composite: uploaded_composite
151
+ };
152
+ }
153
+
118
154
  $effect(() => {
119
155
  if (background_image) onupload?.();
120
156
  });
121
157
 
122
158
  export async function get_data(): Promise<ImageBlobs> {
159
+ if (!has_user_edits) {
160
+ try {
161
+ return await upload_original_data();
162
+ } catch {}
163
+ }
164
+
123
165
  let blobs;
124
166
  try {
125
167
  blobs = await editor.get_blobs();
@@ -127,38 +169,43 @@
127
169
  return { background: null, layers: [], composite: null };
128
170
  }
129
171
 
130
- const bg = blobs.background
172
+ const background_upload = blobs.background
131
173
  ? upload(
132
174
  await prepare_files([new File([blobs.background], "background.png")]),
133
175
  root
134
176
  )
135
177
  : Promise.resolve(null);
136
178
 
137
- const layers = blobs.layers
179
+ const layer_uploads = blobs.layers
138
180
  .filter(is_not_null)
139
181
  .map(async (blob, i) =>
140
182
  upload(await prepare_files([new File([blob], `layer_${i}.png`)]), root)
141
183
  );
142
184
 
143
- const composite = blobs.composite
185
+ const composite_upload = blobs.composite
144
186
  ? upload(
145
187
  await prepare_files([new File([blobs.composite], "composite.png")]),
146
188
  root
147
189
  )
148
190
  : Promise.resolve(null);
149
191
 
150
- const [background, composite_, ...layers_] = await Promise.all([
151
- bg,
152
- composite,
153
- ...layers
154
- ]);
192
+ const [uploaded_background, uploaded_composite, ...uploaded_layers] =
193
+ await Promise.all([
194
+ background_upload,
195
+ composite_upload,
196
+ ...layer_uploads
197
+ ]);
155
198
 
156
199
  return {
157
- background: Array.isArray(background) ? background[0] : background,
158
- layers: layers_
200
+ background: Array.isArray(uploaded_background)
201
+ ? uploaded_background[0]
202
+ : uploaded_background,
203
+ layers: uploaded_layers
159
204
  .flatMap((layer) => (Array.isArray(layer) ? layer : [layer]))
160
205
  .filter(is_file_data),
161
- composite: Array.isArray(composite_) ? composite_[0] : composite_
206
+ composite: Array.isArray(uploaded_composite)
207
+ ? uploaded_composite[0]
208
+ : uploaded_composite
162
209
  };
163
210
  }
164
211
 
@@ -176,6 +223,7 @@
176
223
 
177
224
  let background_image = $state(false);
178
225
  let can_undo = $state(false);
226
+ let has_user_edits = $state(false);
179
227
 
180
228
  type BinaryImages = [string, string, File, number | null][];
181
229
 
@@ -282,6 +330,7 @@
282
330
  {show_download_button}
283
331
  {theme_mode}
284
332
  bind:can_undo
333
+ bind:has_user_edits
285
334
  bind:full_history
286
335
  >
287
336
  {#if current_tool === "image" && !can_undo}
@@ -295,15 +344,15 @@
295
344
  <p>{paragraph}</p>
296
345
  {/if}
297
346
  {:else}
298
- <div>Upload an image</div>
347
+ <div>{i18n("image_editor.upload_image")}</div>
299
348
  {/if}
300
349
  {/if}
301
350
 
302
351
  {#if sources && sources.length && brush && !placeholder}
303
- <div class="or">or</div>
352
+ <div class="or">{i18n("common.or")}</div>
304
353
  {/if}
305
354
  {#if brush && !placeholder}
306
- <div>select the draw tool to start</div>
355
+ <div>{i18n("image_editor.select_draw_tool")}</div>
307
356
  {/if}
308
357
  </div>
309
358
  {/if}
@@ -0,0 +1,50 @@
1
+ <script lang="ts">
2
+ import type { I18nFormatter } from "@gradio/utils";
3
+ import { writable } from "svelte/store";
4
+ import Controls from "./shared/Controls.svelte";
5
+ import SecondaryToolbar from "./shared/SecondaryToolbar.svelte";
6
+ import Toolbar from "./shared/Toolbar.svelte";
7
+
8
+ let {
9
+ props
10
+ }: {
11
+ props: {
12
+ kind: "controls" | "toolbar" | "layers";
13
+ i18n: I18nFormatter;
14
+ };
15
+ } = $props();
16
+
17
+ const layers = writable({
18
+ active_layer: "layer-1",
19
+ layers: [
20
+ { name: "Layer 1", id: "layer-1", user_created: false, visible: true }
21
+ ]
22
+ });
23
+ </script>
24
+
25
+ {#if props.kind === "controls"}
26
+ <Controls
27
+ i18n={props.i18n}
28
+ tool="image"
29
+ can_undo={false}
30
+ can_redo={false}
31
+ enable_download={true}
32
+ changeable={true}
33
+ />
34
+ {:else if props.kind === "toolbar"}
35
+ <Toolbar
36
+ i18n={props.i18n}
37
+ brush_options={{
38
+ default_size: "auto",
39
+ colors: ["#000000"],
40
+ default_color: "#000000",
41
+ color_mode: "defaults"
42
+ }}
43
+ eraser_options={{ default_size: "auto" }}
44
+ sources={["upload", "clipboard", "webcam"]}
45
+ transforms={[]}
46
+ background={false}
47
+ />
48
+ {:else}
49
+ <SecondaryToolbar i18n={props.i18n} {layers} />
50
+ {/if}
@@ -0,0 +1,10 @@
1
+ import type { I18nFormatter } from "@gradio/utils";
2
+ type $$ComponentProps = {
3
+ props: {
4
+ kind: "controls" | "toolbar" | "layers";
5
+ i18n: I18nFormatter;
6
+ };
7
+ };
8
+ declare const ImageEditorTestWrapper: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type ImageEditorTestWrapper = ReturnType<typeof ImageEditorTestWrapper>;
10
+ export default ImageEditorTestWrapper;
package/dist/Index.svelte CHANGED
@@ -1,5 +1,3 @@
1
- <svelte:options accessors={true} immutable={true} />
2
-
3
1
  <script lang="ts">
4
2
  import type { ImageBlobs } from "./InteractiveImageEditor.svelte";
5
3
 
@@ -1,4 +1,4 @@
1
- <script lang="ts" context="module">
1
+ <script module lang="ts">
2
2
  export interface EditorData {
3
3
  background: FileData | null;
4
4
  layers: FileData[] | null;
@@ -115,11 +115,53 @@
115
115
  return !!o;
116
116
  }
117
117
 
118
+ async function upload_original(
119
+ file_data: FileData | null,
120
+ fallback_name: string
121
+ ): Promise<FileData | null> {
122
+ if (!file_data) return null;
123
+
124
+ let file = file_data.blob;
125
+ if (!file) {
126
+ if (!file_data.url) throw new Error("Image file has no URL");
127
+ const response = await fetch(file_data.url);
128
+ if (!response.ok) throw new Error("Image file could not be fetched");
129
+ const blob = await response.blob();
130
+ file = new File([blob], file_data.orig_name || fallback_name, {
131
+ type: file_data.mime_type || blob.type
132
+ });
133
+ }
134
+
135
+ const uploaded = await upload(await prepare_files([file]), root);
136
+ return Array.isArray(uploaded) ? uploaded[0] : uploaded;
137
+ }
138
+
139
+ async function upload_original_data(): Promise<ImageBlobs> {
140
+ const [uploaded_background, uploaded_composite, ...uploaded_layers] =
141
+ await Promise.all([
142
+ upload_original(background, "background.png"),
143
+ upload_original(composite, "composite.png"),
144
+ ...layers.map((layer, i) => upload_original(layer, `layer_${i}.png`))
145
+ ]);
146
+
147
+ return {
148
+ background: uploaded_background,
149
+ layers: uploaded_layers.filter(is_file_data),
150
+ composite: uploaded_composite
151
+ };
152
+ }
153
+
118
154
  $effect(() => {
119
155
  if (background_image) onupload?.();
120
156
  });
121
157
 
122
158
  export async function get_data(): Promise<ImageBlobs> {
159
+ if (!has_user_edits) {
160
+ try {
161
+ return await upload_original_data();
162
+ } catch {}
163
+ }
164
+
123
165
  let blobs;
124
166
  try {
125
167
  blobs = await editor.get_blobs();
@@ -127,38 +169,43 @@
127
169
  return { background: null, layers: [], composite: null };
128
170
  }
129
171
 
130
- const bg = blobs.background
172
+ const background_upload = blobs.background
131
173
  ? upload(
132
174
  await prepare_files([new File([blobs.background], "background.png")]),
133
175
  root
134
176
  )
135
177
  : Promise.resolve(null);
136
178
 
137
- const layers = blobs.layers
179
+ const layer_uploads = blobs.layers
138
180
  .filter(is_not_null)
139
181
  .map(async (blob, i) =>
140
182
  upload(await prepare_files([new File([blob], `layer_${i}.png`)]), root)
141
183
  );
142
184
 
143
- const composite = blobs.composite
185
+ const composite_upload = blobs.composite
144
186
  ? upload(
145
187
  await prepare_files([new File([blobs.composite], "composite.png")]),
146
188
  root
147
189
  )
148
190
  : Promise.resolve(null);
149
191
 
150
- const [background, composite_, ...layers_] = await Promise.all([
151
- bg,
152
- composite,
153
- ...layers
154
- ]);
192
+ const [uploaded_background, uploaded_composite, ...uploaded_layers] =
193
+ await Promise.all([
194
+ background_upload,
195
+ composite_upload,
196
+ ...layer_uploads
197
+ ]);
155
198
 
156
199
  return {
157
- background: Array.isArray(background) ? background[0] : background,
158
- layers: layers_
200
+ background: Array.isArray(uploaded_background)
201
+ ? uploaded_background[0]
202
+ : uploaded_background,
203
+ layers: uploaded_layers
159
204
  .flatMap((layer) => (Array.isArray(layer) ? layer : [layer]))
160
205
  .filter(is_file_data),
161
- composite: Array.isArray(composite_) ? composite_[0] : composite_
206
+ composite: Array.isArray(uploaded_composite)
207
+ ? uploaded_composite[0]
208
+ : uploaded_composite
162
209
  };
163
210
  }
164
211
 
@@ -176,6 +223,7 @@
176
223
 
177
224
  let background_image = $state(false);
178
225
  let can_undo = $state(false);
226
+ let has_user_edits = $state(false);
179
227
 
180
228
  type BinaryImages = [string, string, File, number | null][];
181
229
 
@@ -282,6 +330,7 @@
282
330
  {show_download_button}
283
331
  {theme_mode}
284
332
  bind:can_undo
333
+ bind:has_user_edits
285
334
  bind:full_history
286
335
  >
287
336
  {#if current_tool === "image" && !can_undo}
@@ -295,15 +344,15 @@
295
344
  <p>{paragraph}</p>
296
345
  {/if}
297
346
  {:else}
298
- <div>Upload an image</div>
347
+ <div>{i18n("image_editor.upload_image")}</div>
299
348
  {/if}
300
349
  {/if}
301
350
 
302
351
  {#if sources && sources.length && brush && !placeholder}
303
- <div class="or">or</div>
352
+ <div class="or">{i18n("common.or")}</div>
304
353
  {/if}
305
354
  {#if brush && !placeholder}
306
- <div>select the draw tool to start</div>
355
+ <div>{i18n("image_editor.select_draw_tool")}</div>
307
356
  {/if}
308
357
  </div>
309
358
  {/if}
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { IconButton, IconButtonWrapper } from "@gradio/atoms";
3
+ import type { I18nFormatter } from "@gradio/utils";
3
4
  import {
4
5
  Check,
5
6
  Trash,
@@ -11,6 +12,7 @@
11
12
  Redo
12
13
  } from "@gradio/icons";
13
14
  let {
15
+ i18n,
14
16
  can_save = false,
15
17
  changeable = false,
16
18
  current_zoom = 1,
@@ -29,6 +31,7 @@
29
31
  onpan,
30
32
  ondownload
31
33
  }: {
34
+ i18n: I18nFormatter;
32
35
  can_save?: boolean;
33
36
  changeable?: boolean;
34
37
  current_zoom?: number;
@@ -73,7 +76,7 @@
73
76
  {#if enable_download}
74
77
  <IconButton
75
78
  Icon={Download}
76
- label="Download"
79
+ label={i18n("common.download")}
77
80
  onclick={(event) => {
78
81
  ondownload?.();
79
82
  event.stopPropagation();
@@ -83,7 +86,7 @@
83
86
 
84
87
  <IconButton
85
88
  Icon={Pan}
86
- label="Pan"
89
+ label={i18n("image_editor.pan")}
87
90
  onclick={(e) => {
88
91
  e.stopPropagation();
89
92
  onpan?.();
@@ -97,7 +100,7 @@
97
100
 
98
101
  <IconButton
99
102
  Icon={ZoomOut}
100
- label="Zoom out"
103
+ label={i18n("image_editor.zoom_out")}
101
104
  onclick={(event) => {
102
105
  onzoom_out?.();
103
106
  event.stopPropagation();
@@ -105,7 +108,7 @@
105
108
  />
106
109
  <IconButton
107
110
  Icon={ZoomIn}
108
- label="Zoom in"
111
+ label={i18n("image_editor.zoom_in")}
109
112
  onclick={(event) => {
110
113
  onzoom_in?.();
111
114
  event.stopPropagation();
@@ -129,7 +132,7 @@
129
132
  handle_zoom_change("fit");
130
133
  }}
131
134
  >
132
- Fit to screen
135
+ {i18n("image_editor.fit_to_screen")}
133
136
  </button>
134
137
  </li>
135
138
  {#each [0.25, 0.5, 1, 2, 4] as zoom}
@@ -152,7 +155,7 @@
152
155
 
153
156
  <IconButton
154
157
  Icon={Undo}
155
- label="Undo"
158
+ label={i18n("common.undo")}
156
159
  onclick={(event) => {
157
160
  onundo?.();
158
161
  event.stopPropagation();
@@ -162,7 +165,7 @@
162
165
 
163
166
  <IconButton
164
167
  Icon={Redo}
165
- label="Redo"
168
+ label={i18n("image_editor.redo")}
166
169
  onclick={(event) => {
167
170
  onredo?.();
168
171
  event.stopPropagation();
@@ -174,7 +177,7 @@
174
177
  <IconButton
175
178
  disabled={!can_save}
176
179
  Icon={Check}
177
- label="Save changes"
180
+ label={i18n("image_editor.save_changes")}
178
181
  onclick={(event) => {
179
182
  onsave?.();
180
183
  event.stopPropagation();
@@ -185,7 +188,7 @@
185
188
 
186
189
  <IconButton
187
190
  Icon={Trash}
188
- label="Clear canvas"
191
+ label={i18n("image_editor.clear_canvas")}
189
192
  onclick={(event) => {
190
193
  onremove_image?.();
191
194
  event.stopPropagation();