@gradio/imageeditor 0.20.2 → 0.21.0

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,25 @@
1
1
  # @gradio/imageeditor
2
2
 
3
+ ## 0.21.0
4
+
5
+ ### Features
6
+
7
+ - [`96a79b1`](https://github.com/gradio-app/gradio/commit/96a79b19c8b4a0634c9ab4a969460c0ad55ddf77) - Refactor the remaining component unit tests and remove components.test.ts. Thanks @abidlabs!
8
+
9
+ ### Dependency updates
10
+
11
+ - @gradio/upload@0.19.0
12
+ - @gradio/statustracker@0.15.4
13
+ - @gradio/client@2.7.1
14
+ - @gradio/image@0.28.4
15
+
16
+ ## 0.20.2
17
+
18
+ ### Dependency updates
19
+
20
+ - @gradio/utils@0.14.2
21
+ - @gradio/client@2.7.0
22
+
3
23
  ## 0.20.2
4
24
 
5
25
  ### Fixes
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, test } from "vitest";
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
2
  import {
3
3
  cleanup,
4
4
  fireEvent,
@@ -127,3 +127,589 @@ describe("Internationalization", () => {
127
127
  expect(getByRole("button", { name: "Añadir capa" })).toBeVisible();
128
128
  });
129
129
  });
130
+
131
+ describe("Static mode", () => {
132
+ afterEach(() => cleanup());
133
+
134
+ const static_props = { ...default_props, interactive: false };
135
+
136
+ test("renders the composite image", async () => {
137
+ const { getByRole } = await render(ImageEditor, {
138
+ ...static_props,
139
+ value: { background: null, layers: [], composite: TEST_PNG }
140
+ });
141
+
142
+ // The static branch renders the composite through StaticImage without any
143
+ // alt text, so the <img> is exposed as decorative rather than role="img".
144
+ await waitFor(() => expect(getByRole("presentation")).toBeVisible());
145
+ });
146
+
147
+ test("renders no editing toolbar", async () => {
148
+ const { queryByRole } = await render(ImageEditor, {
149
+ ...static_props,
150
+ value: { background: null, layers: [], composite: TEST_PNG }
151
+ });
152
+
153
+ expect(
154
+ queryByRole("button", { name: "image_editor.brush" })
155
+ ).not.toBeInTheDocument();
156
+ });
157
+
158
+ test("renders the label", async () => {
159
+ const { getByText } = await render(ImageEditor, {
160
+ ...static_props,
161
+ label: "Sketch pad",
162
+ show_label: true,
163
+ value: { background: null, layers: [], composite: TEST_PNG }
164
+ });
165
+
166
+ await waitFor(() => expect(getByText("Sketch pad")).toBeVisible());
167
+ });
168
+
169
+ test("renders an empty component when there is no value", async () => {
170
+ const { queryByRole } = await render(ImageEditor, {
171
+ ...static_props,
172
+ value: null
173
+ });
174
+
175
+ expect(queryByRole("presentation")).not.toBeInTheDocument();
176
+ });
177
+
178
+ test("clear_status is dispatched when the error status is dismissed", async () => {
179
+ const { listen, getByLabelText } = await render(ImageEditor, {
180
+ ...static_props,
181
+ loading_status: {
182
+ status: "error",
183
+ queue_position: null,
184
+ queue_size: null,
185
+ eta: null,
186
+ message: "it broke",
187
+ show_progress: "full",
188
+ scroll_to_output: false,
189
+ visible: true,
190
+ fn_index: 0
191
+ }
192
+ });
193
+ const clear_status = listen("clear_status");
194
+
195
+ await fireEvent.click(getByLabelText("common.clear"));
196
+
197
+ expect(clear_status).toHaveBeenCalledTimes(1);
198
+ });
199
+ });
200
+
201
+ describe("Interactive mode", () => {
202
+ afterEach(() => cleanup());
203
+
204
+ async function render_editor(
205
+ overrides: Record<string, any> = {}
206
+ ): Promise<Awaited<ReturnType<typeof render>>> {
207
+ const view = await render(ImageEditor, {
208
+ ...default_props,
209
+ client: mock_client(),
210
+ ...overrides
211
+ });
212
+ await waitFor(
213
+ () => {
214
+ expect(
215
+ view.getByRole("button", { name: "image_editor.pan" })
216
+ ).toBeVisible();
217
+ },
218
+ { timeout: 5000 }
219
+ );
220
+ return view;
221
+ }
222
+
223
+ test("renders the canvas controls", async () => {
224
+ const { getByRole } = await render_editor();
225
+
226
+ expect(getByRole("button", { name: "image_editor.pan" })).toBeVisible();
227
+ expect(
228
+ getByRole("button", { name: "image_editor.zoom_out" })
229
+ ).toBeVisible();
230
+ expect(getByRole("button", { name: "common.undo" })).toBeVisible();
231
+ expect(
232
+ getByRole("button", { name: "image_editor.clear_canvas" })
233
+ ).toBeVisible();
234
+ });
235
+
236
+ test("renders the drawing tools", async () => {
237
+ const { getByRole } = await render_editor();
238
+
239
+ expect(getByRole("button", { name: "image_editor.brush" })).toBeVisible();
240
+ expect(getByRole("button", { name: "image_editor.erase" })).toBeVisible();
241
+ });
242
+
243
+ test("undo and redo start disabled on a fresh canvas", async () => {
244
+ const { getByRole } = await render_editor();
245
+
246
+ expect(getByRole("button", { name: "common.undo" })).toBeDisabled();
247
+ expect(getByRole("button", { name: "image_editor.redo" })).toBeDisabled();
248
+ });
249
+
250
+ test("renders the label", async () => {
251
+ const { getByText } = await render_editor({
252
+ label: "Sketch pad",
253
+ show_label: true
254
+ });
255
+
256
+ expect(getByText("Sketch pad")).toBeVisible();
257
+ });
258
+
259
+ test("the download button is shown by default", async () => {
260
+ const { getByRole } = await render_editor({ buttons: null });
261
+
262
+ expect(getByRole("button", { name: "common.download" })).toBeVisible();
263
+ });
264
+
265
+ test("an empty buttons list hides the download button", async () => {
266
+ const { queryByRole } = await render_editor({ buttons: [] });
267
+
268
+ expect(
269
+ queryByRole("button", { name: "common.download" })
270
+ ).not.toBeInTheDocument();
271
+ });
272
+
273
+ test("buttons: ['download'] shows the download button", async () => {
274
+ const { getByRole } = await render_editor({ buttons: ["download"] });
275
+
276
+ expect(getByRole("button", { name: "common.download" })).toBeVisible();
277
+ });
278
+
279
+ test("the save button only appears when an apply listener is attached", async () => {
280
+ const { queryByRole } = await render_editor({ attached_events: [] });
281
+
282
+ expect(
283
+ queryByRole("button", { name: "image_editor.save_changes" })
284
+ ).not.toBeInTheDocument();
285
+ });
286
+
287
+ test("attaching an apply listener shows the save button", async () => {
288
+ const { getByRole } = await render_editor({ attached_events: ["apply"] });
289
+
290
+ expect(
291
+ getByRole("button", { name: "image_editor.save_changes" })
292
+ ).toBeVisible();
293
+ });
294
+ });
295
+
296
+ describe("Toolbar: sources", () => {
297
+ afterEach(() => cleanup());
298
+
299
+ test("all three sources render their own button", async () => {
300
+ const { getByRole } = await render(ImageEditorTestWrapper, {
301
+ kind: "toolbar",
302
+ sources: ["upload", "clipboard", "webcam"]
303
+ });
304
+
305
+ expect(getByRole("button", { name: "image_editor.upload" })).toBeVisible();
306
+ expect(getByRole("button", { name: "image_editor.paste" })).toBeVisible();
307
+ expect(getByRole("button", { name: "image_editor.webcam" })).toBeVisible();
308
+ });
309
+
310
+ test("only the listed sources are offered", async () => {
311
+ const { getByRole, queryByRole } = await render(ImageEditorTestWrapper, {
312
+ kind: "toolbar",
313
+ sources: ["upload"]
314
+ });
315
+
316
+ expect(getByRole("button", { name: "image_editor.upload" })).toBeVisible();
317
+ expect(
318
+ queryByRole("button", { name: "image_editor.webcam" })
319
+ ).not.toBeInTheDocument();
320
+ expect(
321
+ queryByRole("button", { name: "image_editor.paste" })
322
+ ).not.toBeInTheDocument();
323
+ });
324
+
325
+ test("no sources and no transforms hides the image tool entirely", async () => {
326
+ const { queryByRole } = await render(ImageEditorTestWrapper, {
327
+ kind: "toolbar",
328
+ sources: [],
329
+ transforms: []
330
+ });
331
+
332
+ expect(
333
+ queryByRole("button", { name: "image.image" })
334
+ ).not.toBeInTheDocument();
335
+ });
336
+
337
+ test("a transform keeps the image tool available once there is a background", async () => {
338
+ const { getByRole } = await render(ImageEditorTestWrapper, {
339
+ kind: "toolbar",
340
+ sources: [],
341
+ transforms: ["crop"],
342
+ background: true
343
+ });
344
+
345
+ expect(getByRole("button", { name: "image.image" })).toBeVisible();
346
+ });
347
+ });
348
+
349
+ describe("Toolbar: transforms", () => {
350
+ afterEach(() => cleanup());
351
+
352
+ test("crop and resize are offered for an image with a background", async () => {
353
+ const { getByRole } = await render(ImageEditorTestWrapper, {
354
+ kind: "toolbar",
355
+ transforms: ["crop", "resize"],
356
+ background: true
357
+ });
358
+
359
+ expect(getByRole("button", { name: "image_editor.crop" })).toBeVisible();
360
+ expect(getByRole("button", { name: "image_editor.resize" })).toBeVisible();
361
+ });
362
+
363
+ test("only the listed transforms are offered", async () => {
364
+ const { getByRole, queryByRole } = await render(ImageEditorTestWrapper, {
365
+ kind: "toolbar",
366
+ transforms: ["crop"],
367
+ background: true
368
+ });
369
+
370
+ expect(getByRole("button", { name: "image_editor.crop" })).toBeVisible();
371
+ expect(
372
+ queryByRole("button", { name: "image_editor.resize" })
373
+ ).not.toBeInTheDocument();
374
+ });
375
+
376
+ test("transforms are not offered until there is a background to transform", async () => {
377
+ const { queryByRole } = await render(ImageEditorTestWrapper, {
378
+ kind: "toolbar",
379
+ transforms: ["crop", "resize"],
380
+ background: false
381
+ });
382
+
383
+ expect(
384
+ queryByRole("button", { name: "image_editor.crop" })
385
+ ).not.toBeInTheDocument();
386
+ });
387
+ });
388
+
389
+ describe("Toolbar: brush and eraser", () => {
390
+ afterEach(() => cleanup());
391
+
392
+ test("brush: false removes the brush tool", async () => {
393
+ const { queryByRole, getByRole } = await render(ImageEditorTestWrapper, {
394
+ kind: "toolbar",
395
+ brush_options: false
396
+ });
397
+
398
+ expect(
399
+ queryByRole("button", { name: "image_editor.brush" })
400
+ ).not.toBeInTheDocument();
401
+ expect(getByRole("button", { name: "image_editor.erase" })).toBeVisible();
402
+ });
403
+
404
+ test("eraser: false removes the eraser tool", async () => {
405
+ const { queryByRole, getByRole } = await render(ImageEditorTestWrapper, {
406
+ kind: "toolbar",
407
+ eraser_options: false
408
+ });
409
+
410
+ expect(
411
+ queryByRole("button", { name: "image_editor.erase" })
412
+ ).not.toBeInTheDocument();
413
+ expect(getByRole("button", { name: "image_editor.brush" })).toBeVisible();
414
+ });
415
+
416
+ test("clicking the brush asks the editor to switch to the draw tool", async () => {
417
+ const ontool_change = vi.fn();
418
+ const { getByRole } = await render(ImageEditorTestWrapper, {
419
+ kind: "toolbar",
420
+ ontool_change
421
+ });
422
+
423
+ await fireEvent.click(getByRole("button", { name: "image_editor.brush" }));
424
+
425
+ expect(ontool_change).toHaveBeenCalledWith({ tool: "draw" });
426
+ });
427
+
428
+ test("clicking the eraser asks the editor to switch to the erase tool", async () => {
429
+ const ontool_change = vi.fn();
430
+ const { getByRole } = await render(ImageEditorTestWrapper, {
431
+ kind: "toolbar",
432
+ ontool_change
433
+ });
434
+
435
+ await fireEvent.click(getByRole("button", { name: "image_editor.erase" }));
436
+
437
+ expect(ontool_change).toHaveBeenCalledWith({ tool: "erase" });
438
+ });
439
+
440
+ test("the draw tool offers colour and size controls", async () => {
441
+ const { getByRole } = await render(ImageEditorTestWrapper, {
442
+ kind: "toolbar",
443
+ tool: "draw"
444
+ });
445
+
446
+ expect(getByRole("button", { name: "image_editor.color" })).toBeVisible();
447
+ expect(
448
+ getByRole("button", { name: "image_editor.brush_size" })
449
+ ).toBeVisible();
450
+ });
451
+
452
+ test("the erase tool offers a size control", async () => {
453
+ const { getByRole } = await render(ImageEditorTestWrapper, {
454
+ kind: "toolbar",
455
+ tool: "erase"
456
+ });
457
+
458
+ expect(
459
+ getByRole("button", { name: "image_editor.eraser_size" })
460
+ ).toBeVisible();
461
+ });
462
+
463
+ test("choosing the brush size sub-tool is reported to the editor", async () => {
464
+ const onsubtool_change = vi.fn();
465
+ const { getByRole } = await render(ImageEditorTestWrapper, {
466
+ kind: "toolbar",
467
+ tool: "draw",
468
+ onsubtool_change
469
+ });
470
+
471
+ await fireEvent.click(
472
+ getByRole("button", { name: "image_editor.brush_size" })
473
+ );
474
+
475
+ expect(onsubtool_change).toHaveBeenCalledWith({
476
+ tool: "draw",
477
+ subtool: "size"
478
+ });
479
+ });
480
+ });
481
+
482
+ describe("Controls", () => {
483
+ afterEach(() => cleanup());
484
+
485
+ test("undo is disabled until there is something to undo", async () => {
486
+ const { getByRole } = await render(ImageEditorTestWrapper, {
487
+ kind: "controls",
488
+ can_undo: false
489
+ });
490
+
491
+ expect(getByRole("button", { name: "common.undo" })).toBeDisabled();
492
+ });
493
+
494
+ test("undo is enabled when there is something to undo", async () => {
495
+ const { getByRole } = await render(ImageEditorTestWrapper, {
496
+ kind: "controls",
497
+ can_undo: true
498
+ });
499
+
500
+ expect(getByRole("button", { name: "common.undo" })).toBeEnabled();
501
+ });
502
+
503
+ test("clicking undo calls back", async () => {
504
+ const onundo = vi.fn();
505
+ const { getByRole } = await render(ImageEditorTestWrapper, {
506
+ kind: "controls",
507
+ can_undo: true,
508
+ onundo
509
+ });
510
+
511
+ await fireEvent.click(getByRole("button", { name: "common.undo" }));
512
+
513
+ expect(onundo).toHaveBeenCalledTimes(1);
514
+ });
515
+
516
+ test("clicking redo calls back", async () => {
517
+ const onredo = vi.fn();
518
+ const { getByRole } = await render(ImageEditorTestWrapper, {
519
+ kind: "controls",
520
+ can_redo: true,
521
+ onredo
522
+ });
523
+
524
+ await fireEvent.click(getByRole("button", { name: "image_editor.redo" }));
525
+
526
+ expect(onredo).toHaveBeenCalledTimes(1);
527
+ });
528
+
529
+ test("clicking clear calls back", async () => {
530
+ const onremove_image = vi.fn();
531
+ const { getByRole } = await render(ImageEditorTestWrapper, {
532
+ kind: "controls",
533
+ onremove_image
534
+ });
535
+
536
+ await fireEvent.click(
537
+ getByRole("button", { name: "image_editor.clear_canvas" })
538
+ );
539
+
540
+ expect(onremove_image).toHaveBeenCalledTimes(1);
541
+ });
542
+
543
+ test("enable_download: false removes the download button", async () => {
544
+ const { queryByRole } = await render(ImageEditorTestWrapper, {
545
+ kind: "controls",
546
+ enable_download: false
547
+ });
548
+
549
+ expect(
550
+ queryByRole("button", { name: "common.download" })
551
+ ).not.toBeInTheDocument();
552
+ });
553
+
554
+ test("changeable: false removes the save button", async () => {
555
+ const { queryByRole } = await render(ImageEditorTestWrapper, {
556
+ kind: "controls",
557
+ changeable: false
558
+ });
559
+
560
+ expect(
561
+ queryByRole("button", { name: "image_editor.save_changes" })
562
+ ).not.toBeInTheDocument();
563
+ });
564
+
565
+ test("save is disabled until there are changes to save", async () => {
566
+ const { getByRole } = await render(ImageEditorTestWrapper, {
567
+ kind: "controls",
568
+ changeable: true,
569
+ can_save: false
570
+ });
571
+
572
+ expect(
573
+ getByRole("button", { name: "image_editor.save_changes" })
574
+ ).toBeDisabled();
575
+ });
576
+
577
+ test("clicking save calls back", async () => {
578
+ const onsave = vi.fn();
579
+ const { getByRole } = await render(ImageEditorTestWrapper, {
580
+ kind: "controls",
581
+ changeable: true,
582
+ can_save: true,
583
+ onsave
584
+ });
585
+
586
+ await fireEvent.click(
587
+ getByRole("button", { name: "image_editor.save_changes" })
588
+ );
589
+
590
+ expect(onsave).toHaveBeenCalledTimes(1);
591
+ });
592
+
593
+ test("clicking zoom out calls back", async () => {
594
+ const onzoom_out = vi.fn();
595
+ const { getByRole } = await render(ImageEditorTestWrapper, {
596
+ kind: "controls",
597
+ onzoom_out
598
+ });
599
+
600
+ await fireEvent.click(
601
+ getByRole("button", { name: "image_editor.zoom_out" })
602
+ );
603
+
604
+ expect(onzoom_out).toHaveBeenCalledTimes(1);
605
+ });
606
+
607
+ test("clicking pan calls back", async () => {
608
+ const onpan = vi.fn();
609
+ const { getByRole } = await render(ImageEditorTestWrapper, {
610
+ kind: "controls",
611
+ onpan
612
+ });
613
+
614
+ await fireEvent.click(getByRole("button", { name: "image_editor.pan" }));
615
+
616
+ expect(onpan).toHaveBeenCalledTimes(1);
617
+ });
618
+ });
619
+
620
+ describe("Layers", () => {
621
+ afterEach(() => cleanup());
622
+
623
+ const named_layers = {
624
+ active_layer: "layer-1",
625
+ layers: [
626
+ { name: "Background", id: "layer-1", user_created: false, visible: true },
627
+ { name: "Sketch", id: "layer-2", user_created: true, visible: true }
628
+ ]
629
+ };
630
+
631
+ test("the active layer is named in the toolbar", async () => {
632
+ const { getByText } = await render(ImageEditorTestWrapper, {
633
+ kind: "layers",
634
+ layer_store: named_layers
635
+ });
636
+
637
+ expect(getByText("Background")).toBeVisible();
638
+ });
639
+
640
+ test("opening the layer panel lists every layer", async () => {
641
+ const { getByRole, getAllByText } = await render(ImageEditorTestWrapper, {
642
+ kind: "layers",
643
+ layer_store: named_layers
644
+ });
645
+
646
+ await fireEvent.click(
647
+ getByRole("button", { name: "image_editor.show_layers" })
648
+ );
649
+
650
+ expect(getAllByText("Sketch").length).toBeGreaterThan(0);
651
+ });
652
+
653
+ test("auto-generated layer names are translated", async () => {
654
+ const { getByText } = await render(ImageEditorTestWrapper, {
655
+ kind: "layers",
656
+ i18n: spanish_i18n
657
+ });
658
+
659
+ expect(getByText("Capa 1")).toBeVisible();
660
+ });
661
+
662
+ test("a user-supplied layer name is shown verbatim", async () => {
663
+ const { getByText } = await render(ImageEditorTestWrapper, {
664
+ kind: "layers",
665
+ i18n: spanish_i18n,
666
+ layer_store: named_layers
667
+ });
668
+
669
+ expect(getByText("Background")).toBeVisible();
670
+ });
671
+
672
+ test("a new layer can be added when additional layers are allowed", async () => {
673
+ const onnew_layer = vi.fn();
674
+ const { getByRole } = await render(ImageEditorTestWrapper, {
675
+ kind: "layers",
676
+ enable_additional_layers: true,
677
+ onnew_layer
678
+ });
679
+
680
+ await fireEvent.click(
681
+ getByRole("button", { name: "image_editor.show_layers" })
682
+ );
683
+ await fireEvent.click(
684
+ getByRole("button", { name: "image_editor.add_layer" })
685
+ );
686
+
687
+ expect(onnew_layer).toHaveBeenCalledTimes(1);
688
+ });
689
+
690
+ test("the add layer button is hidden when additional layers are disallowed", async () => {
691
+ const { getByRole, queryByRole } = await render(ImageEditorTestWrapper, {
692
+ kind: "layers",
693
+ enable_additional_layers: false
694
+ });
695
+
696
+ await fireEvent.click(
697
+ getByRole("button", { name: "image_editor.show_layers" })
698
+ );
699
+
700
+ expect(
701
+ queryByRole("button", { name: "image_editor.add_layer" })
702
+ ).not.toBeInTheDocument();
703
+ });
704
+
705
+ test("enable_layers: false renders no layer controls at all", async () => {
706
+ const { queryByRole } = await render(ImageEditorTestWrapper, {
707
+ kind: "layers",
708
+ enable_layers: false
709
+ });
710
+
711
+ expect(
712
+ queryByRole("button", { name: "image_editor.show_layers" })
713
+ ).not.toBeInTheDocument();
714
+ });
715
+ });
@@ -10,41 +10,72 @@
10
10
  }: {
11
11
  props: {
12
12
  kind: "controls" | "toolbar" | "layers";
13
- i18n: I18nFormatter;
13
+ i18n?: I18nFormatter;
14
+ [key: string]: any;
14
15
  };
15
16
  } = $props();
16
17
 
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
- });
18
+ const identity: I18nFormatter = (s: string) => s;
19
+
20
+ const default_brush = {
21
+ default_size: "auto" as const,
22
+ colors: ["#000000"],
23
+ default_color: "#000000",
24
+ color_mode: "defaults" as const
25
+ };
26
+
27
+ const layers = writable(
28
+ props.layer_store ?? {
29
+ active_layer: "layer-1",
30
+ layers: [
31
+ { name: "Layer 1", id: "layer-1", user_created: false, visible: true }
32
+ ]
33
+ }
34
+ );
23
35
  </script>
24
36
 
25
37
  {#if props.kind === "controls"}
26
38
  <Controls
27
- i18n={props.i18n}
28
- tool="image"
29
- can_undo={false}
30
- can_redo={false}
31
- enable_download={true}
32
- changeable={true}
39
+ i18n={props.i18n ?? identity}
40
+ tool={props.tool ?? "image"}
41
+ can_undo={props.can_undo ?? false}
42
+ can_redo={props.can_redo ?? false}
43
+ can_save={props.can_save ?? false}
44
+ enable_download={props.enable_download ?? true}
45
+ changeable={props.changeable ?? true}
46
+ current_zoom={props.current_zoom ?? 1}
47
+ onundo={props.onundo}
48
+ onredo={props.onredo}
49
+ onsave={props.onsave}
50
+ onremove_image={props.onremove_image}
51
+ ondownload={props.ondownload}
52
+ onzoom_in={props.onzoom_in}
53
+ onzoom_out={props.onzoom_out}
54
+ onpan={props.onpan}
33
55
  />
34
56
  {:else if props.kind === "toolbar"}
35
57
  <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}
58
+ i18n={props.i18n ?? identity}
59
+ brush_options={props.brush_options === undefined
60
+ ? default_brush
61
+ : props.brush_options}
62
+ eraser_options={props.eraser_options === undefined
63
+ ? { default_size: "auto" }
64
+ : props.eraser_options}
65
+ sources={props.sources ?? ["upload", "clipboard", "webcam"]}
66
+ transforms={props.transforms ?? []}
67
+ background={props.background ?? false}
68
+ tool={props.tool ?? "image"}
69
+ subtool={props.subtool ?? null}
70
+ ontool_change={props.ontool_change}
71
+ onsubtool_change={props.onsubtool_change}
47
72
  />
48
73
  {:else}
49
- <SecondaryToolbar i18n={props.i18n} {layers} />
74
+ <SecondaryToolbar
75
+ i18n={props.i18n ?? identity}
76
+ {layers}
77
+ enable_layers={props.enable_layers ?? true}
78
+ enable_additional_layers={props.enable_additional_layers ?? true}
79
+ onnew_layer={props.onnew_layer}
80
+ />
50
81
  {/if}
@@ -10,41 +10,72 @@
10
10
  }: {
11
11
  props: {
12
12
  kind: "controls" | "toolbar" | "layers";
13
- i18n: I18nFormatter;
13
+ i18n?: I18nFormatter;
14
+ [key: string]: any;
14
15
  };
15
16
  } = $props();
16
17
 
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
- });
18
+ const identity: I18nFormatter = (s: string) => s;
19
+
20
+ const default_brush = {
21
+ default_size: "auto" as const,
22
+ colors: ["#000000"],
23
+ default_color: "#000000",
24
+ color_mode: "defaults" as const
25
+ };
26
+
27
+ const layers = writable(
28
+ props.layer_store ?? {
29
+ active_layer: "layer-1",
30
+ layers: [
31
+ { name: "Layer 1", id: "layer-1", user_created: false, visible: true }
32
+ ]
33
+ }
34
+ );
23
35
  </script>
24
36
 
25
37
  {#if props.kind === "controls"}
26
38
  <Controls
27
- i18n={props.i18n}
28
- tool="image"
29
- can_undo={false}
30
- can_redo={false}
31
- enable_download={true}
32
- changeable={true}
39
+ i18n={props.i18n ?? identity}
40
+ tool={props.tool ?? "image"}
41
+ can_undo={props.can_undo ?? false}
42
+ can_redo={props.can_redo ?? false}
43
+ can_save={props.can_save ?? false}
44
+ enable_download={props.enable_download ?? true}
45
+ changeable={props.changeable ?? true}
46
+ current_zoom={props.current_zoom ?? 1}
47
+ onundo={props.onundo}
48
+ onredo={props.onredo}
49
+ onsave={props.onsave}
50
+ onremove_image={props.onremove_image}
51
+ ondownload={props.ondownload}
52
+ onzoom_in={props.onzoom_in}
53
+ onzoom_out={props.onzoom_out}
54
+ onpan={props.onpan}
33
55
  />
34
56
  {:else if props.kind === "toolbar"}
35
57
  <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}
58
+ i18n={props.i18n ?? identity}
59
+ brush_options={props.brush_options === undefined
60
+ ? default_brush
61
+ : props.brush_options}
62
+ eraser_options={props.eraser_options === undefined
63
+ ? { default_size: "auto" }
64
+ : props.eraser_options}
65
+ sources={props.sources ?? ["upload", "clipboard", "webcam"]}
66
+ transforms={props.transforms ?? []}
67
+ background={props.background ?? false}
68
+ tool={props.tool ?? "image"}
69
+ subtool={props.subtool ?? null}
70
+ ontool_change={props.ontool_change}
71
+ onsubtool_change={props.onsubtool_change}
47
72
  />
48
73
  {:else}
49
- <SecondaryToolbar i18n={props.i18n} {layers} />
74
+ <SecondaryToolbar
75
+ i18n={props.i18n ?? identity}
76
+ {layers}
77
+ enable_layers={props.enable_layers ?? true}
78
+ enable_additional_layers={props.enable_additional_layers ?? true}
79
+ onnew_layer={props.onnew_layer}
80
+ />
50
81
  {/if}
@@ -2,7 +2,8 @@ import type { I18nFormatter } from "@gradio/utils";
2
2
  type $$ComponentProps = {
3
3
  props: {
4
4
  kind: "controls" | "toolbar" | "layers";
5
- i18n: I18nFormatter;
5
+ i18n?: I18nFormatter;
6
+ [key: string]: any;
6
7
  };
7
8
  };
8
9
  declare const ImageEditorTestWrapper: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -789,7 +789,16 @@ export class ResizeTool {
789
789
  * Cleans up DOM event listeners
790
790
  */
791
791
  cleanup_dom_event_listeners() {
792
- const canvas = this.image_editor_context?.app?.canvas;
792
+ // Pixi's `canvas` getter dereferences the renderer, which is null once the
793
+ // app has been destroyed — so optional chaining on `app` is not enough when
794
+ // the editor is torn down before this cleanup runs.
795
+ let canvas;
796
+ try {
797
+ canvas = this.image_editor_context?.app?.canvas;
798
+ }
799
+ catch {
800
+ canvas = undefined;
801
+ }
793
802
  if (this.dom_mousedown_handler) {
794
803
  canvas?.removeEventListener("mousedown", this.dom_mousedown_handler);
795
804
  this.dom_mousedown_handler = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradio/imageeditor",
3
- "version": "0.20.2",
3
+ "version": "0.21.0",
4
4
  "description": "Gradio UI packages",
5
5
  "type": "module",
6
6
  "author": "",
@@ -11,16 +11,16 @@
11
11
  "pixi-filters": "^6.1.4",
12
12
  "pixi.js": "^8.14.0",
13
13
  "tinycolor2": "^1.6.0",
14
- "@gradio/client": "^2.6.0",
15
- "@gradio/image": "^0.28.3",
16
- "@gradio/icons": "^0.16.0",
17
- "@gradio/statustracker": "^0.15.3",
18
- "@gradio/utils": "^0.14.1",
19
14
  "@gradio/atoms": "^0.26.2",
20
- "@gradio/upload": "^0.18.3"
15
+ "@gradio/icons": "^0.16.0",
16
+ "@gradio/image": "^0.28.4",
17
+ "@gradio/upload": "^0.19.0",
18
+ "@gradio/statustracker": "^0.15.4",
19
+ "@gradio/client": "^2.7.1",
20
+ "@gradio/utils": "^0.14.2"
21
21
  },
22
22
  "devDependencies": {
23
- "@gradio/preview": "^0.17.0"
23
+ "@gradio/preview": "^0.17.1"
24
24
  },
25
25
  "main_changeset": true,
26
26
  "main": "./Index.svelte",
@@ -1078,7 +1078,15 @@ export class ResizeTool implements Tool {
1078
1078
  * Cleans up DOM event listeners
1079
1079
  */
1080
1080
  private cleanup_dom_event_listeners(): void {
1081
- const canvas = this.image_editor_context?.app?.canvas;
1081
+ // Pixi's `canvas` getter dereferences the renderer, which is null once the
1082
+ // app has been destroyed — so optional chaining on `app` is not enough when
1083
+ // the editor is torn down before this cleanup runs.
1084
+ let canvas: HTMLCanvasElement | undefined;
1085
+ try {
1086
+ canvas = this.image_editor_context?.app?.canvas;
1087
+ } catch {
1088
+ canvas = undefined;
1089
+ }
1082
1090
 
1083
1091
  if (this.dom_mousedown_handler) {
1084
1092
  canvas?.removeEventListener("mousedown", this.dom_mousedown_handler);