@gradio/imageeditor 0.12.14 → 0.14.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.
Files changed (131) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/Example.svelte +1 -1
  3. package/IMAGE_EDITOR_OVERVIEW.md +224 -0
  4. package/ImageEditor.stories.svelte +13 -5
  5. package/Index.svelte +46 -45
  6. package/{shared/InteractiveImageEditor.svelte → InteractiveImageEditor.svelte} +65 -103
  7. package/README.md +2 -0
  8. package/dist/Example.svelte.d.ts +1 -1
  9. package/dist/Index.svelte +35 -33
  10. package/dist/Index.svelte.d.ts +25 -16
  11. package/dist/{shared/InteractiveImageEditor.svelte → InteractiveImageEditor.svelte} +54 -88
  12. package/dist/{shared/InteractiveImageEditor.svelte.d.ts → InteractiveImageEditor.svelte.d.ts} +17 -15
  13. package/dist/shared/Anchor.svelte +311 -0
  14. package/dist/shared/Anchor.svelte.d.ts +18 -0
  15. package/dist/shared/Controls.svelte +169 -18
  16. package/dist/shared/Controls.svelte.d.ts +9 -2
  17. package/dist/shared/{tools/IconButton.svelte → IconButton.svelte} +19 -3
  18. package/dist/shared/{tools/IconButton.svelte.d.ts → IconButton.svelte.d.ts} +4 -1
  19. package/dist/shared/ImageEditor.svelte +572 -289
  20. package/dist/shared/ImageEditor.svelte.d.ts +54 -56
  21. package/dist/shared/Layers.svelte +260 -0
  22. package/dist/shared/Layers.svelte.d.ts +37 -0
  23. package/dist/shared/Resize.svelte +218 -0
  24. package/dist/shared/Resize.svelte.d.ts +29 -0
  25. package/dist/shared/SecondaryToolbar.svelte +55 -0
  26. package/dist/shared/SecondaryToolbar.svelte.d.ts +37 -0
  27. package/dist/shared/Toolbar.svelte +337 -0
  28. package/dist/shared/Toolbar.svelte.d.ts +42 -0
  29. package/dist/shared/{tools → brush}/BrushOptions.svelte +68 -64
  30. package/dist/shared/{tools → brush}/BrushOptions.svelte.d.ts +9 -9
  31. package/dist/shared/{tools → brush}/BrushSize.svelte +2 -27
  32. package/dist/shared/{tools → brush}/ColorField.svelte +2 -1
  33. package/dist/shared/{tools → brush}/ColorField.svelte.d.ts +1 -0
  34. package/dist/shared/{tools → brush}/ColorPicker.svelte +81 -9
  35. package/dist/shared/{tools → brush}/ColorPicker.svelte.d.ts +1 -0
  36. package/dist/shared/{tools → brush}/ColorSwatch.svelte +78 -38
  37. package/dist/shared/{tools → brush}/ColorSwatch.svelte.d.ts +6 -2
  38. package/dist/shared/brush/brush-cursor.d.ts +101 -0
  39. package/dist/shared/brush/brush-cursor.js +327 -0
  40. package/dist/shared/brush/brush-textures.d.ts +65 -0
  41. package/dist/shared/brush/brush-textures.js +385 -0
  42. package/dist/shared/brush/brush-utils.d.ts +32 -0
  43. package/dist/shared/brush/brush-utils.js +59 -0
  44. package/dist/shared/brush/brush.d.ts +152 -0
  45. package/dist/shared/brush/brush.js +397 -0
  46. package/dist/shared/brush/types.d.ts +37 -0
  47. package/dist/shared/brush/types.js +1 -0
  48. package/dist/shared/core/editor.d.ts +261 -0
  49. package/dist/shared/core/editor.js +1264 -0
  50. package/dist/shared/crop/crop.d.ts +201 -0
  51. package/dist/shared/crop/crop.js +727 -0
  52. package/dist/shared/image/image.d.ts +89 -0
  53. package/dist/shared/image/image.js +183 -0
  54. package/dist/shared/resize/resize.d.ts +270 -0
  55. package/dist/shared/resize/resize.js +1413 -0
  56. package/dist/shared/types.d.ts +16 -0
  57. package/dist/shared/utils/pixi.d.ts +16 -57
  58. package/dist/shared/utils/pixi.js +15 -113
  59. package/dist/shared/zoom/zoom.d.ts +136 -0
  60. package/dist/shared/zoom/zoom.js +548 -0
  61. package/package.json +8 -7
  62. package/shared/Anchor.svelte +384 -0
  63. package/shared/Controls.svelte +178 -37
  64. package/shared/{tools/IconButton.svelte → IconButton.svelte} +21 -4
  65. package/shared/ImageEditor.svelte +712 -350
  66. package/shared/Layers.svelte +282 -0
  67. package/shared/Resize.svelte +239 -0
  68. package/shared/SecondaryToolbar.svelte +67 -0
  69. package/shared/Toolbar.svelte +386 -0
  70. package/shared/brush/BRUSH_TOOL.md +134 -0
  71. package/shared/brush/BrushOptions.svelte +200 -0
  72. package/shared/{tools → brush}/BrushSize.svelte +2 -30
  73. package/shared/{tools → brush}/ColorField.svelte +3 -1
  74. package/shared/{tools → brush}/ColorPicker.svelte +86 -9
  75. package/shared/{tools → brush}/ColorSwatch.svelte +95 -42
  76. package/shared/brush/brush-cursor.ts +408 -0
  77. package/shared/brush/brush-textures.ts +485 -0
  78. package/shared/brush/brush-utils.ts +76 -0
  79. package/shared/brush/brush.ts +510 -0
  80. package/shared/brush/types.ts +41 -0
  81. package/shared/core/EDITOR.md +182 -0
  82. package/shared/core/editor.ts +1788 -0
  83. package/shared/crop/CROP.md +139 -0
  84. package/shared/crop/crop.ts +1027 -0
  85. package/shared/image/IMAGE.md +135 -0
  86. package/shared/image/image.ts +298 -0
  87. package/shared/resize/resize.ts +1894 -0
  88. package/shared/types.ts +19 -0
  89. package/shared/utils/pixi.ts +19 -246
  90. package/shared/zoom/zoom.ts +729 -0
  91. package/dist/shared/layers/Layers.svelte +0 -231
  92. package/dist/shared/layers/Layers.svelte.d.ts +0 -18
  93. package/dist/shared/layers/utils.d.ts +0 -73
  94. package/dist/shared/layers/utils.js +0 -156
  95. package/dist/shared/tools/Brush.svelte +0 -215
  96. package/dist/shared/tools/Brush.svelte.d.ts +0 -29
  97. package/dist/shared/tools/Crop.svelte +0 -155
  98. package/dist/shared/tools/Crop.svelte.d.ts +0 -16
  99. package/dist/shared/tools/Cropper.svelte +0 -271
  100. package/dist/shared/tools/Cropper.svelte.d.ts +0 -41
  101. package/dist/shared/tools/Handle.svelte +0 -260
  102. package/dist/shared/tools/Handle.svelte.d.ts +0 -29
  103. package/dist/shared/tools/Sources.svelte +0 -257
  104. package/dist/shared/tools/Sources.svelte.d.ts +0 -34
  105. package/dist/shared/tools/Tools.svelte +0 -102
  106. package/dist/shared/tools/Tools.svelte.d.ts +0 -48
  107. package/dist/shared/tools/brush.d.ts +0 -62
  108. package/dist/shared/tools/brush.js +0 -133
  109. package/dist/shared/tools/crop.d.ts +0 -15
  110. package/dist/shared/tools/crop.js +0 -154
  111. package/dist/shared/tools/index.d.ts +0 -5
  112. package/dist/shared/tools/index.js +0 -5
  113. package/dist/shared/tools/sources.d.ts +0 -46
  114. package/dist/shared/tools/sources.js +0 -121
  115. package/dist/shared/tools/types.d.ts +0 -1
  116. package/shared/layers/Layers.svelte +0 -250
  117. package/shared/layers/utils.ts +0 -294
  118. package/shared/tools/Brush.svelte +0 -280
  119. package/shared/tools/BrushOptions.svelte +0 -180
  120. package/shared/tools/Crop.svelte +0 -189
  121. package/shared/tools/Cropper.svelte +0 -346
  122. package/shared/tools/Handle.svelte +0 -289
  123. package/shared/tools/Sources.svelte +0 -283
  124. package/shared/tools/Tools.svelte +0 -162
  125. package/shared/tools/brush.ts +0 -247
  126. package/shared/tools/crop.ts +0 -213
  127. package/shared/tools/index.ts +0 -5
  128. package/shared/tools/sources.ts +0 -195
  129. package/shared/tools/types.ts +0 -1
  130. /package/dist/shared/{tools → brush}/BrushSize.svelte.d.ts +0 -0
  131. /package/dist/shared/{tools/types.js → types.js} +0 -0
@@ -1,447 +1,809 @@
1
1
  <script lang="ts" context="module">
2
- import type { Writable, Readable } from "svelte/store";
3
- import type { Spring } from "svelte/motion";
4
- import { type PixiApp } from "./utils/pixi";
5
- import { type CommandManager, type CommandNode } from "./utils/commands";
2
+ import type { Tool, Subtool } from "./Toolbar.svelte";
6
3
 
7
4
  export const EDITOR_KEY = Symbol("editor");
8
5
  export type context_type = "bg" | "layers" | "crop" | "draw" | "erase";
9
- type PartialRecord<K extends keyof any, T> = Partial<Record<K, T>>;
10
- import { type tool } from "./tools";
11
-
12
- export interface EditorContext {
13
- pixi: Writable<PixiApp | null>;
14
- current_layer: Writable<LayerScene | null>;
15
- dimensions: Writable<[number, number]>;
16
- editor_box: Writable<{
17
- parent_width: number;
18
- parent_height: number;
19
- parent_left: number;
20
- parent_top: number;
21
- parent_right: number;
22
- parent_bottom: number;
23
- child_width: number;
24
- child_height: number;
25
- child_left: number;
26
- child_top: number;
27
- child_right: number;
28
- child_bottom: number;
29
- }>;
30
- active_tool: Writable<tool>;
31
- toolbar_box: Writable<DOMRect | Record<string, never>>;
32
- crop: Writable<[number, number, number, number]>;
33
- position_spring: Spring<{
34
- x: number;
35
- y: number;
36
- }>;
37
- command_manager: CommandManager;
38
- current_history: CommandManager["current_history"];
39
- register_context: (
40
- type: context_type,
41
- {
42
- reset_fn,
43
- init_fn
44
- }: {
45
- reset_fn?: () => void;
46
- init_fn?: (dimensions?: [number, number]) => void;
47
- }
48
- ) => void;
49
- reset: (clear_image: boolean, dimensions: [number, number]) => void;
50
- }
51
6
  </script>
52
7
 
53
8
  <script lang="ts">
54
- import { onMount, setContext, createEventDispatcher, tick } from "svelte";
55
- import { writable } from "svelte/store";
56
- import { spring } from "svelte/motion";
57
- import { Rectangle } from "pixi.js";
58
-
59
- import { command_manager } from "./utils/commands";
60
-
61
- import { type LayerScene } from "./layers/utils";
62
- import { create_pixi_app, type ImageBlobs } from "./utils/pixi";
9
+ import { onMount, createEventDispatcher, tick } from "svelte";
10
+ import Toolbar, { type Tool as ToolbarTool } from "./Toolbar.svelte";
11
+ import { CropTool } from "./crop/crop";
12
+ import { ResizeTool } from "./resize/resize";
13
+ import { Webcam } from "@gradio/image";
14
+ import type { I18nFormatter } from "@gradio/utils";
15
+ import type { Client, FileData } from "@gradio/client";
16
+ import tinycolor, { type ColorInput } from "tinycolor2";
17
+ import { ZoomTool } from "./zoom/zoom";
18
+ import { type CommandManager, type CommandNode } from "./utils/commands";
19
+ import { ImageEditor } from "./core/editor";
20
+ import { type Brush, type Eraser } from "./brush/types";
21
+ import { BrushTool } from "./brush/brush";
22
+ import { create_drag } from "@gradio/upload";
23
+ import SecondaryToolbar from "./SecondaryToolbar.svelte";
24
+ import { Check } from "@gradio/icons";
25
+ import type { LayerOptions, Source, Transform, WebcamOptions } from "./types";
26
+
27
+ import { type ImageBlobs } from "./types";
63
28
  import Controls from "./Controls.svelte";
64
- export let antialias = true;
65
- export let crop_size: [number, number] | undefined;
66
- export let changeable = false;
67
- export let history: boolean;
68
- export let bg = false;
69
- export let sources: ("clipboard" | "webcam" | "upload")[];
29
+ import IconButton from "./IconButton.svelte";
30
+
31
+ const { drag, open_file_upload } = create_drag();
70
32
  const dispatch = createEventDispatcher<{
71
33
  clear?: never;
72
34
  save: void;
73
35
  change: void;
74
36
  history: CommandManager["current_history"];
37
+ upload: void;
38
+ input: void;
39
+ download_error: string;
75
40
  }>();
76
- export let crop_constraint = false;
77
- export let canvas_size: [number, number] | undefined;
78
- export let parent_height: number;
79
- export let full_history: CommandNode | null = null;
80
-
81
- $: orig_canvas_size = canvas_size;
82
-
83
- const BASE_DIMENSIONS: [number, number] = canvas_size || [800, 600];
84
-
85
- let dimensions = writable(BASE_DIMENSIONS);
86
- export let height = 0;
87
-
88
- let editor_box: EditorContext["editor_box"] = writable({
89
- parent_width: 0,
90
- parent_height: parent_height,
91
- parent_top: 0,
92
- parent_left: 0,
93
- parent_right: 0,
94
- parent_bottom: 0,
95
- child_width: 0,
96
- child_height: parent_height,
97
- child_top: 0,
98
- child_left: 0,
99
- child_right: 0,
100
- child_bottom: 0
101
- });
102
41
 
103
- export let canvas_height = undefined;
42
+ export const antialias = true;
43
+ export const full_history: CommandNode | null = null;
104
44
 
105
- $: height = $editor_box.child_height;
106
- $: canvas_height = $editor_box.child_height + 1;
45
+ export let changeable = false;
46
+ export let sources: Source[] = ["upload", "webcam", "clipboard"];
47
+ export let transforms: Transform[] = ["crop", "resize"];
48
+ export let canvas_size: [number, number];
49
+ export let is_dragging = false;
50
+ export let background_image = false;
51
+ export let brush_options: Brush;
52
+ export let eraser_options: Eraser;
53
+ export let fixed_canvas = false;
54
+ export let root: string;
55
+ export let i18n: I18nFormatter;
56
+ export let upload: Client["upload"];
57
+ export let composite: FileData | null;
58
+ export let layers: FileData[];
59
+ export let background: FileData | null;
60
+ export let border_region = 0;
61
+ export let layer_options: LayerOptions;
62
+ export let current_tool: ToolbarTool;
63
+ export let webcam_options: WebcamOptions;
64
+ export let show_download_button = false;
65
+ export let theme_mode: "dark" | "light";
107
66
 
108
- const crop = writable<[number, number, number, number]>([0, 0, 1, 1]);
109
- const position_spring = spring(
110
- { x: 0, y: 0 },
111
- {
112
- stiffness: 0.1,
113
- damping: 0.5
67
+ let pixi_target: HTMLDivElement;
68
+ let pixi_target_crop: HTMLDivElement;
69
+
70
+ $: if (layer_options) {
71
+ if (check_if_should_init()) {
72
+ editor.set_layer_options(layer_options);
73
+ refresh_tools();
114
74
  }
115
- );
116
- const pixi = writable<PixiApp | null>(null);
75
+ }
76
+
77
+ function refresh_tools(): void {
78
+ editor.set_tool(current_tool);
79
+ editor.set_subtool(current_subtool);
80
+ }
117
81
 
118
- const CommandManager = command_manager();
82
+ function check_if_should_init(): boolean {
83
+ return layer_options && editor && ready;
84
+ }
119
85
 
120
- const { can_redo, can_undo, current_history } = CommandManager;
86
+ export let has_drawn = false;
121
87
 
122
- function get_start_history(history: any): any {
123
- if (history.previous) {
124
- return get_start_history(history.previous);
125
- }
126
- return history;
88
+ /**
89
+ * Gets the image blobs from the editor
90
+ * @returns {Promise<ImageBlobs>} Object containing background, layers, and composite image blobs
91
+ */
92
+ export async function get_blobs(): Promise<ImageBlobs> {
93
+ if (!editor) return { background: null, layers: [], composite: null };
94
+ if (!background_image && !has_drawn && !layers.length)
95
+ return { background: null, layers: [], composite: null };
96
+ const blobs = await editor.get_blobs();
97
+ return blobs;
127
98
  }
128
99
 
129
- $: $current_history.previous, dispatch("change");
130
- $: dispatch("history", get_start_history($current_history));
100
+ let editor: ImageEditor;
131
101
 
132
- $: {
133
- history = !!$current_history.previous || $active_tool !== "bg";
134
- }
135
- const is_browser = typeof window !== "undefined";
136
-
137
- const active_tool: Writable<tool> = writable("bg");
138
- const reset_context: Writable<PartialRecord<context_type, () => void>> =
139
- writable({});
140
- const init_context: Writable<
141
- PartialRecord<context_type, (dimensions?: typeof $dimensions) => void>
142
- > = writable({});
143
- const contexts: Writable<context_type[]> = writable([]);
144
- const toolbar_box: Writable<DOMRect | Record<string, never>> = writable(
145
- is_browser ? new DOMRect() : {}
146
- );
102
+ /**
103
+ * Adds an image to the editor
104
+ * @param {Blob | File} image - The image to add
105
+ */
106
+ export function add_image(image: Blob | File): void {
107
+ editor.add_image({ image });
108
+ }
147
109
 
148
- const sort_order = ["bg", "layers", "crop", "draw", "erase"] as const;
149
- const editor_context = setContext<EditorContext>(EDITOR_KEY, {
150
- pixi,
151
- current_layer: writable(null),
152
- dimensions,
153
- editor_box,
154
- toolbar_box,
155
- active_tool,
156
- crop,
157
- position_spring,
158
- command_manager: CommandManager,
159
- current_history,
160
- register_context: (
161
- type: context_type,
162
- {
163
- reset_fn,
164
- init_fn
165
- }: {
166
- reset_fn?: () => void;
167
- init_fn?: (dimensions?: [number, number]) => void;
168
- }
169
- ) => {
170
- contexts.update((c) => [...c, type]);
171
- init_context.update((c) => ({ ...c, [type]: init_fn }));
172
- reset_context.update((c) => ({ ...c, [type]: reset_fn }));
173
- },
174
- reset: (clear_image: boolean, dimensions: [number, number]) => {
175
- bg = false;
176
-
177
- const _sorted_contexts = $contexts.sort((a, b) => {
178
- return sort_order.indexOf(a) - sort_order.indexOf(b);
179
- });
180
- for (const k of _sorted_contexts) {
181
- if (k in $reset_context && typeof $reset_context[k] === "function") {
182
- $reset_context[k]?.();
183
- }
110
+ let pending_bg: Promise<void>;
111
+ /**
112
+ * Adds an image to the editor from a URL
113
+ * @param {string | FileData} source - The URL of the image or a FileData object
114
+ * @returns {Promise<void>}
115
+ */
116
+ export async function add_image_from_url(
117
+ source:
118
+ | string
119
+ | {
120
+ url: string;
121
+ meta: {
122
+ _type: string;
123
+ };
124
+ }
125
+ | any
126
+ ): Promise<void> {
127
+ if (!editor || !source || !check_if_should_init()) return;
128
+ let url: string;
129
+
130
+ // Handle different source types
131
+ if (typeof source === "string") {
132
+ url = source;
133
+ } else if (source?.meta?._type === "gradio.FileData" && source?.url) {
134
+ url = source.url;
135
+ } else {
136
+ console.warn("Invalid source provided to add_image_from_url:", source);
137
+ return;
138
+ }
139
+
140
+ try {
141
+ pending_bg = editor.add_image_from_url(url);
142
+ let pending_crop = crop.add_image_from_url(url);
143
+ await Promise.all([pending_bg, pending_crop]);
144
+ crop.set_tool("image");
145
+ crop.set_subtool("crop");
146
+ background_image = true;
147
+ dispatch("upload");
148
+ dispatch("input");
149
+ } catch (error) {
150
+ console.error("Error adding image from URL:", error);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Adds a new layer with an image loaded from a URL
156
+ * @param {string | FileData} source - The URL of the image or a FileData object
157
+ * @returns {Promise<string | null>} - The ID of the created layer, or null if failed
158
+ */
159
+ export async function add_layers_from_url(
160
+ source: FileData[] | any
161
+ ): Promise<void> {
162
+ if (!editor || !source.length || !check_if_should_init()) return;
163
+
164
+ // Handle different source types
165
+ if (
166
+ Array.isArray(source) &&
167
+ source.every((item) => item?.meta?._type === "gradio.FileData")
168
+ ) {
169
+ try {
170
+ await pending_bg;
171
+
172
+ await editor.add_layers_from_url(source.map((item) => item.url));
173
+ dispatch("change");
174
+ dispatch("input");
175
+ } catch (error) {
176
+ console.error("Error adding layer from URL:", error);
184
177
  }
178
+ }
179
+ }
185
180
 
186
- for (const k of _sorted_contexts) {
187
- if (k in $init_context && typeof $init_context[k] === "function") {
188
- if (k === "bg" && !clear_image) {
189
- continue;
190
- } else {
191
- $init_context[k]?.(dimensions);
192
- }
193
- }
194
- CommandManager.reset();
195
- $pixi?.resize?.(...dimensions);
181
+ let brush: BrushTool;
182
+ let zoom: ZoomTool;
183
+ let zoom_level = 1;
184
+ let ready = false;
185
+ let mounted = false;
186
+ let min_zoom = true;
187
+
188
+ let last_dimensions = { width: 0, height: 0 };
189
+
190
+ /**
191
+ * Handles visibility changes and resets zoom if dimensions have changed
192
+ */
193
+ async function handle_visibility_change(): Promise<void> {
194
+ if (!editor || !ready || !zoom) return;
195
+ await tick();
196
+
197
+ const is_visible = pixi_target.offsetParent !== null;
198
+
199
+ if (is_visible) {
200
+ const current_dimensions = pixi_target.getBoundingClientRect();
201
+
202
+ if (
203
+ current_dimensions.width !== last_dimensions.width ||
204
+ current_dimensions.height !== last_dimensions.height
205
+ ) {
206
+ // Use set_zoom with "fit" to reset to appropriate zoom level
207
+ zoom.set_zoom("fit");
208
+
209
+ // Update the last known dimensions
210
+ last_dimensions = {
211
+ width: current_dimensions.width,
212
+ height: current_dimensions.height
213
+ };
196
214
  }
197
215
  }
216
+ }
217
+
218
+ onMount(() => {
219
+ let intersection_observer: IntersectionObserver;
220
+ let resize_observer: ResizeObserver;
221
+ init_image_editor().then(() => {
222
+ mounted = true;
223
+ intersection_observer = new IntersectionObserver(() => {
224
+ handle_visibility_change();
225
+ });
226
+
227
+ resize_observer = new ResizeObserver(() => {
228
+ handle_visibility_change();
229
+ });
230
+
231
+ intersection_observer.observe(pixi_target);
232
+ resize_observer.observe(pixi_target);
233
+ });
234
+
235
+ // Set up mutation observer to detect visibility changes
236
+
237
+ return () => {
238
+ if (intersection_observer) {
239
+ intersection_observer.disconnect();
240
+ }
241
+ if (resize_observer) {
242
+ resize_observer.disconnect();
243
+ }
244
+ if (editor) {
245
+ editor.destroy();
246
+ }
247
+ };
198
248
  });
199
249
 
200
- let pixi_target: HTMLDivElement;
250
+ let crop: ImageEditor;
251
+ let crop_zoom: ZoomTool;
252
+ async function init_image_editor(): Promise<void> {
253
+ brush = new BrushTool();
254
+ zoom = new ZoomTool();
255
+ editor = new ImageEditor({
256
+ target_element: pixi_target,
257
+ width: canvas_size[0],
258
+ height: canvas_size[1],
259
+ tools: ["image", zoom, new ResizeTool(), brush],
260
+ fixed_canvas,
261
+ border_region,
262
+ layer_options,
263
+ theme_mode
264
+ });
201
265
 
202
- let canvas_wrap: HTMLDivElement;
203
-
204
- function get_dimensions(parent: HTMLDivElement, child: HTMLDivElement): void {
205
- if (!parent || !child) return;
206
- const {
207
- width: parent_width,
208
- height: parent_height,
209
- top: parent_top,
210
- left: parent_left,
211
- right: parent_right,
212
- bottom: parent_bottom
213
- } = canvas_wrap.getBoundingClientRect();
214
- const {
215
- width: child_width,
216
- height: child_height,
217
- top: child_top,
218
- left: child_left,
219
- right: child_right,
220
- bottom: child_bottom
221
- } = child.getBoundingClientRect();
222
- editor_box.set({
223
- child_width,
224
- child_height,
225
- child_left,
226
- child_right,
227
- child_top,
228
- child_bottom,
229
-
230
- parent_width,
231
- parent_height,
232
- parent_left,
233
- parent_right,
234
- parent_top,
235
- parent_bottom
266
+ brush.on("change", () => {
267
+ has_drawn = true;
236
268
  });
237
- }
238
269
 
239
- $: if (crop_constraint && bg && !history) {
240
- set_crop();
241
- }
270
+ crop_zoom = new ZoomTool();
271
+
272
+ crop = new ImageEditor({
273
+ target_element: pixi_target_crop,
274
+ width: canvas_size[0],
275
+ height: canvas_size[1],
276
+ tools: ["image", crop_zoom, new CropTool()],
277
+ dark: true,
278
+ fixed_canvas: false,
279
+ border_region: 0,
280
+ pad_bottom: 40
281
+ });
242
282
 
243
- function set_crop(): void {
244
- requestAnimationFrame(() => {
245
- tick().then((v) => ($active_tool = "crop"));
283
+ editor.scale.subscribe((_scale) => {
284
+ zoom_level = _scale;
285
+ });
286
+
287
+ editor.min_zoom.subscribe((is_min_zoom) => {
288
+ min_zoom = is_min_zoom;
246
289
  });
247
- }
248
290
 
249
- function reposition_canvas(): void {
250
- if (!$editor_box) return;
251
- const [l, t, w, h] = $crop;
291
+ editor.dimensions.subscribe((dimensions) => {
292
+ // Store dimensions for later comparison
293
+ last_dimensions = { ...dimensions };
294
+ });
252
295
 
253
- const cx = 0;
254
- const cy = 0;
255
- const cw = $editor_box.child_width;
256
- const ch = $editor_box.child_height;
296
+ await Promise.all([editor.ready, crop.ready]).then(() => {
297
+ handle_tool_change({ tool: "image" });
298
+ ready = true;
299
+ if (sources.length > 0) {
300
+ handle_tool_change({ tool: "image" });
301
+ } else {
302
+ handle_tool_change({ tool: "draw" });
303
+ }
304
+ crop.set_subtool("crop");
305
+ });
257
306
 
258
- const x = 0.5 * $editor_box.child_width - cx - cw / 2;
259
- const y = 0.5 * $editor_box.child_height - cy - ch / 2;
307
+ editor.on("change", () => {
308
+ dispatch("change");
309
+ });
260
310
 
261
- position_spring.set({ x, y });
311
+ if (background || layers.length > 0) {
312
+ if (background) {
313
+ await add_image_from_url(background);
314
+ }
315
+ if (layers.length > 0) {
316
+ await add_layers_from_url(layers);
317
+ }
318
+ handle_tool_change({ tool: "draw" });
319
+ } else if (composite) {
320
+ await add_image_from_url(composite);
321
+ handle_tool_change({ tool: "draw" });
322
+ }
262
323
  }
263
324
 
264
- export async function get_blobs(): Promise<ImageBlobs> {
265
- if (!$pixi || !$pixi.get_layers)
266
- return { background: null, layers: [], composite: null };
267
- const [l, t, w, h] = $crop;
268
-
269
- return $pixi?.get_blobs(
270
- $pixi.get_layers(),
271
- new Rectangle(
272
- Math.round(0),
273
- Math.round(0),
274
- Math.round(1 * $dimensions[0]),
275
- Math.round(1 * $dimensions[1])
276
- ),
277
- $dimensions
278
- );
325
+ $: if (
326
+ background == null &&
327
+ layers.length == 0 &&
328
+ composite == null &&
329
+ editor &&
330
+ ready
331
+ ) {
332
+ editor.reset_canvas();
333
+ handle_tool_change({ tool: "image" });
334
+ background_image = false;
335
+ has_drawn = false;
279
336
  }
280
337
 
281
- $: $crop && reposition_canvas();
282
- $: $position_spring && get_dimensions(canvas_wrap, pixi_target);
338
+ $: current_tool === "image" &&
339
+ current_subtool === "crop" &&
340
+ crop_zoom.set_zoom("fit");
341
+
342
+ // function resize_canvas(width: number, height: number): void {
343
+ // if (!editor) return;
344
+ // if (mounted && ready) {
345
+ // editor.resize(width, height);
346
+ // }
347
+ // }
348
+
349
+ /**
350
+ * Handles file uploads
351
+ * @param {File[]} files - The uploaded files
352
+ */
353
+ async function handle_files(
354
+ files: File[] | Blob[] | File | Blob | null
355
+ ): Promise<void> {
356
+ if (files == null) return;
357
+ if (!sources.includes("upload")) return;
358
+ const _file = Array.isArray(files) ? files[0] : files;
359
+ await editor.add_image({ image: _file });
360
+ await crop.add_image({ image: _file });
361
+ crop.reset();
362
+ background_image = true;
363
+ handle_tool_change({ tool: "draw" });
364
+ dispatch("upload");
365
+ dispatch("input");
366
+ dispatch("change");
367
+ }
283
368
 
284
- export function handle_remove(): void {
285
- editor_context.reset(
286
- true,
287
- orig_canvas_size ? orig_canvas_size : $dimensions
288
- );
289
- if (!sources.length) {
290
- set_tool("draw");
291
- } else {
292
- set_tool("bg");
369
+ /**
370
+ * Handles tool change events
371
+ * @param {{ tool: ToolbarTool }} param0 - Object containing the selected tool
372
+ */
373
+ function handle_tool_change({ tool }: { tool: ToolbarTool }): void {
374
+ editor.set_tool(tool);
375
+ current_tool = tool;
376
+
377
+ if (tool === "image") {
378
+ crop.set_tool("image");
379
+ crop.set_subtool("crop");
293
380
  }
294
- dispatch("clear");
295
-
296
- let _size = (canvas_size ? canvas_size : crop_size) || [800, 600];
297
- editor_context.reset(true, _size);
298
381
  }
299
382
 
300
- onMount(() => {
301
- const _size = (canvas_size ? canvas_size : crop_size) || [800, 600];
302
- const app = create_pixi_app({
303
- target: pixi_target,
304
- dimensions: _size,
305
- antialias
306
- });
383
+ /**
384
+ * Handles subtool change events
385
+ * @param {{ tool: ToolbarTool, subtool: Subtool }} param0 - Object containing the selected tool and subtool
386
+ */
387
+ function handle_subtool_change({
388
+ tool,
389
+ subtool
390
+ }: {
391
+ tool: ToolbarTool;
392
+ subtool: Subtool | null;
393
+ }): void {
394
+ editor.set_subtool(subtool);
395
+ current_subtool = subtool;
396
+
397
+ if (subtool === null) {
398
+ return;
399
+ }
307
400
 
308
- function resize(width: number, height: number): void {
309
- app.resize(width, height);
310
- dimensions.set([width, height]);
401
+ if (tool === "draw") {
402
+ if (subtool === "size") {
403
+ brush_size_visible = true;
404
+ } else if (subtool === "color") {
405
+ brush_color_visible = true;
406
+ }
311
407
  }
312
408
 
313
- pixi.set({ ...app, resize });
409
+ if (tool === "erase" && subtool === "size") {
410
+ eraser_size_visible = true;
411
+ }
314
412
 
315
- const resizer = new ResizeObserver((entries) => {
316
- for (const entry of entries) {
317
- get_dimensions(canvas_wrap, pixi_target);
318
- }
319
- });
413
+ if (tool === "image" && subtool === "paste") {
414
+ process_clipboard();
415
+ }
320
416
 
321
- resizer.observe(canvas_wrap);
322
- resizer.observe(pixi_target);
417
+ if (tool === "image" && subtool === "upload") {
418
+ tick().then(() => {
419
+ disable_click = false;
420
+ open_file_upload();
421
+ });
422
+ }
423
+ }
424
+
425
+ let eraser_size_visible = false;
426
+ let selected_color: ColorInput | string;
427
+ let selected_size: number;
428
+ let selected_opacity = 1;
429
+ let selected_eraser_size: number;
430
+
431
+ $: {
432
+ if (brush_options) {
433
+ update_brush_options();
434
+ }
323
435
 
324
- for (const k of $contexts) {
325
- if (k in $init_context && typeof $init_context[k] === "function") {
326
- $init_context[k]?.($dimensions);
436
+ if (eraser_options) {
437
+ update_eraser_options();
438
+ }
439
+ }
440
+
441
+ function update_brush_options(): void {
442
+ const default_color =
443
+ brush_options.default_color === "auto"
444
+ ? brush_options.colors[0]
445
+ : brush_options.default_color;
446
+
447
+ // color is already a tuple [color, opacity]
448
+ if (Array.isArray(default_color)) {
449
+ selected_color = default_color[0];
450
+ selected_opacity = default_color[1];
451
+ } else {
452
+ selected_color = default_color;
453
+
454
+ // color is a string, check if it has opacity info
455
+ const color = tinycolor(default_color);
456
+ if (color.getAlpha() < 1) {
457
+ selected_opacity = color.getAlpha();
458
+ } else {
459
+ selected_opacity = 1;
327
460
  }
328
461
  }
329
462
 
330
- resize(...$dimensions);
463
+ selected_size =
464
+ typeof brush_options.default_size === "number"
465
+ ? brush_options.default_size
466
+ : 25;
467
+ }
468
+
469
+ function update_eraser_options(): void {
470
+ selected_eraser_size =
471
+ eraser_options.default_size === "auto" ? 25 : eraser_options.default_size;
472
+ }
331
473
 
332
- tick().then(() => {
333
- if (full_history) {
334
- CommandManager.hydrate(full_history);
474
+ let brush_size_visible = false;
475
+
476
+ let brush_color_visible = false;
477
+
478
+ $: brush?.set_brush_color(
479
+ (() => {
480
+ let color_value;
481
+ if (selected_color === "auto") {
482
+ const default_color =
483
+ brush_options.colors.find((color) =>
484
+ Array.isArray(color)
485
+ ? color[0] === brush_options.default_color
486
+ : color === brush_options.default_color
487
+ ) || brush_options.colors[0];
488
+
489
+ color_value = Array.isArray(default_color)
490
+ ? default_color[0]
491
+ : default_color;
492
+ } else {
493
+ color_value = selected_color;
335
494
  }
336
- });
495
+ return color_value;
496
+ })()
497
+ );
337
498
 
338
- return () => {
339
- $pixi?.destroy();
340
- resizer.disconnect();
341
- for (const k of $contexts) {
342
- if (k in $reset_context) {
343
- $reset_context[k]?.();
344
- }
499
+ // Type-safe brush size handling
500
+ $: brush?.set_brush_size(
501
+ typeof selected_size === "number" ? selected_size : 25
502
+ );
503
+
504
+ $: brush?.set_eraser_size(
505
+ typeof selected_eraser_size === "number" ? selected_eraser_size : 25
506
+ );
507
+
508
+ $: disable_click =
509
+ current_tool !== "image" ||
510
+ (current_tool === "image" && background_image) ||
511
+ (current_tool === "image" && current_subtool === "webcam") ||
512
+ !sources.includes("upload");
513
+
514
+ let current_subtool: Subtool | null = null;
515
+ let preview = false;
516
+ $: brush?.preview_brush(preview);
517
+ $: brush?.set_brush_opacity(selected_opacity);
518
+
519
+ function handle_zoom_change(zoom_level: number | "fit"): void {
520
+ zoom.set_zoom(zoom_level);
521
+ }
522
+
523
+ function zoom_in_out(direction: "in" | "out"): void {
524
+ zoom.set_zoom(
525
+ direction === "in"
526
+ ? zoom_level + (zoom_level < 1 ? 0.1 : zoom_level * 0.1)
527
+ : zoom_level - (zoom_level < 1 ? 0.1 : zoom_level * 0.1)
528
+ );
529
+ }
530
+
531
+ async function process_clipboard(): Promise<void> {
532
+ const items = await navigator.clipboard.read();
533
+
534
+ for (let i = 0; i < items.length; i++) {
535
+ const type = items[i].types.find((t) => t.startsWith("image/"));
536
+ if (type) {
537
+ const blob = await items[i].getType(type);
538
+
539
+ handle_files(blob);
345
540
  }
346
- };
347
- });
541
+ }
542
+ }
348
543
 
349
- let saved_history: null | typeof $current_history = $current_history;
544
+ function handle_capture(e: CustomEvent): void {
545
+ if (e.detail !== null) {
546
+ handle_files(e.detail as Blob);
547
+ }
548
+ handle_subtool_change({ tool: current_tool, subtool: null });
549
+ }
350
550
 
351
551
  function handle_save(): void {
352
- saved_history = $current_history;
353
552
  dispatch("save");
354
553
  }
355
554
 
356
- export function set_tool(tool: tool): void {
357
- $active_tool = tool;
555
+ $: add_image_from_url(composite || background);
556
+ $: add_layers_from_url(layers);
557
+
558
+ async function handle_crop_confirm(): Promise<void> {
559
+ const { image } = await crop.get_crop_bounds();
560
+ if (!image) return;
561
+
562
+ await editor.add_image({
563
+ image,
564
+ resize: false
565
+ });
566
+ handle_subtool_change({ tool: "image", subtool: null });
567
+ dispatch("change");
568
+ dispatch("input");
569
+ }
570
+
571
+ async function handle_download(): Promise<void> {
572
+ const blobs = await editor.get_blobs();
573
+
574
+ const blob = blobs.composite;
575
+ if (!blob) {
576
+ dispatch("download_error", "Unable to generate image to download.");
577
+ return;
578
+ }
579
+ const url = URL.createObjectURL(blob);
580
+ const link = document.createElement("a");
581
+ link.href = url;
582
+ link.download = "image.png";
583
+ link.click();
584
+ URL.revokeObjectURL(url);
358
585
  }
359
586
  </script>
360
587
 
361
- <svelte:window on:scroll={() => get_dimensions(canvas_wrap, pixi_target)} />
362
-
363
- <div data-testid="image" class="image-container">
364
- <Controls
365
- can_undo={$can_undo}
366
- can_redo={$can_redo}
367
- can_save={saved_history !== $current_history}
368
- {changeable}
369
- on:undo={CommandManager.undo}
370
- on:redo={CommandManager.redo}
371
- on:remove_image={handle_remove}
372
- on:save={handle_save}
373
- />
374
- <div class="container">
375
- <div class="wrap" bind:this={canvas_wrap}>
376
- <div bind:this={pixi_target} class="stage-wrap" class:bg={!bg}></div>
377
- </div>
378
- <div class="tools-wrap">
379
- <slot />
588
+ <div
589
+ data-testid="image"
590
+ class="image-container"
591
+ class:dark-bg={current_subtool === "crop"}
592
+ use:drag={{
593
+ on_drag_change: (dragging) => (is_dragging = dragging),
594
+ on_files: handle_files,
595
+ accepted_types: "image/*",
596
+ disable_click: disable_click
597
+ }}
598
+ aria-label={"Click to upload or drop files"}
599
+ aria-dropeffect="copy"
600
+ >
601
+ {#if ready}
602
+ {#if current_subtool !== "crop"}
603
+ <Controls
604
+ {changeable}
605
+ on:set_zoom={(e) => handle_zoom_change(e.detail)}
606
+ on:zoom_in={() => zoom_in_out("in")}
607
+ on:zoom_out={() => zoom_in_out("out")}
608
+ {min_zoom}
609
+ current_zoom={zoom_level}
610
+ on:remove_image={() => {
611
+ editor.reset_canvas();
612
+ handle_tool_change({ tool: "image" });
613
+ background_image = false;
614
+ has_drawn = false;
615
+ }}
616
+ tool={current_tool}
617
+ can_save={true}
618
+ on:save={handle_save}
619
+ on:pan={(e) => {
620
+ handle_tool_change({ tool: "pan" });
621
+ }}
622
+ enable_download={show_download_button}
623
+ on:download={() => handle_download()}
624
+ />
625
+ {/if}
626
+
627
+ {#if current_subtool !== "crop"}
628
+ <Toolbar
629
+ {sources}
630
+ {transforms}
631
+ background={background_image}
632
+ on:tool_change={(e) => handle_tool_change(e.detail)}
633
+ on:subtool_change={(e) => handle_subtool_change(e.detail)}
634
+ show_brush_size={brush_size_visible}
635
+ show_brush_color={brush_color_visible}
636
+ show_eraser_size={eraser_size_visible}
637
+ {brush_options}
638
+ {eraser_options}
639
+ bind:selected_color
640
+ bind:selected_size
641
+ bind:selected_eraser_size
642
+ bind:selected_opacity
643
+ bind:preview
644
+ tool={current_tool}
645
+ subtool={current_subtool}
646
+ />
647
+ {/if}
648
+
649
+ {#if current_tool === "image" && current_subtool === "webcam"}
650
+ <div class="modal">
651
+ <div class="modal-inner">
652
+ <Webcam
653
+ {upload}
654
+ {root}
655
+ on:capture={handle_capture}
656
+ on:error
657
+ on:drag
658
+ streaming={false}
659
+ mode="image"
660
+ include_audio={false}
661
+ {i18n}
662
+ mirror_webcam={webcam_options.mirror}
663
+ webcam_constraints={webcam_options.constraints}
664
+ />
665
+ </div>
666
+ </div>
667
+ {/if}
668
+
669
+ {#if current_subtool !== "crop" && !layer_options.disabled}
670
+ <SecondaryToolbar
671
+ enable_additional_layers={layer_options.allow_additional_layers}
672
+ layers={editor.layers}
673
+ on:new_layer={() => {
674
+ editor.add_layer();
675
+ }}
676
+ on:change_layer={(e) => {
677
+ editor.set_layer(e.detail);
678
+ if (current_tool === "draw") {
679
+ handle_tool_change({ tool: "draw" });
680
+ }
681
+ }}
682
+ on:move_layer={(e) => {
683
+ editor.move_layer(e.detail.id, e.detail.direction);
684
+ }}
685
+ on:delete_layer={(e) => {
686
+ editor.delete_layer(e.detail);
687
+ }}
688
+ on:toggle_layer_visibility={(e) => {
689
+ editor.toggle_layer_visibility(e.detail);
690
+ }}
691
+ />
692
+ {/if}
693
+ {/if}
694
+ <div
695
+ class="pixi-target"
696
+ class:visible={current_subtool !== "crop"}
697
+ bind:this={pixi_target}
698
+ ></div>
699
+ <div
700
+ class="pixi-target-crop"
701
+ class:visible={current_subtool === "crop"}
702
+ bind:this={pixi_target_crop}
703
+ ></div>
704
+
705
+ {#if current_subtool === "crop"}
706
+ <div class="crop-confirm-button">
707
+ <IconButton
708
+ Icon={Check}
709
+ label="Confirm crop"
710
+ show_label={true}
711
+ size="large"
712
+ padded={true}
713
+ color="white"
714
+ background="var(--color-green-500)"
715
+ label_position="right"
716
+ on:click={handle_crop_confirm}
717
+ />
380
718
  </div>
381
- <div
382
- class="canvas"
383
- class:no-border={!bg && $active_tool === "bg" && !history}
384
- style:width="{$editor_box.child_width + 1}px"
385
- style:height="{$editor_box.child_height + 1}px"
386
- style:top="{$editor_box.child_top - $editor_box.parent_top - 0.5}px"
387
- style:left="{$editor_box.child_left - $editor_box.parent_left - 0.5}px"
388
- ></div>
389
- </div>
719
+ {/if}
720
+ <slot></slot>
390
721
  </div>
391
722
 
392
723
  <style>
393
- .wrap {
724
+ .image-container {
394
725
  display: flex;
395
- width: 100%;
396
726
  height: 100%;
397
- position: relative;
727
+ flex-direction: column;
398
728
  justify-content: center;
729
+ align-items: center;
730
+ max-height: 100%;
731
+ border-radius: var(--radius-sm);
399
732
  }
400
- .canvas {
733
+
734
+ .pixi-target {
735
+ width: 100%;
736
+ height: 100%;
401
737
  position: absolute;
402
- border: var(--block-border-color) 1px solid;
738
+ top: 0;
739
+ left: 0;
740
+ z-index: 1;
741
+ display: block;
742
+ opacity: 0;
403
743
  pointer-events: none;
404
- border-radius: var(--radius-md);
744
+ border-radius: var(--radius-sm);
405
745
  }
406
746
 
407
- .container {
408
- position: relative;
409
- margin: var(--spacing-md);
410
- /* in case the canvas_size is really small */
411
- /* set min-height so that upload text does not cover the toolbar */
412
- min-height: 100px;
747
+ .pixi-target-crop {
748
+ width: 100%;
749
+ height: 100%;
750
+ position: absolute;
751
+ top: 0;
752
+ left: 0;
753
+ z-index: 2;
754
+ display: block;
755
+ opacity: 0;
756
+ pointer-events: none;
757
+ border-radius: var(--radius-sm);
413
758
  }
414
759
 
415
- .no-border {
416
- border: none;
760
+ .visible {
761
+ opacity: 1;
762
+ pointer-events: auto;
417
763
  }
418
764
 
419
- .stage-wrap {
420
- margin-bottom: var(--size-1);
421
- border-radius: var(--radius-md);
765
+ .pixi-target {
766
+ width: 100%;
767
+ height: 100%;
768
+ position: absolute;
769
+ top: 0;
770
+ left: 0;
771
+ right: 0;
772
+ bottom: 0;
422
773
  overflow: hidden;
423
- height: fit-content;
424
- width: auto;
425
774
  }
426
775
 
427
- .tools-wrap {
776
+ .modal {
777
+ position: absolute;
778
+ height: 100%;
779
+ width: 100%;
780
+ left: 0;
781
+ right: 0;
782
+ margin: auto;
783
+ z-index: var(--layer-top);
428
784
  display: flex;
429
- justify-content: center;
430
785
  align-items: center;
431
- padding: 0 var(--spacing-xl) 0 0;
432
- border: 1px solid var(--block-border-color);
433
- border-radius: var(--radius-sm);
434
- margin: var(--spacing-xxl) 0 var(--spacing-xxl) 0;
435
- width: fit-content;
436
- margin: 0 auto;
437
786
  }
438
787
 
439
- .image-container {
440
- display: flex;
788
+ .modal-inner {
441
789
  height: 100%;
442
- flex-direction: column;
443
- justify-content: center;
790
+ width: 100%;
791
+ background: var(--block-background-fill);
792
+ }
793
+
794
+ .dark-bg {
795
+ background: #333;
796
+ }
797
+
798
+ .crop-confirm-button {
799
+ position: absolute;
800
+ bottom: 8px;
801
+ left: 0;
802
+ right: 0;
803
+ margin: auto;
804
+ z-index: var(--layer-top);
805
+ display: flex;
444
806
  align-items: center;
445
- max-height: 100%;
807
+ justify-content: center;
446
808
  }
447
809
  </style>