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