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,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SenangWebs Studio - Layer
|
|
3
|
+
* Individual layer class
|
|
4
|
+
* @version 2.0.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Events } from '../core/EventEmitter.js';
|
|
8
|
+
|
|
9
|
+
let layerIdCounter = 0;
|
|
10
|
+
|
|
11
|
+
export class Layer {
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.id = options.id || `layer_${++layerIdCounter}_${Date.now()}`;
|
|
14
|
+
this.name = options.name || `Layer ${layerIdCounter}`;
|
|
15
|
+
this.type = options.type || 'raster'; // raster, text, shape, adjustment
|
|
16
|
+
this.visible = options.visible !== undefined ? options.visible : true;
|
|
17
|
+
this.locked = options.locked || false;
|
|
18
|
+
this.opacity = options.opacity !== undefined ? options.opacity : 100;
|
|
19
|
+
this.blendMode = options.blendMode || 'normal';
|
|
20
|
+
this.position = options.position || { x: 0, y: 0 };
|
|
21
|
+
this.width = options.width || 0;
|
|
22
|
+
this.height = options.height || 0;
|
|
23
|
+
|
|
24
|
+
// Canvas for raster layers
|
|
25
|
+
this.canvas = null;
|
|
26
|
+
this.ctx = null;
|
|
27
|
+
|
|
28
|
+
// Text layer properties
|
|
29
|
+
this.textContent = options.textContent || '';
|
|
30
|
+
this.textStyle = {
|
|
31
|
+
fontFamily: 'Arial',
|
|
32
|
+
fontSize: 48,
|
|
33
|
+
fontWeight: 'normal',
|
|
34
|
+
fontStyle: 'normal',
|
|
35
|
+
textAlign: 'left',
|
|
36
|
+
color: '#000000',
|
|
37
|
+
lineHeight: 1.2,
|
|
38
|
+
...options.textStyle
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Shape layer properties
|
|
42
|
+
this.shapeType = options.shapeType || null; // rectangle, ellipse, line, polygon
|
|
43
|
+
this.shapeData = options.shapeData || null;
|
|
44
|
+
this.fillColor = options.fillColor || '#000000';
|
|
45
|
+
this.strokeColor = options.strokeColor || 'transparent';
|
|
46
|
+
this.strokeWidth = options.strokeWidth || 0;
|
|
47
|
+
|
|
48
|
+
// Layer mask
|
|
49
|
+
this.mask = null;
|
|
50
|
+
this.maskEnabled = false;
|
|
51
|
+
|
|
52
|
+
// Filters/adjustments
|
|
53
|
+
this.filters = options.filters || [];
|
|
54
|
+
|
|
55
|
+
// Parent reference (set by LayerManager)
|
|
56
|
+
this.app = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Initialize layer canvas
|
|
61
|
+
* @param {number} width - Canvas width
|
|
62
|
+
* @param {number} height - Canvas height
|
|
63
|
+
*/
|
|
64
|
+
initCanvas(width, height) {
|
|
65
|
+
this.width = width;
|
|
66
|
+
this.height = height;
|
|
67
|
+
this.canvas = document.createElement('canvas');
|
|
68
|
+
this.canvas.width = width;
|
|
69
|
+
this.canvas.height = height;
|
|
70
|
+
this.ctx = this.canvas.getContext('2d');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Clear layer canvas
|
|
75
|
+
*/
|
|
76
|
+
clear() {
|
|
77
|
+
if (this.ctx) {
|
|
78
|
+
this.ctx.clearRect(0, 0, this.width, this.height);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fill layer with color
|
|
84
|
+
* @param {string} color - Fill color
|
|
85
|
+
*/
|
|
86
|
+
fill(color) {
|
|
87
|
+
if (!this.ctx) return;
|
|
88
|
+
this.ctx.fillStyle = color;
|
|
89
|
+
this.ctx.fillRect(0, 0, this.width, this.height);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Load image into layer
|
|
94
|
+
* @param {HTMLImageElement|string} source - Image or URL
|
|
95
|
+
* @returns {Promise}
|
|
96
|
+
*/
|
|
97
|
+
loadImage(source) {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const img = source instanceof HTMLImageElement ? source : new Image();
|
|
100
|
+
|
|
101
|
+
const onLoad = () => {
|
|
102
|
+
if (!this.canvas) {
|
|
103
|
+
this.initCanvas(img.width, img.height);
|
|
104
|
+
}
|
|
105
|
+
this.ctx.drawImage(img, 0, 0);
|
|
106
|
+
resolve(this);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (source instanceof HTMLImageElement && source.complete) {
|
|
110
|
+
onLoad();
|
|
111
|
+
} else {
|
|
112
|
+
img.onload = onLoad;
|
|
113
|
+
img.onerror = reject;
|
|
114
|
+
if (typeof source === 'string') {
|
|
115
|
+
img.crossOrigin = 'anonymous';
|
|
116
|
+
img.src = source;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Load from data URL
|
|
124
|
+
* @param {string} dataURL - Data URL
|
|
125
|
+
* @returns {Promise}
|
|
126
|
+
*/
|
|
127
|
+
loadFromDataURL(dataURL) {
|
|
128
|
+
return this.loadImage(dataURL);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resize layer canvas
|
|
133
|
+
* @param {number} width - New width
|
|
134
|
+
* @param {number} height - New height
|
|
135
|
+
* @param {boolean} resizeContent - Scale content to fit
|
|
136
|
+
*/
|
|
137
|
+
resize(width, height, resizeContent = false) {
|
|
138
|
+
if (!this.canvas) {
|
|
139
|
+
this.initCanvas(width, height);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Create temp canvas with current content
|
|
144
|
+
const tempCanvas = document.createElement('canvas');
|
|
145
|
+
tempCanvas.width = this.width;
|
|
146
|
+
tempCanvas.height = this.height;
|
|
147
|
+
const tempCtx = tempCanvas.getContext('2d');
|
|
148
|
+
tempCtx.drawImage(this.canvas, 0, 0);
|
|
149
|
+
|
|
150
|
+
// Resize main canvas
|
|
151
|
+
this.canvas.width = width;
|
|
152
|
+
this.canvas.height = height;
|
|
153
|
+
this.width = width;
|
|
154
|
+
this.height = height;
|
|
155
|
+
|
|
156
|
+
// Restore content
|
|
157
|
+
if (resizeContent) {
|
|
158
|
+
this.ctx.drawImage(tempCanvas, 0, 0, width, height);
|
|
159
|
+
} else {
|
|
160
|
+
this.ctx.drawImage(tempCanvas, 0, 0);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get image data at position
|
|
166
|
+
* @param {number} x - X position
|
|
167
|
+
* @param {number} y - Y position
|
|
168
|
+
* @param {number} width - Width
|
|
169
|
+
* @param {number} height - Height
|
|
170
|
+
* @returns {ImageData}
|
|
171
|
+
*/
|
|
172
|
+
getImageData(x = 0, y = 0, width = this.width, height = this.height) {
|
|
173
|
+
if (!this.ctx) return null;
|
|
174
|
+
return this.ctx.getImageData(x, y, width, height);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Put image data at position
|
|
179
|
+
* @param {ImageData} imageData - Image data
|
|
180
|
+
* @param {number} x - X position
|
|
181
|
+
* @param {number} y - Y position
|
|
182
|
+
*/
|
|
183
|
+
putImageData(imageData, x = 0, y = 0) {
|
|
184
|
+
if (!this.ctx) return;
|
|
185
|
+
this.ctx.putImageData(imageData, x, y);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Draw on layer
|
|
190
|
+
* @param {Function} drawFn - Drawing function receiving context
|
|
191
|
+
*/
|
|
192
|
+
draw(drawFn) {
|
|
193
|
+
if (!this.ctx) return;
|
|
194
|
+
this.ctx.save();
|
|
195
|
+
drawFn(this.ctx);
|
|
196
|
+
this.ctx.restore();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Render layer to context
|
|
201
|
+
* @param {CanvasRenderingContext2D} ctx - Target context
|
|
202
|
+
*/
|
|
203
|
+
render(ctx) {
|
|
204
|
+
if (!this.visible) return;
|
|
205
|
+
|
|
206
|
+
// For raster layers, require canvas
|
|
207
|
+
if (this.type === 'raster' && !this.canvas) return;
|
|
208
|
+
|
|
209
|
+
ctx.save();
|
|
210
|
+
|
|
211
|
+
// Apply opacity
|
|
212
|
+
ctx.globalAlpha = this.opacity / 100;
|
|
213
|
+
|
|
214
|
+
// Apply blend mode
|
|
215
|
+
ctx.globalCompositeOperation = this.getCompositeOperation();
|
|
216
|
+
|
|
217
|
+
// Apply position
|
|
218
|
+
ctx.translate(this.position.x, this.position.y);
|
|
219
|
+
|
|
220
|
+
// Draw based on type
|
|
221
|
+
switch (this.type) {
|
|
222
|
+
case 'text':
|
|
223
|
+
this.renderText(ctx);
|
|
224
|
+
break;
|
|
225
|
+
case 'shape':
|
|
226
|
+
this.renderShape(ctx);
|
|
227
|
+
break;
|
|
228
|
+
default:
|
|
229
|
+
if (this.canvas) {
|
|
230
|
+
ctx.drawImage(this.canvas, 0, 0);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
ctx.restore();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Render text layer
|
|
239
|
+
* @param {CanvasRenderingContext2D} ctx - Target context
|
|
240
|
+
*/
|
|
241
|
+
renderText(ctx) {
|
|
242
|
+
const { fontFamily, fontSize, fontWeight, fontStyle, textAlign, color, lineHeight } = this.textStyle;
|
|
243
|
+
|
|
244
|
+
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
245
|
+
ctx.fillStyle = color;
|
|
246
|
+
ctx.textAlign = textAlign;
|
|
247
|
+
ctx.textBaseline = 'top';
|
|
248
|
+
|
|
249
|
+
const lines = this.textContent.split('\n');
|
|
250
|
+
lines.forEach((line, index) => {
|
|
251
|
+
ctx.fillText(line, 0, index * fontSize * lineHeight);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Render shape layer
|
|
257
|
+
* @param {CanvasRenderingContext2D} ctx - Target context
|
|
258
|
+
*/
|
|
259
|
+
renderShape(ctx) {
|
|
260
|
+
if (!this.shapeData) return;
|
|
261
|
+
|
|
262
|
+
ctx.fillStyle = this.fillColor;
|
|
263
|
+
ctx.strokeStyle = this.strokeColor;
|
|
264
|
+
ctx.lineWidth = this.strokeWidth;
|
|
265
|
+
|
|
266
|
+
const { x, y, width, height, points } = this.shapeData;
|
|
267
|
+
|
|
268
|
+
switch (this.shapeType) {
|
|
269
|
+
case 'rectangle':
|
|
270
|
+
ctx.beginPath();
|
|
271
|
+
ctx.rect(x, y, width, height);
|
|
272
|
+
if (this.fillColor !== 'transparent') ctx.fill();
|
|
273
|
+
if (this.strokeWidth > 0) ctx.stroke();
|
|
274
|
+
break;
|
|
275
|
+
|
|
276
|
+
case 'ellipse':
|
|
277
|
+
ctx.beginPath();
|
|
278
|
+
ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
|
|
279
|
+
if (this.fillColor !== 'transparent') ctx.fill();
|
|
280
|
+
if (this.strokeWidth > 0) ctx.stroke();
|
|
281
|
+
break;
|
|
282
|
+
|
|
283
|
+
case 'line':
|
|
284
|
+
ctx.beginPath();
|
|
285
|
+
ctx.moveTo(points[0].x, points[0].y);
|
|
286
|
+
ctx.lineTo(points[1].x, points[1].y);
|
|
287
|
+
ctx.stroke();
|
|
288
|
+
break;
|
|
289
|
+
|
|
290
|
+
case 'polygon':
|
|
291
|
+
if (points && points.length > 2) {
|
|
292
|
+
ctx.beginPath();
|
|
293
|
+
ctx.moveTo(points[0].x, points[0].y);
|
|
294
|
+
points.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
|
|
295
|
+
ctx.closePath();
|
|
296
|
+
if (this.fillColor !== 'transparent') ctx.fill();
|
|
297
|
+
if (this.strokeWidth > 0) ctx.stroke();
|
|
298
|
+
}
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Get composite operation from blend mode
|
|
305
|
+
* @returns {string} Composite operation
|
|
306
|
+
*/
|
|
307
|
+
getCompositeOperation() {
|
|
308
|
+
const blendModeMap = {
|
|
309
|
+
'normal': 'source-over',
|
|
310
|
+
'multiply': 'multiply',
|
|
311
|
+
'screen': 'screen',
|
|
312
|
+
'overlay': 'overlay',
|
|
313
|
+
'darken': 'darken',
|
|
314
|
+
'lighten': 'lighten',
|
|
315
|
+
'color-dodge': 'color-dodge',
|
|
316
|
+
'color-burn': 'color-burn',
|
|
317
|
+
'hard-light': 'hard-light',
|
|
318
|
+
'soft-light': 'soft-light',
|
|
319
|
+
'difference': 'difference',
|
|
320
|
+
'exclusion': 'exclusion',
|
|
321
|
+
'hue': 'hue',
|
|
322
|
+
'saturation': 'saturation',
|
|
323
|
+
'color': 'color',
|
|
324
|
+
'luminosity': 'luminosity'
|
|
325
|
+
};
|
|
326
|
+
return blendModeMap[this.blendMode] || 'source-over';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Clone layer
|
|
331
|
+
* @returns {Layer}
|
|
332
|
+
*/
|
|
333
|
+
clone() {
|
|
334
|
+
const cloned = new Layer({
|
|
335
|
+
name: `${this.name} copy`,
|
|
336
|
+
type: this.type,
|
|
337
|
+
visible: this.visible,
|
|
338
|
+
locked: this.locked,
|
|
339
|
+
opacity: this.opacity,
|
|
340
|
+
blendMode: this.blendMode,
|
|
341
|
+
position: { ...this.position },
|
|
342
|
+
textContent: this.textContent,
|
|
343
|
+
textStyle: { ...this.textStyle },
|
|
344
|
+
shapeType: this.shapeType,
|
|
345
|
+
shapeData: this.shapeData ? { ...this.shapeData } : null,
|
|
346
|
+
fillColor: this.fillColor,
|
|
347
|
+
strokeColor: this.strokeColor,
|
|
348
|
+
strokeWidth: this.strokeWidth,
|
|
349
|
+
filters: [...this.filters]
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
if (this.canvas) {
|
|
353
|
+
cloned.initCanvas(this.width, this.height);
|
|
354
|
+
cloned.ctx.drawImage(this.canvas, 0, 0);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return cloned;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Export to data URL
|
|
362
|
+
* @param {string} format - Image format
|
|
363
|
+
* @param {number} quality - Quality (0-1)
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
toDataURL(format = 'image/png', quality = 1) {
|
|
367
|
+
if (!this.canvas) return null;
|
|
368
|
+
return this.canvas.toDataURL(format, quality);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Serialize layer to JSON
|
|
373
|
+
* @returns {Object}
|
|
374
|
+
*/
|
|
375
|
+
toJSON() {
|
|
376
|
+
return {
|
|
377
|
+
id: this.id,
|
|
378
|
+
name: this.name,
|
|
379
|
+
type: this.type,
|
|
380
|
+
visible: this.visible,
|
|
381
|
+
locked: this.locked,
|
|
382
|
+
opacity: this.opacity,
|
|
383
|
+
blendMode: this.blendMode,
|
|
384
|
+
position: this.position,
|
|
385
|
+
width: this.width,
|
|
386
|
+
height: this.height,
|
|
387
|
+
textContent: this.textContent,
|
|
388
|
+
textStyle: this.textStyle,
|
|
389
|
+
shapeType: this.shapeType,
|
|
390
|
+
shapeData: this.shapeData,
|
|
391
|
+
fillColor: this.fillColor,
|
|
392
|
+
strokeColor: this.strokeColor,
|
|
393
|
+
strokeWidth: this.strokeWidth,
|
|
394
|
+
filters: this.filters,
|
|
395
|
+
imageData: this.canvas ? this.toDataURL() : null
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Create layer from JSON
|
|
401
|
+
* @param {Object} json - JSON data
|
|
402
|
+
* @returns {Promise<Layer>}
|
|
403
|
+
*/
|
|
404
|
+
static async fromJSON(json) {
|
|
405
|
+
const layer = new Layer(json);
|
|
406
|
+
|
|
407
|
+
if (json.imageData) {
|
|
408
|
+
await layer.loadFromDataURL(json.imageData);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return layer;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export default Layer;
|