@gradio/imageeditor 0.14.4 → 0.15.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 (39) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/InteractiveImageEditor.svelte +4 -7
  3. package/dist/InteractiveImageEditor.svelte +4 -3
  4. package/dist/InteractiveImageEditor.svelte.d.ts +1 -1
  5. package/dist/shared/Controls.svelte +32 -1
  6. package/dist/shared/Controls.svelte.d.ts +2 -0
  7. package/dist/shared/ImageEditor.svelte +23 -1
  8. package/dist/shared/ImageEditor.svelte.d.ts +2 -1
  9. package/dist/shared/brush/brush-cursor.js +0 -42
  10. package/dist/shared/brush/brush-textures.d.ts +19 -2
  11. package/dist/shared/brush/brush-textures.js +120 -82
  12. package/dist/shared/brush/brush.js +0 -52
  13. package/dist/shared/{utils → core}/commands.d.ts +19 -58
  14. package/dist/shared/core/commands.js +70 -0
  15. package/dist/shared/core/editor.d.ts +4 -91
  16. package/dist/shared/core/editor.js +18 -708
  17. package/dist/shared/core/layers.d.ts +143 -0
  18. package/dist/shared/core/layers.js +774 -0
  19. package/dist/shared/image/image.d.ts +14 -4
  20. package/dist/shared/image/image.js +91 -18
  21. package/dist/shared/resize/resize.d.ts +31 -0
  22. package/dist/shared/resize/resize.js +127 -290
  23. package/dist/shared/zoom/zoom.js +0 -51
  24. package/package.json +7 -7
  25. package/shared/Controls.svelte +33 -1
  26. package/shared/ImageEditor.svelte +25 -9
  27. package/shared/Toolbar.svelte +1 -4
  28. package/shared/brush/ColorSwatch.svelte +0 -3
  29. package/shared/brush/brush-cursor.ts +0 -42
  30. package/shared/brush/brush-textures.ts +166 -83
  31. package/shared/brush/brush.ts +0 -53
  32. package/shared/core/commands.ts +118 -0
  33. package/shared/core/editor.ts +33 -957
  34. package/shared/core/layers.ts +1068 -0
  35. package/shared/image/image.ts +139 -21
  36. package/shared/resize/resize.ts +160 -299
  37. package/shared/zoom/zoom.ts +0 -51
  38. package/dist/shared/utils/commands.js +0 -73
  39. package/shared/utils/commands.ts +0 -174
@@ -1,37 +1,24 @@
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";
1
+ import { Application, Container, Graphics, Sprite, Assets } from "pixi.js";
15
2
 
16
3
  import { DropShadowFilter as BlurFilter } from "pixi-filters/drop-shadow";
17
4
 
18
5
  import { ImageTool } from "../image/image";
19
- import { AddImageCommand } from "../image/image";
20
6
 
21
7
  import { ZoomTool } from "../zoom/zoom";
22
8
  import type { Subtool, Tool as ToolbarTool } from "../Toolbar.svelte";
23
9
  import type { Readable, Writable } from "svelte/store";
24
10
  import { spring, type Spring } from "svelte/motion";
25
- import { writable, get } from "svelte/store";
26
- import { Rectangle } from "pixi.js";
11
+ import { writable } from "svelte/store";
27
12
  import { type ImageBlobs, type LayerOptions } from "../types";
28
- import { get_canvas_blob } from "../utils/pixi";
29
13
  import type { BrushTool } from "../brush/brush";
30
14
  import type { CropTool } from "../crop/crop";
31
- export interface Command {
32
- execute: () => Promise<void>;
33
- undo: () => Promise<void>;
34
- }
15
+ import { CommandManager, type Command } from "./commands";
16
+ import {
17
+ LayerManager,
18
+ AddLayerCommand,
19
+ RemoveLayerCommand,
20
+ ReorderLayerCommand
21
+ } from "./layers";
35
22
 
36
23
  export interface Tool {
37
24
  name: string;
@@ -46,895 +33,6 @@ export interface Tool {
46
33
  off?: (event: string, callback: () => void) => void;
47
34
  }
48
35
 
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
- } else {
904
- // the id is the same but the layer is new, we need to find the new layer
905
- this.active_layer =
906
- this.layers.find((l) => l.id === this.active_layer_id)?.container ||
907
- this.layers[0].container;
908
- }
909
-
910
- this.layer_store.update((state) => ({
911
- active_layer: this.active_layer_id || this.layers[0].id,
912
- layers: this.layers
913
- }));
914
- }
915
-
916
- init_layers(width: number, height: number): void {
917
- for (const layer of this.layers) {
918
- this.delete_layer(layer.id);
919
- }
920
- for (const layer of this.layer_options.layers) {
921
- this.create_layer({
922
- width,
923
- height,
924
- layer_name: layer,
925
- user_created: false
926
- });
927
- }
928
-
929
- this.active_layer = this.layers[0].container;
930
- this.active_layer_id = this.layers[0].id;
931
- this.layer_store.update((_layers) => ({
932
- active_layer: this.layers[0].id,
933
- layers: this.layers
934
- }));
935
- }
936
- }
937
-
938
36
  const core_tools = ["image", "zoom"] as const;
939
37
 
940
38
  interface ImageEditorOptions {
@@ -1002,7 +100,6 @@ class EditorState {
1002
100
  throw new Error("EditorState must be created by ImageEditor");
1003
101
  }
1004
102
 
1005
- // Store private data
1006
103
  this.scale = 1.0;
1007
104
  this.position = { x: 0, y: 0 };
1008
105
  this.subscribers = new Set();
@@ -1024,7 +121,6 @@ class EditorState {
1024
121
  });
1025
122
  }
1026
123
 
1027
- // Internal methods - only accessible to EditorState class
1028
124
  _set_position(x: number, y: number): void {
1029
125
  const oldPosition = { ...this.position };
1030
126
  this.position = { x, y };
@@ -1048,7 +144,6 @@ class EditorState {
1048
144
  });
1049
145
  }
1050
146
 
1051
- // Public method but only affects notifications
1052
147
  subscribe(callback: (value: any) => void): () => void {
1053
148
  this.subscribers.add(callback);
1054
149
  return () => this.subscribers.delete(callback);
@@ -1063,7 +158,8 @@ export class ImageEditor {
1063
158
  private app!: Application;
1064
159
  private ui_container!: Container;
1065
160
  private image_container!: Container;
1066
- private command_manager: CommandManager;
161
+ command_manager: CommandManager;
162
+
1067
163
  private layer_manager!: LayerManager;
1068
164
  private tools: Map<string, Tool> = new Map<string, Tool>();
1069
165
  private current_tool!: ToolbarTool;
@@ -1114,6 +210,7 @@ export class ImageEditor {
1114
210
  this.width = options.width;
1115
211
  this.height = options.height;
1116
212
  this.command_manager = new CommandManager();
213
+
1117
214
  this.ready = new Promise((resolve) => {
1118
215
  this.ready_resolve = resolve;
1119
216
  });
@@ -1204,16 +301,13 @@ export class ImageEditor {
1204
301
  powerPreference: "high-performance"
1205
302
  });
1206
303
 
1207
- // Set canvas properties
1208
304
  const canvas = this.app.canvas as HTMLCanvasElement;
1209
305
  canvas.style.background = "transparent";
1210
306
 
1211
307
  await this.setup_containers();
1212
308
 
1213
- //create background layer
1214
309
  this.layer_manager.create_background_layer(this.width, this.height);
1215
310
 
1216
- // Create initial layer
1217
311
  this.layer_manager.init_layers(this.width, this.height);
1218
312
 
1219
313
  for (const tool of this.tools.values()) {
@@ -1229,7 +323,6 @@ export class ImageEditor {
1229
323
 
1230
324
  this.target_element.appendChild(canvas);
1231
325
 
1232
- // Ensure parent element is transparent
1233
326
  this.target_element.style.background = "transparent";
1234
327
 
1235
328
  this.dimensions.subscribe((dimensions) => {
@@ -1256,7 +349,6 @@ export class ImageEditor {
1256
349
  const effective_width = this.dimensions_value.width * this.scale_value;
1257
350
  const effective_height = this.dimensions_value.height * this.scale_value;
1258
351
 
1259
- // Convert image_container's position (in stage space) to outline_container local coordinates.
1260
352
  const local_x = Math.round(
1261
353
  this.image_container.position.x - this.outline_container.position.x
1262
354
  );
@@ -1264,7 +356,6 @@ export class ImageEditor {
1264
356
  this.image_container.position.y - this.outline_container.position.y
1265
357
  );
1266
358
 
1267
- // Make sure overlay container follows the same position as outline container
1268
359
  this.overlay_container.position.set(
1269
360
  this.outline_container.position.x,
1270
361
  this.outline_container.position.y
@@ -1278,7 +369,6 @@ export class ImageEditor {
1278
369
  alpha: 1
1279
370
  });
1280
371
 
1281
- // Draw border region indicator if border_region > 0
1282
372
  if (this.border_region > 0) {
1283
373
  const scaled_border = this.border_region * this.scale_value;
1284
374
  const border_x = local_x + scaled_border - 1;
@@ -1286,10 +376,8 @@ export class ImageEditor {
1286
376
  const border_width = effective_width - scaled_border * 2 + 1;
1287
377
  const border_height = effective_height - scaled_border * 2 + 1;
1288
378
 
1289
- // Clear previous overlay graphics
1290
379
  this.overlay_graphics.clear();
1291
380
 
1292
- // Draw border rectangle using pixelLine for crisp 1px outline
1293
381
  this.overlay_graphics
1294
382
  .rect(border_x, border_y, border_width, border_height)
1295
383
  .stroke({
@@ -1299,15 +387,12 @@ export class ImageEditor {
1299
387
  pixelLine: true
1300
388
  });
1301
389
 
1302
- // Create dashed line effect by drawing small rects along the border
1303
390
  const dashLength = 5;
1304
391
  const gapLength = 5;
1305
392
  const totalLength = dashLength + gapLength;
1306
393
  const lineColor = 0x999999;
1307
394
 
1308
- // Draw dashed horizontal lines (top and bottom)
1309
395
  for (let x = border_x; x < border_x + border_width; x += totalLength) {
1310
- // Top dash
1311
396
  this.overlay_graphics
1312
397
  .rect(
1313
398
  x,
@@ -1317,7 +402,6 @@ export class ImageEditor {
1317
402
  )
1318
403
  .fill({ color: lineColor, alpha: 0.7 });
1319
404
 
1320
- // Bottom dash
1321
405
  this.overlay_graphics
1322
406
  .rect(
1323
407
  x,
@@ -1328,9 +412,7 @@ export class ImageEditor {
1328
412
  .fill({ color: lineColor, alpha: 0.7 });
1329
413
  }
1330
414
 
1331
- // Draw dashed vertical lines (left and right)
1332
415
  for (let y = border_y; y < border_y + border_height; y += totalLength) {
1333
- // Left dash
1334
416
  this.overlay_graphics
1335
417
  .rect(
1336
418
  border_x,
@@ -1340,7 +422,6 @@ export class ImageEditor {
1340
422
  )
1341
423
  .fill({ color: lineColor, alpha: 0.7 });
1342
424
 
1343
- // Right dash
1344
425
  this.overlay_graphics
1345
426
  .rect(
1346
427
  border_x + border_width,
@@ -1351,7 +432,6 @@ export class ImageEditor {
1351
432
  .fill({ color: lineColor, alpha: 0.7 });
1352
433
  }
1353
434
  } else {
1354
- // Clear overlay graphics if no border region
1355
435
  this.overlay_graphics.clear();
1356
436
  }
1357
437
  });
@@ -1445,7 +525,6 @@ export class ImageEditor {
1445
525
  this.app.renderer.resize(width, height);
1446
526
  }
1447
527
 
1448
- // Instead, update the container position to stay centered
1449
528
  const app_center_x = width / 2;
1450
529
  const app_center_y = height / 2;
1451
530
 
@@ -1464,6 +543,12 @@ export class ImageEditor {
1464
543
  zoom.cleanup();
1465
544
  zoom.setup(this.context, this.current_tool, this.current_subtool);
1466
545
  }
546
+
547
+ const brush = this.tools.get("brush") as BrushTool;
548
+
549
+ if (brush) {
550
+ this.set_tool("draw");
551
+ }
1467
552
  }
1468
553
 
1469
554
  async set_image_properties(properties: {
@@ -1534,6 +619,7 @@ export class ImageEditor {
1534
619
  * @param url The URL of the image to add
1535
620
  */
1536
621
  async add_image_from_url(url: string): Promise<void> {
622
+ this.command_manager.reset();
1537
623
  const image_tool = this.tools.get("image") as ImageTool;
1538
624
  const texture = await Assets.load(url);
1539
625
  await image_tool.add_image({
@@ -1542,14 +628,10 @@ export class ImageEditor {
1542
628
  border_region: this.border_region
1543
629
  });
1544
630
 
1545
- // Update resize tool if present
1546
631
  const resize_tool = this.tools.get("resize") as any;
1547
632
  if (resize_tool && typeof resize_tool.set_border_region === "function") {
1548
633
  resize_tool.set_border_region(this.border_region);
1549
634
  }
1550
-
1551
- this.notify("change");
1552
- this.notify("input");
1553
635
  }
1554
636
 
1555
637
  set_tool(tool: ToolbarTool): void {
@@ -1574,11 +656,9 @@ export class ImageEditor {
1574
656
 
1575
657
  async reset_canvas(): Promise<void> {
1576
658
  this.layer_manager.reset_layers(this.width, this.height);
1577
- // Clear background image
1578
659
  this.background_image = undefined;
1579
660
  this.background_image_present.set(false);
1580
661
 
1581
- // Reset position, scale and dimensions to default values
1582
662
  await this.set_image_properties({
1583
663
  width: this.width,
1584
664
  height: this.height,
@@ -1587,13 +667,10 @@ export class ImageEditor {
1587
667
  animate: false
1588
668
  });
1589
669
 
1590
- // Reset command stacks
1591
670
  this.command_manager = new CommandManager();
1592
671
 
1593
- //create background layer
1594
672
  this.layer_manager.create_background_layer(this.width, this.height);
1595
673
 
1596
- // Reset tools
1597
674
  for (const tool of this.tools.values()) {
1598
675
  tool.cleanup();
1599
676
  tool.setup(this.context, this.current_tool, this.current_subtool);
@@ -1610,14 +687,14 @@ export class ImageEditor {
1610
687
  }
1611
688
 
1612
689
  add_layer(): void {
1613
- this.layer_manager.create_layer({
690
+ const add_layer_command = new AddLayerCommand(this.context, {
1614
691
  width: this.width,
1615
692
  height: this.height,
1616
- layer_name: undefined,
1617
693
  user_created: true,
1618
- layer_id: undefined,
1619
694
  make_active: true
1620
695
  });
696
+
697
+ this.command_manager.execute(add_layer_command);
1621
698
  this.notify("change");
1622
699
  }
1623
700
 
@@ -1627,6 +704,7 @@ export class ImageEditor {
1627
704
  * @returns A Promise that resolves when all layers are added
1628
705
  */
1629
706
  async add_layers_from_url(layer_urls: string[] | undefined): Promise<void> {
707
+ this.command_manager.reset();
1630
708
  const _layers = this.layer_manager.get_layers();
1631
709
  _layers.forEach((l) => this.layer_manager.delete_layer(l.id));
1632
710
  if (layer_urls === undefined || layer_urls.length === 0) {
@@ -1645,7 +723,6 @@ export class ImageEditor {
1645
723
 
1646
724
  this.layer_manager.set_active_layer(_layers[0].id);
1647
725
 
1648
- // Update resize tool if present with border region
1649
726
  const resize_tool = this.tools.get("resize") as any;
1650
727
  if (resize_tool && typeof resize_tool.set_border_region === "function") {
1651
728
  resize_tool.set_border_region(this.border_region);
@@ -1661,12 +738,18 @@ export class ImageEditor {
1661
738
  }
1662
739
 
1663
740
  move_layer(id: string, direction: "up" | "down"): void {
1664
- this.layer_manager.move_layer(id, direction);
741
+ const reorder_layer_command = new ReorderLayerCommand(
742
+ this.context,
743
+ id,
744
+ direction
745
+ );
746
+ this.command_manager.execute(reorder_layer_command);
1665
747
  this.notify("change");
1666
748
  }
1667
749
 
1668
750
  delete_layer(id: string): void {
1669
- this.layer_manager.delete_layer(id);
751
+ const remove_layer_command = new RemoveLayerCommand(this.context, id);
752
+ this.command_manager.execute(remove_layer_command);
1670
753
  this.notify("change");
1671
754
  }
1672
755
 
@@ -1685,11 +768,9 @@ export class ImageEditor {
1685
768
  | "bottom-right",
1686
769
  scale: boolean
1687
770
  ): void {
1688
- // Store old dimensions before updating
1689
771
  const oldWidth = this.width;
1690
772
  const oldHeight = this.height;
1691
773
 
1692
- // Resize layers first, passing old dimensions for anchor calculation
1693
774
  this.layer_manager.resize_all_layers(
1694
775
  width,
1695
776
  height,
@@ -1699,16 +780,14 @@ export class ImageEditor {
1699
780
  oldHeight
1700
781
  );
1701
782
 
1702
- // Update the editor's main dimension tracking *after* resizing layers
1703
783
  this.width = width;
1704
784
  this.height = height;
1705
785
 
1706
- // Update the dimensions store and reset view scale/position
1707
786
  this.set_image_properties({
1708
787
  width,
1709
788
  height,
1710
- scale: 1, // Reset scale
1711
- position: { x: 0, y: 0 }, // Reset pan/position of the image_container
789
+ scale: 1,
790
+ position: { x: 0, y: 0 },
1712
791
  animate: false
1713
792
  });
1714
793
  this.notify("change");
@@ -1741,9 +820,6 @@ export class ImageEditor {
1741
820
 
1742
821
  destroy(): void {
1743
822
  if (!this.app) return;
1744
- // for (const tool of this.tools) {
1745
- // tool[1]?.cleanup();
1746
- // }
1747
823
 
1748
824
  this.app?.destroy();
1749
825
  }