@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.
- package/dist/canvas/Canvas.svelte +353 -49
- package/dist/canvas/Canvas.svelte.d.ts +24 -0
- package/dist/canvas/CanvasToolbar.svelte +113 -26
- package/dist/canvas/CanvasToolbar.svelte.d.ts +7 -1
- package/dist/canvas/PanZoomPanel.svelte +136 -6
- package/dist/canvas/PanZoomPanel.svelte.d.ts +1 -0
- package/dist/canvas/props-panel/PropsPanel.svelte +40 -11
- package/dist/canvas/shapes/ImageShape.d.ts +9 -0
- package/dist/canvas/shapes/ImageShape.js +115 -24
- package/dist/components/Tooltip.svelte +10 -2
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
2085
|
-
//
|
|
2086
|
-
//
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
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('
|
|
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:
|
|
2138
|
-
tr: { x:
|
|
2139
|
-
bl: { x:
|
|
2140
|
-
br: { x:
|
|
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({
|
|
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
|
-
|
|
4587
|
-
|
|
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
|
-
|
|
5194
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
8944
|
-
if (
|
|
8945
|
-
|
|
8946
|
-
obj.set({ fill:
|
|
8947
|
-
|
|
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
|
-
|
|
8986
|
-
|
|
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
|
-
<
|
|
11084
|
-
|
|
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>
|