@nasser-sw/fabric 7.0.1-beta17 → 7.0.1-beta18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.history/package_20251226051014.json +164 -0
  2. package/dist/fabric.d.ts +2 -0
  3. package/dist/fabric.d.ts.map +1 -1
  4. package/dist/fabric.min.mjs +1 -1
  5. package/dist/fabric.mjs +2 -0
  6. package/dist/fabric.mjs.map +1 -1
  7. package/dist/index.js +1742 -368
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.min.js +1 -1
  10. package/dist/index.min.js.map +1 -1
  11. package/dist/index.min.mjs +1 -1
  12. package/dist/index.min.mjs.map +1 -1
  13. package/dist/index.mjs +1741 -369
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/index.node.cjs +1742 -368
  16. package/dist/index.node.cjs.map +1 -1
  17. package/dist/index.node.mjs +1741 -369
  18. package/dist/index.node.mjs.map +1 -1
  19. package/dist/package.json.min.mjs +1 -1
  20. package/dist/package.json.mjs +1 -1
  21. package/dist/src/LayoutManager/LayoutStrategies/FrameLayout.d.ts +31 -0
  22. package/dist/src/LayoutManager/LayoutStrategies/FrameLayout.d.ts.map +1 -0
  23. package/dist/src/LayoutManager/LayoutStrategies/FrameLayout.min.mjs +2 -0
  24. package/dist/src/LayoutManager/LayoutStrategies/FrameLayout.min.mjs.map +1 -0
  25. package/dist/src/LayoutManager/LayoutStrategies/FrameLayout.mjs +81 -0
  26. package/dist/src/LayoutManager/LayoutStrategies/FrameLayout.mjs.map +1 -0
  27. package/dist/src/LayoutManager/index.d.ts +1 -0
  28. package/dist/src/LayoutManager/index.d.ts.map +1 -1
  29. package/dist/src/controls/commonControls.d.ts.map +1 -1
  30. package/dist/src/controls/commonControls.mjs +25 -6
  31. package/dist/src/controls/commonControls.mjs.map +1 -1
  32. package/dist/src/controls/controlRendering.d.ts +20 -0
  33. package/dist/src/controls/controlRendering.d.ts.map +1 -1
  34. package/dist/src/controls/controlRendering.mjs +63 -1
  35. package/dist/src/controls/controlRendering.mjs.map +1 -1
  36. package/dist/src/shapes/Frame.d.ts +298 -0
  37. package/dist/src/shapes/Frame.d.ts.map +1 -0
  38. package/dist/src/shapes/Frame.min.mjs +2 -0
  39. package/dist/src/shapes/Frame.min.mjs.map +1 -0
  40. package/dist/src/shapes/Frame.mjs +1236 -0
  41. package/dist/src/shapes/Frame.mjs.map +1 -0
  42. package/dist/src/shapes/Object/defaultValues.d.ts.map +1 -1
  43. package/dist/src/shapes/Object/defaultValues.mjs +8 -7
  44. package/dist/src/shapes/Object/defaultValues.mjs.map +1 -1
  45. package/dist-extensions/fabric.d.ts +2 -0
  46. package/dist-extensions/fabric.d.ts.map +1 -1
  47. package/dist-extensions/src/LayoutManager/LayoutStrategies/FrameLayout.d.ts +31 -0
  48. package/dist-extensions/src/LayoutManager/LayoutStrategies/FrameLayout.d.ts.map +1 -0
  49. package/dist-extensions/src/LayoutManager/index.d.ts +1 -0
  50. package/dist-extensions/src/LayoutManager/index.d.ts.map +1 -1
  51. package/dist-extensions/src/controls/commonControls.d.ts.map +1 -1
  52. package/dist-extensions/src/controls/controlRendering.d.ts +20 -0
  53. package/dist-extensions/src/controls/controlRendering.d.ts.map +1 -1
  54. package/dist-extensions/src/shapes/Frame.d.ts +298 -0
  55. package/dist-extensions/src/shapes/Frame.d.ts.map +1 -0
  56. package/dist-extensions/src/shapes/Object/defaultValues.d.ts.map +1 -1
  57. package/fabric.ts +8 -0
  58. package/package.json +1 -1
  59. package/src/LayoutManager/LayoutStrategies/FrameLayout.ts +80 -0
  60. package/src/LayoutManager/index.ts +1 -0
  61. package/src/controls/commonControls.ts +22 -0
  62. package/src/controls/controlRendering.ts +83 -0
  63. package/src/shapes/Frame.ts +1361 -0
  64. package/src/shapes/Object/defaultValues.ts +8 -7
@@ -0,0 +1,1361 @@
1
+ import type { TClassProperties, TOptions, Abortable, TCrossOrigin } from '../typedefs';
2
+ import type { FabricObject } from './Object/FabricObject';
3
+ import type { GroupProps, SerializedGroupProps } from './Group';
4
+ import type { TPointerEvent, Transform, TransformActionHandler } from '../EventTypeDefs';
5
+ import { Group } from './Group';
6
+ import { Rect } from './Rect';
7
+ import { Circle } from './Circle';
8
+ import { Path } from './Path';
9
+ import { FabricImage } from './Image';
10
+ import { classRegistry } from '../ClassRegistry';
11
+ import { LayoutManager } from '../LayoutManager/LayoutManager';
12
+ import { FrameLayout } from '../LayoutManager/LayoutStrategies/FrameLayout';
13
+ import { enlivenObjects, enlivenObjectEnlivables } from '../util/misc/objectEnlive';
14
+ import { Control } from '../controls/Control';
15
+ import { getLocalPoint } from '../controls/util';
16
+ import { wrapWithFireEvent } from '../controls/wrapWithFireEvent';
17
+ import { wrapWithFixedAnchor } from '../controls/wrapWithFixedAnchor';
18
+ import { RESIZING } from '../constants';
19
+
20
+ /**
21
+ * Frame shape types supported out of the box
22
+ */
23
+ export type FrameShapeType = 'rect' | 'circle' | 'rounded-rect' | 'custom';
24
+
25
+ /**
26
+ * Frame metadata for persistence and state management
27
+ */
28
+ export interface FrameMeta {
29
+ /** Aspect ratio label (e.g., '16:9', '1:1', '4:5') */
30
+ aspect?: string;
31
+ /** Content scale factor for cover scaling */
32
+ contentScale?: number;
33
+ /** X offset of content within frame */
34
+ contentOffsetX?: number;
35
+ /** Y offset of content within frame */
36
+ contentOffsetY?: number;
37
+ /** Source URL of the current image */
38
+ imageSrc?: string;
39
+ /** Original image dimensions */
40
+ originalWidth?: number;
41
+ /** Original image dimensions */
42
+ originalHeight?: number;
43
+ }
44
+
45
+ /**
46
+ * Frame-specific properties
47
+ */
48
+ export interface FrameOwnProps {
49
+ /** Fixed width of the frame */
50
+ frameWidth: number;
51
+ /** Fixed height of the frame */
52
+ frameHeight: number;
53
+ /** Shape type for the clip mask */
54
+ frameShape: FrameShapeType;
55
+ /** Border radius for rounded-rect shape */
56
+ frameBorderRadius: number;
57
+ /** Custom SVG path for custom shape */
58
+ frameCustomPath?: string;
59
+ /** Frame metadata for content positioning */
60
+ frameMeta: FrameMeta;
61
+ /** Whether the frame is in edit mode (content can be repositioned) */
62
+ isEditMode: boolean;
63
+ /** Placeholder text shown when frame is empty */
64
+ placeholderText: string;
65
+ /** Placeholder background color */
66
+ placeholderColor: string;
67
+ }
68
+
69
+ export interface SerializedFrameProps extends SerializedGroupProps, FrameOwnProps {}
70
+
71
+ export interface FrameProps extends GroupProps, FrameOwnProps {}
72
+
73
+ export const frameDefaultValues: Partial<TClassProperties<Frame>> = {
74
+ frameWidth: 200,
75
+ frameHeight: 200,
76
+ frameShape: 'rect',
77
+ frameBorderRadius: 0,
78
+ isEditMode: false,
79
+ placeholderText: 'Drop image here',
80
+ placeholderColor: '#d0d0d0',
81
+ frameMeta: {
82
+ contentScale: 1,
83
+ contentOffsetX: 0,
84
+ contentOffsetY: 0,
85
+ },
86
+ };
87
+
88
+ /**
89
+ * Frame class - A Canva-like frame container for images
90
+ *
91
+ * Features:
92
+ * - Fixed dimensions that don't change when content is added/removed
93
+ * - Multiple shape types (rect, circle, rounded-rect, custom SVG path)
94
+ * - Cover scaling: images fill the frame completely, overflow is clipped
95
+ * - Double-click edit mode: reposition/zoom content within frame
96
+ * - Drag & drop support for replacing images
97
+ * - Full serialization/deserialization support
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * // Create a rectangular frame
102
+ * const frame = new Frame([], {
103
+ * frameWidth: 300,
104
+ * frameHeight: 200,
105
+ * frameShape: 'rect',
106
+ * left: 100,
107
+ * top: 100,
108
+ * });
109
+ *
110
+ * // Add image with cover scaling
111
+ * await frame.setImage('https://example.com/image.jpg');
112
+ *
113
+ * canvas.add(frame);
114
+ * ```
115
+ */
116
+ export class Frame extends Group {
117
+ static type = 'Frame';
118
+
119
+ declare frameWidth: number;
120
+ declare frameHeight: number;
121
+ declare frameShape: FrameShapeType;
122
+ declare frameBorderRadius: number;
123
+ declare frameCustomPath?: string;
124
+ declare frameMeta: FrameMeta;
125
+ declare isEditMode: boolean;
126
+ declare placeholderText: string;
127
+ declare placeholderColor: string;
128
+
129
+ /**
130
+ * Reference to the content image
131
+ * @private
132
+ */
133
+ private _contentImage: FabricImage | null = null;
134
+
135
+ /**
136
+ * Reference to the placeholder object
137
+ * @private
138
+ */
139
+ private _placeholder: FabricObject | null = null;
140
+
141
+ static ownDefaults = frameDefaultValues;
142
+
143
+ static getDefaults(): Record<string, any> {
144
+ return {
145
+ ...super.getDefaults(),
146
+ ...Frame.ownDefaults,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Constructor
152
+ * @param objects - Initial objects (typically empty for frames)
153
+ * @param options - Frame configuration options
154
+ */
155
+ constructor(
156
+ objects: FabricObject[] = [],
157
+ options: Partial<FrameProps> = {}
158
+ ) {
159
+ // Set up the frame layout manager before calling super
160
+ const frameLayoutManager = new LayoutManager(new FrameLayout());
161
+
162
+ super(objects, {
163
+ ...options,
164
+ layoutManager: frameLayoutManager,
165
+ });
166
+
167
+ // Apply defaults
168
+ Object.assign(this, Frame.ownDefaults);
169
+
170
+ // Apply user options
171
+ this.setOptions(options);
172
+
173
+ // Ensure frameMeta is properly initialized with defaults
174
+ const defaultMeta = frameDefaultValues.frameMeta || {};
175
+ this.frameMeta = {
176
+ contentScale: defaultMeta.contentScale ?? 1,
177
+ contentOffsetX: defaultMeta.contentOffsetX ?? 0,
178
+ contentOffsetY: defaultMeta.contentOffsetY ?? 0,
179
+ ...options.frameMeta,
180
+ };
181
+
182
+ // Set fixed dimensions
183
+ this.set({
184
+ width: this.frameWidth,
185
+ height: this.frameHeight,
186
+ });
187
+
188
+ // Create clip path based on shape
189
+ this._updateClipPath();
190
+
191
+ // Create placeholder if no content
192
+ if (objects.length === 0) {
193
+ this._createPlaceholder();
194
+ }
195
+
196
+ // Set up custom resize controls (instead of scale controls)
197
+ this._setupResizeControls();
198
+ }
199
+
200
+ /**
201
+ * Sets up custom controls that resize instead of scale
202
+ * This is the key to Canva-like behavior - corners resize the frame dimensions
203
+ * instead of scaling the entire group (which would stretch the image)
204
+ * @private
205
+ */
206
+ private _setupResizeControls(): void {
207
+ // Helper to change width (like changeObjectWidth but for frames)
208
+ // Note: wrapWithFixedAnchor sets origin to opposite corner, so localPoint.x IS the new width
209
+ const changeFrameWidth: TransformActionHandler = (
210
+ eventData,
211
+ transform,
212
+ x,
213
+ y
214
+ ): boolean => {
215
+ const target = transform.target as Frame;
216
+ const localPoint = getLocalPoint(
217
+ transform,
218
+ transform.originX,
219
+ transform.originY,
220
+ x,
221
+ y
222
+ );
223
+
224
+ const oldWidth = target.frameWidth;
225
+ // localPoint.x is distance from anchor (opposite side) to mouse = new width
226
+ const newWidth = Math.max(20, Math.abs(localPoint.x));
227
+
228
+ if (Math.abs(oldWidth - newWidth) < 1) return false;
229
+
230
+ target.frameWidth = newWidth;
231
+ target.width = newWidth;
232
+ target._updateClipPath();
233
+ target._adjustContentAfterResize();
234
+
235
+ return true;
236
+ };
237
+
238
+ // Helper to change height
239
+ const changeFrameHeight: TransformActionHandler = (
240
+ eventData,
241
+ transform,
242
+ x,
243
+ y
244
+ ): boolean => {
245
+ const target = transform.target as Frame;
246
+ const localPoint = getLocalPoint(
247
+ transform,
248
+ transform.originX,
249
+ transform.originY,
250
+ x,
251
+ y
252
+ );
253
+
254
+ const oldHeight = target.frameHeight;
255
+ const newHeight = Math.max(20, Math.abs(localPoint.y));
256
+
257
+ if (Math.abs(oldHeight - newHeight) < 1) return false;
258
+
259
+ target.frameHeight = newHeight;
260
+ target.height = newHeight;
261
+ target._updateClipPath();
262
+ target._adjustContentAfterResize();
263
+
264
+ return true;
265
+ };
266
+
267
+ // Helper to change both width and height (corners)
268
+ const changeFrameSize: TransformActionHandler = (
269
+ eventData,
270
+ transform,
271
+ x,
272
+ y
273
+ ): boolean => {
274
+ const target = transform.target as Frame;
275
+ const localPoint = getLocalPoint(
276
+ transform,
277
+ transform.originX,
278
+ transform.originY,
279
+ x,
280
+ y
281
+ );
282
+
283
+ const oldWidth = target.frameWidth;
284
+ const oldHeight = target.frameHeight;
285
+ const newWidth = Math.max(20, Math.abs(localPoint.x));
286
+ const newHeight = Math.max(20, Math.abs(localPoint.y));
287
+
288
+ if (Math.abs(oldWidth - newWidth) < 1 && Math.abs(oldHeight - newHeight) < 1) return false;
289
+
290
+ target.frameWidth = newWidth;
291
+ target.frameHeight = newHeight;
292
+ target.width = newWidth;
293
+ target.height = newHeight;
294
+ target._updateClipPath();
295
+ target._adjustContentAfterResize();
296
+
297
+ return true;
298
+ };
299
+
300
+ // Create wrapped handlers
301
+ const resizeFromCorner = wrapWithFireEvent(
302
+ RESIZING,
303
+ wrapWithFixedAnchor(changeFrameSize)
304
+ );
305
+
306
+ const resizeX = wrapWithFireEvent(
307
+ RESIZING,
308
+ wrapWithFixedAnchor(changeFrameWidth)
309
+ );
310
+
311
+ const resizeY = wrapWithFireEvent(
312
+ RESIZING,
313
+ wrapWithFixedAnchor(changeFrameHeight)
314
+ );
315
+
316
+ // Guard: ensure controls exist
317
+ if (!this.controls) {
318
+ console.warn('Frame: controls not initialized yet');
319
+ return;
320
+ }
321
+
322
+ // Override corner controls - use resize instead of scale
323
+ const cornerControls = ['tl', 'tr', 'bl', 'br'] as const;
324
+ cornerControls.forEach((corner) => {
325
+ const existing = this.controls[corner];
326
+ if (existing) {
327
+ this.controls[corner] = new Control({
328
+ x: existing.x,
329
+ y: existing.y,
330
+ cursorStyleHandler: existing.cursorStyleHandler,
331
+ actionHandler: resizeFromCorner,
332
+ actionName: 'resizing',
333
+ });
334
+ }
335
+ });
336
+
337
+ // Override side controls for horizontal resize
338
+ const horizontalControls = ['ml', 'mr'] as const;
339
+ horizontalControls.forEach((corner) => {
340
+ const existing = this.controls[corner];
341
+ if (existing) {
342
+ this.controls[corner] = new Control({
343
+ x: existing.x,
344
+ y: existing.y,
345
+ cursorStyleHandler: existing.cursorStyleHandler,
346
+ actionHandler: resizeX,
347
+ actionName: 'resizing',
348
+ render: existing.render, // Keep the global pill renderer
349
+ sizeX: existing.sizeX,
350
+ sizeY: existing.sizeY,
351
+ });
352
+ }
353
+ });
354
+
355
+ // Override side controls for vertical resize
356
+ const verticalControls = ['mt', 'mb'] as const;
357
+ verticalControls.forEach((corner) => {
358
+ const existing = this.controls[corner];
359
+ if (existing) {
360
+ this.controls[corner] = new Control({
361
+ x: existing.x,
362
+ y: existing.y,
363
+ cursorStyleHandler: existing.cursorStyleHandler,
364
+ actionHandler: resizeY,
365
+ actionName: 'resizing',
366
+ render: existing.render, // Keep the global pill renderer
367
+ sizeX: existing.sizeX,
368
+ sizeY: existing.sizeY,
369
+ });
370
+ }
371
+ });
372
+ }
373
+
374
+ /**
375
+ * Adjusts content after a resize operation (called from set override)
376
+ * @private
377
+ */
378
+ private _adjustContentAfterResize(): void {
379
+ // Update placeholder if present (simple rect)
380
+ if (this._placeholder) {
381
+ this._placeholder.set({
382
+ width: this.frameWidth,
383
+ height: this.frameHeight,
384
+ });
385
+ }
386
+
387
+ // Adjust content image (Canva-like behavior)
388
+ if (this._contentImage) {
389
+ const img = this._contentImage;
390
+ const originalWidth = this.frameMeta.originalWidth ?? img.width ?? 100;
391
+ const originalHeight = this.frameMeta.originalHeight ?? img.height ?? 100;
392
+
393
+ // Current image scale and position - preserve user's position
394
+ let currentScale = img.scaleX ?? 1;
395
+ let imgCenterX = img.left ?? 0;
396
+ let imgCenterY = img.top ?? 0;
397
+
398
+ // Check if current scale still covers the frame
399
+ const minScaleForCover = this._calculateCoverScale(originalWidth, originalHeight);
400
+
401
+ if (currentScale < minScaleForCover) {
402
+ // Image is too small to cover frame - scale up proportionally
403
+ // But try to keep the same visual center point
404
+ const scaleRatio = minScaleForCover / currentScale;
405
+
406
+ // Scale position proportionally to maintain visual anchor
407
+ imgCenterX = imgCenterX * scaleRatio;
408
+ imgCenterY = imgCenterY * scaleRatio;
409
+ currentScale = minScaleForCover;
410
+
411
+ img.set({
412
+ scaleX: currentScale,
413
+ scaleY: currentScale,
414
+ });
415
+
416
+ this.frameMeta = {
417
+ ...this.frameMeta,
418
+ contentScale: currentScale,
419
+ };
420
+ }
421
+
422
+ // Now constrain position only if needed to prevent empty space
423
+ const scaledImgHalfW = (originalWidth * currentScale) / 2;
424
+ const scaledImgHalfH = (originalHeight * currentScale) / 2;
425
+ const frameHalfW = this.frameWidth / 2;
426
+ const frameHalfH = this.frameHeight / 2;
427
+
428
+ // Calculate how much the image can move while still covering the frame
429
+ const maxOffsetX = Math.max(0, scaledImgHalfW - frameHalfW);
430
+ const maxOffsetY = Math.max(0, scaledImgHalfH - frameHalfH);
431
+
432
+ // Only constrain if position would show empty space
433
+ const needsConstraintX = Math.abs(imgCenterX) > maxOffsetX;
434
+ const needsConstraintY = Math.abs(imgCenterY) > maxOffsetY;
435
+
436
+ if (needsConstraintX) {
437
+ imgCenterX = Math.max(-maxOffsetX, Math.min(maxOffsetX, imgCenterX));
438
+ }
439
+ if (needsConstraintY) {
440
+ imgCenterY = Math.max(-maxOffsetY, Math.min(maxOffsetY, imgCenterY));
441
+ }
442
+
443
+ if (needsConstraintX || needsConstraintY) {
444
+ img.set({
445
+ left: imgCenterX,
446
+ top: imgCenterY,
447
+ });
448
+
449
+ this.frameMeta = {
450
+ ...this.frameMeta,
451
+ contentOffsetX: imgCenterX,
452
+ contentOffsetY: imgCenterY,
453
+ };
454
+ }
455
+
456
+ img.setCoords();
457
+ }
458
+
459
+ this.setCoords();
460
+ }
461
+
462
+ /**
463
+ * Updates the clip path based on the current frame shape
464
+ * @private
465
+ */
466
+ private _updateClipPath(): void {
467
+ let clipPath: FabricObject;
468
+
469
+ switch (this.frameShape) {
470
+ case 'circle': {
471
+ const radius = Math.min(this.frameWidth, this.frameHeight) / 2;
472
+ clipPath = new Circle({
473
+ radius,
474
+ originX: 'center',
475
+ originY: 'center',
476
+ left: 0,
477
+ top: 0,
478
+ });
479
+ break;
480
+ }
481
+
482
+ case 'rounded-rect': {
483
+ clipPath = new Rect({
484
+ width: this.frameWidth,
485
+ height: this.frameHeight,
486
+ rx: this.frameBorderRadius,
487
+ ry: this.frameBorderRadius,
488
+ originX: 'center',
489
+ originY: 'center',
490
+ left: 0,
491
+ top: 0,
492
+ });
493
+ break;
494
+ }
495
+
496
+ case 'custom': {
497
+ if (this.frameCustomPath) {
498
+ clipPath = new Path(this.frameCustomPath, {
499
+ originX: 'center',
500
+ originY: 'center',
501
+ left: 0,
502
+ top: 0,
503
+ });
504
+ // Scale custom path to fit frame
505
+ const pathBounds = clipPath.getBoundingRect();
506
+ const scaleX = this.frameWidth / pathBounds.width;
507
+ const scaleY = this.frameHeight / pathBounds.height;
508
+ clipPath.set({ scaleX, scaleY });
509
+ } else {
510
+ // Fallback to rect if no custom path
511
+ clipPath = new Rect({
512
+ width: this.frameWidth,
513
+ height: this.frameHeight,
514
+ originX: 'center',
515
+ originY: 'center',
516
+ left: 0,
517
+ top: 0,
518
+ });
519
+ }
520
+ break;
521
+ }
522
+
523
+ case 'rect':
524
+ default: {
525
+ clipPath = new Rect({
526
+ width: this.frameWidth,
527
+ height: this.frameHeight,
528
+ originX: 'center',
529
+ originY: 'center',
530
+ left: 0,
531
+ top: 0,
532
+ });
533
+ break;
534
+ }
535
+ }
536
+
537
+ this.clipPath = clipPath;
538
+ this.set('dirty', true);
539
+ }
540
+
541
+ /**
542
+ * Creates a placeholder element for empty frames
543
+ * Shows a colored rectangle - users can customize via placeholderColor
544
+ * @private
545
+ */
546
+ private _createPlaceholder(): void {
547
+ // Remove existing placeholder if any
548
+ if (this._placeholder) {
549
+ super.remove(this._placeholder);
550
+ this._placeholder = null;
551
+ }
552
+
553
+ // Create placeholder background
554
+ const placeholder = new Rect({
555
+ width: this.frameWidth,
556
+ height: this.frameHeight,
557
+ fill: this.placeholderColor,
558
+ originX: 'center',
559
+ originY: 'center',
560
+ left: 0,
561
+ top: 0,
562
+ selectable: false,
563
+ evented: false,
564
+ });
565
+
566
+ this._placeholder = placeholder;
567
+ super.add(placeholder);
568
+
569
+ // Ensure dimensions remain fixed
570
+ this._restoreFixedDimensions();
571
+ }
572
+
573
+ /**
574
+ * Removes the placeholder element
575
+ * @private
576
+ */
577
+ private _removePlaceholder(): void {
578
+ if (this._placeholder) {
579
+ super.remove(this._placeholder);
580
+ this._placeholder = null;
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Restores the fixed frame dimensions
586
+ * @private
587
+ */
588
+ private _restoreFixedDimensions(): void {
589
+ this.set({
590
+ width: this.frameWidth,
591
+ height: this.frameHeight,
592
+ });
593
+ }
594
+
595
+ /**
596
+ * Sets an image in the frame with cover scaling
597
+ *
598
+ * @param src - Image source URL
599
+ * @param options - Optional loading options
600
+ * @returns Promise that resolves when the image is loaded and set
601
+ *
602
+ * @example
603
+ * ```ts
604
+ * await frame.setImage('https://example.com/photo.jpg');
605
+ * canvas.renderAll();
606
+ * ```
607
+ */
608
+ async setImage(
609
+ src: string,
610
+ options: { crossOrigin?: TCrossOrigin; signal?: AbortSignal } = {}
611
+ ): Promise<void> {
612
+ const { crossOrigin = 'anonymous', signal } = options;
613
+
614
+ // Load the image
615
+ const image = await FabricImage.fromURL(src, { crossOrigin, signal });
616
+
617
+ // Get original dimensions
618
+ const originalWidth = image.width ?? 100;
619
+ const originalHeight = image.height ?? 100;
620
+
621
+ // Calculate cover scale
622
+ const scale = this._calculateCoverScale(originalWidth, originalHeight);
623
+
624
+ // Configure image for frame
625
+ image.set({
626
+ scaleX: scale,
627
+ scaleY: scale,
628
+ originX: 'center',
629
+ originY: 'center',
630
+ left: 0,
631
+ top: 0,
632
+ selectable: false,
633
+ evented: false,
634
+ });
635
+
636
+ // Remove existing content
637
+ this._clearContent();
638
+
639
+ // Add new image
640
+ this._contentImage = image;
641
+ super.add(image);
642
+
643
+ // Force re-center the image after adding (layout might have moved it)
644
+ this._contentImage.set({
645
+ left: 0,
646
+ top: 0,
647
+ });
648
+
649
+ // Update metadata
650
+ this.frameMeta = {
651
+ ...this.frameMeta,
652
+ contentScale: scale,
653
+ contentOffsetX: 0,
654
+ contentOffsetY: 0,
655
+ imageSrc: src,
656
+ originalWidth,
657
+ originalHeight,
658
+ };
659
+
660
+ // Restore dimensions (in case Group recalculated them)
661
+ this._restoreFixedDimensions();
662
+
663
+ // Force recalculation of coordinates
664
+ this.setCoords();
665
+ this._contentImage.setCoords();
666
+
667
+ this.set('dirty', true);
668
+ }
669
+
670
+ /**
671
+ * Sets an image from an existing FabricImage object
672
+ *
673
+ * @param image - FabricImage instance
674
+ */
675
+ setImageObject(image: FabricImage): void {
676
+ const originalWidth = image.width ?? 100;
677
+ const originalHeight = image.height ?? 100;
678
+
679
+ // Calculate cover scale
680
+ const scale = this._calculateCoverScale(originalWidth, originalHeight);
681
+
682
+ // Configure image for frame
683
+ image.set({
684
+ scaleX: scale,
685
+ scaleY: scale,
686
+ originX: 'center',
687
+ originY: 'center',
688
+ left: 0,
689
+ top: 0,
690
+ selectable: false,
691
+ evented: false,
692
+ });
693
+
694
+ // Remove existing content
695
+ this._clearContent();
696
+
697
+ // Add new image
698
+ this._contentImage = image;
699
+ super.add(image);
700
+
701
+ // Update metadata
702
+ this.frameMeta = {
703
+ ...this.frameMeta,
704
+ contentScale: scale,
705
+ contentOffsetX: 0,
706
+ contentOffsetY: 0,
707
+ imageSrc: image.getSrc(),
708
+ originalWidth,
709
+ originalHeight,
710
+ };
711
+
712
+ // Restore dimensions
713
+ this._restoreFixedDimensions();
714
+
715
+ this.set('dirty', true);
716
+ }
717
+
718
+ /**
719
+ * Calculates the cover scale factor for an image
720
+ * Cover scaling ensures the image fills the frame completely
721
+ *
722
+ * @param imageWidth - Original image width
723
+ * @param imageHeight - Original image height
724
+ * @returns Scale factor to apply
725
+ * @private
726
+ */
727
+ private _calculateCoverScale(imageWidth: number, imageHeight: number): number {
728
+ const scaleX = this.frameWidth / imageWidth;
729
+ const scaleY = this.frameHeight / imageHeight;
730
+ return Math.max(scaleX, scaleY);
731
+ }
732
+
733
+ /**
734
+ * Clears all content from the frame
735
+ * @private
736
+ */
737
+ private _clearContent(): void {
738
+ // Remove placeholder
739
+ this._removePlaceholder();
740
+
741
+ // Remove content image
742
+ if (this._contentImage) {
743
+ super.remove(this._contentImage);
744
+ this._contentImage = null;
745
+ }
746
+
747
+ // Clear any other objects
748
+ const objects = this.getObjects();
749
+ objects.forEach((obj) => super.remove(obj));
750
+ }
751
+
752
+ /**
753
+ * Clears the frame content and shows placeholder
754
+ */
755
+ clearContent(): void {
756
+ this._clearContent();
757
+ this._createPlaceholder();
758
+
759
+ // Reset metadata
760
+ this.frameMeta = {
761
+ contentScale: 1,
762
+ contentOffsetX: 0,
763
+ contentOffsetY: 0,
764
+ };
765
+
766
+ this.set('dirty', true);
767
+ }
768
+
769
+ /**
770
+ * Checks if the frame has image content
771
+ */
772
+ hasContent(): boolean {
773
+ return this._contentImage !== null;
774
+ }
775
+
776
+ /**
777
+ * Gets the current content image
778
+ */
779
+ getContentImage(): FabricImage | null {
780
+ return this._contentImage;
781
+ }
782
+
783
+ /**
784
+ * Enters edit mode for repositioning content within the frame
785
+ * In edit mode, the content image can be dragged and scaled
786
+ */
787
+ enterEditMode(): void {
788
+ if (!this._contentImage || this.isEditMode) {
789
+ return;
790
+ }
791
+
792
+ this.isEditMode = true;
793
+
794
+ // Enable sub-target interaction so clicks go through to content
795
+ this.subTargetCheck = true;
796
+ this.interactive = true;
797
+
798
+ // Calculate minimum scale to cover frame
799
+ const originalWidth = this.frameMeta.originalWidth ?? this._contentImage.width ?? 100;
800
+ const originalHeight = this.frameMeta.originalHeight ?? this._contentImage.height ?? 100;
801
+ const minScale = this._calculateCoverScale(originalWidth, originalHeight);
802
+
803
+ // Make content image interactive with scale constraint
804
+ this._contentImage.set({
805
+ selectable: true,
806
+ evented: true,
807
+ hasControls: true,
808
+ hasBorders: true,
809
+ minScaleLimit: minScale,
810
+ lockScalingFlip: true,
811
+ });
812
+
813
+ // Store clip path but keep rendering it for the overlay effect
814
+ if (this.clipPath) {
815
+ this._editModeClipPath = this.clipPath as FabricObject;
816
+ this.clipPath = undefined;
817
+ }
818
+
819
+ // Add constraint handlers for moving/scaling
820
+ this._setupEditModeConstraints();
821
+
822
+ this.set('dirty', true);
823
+
824
+ // Select the content image on the canvas
825
+ if (this.canvas) {
826
+ this.canvas.setActiveObject(this._contentImage);
827
+ this.canvas.renderAll();
828
+ }
829
+
830
+ // Fire custom event
831
+ (this as any).fire('frame:editmode:enter', { target: this });
832
+ }
833
+
834
+ /**
835
+ * Bound constraint handler references for cleanup
836
+ * @private
837
+ */
838
+ private _boundConstrainMove?: (e: any) => void;
839
+ private _boundConstrainScale?: (e: any) => void;
840
+
841
+ /**
842
+ * Sets up constraints for edit mode - prevents gaps
843
+ * @private
844
+ */
845
+ private _setupEditModeConstraints(): void {
846
+ if (!this._contentImage || !this.canvas) return;
847
+
848
+ const frame = this;
849
+ const img = this._contentImage;
850
+
851
+ // Constrain movement to prevent gaps
852
+ this._boundConstrainMove = (e: any) => {
853
+ if (e.target !== img || !frame.isEditMode) return;
854
+
855
+ const originalWidth = frame.frameMeta.originalWidth ?? img.width ?? 100;
856
+ const originalHeight = frame.frameMeta.originalHeight ?? img.height ?? 100;
857
+ const currentScale = img.scaleX ?? 1;
858
+
859
+ const scaledImgHalfW = (originalWidth * currentScale) / 2;
860
+ const scaledImgHalfH = (originalHeight * currentScale) / 2;
861
+ const frameHalfW = frame.frameWidth / 2;
862
+ const frameHalfH = frame.frameHeight / 2;
863
+
864
+ const maxOffsetX = Math.max(0, scaledImgHalfW - frameHalfW);
865
+ const maxOffsetY = Math.max(0, scaledImgHalfH - frameHalfH);
866
+
867
+ let left = img.left ?? 0;
868
+ let top = img.top ?? 0;
869
+
870
+ // Constrain position
871
+ left = Math.max(-maxOffsetX, Math.min(maxOffsetX, left));
872
+ top = Math.max(-maxOffsetY, Math.min(maxOffsetY, top));
873
+
874
+ img.set({ left, top });
875
+ };
876
+
877
+ // Constrain scaling to prevent gaps
878
+ this._boundConstrainScale = (e: any) => {
879
+ if (e.target !== img || !frame.isEditMode) return;
880
+
881
+ const originalWidth = frame.frameMeta.originalWidth ?? img.width ?? 100;
882
+ const originalHeight = frame.frameMeta.originalHeight ?? img.height ?? 100;
883
+ const minScale = frame._calculateCoverScale(originalWidth, originalHeight);
884
+
885
+ let scaleX = img.scaleX ?? 1;
886
+ let scaleY = img.scaleY ?? 1;
887
+
888
+ // Ensure uniform scaling and minimum scale
889
+ const scale = Math.max(minScale, Math.max(scaleX, scaleY));
890
+ img.set({ scaleX: scale, scaleY: scale });
891
+
892
+ // Also constrain position after scale
893
+ frame._boundConstrainMove?.(e);
894
+ };
895
+
896
+ this.canvas.on('object:moving', this._boundConstrainMove);
897
+ this.canvas.on('object:scaling', this._boundConstrainScale);
898
+ }
899
+
900
+ /**
901
+ * Removes edit mode constraint handlers
902
+ * @private
903
+ */
904
+ private _removeEditModeConstraints(): void {
905
+ if (!this.canvas) return;
906
+
907
+ if (this._boundConstrainMove) {
908
+ this.canvas.off('object:moving', this._boundConstrainMove);
909
+ this._boundConstrainMove = undefined;
910
+ }
911
+ if (this._boundConstrainScale) {
912
+ this.canvas.off('object:scaling', this._boundConstrainScale);
913
+ this._boundConstrainScale = undefined;
914
+ }
915
+ }
916
+
917
+ /**
918
+ * Stored clip path before edit mode
919
+ * @private
920
+ */
921
+ private _editModeClipPath?: FabricObject;
922
+
923
+ /**
924
+ * Custom render to show edit mode overlay
925
+ * @override
926
+ */
927
+ render(ctx: CanvasRenderingContext2D): void {
928
+ super.render(ctx);
929
+
930
+ // Draw edit mode overlay if in edit mode
931
+ if (this.isEditMode && this._editModeClipPath) {
932
+ this._renderEditModeOverlay(ctx);
933
+ }
934
+ }
935
+
936
+ /**
937
+ * Renders the edit mode overlay - dims area outside frame, shows frame border
938
+ * @private
939
+ */
940
+ private _renderEditModeOverlay(ctx: CanvasRenderingContext2D): void {
941
+ ctx.save();
942
+
943
+ // Apply the group's transform
944
+ const m = this.calcTransformMatrix();
945
+ ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
946
+
947
+ // Draw semi-transparent overlay on the OUTSIDE of the frame
948
+ // We do this by drawing a large rect and cutting out the frame shape
949
+ ctx.beginPath();
950
+
951
+ // Large outer rectangle (covers the whole image area)
952
+ const padding = 2000; // Large enough to cover any overflow
953
+ ctx.rect(-padding, -padding, padding * 2, padding * 2);
954
+
955
+ // Cut out the frame shape (counter-clockwise to create hole)
956
+ if (this.frameShape === 'circle') {
957
+ const radius = Math.min(this.frameWidth, this.frameHeight) / 2;
958
+ ctx.moveTo(radius, 0);
959
+ ctx.arc(0, 0, radius, 0, Math.PI * 2, true);
960
+ } else if (this.frameShape === 'rounded-rect') {
961
+ const w = this.frameWidth / 2;
962
+ const h = this.frameHeight / 2;
963
+ const r = Math.min(this.frameBorderRadius, w, h);
964
+ ctx.moveTo(w, h - r);
965
+ ctx.arcTo(w, -h, w - r, -h, r);
966
+ ctx.arcTo(-w, -h, -w, -h + r, r);
967
+ ctx.arcTo(-w, h, -w + r, h, r);
968
+ ctx.arcTo(w, h, w, h - r, r);
969
+ ctx.closePath();
970
+ } else {
971
+ // Rectangle
972
+ const w = this.frameWidth / 2;
973
+ const h = this.frameHeight / 2;
974
+ ctx.moveTo(w, -h);
975
+ ctx.lineTo(-w, -h);
976
+ ctx.lineTo(-w, h);
977
+ ctx.lineTo(w, h);
978
+ ctx.closePath();
979
+ }
980
+
981
+ // Fill with semi-transparent dark overlay
982
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
983
+ ctx.fill('evenodd');
984
+
985
+ // Draw frame border
986
+ ctx.beginPath();
987
+ if (this.frameShape === 'circle') {
988
+ const radius = Math.min(this.frameWidth, this.frameHeight) / 2;
989
+ ctx.arc(0, 0, radius, 0, Math.PI * 2);
990
+ } else if (this.frameShape === 'rounded-rect') {
991
+ const w = this.frameWidth / 2;
992
+ const h = this.frameHeight / 2;
993
+ const r = Math.min(this.frameBorderRadius, w, h);
994
+ ctx.moveTo(w - r, -h);
995
+ ctx.arcTo(w, -h, w, -h + r, r);
996
+ ctx.arcTo(w, h, w - r, h, r);
997
+ ctx.arcTo(-w, h, -w, h - r, r);
998
+ ctx.arcTo(-w, -h, -w + r, -h, r);
999
+ ctx.closePath();
1000
+ } else {
1001
+ const w = this.frameWidth / 2;
1002
+ const h = this.frameHeight / 2;
1003
+ ctx.rect(-w, -h, this.frameWidth, this.frameHeight);
1004
+ }
1005
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
1006
+ ctx.lineWidth = 2;
1007
+ ctx.stroke();
1008
+
1009
+ // Draw subtle dashed line for frame boundary
1010
+ ctx.setLineDash([5, 5]);
1011
+ ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)';
1012
+ ctx.lineWidth = 1;
1013
+ ctx.stroke();
1014
+
1015
+ ctx.restore();
1016
+ }
1017
+
1018
+ /**
1019
+ * Exits edit mode and saves the content position
1020
+ */
1021
+ exitEditMode(): void {
1022
+ if (!this._contentImage || !this.isEditMode) {
1023
+ return;
1024
+ }
1025
+
1026
+ this.isEditMode = false;
1027
+
1028
+ // Remove constraint handlers
1029
+ this._removeEditModeConstraints();
1030
+
1031
+ // Disable sub-target interaction
1032
+ this.subTargetCheck = false;
1033
+ this.interactive = false;
1034
+
1035
+ // Get the current position of the content
1036
+ const contentLeft = this._contentImage.left ?? 0;
1037
+ const contentTop = this._contentImage.top ?? 0;
1038
+ const contentScaleX = this._contentImage.scaleX ?? 1;
1039
+ const contentScaleY = this._contentImage.scaleY ?? 1;
1040
+
1041
+ // Constrain position so image always covers the frame
1042
+ const originalWidth = this.frameMeta.originalWidth ?? this._contentImage.width ?? 100;
1043
+ const originalHeight = this.frameMeta.originalHeight ?? this._contentImage.height ?? 100;
1044
+ const currentScale = Math.max(contentScaleX, contentScaleY);
1045
+ const scaledImgHalfW = (originalWidth * currentScale) / 2;
1046
+ const scaledImgHalfH = (originalHeight * currentScale) / 2;
1047
+ const frameHalfW = this.frameWidth / 2;
1048
+ const frameHalfH = this.frameHeight / 2;
1049
+
1050
+ // Ensure image covers frame (constrain position)
1051
+ const maxOffsetX = Math.max(0, scaledImgHalfW - frameHalfW);
1052
+ const maxOffsetY = Math.max(0, scaledImgHalfH - frameHalfH);
1053
+ const constrainedLeft = Math.max(-maxOffsetX, Math.min(maxOffsetX, contentLeft));
1054
+ const constrainedTop = Math.max(-maxOffsetY, Math.min(maxOffsetY, contentTop));
1055
+
1056
+ // Apply constrained position
1057
+ this._contentImage.set({
1058
+ left: constrainedLeft,
1059
+ top: constrainedTop,
1060
+ });
1061
+
1062
+ // Update metadata with new offsets and scale
1063
+ this.frameMeta = {
1064
+ ...this.frameMeta,
1065
+ contentOffsetX: constrainedLeft,
1066
+ contentOffsetY: constrainedTop,
1067
+ contentScale: currentScale,
1068
+ };
1069
+
1070
+ // Make content non-interactive again
1071
+ this._contentImage.set({
1072
+ selectable: false,
1073
+ evented: false,
1074
+ hasControls: false,
1075
+ hasBorders: false,
1076
+ });
1077
+
1078
+ // Restore clip path
1079
+ if (this._editModeClipPath) {
1080
+ this.clipPath = this._editModeClipPath;
1081
+ this._editModeClipPath = undefined;
1082
+ } else {
1083
+ this._updateClipPath();
1084
+ }
1085
+
1086
+ this.set('dirty', true);
1087
+
1088
+ // Re-select the frame itself
1089
+ if (this.canvas) {
1090
+ this.canvas.setActiveObject(this);
1091
+ this.canvas.renderAll();
1092
+ }
1093
+
1094
+ // Fire custom event
1095
+ (this as any).fire('frame:editmode:exit', { target: this });
1096
+ }
1097
+
1098
+ /**
1099
+ * Toggles edit mode
1100
+ */
1101
+ toggleEditMode(): void {
1102
+ if (this.isEditMode) {
1103
+ this.exitEditMode();
1104
+ } else {
1105
+ this.enterEditMode();
1106
+ }
1107
+ }
1108
+
1109
+ /**
1110
+ * Resizes the frame to new dimensions (Canva-like behavior)
1111
+ *
1112
+ * Canva behavior:
1113
+ * - When frame shrinks: crops more of image (no scale change)
1114
+ * - When frame grows: uncrops to show more, preserving position
1115
+ * - Only scales up when image can't cover the frame anymore
1116
+ *
1117
+ * @param width - New frame width
1118
+ * @param height - New frame height
1119
+ * @param options - Resize options
1120
+ */
1121
+ resizeFrame(
1122
+ width: number,
1123
+ height: number,
1124
+ options: { maintainAspect?: boolean } = {}
1125
+ ): void {
1126
+ const { maintainAspect = false } = options;
1127
+
1128
+ if (maintainAspect) {
1129
+ const currentAspect = this.frameWidth / this.frameHeight;
1130
+ const newAspect = width / height;
1131
+
1132
+ if (newAspect > currentAspect) {
1133
+ height = width / currentAspect;
1134
+ } else {
1135
+ width = height * currentAspect;
1136
+ }
1137
+ }
1138
+
1139
+ this.frameWidth = width;
1140
+ this.frameHeight = height;
1141
+
1142
+ // Update dimensions using super.set to avoid re-triggering conversion
1143
+ super.set({
1144
+ width: this.frameWidth,
1145
+ height: this.frameHeight,
1146
+ });
1147
+
1148
+ // Update clip path
1149
+ this._updateClipPath();
1150
+
1151
+ // Canva-like content adjustment
1152
+ this._adjustContentAfterResize();
1153
+
1154
+ this.set('dirty', true);
1155
+ this.setCoords();
1156
+ }
1157
+
1158
+ /**
1159
+ * Sets the frame shape
1160
+ *
1161
+ * @param shape - Shape type
1162
+ * @param customPath - Custom SVG path for 'custom' shape type
1163
+ */
1164
+ setFrameShape(shape: FrameShapeType, customPath?: string): void {
1165
+ this.frameShape = shape;
1166
+ if (customPath) {
1167
+ this.frameCustomPath = customPath;
1168
+ }
1169
+ this._updateClipPath();
1170
+ this.set('dirty', true);
1171
+ }
1172
+
1173
+ /**
1174
+ * Sets the border radius for rounded-rect shape
1175
+ *
1176
+ * @param radius - Border radius in pixels
1177
+ */
1178
+ setBorderRadius(radius: number): void {
1179
+ this.frameBorderRadius = radius;
1180
+ if (this.frameShape === 'rounded-rect') {
1181
+ this._updateClipPath();
1182
+ this.set('dirty', true);
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * Override add to maintain fixed dimensions
1188
+ */
1189
+ add(...objects: FabricObject[]): number {
1190
+ const size = super.add(...objects);
1191
+ this._restoreFixedDimensions();
1192
+ return size;
1193
+ }
1194
+
1195
+ /**
1196
+ * Override remove to maintain fixed dimensions
1197
+ */
1198
+ remove(...objects: FabricObject[]): FabricObject[] {
1199
+ const removed = super.remove(...objects);
1200
+ this._restoreFixedDimensions();
1201
+ return removed;
1202
+ }
1203
+
1204
+ /**
1205
+ * Override insertAt to maintain fixed dimensions
1206
+ */
1207
+ insertAt(index: number, ...objects: FabricObject[]): number {
1208
+ const size = super.insertAt(index, ...objects);
1209
+ this._restoreFixedDimensions();
1210
+ return size;
1211
+ }
1212
+
1213
+ /**
1214
+ * Serializes the frame to a plain object
1215
+ */
1216
+ // @ts-ignore - Frame extends Group's toObject with additional properties
1217
+ toObject(propertiesToInclude: string[] = []): any {
1218
+ return {
1219
+ ...(super.toObject as any)(propertiesToInclude),
1220
+ frameWidth: this.frameWidth,
1221
+ frameHeight: this.frameHeight,
1222
+ frameShape: this.frameShape,
1223
+ frameBorderRadius: this.frameBorderRadius,
1224
+ frameCustomPath: this.frameCustomPath,
1225
+ frameMeta: { ...this.frameMeta },
1226
+ isEditMode: false, // Always serialize as not in edit mode
1227
+ placeholderText: this.placeholderText,
1228
+ placeholderColor: this.placeholderColor,
1229
+ };
1230
+ }
1231
+
1232
+ /**
1233
+ * Creates a Frame instance from a serialized object
1234
+ */
1235
+ static fromObject<T extends TOptions<SerializedFrameProps>>(
1236
+ object: T,
1237
+ abortable?: Abortable
1238
+ ): Promise<Frame> {
1239
+ const {
1240
+ objects = [],
1241
+ layoutManager,
1242
+ frameWidth,
1243
+ frameHeight,
1244
+ frameShape,
1245
+ frameBorderRadius,
1246
+ frameCustomPath,
1247
+ frameMeta,
1248
+ placeholderText,
1249
+ placeholderColor,
1250
+ ...groupOptions
1251
+ } = object;
1252
+
1253
+ return Promise.all([
1254
+ enlivenObjects<FabricObject>(objects, abortable),
1255
+ enlivenObjectEnlivables(groupOptions, abortable),
1256
+ ]).then(([enlivenedObjects, hydratedOptions]) => {
1257
+ // Create frame with restored options
1258
+ const frame = new Frame([], {
1259
+ ...groupOptions,
1260
+ ...hydratedOptions,
1261
+ frameWidth,
1262
+ frameHeight,
1263
+ frameShape,
1264
+ frameBorderRadius,
1265
+ frameCustomPath,
1266
+ frameMeta: frameMeta ? {
1267
+ contentScale: frameMeta.contentScale ?? 1,
1268
+ contentOffsetX: frameMeta.contentOffsetX ?? 0,
1269
+ contentOffsetY: frameMeta.contentOffsetY ?? 0,
1270
+ ...frameMeta,
1271
+ } : undefined,
1272
+ placeholderText,
1273
+ placeholderColor,
1274
+ });
1275
+
1276
+ // If there was an image, restore it
1277
+ if (frameMeta?.imageSrc) {
1278
+ // Async restoration of image - caller should wait if needed
1279
+ frame.setImage(frameMeta.imageSrc).then(() => {
1280
+ // Restore content position from metadata
1281
+ if (frame._contentImage) {
1282
+ frame._contentImage.set({
1283
+ left: frameMeta.contentOffsetX ?? 0,
1284
+ top: frameMeta.contentOffsetY ?? 0,
1285
+ scaleX: frameMeta.contentScale ?? 1,
1286
+ scaleY: frameMeta.contentScale ?? 1,
1287
+ });
1288
+ }
1289
+ frame.set('dirty', true);
1290
+ }).catch((err) => {
1291
+ console.warn('Failed to restore frame image:', err);
1292
+ });
1293
+ }
1294
+
1295
+ return frame;
1296
+ });
1297
+ }
1298
+
1299
+ /**
1300
+ * Creates a Frame with a specific aspect ratio preset
1301
+ *
1302
+ * @param aspect - Aspect ratio preset (e.g., '16:9', '1:1', '4:5', '9:16')
1303
+ * @param size - Base size in pixels
1304
+ * @param options - Additional frame options
1305
+ */
1306
+ static createWithAspect(
1307
+ aspect: string,
1308
+ size: number = 200,
1309
+ options: Partial<FrameProps> = {}
1310
+ ): Frame {
1311
+ let width: number;
1312
+ let height: number;
1313
+
1314
+ switch (aspect) {
1315
+ case '16:9':
1316
+ width = size;
1317
+ height = size * (9 / 16);
1318
+ break;
1319
+ case '9:16':
1320
+ width = size * (9 / 16);
1321
+ height = size;
1322
+ break;
1323
+ case '4:5':
1324
+ width = size * (4 / 5);
1325
+ height = size;
1326
+ break;
1327
+ case '4:3':
1328
+ width = size;
1329
+ height = size * (3 / 4);
1330
+ break;
1331
+ case '3:4':
1332
+ width = size * (3 / 4);
1333
+ height = size;
1334
+ break;
1335
+ case '1:1':
1336
+ default:
1337
+ width = size;
1338
+ height = size;
1339
+ break;
1340
+ }
1341
+
1342
+ const defaultMeta = frameDefaultValues.frameMeta || {};
1343
+
1344
+ return new Frame([], {
1345
+ ...options,
1346
+ frameWidth: width,
1347
+ frameHeight: height,
1348
+ frameMeta: {
1349
+ contentScale: defaultMeta.contentScale ?? 1,
1350
+ contentOffsetX: defaultMeta.contentOffsetX ?? 0,
1351
+ contentOffsetY: defaultMeta.contentOffsetY ?? 0,
1352
+ aspect,
1353
+ ...options.frameMeta,
1354
+ },
1355
+ });
1356
+ }
1357
+ }
1358
+
1359
+ // Register the Frame class with the class registry
1360
+ classRegistry.setClass(Frame);
1361
+ classRegistry.setClass(Frame, 'frame');