@overtone-art/canvas-editor-core 0.0.1 → 0.2.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/dist/index.d.mts +214 -1
- package/dist/index.d.ts +214 -1
- package/dist/index.js +660 -31
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +646 -25
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -11
package/dist/index.mjs
CHANGED
|
@@ -48,6 +48,8 @@ var Layer = class {
|
|
|
48
48
|
visible;
|
|
49
49
|
locked;
|
|
50
50
|
opacity;
|
|
51
|
+
/** Non-fabric data (e.g. pattern config) that must persist with the layer. */
|
|
52
|
+
meta;
|
|
51
53
|
fabricObject;
|
|
52
54
|
constructor(type, fabricObject, name, id) {
|
|
53
55
|
this.id = id ?? generateId();
|
|
@@ -56,9 +58,13 @@ var Layer = class {
|
|
|
56
58
|
this.visible = true;
|
|
57
59
|
this.locked = false;
|
|
58
60
|
this.opacity = 1;
|
|
61
|
+
this.meta = {};
|
|
59
62
|
this.fabricObject = fabricObject;
|
|
60
63
|
this.fabricObject._layerId = this.id;
|
|
61
64
|
}
|
|
65
|
+
hasMeta() {
|
|
66
|
+
return Object.keys(this.meta).length > 0;
|
|
67
|
+
}
|
|
62
68
|
toData() {
|
|
63
69
|
return {
|
|
64
70
|
id: this.id,
|
|
@@ -66,7 +72,8 @@ var Layer = class {
|
|
|
66
72
|
name: this.name,
|
|
67
73
|
visible: this.visible,
|
|
68
74
|
locked: this.locked,
|
|
69
|
-
opacity: this.opacity
|
|
75
|
+
opacity: this.opacity,
|
|
76
|
+
...this.hasMeta() ? { meta: this.meta } : {}
|
|
70
77
|
};
|
|
71
78
|
}
|
|
72
79
|
serialize() {
|
|
@@ -126,6 +133,8 @@ var LayerManager = class {
|
|
|
126
133
|
}
|
|
127
134
|
this.canvas.requestRenderAll();
|
|
128
135
|
this.events.emit("layer:selected", { layerId: id });
|
|
136
|
+
const selected = id && this.get(id) ? [id] : [];
|
|
137
|
+
this.events.emit("selection:changed", { selected });
|
|
129
138
|
}
|
|
130
139
|
get(id) {
|
|
131
140
|
return this.layers.find((l) => l.id === id);
|
|
@@ -224,6 +233,7 @@ var HistoryManager = class {
|
|
|
224
233
|
this.emitChanged();
|
|
225
234
|
}
|
|
226
235
|
async undo() {
|
|
236
|
+
this.cancelPending();
|
|
227
237
|
const state = this.undoStack.pop();
|
|
228
238
|
if (!state) return;
|
|
229
239
|
this.redoStack.push(this.getState());
|
|
@@ -233,6 +243,7 @@ var HistoryManager = class {
|
|
|
233
243
|
this.emitChanged();
|
|
234
244
|
}
|
|
235
245
|
async redo() {
|
|
246
|
+
this.cancelPending();
|
|
236
247
|
const state = this.redoStack.pop();
|
|
237
248
|
if (!state) return;
|
|
238
249
|
this.undoStack.push(this.getState());
|
|
@@ -241,6 +252,11 @@ var HistoryManager = class {
|
|
|
241
252
|
this.paused = false;
|
|
242
253
|
this.emitChanged();
|
|
243
254
|
}
|
|
255
|
+
/** True while a restore (undo/redo/deserialize) is in flight. Managers that
|
|
256
|
+
* react to canvas events should skip mutating history during this window. */
|
|
257
|
+
isRestoring() {
|
|
258
|
+
return this.paused;
|
|
259
|
+
}
|
|
244
260
|
pause() {
|
|
245
261
|
this.paused = true;
|
|
246
262
|
}
|
|
@@ -258,6 +274,12 @@ var HistoryManager = class {
|
|
|
258
274
|
this.redoStack = [];
|
|
259
275
|
this.emitChanged();
|
|
260
276
|
}
|
|
277
|
+
cancelPending() {
|
|
278
|
+
if (this.debounceTimer) {
|
|
279
|
+
clearTimeout(this.debounceTimer);
|
|
280
|
+
this.debounceTimer = null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
261
283
|
dispose() {
|
|
262
284
|
if (this.debounceTimer) {
|
|
263
285
|
clearTimeout(this.debounceTimer);
|
|
@@ -271,6 +293,466 @@ var HistoryManager = class {
|
|
|
271
293
|
}
|
|
272
294
|
};
|
|
273
295
|
|
|
296
|
+
// src/snapping.ts
|
|
297
|
+
var STROKE = "#22c55e";
|
|
298
|
+
var SnapManager = class {
|
|
299
|
+
constructor(canvas, events) {
|
|
300
|
+
this.canvas = canvas;
|
|
301
|
+
this.events = events;
|
|
302
|
+
this.onMoving = (e) => this.handleMoving(e.target);
|
|
303
|
+
this.onAfterRender = () => this.drawGuides();
|
|
304
|
+
this.onModified = () => this.clearGuides();
|
|
305
|
+
this.canvas.on("object:moving", this.onMoving);
|
|
306
|
+
this.canvas.on("after:render", this.onAfterRender);
|
|
307
|
+
this.canvas.on("mouse:up", this.onModified);
|
|
308
|
+
this.canvas.on("selection:cleared", this.onModified);
|
|
309
|
+
}
|
|
310
|
+
canvas;
|
|
311
|
+
events;
|
|
312
|
+
enabled = false;
|
|
313
|
+
threshold = 8;
|
|
314
|
+
guides = [];
|
|
315
|
+
onMoving;
|
|
316
|
+
onAfterRender;
|
|
317
|
+
onModified;
|
|
318
|
+
setEnabled(enabled) {
|
|
319
|
+
if (this.enabled === enabled) return;
|
|
320
|
+
this.enabled = enabled;
|
|
321
|
+
if (!enabled) this.clearGuides();
|
|
322
|
+
this.events.emit("snap:changed", { enabled });
|
|
323
|
+
}
|
|
324
|
+
isEnabled() {
|
|
325
|
+
return this.enabled;
|
|
326
|
+
}
|
|
327
|
+
setThreshold(px) {
|
|
328
|
+
this.threshold = Math.max(0, px);
|
|
329
|
+
}
|
|
330
|
+
dispose() {
|
|
331
|
+
this.canvas.off("object:moving", this.onMoving);
|
|
332
|
+
this.canvas.off("after:render", this.onAfterRender);
|
|
333
|
+
this.canvas.off("mouse:up", this.onModified);
|
|
334
|
+
this.canvas.off("selection:cleared", this.onModified);
|
|
335
|
+
this.guides = [];
|
|
336
|
+
}
|
|
337
|
+
handleMoving(target) {
|
|
338
|
+
if (!this.enabled || !target) return;
|
|
339
|
+
target.setCoords();
|
|
340
|
+
const r = target.getBoundingRect();
|
|
341
|
+
const cw = this.canvas.getWidth();
|
|
342
|
+
const ch = this.canvas.getHeight();
|
|
343
|
+
const { xs, ys } = collectCandidates(this.canvas, target, cw, ch);
|
|
344
|
+
this.guides = [];
|
|
345
|
+
const dx = bestSnap([r.left, r.left + r.width / 2, r.left + r.width], xs, this.threshold);
|
|
346
|
+
if (dx !== null) {
|
|
347
|
+
target.set("left", (target.left ?? 0) + dx.delta);
|
|
348
|
+
this.guides.push({ x: dx.line });
|
|
349
|
+
}
|
|
350
|
+
const dy = bestSnap([r.top, r.top + r.height / 2, r.top + r.height], ys, this.threshold);
|
|
351
|
+
if (dy !== null) {
|
|
352
|
+
target.set("top", (target.top ?? 0) + dy.delta);
|
|
353
|
+
this.guides.push({ y: dy.line });
|
|
354
|
+
}
|
|
355
|
+
target.setCoords();
|
|
356
|
+
}
|
|
357
|
+
drawGuides() {
|
|
358
|
+
if (!this.enabled || this.guides.length === 0) return;
|
|
359
|
+
const ctx = this.canvas.contextTop;
|
|
360
|
+
if (!ctx) return;
|
|
361
|
+
const retina = this.canvas.getRetinaScaling();
|
|
362
|
+
const cw = this.canvas.getWidth();
|
|
363
|
+
const ch = this.canvas.getHeight();
|
|
364
|
+
ctx.save();
|
|
365
|
+
ctx.setTransform(retina, 0, 0, retina, 0, 0);
|
|
366
|
+
ctx.lineWidth = 1;
|
|
367
|
+
ctx.strokeStyle = STROKE;
|
|
368
|
+
ctx.setLineDash([4, 4]);
|
|
369
|
+
ctx.beginPath();
|
|
370
|
+
for (const g of this.guides) {
|
|
371
|
+
if (g.x !== void 0) {
|
|
372
|
+
ctx.moveTo(g.x, 0);
|
|
373
|
+
ctx.lineTo(g.x, ch);
|
|
374
|
+
}
|
|
375
|
+
if (g.y !== void 0) {
|
|
376
|
+
ctx.moveTo(0, g.y);
|
|
377
|
+
ctx.lineTo(cw, g.y);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
ctx.stroke();
|
|
381
|
+
ctx.restore();
|
|
382
|
+
}
|
|
383
|
+
clearGuides() {
|
|
384
|
+
if (this.guides.length === 0) return;
|
|
385
|
+
this.guides = [];
|
|
386
|
+
this.canvas.clearContext(this.canvas.contextTop);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
function collectCandidates(canvas, moving, cw, ch) {
|
|
390
|
+
const xs = [0, cw / 2, cw];
|
|
391
|
+
const ys = [0, ch / 2, ch];
|
|
392
|
+
for (const obj of canvas.getObjects()) {
|
|
393
|
+
if (obj === moving || !obj.visible) continue;
|
|
394
|
+
obj.setCoords();
|
|
395
|
+
const r = obj.getBoundingRect();
|
|
396
|
+
xs.push(r.left, r.left + r.width / 2, r.left + r.width);
|
|
397
|
+
ys.push(r.top, r.top + r.height / 2, r.top + r.height);
|
|
398
|
+
}
|
|
399
|
+
return { xs, ys };
|
|
400
|
+
}
|
|
401
|
+
function bestSnap(edges, candidates, threshold) {
|
|
402
|
+
let best = null;
|
|
403
|
+
for (const edge of edges) {
|
|
404
|
+
for (const c of candidates) {
|
|
405
|
+
const dist = Math.abs(edge - c);
|
|
406
|
+
if (dist <= threshold && (best === null || dist < best.dist)) {
|
|
407
|
+
best = { delta: c - edge, line: c, dist };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return best ? { delta: best.delta, line: best.line } : null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// src/crop.ts
|
|
415
|
+
import { Rect } from "fabric";
|
|
416
|
+
|
|
417
|
+
// src/utils/clamp.ts
|
|
418
|
+
function clamp(v, min, max) {
|
|
419
|
+
return Math.min(max, Math.max(min, v));
|
|
420
|
+
}
|
|
421
|
+
function round2(v) {
|
|
422
|
+
return Math.round(v * 100) / 100;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/crop.ts
|
|
426
|
+
var CropController = class {
|
|
427
|
+
constructor(canvas, history, events) {
|
|
428
|
+
this.canvas = canvas;
|
|
429
|
+
this.history = history;
|
|
430
|
+
this.events = events;
|
|
431
|
+
this.onSelectionCleared = () => {
|
|
432
|
+
if (this.session) this.cancel();
|
|
433
|
+
};
|
|
434
|
+
this.canvas.on("selection:cleared", this.onSelectionCleared);
|
|
435
|
+
}
|
|
436
|
+
canvas;
|
|
437
|
+
history;
|
|
438
|
+
events;
|
|
439
|
+
session = null;
|
|
440
|
+
onSelectionCleared;
|
|
441
|
+
isActive() {
|
|
442
|
+
return this.session !== null;
|
|
443
|
+
}
|
|
444
|
+
activeLayerId() {
|
|
445
|
+
return this.session?.layerId ?? null;
|
|
446
|
+
}
|
|
447
|
+
start(layer) {
|
|
448
|
+
if (layer.type !== "image") return;
|
|
449
|
+
if (this.session) this.cancel();
|
|
450
|
+
const image = layer.fabricObject;
|
|
451
|
+
const prevAngle = image.angle ?? 0;
|
|
452
|
+
if (prevAngle) image.rotate(0);
|
|
453
|
+
image.setCoords();
|
|
454
|
+
const b = image.getBoundingRect();
|
|
455
|
+
const rect = new Rect({
|
|
456
|
+
left: b.left,
|
|
457
|
+
top: b.top,
|
|
458
|
+
width: b.width,
|
|
459
|
+
height: b.height,
|
|
460
|
+
originX: "left",
|
|
461
|
+
originY: "top",
|
|
462
|
+
fill: "rgba(34,197,94,0.12)",
|
|
463
|
+
stroke: STROKE2,
|
|
464
|
+
strokeWidth: 1,
|
|
465
|
+
strokeDashArray: [4, 4],
|
|
466
|
+
strokeUniform: true,
|
|
467
|
+
lockRotation: true,
|
|
468
|
+
cornerColor: STROKE2,
|
|
469
|
+
transparentCorners: false
|
|
470
|
+
});
|
|
471
|
+
rect.setControlsVisibility({ mtr: false });
|
|
472
|
+
const prevSelectable = image.selectable ?? true;
|
|
473
|
+
const prevEvented = image.evented ?? true;
|
|
474
|
+
image.selectable = false;
|
|
475
|
+
image.evented = false;
|
|
476
|
+
this.canvas.add(rect);
|
|
477
|
+
this.canvas.setActiveObject(rect);
|
|
478
|
+
this.canvas.requestRenderAll();
|
|
479
|
+
this.session = { layerId: layer.id, image, rect, prevSelectable, prevEvented, prevAngle };
|
|
480
|
+
this.events.emit("crop:changed", { active: true, layerId: layer.id });
|
|
481
|
+
}
|
|
482
|
+
apply() {
|
|
483
|
+
const s = this.session;
|
|
484
|
+
if (!s) return;
|
|
485
|
+
const { image, rect } = s;
|
|
486
|
+
rect.setCoords();
|
|
487
|
+
image.setCoords();
|
|
488
|
+
const scaleX = image.scaleX ?? 1;
|
|
489
|
+
const scaleY = image.scaleY ?? 1;
|
|
490
|
+
const imgLeft = image.left ?? 0;
|
|
491
|
+
const imgTop = image.top ?? 0;
|
|
492
|
+
const el = image.getElement();
|
|
493
|
+
const naturalW = el.naturalWidth || image.width || 0;
|
|
494
|
+
const naturalH = el.naturalHeight || image.height || 0;
|
|
495
|
+
let cropX = (image.cropX ?? 0) + (rect.left - imgLeft) / scaleX;
|
|
496
|
+
let cropY = (image.cropY ?? 0) + (rect.top - imgTop) / scaleY;
|
|
497
|
+
let cropW = rect.getScaledWidth() / scaleX;
|
|
498
|
+
let cropH = rect.getScaledHeight() / scaleY;
|
|
499
|
+
cropX = clamp(cropX, 0, Math.max(0, naturalW - 1));
|
|
500
|
+
cropY = clamp(cropY, 0, Math.max(0, naturalH - 1));
|
|
501
|
+
cropW = clamp(cropW, 1, naturalW - cropX);
|
|
502
|
+
cropH = clamp(cropH, 1, naturalH - cropY);
|
|
503
|
+
image.set({
|
|
504
|
+
cropX,
|
|
505
|
+
cropY,
|
|
506
|
+
width: cropW,
|
|
507
|
+
height: cropH,
|
|
508
|
+
// Anchor the displayed result where the rect sits.
|
|
509
|
+
left: imgLeft + (cropX - (image.cropX ?? 0)) * scaleX,
|
|
510
|
+
top: imgTop + (cropY - (image.cropY ?? 0)) * scaleY
|
|
511
|
+
});
|
|
512
|
+
image.setCoords();
|
|
513
|
+
this.finish();
|
|
514
|
+
this.history.save();
|
|
515
|
+
}
|
|
516
|
+
cancel() {
|
|
517
|
+
const s = this.session;
|
|
518
|
+
if (!s) return;
|
|
519
|
+
if (s.prevAngle) {
|
|
520
|
+
s.image.rotate(s.prevAngle);
|
|
521
|
+
s.image.setCoords();
|
|
522
|
+
}
|
|
523
|
+
this.finish();
|
|
524
|
+
}
|
|
525
|
+
dispose() {
|
|
526
|
+
this.cancel();
|
|
527
|
+
this.canvas.off("selection:cleared", this.onSelectionCleared);
|
|
528
|
+
}
|
|
529
|
+
finish() {
|
|
530
|
+
const s = this.session;
|
|
531
|
+
if (!s) return;
|
|
532
|
+
this.session = null;
|
|
533
|
+
this.canvas.remove(s.rect);
|
|
534
|
+
s.image.selectable = s.prevSelectable;
|
|
535
|
+
s.image.evented = s.prevEvented;
|
|
536
|
+
this.canvas.setActiveObject(s.image);
|
|
537
|
+
this.canvas.requestRenderAll();
|
|
538
|
+
this.events.emit("crop:changed", { active: false, layerId: null });
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
var STROKE2 = "#22c55e";
|
|
542
|
+
|
|
543
|
+
// src/pattern.ts
|
|
544
|
+
var PatternManager = class {
|
|
545
|
+
constructor(canvas, layers, history) {
|
|
546
|
+
this.canvas = canvas;
|
|
547
|
+
this.layers = layers;
|
|
548
|
+
this.history = history;
|
|
549
|
+
}
|
|
550
|
+
canvas;
|
|
551
|
+
layers;
|
|
552
|
+
history;
|
|
553
|
+
// Per-layer task chain. apply()/disable() both await an async setSrc on the
|
|
554
|
+
// same fabric image; running two concurrently lets their setSrc resolutions
|
|
555
|
+
// interleave (wrong image installed, original lost). Serialising per layer
|
|
556
|
+
// guarantees the last-requested operation wins and state stays consistent.
|
|
557
|
+
chains = /* @__PURE__ */ new Map();
|
|
558
|
+
isPattern(layerId) {
|
|
559
|
+
return !!this.layers.get(layerId)?.meta.pattern;
|
|
560
|
+
}
|
|
561
|
+
getConfig(layerId) {
|
|
562
|
+
return this.layers.get(layerId)?.meta.pattern?.config ?? null;
|
|
563
|
+
}
|
|
564
|
+
/** Turn a plain image layer into a pattern, or update an existing one. */
|
|
565
|
+
apply(layerId, config) {
|
|
566
|
+
return this.enqueue(layerId, async () => {
|
|
567
|
+
const layer = this.layers.get(layerId);
|
|
568
|
+
if (!layer || layer.type !== "image") return;
|
|
569
|
+
const image = layer.fabricObject;
|
|
570
|
+
const firstEnable = !layer.meta.pattern;
|
|
571
|
+
if (!layer.meta.pattern) {
|
|
572
|
+
layer.meta.pattern = {
|
|
573
|
+
config,
|
|
574
|
+
originalSrc: elementToDataURL(image) ?? image.getSrc(),
|
|
575
|
+
original: {
|
|
576
|
+
left: image.left ?? 0,
|
|
577
|
+
top: image.top ?? 0,
|
|
578
|
+
scaleX: image.scaleX ?? 1,
|
|
579
|
+
scaleY: image.scaleY ?? 1,
|
|
580
|
+
width: image.width ?? 0,
|
|
581
|
+
height: image.height ?? 0,
|
|
582
|
+
angle: image.angle ?? 0,
|
|
583
|
+
cropX: image.cropX ?? 0,
|
|
584
|
+
cropY: image.cropY ?? 0
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
} else {
|
|
588
|
+
layer.meta.pattern.config = config;
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
await this.renderLayer(layer);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
if (firstEnable) delete layer.meta.pattern;
|
|
594
|
+
throw err;
|
|
595
|
+
}
|
|
596
|
+
this.history.save();
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
/** Restore the original image and drop the pattern. */
|
|
600
|
+
disable(layerId) {
|
|
601
|
+
return this.enqueue(layerId, async () => {
|
|
602
|
+
const layer = this.layers.get(layerId);
|
|
603
|
+
const state = layer?.meta.pattern;
|
|
604
|
+
if (!layer || !state) return;
|
|
605
|
+
const image = layer.fabricObject;
|
|
606
|
+
await image.setSrc(state.originalSrc);
|
|
607
|
+
image.set({
|
|
608
|
+
left: state.original.left,
|
|
609
|
+
top: state.original.top,
|
|
610
|
+
scaleX: state.original.scaleX,
|
|
611
|
+
scaleY: state.original.scaleY,
|
|
612
|
+
width: state.original.width,
|
|
613
|
+
height: state.original.height,
|
|
614
|
+
cropX: state.original.cropX,
|
|
615
|
+
cropY: state.original.cropY,
|
|
616
|
+
angle: state.original.angle
|
|
617
|
+
});
|
|
618
|
+
image.setCoords();
|
|
619
|
+
delete layer.meta.pattern;
|
|
620
|
+
this.canvas.requestRenderAll();
|
|
621
|
+
this.history.save();
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
/** Run `task` after any in-flight work for this layer, regardless of outcome. */
|
|
625
|
+
enqueue(layerId, task) {
|
|
626
|
+
const prev = this.chains.get(layerId) ?? Promise.resolve();
|
|
627
|
+
const next = prev.then(task, task);
|
|
628
|
+
this.chains.set(
|
|
629
|
+
layerId,
|
|
630
|
+
next.catch(() => void 0)
|
|
631
|
+
);
|
|
632
|
+
return next;
|
|
633
|
+
}
|
|
634
|
+
async renderLayer(layer) {
|
|
635
|
+
const state = layer.meta.pattern;
|
|
636
|
+
if (!state) return;
|
|
637
|
+
const image = layer.fabricObject;
|
|
638
|
+
const cw = this.canvas.getWidth();
|
|
639
|
+
const ch = this.canvas.getHeight();
|
|
640
|
+
const tileW = state.original.width * state.original.scaleX;
|
|
641
|
+
const tileH = state.original.height * state.original.scaleY;
|
|
642
|
+
const dataUrl = await buildPatternDataURL(
|
|
643
|
+
state.originalSrc,
|
|
644
|
+
state.config,
|
|
645
|
+
cw,
|
|
646
|
+
ch,
|
|
647
|
+
tileW,
|
|
648
|
+
tileH
|
|
649
|
+
);
|
|
650
|
+
await image.setSrc(dataUrl);
|
|
651
|
+
image.set({ left: 0, top: 0, scaleX: 1, scaleY: 1, cropX: 0, cropY: 0, angle: 0 });
|
|
652
|
+
image.setCoords();
|
|
653
|
+
this.canvas.requestRenderAll();
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
function elementToDataURL(image) {
|
|
657
|
+
try {
|
|
658
|
+
const el = image.getElement();
|
|
659
|
+
const w = el.naturalWidth || el.width;
|
|
660
|
+
const h = el.naturalHeight || el.height;
|
|
661
|
+
if (!w || !h) return null;
|
|
662
|
+
const off = document.createElement("canvas");
|
|
663
|
+
off.width = w;
|
|
664
|
+
off.height = h;
|
|
665
|
+
const ctx = off.getContext("2d");
|
|
666
|
+
if (!ctx) return null;
|
|
667
|
+
ctx.drawImage(el, 0, 0);
|
|
668
|
+
return off.toDataURL("image/png");
|
|
669
|
+
} catch {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
var IMAGE_CACHE_MAX = 16;
|
|
674
|
+
var imageCache = /* @__PURE__ */ new Map();
|
|
675
|
+
function loadImage(src) {
|
|
676
|
+
const cached = imageCache.get(src);
|
|
677
|
+
if (cached) {
|
|
678
|
+
imageCache.delete(src);
|
|
679
|
+
imageCache.set(src, cached);
|
|
680
|
+
return cached;
|
|
681
|
+
}
|
|
682
|
+
const promise = decodeImage(src);
|
|
683
|
+
promise.catch(() => {
|
|
684
|
+
if (imageCache.get(src) === promise) imageCache.delete(src);
|
|
685
|
+
});
|
|
686
|
+
imageCache.set(src, promise);
|
|
687
|
+
if (imageCache.size > IMAGE_CACHE_MAX) {
|
|
688
|
+
const oldest = imageCache.keys().next().value;
|
|
689
|
+
if (oldest !== void 0) imageCache.delete(oldest);
|
|
690
|
+
}
|
|
691
|
+
return promise;
|
|
692
|
+
}
|
|
693
|
+
function decodeImage(src) {
|
|
694
|
+
return new Promise((resolve, reject) => {
|
|
695
|
+
const img = new Image();
|
|
696
|
+
img.crossOrigin = "anonymous";
|
|
697
|
+
img.onload = () => resolve(img);
|
|
698
|
+
img.onerror = () => reject(new Error(`Failed to load pattern source: ${src}`));
|
|
699
|
+
img.src = src;
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
function clearPatternImageCache() {
|
|
703
|
+
imageCache.clear();
|
|
704
|
+
}
|
|
705
|
+
async function buildPatternDataURL(src, config, targetW, targetH, baseW, baseH) {
|
|
706
|
+
const img = await loadImage(src);
|
|
707
|
+
const off = document.createElement("canvas");
|
|
708
|
+
off.width = Math.max(1, Math.round(targetW));
|
|
709
|
+
off.height = Math.max(1, Math.round(targetH));
|
|
710
|
+
const ctx = off.getContext("2d");
|
|
711
|
+
if (!ctx) return off.toDataURL("image/png");
|
|
712
|
+
drawTiles(ctx, img, config, targetW, targetH, baseW, baseH);
|
|
713
|
+
return off.toDataURL("image/png");
|
|
714
|
+
}
|
|
715
|
+
function computeTilePositions(config, targetW, targetH, baseW, baseH) {
|
|
716
|
+
const tileW = Math.max(1, baseW * (1 + config.horizontalSpacing / 100));
|
|
717
|
+
const tileH = Math.max(1, baseH * (1 + config.verticalSpacing / 100));
|
|
718
|
+
const diag = Math.sqrt(targetW * targetW + targetH * targetH);
|
|
719
|
+
const cols = Math.ceil(diag / tileW) + 2;
|
|
720
|
+
const rows = Math.ceil(diag / tileH) + 2;
|
|
721
|
+
const halfCols = Math.ceil(cols / 2);
|
|
722
|
+
const halfRows = Math.ceil(rows / 2);
|
|
723
|
+
const placements = [];
|
|
724
|
+
for (let j = -halfRows; j <= halfRows; j++) {
|
|
725
|
+
for (let i = -halfCols; i <= halfCols; i++) {
|
|
726
|
+
let x = i * tileW;
|
|
727
|
+
let y = j * tileH;
|
|
728
|
+
if (config.mode === "brick-horizontal" && mod2(j) === 1) {
|
|
729
|
+
x += tileW * (config.horizontalOffset / 100);
|
|
730
|
+
} else if (config.mode === "brick-vertical" && mod2(i) === 1) {
|
|
731
|
+
y += tileH * (config.horizontalOffset / 100);
|
|
732
|
+
}
|
|
733
|
+
const rotation = (i * config.rotationStepH + j * config.rotationStepV) * Math.PI / 180;
|
|
734
|
+
placements.push({ x, y, rotation });
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return placements;
|
|
738
|
+
}
|
|
739
|
+
function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH) {
|
|
740
|
+
ctx.save();
|
|
741
|
+
ctx.translate(targetW / 2, targetH / 2);
|
|
742
|
+
ctx.rotate(config.angle * Math.PI / 180);
|
|
743
|
+
for (const tile of computeTilePositions(config, targetW, targetH, baseW, baseH)) {
|
|
744
|
+
ctx.save();
|
|
745
|
+
ctx.translate(tile.x, tile.y);
|
|
746
|
+
ctx.rotate(tile.rotation);
|
|
747
|
+
ctx.drawImage(img, -baseW / 2, -baseH / 2, baseW, baseH);
|
|
748
|
+
ctx.restore();
|
|
749
|
+
}
|
|
750
|
+
ctx.restore();
|
|
751
|
+
}
|
|
752
|
+
function mod2(n) {
|
|
753
|
+
return (n % 2 + 2) % 2;
|
|
754
|
+
}
|
|
755
|
+
|
|
274
756
|
// src/utils/units.ts
|
|
275
757
|
var MM_PER_INCH = 25.4;
|
|
276
758
|
var UnitConverter = class {
|
|
@@ -327,14 +809,20 @@ function serializeEditor(editor) {
|
|
|
327
809
|
height: editor.canvas.getHeight()
|
|
328
810
|
},
|
|
329
811
|
layers: editor.layers.getAll().map((layer) => layer.serialize()),
|
|
330
|
-
background
|
|
812
|
+
// The configured design background, not the live canvas value (which is
|
|
813
|
+
// forced transparent while a mockup preview is active).
|
|
814
|
+
background: editor.getDesignBackground(),
|
|
815
|
+
mockup: editor.getMockup()
|
|
331
816
|
};
|
|
332
817
|
}
|
|
333
818
|
async function deserializeEditor(editor, state) {
|
|
334
819
|
editor.layers.clear();
|
|
335
820
|
editor.canvas.setDimensions({ width: state.canvas.width, height: state.canvas.height });
|
|
336
821
|
if (state.background) {
|
|
337
|
-
editor.
|
|
822
|
+
editor.setBackground(state.background);
|
|
823
|
+
}
|
|
824
|
+
if (state.mockup !== void 0) {
|
|
825
|
+
editor.setMockup(state.mockup);
|
|
338
826
|
}
|
|
339
827
|
for (const serializedLayer of state.layers) {
|
|
340
828
|
await restoreLayer(editor, serializedLayer);
|
|
@@ -344,12 +832,10 @@ async function deserializeEditor(editor, state) {
|
|
|
344
832
|
async function restoreLayer(editor, serialized) {
|
|
345
833
|
const objects = await util.enlivenObjects([serialized.fabricObject]);
|
|
346
834
|
const fabricObject = objects[0];
|
|
347
|
-
const layer = editor.layers.add(
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
serialized.id
|
|
352
|
-
);
|
|
835
|
+
const layer = editor.layers.add(serialized.type, fabricObject, serialized.name, serialized.id);
|
|
836
|
+
if (serialized.meta) {
|
|
837
|
+
layer.meta = serialized.meta;
|
|
838
|
+
}
|
|
353
839
|
if (!serialized.visible) {
|
|
354
840
|
editor.layers.setVisibility(layer.id, false);
|
|
355
841
|
}
|
|
@@ -380,32 +866,36 @@ function exportDataURL(canvas, format = "png", multiplier = 1) {
|
|
|
380
866
|
return canvas.toDataURL({ format, multiplier });
|
|
381
867
|
}
|
|
382
868
|
|
|
383
|
-
// src/utils/clamp.ts
|
|
384
|
-
function clamp(v, min, max) {
|
|
385
|
-
return Math.min(max, Math.max(min, v));
|
|
386
|
-
}
|
|
387
|
-
function round2(v) {
|
|
388
|
-
return Math.round(v * 100) / 100;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
869
|
// src/editor.ts
|
|
870
|
+
var MIN_ZOOM = 0.1;
|
|
871
|
+
var MAX_ZOOM = 8;
|
|
392
872
|
var CanvasEditor = class {
|
|
393
873
|
canvas;
|
|
394
874
|
layers;
|
|
395
875
|
history;
|
|
396
876
|
events;
|
|
397
877
|
units;
|
|
878
|
+
snapping;
|
|
879
|
+
crop;
|
|
880
|
+
patterns;
|
|
398
881
|
fileAdapter;
|
|
399
882
|
imageProvider;
|
|
883
|
+
zoomLevel = 1;
|
|
884
|
+
mockup = null;
|
|
885
|
+
// The design's configured background. The live canvas background is forced
|
|
886
|
+
// transparent while a mockup preview is shown, so this is the source of truth
|
|
887
|
+
// for serialization and export — not the (possibly transient) canvas value.
|
|
888
|
+
designBackground;
|
|
400
889
|
constructor(canvasElement, config) {
|
|
401
890
|
this.events = new EventEmitter();
|
|
402
891
|
this.units = new UnitConverter(config.unit ?? "px", config.dpi ?? 72);
|
|
403
892
|
const widthPx = this.units.toPixels(config.width);
|
|
404
893
|
const heightPx = this.units.toPixels(config.height);
|
|
894
|
+
this.designBackground = config.backgroundColor ?? "#ffffff";
|
|
405
895
|
this.canvas = new Canvas(canvasElement, {
|
|
406
896
|
width: widthPx,
|
|
407
897
|
height: heightPx,
|
|
408
|
-
backgroundColor:
|
|
898
|
+
backgroundColor: this.designBackground,
|
|
409
899
|
preserveObjectStacking: config.preserveObjectStacking ?? true,
|
|
410
900
|
selection: true
|
|
411
901
|
});
|
|
@@ -420,6 +910,9 @@ var CanvasEditor = class {
|
|
|
420
910
|
},
|
|
421
911
|
events: this.events
|
|
422
912
|
});
|
|
913
|
+
this.snapping = new SnapManager(this.canvas, this.events);
|
|
914
|
+
this.crop = new CropController(this.canvas, this.history, this.events);
|
|
915
|
+
this.patterns = new PatternManager(this.canvas, this.layers, this.history);
|
|
423
916
|
this.setupCanvasEvents();
|
|
424
917
|
this.history.saveImmediate();
|
|
425
918
|
}
|
|
@@ -456,7 +949,11 @@ var CanvasEditor = class {
|
|
|
456
949
|
fill: "#666666",
|
|
457
950
|
width: 300
|
|
458
951
|
});
|
|
459
|
-
const layer = this.layers.add(
|
|
952
|
+
const layer = this.layers.add(
|
|
953
|
+
"template",
|
|
954
|
+
placeholder,
|
|
955
|
+
template.name
|
|
956
|
+
);
|
|
460
957
|
this.history.save();
|
|
461
958
|
return layer;
|
|
462
959
|
}
|
|
@@ -472,6 +969,36 @@ var CanvasEditor = class {
|
|
|
472
969
|
if (!active) return null;
|
|
473
970
|
return this.layers.findByObject(active) ?? null;
|
|
474
971
|
}
|
|
972
|
+
/** Mirror a layer along the given axis. */
|
|
973
|
+
flipLayer(id, axis) {
|
|
974
|
+
const layer = this.layers.get(id);
|
|
975
|
+
if (!layer) return;
|
|
976
|
+
const obj = layer.fabricObject;
|
|
977
|
+
if (axis === "horizontal") {
|
|
978
|
+
obj.set("flipX", !obj.flipX);
|
|
979
|
+
} else {
|
|
980
|
+
obj.set("flipY", !obj.flipY);
|
|
981
|
+
}
|
|
982
|
+
obj.setCoords();
|
|
983
|
+
this.canvas.requestRenderAll();
|
|
984
|
+
this.history.save();
|
|
985
|
+
}
|
|
986
|
+
/** Clone a layer (offset slightly) and select the copy. */
|
|
987
|
+
async duplicateLayer(id) {
|
|
988
|
+
const layer = this.layers.get(id);
|
|
989
|
+
if (!layer) return null;
|
|
990
|
+
const clone = await layer.fabricObject.clone();
|
|
991
|
+
if (!layer.meta.pattern) {
|
|
992
|
+
clone.set({ left: (clone.left ?? 0) + 15, top: (clone.top ?? 0) + 15 });
|
|
993
|
+
}
|
|
994
|
+
clone.setCoords();
|
|
995
|
+
const copy = this.layers.add(layer.type, clone, `${layer.name} copy`);
|
|
996
|
+
copy.meta = structuredClone(layer.meta);
|
|
997
|
+
this.canvas.setActiveObject(clone);
|
|
998
|
+
this.canvas.requestRenderAll();
|
|
999
|
+
this.history.save();
|
|
1000
|
+
return copy;
|
|
1001
|
+
}
|
|
475
1002
|
// ─── Serialization ──────────────────────────────────
|
|
476
1003
|
toJSON() {
|
|
477
1004
|
return serializeEditor(this);
|
|
@@ -482,18 +1009,33 @@ var CanvasEditor = class {
|
|
|
482
1009
|
// ─── Export ──────────────────────────────────────────
|
|
483
1010
|
async toPNG(options) {
|
|
484
1011
|
this.events.emit("export:start", { format: "png" });
|
|
485
|
-
const blob = await exportPNG(this.canvas, options);
|
|
1012
|
+
const blob = await this.withDesignBackground(() => exportPNG(this.canvas, options));
|
|
486
1013
|
this.events.emit("export:complete", { format: "png" });
|
|
487
1014
|
return blob;
|
|
488
1015
|
}
|
|
489
1016
|
toSVG() {
|
|
490
1017
|
this.events.emit("export:start", { format: "svg" });
|
|
491
|
-
const svg = exportSVG(this.canvas);
|
|
1018
|
+
const svg = this.withDesignBackground(() => exportSVG(this.canvas));
|
|
492
1019
|
this.events.emit("export:complete", { format: "svg" });
|
|
493
1020
|
return svg;
|
|
494
1021
|
}
|
|
495
1022
|
toDataURL(format = "png", multiplier = 1) {
|
|
496
|
-
return exportDataURL(this.canvas, format, multiplier);
|
|
1023
|
+
return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Run an export with the configured design background applied, even when a
|
|
1027
|
+
* mockup preview has forced the live canvas transparent — so exports reflect
|
|
1028
|
+
* the design, not the preview. Restores the preview state afterwards.
|
|
1029
|
+
*/
|
|
1030
|
+
withDesignBackground(fn) {
|
|
1031
|
+
if (!this.mockup) return fn();
|
|
1032
|
+
const previewBg = this.canvas.backgroundColor;
|
|
1033
|
+
this.canvas.backgroundColor = this.designBackground;
|
|
1034
|
+
try {
|
|
1035
|
+
return fn();
|
|
1036
|
+
} finally {
|
|
1037
|
+
this.canvas.backgroundColor = previewBg;
|
|
1038
|
+
}
|
|
497
1039
|
}
|
|
498
1040
|
toPrintifyPositioning() {
|
|
499
1041
|
const result = {};
|
|
@@ -545,22 +1087,82 @@ var CanvasEditor = class {
|
|
|
545
1087
|
}
|
|
546
1088
|
// ─── Canvas Operations ──────────────────────────────
|
|
547
1089
|
setBackground(color) {
|
|
548
|
-
this.
|
|
549
|
-
this.
|
|
1090
|
+
this.designBackground = color;
|
|
1091
|
+
if (!this.mockup) {
|
|
1092
|
+
this.canvas.backgroundColor = color;
|
|
1093
|
+
this.canvas.requestRenderAll();
|
|
1094
|
+
}
|
|
550
1095
|
this.history.save();
|
|
551
1096
|
}
|
|
1097
|
+
getDesignBackground() {
|
|
1098
|
+
return this.designBackground;
|
|
1099
|
+
}
|
|
552
1100
|
resize(width, height) {
|
|
553
1101
|
const widthPx = this.units.toPixels(width);
|
|
554
1102
|
const heightPx = this.units.toPixels(height);
|
|
555
1103
|
this.canvas.setDimensions({ width: widthPx, height: heightPx });
|
|
556
1104
|
this.canvas.requestRenderAll();
|
|
557
1105
|
}
|
|
1106
|
+
// ─── Zoom ───────────────────────────────────────────
|
|
1107
|
+
//
|
|
1108
|
+
// Zoom is a *display* transform (the view layer CSS-scales the canvas), not a
|
|
1109
|
+
// fabric viewport zoom — fabric's setZoom bakes into toDataURL/toSVG and would
|
|
1110
|
+
// corrupt exports. The editor only tracks the level and emits an event.
|
|
1111
|
+
setZoom(level) {
|
|
1112
|
+
const next = clamp(round2(level), MIN_ZOOM, MAX_ZOOM);
|
|
1113
|
+
if (next === this.zoomLevel) return;
|
|
1114
|
+
this.zoomLevel = next;
|
|
1115
|
+
this.events.emit("zoom:changed", { zoom: next });
|
|
1116
|
+
}
|
|
1117
|
+
/** @deprecated use setZoom — kept for backward compatibility. */
|
|
558
1118
|
zoom(level) {
|
|
559
|
-
this.
|
|
1119
|
+
this.setZoom(level);
|
|
1120
|
+
}
|
|
1121
|
+
getZoom() {
|
|
1122
|
+
return this.zoomLevel;
|
|
1123
|
+
}
|
|
1124
|
+
zoomIn(step = 0.1) {
|
|
1125
|
+
this.setZoom(this.zoomLevel + step);
|
|
1126
|
+
}
|
|
1127
|
+
zoomOut(step = 0.1) {
|
|
1128
|
+
this.setZoom(this.zoomLevel - step);
|
|
1129
|
+
}
|
|
1130
|
+
resetZoom() {
|
|
1131
|
+
this.setZoom(1);
|
|
1132
|
+
}
|
|
1133
|
+
/** Fit the canvas inside a viewport (in px), accounting for padding. */
|
|
1134
|
+
zoomToFit(viewportWidth, viewportHeight, padding = 0) {
|
|
1135
|
+
const w = this.canvas.getWidth();
|
|
1136
|
+
const h = this.canvas.getHeight();
|
|
1137
|
+
if (w <= 0 || h <= 0) return;
|
|
1138
|
+
const sx = (viewportWidth - padding * 2) / w;
|
|
1139
|
+
const sy = (viewportHeight - padding * 2) / h;
|
|
1140
|
+
this.setZoom(Math.min(sx, sy));
|
|
1141
|
+
}
|
|
1142
|
+
// ─── Patterns ───────────────────────────────────────
|
|
1143
|
+
applyPattern(layerId, config) {
|
|
1144
|
+
return this.patterns.apply(layerId, config);
|
|
1145
|
+
}
|
|
1146
|
+
clearPattern(layerId) {
|
|
1147
|
+
return this.patterns.disable(layerId);
|
|
1148
|
+
}
|
|
1149
|
+
// ─── Mockup (preview-only) ──────────────────────────
|
|
1150
|
+
setMockup(mockup) {
|
|
1151
|
+
this.mockup = mockup;
|
|
1152
|
+
this.canvas.backgroundColor = mockup ? "" : this.designBackground;
|
|
560
1153
|
this.canvas.requestRenderAll();
|
|
1154
|
+
this.events.emit("mockup:changed", { mockup });
|
|
1155
|
+
}
|
|
1156
|
+
clearMockup() {
|
|
1157
|
+
this.setMockup(null);
|
|
1158
|
+
}
|
|
1159
|
+
getMockup() {
|
|
1160
|
+
return this.mockup;
|
|
561
1161
|
}
|
|
562
1162
|
// ─── Cleanup ────────────────────────────────────────
|
|
563
1163
|
dispose() {
|
|
1164
|
+
this.snapping.dispose();
|
|
1165
|
+
this.crop.dispose();
|
|
564
1166
|
this.history.dispose();
|
|
565
1167
|
this.events.removeAllListeners();
|
|
566
1168
|
this.canvas.dispose();
|
|
@@ -588,15 +1190,34 @@ var CanvasEditor = class {
|
|
|
588
1190
|
});
|
|
589
1191
|
}
|
|
590
1192
|
};
|
|
1193
|
+
|
|
1194
|
+
// src/types.ts
|
|
1195
|
+
var DEFAULT_PATTERN_CONFIG = {
|
|
1196
|
+
mode: "grid",
|
|
1197
|
+
horizontalSpacing: 0,
|
|
1198
|
+
verticalSpacing: 0,
|
|
1199
|
+
angle: 0,
|
|
1200
|
+
horizontalOffset: 0,
|
|
1201
|
+
rotationStepH: 0,
|
|
1202
|
+
rotationStepV: 0
|
|
1203
|
+
};
|
|
591
1204
|
export {
|
|
592
1205
|
CanvasEditor,
|
|
1206
|
+
CropController,
|
|
1207
|
+
DEFAULT_PATTERN_CONFIG,
|
|
593
1208
|
EventEmitter,
|
|
594
1209
|
HistoryManager,
|
|
595
1210
|
Layer,
|
|
596
1211
|
LayerManager,
|
|
1212
|
+
PatternManager,
|
|
1213
|
+
SnapManager,
|
|
597
1214
|
UnitConverter,
|
|
1215
|
+
buildPatternDataURL,
|
|
598
1216
|
clamp,
|
|
1217
|
+
clearPatternImageCache,
|
|
1218
|
+
computeTilePositions,
|
|
599
1219
|
deserializeEditor,
|
|
1220
|
+
drawTiles,
|
|
600
1221
|
exportDataURL,
|
|
601
1222
|
exportPNG,
|
|
602
1223
|
exportSVG,
|