@widgetic/canvas 0.5.7 → 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 +262 -24
- package/dist/canvas/Canvas.svelte.d.ts +3 -0
- package/dist/canvas/CanvasToolbar.svelte +115 -35
- package/dist/canvas/CanvasToolbar.svelte.d.ts +7 -2
- package/dist/canvas/PanZoomPanel.svelte +74 -18
- package/dist/canvas/props-panel/PropsPanel.svelte +21 -5
- 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',
|
|
@@ -156,6 +156,8 @@
|
|
|
156
156
|
// Extra canvas chrome (undo/redo/lock, frame, image, shape library).
|
|
157
157
|
// Default off for the sketch MVP. Same pattern as wireframeMode: keep the code, hide the UI.
|
|
158
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;
|
|
159
161
|
|
|
160
162
|
// Callback fired once when Fabric canvas is fully initialized and ready
|
|
161
163
|
export let onReady: (() => void) | null = null;
|
|
@@ -282,7 +284,7 @@
|
|
|
282
284
|
const GRID_COLOR = 'rgba(0, 0, 0, 0.1)'; // Light gray dots
|
|
283
285
|
|
|
284
286
|
// Tools state
|
|
285
|
-
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';
|
|
286
288
|
|
|
287
289
|
// Minimum size for image placeholder to fit UI text
|
|
288
290
|
const MIN_IMAGE_PLACEHOLDER_WIDTH = 120;
|
|
@@ -649,6 +651,30 @@
|
|
|
649
651
|
{ children: any[]; bounds: { left: number; top: number; width: number; height: number } }
|
|
650
652
|
>();
|
|
651
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
|
+
|
|
652
678
|
/**
|
|
653
679
|
* HELPER FUNCTIONS
|
|
654
680
|
*/
|
|
@@ -2421,6 +2447,20 @@
|
|
|
2421
2447
|
(obj as any)._lockAspectRatio = true;
|
|
2422
2448
|
(obj as any)._forceLockAspectRatio = true;
|
|
2423
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
|
+
}
|
|
2424
2464
|
}
|
|
2425
2465
|
}
|
|
2426
2466
|
|
|
@@ -2861,7 +2901,12 @@
|
|
|
2861
2901
|
// Override PencilBrush round-cap defaults so the Corner Roundness slider
|
|
2862
2902
|
// has a visible effect. At 0% the drawing appears sharp (square caps, miter
|
|
2863
2903
|
// joins); moving the slider progressively rounds caps and joins.
|
|
2864
|
-
path.set({
|
|
2904
|
+
path.set({
|
|
2905
|
+
strokeLineCap: 'square',
|
|
2906
|
+
strokeLineJoin: 'miter',
|
|
2907
|
+
fill: 'transparent',
|
|
2908
|
+
_fillOpacity: 0,
|
|
2909
|
+
});
|
|
2865
2910
|
checkAndAddNewShapeToFrame(path);
|
|
2866
2911
|
takeHistorySnapshot();
|
|
2867
2912
|
});
|
|
@@ -3472,7 +3517,7 @@
|
|
|
3472
3517
|
// Fill opacity (percentage stored separately from the baked rgba fill value)
|
|
3473
3518
|
'_fillOpacity',
|
|
3474
3519
|
// Library shape metadata
|
|
3475
|
-
'_shapeKey', '_cornerRadiusMode',
|
|
3520
|
+
'_shapeKey', '_cornerRadiusMode', '_iconFill',
|
|
3476
3521
|
// Textbox box stroke and corner properties
|
|
3477
3522
|
'_boxStroke', '_boxStrokeWidth', '_boxStrokeDashArray',
|
|
3478
3523
|
'_boxCornerRadius', '_boxFill',
|
|
@@ -4703,9 +4748,47 @@
|
|
|
4703
4748
|
openFilePickerForPlaceholder(target);
|
|
4704
4749
|
return;
|
|
4705
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
|
+
}
|
|
4706
4761
|
};
|
|
4707
4762
|
|
|
4708
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
|
+
});
|
|
4709
4792
|
|
|
4710
4793
|
// ─── Frame label drag & cursor handlers ───
|
|
4711
4794
|
// Handles hover cursor and click-drag on frame labels (including nested frames).
|
|
@@ -5247,8 +5330,43 @@
|
|
|
5247
5330
|
|
|
5248
5331
|
// Process each dropped file
|
|
5249
5332
|
Array.from(files).forEach((file) => {
|
|
5250
|
-
|
|
5251
|
-
|
|
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
|
+
});
|
|
5252
5370
|
return;
|
|
5253
5371
|
}
|
|
5254
5372
|
|
|
@@ -5440,6 +5558,10 @@
|
|
|
5440
5558
|
});
|
|
5441
5559
|
|
|
5442
5560
|
onDestroy(() => {
|
|
5561
|
+
if (videoRenderRaf) {
|
|
5562
|
+
cancelAnimationFrame(videoRenderRaf);
|
|
5563
|
+
videoRenderRaf = 0;
|
|
5564
|
+
}
|
|
5443
5565
|
// CRITICAL: Notify the host to save canvas data BEFORE any cleanup.
|
|
5444
5566
|
// This fires when Vite HMR rebuilds this component or the page navigates away.
|
|
5445
5567
|
// At this point canvas is still valid — canvas.dispose() hasn't run yet.
|
|
@@ -6459,6 +6581,14 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
6459
6581
|
(shape as any)._shapeKey = shapeKey;
|
|
6460
6582
|
(shape as any)._cornerRadiusMode = cornerRadiusMode;
|
|
6461
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
|
+
}
|
|
6462
6592
|
if (lockAspectRatio) {
|
|
6463
6593
|
(shape as any)._lockAspectRatio = true;
|
|
6464
6594
|
(shape as any)._forceLockAspectRatio = true;
|
|
@@ -6510,7 +6640,8 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
6510
6640
|
top: pointer.y,
|
|
6511
6641
|
originX: 'left',
|
|
6512
6642
|
originY: 'top',
|
|
6513
|
-
fill: '#ffffff',
|
|
6643
|
+
fill: getLibraryShapeMeta(pendingLibraryShapeKey).cornerRadiusMode === 'strokeRound' ? '' : '#ffffff',
|
|
6644
|
+
backgroundColor: getLibraryShapeMeta(pendingLibraryShapeKey).cornerRadiusMode === 'strokeRound' ? '#ffffff' : undefined,
|
|
6514
6645
|
_fillOpacity: 1,
|
|
6515
6646
|
stroke: '#374151',
|
|
6516
6647
|
strokeWidth: 3,
|
|
@@ -7369,6 +7500,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
7369
7500
|
top: number;
|
|
7370
7501
|
width: number;
|
|
7371
7502
|
height: number;
|
|
7503
|
+
mediaKind?: 'image' | 'video';
|
|
7372
7504
|
}) {
|
|
7373
7505
|
// Use ImageShape class for image placeholders. When converted to a widget,
|
|
7374
7506
|
// the prototype is swapped to WidgetShape via upgradeToWidgetShape().
|
|
@@ -7391,7 +7523,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
7391
7523
|
minScaleLimit: 0.01
|
|
7392
7524
|
}) as any;
|
|
7393
7525
|
|
|
7394
|
-
imageShape._customType = 'image';
|
|
7526
|
+
imageShape._customType = options.mediaKind === 'video' ? 'video' : 'image';
|
|
7395
7527
|
imageShape._hasImage = false;
|
|
7396
7528
|
imageShape._imageElement = null;
|
|
7397
7529
|
imageShape._originalFilename = null;
|
|
@@ -7613,7 +7745,16 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
7613
7745
|
* Image Tool - Draw ImagePlaceholder on canvas
|
|
7614
7746
|
*/
|
|
7615
7747
|
export function onSelectImageTool() {
|
|
7616
|
-
|
|
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, () => {
|
|
7617
7758
|
if (!canvas) {
|
|
7618
7759
|
return;
|
|
7619
7760
|
}
|
|
@@ -7714,7 +7855,8 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
7714
7855
|
left: finalBounds.left,
|
|
7715
7856
|
top: finalBounds.top,
|
|
7716
7857
|
width: finalBounds.width,
|
|
7717
|
-
height: finalBounds.height
|
|
7858
|
+
height: finalBounds.height,
|
|
7859
|
+
mediaKind: kind
|
|
7718
7860
|
});
|
|
7719
7861
|
|
|
7720
7862
|
canvas.add(placeholder);
|
|
@@ -7750,20 +7892,40 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
7750
7892
|
*/
|
|
7751
7893
|
function openFilePickerForPlaceholder(placeholder: any) {
|
|
7752
7894
|
if (!canvas) return;
|
|
7895
|
+
const isVideo = placeholder._customType === 'video';
|
|
7753
7896
|
|
|
7754
7897
|
const fileInput = document.createElement('input');
|
|
7755
7898
|
fileInput.type = 'file';
|
|
7756
|
-
fileInput.accept = 'image/*';
|
|
7899
|
+
fileInput.accept = isVideo ? 'video/*' : 'image/*';
|
|
7757
7900
|
fileInput.style.display = 'none';
|
|
7758
7901
|
|
|
7759
7902
|
fileInput.onchange = (event: Event) => {
|
|
7760
7903
|
const target = event.target as HTMLInputElement;
|
|
7761
7904
|
const file = target.files?.[0];
|
|
7762
7905
|
|
|
7763
|
-
if (!file
|
|
7906
|
+
if (!file) {
|
|
7907
|
+
document.body.removeChild(fileInput);
|
|
7908
|
+
return;
|
|
7909
|
+
}
|
|
7910
|
+
if (isVideo && !file.type.startsWith('video/')) {
|
|
7764
7911
|
document.body.removeChild(fileInput);
|
|
7765
7912
|
return;
|
|
7766
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
|
+
}
|
|
7767
7929
|
|
|
7768
7930
|
const reader = new FileReader();
|
|
7769
7931
|
reader.onload = (e) => {
|
|
@@ -7780,6 +7942,59 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
7780
7942
|
fileInput.click();
|
|
7781
7943
|
}
|
|
7782
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
|
+
|
|
7783
7998
|
/**
|
|
7784
7999
|
* Load image from URL into selected ImagePlaceholder (from PropsPanel)
|
|
7785
8000
|
*/
|
|
@@ -7800,20 +8015,32 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
7800
8015
|
// Works for both ImagePlaceholder and loaded Image
|
|
7801
8016
|
const currentObject = selectedObject;
|
|
7802
8017
|
|
|
8018
|
+
const isVideo = (currentObject as any)._customType === 'video';
|
|
7803
8019
|
const fileInput = document.createElement('input');
|
|
7804
8020
|
fileInput.type = 'file';
|
|
7805
|
-
fileInput.accept = 'image/*';
|
|
8021
|
+
fileInput.accept = isVideo ? 'video/*' : 'image/*';
|
|
7806
8022
|
fileInput.style.display = 'none';
|
|
7807
8023
|
|
|
7808
8024
|
fileInput.onchange = (event: Event) => {
|
|
7809
8025
|
const target = event.target as HTMLInputElement;
|
|
7810
8026
|
const file = target.files?.[0];
|
|
7811
8027
|
|
|
7812
|
-
if (!file || !file.type.startsWith('image/')) {
|
|
8028
|
+
if (!file || (isVideo ? !file.type.startsWith('video/') : !file.type.startsWith('image/'))) {
|
|
7813
8029
|
document.body.removeChild(fileInput);
|
|
7814
8030
|
return;
|
|
7815
8031
|
}
|
|
7816
|
-
|
|
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
|
+
|
|
7817
8044
|
const reader = new FileReader();
|
|
7818
8045
|
reader.onload = (e) => {
|
|
7819
8046
|
const dataUrl = e.target?.result as string;
|
|
@@ -8339,7 +8566,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
8339
8566
|
// Fill opacity (percentage stored separately from the baked rgba fill value)
|
|
8340
8567
|
'_fillOpacity',
|
|
8341
8568
|
// Library shape metadata
|
|
8342
|
-
'_shapeKey', '_cornerRadiusMode',
|
|
8569
|
+
'_shapeKey', '_cornerRadiusMode', '_iconFill',
|
|
8343
8570
|
'_boxStroke', '_boxStrokeWidth', '_boxStrokeDashArray',
|
|
8344
8571
|
'_boxCornerRadius', '_boxFill',
|
|
8345
8572
|
'_textPadding',
|
|
@@ -9003,13 +9230,13 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
9003
9230
|
// Helper to apply fill color while preserving fill opacity
|
|
9004
9231
|
const applyFillColor = (obj: any, clr: string) => {
|
|
9005
9232
|
const fillOpacity = obj._fillOpacity ?? 1;
|
|
9006
|
-
|
|
9007
|
-
if (
|
|
9008
|
-
|
|
9009
|
-
obj.set({ fill:
|
|
9010
|
-
|
|
9011
|
-
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;
|
|
9012
9238
|
}
|
|
9239
|
+
obj.set({ fill: painted });
|
|
9013
9240
|
};
|
|
9014
9241
|
|
|
9015
9242
|
// If it's a Frame, update the background rect specifically (preserving fill opacity)
|
|
@@ -9045,8 +9272,12 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
9045
9272
|
// Skip ImagePlaceholder — its fill background is hidden once an image is
|
|
9046
9273
|
// loaded; only stroke properties are meaningful for this type.
|
|
9047
9274
|
} else if ('fill' in obj) {
|
|
9048
|
-
|
|
9049
|
-
|
|
9275
|
+
if (paintsFillOnStroke(obj)) {
|
|
9276
|
+
obj._iconFill = color;
|
|
9277
|
+
obj.set({ fill: '', backgroundColor: color });
|
|
9278
|
+
} else {
|
|
9279
|
+
obj.set({ fill: color });
|
|
9280
|
+
}
|
|
9050
9281
|
obj._fillOpacity = 1;
|
|
9051
9282
|
}
|
|
9052
9283
|
});
|
|
@@ -9209,6 +9440,11 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
9209
9440
|
// Helper to apply fill opacity to a single shape object
|
|
9210
9441
|
const applyFillOpacity = (obj: any) => {
|
|
9211
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
|
+
}
|
|
9212
9448
|
const currentFill = obj.fill || '#FFFFFF';
|
|
9213
9449
|
const rgbaFill = colorToRgba(currentFill, alpha);
|
|
9214
9450
|
obj.set({ fill: rgbaFill });
|
|
@@ -11196,6 +11432,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
11196
11432
|
{onSelectShapeTool}
|
|
11197
11433
|
{onSelectFrameTool}
|
|
11198
11434
|
{onSelectImageTool}
|
|
11435
|
+
onSelectVideoTool={onSelectVideoTool}
|
|
11199
11436
|
{onGroupSelection}
|
|
11200
11437
|
{onUngroupSelection}
|
|
11201
11438
|
onDuplicateSelection={() => _duplicateSelected()}
|
|
@@ -11222,6 +11459,7 @@ function checkAndAddNewShapeToFrame(shape: any) {
|
|
|
11222
11459
|
{onInsertLibraryShape}
|
|
11223
11460
|
{onSelectLibraryShapeTool}
|
|
11224
11461
|
{advancedFeatures}
|
|
11462
|
+
{mediaBtsOnBar}
|
|
11225
11463
|
/>
|
|
11226
11464
|
{/if}
|
|
11227
11465
|
|
|
@@ -23,6 +23,7 @@ declare const __propDef: {
|
|
|
23
23
|
panZoomPosition?: "TL" | "TC" | "TR" | "BL" | "BC" | "BR";
|
|
24
24
|
wireframeMode?: boolean;
|
|
25
25
|
advancedFeatures?: boolean;
|
|
26
|
+
/** When true and advanced is on, Frame/Image/Video stay on the toolbar. Default true. */ mediaBtsOnBar?: boolean;
|
|
26
27
|
onReady?: (() => void) | null;
|
|
27
28
|
isExternalDataLoading?: boolean;
|
|
28
29
|
onBeforeUnmount?: ((json: string | null) => void) | null;
|
|
@@ -91,6 +92,7 @@ declare const __propDef: {
|
|
|
91
92
|
setPrimaryWidgetShape?: (widgetId: string) => boolean;
|
|
92
93
|
getPrimaryWidgetId?: () => string | null;
|
|
93
94
|
onSelectImageTool?: () => void;
|
|
95
|
+
onSelectVideoTool?: () => void;
|
|
94
96
|
onSelectPanTool?: () => void;
|
|
95
97
|
zoomIn?: () => void;
|
|
96
98
|
zoomOut?: () => void;
|
|
@@ -173,6 +175,7 @@ export default class Canvas extends SvelteComponentTyped<CanvasProps, CanvasEven
|
|
|
173
175
|
get setPrimaryWidgetShape(): (widgetId: string) => boolean;
|
|
174
176
|
get getPrimaryWidgetId(): () => string | null;
|
|
175
177
|
get onSelectImageTool(): () => void;
|
|
178
|
+
get onSelectVideoTool(): () => void;
|
|
176
179
|
get onSelectPanTool(): () => void;
|
|
177
180
|
get zoomIn(): () => void;
|
|
178
181
|
get zoomOut(): () => void;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* COMPONENT PROPS
|
|
8
8
|
*/
|
|
9
9
|
// Tool type definition
|
|
10
|
-
type ToolName = 'select' | 'erase' | 'draw' | 'arrow' | 'text' | 'rect' | 'circle' | 'circle-true' | 'triangle' | 'frame' | 'pan' | 'image' | 'library-shape';
|
|
10
|
+
type ToolName = 'select' | 'erase' | 'draw' | 'arrow' | 'text' | 'rect' | 'circle' | 'circle-true' | 'triangle' | 'frame' | 'pan' | 'image' | 'video' | 'library-shape';
|
|
11
11
|
// Shape tool sub-type for the shapes dropdown (Arrow is a dedicated button, not in dropdown)
|
|
12
12
|
type ShapeToolName = 'rect' | 'circle' | 'circle-true' | 'triangle';
|
|
13
13
|
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
export let onSelectTextTool: () => void = () => {};
|
|
32
32
|
export let onSelectPanTool: () => void = () => {};
|
|
33
33
|
export let onSelectImageTool: () => void = () => {};
|
|
34
|
+
export let onSelectVideoTool: () => void = () => {};
|
|
34
35
|
// Zoom callbacks — optional, used for Ctrl+/Ctrl- shortcuts
|
|
35
36
|
export let onZoomIn: () => void = () => {};
|
|
36
37
|
export let onZoomOut: () => void = () => {};
|
|
@@ -63,8 +64,16 @@
|
|
|
63
64
|
/** Draw-to-place: activates library shape tool so user clicks-and-drags to size it. */
|
|
64
65
|
export let onSelectLibraryShapeTool: (shapeKey: string, pathData: string) => void = () => {};
|
|
65
66
|
|
|
66
|
-
/** Extra tools (undo/redo/lock
|
|
67
|
+
/** Extra tools (undo/redo/lock). Code stays; UI is gated. */
|
|
67
68
|
export let advancedFeatures: boolean = false;
|
|
69
|
+
/**
|
|
70
|
+
* When true (default) and advancedFeatures is on, Frame / Image / Video sit on the toolbar.
|
|
71
|
+
* When false, they leave the bar and appear under More Shapes → Media Shapes.
|
|
72
|
+
*/
|
|
73
|
+
export let mediaBtsOnBar: boolean = true;
|
|
74
|
+
|
|
75
|
+
$: showMediaOnBar = advancedFeatures && mediaBtsOnBar;
|
|
76
|
+
$: showMediaInDropdown = !showMediaOnBar;
|
|
68
77
|
|
|
69
78
|
// Pre-group shapes for the dropdown
|
|
70
79
|
const libraryShapesByCategory = getShapesByCategory();
|
|
@@ -79,7 +88,7 @@
|
|
|
79
88
|
|
|
80
89
|
// ─── Shapes + Image dropdown state ───
|
|
81
90
|
// The last-selected tool within the dropdown (shapes, image, OR library shape).
|
|
82
|
-
type DropdownToolName = ShapeToolName | 'image' | 'library-shape';
|
|
91
|
+
type DropdownToolName = ShapeToolName | 'image' | 'video' | 'frame' | 'library-shape';
|
|
83
92
|
let lastSelectedDropdownTool: DropdownToolName = 'rect';
|
|
84
93
|
// When a library shape was last selected, store its info for the trigger button
|
|
85
94
|
let lastSelectedLibraryShape: LibraryShape | null = null;
|
|
@@ -137,12 +146,29 @@
|
|
|
137
146
|
};
|
|
138
147
|
|
|
139
148
|
// The currently displayed tool in the dropdown button (active tool if it's a dropdown tool, else last selected)
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
149
|
+
const mediaToolInfo: Record<'frame' | 'image' | 'video', { iconSvg: string; label: string; key: string }> = {
|
|
150
|
+
frame: {
|
|
151
|
+
iconSvg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 3v6"/></svg>`,
|
|
152
|
+
label: 'Frame',
|
|
153
|
+
key: 'F',
|
|
154
|
+
},
|
|
155
|
+
image: {
|
|
156
|
+
iconSvg: imageDropdownInfo.iconSvg,
|
|
157
|
+
label: 'Image',
|
|
158
|
+
key: 'I',
|
|
159
|
+
},
|
|
160
|
+
video: {
|
|
161
|
+
iconSvg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="14" height="12" rx="2"/><path d="M16 10l6-3v10l-6-3z"/></svg>`,
|
|
162
|
+
label: 'Video',
|
|
163
|
+
key: '',
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const dropdownTools: DropdownToolName[] = ['rect', 'circle', 'circle-true', 'triangle', 'image', 'video', 'frame', 'library-shape'];
|
|
168
|
+
$: activeDropdownTool = dropdownTools.includes(activeTool as DropdownToolName)
|
|
143
169
|
? (activeTool as DropdownToolName)
|
|
144
170
|
: lastSelectedDropdownTool;
|
|
145
|
-
$: activeDropdownIsActive =
|
|
171
|
+
$: activeDropdownIsActive = dropdownTools.includes(activeTool as DropdownToolName);
|
|
146
172
|
|
|
147
173
|
// Backwards-compat aliases used in template
|
|
148
174
|
$: activeShapeInDropdown = activeDropdownTool as ShapeToolName;
|
|
@@ -155,10 +181,23 @@
|
|
|
155
181
|
}
|
|
156
182
|
|
|
157
183
|
function selectImageFromDropdown() {
|
|
184
|
+
lastSelectedDropdownTool = 'image';
|
|
158
185
|
if (!KEEP_SHAPES_DROPDOWN_OPEN) shapesDropdownOpen = false;
|
|
159
186
|
onSelectImageTool();
|
|
160
187
|
}
|
|
161
188
|
|
|
189
|
+
function selectFrameFromDropdown() {
|
|
190
|
+
lastSelectedDropdownTool = 'frame';
|
|
191
|
+
if (!KEEP_SHAPES_DROPDOWN_OPEN) shapesDropdownOpen = false;
|
|
192
|
+
onSelectFrameTool();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function selectVideoFromDropdown() {
|
|
196
|
+
lastSelectedDropdownTool = 'video';
|
|
197
|
+
if (!KEEP_SHAPES_DROPDOWN_OPEN) shapesDropdownOpen = false;
|
|
198
|
+
onSelectVideoTool();
|
|
199
|
+
}
|
|
200
|
+
|
|
162
201
|
function selectLibraryShape(shape: LibraryShape) {
|
|
163
202
|
lastSelectedDropdownTool = 'library-shape';
|
|
164
203
|
lastSelectedLibraryShape = shape;
|
|
@@ -170,14 +209,26 @@
|
|
|
170
209
|
function activateDropdownTool() {
|
|
171
210
|
if (activeDropdownTool === 'library-shape' && lastSelectedLibraryShape) {
|
|
172
211
|
selectLibraryShape(lastSelectedLibraryShape);
|
|
212
|
+
} else if (activeDropdownTool === 'image') {
|
|
213
|
+
selectImageFromDropdown();
|
|
214
|
+
} else if (activeDropdownTool === 'video') {
|
|
215
|
+
selectVideoFromDropdown();
|
|
216
|
+
} else if (activeDropdownTool === 'frame') {
|
|
217
|
+
selectFrameFromDropdown();
|
|
173
218
|
} else {
|
|
174
219
|
selectShape(activeDropdownTool as ShapeToolName);
|
|
175
220
|
}
|
|
176
221
|
}
|
|
177
222
|
|
|
223
|
+
$: mediaTrigger = (activeDropdownTool === 'frame' || activeDropdownTool === 'image' || activeDropdownTool === 'video')
|
|
224
|
+
? mediaToolInfo[activeDropdownTool]
|
|
225
|
+
: null;
|
|
226
|
+
|
|
178
227
|
// Tooltip text for the main dropdown button
|
|
179
228
|
$: dropdownButtonTooltip = activeDropdownTool === 'library-shape' && lastSelectedLibraryShape
|
|
180
229
|
? lastSelectedLibraryShape.label
|
|
230
|
+
: mediaTrigger
|
|
231
|
+
? (mediaTrigger.key ? `${mediaTrigger.label} (${mediaTrigger.key})` : mediaTrigger.label)
|
|
181
232
|
: shapeInfo[activeDropdownTool as ShapeToolName]?.label
|
|
182
233
|
? (shapeInfo[activeDropdownTool as ShapeToolName].key
|
|
183
234
|
? `${shapeInfo[activeDropdownTool as ShapeToolName].label} (${shapeInfo[activeDropdownTool as ShapeToolName].key})`
|
|
@@ -388,12 +439,12 @@
|
|
|
388
439
|
case 'D': event.preventDefault(); onSelectDrawTool(); break;
|
|
389
440
|
case 'H': event.preventDefault(); onSelectPanTool(); break;
|
|
390
441
|
case 'A': event.preventDefault(); selectShape('arrow'); break;
|
|
391
|
-
case 'R':
|
|
392
|
-
case 'G':
|
|
393
|
-
case 'O':
|
|
394
|
-
case 'F':
|
|
442
|
+
case 'R': event.preventDefault(); selectShape('rect'); break;
|
|
443
|
+
case 'G': event.preventDefault(); selectShape('triangle'); break;
|
|
444
|
+
case 'O': event.preventDefault(); selectShape('circle'); break;
|
|
445
|
+
case 'F': event.preventDefault(); onSelectFrameTool(); break;
|
|
395
446
|
case 'T': event.preventDefault(); onSelectTextTool(); break;
|
|
396
|
-
case 'I':
|
|
447
|
+
case 'I': event.preventDefault(); onSelectImageTool(); break;
|
|
397
448
|
}
|
|
398
449
|
}
|
|
399
450
|
|
|
@@ -628,7 +679,7 @@
|
|
|
628
679
|
</button>
|
|
629
680
|
</Tooltip>
|
|
630
681
|
|
|
631
|
-
{#if
|
|
682
|
+
{#if showMediaOnBar}
|
|
632
683
|
<div class="toolbar-divider"></div>
|
|
633
684
|
|
|
634
685
|
<Tooltip text="Frame (F)">
|
|
@@ -655,7 +706,19 @@
|
|
|
655
706
|
</button>
|
|
656
707
|
</Tooltip>
|
|
657
708
|
|
|
658
|
-
|
|
709
|
+
<Tooltip text="Video">
|
|
710
|
+
<button class="video-bt {buttonClass} {activeTool === 'video' ? activeClass : inactiveClass}"
|
|
711
|
+
onclick={onSelectVideoTool}
|
|
712
|
+
aria-label="Video">
|
|
713
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
714
|
+
<rect x="2" y="6" width="14" height="12" rx="2"/>
|
|
715
|
+
<path d="M16 10l6-3v10l-6-3z"/>
|
|
716
|
+
</svg>
|
|
717
|
+
</button>
|
|
718
|
+
</Tooltip>
|
|
719
|
+
{/if}
|
|
720
|
+
|
|
721
|
+
<!-- Shapes: tldraw-style split — left activates tool, right opens dropdown. Always available. -->
|
|
659
722
|
<div class="shapes-split-ct" bind:this={dropdownTriggerEl}>
|
|
660
723
|
<Tooltip text={dropdownButtonTooltip}>
|
|
661
724
|
<button
|
|
@@ -665,6 +728,8 @@
|
|
|
665
728
|
>
|
|
666
729
|
{#if activeDropdownTool === 'library-shape' && lastSelectedLibraryShape}
|
|
667
730
|
{@html lastSelectedLibraryShape.iconSvg}
|
|
731
|
+
{:else if mediaTrigger}
|
|
732
|
+
{@html mediaTrigger.iconSvg}
|
|
668
733
|
{:else if shapeInfo[activeDropdownTool as ShapeToolName]}
|
|
669
734
|
{@html shapeInfo[activeDropdownTool as ShapeToolName].iconSvg}
|
|
670
735
|
{:else}
|
|
@@ -684,7 +749,6 @@
|
|
|
684
749
|
</button>
|
|
685
750
|
</Tooltip>
|
|
686
751
|
</div>
|
|
687
|
-
{/if}
|
|
688
752
|
</div>
|
|
689
753
|
</div>
|
|
690
754
|
|
|
@@ -715,7 +779,7 @@
|
|
|
715
779
|
dropdownOpenAbove=true: appears above the trigger (toolbar at bottom).
|
|
716
780
|
dropdownOpenAbove=false: appears below (toolbar at top).
|
|
717
781
|
═══════════════════════════════════════════════════════════════ -->
|
|
718
|
-
{#if shapesDropdownOpen
|
|
782
|
+
{#if shapesDropdownOpen}
|
|
719
783
|
<!-- position:absolute within .canvas-container (position:relative) so the dropdown
|
|
720
784
|
is correctly positioned regardless of any CSS transform on ancestors. -->
|
|
721
785
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
@@ -729,6 +793,29 @@
|
|
|
729
793
|
use:overlayScrollbar
|
|
730
794
|
style={shapesDropdownNeedsScroll ? 'margin-right: -6px' : ''}
|
|
731
795
|
>
|
|
796
|
+
<!-- Media Shapes — first so Frame / Image / Video are easy to reach when not on the bar -->
|
|
797
|
+
{#if showMediaInDropdown}
|
|
798
|
+
<div class="shapes-category-label">Media Shapes</div>
|
|
799
|
+
<div class="shapes-library-grid">
|
|
800
|
+
<Tooltip text="Frame (F)" position="above">
|
|
801
|
+
<button class="shape-library-bt {activeTool === 'frame' ? 'active' : ''}" onclick={selectFrameFromDropdown} aria-label="Frame">
|
|
802
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 3v6"/></svg>
|
|
803
|
+
</button>
|
|
804
|
+
</Tooltip>
|
|
805
|
+
<Tooltip text="Image (I)" position="above">
|
|
806
|
+
<button class="shape-library-bt {activeTool === 'image' ? 'active' : ''}" onclick={selectImageFromDropdown} aria-label="Image">
|
|
807
|
+
{@html imageDropdownInfo.iconSvg}
|
|
808
|
+
</button>
|
|
809
|
+
</Tooltip>
|
|
810
|
+
<Tooltip text="Video" position="above">
|
|
811
|
+
<button class="shape-library-bt {activeTool === 'video' ? 'active' : ''}" onclick={selectVideoFromDropdown} aria-label="Video">
|
|
812
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="14" height="12" rx="2"/><path d="M16 10l6-3v10l-6-3z"/></svg>
|
|
813
|
+
</button>
|
|
814
|
+
</Tooltip>
|
|
815
|
+
</div>
|
|
816
|
+
<div class="shapes-dropdown-divider"></div>
|
|
817
|
+
{/if}
|
|
818
|
+
|
|
732
819
|
<!-- Geometric Shapes — 3×2 grid -->
|
|
733
820
|
<div class="shapes-category-label">Geometric Shapes</div>
|
|
734
821
|
<div class="shapes-library-grid">
|
|
@@ -768,21 +855,6 @@
|
|
|
768
855
|
{/each}
|
|
769
856
|
</div>
|
|
770
857
|
|
|
771
|
-
<!-- Media (hidden — Image tool moved to main toolbar) -->
|
|
772
|
-
{#if false}
|
|
773
|
-
<div class="shapes-dropdown-divider"></div>
|
|
774
|
-
<div class="shapes-category-label">Media</div>
|
|
775
|
-
<Tooltip text="Image (I)" position="above">
|
|
776
|
-
<button
|
|
777
|
-
class="shape-option-bt {activeTool === 'image' ? 'active' : ''}"
|
|
778
|
-
onclick={selectImageFromDropdown}
|
|
779
|
-
>
|
|
780
|
-
{@html imageDropdownInfo.iconSvg}
|
|
781
|
-
{imageDropdownInfo.label}
|
|
782
|
-
</button>
|
|
783
|
-
</Tooltip>
|
|
784
|
-
{/if}
|
|
785
|
-
|
|
786
858
|
<!-- Library shape categories (skip Geometric, already rendered above) -->
|
|
787
859
|
{#each [...libraryShapesByCategory.entries()].filter(([cat]) => cat !== 'Geometric Shapes') as [category, shapes]}
|
|
788
860
|
<div class="shapes-dropdown-divider"></div>
|
|
@@ -901,6 +973,13 @@
|
|
|
901
973
|
background: transparent !important;
|
|
902
974
|
box-shadow: none !important;
|
|
903
975
|
}
|
|
976
|
+
.canvas-toolbar-el :global(button:focus),
|
|
977
|
+
.canvas-toolbar-el :global(button:focus-visible),
|
|
978
|
+
.canvas-toolbar-el :global(a:focus),
|
|
979
|
+
.canvas-toolbar-el :global(a:focus-visible) {
|
|
980
|
+
outline: none !important;
|
|
981
|
+
box-shadow: none !important;
|
|
982
|
+
}
|
|
904
983
|
|
|
905
984
|
/* ── "Sheath" — the white container holding all buttons ── */
|
|
906
985
|
.tools-ct {
|
|
@@ -908,7 +987,7 @@
|
|
|
908
987
|
border-radius: 1.25rem;
|
|
909
988
|
box-shadow: 0 4px 24px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.04);
|
|
910
989
|
height: 80px;
|
|
911
|
-
padding: 0
|
|
990
|
+
padding: 0 24px;
|
|
912
991
|
gap: 4px;
|
|
913
992
|
align-items: center;
|
|
914
993
|
overflow: visible;
|
|
@@ -926,9 +1005,10 @@ button {
|
|
|
926
1005
|
background: transparent;
|
|
927
1006
|
}
|
|
928
1007
|
|
|
929
|
-
button:focus
|
|
930
|
-
|
|
931
|
-
|
|
1008
|
+
button:focus,
|
|
1009
|
+
button:focus-visible {
|
|
1010
|
+
outline: none !important;
|
|
1011
|
+
box-shadow: none !important;
|
|
932
1012
|
}
|
|
933
1013
|
|
|
934
1014
|
button:disabled {
|
|
@@ -3,7 +3,7 @@ declare const __propDef: {
|
|
|
3
3
|
props: {
|
|
4
4
|
class?: string;
|
|
5
5
|
style?: string;
|
|
6
|
-
activeTool?: "select" | "rect" | "erase" | "draw" | "arrow" | "text" | "circle" | "circle-true" | "triangle" | "frame" | "pan" | "image" | "library-shape";
|
|
6
|
+
activeTool?: "select" | "video" | "rect" | "erase" | "draw" | "arrow" | "text" | "circle" | "circle-true" | "triangle" | "frame" | "pan" | "image" | "library-shape";
|
|
7
7
|
activeClass?: string;
|
|
8
8
|
inactiveClass?: string;
|
|
9
9
|
buttonClass?: string;
|
|
@@ -15,6 +15,7 @@ declare const __propDef: {
|
|
|
15
15
|
onSelectTextTool?: () => void;
|
|
16
16
|
onSelectPanTool?: () => void;
|
|
17
17
|
onSelectImageTool?: () => void;
|
|
18
|
+
onSelectVideoTool?: () => void;
|
|
18
19
|
onZoomIn?: () => void;
|
|
19
20
|
onZoomOut?: () => void;
|
|
20
21
|
onZoomReset?: () => void;
|
|
@@ -32,7 +33,11 @@ declare const __propDef: {
|
|
|
32
33
|
onToggleStickyTool?: () => void;
|
|
33
34
|
/** Called when user clicks a library shape. Canvas inserts it as a Path. */ onInsertLibraryShape?: (shapeKey: string, pathData: string) => void;
|
|
34
35
|
/** Draw-to-place: activates library shape tool so user clicks-and-drags to size it. */ onSelectLibraryShapeTool?: (shapeKey: string, pathData: string) => void;
|
|
35
|
-
/** Extra tools (undo/redo/lock
|
|
36
|
+
/** Extra tools (undo/redo/lock). Code stays; UI is gated. */ advancedFeatures?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* When true (default) and advancedFeatures is on, Frame / Image / Video sit on the toolbar.
|
|
39
|
+
* When false, they leave the bar and appear under More Shapes → Media Shapes.
|
|
40
|
+
*/ mediaBtsOnBar?: boolean;
|
|
36
41
|
addText?: () => void;
|
|
37
42
|
addRect?: () => void;
|
|
38
43
|
addTriangle?: () => void;
|
|
@@ -134,6 +134,19 @@
|
|
|
134
134
|
|
|
135
135
|
<!-- Zoom Level Input -->
|
|
136
136
|
<div class="zoom-level">
|
|
137
|
+
<Tooltip text="Reset zoom to 100% [Ctrl/Cmd] + [0]" position="above">
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
class="zoom-reset-in-input"
|
|
141
|
+
onclick={onZoomReset}
|
|
142
|
+
aria-label="Reset zoom to 100%"
|
|
143
|
+
>
|
|
144
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
145
|
+
<polyline points="1 4 1 10 7 10"/>
|
|
146
|
+
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
|
|
147
|
+
</svg>
|
|
148
|
+
</button>
|
|
149
|
+
</Tooltip>
|
|
137
150
|
<input
|
|
138
151
|
type="text"
|
|
139
152
|
class="zoom-input"
|
|
@@ -208,7 +221,7 @@
|
|
|
208
221
|
</Tooltip>
|
|
209
222
|
{/if}
|
|
210
223
|
|
|
211
|
-
|
|
224
|
+
{#if showSaveButton && advancedFeatures}
|
|
212
225
|
<div class="divider"></div>
|
|
213
226
|
<Tooltip text={hasUnsavedChanges ? 'Unsaved changes — click to save [Cmd+S]' : 'All changes saved [Cmd+S]'} position="above">
|
|
214
227
|
<button
|
|
@@ -229,18 +242,15 @@
|
|
|
229
242
|
{/if}
|
|
230
243
|
|
|
231
244
|
<div class="pan-zoom-minimize-anchor">
|
|
232
|
-
<Tooltip text="Minimize
|
|
245
|
+
<Tooltip text="Minimize Zoom Panel" position="above">
|
|
233
246
|
<button
|
|
234
247
|
type="button"
|
|
235
|
-
class="
|
|
248
|
+
class="pan-zoom-minimize-btn"
|
|
236
249
|
onclick={toggleMinimized}
|
|
237
|
-
aria-label="Minimize
|
|
250
|
+
aria-label="Minimize Zoom Panel"
|
|
238
251
|
>
|
|
239
|
-
<svg width="
|
|
240
|
-
<
|
|
241
|
-
<polyline points="20 10 14 10 14 4"/>
|
|
242
|
-
<line x1="14" y1="10" x2="21" y2="3"/>
|
|
243
|
-
<line x1="3" y1="21" x2="10" y2="14"/>
|
|
252
|
+
<svg width="8" height="8" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
253
|
+
<rect x="2" y="5.25" width="8" height="1.5" rx="0.25" fill="currentColor"/>
|
|
244
254
|
</svg>
|
|
245
255
|
</button>
|
|
246
256
|
</Tooltip>
|
|
@@ -272,11 +282,25 @@
|
|
|
272
282
|
}
|
|
273
283
|
|
|
274
284
|
.pan-zoom-minimize-btn {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
285
|
+
display: flex;
|
|
286
|
+
align-items: center;
|
|
287
|
+
justify-content: center;
|
|
288
|
+
width: 14px;
|
|
289
|
+
height: 14px;
|
|
290
|
+
padding: 0;
|
|
291
|
+
border-radius: 9999px;
|
|
292
|
+
background: #ffffff;
|
|
293
|
+
border: 1px solid #9ca3af;
|
|
294
|
+
color: #9ca3af;
|
|
295
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
|
296
|
+
cursor: pointer;
|
|
297
|
+
transition: color 0.15s ease, border-color 0.15s ease;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.pan-zoom-minimize-btn:hover:not(:disabled) {
|
|
301
|
+
background: #ffffff;
|
|
302
|
+
border-color: #111827;
|
|
303
|
+
color: #111827;
|
|
280
304
|
}
|
|
281
305
|
|
|
282
306
|
.pan-zoom-panel-minimized {
|
|
@@ -342,6 +366,14 @@
|
|
|
342
366
|
cursor: not-allowed;
|
|
343
367
|
}
|
|
344
368
|
|
|
369
|
+
.pan-zoom-panel button:focus,
|
|
370
|
+
.pan-zoom-panel button:focus-visible,
|
|
371
|
+
.pan-zoom-panel input:focus,
|
|
372
|
+
.pan-zoom-panel input:focus-visible {
|
|
373
|
+
outline: none;
|
|
374
|
+
box-shadow: none;
|
|
375
|
+
}
|
|
376
|
+
|
|
345
377
|
/* Keyboard shortcuts button — slightly bigger to ensure the icon is clearly readable */
|
|
346
378
|
.shortcuts-btn {
|
|
347
379
|
width: 36px;
|
|
@@ -370,14 +402,34 @@
|
|
|
370
402
|
background: #f9fafb;
|
|
371
403
|
}
|
|
372
404
|
|
|
405
|
+
.zoom-reset-in-input {
|
|
406
|
+
display: flex;
|
|
407
|
+
align-items: center;
|
|
408
|
+
justify-content: center;
|
|
409
|
+
width: 18px;
|
|
410
|
+
height: 24px;
|
|
411
|
+
padding: 0;
|
|
412
|
+
margin: 0 0 0 2px;
|
|
413
|
+
border: none;
|
|
414
|
+
background: transparent;
|
|
415
|
+
color: #6b7280;
|
|
416
|
+
cursor: pointer;
|
|
417
|
+
flex-shrink: 0;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.zoom-reset-in-input:hover {
|
|
421
|
+
color: #111827;
|
|
422
|
+
background: #f3f4f6;
|
|
423
|
+
}
|
|
424
|
+
|
|
373
425
|
.zoom-input {
|
|
374
|
-
width:
|
|
375
|
-
padding: 4px
|
|
426
|
+
width: 28px;
|
|
427
|
+
padding: 4px 0;
|
|
376
428
|
border: none;
|
|
377
429
|
background: transparent;
|
|
378
430
|
font-size: 12px;
|
|
379
431
|
font-weight: 500;
|
|
380
|
-
text-align:
|
|
432
|
+
text-align: center;
|
|
381
433
|
outline: none;
|
|
382
434
|
}
|
|
383
435
|
|
|
@@ -386,9 +438,13 @@
|
|
|
386
438
|
}
|
|
387
439
|
|
|
388
440
|
.zoom-unit {
|
|
389
|
-
|
|
441
|
+
width: 18px;
|
|
442
|
+
padding: 4px 0;
|
|
443
|
+
margin: 0 2px 0 0;
|
|
390
444
|
font-size: 11px;
|
|
391
445
|
color: #6b7280;
|
|
446
|
+
text-align: center;
|
|
447
|
+
flex-shrink: 0;
|
|
392
448
|
}
|
|
393
449
|
|
|
394
450
|
.save-btn--dirty {
|
|
@@ -122,9 +122,8 @@
|
|
|
122
122
|
$: isFrame = customType === 'frame' || objectType === 'frame';
|
|
123
123
|
// WidgetShape is a Rect subclass with _customType === 'image'
|
|
124
124
|
// isImagePlaceholder: when no image loaded yet (_hasImage is false)
|
|
125
|
-
$: isImagePlaceholder = customType === 'image' && !selectedObject?._hasImage;
|
|
126
|
-
|
|
127
|
-
$: isRect = objectType === 'rect' && !isFrame && customType !== 'image';
|
|
125
|
+
$: isImagePlaceholder = (customType === 'image' || customType === 'video') && !selectedObject?._hasImage;
|
|
126
|
+
$: isRect = objectType === 'rect' && !isFrame && customType !== 'image' && customType !== 'video';
|
|
128
127
|
$: isTextbox = objectType === 'textbox';
|
|
129
128
|
$: isPath = objectType === 'path' && !customType;
|
|
130
129
|
$: isArrow = objectType === 'path' && customType === 'arrow';
|
|
@@ -141,7 +140,7 @@
|
|
|
141
140
|
$: isMultiSelect = (isGroup && !isFrame) || isActiveSelection;
|
|
142
141
|
$: isPlainGroup = isGroup && !isFrame;
|
|
143
142
|
$: isLine = objectType === 'line';
|
|
144
|
-
$: isImage = objectType === 'image' || customType === 'image';
|
|
143
|
+
$: isImage = objectType === 'image' || customType === 'image' || customType === 'video';
|
|
145
144
|
// Widget link: true when the selected object has already been converted to a widget
|
|
146
145
|
$: hasWidgetId = !!selectedObject?._widgetId;
|
|
147
146
|
$: widgetId = selectedObject?._widgetId ?? null;
|
|
@@ -195,6 +194,7 @@
|
|
|
195
194
|
if (obj._customType === 'frame') return 'Frame';
|
|
196
195
|
if (obj._customType === 'arrow') return 'Arrow';
|
|
197
196
|
if (obj._customType === 'triangle') return 'Triangle';
|
|
197
|
+
if (obj._customType === 'video') return 'Video';
|
|
198
198
|
if (obj._customType === 'image' || obj.type === 'image') return 'Image';
|
|
199
199
|
// Library shapes: derive a readable name from _shapeKey (e.g. 'arrow-right' → 'Arrow Right')
|
|
200
200
|
// Fall back to 'Custom Shape (Drawing)' if _shapeKey is missing (old saves).
|
|
@@ -226,7 +226,13 @@
|
|
|
226
226
|
// Get current values from selected object
|
|
227
227
|
// For Frames (Group-based), read fill/stroke from custom properties (_frameFill, _frameStroke)
|
|
228
228
|
// because the actual fill/stroke is on the background Rect child, not the Group itself
|
|
229
|
-
$:
|
|
229
|
+
$: usesPathBackgroundFill = selectedObject?._cornerRadiusMode === 'strokeRound'
|
|
230
|
+
|| ['cross', 'plus', 'minus', 'check'].includes(selectedObject?._shapeKey);
|
|
231
|
+
$: currentFill = isFrame
|
|
232
|
+
? (selectedObject?._frameFill ?? '#FFFFFF')
|
|
233
|
+
: (usesPathBackgroundFill
|
|
234
|
+
? (selectedObject?._iconFill || selectedObject?.backgroundColor || '#FFFFFF')
|
|
235
|
+
: (selectedObject?.fill ?? '#FFFFFF'));
|
|
230
236
|
$: currentFillOpacity = Math.round((selectedObject?._fillOpacity ?? 1) * 100);
|
|
231
237
|
$: currentStroke = isFrame ? (selectedObject?._frameStroke ?? '#000000') : (selectedObject?.stroke ?? '#000000');
|
|
232
238
|
$: currentStrokeWidth = isFrame ? (selectedObject?._frameStrokeWidth ?? 1) : (selectedObject?.strokeWidth ?? 1);
|
|
@@ -1305,6 +1311,16 @@
|
|
|
1305
1311
|
border-radius: 8px;
|
|
1306
1312
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
1307
1313
|
}
|
|
1314
|
+
|
|
1315
|
+
.props-panel-el :global(button:focus),
|
|
1316
|
+
.props-panel-el :global(button:focus-visible),
|
|
1317
|
+
.props-panel-el :global(a:focus),
|
|
1318
|
+
.props-panel-el :global(a:focus-visible),
|
|
1319
|
+
.props-panel-el :global(input:focus),
|
|
1320
|
+
.props-panel-el :global(input:focus-visible) {
|
|
1321
|
+
outline: none !important;
|
|
1322
|
+
box-shadow: none !important;
|
|
1323
|
+
}
|
|
1308
1324
|
|
|
1309
1325
|
/* Sticky header: stays at top, never scrolls */
|
|
1310
1326
|
.props-panel-sticky-header {
|
|
@@ -24,8 +24,17 @@ export declare class ImageShape extends Rect {
|
|
|
24
24
|
_cornerRoundness: number;
|
|
25
25
|
_cornerRoundnessPixels: number;
|
|
26
26
|
_objectId: string | null;
|
|
27
|
+
_videoElement: HTMLVideoElement | null;
|
|
28
|
+
_videoObjectUrl: string | null;
|
|
27
29
|
_render(ctx: CanvasRenderingContext2D): void;
|
|
28
30
|
private _renderImage;
|
|
31
|
+
getVideoPlayBadgeRadius(): number;
|
|
32
|
+
isVideoPlayBadgeHit(scenePoint: {
|
|
33
|
+
x: number;
|
|
34
|
+
y: number;
|
|
35
|
+
}): boolean;
|
|
36
|
+
toggleVideoPlayback(): Promise<boolean>;
|
|
37
|
+
private _renderVideoChrome;
|
|
29
38
|
private _renderPlaceholderOverlay;
|
|
30
39
|
toObject(propertiesToInclude?: any[]): any;
|
|
31
40
|
static fromObject(object: Record<string, unknown>, options?: Record<string, unknown>): Promise<ImageShape>;
|
|
@@ -36,9 +36,12 @@ export class ImageShape extends Rect {
|
|
|
36
36
|
_cornerRoundness = 0;
|
|
37
37
|
_cornerRoundnessPixels = 0;
|
|
38
38
|
_objectId = null;
|
|
39
|
+
_videoElement = null;
|
|
40
|
+
_videoObjectUrl = null;
|
|
39
41
|
// ── Placeholder rendering ─────────────────────────────────────────────────
|
|
40
42
|
_render(ctx) {
|
|
41
|
-
const
|
|
43
|
+
const hasVideoFrame = this._customType === 'video' && !!this._videoElement && this._videoElement.readyState >= 2;
|
|
44
|
+
const hasImage = (this._hasImage && this._imageElement) || hasVideoFrame;
|
|
42
45
|
if (hasImage) {
|
|
43
46
|
this._renderImage(ctx);
|
|
44
47
|
}
|
|
@@ -78,9 +81,12 @@ export class ImageShape extends Rect {
|
|
|
78
81
|
ctx.closePath();
|
|
79
82
|
ctx.clip();
|
|
80
83
|
const img = this._imageElement;
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
const video = this._customType === 'video' ? this._videoElement : null;
|
|
85
|
+
const useVideo = !!(video && video.readyState >= 2);
|
|
86
|
+
const source = useVideo ? video : (img && img.complete && img.naturalWidth > 0 ? img : null);
|
|
87
|
+
const imgReady = !!source;
|
|
88
|
+
if (source) {
|
|
89
|
+
if (this._isWidgetImage && img && img.complete && img.naturalWidth > 0 && !useVideo) {
|
|
84
90
|
const imgW = img.naturalWidth;
|
|
85
91
|
const imgH = img.naturalHeight;
|
|
86
92
|
const scale = Math.min(w / imgW, h / imgH);
|
|
@@ -89,10 +95,13 @@ export class ImageShape extends Rect {
|
|
|
89
95
|
ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh);
|
|
90
96
|
}
|
|
91
97
|
else {
|
|
92
|
-
ctx.drawImage(
|
|
98
|
+
ctx.drawImage(source, -w / 2, -h / 2, w, h);
|
|
93
99
|
}
|
|
94
100
|
}
|
|
95
101
|
ctx.restore();
|
|
102
|
+
if (this._customType === 'video' && imgReady) {
|
|
103
|
+
this._renderVideoChrome(ctx, w, h);
|
|
104
|
+
}
|
|
96
105
|
if (this.stroke && (this.strokeWidth ?? 0) > 0) {
|
|
97
106
|
ctx.save();
|
|
98
107
|
ctx.strokeStyle = this.stroke;
|
|
@@ -126,32 +135,114 @@ export class ImageShape extends Rect {
|
|
|
126
135
|
ctx.restore();
|
|
127
136
|
}
|
|
128
137
|
}
|
|
138
|
+
getVideoPlayBadgeRadius() {
|
|
139
|
+
const w = this.width || 100;
|
|
140
|
+
const h = this.height || 100;
|
|
141
|
+
const size = Math.max(18, Math.min(w, h) * 0.22);
|
|
142
|
+
return (size / 2) * Math.max(this.scaleX || 1, this.scaleY || 1);
|
|
143
|
+
}
|
|
144
|
+
isVideoPlayBadgeHit(scenePoint) {
|
|
145
|
+
if (this._customType !== 'video' || !this._videoElement)
|
|
146
|
+
return false;
|
|
147
|
+
const center = this.getCenterPoint();
|
|
148
|
+
const radius = this.getVideoPlayBadgeRadius();
|
|
149
|
+
const dx = scenePoint.x - center.x;
|
|
150
|
+
const dy = scenePoint.y - center.y;
|
|
151
|
+
return dx * dx + dy * dy <= radius * radius;
|
|
152
|
+
}
|
|
153
|
+
async toggleVideoPlayback() {
|
|
154
|
+
const video = this._videoElement;
|
|
155
|
+
if (!video) {
|
|
156
|
+
canvasWarn('ImageShape: no video element to play');
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
if (video.paused) {
|
|
161
|
+
await video.play();
|
|
162
|
+
canvasLog('ImageShape: video playing');
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
video.pause();
|
|
166
|
+
canvasLog('ImageShape: video paused');
|
|
167
|
+
}
|
|
168
|
+
this.dirty = true;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
canvasWarn('ImageShape: video play/pause failed', err);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
_renderVideoChrome(ctx, w, h) {
|
|
177
|
+
const video = this._videoElement;
|
|
178
|
+
const playing = !!(video && !video.paused && !video.ended);
|
|
179
|
+
const size = Math.max(18, Math.min(w, h) * 0.22);
|
|
180
|
+
ctx.save();
|
|
181
|
+
ctx.beginPath();
|
|
182
|
+
ctx.arc(0, 0, size / 2, 0, Math.PI * 2);
|
|
183
|
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
|
|
184
|
+
ctx.fill();
|
|
185
|
+
ctx.fillStyle = '#ffffff';
|
|
186
|
+
if (playing) {
|
|
187
|
+
const barW = size * 0.08;
|
|
188
|
+
const barH = size * 0.28;
|
|
189
|
+
const gap = size * 0.08;
|
|
190
|
+
ctx.fillRect(-gap / 2 - barW, -barH / 2, barW, barH);
|
|
191
|
+
ctx.fillRect(gap / 2, -barH / 2, barW, barH);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
ctx.beginPath();
|
|
195
|
+
const tri = size * 0.18;
|
|
196
|
+
ctx.moveTo(-tri * 0.35, -tri);
|
|
197
|
+
ctx.lineTo(tri * 0.85, 0);
|
|
198
|
+
ctx.lineTo(-tri * 0.35, tri);
|
|
199
|
+
ctx.closePath();
|
|
200
|
+
ctx.fill();
|
|
201
|
+
}
|
|
202
|
+
if (video && Number.isFinite(video.duration) && video.duration > 0) {
|
|
203
|
+
const barH = Math.max(4, Math.min(8, h * 0.06));
|
|
204
|
+
const progress = Math.max(0, Math.min(1, video.currentTime / video.duration));
|
|
205
|
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
|
|
206
|
+
ctx.fillRect(-w / 2, h / 2 - barH, w, barH);
|
|
207
|
+
ctx.fillStyle = '#ffffff';
|
|
208
|
+
ctx.fillRect(-w / 2, h / 2 - barH, w * progress, barH);
|
|
209
|
+
}
|
|
210
|
+
ctx.restore();
|
|
211
|
+
}
|
|
129
212
|
// ── Placeholder icon + text overlay ──────────────────────────────────────
|
|
130
213
|
_renderPlaceholderOverlay(ctx) {
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
214
|
+
const scaleX = Math.abs(this.scaleX || 1) || 1;
|
|
215
|
+
const scaleY = Math.abs(this.scaleY || 1) || 1;
|
|
216
|
+
ctx.save();
|
|
217
|
+
ctx.scale(1 / scaleX, 1 / scaleY);
|
|
218
|
+
const visualW = Math.abs((this.width || 100) * scaleX);
|
|
219
|
+
const visualH = Math.abs((this.height || 100) * scaleY);
|
|
220
|
+
const iconSize = 36;
|
|
221
|
+
const fontSize = 16;
|
|
222
|
+
const lineGap = fontSize + 6;
|
|
223
|
+
const contentWidth = 168;
|
|
224
|
+
const contentHeight = iconSize + 12 + lineGap * 2;
|
|
225
|
+
const contentScale = Math.max(0.7, Math.min((visualW * 0.88) / contentWidth, (visualH * 0.7) / contentHeight));
|
|
135
226
|
ctx.fillStyle = '#94a3b8';
|
|
136
227
|
ctx.textAlign = 'center';
|
|
137
228
|
ctx.textBaseline = 'middle';
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
229
|
+
ctx.save();
|
|
230
|
+
ctx.scale(contentScale, contentScale);
|
|
231
|
+
ctx.font = `${iconSize}px Arial`;
|
|
232
|
+
ctx.fillText(this._customType === 'video' ? '🎬' : '🖼️', 0, -28);
|
|
233
|
+
ctx.font = `${fontSize}px Arial`;
|
|
234
|
+
if (this._customType === 'video') {
|
|
235
|
+
ctx.fillText('Drop video', 0, 8);
|
|
236
|
+
ctx.fillText('or', 0, 8 + lineGap);
|
|
237
|
+
ctx.fillText('double click', 0, 8 + lineGap * 2);
|
|
142
238
|
}
|
|
143
239
|
else {
|
|
144
|
-
ctx.
|
|
145
|
-
ctx.
|
|
146
|
-
ctx.
|
|
147
|
-
ctx.fillText('🖼️', 0, -28);
|
|
148
|
-
const fontSize = 12;
|
|
149
|
-
ctx.font = `${fontSize}px Arial`;
|
|
150
|
-
ctx.fillText('Drop image', 0, 0);
|
|
151
|
-
ctx.fillText('or', 0, fontSize + 4);
|
|
152
|
-
ctx.fillText('double click', 0, (fontSize + 4) * 2);
|
|
153
|
-
ctx.restore();
|
|
240
|
+
ctx.fillText('Drop image', 0, 8);
|
|
241
|
+
ctx.fillText('or', 0, 8 + lineGap);
|
|
242
|
+
ctx.fillText('double click', 0, 8 + lineGap * 2);
|
|
154
243
|
}
|
|
244
|
+
ctx.restore();
|
|
245
|
+
ctx.restore();
|
|
155
246
|
}
|
|
156
247
|
// ── Serialisation ─────────────────────────────────────────────────────────
|
|
157
248
|
toObject(propertiesToInclude) {
|
|
@@ -225,7 +316,7 @@ classRegistry.setClass(ImageShape);
|
|
|
225
316
|
* swapping the prototype chain. No object re-creation needed.
|
|
226
317
|
*/
|
|
227
318
|
export function upgradeToImageShape(obj) {
|
|
228
|
-
if (!obj || obj._customType !== 'image')
|
|
319
|
+
if (!obj || (obj._customType !== 'image' && obj._customType !== 'video'))
|
|
229
320
|
return null;
|
|
230
321
|
if (obj instanceof ImageShape)
|
|
231
322
|
return obj;
|
|
@@ -25,6 +25,14 @@
|
|
|
25
25
|
|
|
26
26
|
let triggerEl: HTMLElement | null = null;
|
|
27
27
|
|
|
28
|
+
$: if (text) refreshVisibleTooltip();
|
|
29
|
+
|
|
30
|
+
function refreshVisibleTooltip() {
|
|
31
|
+
if (tooltipBodyEl && tooltipBodyEl.style.display === 'block') {
|
|
32
|
+
show();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
// Lazily-created body-level elements
|
|
29
37
|
let tooltipBodyEl: HTMLElement | null = null;
|
|
30
38
|
let arrowBodyEl: HTMLElement | null = null;
|
|
@@ -48,8 +56,8 @@
|
|
|
48
56
|
lineHeight: '1.4',
|
|
49
57
|
padding: '5px 10px',
|
|
50
58
|
borderRadius: '6px',
|
|
51
|
-
whiteSpace: '
|
|
52
|
-
maxWidth: '
|
|
59
|
+
whiteSpace: 'nowrap',
|
|
60
|
+
maxWidth: 'none',
|
|
53
61
|
textAlign: 'center',
|
|
54
62
|
boxShadow: '0 2px 8px rgba(0,0,0,0.25)',
|
|
55
63
|
userSelect: 'none',
|