@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.
@@ -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({ strokeLineCap: 'square', strokeLineJoin: 'miter' });
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
- if (!file.type.startsWith('image/')) {
5251
- canvasLog('Skipped non-image file:', file.name);
5333
+ const isVideo = file.type.startsWith('video/');
5334
+ if (!file.type.startsWith('image/') && !isVideo) {
5335
+ canvasLog('Skipped non-media file:', file.name);
5336
+ return;
5337
+ }
5338
+
5339
+ if (isVideo) {
5340
+ const { video, objectUrl } = createVideoElementFromFile(file);
5341
+ capturePosterFromVideo(video).then((posterUrl) => {
5342
+ const maxWidth = canvas.width * 0.6;
5343
+ const maxHeight = canvas.height * 0.6;
5344
+ const tempImg = new Image();
5345
+ tempImg.onload = () => {
5346
+ let width = tempImg.width;
5347
+ let height = tempImg.height;
5348
+ if (width > maxWidth || height > maxHeight) {
5349
+ const scale = Math.min(maxWidth / width, maxHeight / height);
5350
+ width *= scale;
5351
+ height *= scale;
5352
+ }
5353
+ const videoShape = createImagePlaceholder({
5354
+ left: dropX - width / 2,
5355
+ top: dropY - height / 2,
5356
+ width,
5357
+ height,
5358
+ mediaKind: 'video'
5359
+ });
5360
+ canvas.add(videoShape);
5361
+ canvas.setActiveObject(videoShape);
5362
+ attachVideoElementToShape(videoShape, video, objectUrl);
5363
+ loadImageIntoShape(videoShape, posterUrl, file.name);
5364
+ canvasLog('Video dropped on canvas:', file.name);
5365
+ };
5366
+ tempImg.src = posterUrl;
5367
+ }).catch((err) => {
5368
+ console.error('Failed to load dropped video:', err);
5369
+ });
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
- setActiveTool('image', () => {
7748
+ startMediaPlaceholderTool('image');
7749
+ }
7750
+
7751
+ /** Video tool — same draw-to-place flow as Image, with a video placeholder and upload filter. */
7752
+ export function onSelectVideoTool() {
7753
+ startMediaPlaceholderTool('video');
7754
+ }
7755
+
7756
+ function startMediaPlaceholderTool(kind: 'image' | 'video') {
7757
+ setActiveTool(kind, () => {
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 || !file.type.startsWith('image/')) {
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
- // If we have fill opacity < 1, convert to rgba
9007
- if (fillOpacity < 1) {
9008
- const rgbaColor = colorToRgba(clr, fillOpacity);
9009
- obj.set({ fill: rgbaColor });
9010
- } else {
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
- // For shapes: apply color at full opacity for uniformity
9049
- obj.set({ fill: color });
9275
+ if (paintsFillOnStroke(obj)) {
9276
+ obj._iconFill = color;
9277
+ obj.set({ fill: '', backgroundColor: color });
9278
+ } else {
9279
+ obj.set({ fill: color });
9280
+ }
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, frame, image, shape library). Code stays; UI is gated. */
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 dropdownTools: DropdownToolName[] = ['rect', 'circle', 'circle-true', 'triangle', 'image', 'library-shape'];
141
- const dropdownToolsWithoutImage = dropdownTools.filter(t => t !== 'image');
142
- $: activeDropdownTool = dropdownToolsWithoutImage.includes(activeTool as DropdownToolName)
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 = dropdownToolsWithoutImage.includes(activeTool as DropdownToolName);
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': if (advancedFeatures) { event.preventDefault(); selectShape('rect'); } break;
392
- case 'G': if (advancedFeatures) { event.preventDefault(); selectShape('triangle'); } break;
393
- case 'O': if (advancedFeatures) { event.preventDefault(); selectShape('circle'); } break;
394
- case 'F': if (advancedFeatures) { event.preventDefault(); onSelectFrameTool(); } break;
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': if (advancedFeatures) { event.preventDefault(); onSelectImageTool(); } break;
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 advancedFeatures}
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
- <!-- Shapes: tldraw-style split — left activates tool, right opens dropdown -->
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 && advancedFeatures}
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 12px;
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
- outline: none;
931
- box-shadow: none;
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, frame, image, shape library). Code stays; UI is gated. */ advancedFeatures?: boolean;
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
- {#if showSaveButton}
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 zoom" position="above">
245
+ <Tooltip text="Minimize Zoom Panel" position="above">
233
246
  <button
234
247
  type="button"
235
- class="panel-btn pan-zoom-minimize-btn"
248
+ class="pan-zoom-minimize-btn"
236
249
  onclick={toggleMinimized}
237
- aria-label="Minimize zoom controls"
250
+ aria-label="Minimize Zoom Panel"
238
251
  >
239
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
240
- <polyline points="4 14 10 14 10 20"/>
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
- width: 18px;
276
- height: 18px;
277
- background: white;
278
- border: 1px solid #e5e7eb;
279
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
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: 36px;
375
- padding: 4px 6px;
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: right;
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
- padding: 4px 6px 4px 0;
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
- // isRect excludes WidgetShape (which has _customType === 'image')
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
- $: currentFill = isFrame ? (selectedObject?._frameFill ?? '#FFFFFF') : (selectedObject?.fill ?? '#FFFFFF');
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 hasImage = this._hasImage && this._imageElement;
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 imgReady = img.complete && img.naturalWidth > 0;
82
- if (imgReady) {
83
- if (this._isWidgetImage) {
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(img, -w / 2, -h / 2, w, h);
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 availableW = this.width || 100;
132
- const availableH = this.height || 100;
133
- const contentHeight = 76;
134
- const contentScale = Math.min(1, availableH / contentHeight, availableW / 100);
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
- if (contentScale < 0.3) {
139
- const tinyIconSize = Math.max(8, 24 * contentScale);
140
- ctx.font = `${tinyIconSize}px Arial`;
141
- ctx.fillText('🖼️', 0, 0);
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.save();
145
- ctx.scale(contentScale, contentScale);
146
- ctx.font = '24px Arial';
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: 'normal',
52
- maxWidth: '200px',
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',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@widgetic/canvas",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "type": "module",
5
5
  "svelte": "./dist/index.js",
6
6
  "module": "./dist/index.js",