@widgetic/canvas 0.5.6 → 0.5.9

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.
@@ -95,7 +95,7 @@
95
95
  const propsToRegister = [
96
96
  '_objectId', '_customType',
97
97
  '_cornerRoundness', '_cornerRoundnessPixels', '_fillOpacity',
98
- '_shapeKey', '_cornerRadiusMode',
98
+ '_shapeKey', '_cornerRadiusMode', '_iconFill',
99
99
  '_boxStroke', '_boxStrokeWidth', '_boxStrokeDashArray',
100
100
  '_boxCornerRadius', '_boxFill',
101
101
  '_textPadding', '_textStrokeColor', '_textStrokeWidth',
@@ -153,6 +153,12 @@
153
153
  // (white fill, dark stroke) on all newly created objects
154
154
  export let wireframeMode: boolean = true;
155
155
 
156
+ // Extra canvas chrome (undo/redo/lock, frame, image, shape library).
157
+ // Default off for the sketch MVP. Same pattern as wireframeMode: keep the code, hide the UI.
158
+ export let advancedFeatures: boolean = false;
159
+ /** When true and advanced is on, Frame/Image/Video stay on the toolbar. Default true. */
160
+ export let mediaBtsOnBar: boolean = true;
161
+
156
162
  // Callback fired once when Fabric canvas is fully initialized and ready
157
163
  export let onReady: (() => void) | null = null;
158
164
 
@@ -278,7 +284,7 @@
278
284
  const GRID_COLOR = 'rgba(0, 0, 0, 0.1)'; // Light gray dots
279
285
 
280
286
  // Tools state
281
- type ToolName = 'select' | 'erase' | 'draw' | 'arrow' | 'text' | 'rect' | 'circle' | 'circle-true' | 'triangle' | 'frame' | 'pan' | 'image' | 'library-shape';
287
+ type ToolName = 'select' | 'erase' | 'draw' | 'arrow' | 'text' | 'rect' | 'circle' | 'circle-true' | 'triangle' | 'frame' | 'pan' | 'image' | 'video' | 'library-shape';
282
288
 
283
289
  // Minimum size for image placeholder to fit UI text
284
290
  const MIN_IMAGE_PLACEHOLDER_WIDTH = 120;
@@ -645,6 +651,30 @@
645
651
  { children: any[]; bounds: { left: number; top: number; width: number; height: number } }
646
652
  >();
647
653
 
654
+ const STROKE_PAINT_SHAPE_KEYS = new Set(['cross', 'plus', 'minus', 'check']);
655
+ function paintsFillOnStroke(obj: any): boolean {
656
+ return !!obj && (obj._cornerRadiusMode === 'strokeRound' || STROKE_PAINT_SHAPE_KEYS.has(obj._shapeKey));
657
+ }
658
+
659
+ let videoRenderRaf = 0;
660
+ let videoPointerDown: { x: number; y: number; target: ImageShape; scene: { x: number; y: number } } | null = null;
661
+
662
+ function startVideoRenderLoop() {
663
+ if (videoRenderRaf) return;
664
+ const tick = () => {
665
+ videoRenderRaf = 0;
666
+ if (!canvas) return;
667
+ const playing = canvas.getObjects().some((obj: any) => {
668
+ const video = obj?._videoElement as HTMLVideoElement | undefined;
669
+ return obj?._customType === 'video' && video && !video.paused && !video.ended;
670
+ });
671
+ if (!playing) return;
672
+ canvas.requestRenderAll();
673
+ videoRenderRaf = requestAnimationFrame(tick);
674
+ };
675
+ videoRenderRaf = requestAnimationFrame(tick);
676
+ }
677
+
648
678
  /**
649
679
  * HELPER FUNCTIONS
650
680
  */
@@ -1149,6 +1179,37 @@
1149
1179
  };
1150
1180
  }
1151
1181
 
1182
+ /** Screen-space box for any canvas object id (convert drafts use the grouped sketch). */
1183
+ export function getObjectScreenRect(objectId: string): {
1184
+ left: number;
1185
+ top: number;
1186
+ width: number;
1187
+ height: number;
1188
+ viewportLeft: number;
1189
+ viewportTop: number;
1190
+ viewportWidth: number;
1191
+ viewportHeight: number;
1192
+ } | null {
1193
+ if (!canvas || !canvasEl || !objectId) return null;
1194
+ const obj = canvas.getObjects().find((o: any) => o._objectId === objectId);
1195
+ if (!obj) {
1196
+ canvasLog('Canvas: getObjectScreenRect — no object for', objectId);
1197
+ return null;
1198
+ }
1199
+ const bbox = obj.getBoundingRect();
1200
+ const canvasRect = canvasEl.getBoundingClientRect();
1201
+ return {
1202
+ left: canvasRect.left + bbox.left,
1203
+ top: canvasRect.top + bbox.top,
1204
+ width: bbox.width,
1205
+ height: bbox.height,
1206
+ viewportLeft: canvasRect.left,
1207
+ viewportTop: canvasRect.top,
1208
+ viewportWidth: canvasRect.width,
1209
+ viewportHeight: canvasRect.height,
1210
+ };
1211
+ }
1212
+
1152
1213
  /**
1153
1214
  * Get the current image data URL of a widget shape on the canvas.
1154
1215
  * Returns the base64 data URL or null if the shape is not found or has no image.
@@ -1406,12 +1467,12 @@
1406
1467
  function getPanZoomPositionClass(position: string): string {
1407
1468
  canvasLog('getPanZoomPositionClass: ', position);
1408
1469
  const positions: Record<string, string> = {
1409
- 'TL': 'top-4 left-4',
1410
- 'TC': 'top-4 left-1/2 -translate-x-1/2',
1411
- 'TR': 'top-4 right-4',
1412
- 'BL': 'bottom-20 left-4',
1413
- 'BC': 'bottom-20 left-1/2 -translate-x-1/2',
1414
- 'BR': 'bottom-20 right-4'
1470
+ 'TL': 'absolute z-10 top-4 left-4',
1471
+ 'TC': 'absolute z-10 top-4 left-1/2 -translate-x-1/2',
1472
+ 'TR': 'absolute z-10 top-4 right-4',
1473
+ 'BL': 'absolute z-10 bottom-20 left-4',
1474
+ 'BC': 'absolute z-10 bottom-20 left-1/2 -translate-x-1/2',
1475
+ 'BR': 'absolute z-10 bottom-20 right-4'
1415
1476
  };
1416
1477
  return positions[position] || positions['TL'];
1417
1478
  }
@@ -1447,6 +1508,10 @@
1447
1508
  // Let Fabric handle Shift+corner = proportional resize natively.
1448
1509
  // For _lockAspectRatio shapes, before:transform sets uniformScaling=true.
1449
1510
  (canvas as any).uniScaleKey = 'shiftKey';
1511
+ // Free corner resize (not from center). Keep Alt off Fabric's centeredKey —
1512
+ // Option is used for frame children, and a stuck altKey used to explode scales.
1513
+ canvas.centeredScaling = false;
1514
+ (canvas as any).centeredKey = null;
1450
1515
 
1451
1516
  // Set 'move' cursor as default hover cursor on ALL objects.
1452
1517
  // Set on both canvas level and prototype level for full coverage.
@@ -2056,8 +2121,10 @@
2056
2121
  let altKeyCurrentlyHeld = false;
2057
2122
  const trackAltKeyDown = (e: KeyboardEvent) => { if (e.altKey) altKeyCurrentlyHeld = true; };
2058
2123
  const trackAltKeyUp = (e: KeyboardEvent) => { if (!e.altKey) altKeyCurrentlyHeld = false; };
2124
+ const trackAltKeyBlur = () => { altKeyCurrentlyHeld = false; };
2059
2125
  window.addEventListener('keydown', trackAltKeyDown);
2060
2126
  window.addEventListener('keyup', trackAltKeyUp);
2127
+ window.addEventListener('blur', trackAltKeyBlur);
2061
2128
 
2062
2129
  // ── Custom right-click context menu ───────────────────────────────────
2063
2130
  // Prevent the browser default context menu on the canvas and show ours.
@@ -2081,12 +2148,17 @@
2081
2148
  const transform = opt.transform;
2082
2149
  if (!transform) return;
2083
2150
 
2084
- // Disable Alt/Option-triggered centered scaling for Frame objects.
2085
- // Alt is used for "preserve children" resize on frames, not center-scale.
2086
- // The centeredKey check happens BEFORE before:transform, so we can't prevent it.
2087
- // Instead, override the transform origin from 'center' back to the correct corner.
2088
- if (isFrameShape(transform.target) && transform.originX === 'center' && transform.originY === 'center') {
2089
- // Map corner handle to the correct opposite origin
2151
+ // Only remap when Fabric forced origin=center on an object that is NOT
2152
+ // natively center-origin (library icons / groups already use origin center).
2153
+ // Remapping those made corner resize look like Alt-deflate.
2154
+ const targetObj = transform.target as any;
2155
+ const nativeCenterOrigin =
2156
+ targetObj?.originX === 'center' && targetObj?.originY === 'center';
2157
+ if (
2158
+ !nativeCenterOrigin &&
2159
+ transform.originX === 'center' &&
2160
+ transform.originY === 'center'
2161
+ ) {
2090
2162
  const cornerOriginMap: Record<string, { x: string; y: string }> = {
2091
2163
  tl: { x: 'right', y: 'bottom' },
2092
2164
  tr: { x: 'left', y: 'bottom' },
@@ -2102,7 +2174,7 @@
2102
2174
  if (origin) {
2103
2175
  transform.originX = origin.x;
2104
2176
  transform.originY = origin.y;
2105
- canvasLog('Frame: overrode centered scaling origin for corner', corner, '→', origin);
2177
+ canvasLog('Canvas: overrode accidental centered origin for corner', corner, '→', origin);
2106
2178
  }
2107
2179
  }
2108
2180
 
@@ -2131,13 +2203,19 @@
2131
2203
  // Store the original corners BEFORE any transformation
2132
2204
  // This ensures we have accurate positions to work with
2133
2205
  const target = scalingTarget as any;
2134
- const w = target.width * target.scaleX;
2135
- const h = target.height * target.scaleY;
2206
+ const w = (target.width ?? 0) * (target.scaleX ?? 1);
2207
+ const h = (target.height ?? 0) * (target.scaleY ?? 1);
2208
+ const originX = target.originX ?? 'left';
2209
+ const originY = target.originY ?? 'top';
2210
+ const left = target.left ?? 0;
2211
+ const top = target.top ?? 0;
2212
+ const tlX = originX === 'center' ? left - w / 2 : originX === 'right' ? left - w : left;
2213
+ const tlY = originY === 'center' ? top - h / 2 : originY === 'bottom' ? top - h : top;
2136
2214
  target._originalCorners = {
2137
- tl: { x: target.left, y: target.top },
2138
- tr: { x: target.left + w, y: target.top },
2139
- bl: { x: target.left, y: target.top + h },
2140
- br: { x: target.left + w, y: target.top + h }
2215
+ tl: { x: tlX, y: tlY },
2216
+ tr: { x: tlX + w, y: tlY },
2217
+ bl: { x: tlX, y: tlY + h },
2218
+ br: { x: tlX + w, y: tlY + h }
2141
2219
  };
2142
2220
 
2143
2221
  // For frames: store original children metrics for Alt/Option key resize.
@@ -2369,6 +2447,20 @@
2369
2447
  (obj as any)._lockAspectRatio = true;
2370
2448
  (obj as any)._forceLockAspectRatio = true;
2371
2449
  }
2450
+ if (shapeDef.cornerRadiusMode === 'strokeRound') {
2451
+ const bg = (obj as any).backgroundColor;
2452
+ if (!bg || bg === 'transparent') {
2453
+ const fromFill = typeof obj.fill === 'string' && obj.fill && obj.fill !== 'transparent' ? obj.fill : '#ffffff';
2454
+ obj.set({ fill: '', backgroundColor: fromFill });
2455
+ (obj as any)._iconFill = fromFill;
2456
+ } else if (obj.fill && obj.fill !== '') {
2457
+ obj.set({ fill: '' });
2458
+ }
2459
+ if (!obj.stroke) {
2460
+ obj.set({ stroke: '#374151', strokeWidth: obj.strokeWidth || 3, strokeUniform: true });
2461
+ }
2462
+ obj.dirty = true;
2463
+ }
2372
2464
  }
2373
2465
  }
2374
2466
 
@@ -2809,7 +2901,12 @@
2809
2901
  // Override PencilBrush round-cap defaults so the Corner Roundness slider
2810
2902
  // has a visible effect. At 0% the drawing appears sharp (square caps, miter
2811
2903
  // joins); moving the slider progressively rounds caps and joins.
2812
- path.set({ strokeLineCap: 'square', strokeLineJoin: 'miter' });
2904
+ path.set({
2905
+ strokeLineCap: 'square',
2906
+ strokeLineJoin: 'miter',
2907
+ fill: 'transparent',
2908
+ _fillOpacity: 0,
2909
+ });
2813
2910
  checkAndAddNewShapeToFrame(path);
2814
2911
  takeHistorySnapshot();
2815
2912
  });
@@ -3073,6 +3170,10 @@
3073
3170
  isAltHeld = false;
3074
3171
  }
3075
3172
  };
3173
+ const handleGlobalAltBlur = () => {
3174
+ isAltHeld = false;
3175
+ };
3176
+ window.addEventListener('blur', handleGlobalAltBlur);
3076
3177
 
3077
3178
  // Shared helper: delete whatever is currently active on the canvas.
3078
3179
  // Used by both the Delete key handler and the Cut (Ctrl+X) handler.
@@ -3416,7 +3517,7 @@
3416
3517
  // Fill opacity (percentage stored separately from the baked rgba fill value)
3417
3518
  '_fillOpacity',
3418
3519
  // Library shape metadata
3419
- '_shapeKey', '_cornerRadiusMode',
3520
+ '_shapeKey', '_cornerRadiusMode', '_iconFill',
3420
3521
  // Textbox box stroke and corner properties
3421
3522
  '_boxStroke', '_boxStrokeWidth', '_boxStrokeDashArray',
3422
3523
  '_boxCornerRadius', '_boxFill',
@@ -4369,6 +4470,8 @@
4369
4470
  (canvas as any)._globalShiftKeyupHandler = handleGlobalShiftKeyup;
4370
4471
  (canvas as any)._globalAltKeydownHandler = handleGlobalAltKeydown;
4371
4472
  (canvas as any)._globalAltKeyupHandler = handleGlobalAltKeyup;
4473
+ (canvas as any)._globalAltBlurHandler = handleGlobalAltBlur;
4474
+ (canvas as any)._altTrackBlur = trackAltKeyBlur;
4372
4475
  (canvas as any)._deleteKeyHandler = handleDeleteKeydown;
4373
4476
  (canvas as any)._arrowKeyHandler = handleArrowKeydown;
4374
4477
  (canvas as any)._selectAllHandler = handleSelectAllKeydown;
@@ -4583,9 +4686,8 @@
4583
4686
  exitGroupEdit(false);
4584
4687
  }
4585
4688
  canvasLog('Canvas: Selection cleared');
4586
- if (onNonWidgetSelected) {
4587
- onNonWidgetSelected();
4588
- }
4689
+ // Do not close Widget Details here — Fabric also clears selection when
4690
+ // switching from widget A to widget B, which reopened the previous panel.
4589
4691
  };
4590
4692
 
4591
4693
  canvas.on('selection:created', handleSelectionCreated);
@@ -4646,9 +4748,47 @@
4646
4748
  openFilePickerForPlaceholder(target);
4647
4749
  return;
4648
4750
  }
4751
+ if ((target as any)._customType === 'video') {
4752
+ if ((target as ImageShape)._videoElement) {
4753
+ void (target as ImageShape).toggleVideoPlayback().then((ok) => {
4754
+ if (ok) startVideoRenderLoop();
4755
+ });
4756
+ } else {
4757
+ openFilePickerForPlaceholder(target);
4758
+ }
4759
+ return;
4760
+ }
4649
4761
  };
4650
4762
 
4651
4763
  canvas.on('mouse:dblclick', handleDoubleClick);
4764
+
4765
+ canvas.on('mouse:down', (opt: any) => {
4766
+ const target = opt?.target as ImageShape | undefined;
4767
+ if (target?._customType === 'video' && target._videoElement && canvas) {
4768
+ videoPointerDown = {
4769
+ x: opt.e?.clientX ?? 0,
4770
+ y: opt.e?.clientY ?? 0,
4771
+ target,
4772
+ scene: canvas.getPointer(opt.e),
4773
+ };
4774
+ } else {
4775
+ videoPointerDown = null;
4776
+ }
4777
+ });
4778
+ canvas.on('mouse:up', (opt: any) => {
4779
+ if (!videoPointerDown || !opt?.e) {
4780
+ videoPointerDown = null;
4781
+ return;
4782
+ }
4783
+ const dx = Math.abs((opt.e.clientX ?? 0) - videoPointerDown.x);
4784
+ const dy = Math.abs((opt.e.clientY ?? 0) - videoPointerDown.y);
4785
+ if (dx < 5 && dy < 5 && videoPointerDown.target.isVideoPlayBadgeHit(videoPointerDown.scene)) {
4786
+ void videoPointerDown.target.toggleVideoPlayback().then((ok) => {
4787
+ if (ok) startVideoRenderLoop();
4788
+ });
4789
+ }
4790
+ videoPointerDown = null;
4791
+ });
4652
4792
 
4653
4793
  // ─── Frame label drag & cursor handlers ───
4654
4794
  // Handles hover cursor and click-drag on frame labels (including nested frames).
@@ -5190,8 +5330,43 @@
5190
5330
 
5191
5331
  // Process each dropped file
5192
5332
  Array.from(files).forEach((file) => {
5193
- if (!file.type.startsWith('image/')) {
5194
- canvasLog('Skipped non-image file:', file.name);
5333
+ const isVideo = file.type.startsWith('video/');
5334
+ if (!file.type.startsWith('image/') && !isVideo) {
5335
+ canvasLog('Skipped non-media file:', file.name);
5336
+ return;
5337
+ }
5338
+
5339
+ if (isVideo) {
5340
+ const { video, objectUrl } = createVideoElementFromFile(file);
5341
+ capturePosterFromVideo(video).then((posterUrl) => {
5342
+ const maxWidth = canvas.width * 0.6;
5343
+ const maxHeight = canvas.height * 0.6;
5344
+ const tempImg = new Image();
5345
+ tempImg.onload = () => {
5346
+ let width = tempImg.width;
5347
+ let height = tempImg.height;
5348
+ if (width > maxWidth || height > maxHeight) {
5349
+ const scale = Math.min(maxWidth / width, maxHeight / height);
5350
+ width *= scale;
5351
+ height *= scale;
5352
+ }
5353
+ const videoShape = createImagePlaceholder({
5354
+ left: dropX - width / 2,
5355
+ top: dropY - height / 2,
5356
+ width,
5357
+ height,
5358
+ mediaKind: 'video'
5359
+ });
5360
+ canvas.add(videoShape);
5361
+ canvas.setActiveObject(videoShape);
5362
+ attachVideoElementToShape(videoShape, video, objectUrl);
5363
+ loadImageIntoShape(videoShape, posterUrl, file.name);
5364
+ canvasLog('Video dropped on canvas:', file.name);
5365
+ };
5366
+ tempImg.src = posterUrl;
5367
+ }).catch((err) => {
5368
+ console.error('Failed to load dropped video:', err);
5369
+ });
5195
5370
  return;
5196
5371
  }
5197
5372
 
@@ -5383,6 +5558,10 @@
5383
5558
  });
5384
5559
 
5385
5560
  onDestroy(() => {
5561
+ if (videoRenderRaf) {
5562
+ cancelAnimationFrame(videoRenderRaf);
5563
+ videoRenderRaf = 0;
5564
+ }
5386
5565
  // CRITICAL: Notify the host to save canvas data BEFORE any cleanup.
5387
5566
  // This fires when Vite HMR rebuilds this component or the page navigates away.
5388
5567
  // At this point canvas is still valid — canvas.dispose() hasn't run yet.
@@ -5461,6 +5640,12 @@
5461
5640
  if (canvas && (canvas as any)._altTrackUp) {
5462
5641
  window.removeEventListener('keyup', (canvas as any)._altTrackUp);
5463
5642
  }
5643
+ if (canvas && (canvas as any)._altTrackBlur) {
5644
+ window.removeEventListener('blur', (canvas as any)._altTrackBlur);
5645
+ }
5646
+ if (canvas && (canvas as any)._globalAltBlurHandler) {
5647
+ window.removeEventListener('blur', (canvas as any)._globalAltBlurHandler);
5648
+ }
5464
5649
  if (canvas && (canvas as any)._contextMenuHandler && (canvas as any)._contextMenuTarget) {
5465
5650
  (canvas as any)._contextMenuTarget.removeEventListener('contextmenu', (canvas as any)._contextMenuHandler);
5466
5651
  }
@@ -6396,6 +6581,14 @@ function checkAndAddNewShapeToFrame(shape: any) {
6396
6581
  (shape as any)._shapeKey = shapeKey;
6397
6582
  (shape as any)._cornerRadiusMode = cornerRadiusMode;
6398
6583
  (shape as any)._cornerRoundness = 0;
6584
+ if (cornerRadiusMode === 'strokeRound') {
6585
+ const existingBg = (shape as any).backgroundColor;
6586
+ shape.set({
6587
+ fill: '',
6588
+ backgroundColor: existingBg || '#ffffff',
6589
+ });
6590
+ (shape as any)._iconFill = '#ffffff';
6591
+ }
6399
6592
  if (lockAspectRatio) {
6400
6593
  (shape as any)._lockAspectRatio = true;
6401
6594
  (shape as any)._forceLockAspectRatio = true;
@@ -6447,7 +6640,8 @@ function checkAndAddNewShapeToFrame(shape: any) {
6447
6640
  top: pointer.y,
6448
6641
  originX: 'left',
6449
6642
  originY: 'top',
6450
- fill: '#ffffff',
6643
+ fill: getLibraryShapeMeta(pendingLibraryShapeKey).cornerRadiusMode === 'strokeRound' ? '' : '#ffffff',
6644
+ backgroundColor: getLibraryShapeMeta(pendingLibraryShapeKey).cornerRadiusMode === 'strokeRound' ? '#ffffff' : undefined,
6451
6645
  _fillOpacity: 1,
6452
6646
  stroke: '#374151',
6453
6647
  strokeWidth: 3,
@@ -7306,6 +7500,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
7306
7500
  top: number;
7307
7501
  width: number;
7308
7502
  height: number;
7503
+ mediaKind?: 'image' | 'video';
7309
7504
  }) {
7310
7505
  // Use ImageShape class for image placeholders. When converted to a widget,
7311
7506
  // the prototype is swapped to WidgetShape via upgradeToWidgetShape().
@@ -7328,7 +7523,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
7328
7523
  minScaleLimit: 0.01
7329
7524
  }) as any;
7330
7525
 
7331
- imageShape._customType = 'image';
7526
+ imageShape._customType = options.mediaKind === 'video' ? 'video' : 'image';
7332
7527
  imageShape._hasImage = false;
7333
7528
  imageShape._imageElement = null;
7334
7529
  imageShape._originalFilename = null;
@@ -7550,7 +7745,16 @@ function checkAndAddNewShapeToFrame(shape: any) {
7550
7745
  * Image Tool - Draw ImagePlaceholder on canvas
7551
7746
  */
7552
7747
  export function onSelectImageTool() {
7553
- setActiveTool('image', () => {
7748
+ startMediaPlaceholderTool('image');
7749
+ }
7750
+
7751
+ /** Video tool — same draw-to-place flow as Image, with a video placeholder and upload filter. */
7752
+ export function onSelectVideoTool() {
7753
+ startMediaPlaceholderTool('video');
7754
+ }
7755
+
7756
+ function startMediaPlaceholderTool(kind: 'image' | 'video') {
7757
+ setActiveTool(kind, () => {
7554
7758
  if (!canvas) {
7555
7759
  return;
7556
7760
  }
@@ -7651,7 +7855,8 @@ function checkAndAddNewShapeToFrame(shape: any) {
7651
7855
  left: finalBounds.left,
7652
7856
  top: finalBounds.top,
7653
7857
  width: finalBounds.width,
7654
- height: finalBounds.height
7858
+ height: finalBounds.height,
7859
+ mediaKind: kind
7655
7860
  });
7656
7861
 
7657
7862
  canvas.add(placeholder);
@@ -7687,20 +7892,40 @@ function checkAndAddNewShapeToFrame(shape: any) {
7687
7892
  */
7688
7893
  function openFilePickerForPlaceholder(placeholder: any) {
7689
7894
  if (!canvas) return;
7895
+ const isVideo = placeholder._customType === 'video';
7690
7896
 
7691
7897
  const fileInput = document.createElement('input');
7692
7898
  fileInput.type = 'file';
7693
- fileInput.accept = 'image/*';
7899
+ fileInput.accept = isVideo ? 'video/*' : 'image/*';
7694
7900
  fileInput.style.display = 'none';
7695
7901
 
7696
7902
  fileInput.onchange = (event: Event) => {
7697
7903
  const target = event.target as HTMLInputElement;
7698
7904
  const file = target.files?.[0];
7699
7905
 
7700
- if (!file || !file.type.startsWith('image/')) {
7906
+ if (!file) {
7701
7907
  document.body.removeChild(fileInput);
7702
7908
  return;
7703
7909
  }
7910
+ if (isVideo && !file.type.startsWith('video/')) {
7911
+ document.body.removeChild(fileInput);
7912
+ return;
7913
+ }
7914
+ if (!isVideo && !file.type.startsWith('image/')) {
7915
+ document.body.removeChild(fileInput);
7916
+ return;
7917
+ }
7918
+
7919
+ if (isVideo) {
7920
+ bindVideoFileToShape(placeholder, file).then((posterUrl) => {
7921
+ replacePlaceholderWithImage(placeholder, posterUrl, file.name);
7922
+ }).catch((err) => {
7923
+ console.error('Video poster capture failed', err);
7924
+ }).finally(() => {
7925
+ if (fileInput.parentNode) document.body.removeChild(fileInput);
7926
+ });
7927
+ return;
7928
+ }
7704
7929
 
7705
7930
  const reader = new FileReader();
7706
7931
  reader.onload = (e) => {
@@ -7717,6 +7942,59 @@ function checkAndAddNewShapeToFrame(shape: any) {
7717
7942
  fileInput.click();
7718
7943
  }
7719
7944
 
7945
+ function createVideoElementFromFile(file: File): { video: HTMLVideoElement; objectUrl: string } {
7946
+ const objectUrl = URL.createObjectURL(file);
7947
+ const video = document.createElement('video');
7948
+ video.muted = true;
7949
+ video.loop = true;
7950
+ video.playsInline = true;
7951
+ video.preload = 'auto';
7952
+ video.src = objectUrl;
7953
+ return { video, objectUrl };
7954
+ }
7955
+
7956
+ function capturePosterFromVideo(video: HTMLVideoElement): Promise<string> {
7957
+ return new Promise((resolve, reject) => {
7958
+ const onSeeked = () => {
7959
+ try {
7960
+ const poster = document.createElement('canvas');
7961
+ poster.width = Math.max(1, video.videoWidth || 640);
7962
+ poster.height = Math.max(1, video.videoHeight || 360);
7963
+ poster.getContext('2d')?.drawImage(video, 0, 0, poster.width, poster.height);
7964
+ resolve(poster.toDataURL('image/jpeg', 0.85));
7965
+ } catch (err) {
7966
+ reject(err);
7967
+ }
7968
+ };
7969
+ video.onloadeddata = () => {
7970
+ try {
7971
+ video.currentTime = Math.min(0.15, Number.isFinite(video.duration) ? video.duration * 0.1 : 0.15);
7972
+ } catch (err) {
7973
+ reject(err);
7974
+ }
7975
+ };
7976
+ video.onseeked = onSeeked;
7977
+ video.onerror = () => reject(new Error('Could not load video'));
7978
+ });
7979
+ }
7980
+
7981
+ function attachVideoElementToShape(shape: any, video: HTMLVideoElement, objectUrl: string) {
7982
+ if (shape._videoObjectUrl && shape._videoObjectUrl !== objectUrl) {
7983
+ URL.revokeObjectURL(shape._videoObjectUrl);
7984
+ }
7985
+ shape._videoElement = video;
7986
+ shape._videoObjectUrl = objectUrl;
7987
+ video.addEventListener('play', () => startVideoRenderLoop());
7988
+ video.addEventListener('pause', () => canvas?.requestRenderAll());
7989
+ video.addEventListener('ended', () => canvas?.requestRenderAll());
7990
+ }
7991
+
7992
+ function bindVideoFileToShape(shape: any, file: File): Promise<string> {
7993
+ const { video, objectUrl } = createVideoElementFromFile(file);
7994
+ attachVideoElementToShape(shape, video, objectUrl);
7995
+ return capturePosterFromVideo(video);
7996
+ }
7997
+
7720
7998
  /**
7721
7999
  * Load image from URL into selected ImagePlaceholder (from PropsPanel)
7722
8000
  */
@@ -7737,20 +8015,32 @@ function checkAndAddNewShapeToFrame(shape: any) {
7737
8015
  // Works for both ImagePlaceholder and loaded Image
7738
8016
  const currentObject = selectedObject;
7739
8017
 
8018
+ const isVideo = (currentObject as any)._customType === 'video';
7740
8019
  const fileInput = document.createElement('input');
7741
8020
  fileInput.type = 'file';
7742
- fileInput.accept = 'image/*';
8021
+ fileInput.accept = isVideo ? 'video/*' : 'image/*';
7743
8022
  fileInput.style.display = 'none';
7744
8023
 
7745
8024
  fileInput.onchange = (event: Event) => {
7746
8025
  const target = event.target as HTMLInputElement;
7747
8026
  const file = target.files?.[0];
7748
8027
 
7749
- if (!file || !file.type.startsWith('image/')) {
8028
+ if (!file || (isVideo ? !file.type.startsWith('video/') : !file.type.startsWith('image/'))) {
7750
8029
  document.body.removeChild(fileInput);
7751
8030
  return;
7752
8031
  }
7753
-
8032
+
8033
+ if (isVideo) {
8034
+ bindVideoFileToShape(currentObject, file).then((posterUrl) => {
8035
+ replaceImageWithNewSource(currentObject, posterUrl, file.name);
8036
+ }).catch((err) => {
8037
+ console.error('Video poster capture failed', err);
8038
+ }).finally(() => {
8039
+ if (fileInput.parentNode) document.body.removeChild(fileInput);
8040
+ });
8041
+ return;
8042
+ }
8043
+
7754
8044
  const reader = new FileReader();
7755
8045
  reader.onload = (e) => {
7756
8046
  const dataUrl = e.target?.result as string;
@@ -8276,7 +8566,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
8276
8566
  // Fill opacity (percentage stored separately from the baked rgba fill value)
8277
8567
  '_fillOpacity',
8278
8568
  // Library shape metadata
8279
- '_shapeKey', '_cornerRadiusMode',
8569
+ '_shapeKey', '_cornerRadiusMode', '_iconFill',
8280
8570
  '_boxStroke', '_boxStrokeWidth', '_boxStrokeDashArray',
8281
8571
  '_boxCornerRadius', '_boxFill',
8282
8572
  '_textPadding',
@@ -8940,13 +9230,13 @@ function checkAndAddNewShapeToFrame(shape: any) {
8940
9230
  // Helper to apply fill color while preserving fill opacity
8941
9231
  const applyFillColor = (obj: any, clr: string) => {
8942
9232
  const fillOpacity = obj._fillOpacity ?? 1;
8943
- // If we have fill opacity < 1, convert to rgba
8944
- if (fillOpacity < 1) {
8945
- const rgbaColor = colorToRgba(clr, fillOpacity);
8946
- obj.set({ fill: rgbaColor });
8947
- } else {
8948
- obj.set({ fill: clr });
9233
+ const painted = fillOpacity < 1 ? colorToRgba(clr, fillOpacity) : clr;
9234
+ if (paintsFillOnStroke(obj)) {
9235
+ obj._iconFill = clr;
9236
+ obj.set({ fill: '', backgroundColor: painted });
9237
+ return;
8949
9238
  }
9239
+ obj.set({ fill: painted });
8950
9240
  };
8951
9241
 
8952
9242
  // If it's a Frame, update the background rect specifically (preserving fill opacity)
@@ -8982,8 +9272,12 @@ function checkAndAddNewShapeToFrame(shape: any) {
8982
9272
  // Skip ImagePlaceholder — its fill background is hidden once an image is
8983
9273
  // loaded; only stroke properties are meaningful for this type.
8984
9274
  } else if ('fill' in obj) {
8985
- // For shapes: apply color at full opacity for uniformity
8986
- obj.set({ fill: color });
9275
+ if (paintsFillOnStroke(obj)) {
9276
+ obj._iconFill = color;
9277
+ obj.set({ fill: '', backgroundColor: color });
9278
+ } else {
9279
+ obj.set({ fill: color });
9280
+ }
8987
9281
  obj._fillOpacity = 1;
8988
9282
  }
8989
9283
  });
@@ -9146,6 +9440,11 @@ function checkAndAddNewShapeToFrame(shape: any) {
9146
9440
  // Helper to apply fill opacity to a single shape object
9147
9441
  const applyFillOpacity = (obj: any) => {
9148
9442
  obj._fillOpacity = alpha;
9443
+ if (paintsFillOnStroke(obj)) {
9444
+ const base = obj._iconFill || obj.backgroundColor || '#FFFFFF';
9445
+ obj.set({ fill: '', backgroundColor: colorToRgba(base, alpha) });
9446
+ return;
9447
+ }
9149
9448
  const currentFill = obj.fill || '#FFFFFF';
9150
9449
  const rgbaFill = colorToRgba(currentFill, alpha);
9151
9450
  obj.set({ fill: rgbaFill });
@@ -11080,11 +11379,12 @@ function checkAndAddNewShapeToFrame(shape: any) {
11080
11379
 
11081
11380
  <!-- Pan/Zoom Panel: Position configurable via panZoomPosition prop -->
11082
11381
  {#if !isCanvasLoading}
11083
- <PanZoomPanel
11084
- class="absolute {getPanZoomPositionClass(panZoomPosition)} z-10"
11382
+ <div class="canvas-pan-zoom-overlay {getPanZoomPositionClass(panZoomPosition)}">
11383
+ <PanZoomPanel
11085
11384
  {zoomLevel}
11086
11385
  isPanActive={activeTool === 'pan'}
11087
11386
  {showGrid}
11387
+ {advancedFeatures}
11088
11388
  onPanToggle={() => activeTool === 'pan' ? onSelectSelectTool() : onSelectPanTool()}
11089
11389
  onZoomIn={zoomIn}
11090
11390
  onZoomOut={zoomOut}
@@ -11098,6 +11398,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
11098
11398
  hasUnsavedChanges={_hasUnsavedChanges}
11099
11399
  onSave={saveNow}
11100
11400
  />
11401
+ </div>
11101
11402
  {/if}
11102
11403
 
11103
11404
  <!-- Canvas -->
@@ -11131,6 +11432,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
11131
11432
  {onSelectShapeTool}
11132
11433
  {onSelectFrameTool}
11133
11434
  {onSelectImageTool}
11435
+ onSelectVideoTool={onSelectVideoTool}
11134
11436
  {onGroupSelection}
11135
11437
  {onUngroupSelection}
11136
11438
  onDuplicateSelection={() => _duplicateSelected()}
@@ -11156,6 +11458,8 @@ function checkAndAddNewShapeToFrame(shape: any) {
11156
11458
  onToggleStickyTool={() => { stickyToolEnabled = !stickyToolEnabled; }}
11157
11459
  {onInsertLibraryShape}
11158
11460
  {onSelectLibraryShapeTool}
11461
+ {advancedFeatures}
11462
+ {mediaBtsOnBar}
11159
11463
  />
11160
11464
  {/if}
11161
11465
 
@@ -11294,5 +11598,5 @@ function checkAndAddNewShapeToFrame(shape: any) {
11294
11598
  -->
11295
11599
 
11296
11600
  <style>/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
11297
- button{cursor:pointer}.canvas-container{-webkit-user-select:none;user-select:none}.canvas-container input,.canvas-container textarea{-webkit-user-select:text;user-select:text}.canvas-loader-spinner{border:3px solid #e5e7eb;border-top-color:#374151;border-radius:50%;width:36px;height:36px;animation:.8s linear infinite canvas-spin}@keyframes canvas-spin{to{transform:rotate(360deg)}}
11601
+ button{cursor:pointer}.canvas-container{-webkit-user-select:none;user-select:none}.canvas-container input,.canvas-container textarea{-webkit-user-select:text;user-select:text}.canvas-loader-spinner{border:3px solid #e5e7eb;border-top-color:#374151;border-radius:50%;width:36px;height:36px;animation:.8s linear infinite canvas-spin}@keyframes canvas-spin{to{transform:rotate(360deg)}}.canvas-pan-zoom-overlay{width:max-content;max-width:calc(100% - 2rem);overflow:visible}
11298
11602
  </style>