senangwebs-photobooth 1.0.1 → 2.0.2
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/README.md +219 -235
- package/dist/swp.css +884 -344
- package/dist/swp.js +1 -1
- package/examples/data-attribute.html +69 -0
- package/examples/index.html +56 -51
- package/examples/studio.html +83 -0
- package/package.json +12 -5
- package/src/css/swp.css +884 -344
- package/src/js/core/Canvas.js +398 -0
- package/src/js/core/EventEmitter.js +188 -0
- package/src/js/core/History.js +250 -0
- package/src/js/core/Keyboard.js +323 -0
- package/src/js/filters/FilterManager.js +248 -0
- package/src/js/index.js +48 -0
- package/src/js/io/Clipboard.js +52 -0
- package/src/js/io/FileManager.js +150 -0
- package/src/js/layers/BlendModes.js +342 -0
- package/src/js/layers/Layer.js +415 -0
- package/src/js/layers/LayerManager.js +459 -0
- package/src/js/selection/Selection.js +167 -0
- package/src/js/swp.js +297 -709
- package/src/js/tools/BaseTool.js +264 -0
- package/src/js/tools/BrushTool.js +314 -0
- package/src/js/tools/CropTool.js +400 -0
- package/src/js/tools/EraserTool.js +155 -0
- package/src/js/tools/EyedropperTool.js +184 -0
- package/src/js/tools/FillTool.js +109 -0
- package/src/js/tools/GradientTool.js +141 -0
- package/src/js/tools/HandTool.js +51 -0
- package/src/js/tools/MarqueeTool.js +103 -0
- package/src/js/tools/MoveTool.js +465 -0
- package/src/js/tools/ShapeTool.js +285 -0
- package/src/js/tools/TextTool.js +253 -0
- package/src/js/tools/ToolManager.js +277 -0
- package/src/js/tools/ZoomTool.js +68 -0
- package/src/js/ui/ColorManager.js +71 -0
- package/src/js/ui/UI.js +1211 -0
- package/swp_preview1.png +0 -0
- package/swp_preview2.png +0 -0
- package/webpack.config.js +4 -11
- package/dist/styles.js +0 -1
- package/examples/customization.html +0 -360
- package/spec.md +0 -239
- package/swp_preview.png +0 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SenangWebs Studio - Layer Manager
|
|
3
|
+
* Managing multiple layers
|
|
4
|
+
* @version 2.0.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Events } from '../core/EventEmitter.js';
|
|
8
|
+
import { Layer } from './Layer.js';
|
|
9
|
+
|
|
10
|
+
export class LayerManager {
|
|
11
|
+
constructor(app) {
|
|
12
|
+
this.app = app;
|
|
13
|
+
this.layers = [];
|
|
14
|
+
this.activeLayer = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Initialize with default background layer
|
|
19
|
+
* @param {number} width - Canvas width
|
|
20
|
+
* @param {number} height - Canvas height
|
|
21
|
+
*/
|
|
22
|
+
init(width, height) {
|
|
23
|
+
this.clear();
|
|
24
|
+
|
|
25
|
+
// Create background layer
|
|
26
|
+
const bgLayer = this.addLayer({
|
|
27
|
+
name: 'Background',
|
|
28
|
+
type: 'raster'
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
bgLayer.initCanvas(width, height);
|
|
32
|
+
bgLayer.fill('#ffffff');
|
|
33
|
+
|
|
34
|
+
this.setActiveLayer(bgLayer.id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Add a new layer
|
|
39
|
+
* @param {Object} options - Layer options
|
|
40
|
+
* @returns {Layer}
|
|
41
|
+
*/
|
|
42
|
+
addLayer(options = {}) {
|
|
43
|
+
const layer = new Layer(options);
|
|
44
|
+
layer.app = this.app;
|
|
45
|
+
|
|
46
|
+
// Initialize canvas if dimensions available
|
|
47
|
+
if (!layer.canvas && this.app.canvas) {
|
|
48
|
+
layer.initCanvas(this.app.canvas.width, this.app.canvas.height);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Insert at top (or at specified index)
|
|
52
|
+
const insertIndex = options.index !== undefined ? options.index : this.layers.length;
|
|
53
|
+
this.layers.splice(insertIndex, 0, layer);
|
|
54
|
+
|
|
55
|
+
// Set as active if no active layer
|
|
56
|
+
if (!this.activeLayer) {
|
|
57
|
+
this.activeLayer = layer;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.app.events.emit(Events.LAYER_ADD, { layer, index: insertIndex });
|
|
61
|
+
|
|
62
|
+
return layer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Remove a layer
|
|
67
|
+
* @param {string} layerId - Layer ID
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
removeLayer(layerId) {
|
|
71
|
+
const index = this.layers.findIndex(l => l.id === layerId);
|
|
72
|
+
if (index === -1) return false;
|
|
73
|
+
|
|
74
|
+
// Don't remove the last layer
|
|
75
|
+
if (this.layers.length === 1) {
|
|
76
|
+
console.warn('Cannot remove the last layer');
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const removed = this.layers.splice(index, 1)[0];
|
|
81
|
+
|
|
82
|
+
// Update active layer if needed
|
|
83
|
+
if (this.activeLayer?.id === layerId) {
|
|
84
|
+
this.activeLayer = this.layers[Math.min(index, this.layers.length - 1)];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.app.events.emit(Events.LAYER_REMOVE, { layer: removed, index });
|
|
88
|
+
this.app.canvas.scheduleRender();
|
|
89
|
+
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get layer by ID
|
|
95
|
+
* @param {string} layerId - Layer ID
|
|
96
|
+
* @returns {Layer|null}
|
|
97
|
+
*/
|
|
98
|
+
getLayer(layerId) {
|
|
99
|
+
return this.layers.find(l => l.id === layerId) || null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get all layers
|
|
104
|
+
* @returns {Layer[]}
|
|
105
|
+
*/
|
|
106
|
+
getLayers() {
|
|
107
|
+
return [...this.layers];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get visible layers
|
|
112
|
+
* @returns {Layer[]}
|
|
113
|
+
*/
|
|
114
|
+
getVisibleLayers() {
|
|
115
|
+
return this.layers.filter(l => l.visible);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Set active layer
|
|
120
|
+
* @param {string} layerId - Layer ID
|
|
121
|
+
*/
|
|
122
|
+
setActiveLayer(layerId) {
|
|
123
|
+
const layer = this.getLayer(layerId);
|
|
124
|
+
if (layer) {
|
|
125
|
+
this.activeLayer = layer;
|
|
126
|
+
this.app.events.emit(Events.LAYER_SELECT, { layer });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get active layer
|
|
132
|
+
* @returns {Layer|null}
|
|
133
|
+
*/
|
|
134
|
+
getActiveLayer() {
|
|
135
|
+
return this.activeLayer;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Move layer to new position
|
|
140
|
+
* @param {string} layerId - Layer ID
|
|
141
|
+
* @param {number} newIndex - New index
|
|
142
|
+
*/
|
|
143
|
+
moveLayer(layerId, newIndex) {
|
|
144
|
+
const currentIndex = this.layers.findIndex(l => l.id === layerId);
|
|
145
|
+
if (currentIndex === -1) return;
|
|
146
|
+
|
|
147
|
+
const [layer] = this.layers.splice(currentIndex, 1);
|
|
148
|
+
this.layers.splice(newIndex, 0, layer);
|
|
149
|
+
|
|
150
|
+
this.app.events.emit(Events.LAYER_REORDER, { layer, oldIndex: currentIndex, newIndex });
|
|
151
|
+
this.app.canvas.scheduleRender();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Move layer up
|
|
156
|
+
* @param {string} layerId - Layer ID
|
|
157
|
+
*/
|
|
158
|
+
moveLayerUp(layerId) {
|
|
159
|
+
const index = this.layers.findIndex(l => l.id === layerId);
|
|
160
|
+
if (index < this.layers.length - 1) {
|
|
161
|
+
this.moveLayer(layerId, index + 1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Move layer down
|
|
167
|
+
* @param {string} layerId - Layer ID
|
|
168
|
+
*/
|
|
169
|
+
moveLayerDown(layerId) {
|
|
170
|
+
const index = this.layers.findIndex(l => l.id === layerId);
|
|
171
|
+
if (index > 0) {
|
|
172
|
+
this.moveLayer(layerId, index - 1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Rename layer
|
|
178
|
+
* @param {string} layerId - Layer ID
|
|
179
|
+
* @param {string} name - New name
|
|
180
|
+
*/
|
|
181
|
+
renameLayer(layerId, name) {
|
|
182
|
+
const layer = this.getLayer(layerId);
|
|
183
|
+
if (layer) {
|
|
184
|
+
layer.name = name;
|
|
185
|
+
this.app.events.emit(Events.LAYER_RENAME, { layer, name });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Set layer visibility
|
|
191
|
+
* @param {string} layerId - Layer ID
|
|
192
|
+
* @param {boolean} visible - Visibility
|
|
193
|
+
*/
|
|
194
|
+
setLayerVisibility(layerId, visible) {
|
|
195
|
+
const layer = this.getLayer(layerId);
|
|
196
|
+
if (layer) {
|
|
197
|
+
layer.visible = visible;
|
|
198
|
+
this.app.events.emit(Events.LAYER_VISIBILITY, { layer, visible });
|
|
199
|
+
this.app.canvas.scheduleRender();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Set layer opacity
|
|
205
|
+
* @param {string} layerId - Layer ID
|
|
206
|
+
* @param {number} opacity - Opacity (0-100)
|
|
207
|
+
*/
|
|
208
|
+
setLayerOpacity(layerId, opacity) {
|
|
209
|
+
const layer = this.getLayer(layerId);
|
|
210
|
+
if (layer) {
|
|
211
|
+
layer.opacity = Math.max(0, Math.min(100, opacity));
|
|
212
|
+
this.app.events.emit(Events.LAYER_OPACITY, { layer, opacity: layer.opacity });
|
|
213
|
+
this.app.canvas.scheduleRender();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Set layer blend mode
|
|
219
|
+
* @param {string} layerId - Layer ID
|
|
220
|
+
* @param {string} blendMode - Blend mode
|
|
221
|
+
*/
|
|
222
|
+
setLayerBlendMode(layerId, blendMode) {
|
|
223
|
+
const layer = this.getLayer(layerId);
|
|
224
|
+
if (layer) {
|
|
225
|
+
layer.blendMode = blendMode;
|
|
226
|
+
this.app.events.emit(Events.LAYER_BLEND_MODE, { layer, blendMode });
|
|
227
|
+
this.app.canvas.scheduleRender();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Lock/unlock layer
|
|
233
|
+
* @param {string} layerId - Layer ID
|
|
234
|
+
* @param {boolean} locked - Lock state
|
|
235
|
+
*/
|
|
236
|
+
setLayerLocked(layerId, locked) {
|
|
237
|
+
const layer = this.getLayer(layerId);
|
|
238
|
+
if (layer) {
|
|
239
|
+
layer.locked = locked;
|
|
240
|
+
this.app.events.emit(Events.LAYER_LOCK, { layer, locked });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Duplicate layer
|
|
246
|
+
* @param {string} layerId - Layer ID
|
|
247
|
+
* @returns {Layer|null}
|
|
248
|
+
*/
|
|
249
|
+
duplicateLayer(layerId) {
|
|
250
|
+
const layer = this.getLayer(layerId);
|
|
251
|
+
if (!layer) return null;
|
|
252
|
+
|
|
253
|
+
const cloned = layer.clone();
|
|
254
|
+
cloned.app = this.app;
|
|
255
|
+
|
|
256
|
+
const index = this.layers.findIndex(l => l.id === layerId);
|
|
257
|
+
this.layers.splice(index + 1, 0, cloned);
|
|
258
|
+
|
|
259
|
+
this.app.events.emit(Events.LAYER_DUPLICATE, { original: layer, duplicate: cloned });
|
|
260
|
+
this.app.canvas.scheduleRender();
|
|
261
|
+
|
|
262
|
+
return cloned;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Merge layer with layer below
|
|
267
|
+
* @param {string} layerId - Layer ID
|
|
268
|
+
*/
|
|
269
|
+
mergeDown(layerId) {
|
|
270
|
+
const index = this.layers.findIndex(l => l.id === layerId);
|
|
271
|
+
if (index <= 0) return; // Can't merge bottom layer
|
|
272
|
+
|
|
273
|
+
const topLayer = this.layers[index];
|
|
274
|
+
const bottomLayer = this.layers[index - 1];
|
|
275
|
+
|
|
276
|
+
if (topLayer.locked || bottomLayer.locked) {
|
|
277
|
+
console.warn('Cannot merge locked layers');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Draw top layer onto bottom layer
|
|
282
|
+
bottomLayer.ctx.save();
|
|
283
|
+
bottomLayer.ctx.globalAlpha = topLayer.opacity / 100;
|
|
284
|
+
bottomLayer.ctx.globalCompositeOperation = topLayer.getCompositeOperation();
|
|
285
|
+
bottomLayer.ctx.translate(topLayer.position.x, topLayer.position.y);
|
|
286
|
+
bottomLayer.ctx.drawImage(topLayer.canvas, 0, 0);
|
|
287
|
+
bottomLayer.ctx.restore();
|
|
288
|
+
|
|
289
|
+
// Remove top layer
|
|
290
|
+
this.layers.splice(index, 1);
|
|
291
|
+
|
|
292
|
+
if (this.activeLayer?.id === layerId) {
|
|
293
|
+
this.activeLayer = bottomLayer;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
this.app.events.emit(Events.LAYER_MERGE, { merged: bottomLayer, removed: topLayer });
|
|
297
|
+
this.app.canvas.scheduleRender();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Merge all visible layers
|
|
302
|
+
* @returns {Layer}
|
|
303
|
+
*/
|
|
304
|
+
mergeVisible() {
|
|
305
|
+
const visibleLayers = this.getVisibleLayers();
|
|
306
|
+
if (visibleLayers.length === 0) return null;
|
|
307
|
+
|
|
308
|
+
// Create new layer for merged result
|
|
309
|
+
const mergedLayer = new Layer({
|
|
310
|
+
name: 'Merged',
|
|
311
|
+
type: 'raster'
|
|
312
|
+
});
|
|
313
|
+
mergedLayer.app = this.app;
|
|
314
|
+
mergedLayer.initCanvas(this.app.canvas.width, this.app.canvas.height);
|
|
315
|
+
|
|
316
|
+
// Composite visible layers
|
|
317
|
+
visibleLayers.forEach(layer => {
|
|
318
|
+
mergedLayer.ctx.save();
|
|
319
|
+
mergedLayer.ctx.globalAlpha = layer.opacity / 100;
|
|
320
|
+
mergedLayer.ctx.globalCompositeOperation = layer.getCompositeOperation();
|
|
321
|
+
mergedLayer.ctx.translate(layer.position.x, layer.position.y);
|
|
322
|
+
if (layer.canvas) {
|
|
323
|
+
mergedLayer.ctx.drawImage(layer.canvas, 0, 0);
|
|
324
|
+
}
|
|
325
|
+
mergedLayer.ctx.restore();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Remove old visible layers and add merged
|
|
329
|
+
this.layers = this.layers.filter(l => !l.visible);
|
|
330
|
+
this.layers.push(mergedLayer);
|
|
331
|
+
this.activeLayer = mergedLayer;
|
|
332
|
+
|
|
333
|
+
this.app.canvas.scheduleRender();
|
|
334
|
+
|
|
335
|
+
return mergedLayer;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Flatten all layers into one
|
|
340
|
+
* @returns {Layer}
|
|
341
|
+
*/
|
|
342
|
+
flatten() {
|
|
343
|
+
const flatLayer = new Layer({
|
|
344
|
+
name: 'Background',
|
|
345
|
+
type: 'raster'
|
|
346
|
+
});
|
|
347
|
+
flatLayer.app = this.app;
|
|
348
|
+
flatLayer.initCanvas(this.app.canvas.width, this.app.canvas.height);
|
|
349
|
+
|
|
350
|
+
// Fill with white background
|
|
351
|
+
flatLayer.fill('#ffffff');
|
|
352
|
+
|
|
353
|
+
// Composite all layers
|
|
354
|
+
this.layers.forEach(layer => {
|
|
355
|
+
if (!layer.visible) return;
|
|
356
|
+
|
|
357
|
+
flatLayer.ctx.save();
|
|
358
|
+
flatLayer.ctx.globalAlpha = layer.opacity / 100;
|
|
359
|
+
flatLayer.ctx.globalCompositeOperation = layer.getCompositeOperation();
|
|
360
|
+
flatLayer.ctx.translate(layer.position.x, layer.position.y);
|
|
361
|
+
if (layer.canvas) {
|
|
362
|
+
flatLayer.ctx.drawImage(layer.canvas, 0, 0);
|
|
363
|
+
}
|
|
364
|
+
flatLayer.ctx.restore();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
this.layers = [flatLayer];
|
|
368
|
+
this.activeLayer = flatLayer;
|
|
369
|
+
|
|
370
|
+
this.app.canvas.scheduleRender();
|
|
371
|
+
|
|
372
|
+
return flatLayer;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Render all layers to context
|
|
377
|
+
* @param {CanvasRenderingContext2D} ctx - Target context
|
|
378
|
+
*/
|
|
379
|
+
render(ctx) {
|
|
380
|
+
// Render layers from bottom to top
|
|
381
|
+
this.layers.forEach(layer => {
|
|
382
|
+
if (layer.visible) {
|
|
383
|
+
layer.render(ctx);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Clear all layers
|
|
390
|
+
*/
|
|
391
|
+
clear() {
|
|
392
|
+
this.layers = [];
|
|
393
|
+
this.activeLayer = null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Delete content in selection on active layer
|
|
398
|
+
*/
|
|
399
|
+
deleteSelection() {
|
|
400
|
+
if (!this.activeLayer || this.activeLayer.locked) return;
|
|
401
|
+
|
|
402
|
+
if (!this.app.selection?.hasSelection()) {
|
|
403
|
+
// No selection - remove the active layer entirely (if more than one layer)
|
|
404
|
+
if (this.layers.length > 1) {
|
|
405
|
+
const layerId = this.activeLayer.id;
|
|
406
|
+
this.removeLayer(layerId);
|
|
407
|
+
this.app.history.pushState('Delete Layer');
|
|
408
|
+
} else {
|
|
409
|
+
// Only one layer - just clear its content
|
|
410
|
+
this.activeLayer.clear();
|
|
411
|
+
this.app.canvas.scheduleRender();
|
|
412
|
+
this.app.history.pushState('Clear Layer');
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
// Clear selection area
|
|
416
|
+
this.app.selection.clearSelection(this.activeLayer);
|
|
417
|
+
this.app.canvas.scheduleRender();
|
|
418
|
+
this.app.history.pushState('Delete Selection');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Serialize all layers to JSON
|
|
424
|
+
* @returns {Array}
|
|
425
|
+
*/
|
|
426
|
+
toJSON() {
|
|
427
|
+
return this.layers.map(l => l.toJSON());
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Load layers from JSON
|
|
432
|
+
* @param {Array} layersData - Layers data
|
|
433
|
+
*/
|
|
434
|
+
async fromJSON(layersData) {
|
|
435
|
+
this.clear();
|
|
436
|
+
|
|
437
|
+
for (const data of layersData) {
|
|
438
|
+
const layer = await Layer.fromJSON(data);
|
|
439
|
+
layer.app = this.app;
|
|
440
|
+
this.layers.push(layer);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (this.layers.length > 0) {
|
|
444
|
+
this.activeLayer = this.layers[this.layers.length - 1];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
this.app.canvas.scheduleRender();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Get layer count
|
|
452
|
+
* @returns {number}
|
|
453
|
+
*/
|
|
454
|
+
get count() {
|
|
455
|
+
return this.layers.length;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export default LayerManager;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SenangWebs Studio - Selection System
|
|
3
|
+
* @version 2.0.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Events } from '../core/EventEmitter.js';
|
|
7
|
+
|
|
8
|
+
export class Selection {
|
|
9
|
+
constructor(app) {
|
|
10
|
+
this.app = app;
|
|
11
|
+
this.path = null;
|
|
12
|
+
this.bounds = null;
|
|
13
|
+
this.shape = 'rectangle';
|
|
14
|
+
this.feather = 0;
|
|
15
|
+
this.marchingAntsOffset = 0;
|
|
16
|
+
this.animationId = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
hasSelection() {
|
|
20
|
+
return this.path !== null || this.bounds !== null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setRect(x, y, width, height, shape = 'rectangle') {
|
|
24
|
+
this.bounds = { x, y, width, height };
|
|
25
|
+
this.shape = shape;
|
|
26
|
+
this.path = null;
|
|
27
|
+
this.startMarchingAnts();
|
|
28
|
+
this.app.events.emit(Events.SELECTION_CREATE, { bounds: this.bounds, shape });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setPath(points) {
|
|
32
|
+
this.path = points;
|
|
33
|
+
this.bounds = this.calculatePathBounds(points);
|
|
34
|
+
this.shape = 'freeform';
|
|
35
|
+
this.startMarchingAnts();
|
|
36
|
+
this.app.events.emit(Events.SELECTION_CREATE, { bounds: this.bounds, path: this.path });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
calculatePathBounds(points) {
|
|
40
|
+
if (!points || points.length === 0) return null;
|
|
41
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
42
|
+
points.forEach(p => {
|
|
43
|
+
minX = Math.min(minX, p.x);
|
|
44
|
+
minY = Math.min(minY, p.y);
|
|
45
|
+
maxX = Math.max(maxX, p.x);
|
|
46
|
+
maxY = Math.max(maxY, p.y);
|
|
47
|
+
});
|
|
48
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
clear() {
|
|
52
|
+
this.path = null;
|
|
53
|
+
this.bounds = null;
|
|
54
|
+
this.stopMarchingAnts();
|
|
55
|
+
this.app.events.emit(Events.SELECTION_CLEAR);
|
|
56
|
+
this.app.canvas.scheduleRender();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
selectAll() {
|
|
60
|
+
this.setRect(0, 0, this.app.canvas.width, this.app.canvas.height);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
deselect() {
|
|
64
|
+
this.clear();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
invert() {
|
|
68
|
+
// TODO: Implement selection inversion
|
|
69
|
+
this.app.events.emit(Events.SELECTION_INVERT);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
isPointInSelection(x, y) {
|
|
73
|
+
if (!this.hasSelection()) return true;
|
|
74
|
+
if (this.shape === 'ellipse' && this.bounds) {
|
|
75
|
+
const cx = this.bounds.x + this.bounds.width / 2;
|
|
76
|
+
const cy = this.bounds.y + this.bounds.height / 2;
|
|
77
|
+
const rx = this.bounds.width / 2;
|
|
78
|
+
const ry = this.bounds.height / 2;
|
|
79
|
+
return Math.pow(x - cx, 2) / Math.pow(rx, 2) + Math.pow(y - cy, 2) / Math.pow(ry, 2) <= 1;
|
|
80
|
+
}
|
|
81
|
+
if (this.bounds) {
|
|
82
|
+
return x >= this.bounds.x && x <= this.bounds.x + this.bounds.width &&
|
|
83
|
+
y >= this.bounds.y && y <= this.bounds.y + this.bounds.height;
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
startMarchingAnts() {
|
|
89
|
+
this.stopMarchingAnts();
|
|
90
|
+
const animate = () => {
|
|
91
|
+
this.marchingAntsOffset = (this.marchingAntsOffset + 0.5) % 10;
|
|
92
|
+
this.app.canvas.scheduleRender();
|
|
93
|
+
this.animationId = requestAnimationFrame(animate);
|
|
94
|
+
};
|
|
95
|
+
this.animationId = requestAnimationFrame(animate);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
stopMarchingAnts() {
|
|
99
|
+
if (this.animationId) {
|
|
100
|
+
cancelAnimationFrame(this.animationId);
|
|
101
|
+
this.animationId = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
render(ctx) {
|
|
106
|
+
if (!this.hasSelection()) return;
|
|
107
|
+
|
|
108
|
+
ctx.save();
|
|
109
|
+
ctx.strokeStyle = '#000000';
|
|
110
|
+
ctx.lineWidth = 1;
|
|
111
|
+
ctx.setLineDash([5, 5]);
|
|
112
|
+
ctx.lineDashOffset = -this.marchingAntsOffset;
|
|
113
|
+
|
|
114
|
+
if (this.shape === 'ellipse' && this.bounds) {
|
|
115
|
+
ctx.beginPath();
|
|
116
|
+
ctx.ellipse(this.bounds.x + this.bounds.width/2, this.bounds.y + this.bounds.height/2,
|
|
117
|
+
this.bounds.width/2, this.bounds.height/2, 0, 0, Math.PI * 2);
|
|
118
|
+
ctx.stroke();
|
|
119
|
+
} else if (this.path) {
|
|
120
|
+
ctx.beginPath();
|
|
121
|
+
ctx.moveTo(this.path[0].x, this.path[0].y);
|
|
122
|
+
this.path.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
|
|
123
|
+
ctx.closePath();
|
|
124
|
+
ctx.stroke();
|
|
125
|
+
} else if (this.bounds) {
|
|
126
|
+
ctx.strokeRect(this.bounds.x, this.bounds.y, this.bounds.width, this.bounds.height);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// White stroke for contrast
|
|
130
|
+
ctx.strokeStyle = '#ffffff';
|
|
131
|
+
ctx.lineDashOffset = -this.marchingAntsOffset + 5;
|
|
132
|
+
if (this.shape === 'ellipse' && this.bounds) {
|
|
133
|
+
ctx.beginPath();
|
|
134
|
+
ctx.ellipse(this.bounds.x + this.bounds.width/2, this.bounds.y + this.bounds.height/2,
|
|
135
|
+
this.bounds.width/2, this.bounds.height/2, 0, 0, Math.PI * 2);
|
|
136
|
+
ctx.stroke();
|
|
137
|
+
} else if (this.bounds) {
|
|
138
|
+
ctx.strokeRect(this.bounds.x, this.bounds.y, this.bounds.width, this.bounds.height);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
ctx.restore();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
clearSelection(layer) {
|
|
145
|
+
if (!this.hasSelection() || !layer.ctx) return;
|
|
146
|
+
const ctx = layer.ctx;
|
|
147
|
+
ctx.save();
|
|
148
|
+
ctx.globalCompositeOperation = 'destination-out';
|
|
149
|
+
|
|
150
|
+
if (this.shape === 'ellipse' && this.bounds) {
|
|
151
|
+
ctx.beginPath();
|
|
152
|
+
ctx.ellipse(this.bounds.x + this.bounds.width/2, this.bounds.y + this.bounds.height/2,
|
|
153
|
+
this.bounds.width/2, this.bounds.height/2, 0, 0, Math.PI * 2);
|
|
154
|
+
ctx.fill();
|
|
155
|
+
} else if (this.bounds) {
|
|
156
|
+
ctx.fillRect(this.bounds.x, this.bounds.y, this.bounds.width, this.bounds.height);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
ctx.restore();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
destroy() {
|
|
163
|
+
this.stopMarchingAnts();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export default Selection;
|