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