@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
@@ -0,0 +1,1788 @@
1
+ import {
2
+ Application,
3
+ Container,
4
+ Graphics,
5
+ Sprite,
6
+ Filter,
7
+ RenderTexture,
8
+ GlProgram,
9
+ defaultFilterVert,
10
+ SCALE_MODES,
11
+ Texture,
12
+ type ALPHA_MODES,
13
+ Assets
14
+ } from "pixi.js";
15
+
16
+ import { DropShadowFilter as BlurFilter } from "pixi-filters/drop-shadow";
17
+
18
+ import { ImageTool } from "../image/image";
19
+ import { AddImageCommand } from "../image/image";
20
+
21
+ import { ZoomTool } from "../zoom/zoom";
22
+ import type { Subtool, Tool as ToolbarTool } from "../Toolbar.svelte";
23
+ import type { Readable, Writable } from "svelte/store";
24
+ import { spring, type Spring } from "svelte/motion";
25
+ import { writable, get } from "svelte/store";
26
+ import { Rectangle } from "pixi.js";
27
+ import { type ImageBlobs, type LayerOptions } from "../types";
28
+ import { get_canvas_blob } from "../utils/pixi";
29
+ import type { BrushTool } from "../brush/brush";
30
+ import type { CropTool } from "../crop/crop";
31
+ export interface Command {
32
+ execute: () => Promise<void>;
33
+ undo: () => Promise<void>;
34
+ }
35
+
36
+ export interface Tool {
37
+ name: string;
38
+ setup(
39
+ context: ImageEditorContext,
40
+ tool: ToolbarTool,
41
+ subtool: Subtool
42
+ ): Promise<void>;
43
+ cleanup(): void;
44
+ set_tool(tool: ToolbarTool, subtool: Subtool): void;
45
+ on?: (event: string, callback: () => void) => void;
46
+ off?: (event: string, callback: () => void) => void;
47
+ }
48
+
49
+ export class CommandManager {
50
+ private undo_stack: Command[] = [];
51
+ private redo_stack: Command[] = [];
52
+
53
+ async execute(command: Command): Promise<void> {
54
+ await command.execute();
55
+ this.undo_stack.push(command);
56
+ this.redo_stack = []; // Clear redo stack when new command is executed
57
+ }
58
+
59
+ async undo(): Promise<void> {
60
+ const command = this.undo_stack.pop();
61
+ if (command) {
62
+ await command.undo();
63
+ this.redo_stack.push(command);
64
+ }
65
+ }
66
+
67
+ async redo(): Promise<void> {
68
+ const command = this.redo_stack.pop();
69
+ if (command) {
70
+ await command.execute();
71
+ this.undo_stack.push(command);
72
+ }
73
+ }
74
+ }
75
+
76
+ export class LayerManager {
77
+ private layers: {
78
+ name: string;
79
+ id: string;
80
+ container: Container;
81
+ user_created: boolean;
82
+ visible: boolean;
83
+ }[] = [];
84
+ private active_layer: Container | null = null;
85
+ private active_layer_id: string | null = null;
86
+ private draw_textures: Map<Container, RenderTexture> = new Map();
87
+ layer_store: Writable<{
88
+ active_layer: string;
89
+ layers: {
90
+ name: string;
91
+ id: string;
92
+ user_created: boolean;
93
+ visible: boolean;
94
+ }[];
95
+ }> = writable({
96
+ active_layer: "",
97
+
98
+ layers: []
99
+ });
100
+ private background_layer: Container | null = null;
101
+ private image_container: Container;
102
+ private app: Application;
103
+ private fixed_canvas: boolean;
104
+ private dark: boolean;
105
+ private border_region: number;
106
+ private layer_options: LayerOptions;
107
+ constructor(
108
+ image_container: Container,
109
+ app: Application,
110
+ fixed_canvas: boolean,
111
+ dark: boolean,
112
+ border_region: number,
113
+ layer_options: LayerOptions
114
+ ) {
115
+ this.image_container = image_container;
116
+ this.app = app;
117
+ this.fixed_canvas = fixed_canvas;
118
+ this.dark = dark;
119
+ this.border_region = border_region;
120
+ this.layer_options = layer_options;
121
+ }
122
+
123
+ toggle_layer_visibility(id: string): void {
124
+ const layer = this.layers.find((l) => l.id === id);
125
+ if (layer) {
126
+ layer.container.visible = !layer.container.visible;
127
+ layer.visible = layer.container.visible;
128
+ this.layer_store.update((state) => ({
129
+ active_layer: state.active_layer,
130
+ layers: this.layers
131
+ }));
132
+ }
133
+ }
134
+
135
+ create_background_layer(width: number, height: number): Container {
136
+ if (this.background_layer) {
137
+ this.background_layer.destroy();
138
+ }
139
+
140
+ // Create new background layer
141
+ const layer = new Container();
142
+
143
+ this.background_layer = layer;
144
+
145
+ // Create a render texture for the background with transparency
146
+ const bg_texture = RenderTexture.create({
147
+ width,
148
+ height,
149
+ resolution: window.devicePixelRatio,
150
+ antialias: true,
151
+ scaleMode: SCALE_MODES.NEAREST
152
+ });
153
+
154
+ // Create sprite to display the texture with transparency
155
+ const bg_sprite = new Sprite(bg_texture);
156
+ layer.addChild(bg_sprite);
157
+
158
+ // Clear the texture with transparency
159
+ const clear_graphics = new Graphics();
160
+ clear_graphics.clear();
161
+
162
+ clear_graphics
163
+ .rect(0, 0, width, height)
164
+ .fill({ color: this.dark ? 0x333333 : 0xffffff, alpha: 1 });
165
+
166
+ // Render with transparency
167
+ this.app.renderer.render({
168
+ container: clear_graphics,
169
+ target: bg_texture,
170
+ clear: true
171
+ });
172
+
173
+ this.image_container.addChild(layer);
174
+
175
+ layer.zIndex = -1;
176
+
177
+ this.update_layer_order();
178
+ return layer;
179
+ }
180
+
181
+ set_layer_options(
182
+ layer_options: LayerOptions,
183
+ width: number,
184
+ height: number
185
+ ): void {
186
+ this.layer_options = layer_options;
187
+ this.reset_layers(width, height);
188
+ }
189
+
190
+ /**
191
+ * Creates a background layer from an image URL
192
+ * @param url The URL of the image to use as background
193
+ * @param width The width of the layer (if fixed_canvas is true)
194
+ * @param height The height of the layer (if fixed_canvas is true)
195
+ * @param borderRegion Minimum border region to add around the image for outpainting (in pixels)
196
+ * @returns A Promise that resolves to the background Container when the image is loaded
197
+ */
198
+ async create_background_layer_from_url(
199
+ url: string,
200
+ width?: number,
201
+ height?: number
202
+ ): Promise<Container> {
203
+ const layer = this.create_background_layer(
204
+ width || this.image_container.width,
205
+ height || this.image_container.height
206
+ );
207
+
208
+ try {
209
+ const texture = await Texture.from(url);
210
+ const sprite = new Sprite(texture);
211
+
212
+ const imageWidth = sprite.texture.width;
213
+ const imageHeight = sprite.texture.height;
214
+
215
+ // Calculate dimensions based on whether canvas size is fixed
216
+ const containerWidth = width || this.image_container.width;
217
+ const containerHeight = height || this.image_container.height;
218
+
219
+ if (this.fixed_canvas) {
220
+ // If fixed canvas, fit the image within the specified dimensions minus border region
221
+ // Calculate aspect-preserving dimensions
222
+ const effectiveContainerWidth = Math.max(
223
+ containerWidth - this.border_region * 2,
224
+ 10
225
+ );
226
+ const effectiveContainerHeight = Math.max(
227
+ containerHeight - this.border_region * 2,
228
+ 10
229
+ );
230
+
231
+ const imageAspectRatio = imageWidth / imageHeight;
232
+ const containerAspectRatio =
233
+ effectiveContainerWidth / effectiveContainerHeight;
234
+
235
+ let finalWidth, finalHeight;
236
+ let posX = this.border_region, // Start with border offset
237
+ posY = this.border_region;
238
+
239
+ if (
240
+ imageWidth <= effectiveContainerWidth &&
241
+ imageHeight <= effectiveContainerHeight
242
+ ) {
243
+ // Image fits within container without scaling
244
+ finalWidth = imageWidth;
245
+ finalHeight = imageHeight;
246
+ } else {
247
+ if (imageAspectRatio > containerAspectRatio) {
248
+ // Width is the limiting factor
249
+ finalWidth = effectiveContainerWidth;
250
+ finalHeight = effectiveContainerWidth / imageAspectRatio;
251
+ } else {
252
+ // Height is the limiting factor
253
+ finalHeight = effectiveContainerHeight;
254
+ finalWidth = effectiveContainerHeight * imageAspectRatio;
255
+ }
256
+ }
257
+
258
+ // Center image within the effective container area (which already accounts for border)
259
+ posX += Math.round((effectiveContainerWidth - finalWidth) / 2);
260
+ posY += Math.round((effectiveContainerHeight - finalHeight) / 2);
261
+ sprite.width = finalWidth;
262
+ sprite.height = finalHeight;
263
+ sprite.position.set(posX, posY);
264
+ } else {
265
+ // If not fixed canvas, use the natural dimensions of the image plus border
266
+ // Position starts at the border offset
267
+ sprite.position.set(this.border_region, this.border_region);
268
+
269
+ // We need to re-create the background layer with the actual image dimensions plus border
270
+ if (this.background_layer) {
271
+ this.background_layer.destroy();
272
+ }
273
+
274
+ // Create new layer with image dimensions plus border on all sides
275
+ const totalWidth = imageWidth + this.border_region * 2;
276
+ const totalHeight = imageHeight + this.border_region * 2;
277
+ const newLayer = this.create_background_layer(totalWidth, totalHeight);
278
+
279
+ sprite.width = imageWidth;
280
+ sprite.height = imageHeight;
281
+ newLayer.addChild(sprite);
282
+
283
+ return newLayer;
284
+ }
285
+
286
+ layer.addChild(sprite);
287
+
288
+ return layer;
289
+ } catch (error) {
290
+ console.error("Error loading image from URL:", error);
291
+ return layer;
292
+ }
293
+ }
294
+
295
+ create_layer({
296
+ width,
297
+ height,
298
+ layer_name,
299
+ user_created,
300
+ layer_id = undefined,
301
+ make_active = false
302
+ }: {
303
+ width: number;
304
+ height: number;
305
+ layer_name?: string;
306
+ user_created: boolean;
307
+ layer_id?: string;
308
+ make_active?: boolean;
309
+ }): Container {
310
+ const layer = new Container();
311
+ const _layer_id = layer_id || Math.random().toString(36).substring(2, 15);
312
+ const _layer_name = layer_name || `Layer ${this.layers.length + 1}`;
313
+
314
+ this.layers.push({
315
+ name: _layer_name,
316
+ id: _layer_id,
317
+ container: layer,
318
+ user_created,
319
+ visible: true
320
+ });
321
+
322
+ this.image_container.addChild(layer);
323
+
324
+ // Create a single render texture for drawing with transparency
325
+ const draw_texture = RenderTexture.create({
326
+ width,
327
+ height,
328
+ resolution: window.devicePixelRatio,
329
+ antialias: true,
330
+ scaleMode: SCALE_MODES.NEAREST
331
+ });
332
+
333
+ const canvas_sprite = new Sprite(draw_texture);
334
+ layer.addChild(canvas_sprite);
335
+
336
+ const clear_graphics = new Graphics();
337
+ clear_graphics.clear();
338
+ clear_graphics.beginFill(0, 0);
339
+ clear_graphics.drawRect(0, 0, width, height);
340
+ clear_graphics.endFill();
341
+
342
+ this.app.renderer.render({
343
+ container: clear_graphics,
344
+ target: draw_texture,
345
+ clear: true
346
+ });
347
+
348
+ // Store texture for drawing
349
+ this.draw_textures.set(layer, draw_texture);
350
+
351
+ this.update_layer_order();
352
+ if (make_active) {
353
+ this.set_active_layer(_layer_id);
354
+ }
355
+
356
+ this.layer_store.set({
357
+ active_layer: this.active_layer_id || "",
358
+ layers: this.layers
359
+ });
360
+ return layer;
361
+ }
362
+
363
+ /**
364
+ * Creates a new layer with an image loaded from a URL
365
+ * @param url The URL of the image to load
366
+ * @param borderRegion Minimum border region to add around the image for outpainting (in pixels)
367
+ * @returns A Promise that resolves to the layer ID
368
+ */
369
+ async add_layer_from_url(url: string): Promise<string> {
370
+ const { width, height } = this.image_container.getLocalBounds();
371
+ const layer = this.create_layer({
372
+ width,
373
+ height,
374
+ layer_name: "Layer 1",
375
+ user_created: true
376
+ });
377
+
378
+ const layerIndex = this.layers.findIndex((l) => l.container === layer);
379
+ if (layerIndex === -1) {
380
+ console.error("Could not find newly created layer");
381
+ return "";
382
+ }
383
+ const layerId = this.layers[layerIndex].id;
384
+
385
+ try {
386
+ const texture = await Assets.load(url);
387
+
388
+ const drawTexture = this.draw_textures.get(layer);
389
+ if (!drawTexture) {
390
+ console.error("No draw texture found for layer");
391
+ return layerId;
392
+ }
393
+
394
+ const sprite = new Sprite(texture);
395
+ const imageWidth = sprite.width;
396
+ const imageHeight = sprite.height;
397
+
398
+ let posX = this.border_region;
399
+ let posY = this.border_region;
400
+
401
+ // Calculate effective dimensions (accounting for border)
402
+ const effectiveWidth = this.fixed_canvas
403
+ ? width - this.border_region * 2
404
+ : width;
405
+ const effectiveHeight = this.fixed_canvas
406
+ ? height - this.border_region * 2
407
+ : height;
408
+
409
+ // If the image is smaller than the effective layer area, center it within that area
410
+ if (imageWidth < effectiveWidth || imageHeight < effectiveHeight) {
411
+ posX = Math.floor((effectiveWidth - imageWidth) / 2);
412
+ posY = Math.floor((effectiveHeight - imageHeight) / 2);
413
+ }
414
+
415
+ sprite.position.set(posX, posY);
416
+
417
+ // If the image is larger than the effective layer area, scale it down to fit
418
+ if (imageWidth > effectiveWidth || imageHeight > effectiveHeight) {
419
+ const imageAspectRatio = imageWidth / imageHeight;
420
+ const areaAspectRatio = effectiveWidth / effectiveHeight;
421
+
422
+ let finalWidth, finalHeight;
423
+
424
+ if (imageAspectRatio > areaAspectRatio) {
425
+ // Width is the limiting factor
426
+ finalWidth = effectiveWidth;
427
+ finalHeight = effectiveWidth / imageAspectRatio;
428
+ } else {
429
+ // Height is the limiting factor
430
+ finalHeight = effectiveHeight;
431
+ finalWidth = effectiveHeight * imageAspectRatio;
432
+ }
433
+
434
+ sprite.width = finalWidth;
435
+ sprite.height = finalHeight;
436
+
437
+ // Recalculate position to center within effective area
438
+ posX =
439
+ this.border_region + Math.floor((effectiveWidth - finalWidth) / 2);
440
+ posY =
441
+ this.border_region + Math.floor((effectiveHeight - finalHeight) / 2);
442
+ sprite.position.set(posX, posY);
443
+ }
444
+
445
+ this.app.renderer.render(sprite, { renderTexture: drawTexture });
446
+
447
+ this.set_active_layer(layerId);
448
+
449
+ return layerId;
450
+ } catch (error) {
451
+ console.error("Error loading image from URL:", error);
452
+ return layerId;
453
+ }
454
+ }
455
+
456
+ get_active_layer(): typeof this.active_layer {
457
+ return this.active_layer;
458
+ }
459
+
460
+ set_active_layer(id: string): void {
461
+ if (this.layers.some((l) => l.id === id)) {
462
+ this.active_layer =
463
+ this.layers.find((l) => l.id === id)?.container ||
464
+ this.layers[0]?.container ||
465
+ null;
466
+ this.active_layer_id = id;
467
+ this.layer_store.set({
468
+ active_layer: id,
469
+ layers: this.layers
470
+ });
471
+ }
472
+ }
473
+
474
+ get_layers(): typeof this.layers {
475
+ return this.layers;
476
+ }
477
+
478
+ get_layer_textures(id: string): { draw: RenderTexture } | null {
479
+ const layer = this.layers.find((l) => l.id === id);
480
+ if (layer) {
481
+ const draw = this.draw_textures.get(layer.container);
482
+ if (draw) {
483
+ return { draw };
484
+ }
485
+ }
486
+ return null;
487
+ }
488
+
489
+ delete_layer(id: string): void {
490
+ const index = this.layers.findIndex((l) => l.id === id);
491
+ if (index > -1) {
492
+ // Clean up texture
493
+ const draw_texture = this.draw_textures.get(this.layers[index].container);
494
+ if (draw_texture) {
495
+ draw_texture.destroy();
496
+ this.draw_textures.delete(this.layers[index].container);
497
+ }
498
+ this.layers[index].container.destroy();
499
+ if (this.active_layer === this.layers[index].container) {
500
+ const new_active_layer = this.layers[Math.max(0, index - 1)] || null;
501
+ this.active_layer = new_active_layer?.container || null;
502
+ this.active_layer_id = new_active_layer?.id || null;
503
+ }
504
+ this.layers = this.layers.filter((l) => l.id !== id);
505
+
506
+ this.layer_store.update((_layers) => ({
507
+ active_layer:
508
+ _layers.active_layer === id
509
+ ? this.layers[this.layers.length - 1]?.id
510
+ : _layers.active_layer,
511
+ layers: this.layers
512
+ }));
513
+
514
+ this.update_layer_order();
515
+ }
516
+ }
517
+
518
+ private update_layer_order(): void {
519
+ // Ensure background layer always has lowest z-index
520
+ if (this.background_layer) {
521
+ this.background_layer.zIndex = -1;
522
+ }
523
+ // Update other layers starting from 0
524
+ this.layers.forEach((layer, index) => {
525
+ layer.container.zIndex = index;
526
+ });
527
+ }
528
+
529
+ move_layer(id: string, direction: "up" | "down"): void {
530
+ const index = this.layers.findIndex((l) => l.id === id);
531
+ if (index > -1) {
532
+ const new_index = direction === "up" ? index - 1 : index + 1;
533
+
534
+ this.layers = this.layers.map((l, i) => {
535
+ if (i === index) {
536
+ return this.layers[new_index];
537
+ }
538
+ if (i === new_index) {
539
+ return this.layers[index];
540
+ }
541
+ return l;
542
+ });
543
+ this.update_layer_order();
544
+ this.layer_store.update((_layers) => ({
545
+ active_layer: id,
546
+ layers: this.layers
547
+ }));
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Resizes all layers to a new width and height
553
+ * @param newWidth The new width of the layers
554
+ * @param newHeight The new height of the layers
555
+ * @param scale If true, scales the layer content to fit the new dimensions. If false, keeps the content size unchanged.
556
+ * @param anchor The anchor point to position the content relative to the new size
557
+ */
558
+ resize_all_layers(
559
+ newWidth: number,
560
+ newHeight: number,
561
+ scale: boolean,
562
+ anchor:
563
+ | "top-left"
564
+ | "top"
565
+ | "top-right"
566
+ | "left"
567
+ | "center"
568
+ | "right"
569
+ | "bottom-left"
570
+ | "bottom"
571
+ | "bottom-right",
572
+ // Add old canvas dimensions for anchor calculations when not scaling
573
+ oldCanvasWidth: number,
574
+ oldCanvasHeight: number
575
+ ): void {
576
+ // Create a map of the old layers by ID for reference
577
+ const oldLayersById = new Map(this.layers.map((l) => [l.id, l]));
578
+ const oldBackgroundLayer = this.background_layer;
579
+
580
+ // Calculate offset for non-scaling resize based on anchor and dimension change
581
+ const calculateOffset = (): { offsetX: number; offsetY: number } => {
582
+ let offsetX = 0;
583
+ let offsetY = 0;
584
+ const deltaWidth = newWidth - oldCanvasWidth;
585
+ const deltaHeight = newHeight - oldCanvasHeight;
586
+
587
+ // Determine X offset based on anchor point
588
+ if (anchor.includes("left")) {
589
+ offsetX = 0;
590
+ } else if (anchor.includes("right")) {
591
+ offsetX = deltaWidth;
592
+ } else {
593
+ // center
594
+ offsetX = Math.floor(deltaWidth / 2);
595
+ }
596
+
597
+ // Determine Y offset based on anchor point
598
+ if (anchor.includes("top")) {
599
+ offsetY = 0;
600
+ } else if (anchor.includes("bottom")) {
601
+ offsetY = deltaHeight;
602
+ } else {
603
+ // center
604
+ offsetY = Math.floor(deltaHeight / 2);
605
+ }
606
+
607
+ return { offsetX, offsetY };
608
+ };
609
+
610
+ // First, handle the background layer using the helper method
611
+ // Note: create_background_layer called inside the helper might destroy the old one
612
+ this.background_layer = this._resize_background_layer(
613
+ oldBackgroundLayer, // Pass the potentially null old layer
614
+ newWidth,
615
+ newHeight,
616
+ scale,
617
+ calculateOffset
618
+ );
619
+
620
+ // Now process regular layers
621
+ const processedLayers: {
622
+ name: string;
623
+ id: string;
624
+ container: Container;
625
+ user_created: boolean;
626
+ }[] = [];
627
+
628
+ // Store old layer data before clearing the main arrays
629
+ const oldLayerData = this.layers.map((oldLayer) => ({
630
+ id: oldLayer.id,
631
+ name: oldLayer.name,
632
+ user_created: oldLayer.user_created,
633
+ texture: this.draw_textures.get(oldLayer.container),
634
+ container: oldLayer.container
635
+ }));
636
+
637
+ // Clear the current layers array and textures map. New layers will be added by the helper.
638
+ this.layers = [];
639
+ this.draw_textures.clear(); // Ensure map is cleared before helpers add to it
640
+
641
+ // Rebuild layers in the original order using the helper method
642
+ for (const oldData of oldLayerData) {
643
+ // The helper _resize_single_layer handles creating the new layer,
644
+ // drawing content, cleanup, and adding the new layer to this.layers.
645
+ // It returns null if resizing failed for that layer.
646
+ const newLayer = this._resize_single_layer(
647
+ oldData,
648
+ newWidth,
649
+ newHeight,
650
+ scale,
651
+ calculateOffset
652
+ );
653
+ if (newLayer) {
654
+ processedLayers.push(newLayer);
655
+ }
656
+ }
657
+
658
+ // Ensure the active layer still exists, otherwise select the top one
659
+ const currentActiveId = get(this.layer_store).active_layer;
660
+ const activeLayerExists = processedLayers.some(
661
+ (l) => l.id === currentActiveId
662
+ );
663
+
664
+ if (!activeLayerExists && processedLayers.length > 0) {
665
+ this.set_active_layer(processedLayers[0].id); // Set to top layer (index 0 is bottom visually)
666
+ } else if (processedLayers.length === 0) {
667
+ this.layer_store.update((s) => ({ ...s, active_layer: "" })); // No layers left
668
+ } else {
669
+ // If the active layer still exists, just trigger an update to ensure consistency
670
+ this.layer_store.update((s) => ({ ...s }));
671
+ }
672
+
673
+ // Update Z-indices
674
+ this.update_layer_order();
675
+
676
+ // Final cleanup attempt - textures might need explicit destroy calls
677
+ // Using setTimeout allows the current execution stack to clear
678
+ setTimeout(() => {
679
+ // Attempt to force GC or resource cleanup if needed, though Pixi often handles this
680
+ Assets.cache.reset(); // Be careful with this, might clear too much
681
+ this.app.renderer.textureGC.run(); // Force texture garbage collection
682
+ }, 100);
683
+ }
684
+
685
+ /**
686
+ * Helper method to resize the background layer.
687
+ */
688
+ private _resize_background_layer(
689
+ oldBackgroundLayer: Container | null,
690
+ newWidth: number,
691
+ newHeight: number,
692
+ scale: boolean,
693
+ calculateOffset: () => { offsetX: number; offsetY: number }
694
+ ): Container | null {
695
+ if (!oldBackgroundLayer) {
696
+ // If no old background layer existed, create a new one
697
+ return this.create_background_layer(newWidth, newHeight);
698
+ }
699
+
700
+ // Identify the actual image sprite within the background container
701
+ let backgroundImage: Sprite | null = oldBackgroundLayer.children.find(
702
+ (child) =>
703
+ child instanceof Sprite &&
704
+ child.texture !== (oldBackgroundLayer.children[0] as Sprite)?.texture
705
+ ) as Sprite | null;
706
+
707
+ // Create the new background layer first (this also destroys the old one implicitly if it exists)
708
+ const newBackgroundLayer = this.create_background_layer(
709
+ newWidth,
710
+ newHeight
711
+ );
712
+
713
+ if (backgroundImage) {
714
+ const newBgImage = new Sprite(backgroundImage.texture);
715
+ newBgImage.width = backgroundImage.width; // Preserve original size unless scaling
716
+ newBgImage.height = backgroundImage.height;
717
+
718
+ if (scale) {
719
+ newBgImage.width = newWidth;
720
+ newBgImage.height = newHeight;
721
+ newBgImage.position.set(0, 0);
722
+ } else {
723
+ const { offsetX, offsetY } = calculateOffset();
724
+ newBgImage.position.set(
725
+ backgroundImage.x + offsetX,
726
+ backgroundImage.y + offsetY
727
+ );
728
+ }
729
+ newBackgroundLayer.addChild(newBgImage);
730
+ }
731
+ // If no background image, the new layer created above is already blank and correctly sized.
732
+
733
+ return newBackgroundLayer;
734
+ }
735
+
736
+ /**
737
+ * Helper method to resize a single regular layer.
738
+ */
739
+ private _resize_single_layer(
740
+ oldData: {
741
+ id: string;
742
+ name: string;
743
+ user_created: boolean;
744
+ texture: RenderTexture | undefined;
745
+ container: Container;
746
+ },
747
+ newWidth: number,
748
+ newHeight: number,
749
+ scale: boolean,
750
+ calculateOffset: () => { offsetX: number; offsetY: number }
751
+ ): {
752
+ name: string;
753
+ id: string;
754
+ container: Container;
755
+ user_created: boolean;
756
+ } | null {
757
+ if (!oldData.texture) {
758
+ console.warn(
759
+ `No texture found for layer ${oldData.id}, skipping cleanup.`
760
+ );
761
+ // Destroy the old container if it exists and is not destroyed
762
+ if (oldData.container && !oldData.container.destroyed) {
763
+ if (this.image_container.children.includes(oldData.container)) {
764
+ this.image_container.removeChild(oldData.container);
765
+ }
766
+ oldData.container.destroy({ children: true });
767
+ }
768
+ return null;
769
+ }
770
+
771
+ // Create a new layer container and associated render texture
772
+ const newContainer = this.create_layer({
773
+ // This adds the layer to this.layers and draw_textures
774
+ width: newWidth,
775
+ height: newHeight,
776
+ layer_name: oldData.name,
777
+ user_created: oldData.user_created
778
+ });
779
+
780
+ // Retrieve the newly created layer data (it's the last one added by create_layer)
781
+ const newLayer = this.layers[this.layers.length - 1];
782
+ newLayer.id = oldData.id; // Restore the original ID
783
+
784
+ // Get the render texture for the new layer
785
+ const newTexture = this.draw_textures.get(newContainer);
786
+
787
+ if (!newTexture) {
788
+ console.error(
789
+ `Failed to get texture for newly created layer ${newLayer.id}. Cleaning up.`
790
+ );
791
+ // Clean up the partially created new layer resources
792
+ if (newContainer && !newContainer.destroyed) {
793
+ if (this.image_container.children.includes(newContainer)) {
794
+ this.image_container.removeChild(newContainer);
795
+ }
796
+ newContainer.destroy({ children: true });
797
+ const idx = this.layers.findIndex((l) => l.container === newContainer);
798
+ if (idx > -1) this.layers.splice(idx, 1);
799
+ this.draw_textures.delete(newContainer);
800
+ }
801
+ // Clean up old resources
802
+ if (oldData.texture && !oldData.texture.destroyed)
803
+ oldData.texture.destroy(true);
804
+ if (oldData.container && !oldData.container.destroyed)
805
+ oldData.container.destroy({ children: true });
806
+ return null;
807
+ }
808
+
809
+ // Explicitly clear the new render texture (create_layer might already do this, but explicit is safer)
810
+ this.app.renderer.clear({ target: newTexture, clearColor: [0, 0, 0, 0] });
811
+
812
+ // Create a sprite with the old texture content
813
+ const sprite = new Sprite(oldData.texture);
814
+
815
+ if (scale) {
816
+ sprite.width = newWidth;
817
+ sprite.height = newHeight;
818
+ sprite.position.set(0, 0);
819
+ } else {
820
+ const { offsetX, offsetY } = calculateOffset();
821
+ sprite.position.set(offsetX, offsetY);
822
+ }
823
+
824
+ // Render the sprite onto the new texture
825
+ this.app.renderer.render(sprite, { renderTexture: newTexture });
826
+
827
+ // Clean up temporary sprite and old resources
828
+ sprite.destroy();
829
+ if (oldData.texture && !oldData.texture.destroyed) {
830
+ oldData.texture.destroy(true);
831
+ }
832
+ if (oldData.container && !oldData.container.destroyed) {
833
+ if (this.image_container.children.includes(oldData.container)) {
834
+ this.image_container.removeChild(oldData.container);
835
+ }
836
+ oldData.container.destroy({ children: true });
837
+ }
838
+
839
+ // Return the data for the successfully resized layer
840
+ return newLayer;
841
+ }
842
+
843
+ async get_blobs(width: number, height: number): Promise<ImageBlobs> {
844
+ const blobs = {
845
+ background: await get_canvas_blob(
846
+ this.app.renderer,
847
+ this.background_layer,
848
+ {
849
+ width,
850
+ height,
851
+ x: 0,
852
+ y: 0
853
+ }
854
+ ),
855
+ layers: await Promise.all(
856
+ this.layers.map(async (layer) => {
857
+ const blob = await get_canvas_blob(
858
+ this.app.renderer,
859
+ layer.container,
860
+ {
861
+ width,
862
+ height,
863
+ x: 0,
864
+ y: 0
865
+ }
866
+ );
867
+ if (blob) {
868
+ return blob;
869
+ }
870
+ return null;
871
+ })
872
+ ),
873
+ composite: await get_canvas_blob(this.app.renderer, this.image_container)
874
+ };
875
+
876
+ return blobs;
877
+ }
878
+
879
+ reset_layers(width: number, height: number, persist = false): void {
880
+ const _layers_to_recreate = persist
881
+ ? this.layers.map((layer) => [layer.name, layer.id])
882
+ : this.layer_options.layers.map((layer) => [layer, undefined]);
883
+
884
+ this.layers.forEach((layer) => {
885
+ this.delete_layer(layer.id);
886
+ });
887
+
888
+ for (const [layer_name, layer_id] of _layers_to_recreate) {
889
+ this.create_layer({
890
+ width,
891
+ height,
892
+ layer_name: layer_name,
893
+ user_created: this.layer_options.layers.find((l) => l === layer_name)
894
+ ? false
895
+ : true,
896
+ layer_id: layer_id
897
+ });
898
+ }
899
+
900
+ if (!persist) {
901
+ this.active_layer = this.layers[0].container;
902
+ this.active_layer_id = this.layers[0].id;
903
+ }
904
+
905
+ this.layer_store.update((state) => ({
906
+ active_layer: this.active_layer_id || this.layers[0].id,
907
+ layers: this.layers
908
+ }));
909
+ }
910
+
911
+ init_layers(width: number, height: number): void {
912
+ for (const layer of this.layers) {
913
+ this.delete_layer(layer.id);
914
+ }
915
+ for (const layer of this.layer_options.layers) {
916
+ this.create_layer({
917
+ width,
918
+ height,
919
+ layer_name: layer,
920
+ user_created: false
921
+ });
922
+ }
923
+
924
+ this.active_layer = this.layers[0].container;
925
+ this.active_layer_id = this.layers[0].id;
926
+ this.layer_store.update((_layers) => ({
927
+ active_layer: this.layers[0].id,
928
+ layers: this.layers
929
+ }));
930
+ }
931
+ }
932
+
933
+ const core_tools = ["image", "zoom"] as const;
934
+
935
+ interface ImageEditorOptions {
936
+ target_element: HTMLElement;
937
+ width: number;
938
+ height: number;
939
+ tools: ((typeof core_tools)[number] | Tool)[];
940
+ fixed_canvas?: boolean;
941
+ dark?: boolean;
942
+ border_region?: number;
943
+ layer_options?: LayerOptions;
944
+ pad_bottom?: number;
945
+ theme_mode?: "dark" | "light";
946
+ }
947
+
948
+ const core_tool_map = {
949
+ image: () => new ImageTool(),
950
+ zoom: () => new ZoomTool()
951
+ } as const;
952
+
953
+ export interface ImageEditorContext {
954
+ app: Application;
955
+ ui_container: Container;
956
+ image_container: Container;
957
+ background_image?: Sprite;
958
+ command_manager: CommandManager;
959
+ layer_manager: LayerManager;
960
+ dimensions: Readable<{ width: number; height: number }>;
961
+ scale: Readable<number>;
962
+ position: Readable<{ x: number; y: number }>;
963
+ set_image_properties: (properties: {
964
+ width?: number;
965
+ height?: number;
966
+ scale?: number;
967
+ position?: { x: number; y: number };
968
+ animate?: boolean;
969
+ }) => Promise<void>;
970
+ execute_command: (command: Command) => Promise<void> | void;
971
+ resize_canvas: (width: number, height: number) => void;
972
+ reset: () => void;
973
+ set_background_image: (image: Sprite) => void;
974
+ pad_bottom: number;
975
+ }
976
+
977
+ const spring_config = {
978
+ stiffness: 0.45,
979
+ damping: 0.8
980
+ };
981
+
982
+ interface EditorStatePublic {
983
+ position: { x: number; y: number };
984
+ scale: number;
985
+ subscribe: (callback: (value: any) => void) => () => void;
986
+ current_tool: ToolbarTool;
987
+ current_subtool: Subtool;
988
+ }
989
+
990
+ class EditorState {
991
+ state: EditorStatePublic;
992
+ private scale: number;
993
+ private position: { x: number; y: number };
994
+ private subscribers: Set<(value: any) => void>;
995
+ constructor(editor: ImageEditor) {
996
+ if (!(editor instanceof ImageEditor)) {
997
+ throw new Error("EditorState must be created by ImageEditor");
998
+ }
999
+
1000
+ // Store private data
1001
+ this.scale = 1.0;
1002
+ this.position = { x: 0, y: 0 };
1003
+ this.subscribers = new Set();
1004
+ // @ts-ignore
1005
+ this.state = Object.freeze({
1006
+ get position() {
1007
+ return { ...this.position };
1008
+ },
1009
+ get scale() {
1010
+ return this.scale;
1011
+ },
1012
+ get current_tool() {
1013
+ return this.current_tool;
1014
+ },
1015
+ get current_subtool() {
1016
+ return this.current_subtool;
1017
+ },
1018
+ subscribe: this.subscribe.bind(this)
1019
+ });
1020
+ }
1021
+
1022
+ // Internal methods - only accessible to EditorState class
1023
+ _set_position(x: number, y: number): void {
1024
+ const oldPosition = { ...this.position };
1025
+ this.position = { x, y };
1026
+ this._notify_subscribers("position", oldPosition, this.position);
1027
+ }
1028
+
1029
+ _set_scale(scale: number): void {
1030
+ const oldScale = this.scale;
1031
+ this.scale = scale;
1032
+ this._notify_subscribers("scale", oldScale, scale);
1033
+ }
1034
+
1035
+ _notify_subscribers(property: string, oldValue: any, newValue: any): void {
1036
+ this.subscribers.forEach((callback) => {
1037
+ callback({
1038
+ property,
1039
+ oldValue,
1040
+ newValue,
1041
+ timestamp: Date.now()
1042
+ });
1043
+ });
1044
+ }
1045
+
1046
+ // Public method but only affects notifications
1047
+ subscribe(callback: (value: any) => void): () => void {
1048
+ this.subscribers.add(callback);
1049
+ return () => this.subscribers.delete(callback);
1050
+ }
1051
+ }
1052
+
1053
+ export class ImageEditor {
1054
+ public ready: Promise<void>;
1055
+ public background_image_present = writable(false);
1056
+ public min_zoom = writable(true);
1057
+
1058
+ private app!: Application;
1059
+ private ui_container!: Container;
1060
+ private image_container!: Container;
1061
+ private command_manager: CommandManager;
1062
+ private layer_manager!: LayerManager;
1063
+ private tools: Map<string, Tool> = new Map<string, Tool>();
1064
+ private current_tool!: ToolbarTool;
1065
+ private current_subtool!: Subtool;
1066
+ private target_element: HTMLElement;
1067
+ private width: number;
1068
+ private height: number;
1069
+ dimensions: Spring<{ width: number; height: number }>;
1070
+ scale: Spring<number>;
1071
+ private position: Spring<{ x: number; y: number }>;
1072
+ private state: EditorState;
1073
+ private scale_value = 1;
1074
+ private position_value: { x: number; y: number } = { x: 0, y: 0 };
1075
+ private dimensions_value: { width: number; height: number } = {
1076
+ width: 0,
1077
+ height: 0
1078
+ };
1079
+ layers: Writable<{
1080
+ active_layer: string;
1081
+ layers: {
1082
+ name: string;
1083
+ id: string;
1084
+ user_created: boolean;
1085
+ visible: boolean;
1086
+ }[];
1087
+ }> = writable({
1088
+ active_layer: "",
1089
+ layers: []
1090
+ });
1091
+ private outline_container!: Container;
1092
+ private outline_graphics!: Graphics;
1093
+ private background_image?: Sprite;
1094
+ private ready_resolve!: (value: void | PromiseLike<void>) => void;
1095
+ private event_callbacks: Map<string, (() => void)[]> = new Map();
1096
+ private fixed_canvas: boolean;
1097
+ private dark: boolean;
1098
+ private border_region: number;
1099
+ private layer_options: LayerOptions;
1100
+ private overlay_container!: Container;
1101
+ private overlay_graphics!: Graphics;
1102
+ private pad_bottom: number;
1103
+ private theme_mode: "dark" | "light";
1104
+ constructor(options: ImageEditorOptions) {
1105
+ this.pad_bottom = options.pad_bottom || 0;
1106
+ this.dark = options.dark || false;
1107
+ this.theme_mode = options.theme_mode || "dark";
1108
+ this.target_element = options.target_element;
1109
+ this.width = options.width;
1110
+ this.height = options.height;
1111
+ this.command_manager = new CommandManager();
1112
+ this.ready = new Promise((resolve) => {
1113
+ this.ready_resolve = resolve;
1114
+ });
1115
+ this.fixed_canvas = options.fixed_canvas || false;
1116
+ this.tools = new Map<string, Tool>(
1117
+ options.tools.map((tool) => {
1118
+ if (typeof tool === "string") {
1119
+ return [tool, core_tool_map[tool]()];
1120
+ }
1121
+
1122
+ return [tool.name, tool];
1123
+ })
1124
+ );
1125
+
1126
+ for (const tool of this.tools.values()) {
1127
+ if (tool?.on) {
1128
+ tool.on("change", () => {
1129
+ this.notify("change");
1130
+ });
1131
+ }
1132
+ }
1133
+
1134
+ this.dimensions = spring(
1135
+ { width: this.width, height: this.height },
1136
+ spring_config
1137
+ );
1138
+ this.scale = spring(1, spring_config);
1139
+ this.position = spring({ x: 0, y: 0 }, spring_config);
1140
+ this.state = new EditorState(this);
1141
+ this.border_region = options.border_region || 0;
1142
+ this.layer_options = options.layer_options || {
1143
+ allow_additional_layers: true,
1144
+ layers: ["Layer 1"],
1145
+ disabled: false
1146
+ };
1147
+ this.scale.subscribe((scale) => {
1148
+ this.state._set_scale(scale);
1149
+ });
1150
+ this.position.subscribe((position) => {
1151
+ this.state._set_position(position.x, position.y);
1152
+ });
1153
+ this.init();
1154
+ }
1155
+
1156
+ private get context(): ImageEditorContext {
1157
+ const editor = this;
1158
+ return {
1159
+ app: this.app,
1160
+ ui_container: this.ui_container,
1161
+ image_container: this.image_container,
1162
+ get background_image() {
1163
+ return editor.background_image;
1164
+ },
1165
+ pad_bottom: this.pad_bottom,
1166
+ command_manager: this.command_manager,
1167
+ layer_manager: this.layer_manager,
1168
+ dimensions: { subscribe: this.dimensions.subscribe },
1169
+ scale: { subscribe: this.scale.subscribe },
1170
+ position: { subscribe: this.position.subscribe },
1171
+ set_image_properties: this.set_image_properties.bind(this),
1172
+ execute_command: this.execute_command.bind(this),
1173
+ resize_canvas: this.resize_canvas.bind(this),
1174
+ reset: this.reset.bind(this),
1175
+ set_background_image: this.set_background_image.bind(this)
1176
+ };
1177
+ }
1178
+
1179
+ async init(): Promise<void> {
1180
+ const container_box = this.target_element.getBoundingClientRect();
1181
+ new ResizeObserver((entries) => {
1182
+ entries.forEach((entry) => {
1183
+ this.resize_canvas(
1184
+ entry.contentBoxSize[0].inlineSize,
1185
+ entry.contentBoxSize[0].blockSize
1186
+ );
1187
+ });
1188
+ }).observe(this.target_element);
1189
+
1190
+ this.app = new Application();
1191
+ await this.app.init({
1192
+ width: container_box.width,
1193
+ height: container_box.height,
1194
+ backgroundAlpha: this.dark ? 0 : 1,
1195
+ backgroundColor: this.theme_mode === "dark" ? "#27272a" : "#ffffff",
1196
+ resolution: window.devicePixelRatio,
1197
+ autoDensity: true,
1198
+ antialias: true,
1199
+ powerPreference: "high-performance"
1200
+ });
1201
+
1202
+ // Set canvas properties
1203
+ const canvas = this.app.canvas as HTMLCanvasElement;
1204
+ canvas.style.background = "transparent";
1205
+
1206
+ await this.setup_containers();
1207
+
1208
+ //create background layer
1209
+ this.layer_manager.create_background_layer(this.width, this.height);
1210
+
1211
+ // Create initial layer
1212
+ this.layer_manager.init_layers(this.width, this.height);
1213
+
1214
+ for (const tool of this.tools.values()) {
1215
+ await tool.setup(this.context, this.current_tool, this.current_subtool);
1216
+ }
1217
+
1218
+ const zoom = this.tools.get("zoom") as ZoomTool;
1219
+ if (zoom) {
1220
+ zoom.min_zoom.subscribe((is_min_zoom) => {
1221
+ this.min_zoom.set(is_min_zoom);
1222
+ });
1223
+ }
1224
+
1225
+ this.target_element.appendChild(canvas);
1226
+
1227
+ // Ensure parent element is transparent
1228
+ this.target_element.style.background = "transparent";
1229
+
1230
+ this.dimensions.subscribe((dimensions) => {
1231
+ this.dimensions_value = dimensions;
1232
+ this.image_container.width = dimensions.width;
1233
+ this.image_container.height = dimensions.height;
1234
+ });
1235
+
1236
+ this.scale.subscribe((scale) => {
1237
+ this.scale_value = scale;
1238
+ });
1239
+
1240
+ this.position.subscribe((position) => {
1241
+ this.position_value = position;
1242
+ });
1243
+
1244
+ this.app.ticker.add(() => {
1245
+ this.image_container.scale.set(this.scale_value);
1246
+ this.image_container.position.set(
1247
+ this.position_value.x,
1248
+ this.position_value.y
1249
+ );
1250
+
1251
+ const effective_width = this.dimensions_value.width * this.scale_value;
1252
+ const effective_height = this.dimensions_value.height * this.scale_value;
1253
+
1254
+ // Convert image_container's position (in stage space) to outline_container local coordinates.
1255
+ const local_x = Math.round(
1256
+ this.image_container.position.x - this.outline_container.position.x
1257
+ );
1258
+ const local_y = Math.round(
1259
+ this.image_container.position.y - this.outline_container.position.y
1260
+ );
1261
+
1262
+ // Make sure overlay container follows the same position as outline container
1263
+ this.overlay_container.position.set(
1264
+ this.outline_container.position.x,
1265
+ this.outline_container.position.y
1266
+ );
1267
+
1268
+ this.outline_graphics.clear();
1269
+ this.outline_graphics
1270
+ .rect(local_x, local_y, effective_width, effective_height)
1271
+ .fill({
1272
+ color: this.dark ? 0x333333 : 0xffffff,
1273
+ alpha: 1
1274
+ });
1275
+
1276
+ // Draw border region indicator if border_region > 0
1277
+ if (this.border_region > 0) {
1278
+ const scaled_border = this.border_region * this.scale_value;
1279
+ const border_x = local_x + scaled_border - 1;
1280
+ const border_y = local_y + scaled_border - 1;
1281
+ const border_width = effective_width - scaled_border * 2 + 1;
1282
+ const border_height = effective_height - scaled_border * 2 + 1;
1283
+
1284
+ // Clear previous overlay graphics
1285
+ this.overlay_graphics.clear();
1286
+
1287
+ // Draw border rectangle using pixelLine for crisp 1px outline
1288
+ this.overlay_graphics
1289
+ .rect(border_x, border_y, border_width, border_height)
1290
+ .stroke({
1291
+ color: 0x999999,
1292
+ width: 1,
1293
+ alpha: 0,
1294
+ pixelLine: true
1295
+ });
1296
+
1297
+ // Create dashed line effect by drawing small rects along the border
1298
+ const dashLength = 5;
1299
+ const gapLength = 5;
1300
+ const totalLength = dashLength + gapLength;
1301
+ const lineColor = 0x999999;
1302
+
1303
+ // Draw dashed horizontal lines (top and bottom)
1304
+ for (let x = border_x; x < border_x + border_width; x += totalLength) {
1305
+ // Top dash
1306
+ this.overlay_graphics
1307
+ .rect(
1308
+ x,
1309
+ border_y,
1310
+ Math.min(dashLength, border_x + border_width - x),
1311
+ 1
1312
+ )
1313
+ .fill({ color: lineColor, alpha: 0.7 });
1314
+
1315
+ // Bottom dash
1316
+ this.overlay_graphics
1317
+ .rect(
1318
+ x,
1319
+ border_y + border_height,
1320
+ Math.min(dashLength, border_x + border_width - x),
1321
+ 1
1322
+ )
1323
+ .fill({ color: lineColor, alpha: 0.7 });
1324
+ }
1325
+
1326
+ // Draw dashed vertical lines (left and right)
1327
+ for (let y = border_y; y < border_y + border_height; y += totalLength) {
1328
+ // Left dash
1329
+ this.overlay_graphics
1330
+ .rect(
1331
+ border_x,
1332
+ y,
1333
+ 1,
1334
+ Math.min(dashLength, border_y + border_height - y)
1335
+ )
1336
+ .fill({ color: lineColor, alpha: 0.7 });
1337
+
1338
+ // Right dash
1339
+ this.overlay_graphics
1340
+ .rect(
1341
+ border_x + border_width,
1342
+ y,
1343
+ 1,
1344
+ Math.min(dashLength, border_y + border_height - y)
1345
+ )
1346
+ .fill({ color: lineColor, alpha: 0.7 });
1347
+ }
1348
+ } else {
1349
+ // Clear overlay graphics if no border region
1350
+ this.overlay_graphics.clear();
1351
+ }
1352
+ });
1353
+
1354
+ this.ready_resolve();
1355
+ }
1356
+
1357
+ private async setup_containers(): Promise<void> {
1358
+ this.image_container = new Container({
1359
+ eventMode: "static",
1360
+ sortableChildren: true
1361
+ });
1362
+
1363
+ this.image_container.width = this.width;
1364
+ this.image_container.height = this.height;
1365
+
1366
+ this.app.stage.sortableChildren = true;
1367
+ this.app.stage.alpha = 1;
1368
+ this.app.stage.addChild(this.image_container);
1369
+
1370
+ this.image_container.scale.set(1);
1371
+
1372
+ this.ui_container = new Container({
1373
+ eventMode: "static"
1374
+ });
1375
+ this.app.stage.addChild(this.ui_container);
1376
+ this.ui_container.width = this.width;
1377
+ this.ui_container.height = this.height;
1378
+
1379
+ this.outline_container = new Container();
1380
+ this.outline_container.zIndex = -10;
1381
+
1382
+ this.outline_graphics = new Graphics();
1383
+
1384
+ this.outline_graphics.rect(0, 0, this.width, this.height).fill({
1385
+ color: this.dark ? 0x333333 : 0xffffff,
1386
+ alpha: 0
1387
+ });
1388
+
1389
+ if (!this.dark) {
1390
+ const blurFilter = new BlurFilter({
1391
+ alpha: 0.1,
1392
+ blur: 2,
1393
+ color: 0x000000,
1394
+ offset: { x: 0, y: 0 },
1395
+ quality: 4,
1396
+ shadowOnly: false
1397
+ });
1398
+
1399
+ this.outline_graphics.filters = [blurFilter];
1400
+ }
1401
+
1402
+ this.outline_container.addChild(this.outline_graphics);
1403
+ this.app.stage.addChild(this.outline_container);
1404
+
1405
+ this.overlay_container = new Container();
1406
+ this.app.stage.addChild(this.overlay_container);
1407
+ this.overlay_container.width = this.width;
1408
+ this.overlay_container.height = this.height;
1409
+
1410
+ this.overlay_container.zIndex = 999;
1411
+ this.overlay_container.eventMode = "static";
1412
+ this.overlay_container.interactiveChildren = true;
1413
+ this.overlay_graphics = new Graphics();
1414
+ this.overlay_container.addChild(this.overlay_graphics);
1415
+
1416
+ this.overlay_graphics.rect(0, 0, this.width, this.height).fill({
1417
+ color: 0x000000,
1418
+ alpha: 0
1419
+ });
1420
+
1421
+ const app_center_x = this.app.screen.width / 2;
1422
+ const app_center_y = this.app.screen.height / 2;
1423
+
1424
+ this.image_container.position.set(app_center_x, app_center_y);
1425
+ this.outline_container.position.set(app_center_x, app_center_y);
1426
+
1427
+ this.layer_manager = new LayerManager(
1428
+ this.image_container,
1429
+ this.app,
1430
+ this.fixed_canvas,
1431
+ this.dark,
1432
+ this.border_region,
1433
+ this.layer_options
1434
+ );
1435
+ this.layers = this.layer_manager.layer_store;
1436
+ }
1437
+
1438
+ resize_canvas(width: number, height: number): void {
1439
+ if (this.app.renderer) {
1440
+ this.app.renderer.resize(width, height);
1441
+ }
1442
+
1443
+ // Instead, update the container position to stay centered
1444
+ const app_center_x = width / 2;
1445
+ const app_center_y = height / 2;
1446
+
1447
+ if (this.image_container) {
1448
+ this.image_container.position.set(app_center_x, app_center_y);
1449
+ }
1450
+ if (this.outline_container) {
1451
+ this.outline_container.position.set(app_center_x, app_center_y);
1452
+ }
1453
+ }
1454
+
1455
+ reset(): void {
1456
+ const zoom = this.tools.get("zoom");
1457
+
1458
+ if (zoom) {
1459
+ zoom.cleanup();
1460
+ zoom.setup(this.context, this.current_tool, this.current_subtool);
1461
+ }
1462
+ }
1463
+
1464
+ async set_image_properties(properties: {
1465
+ width?: number;
1466
+ height?: number;
1467
+ scale?: number;
1468
+ position?: { x: number; y: number };
1469
+ animate?: boolean;
1470
+ }): Promise<void> {
1471
+ let hard =
1472
+ typeof properties.animate !== "undefined" ? !properties.animate : true;
1473
+ if (properties.position) {
1474
+ const pos = this.position.set(properties.position, { hard });
1475
+ if (hard) {
1476
+ await pos;
1477
+ }
1478
+ }
1479
+ if (properties.scale) {
1480
+ const scale = this.scale.set(properties.scale, { hard });
1481
+ if (hard) {
1482
+ await scale;
1483
+ }
1484
+ }
1485
+ if (properties.width && properties.height) {
1486
+ this.width = properties.width;
1487
+ this.height = properties.height;
1488
+ const dimensions = this.dimensions.set(
1489
+ { width: properties.width, height: properties.height },
1490
+ { hard }
1491
+ );
1492
+ if (hard) {
1493
+ await dimensions;
1494
+ }
1495
+ }
1496
+ }
1497
+
1498
+ async execute_command(command: Command): Promise<void> {
1499
+ await this.command_manager.execute(command);
1500
+ }
1501
+
1502
+ undo(): void {
1503
+ this.command_manager.undo();
1504
+ this.notify("change");
1505
+ }
1506
+
1507
+ redo(): void {
1508
+ this.command_manager.redo();
1509
+ this.notify("change");
1510
+ }
1511
+
1512
+ async add_image({
1513
+ image,
1514
+ resize = true
1515
+ }: {
1516
+ image: Blob | File;
1517
+ resize?: boolean;
1518
+ }): Promise<void> {
1519
+ const image_tool = this.tools.get("image") as ImageTool;
1520
+ await image_tool.add_image({
1521
+ image,
1522
+ fixed_canvas: this.fixed_canvas,
1523
+ border_region: this.border_region
1524
+ });
1525
+ }
1526
+
1527
+ /**
1528
+ * Adds an image from a URL as the background layer
1529
+ * @param url The URL of the image to add
1530
+ */
1531
+ async add_image_from_url(url: string): Promise<void> {
1532
+ const image_tool = this.tools.get("image") as ImageTool;
1533
+ const texture = await Assets.load(url);
1534
+ await image_tool.add_image({
1535
+ image: texture,
1536
+ fixed_canvas: this.fixed_canvas,
1537
+ border_region: this.border_region
1538
+ });
1539
+
1540
+ // Update resize tool if present
1541
+ const resize_tool = this.tools.get("resize") as any;
1542
+ if (resize_tool && typeof resize_tool.set_border_region === "function") {
1543
+ resize_tool.set_border_region(this.border_region);
1544
+ }
1545
+
1546
+ this.notify("change");
1547
+ this.notify("input");
1548
+ }
1549
+
1550
+ set_tool(tool: ToolbarTool): void {
1551
+ this.current_tool = tool;
1552
+
1553
+ for (const tool of this.tools.values()) {
1554
+ tool.set_tool(this.current_tool, this.current_subtool);
1555
+ }
1556
+ }
1557
+
1558
+ set_subtool(subtool: Subtool): void {
1559
+ this.current_subtool = subtool;
1560
+
1561
+ for (const tool of this.tools.values()) {
1562
+ tool.set_tool(this.current_tool, this.current_subtool);
1563
+ }
1564
+ }
1565
+
1566
+ set_background_image(image: Sprite): void {
1567
+ this.background_image = image;
1568
+ }
1569
+
1570
+ async reset_canvas(): Promise<void> {
1571
+ this.layer_manager.reset_layers(this.width, this.height);
1572
+ // Clear background image
1573
+ this.background_image = undefined;
1574
+ this.background_image_present.set(false);
1575
+
1576
+ // Reset position, scale and dimensions to default values
1577
+ await this.set_image_properties({
1578
+ width: this.width,
1579
+ height: this.height,
1580
+ scale: 1,
1581
+ position: { x: 0, y: 0 },
1582
+ animate: false
1583
+ });
1584
+
1585
+ // Reset command stacks
1586
+ this.command_manager = new CommandManager();
1587
+
1588
+ //create background layer
1589
+ this.layer_manager.create_background_layer(this.width, this.height);
1590
+
1591
+ // Reset tools
1592
+ for (const tool of this.tools.values()) {
1593
+ tool.cleanup();
1594
+ tool.setup(this.context, this.current_tool, this.current_subtool);
1595
+ }
1596
+
1597
+ const zoom_tool = this.tools.get("zoom") as ZoomTool;
1598
+ if (zoom_tool) {
1599
+ zoom_tool.min_zoom.subscribe((is_min_zoom) => {
1600
+ this.min_zoom.set(is_min_zoom);
1601
+ });
1602
+ }
1603
+
1604
+ this.notify("change");
1605
+ }
1606
+
1607
+ add_layer(): void {
1608
+ this.layer_manager.create_layer({
1609
+ width: this.width,
1610
+ height: this.height,
1611
+ layer_name: undefined,
1612
+ user_created: true,
1613
+ layer_id: undefined,
1614
+ make_active: true
1615
+ });
1616
+ this.notify("change");
1617
+ }
1618
+
1619
+ /**
1620
+ * Adds a new layer with an image loaded from a URL
1621
+ * @param layer_urls The URLs of the images to load
1622
+ * @returns A Promise that resolves when all layers are added
1623
+ */
1624
+ async add_layers_from_url(layer_urls: string[] | undefined): Promise<void> {
1625
+ const _layers = this.layer_manager.get_layers();
1626
+ _layers.forEach((l) => this.layer_manager.delete_layer(l.id));
1627
+ if (layer_urls === undefined || layer_urls.length === 0) {
1628
+ this.layer_manager.create_layer({
1629
+ width: this.width,
1630
+ height: this.height,
1631
+ layer_name: undefined,
1632
+ user_created: false
1633
+ });
1634
+ return;
1635
+ }
1636
+
1637
+ for await (const url of layer_urls) {
1638
+ await this.layer_manager.add_layer_from_url(url);
1639
+ }
1640
+
1641
+ this.layer_manager.set_active_layer(_layers[0].id);
1642
+
1643
+ // Update resize tool if present with border region
1644
+ const resize_tool = this.tools.get("resize") as any;
1645
+ if (resize_tool && typeof resize_tool.set_border_region === "function") {
1646
+ resize_tool.set_border_region(this.border_region);
1647
+ }
1648
+
1649
+ this.notify("change");
1650
+ this.notify("input");
1651
+ }
1652
+
1653
+ set_layer(id: string): void {
1654
+ this.layer_manager.set_active_layer(id);
1655
+ this.notify("change");
1656
+ }
1657
+
1658
+ move_layer(id: string, direction: "up" | "down"): void {
1659
+ this.layer_manager.move_layer(id, direction);
1660
+ this.notify("change");
1661
+ }
1662
+
1663
+ delete_layer(id: string): void {
1664
+ this.layer_manager.delete_layer(id);
1665
+ this.notify("change");
1666
+ }
1667
+
1668
+ modify_canvas_size(
1669
+ width: number,
1670
+ height: number,
1671
+ anchor:
1672
+ | "top-left"
1673
+ | "top"
1674
+ | "top-right"
1675
+ | "left"
1676
+ | "center"
1677
+ | "right"
1678
+ | "bottom-left"
1679
+ | "bottom"
1680
+ | "bottom-right",
1681
+ scale: boolean
1682
+ ): void {
1683
+ // Store old dimensions before updating
1684
+ const oldWidth = this.width;
1685
+ const oldHeight = this.height;
1686
+
1687
+ // Resize layers first, passing old dimensions for anchor calculation
1688
+ this.layer_manager.resize_all_layers(
1689
+ width,
1690
+ height,
1691
+ scale,
1692
+ anchor,
1693
+ oldWidth,
1694
+ oldHeight
1695
+ );
1696
+
1697
+ // Update the editor's main dimension tracking *after* resizing layers
1698
+ this.width = width;
1699
+ this.height = height;
1700
+
1701
+ // Update the dimensions store and reset view scale/position
1702
+ this.set_image_properties({
1703
+ width,
1704
+ height,
1705
+ scale: 1, // Reset scale
1706
+ position: { x: 0, y: 0 }, // Reset pan/position of the image_container
1707
+ animate: false
1708
+ });
1709
+ this.notify("change");
1710
+ }
1711
+
1712
+ async get_blobs(): Promise<ImageBlobs> {
1713
+ const blobs = await this.layer_manager.get_blobs(this.width, this.height);
1714
+ return blobs;
1715
+ }
1716
+
1717
+ on(event: "change" | "input", callback: () => void): void {
1718
+ this.event_callbacks.set(event, [
1719
+ ...(this.event_callbacks.get(event) || []),
1720
+ callback
1721
+ ]);
1722
+ }
1723
+
1724
+ off(event: "change", callback: () => void): void {
1725
+ this.event_callbacks.set(
1726
+ event,
1727
+ this.event_callbacks.get(event)?.filter((cb) => cb !== callback) || []
1728
+ );
1729
+ }
1730
+
1731
+ private notify(event: "change" | "input"): void {
1732
+ for (const callback of this.event_callbacks.get(event) || []) {
1733
+ callback();
1734
+ }
1735
+ }
1736
+
1737
+ destroy(): void {
1738
+ if (!this.app) return;
1739
+ // for (const tool of this.tools) {
1740
+ // tool[1]?.cleanup();
1741
+ // }
1742
+
1743
+ this.app?.destroy();
1744
+ }
1745
+
1746
+ resize(width: number, height: number): void {
1747
+ this.set_image_properties({
1748
+ width,
1749
+ height
1750
+ });
1751
+
1752
+ this.reset();
1753
+ }
1754
+
1755
+ async get_crop_bounds(): Promise<{
1756
+ image: Blob | null;
1757
+ crop_dimensions: { width: number; height: number };
1758
+ image_dimensions: { width: number; height: number };
1759
+ x: number;
1760
+ y: number;
1761
+ }> {
1762
+ const crop_tool = this.tools.get("crop") as CropTool;
1763
+ const crop_bounds = crop_tool.get_crop_bounds();
1764
+ const image = await crop_tool.get_image();
1765
+
1766
+ return {
1767
+ image,
1768
+ ...crop_bounds
1769
+ };
1770
+ }
1771
+
1772
+ get background_image_sprite(): Sprite | undefined {
1773
+ return this.background_image;
1774
+ }
1775
+
1776
+ set_layer_options(layer_options: LayerOptions): void {
1777
+ this.layer_options = layer_options;
1778
+ this.layer_manager.set_layer_options(
1779
+ layer_options,
1780
+ this.width,
1781
+ this.height
1782
+ );
1783
+ }
1784
+
1785
+ toggle_layer_visibility(id: string): void {
1786
+ this.layer_manager.toggle_layer_visibility(id);
1787
+ }
1788
+ }