@ngenux/ngage-whiteboarding 1.0.3 → 1.0.5

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/README.md CHANGED
@@ -190,100 +190,6 @@ function CollaborativeApp() {
190
190
  }
191
191
  ```
192
192
 
193
- ### Transparent Background Configuration
194
-
195
- If you want to enforce a transparent background and prevent users from changing it:
196
-
197
- ```tsx
198
- import { WhiteboardProvider, Whiteboard } from '@ngenux/ngage-whiteboarding';
199
-
200
- function TransparentWhiteboard() {
201
- return (
202
- <WhiteboardProvider webSocketUrl="ws://localhost:3001">
203
- <Whiteboard
204
- roomId="transparent-room"
205
- userId="user-123"
206
- isAdmin={true}
207
- allowedUsers={['user-123']}
208
- transparentBackground={true} // Forces transparent & locks background
209
- />
210
- </WhiteboardProvider>
211
- );
212
- }
213
- ```
214
-
215
- **Behavior:**
216
- - When `transparentBackground={true}`:
217
- - ✅ Background is automatically set to transparent
218
- - 🔒 Background color picker is disabled and shows "Transparent background locked"
219
- - Perfect for overlaying the whiteboard on existing content
220
-
221
- - When `transparentBackground={false}` (default):
222
- - ✅ Background defaults to white
223
- - 🎨 Users can change background color freely
224
-
225
- ### Video Background for Annotation
226
-
227
- Annotate over live video streams (screen-share, webcam, etc.):
228
-
229
- ```tsx
230
- import { WhiteboardProvider, Whiteboard } from '@ngenux/ngage-whiteboarding';
231
- import { useEffect, useState } from 'react';
232
-
233
- function VideoAnnotationWhiteboard() {
234
- const [videoStream, setVideoStream] = useState<MediaStream | undefined>();
235
-
236
- // Get screen share or webcam stream
237
- useEffect(() => {
238
- async function getStream() {
239
- try {
240
- // Screen share example
241
- const stream = await navigator.mediaDevices.getDisplayMedia({
242
- video: { width: 1920, height: 1080 }
243
- });
244
- setVideoStream(stream);
245
- } catch (err) {
246
- console.error('Failed to get video stream:', err);
247
- }
248
- }
249
- getStream();
250
-
251
- return () => {
252
- // Cleanup: stop all tracks when component unmounts
253
- if (videoStream) {
254
- videoStream.getTracks().forEach(track => track.stop());
255
- }
256
- };
257
- }, []);
258
-
259
- return (
260
- <WhiteboardProvider webSocketUrl="ws://localhost:3001">
261
- <Whiteboard
262
- roomId="video-annotation-room"
263
- userId="user-123"
264
- isAdmin={true}
265
- allowedUsers={['user-123']}
266
- videoStream={videoStream} // Video renders as background layer
267
- />
268
- </WhiteboardProvider>
269
- );
270
- }
271
- ```
272
-
273
- **Features:**
274
- - 🎥 **Video as background**: Stream renders behind whiteboard canvas
275
- - 🖊️ **Draw over video**: All drawing tools work normally over the video
276
- - 🔒 **Auto-transparent**: Background automatically set to transparent
277
- - ⚡ **60fps performance**: HTML video layer, no canvas copying
278
- - 📐 **Auto-scaling**: Video maintains aspect ratio with letterbox
279
- - 🎯 **Pointer-events handled**: Video doesn't interfere with drawing
280
-
281
- **Use cases:**
282
- - Screen-share annotation for remote presentations
283
- - Video call annotation
284
- - Educational content markup
285
- - Live streaming annotation
286
-
287
193
  ## 📝 API Reference
288
194
 
289
195
  ### WhiteboardProvider Props
package/dist/index.esm.js CHANGED
@@ -36710,9 +36710,27 @@ const BoardComponent = forwardRef(({ roomId = 'default-room', queueAction, hasTo
36710
36710
  const activeShapes = Object.values(state.activeDrawings);
36711
36711
  return activeShapes.map(shape => (jsx(MemoizedShapeComponent, { shape: shape }, `active-${shape.id}`)));
36712
36712
  }, [state.activeDrawings]);
36713
+ const getStageDataURL = useCallback((format = 'png') => {
36714
+ if (!stageRef.current) {
36715
+ return '';
36716
+ }
36717
+ try {
36718
+ const stage = stageRef.current;
36719
+ return stage.toDataURL({
36720
+ mimeType: format === 'png' ? 'image/png' : 'image/jpeg',
36721
+ quality: 1,
36722
+ pixelRatio: 2,
36723
+ });
36724
+ }
36725
+ catch (error) {
36726
+ console.error('Failed to get stage data URL:', error);
36727
+ return '';
36728
+ }
36729
+ }, []);
36713
36730
  useImperativeHandle(ref, () => ({
36714
36731
  exportAsImage,
36715
36732
  exportAsPDF,
36733
+ getStageDataURL,
36716
36734
  }));
36717
36735
  // Memoized cursor style for performance
36718
36736
  const cursorStyle = useMemo(() => ({
@@ -37033,6 +37051,14 @@ const Undo2 = createLucideIcon("Undo2", [
37033
37051
  const TopToolbar = ({ queueAction, handleExportImage, handleClear, handleLockToggle, isAdmin = false, hasToolAccess = false, isGloballyUnlocked = false, shouldBeOpenByDefault = true, hasVideoBackground = false }) => {
37034
37052
  const { state, dispatch } = useWhiteboard();
37035
37053
  const [isVisible, setIsVisible] = useState(shouldBeOpenByDefault);
37054
+ const [isInitialized, setIsInitialized] = useState(false);
37055
+ // Wait for context to be fully initialized before rendering
37056
+ // Check that state.userId is set to ensure context is fully ready
37057
+ useEffect(() => {
37058
+ if (state && state.userId) {
37059
+ setIsInitialized(true);
37060
+ }
37061
+ }, [state]);
37036
37062
  const handleToggleVisibility = () => {
37037
37063
  setIsVisible(!isVisible);
37038
37064
  };
@@ -37094,6 +37120,10 @@ const TopToolbar = ({ queueAction, handleExportImage, handleClear, handleLockTog
37094
37120
  }
37095
37121
  }
37096
37122
  };
37123
+ // Don't render until fully initialized to prevent race conditions
37124
+ if (!isInitialized) {
37125
+ return null;
37126
+ }
37097
37127
  return (jsxs(Fragment, { children: [jsxs("div", { className: "absolute top-5 left-1/2 transform -translate-x-1/2 flex flex-col items-center z-10", children: [!isVisible && (jsx("button", { className: "w-10 h-10 flex items-center justify-center bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300", onClick: handleToggleVisibility, title: "Show Tools", children: jsx(ChevronDown, { size: 16, className: "text-current" }) })), isVisible && (jsx("div", { className: "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg", children: jsxs("div", { className: "flex items-center gap-1 p-1", children: [isAdmin && (jsx("button", { className: `w-10 h-10 flex items-center justify-center rounded transition-colors ${isGloballyUnlocked
37098
37128
  ? 'bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/70'
37099
37129
  : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}`, onClick: handleLockToggle, title: isGloballyUnlocked ? 'Whiteboard unlocked for all users - Click to lock' : 'Whiteboard locked - Click to unlock for all users', children: isGloballyUnlocked ? jsx(LockOpen, { size: 16, className: "text-current" }) : jsx(Lock, { size: 16, className: "text-current" }) })), jsx("button", { className: `w-10 h-10 flex items-center justify-center rounded ${hasToolAccess ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300' : 'opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600'}`, onClick: handleUndo, disabled: !hasToolAccess, title: hasToolAccess ? 'Undo' : 'Access restricted', children: jsx(Undo2, { size: 16, className: "text-current" }) }), jsx("button", { className: `w-10 h-10 flex items-center justify-center rounded ${hasToolAccess ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300' : 'opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600'}`, onClick: handleRedo, disabled: !hasToolAccess, title: hasToolAccess ? 'Redo' : 'Access restricted', children: jsx(Redo2, { size: 16, className: "text-current" }) }), jsx("div", { className: "w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" }), tools.map((tool) => (jsx("button", { className: `w-10 h-10 flex items-center justify-center rounded transition-colors ${!hasToolAccess
@@ -37111,6 +37141,14 @@ const LeftSidebar = ({ queueAction, hasToolAccess = false, shouldBeOpenByDefault
37111
37141
  const [position, setPosition] = useState({ x: 20, y: 80 });
37112
37142
  const [hasEverHadAccess, setHasEverHadAccess] = useState(hasToolAccess);
37113
37143
  const [wasManuallyClosedAfterAccess, setWasManuallyClosedAfterAccess] = useState(false);
37144
+ const [isInitialized, setIsInitialized] = useState(false);
37145
+ // Wait for context to be fully initialized before rendering
37146
+ // Check that state.userId is set to ensure context is fully ready
37147
+ useEffect(() => {
37148
+ if (state && state.userId) {
37149
+ setIsInitialized(true);
37150
+ }
37151
+ }, [state]);
37114
37152
  // Set white as default stroke color when video background is active
37115
37153
  useEffect(() => {
37116
37154
  if (hasVideoBackground && state.color === '#000000') {
@@ -37231,6 +37269,10 @@ const LeftSidebar = ({ queueAction, hasToolAccess = false, shouldBeOpenByDefault
37231
37269
  // Only update local state - don't broadcast opacity changes to other users
37232
37270
  dispatch({ type: 'SET_OPACITY', payload: opacity });
37233
37271
  };
37272
+ // Don't render until fully initialized to prevent race conditions
37273
+ if (!isInitialized) {
37274
+ return null;
37275
+ }
37234
37276
  if (!isVisible) {
37235
37277
  // Only show the sidebar toggle button if user has tool access
37236
37278
  if (!hasToolAccess) {
@@ -37244,16 +37286,20 @@ const LeftSidebar = ({ queueAction, hasToolAccess = false, shouldBeOpenByDefault
37244
37286
  width: '250px',
37245
37287
  maxHeight: '80vh',
37246
37288
  cursor: isDragging ? 'grabbing' : 'grab'
37247
- }, onMouseDown: handleMouseDown, children: [jsxs("div", { className: "flex items-center justify-between p-2 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-t-lg", children: [jsxs("div", { className: "flex items-center gap-2", children: [jsx("span", { className: "text-sm font-medium text-gray-700 dark:text-gray-200", children: "Style Panel" }), !hasToolAccess && (jsx("span", { className: "text-xs bg-orange-100 dark:bg-orange-900 text-orange-600 dark:text-orange-300 px-2 py-1 rounded", children: "Locked" }))] }), jsx("button", { className: "close-button p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded", onClick: handleManualClose, onMouseDown: (e) => e.stopPropagation(), title: "Close Panel", children: jsx(ChevronLeft, { size: 14, className: "text-gray-600 dark:text-gray-300" }) })] }), jsxs("div", { className: "sidebar-content p-3 overflow-y-auto", style: { maxHeight: 'calc(80vh - 40px)' }, children: [!hasToolAccess && (jsxs("div", { className: "mb-4 p-3 bg-orange-50 dark:bg-orange-900/30 border border-orange-200 dark:border-orange-800 rounded-lg text-center", children: [jsx("div", { className: "text-orange-700 dark:text-orange-300 text-sm font-medium mb-1", children: "\uD83D\uDD12 Tools Locked" }), jsx("div", { className: "text-orange-600 dark:text-orange-400 text-xs", children: "Contact admin for access" })] })), jsxs("div", { className: "mb-4", children: [jsx("div", { className: "text-xs font-medium text-gray-700 dark:text-gray-200 mb-2", children: "Stroke Color" }), jsx("div", { className: "grid grid-cols-6 gap-1", children: strokeColors.map((color) => (jsx("button", { className: `w-6 h-6 rounded border-2 transition-all ${!hasToolAccess
37248
- ? 'opacity-50 cursor-not-allowed border-gray-200 dark:border-gray-700'
37289
+ }, onMouseDown: handleMouseDown, children: [jsxs("div", { className: "flex items-center justify-between p-2 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-t-lg", children: [jsxs("div", { className: "flex items-center gap-2", children: [jsx("span", { className: "text-sm font-medium text-gray-700 dark:text-gray-200", children: "Style Panel" }), !hasToolAccess && (jsx("span", { className: "text-xs bg-orange-100 dark:bg-orange-900 text-orange-600 dark:text-orange-300 px-2 py-1 rounded", children: "Locked" }))] }), jsx("button", { className: "close-button p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded", onClick: handleManualClose, onMouseDown: (e) => e.stopPropagation(), title: "Close Panel", children: jsx(ChevronLeft, { size: 14, className: "text-gray-600 dark:text-gray-300" }) })] }), jsxs("div", { className: "sidebar-content p-3 overflow-y-auto", style: { maxHeight: 'calc(80vh - 40px)' }, children: [!hasToolAccess && (jsxs("div", { className: "mb-4 p-3 bg-orange-50 dark:bg-orange-900/30 border border-orange-200 dark:border-orange-800 rounded-lg text-center", children: [jsx("div", { className: "text-orange-700 dark:text-orange-300 text-sm font-medium mb-1", children: "\uD83D\uDD12 Tools Locked" }), jsx("div", { className: "text-orange-600 dark:text-orange-400 text-xs", children: "Contact admin for access" })] })), jsxs("div", { className: "mb-4", children: [jsx("div", { className: "text-xs font-medium text-gray-700 dark:text-gray-200 mb-2", children: "Stroke Color" }), jsx("div", { className: "grid grid-cols-6 gap-1", children: strokeColors.map((color) => (jsx("button", { className: `w-6 h-6 rounded transition-all relative ${!hasToolAccess
37290
+ ? 'opacity-50 cursor-not-allowed border-2 border-gray-200 dark:border-gray-700'
37249
37291
  : state.color === color
37250
- ? 'border-purple-400 dark:border-purple-500 scale-110'
37292
+ ? 'border-[3px] border-yellow-400 dark:border-yellow-300 scale-125 shadow-lg'
37251
37293
  : color === '#FFFFFF'
37252
- ? 'border-gray-400 dark:border-gray-500 hover:border-gray-500 dark:hover:border-gray-400'
37253
- : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'}`, style: {
37294
+ ? 'border-2 border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
37295
+ : 'border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'}`, style: {
37254
37296
  backgroundColor: color,
37255
- boxShadow: color === '#FFFFFF' ? 'inset 0 0 0 1px rgba(0,0,0,0.1)' : 'none'
37256
- }, onClick: () => handleColorChange(color), disabled: !hasToolAccess, title: hasToolAccess ? (color === '#FFFFFF' ? 'White' : color) : 'Access restricted' }, color))) })] }), jsxs("div", { className: "mb-4", children: [jsx("div", { className: "text-xs font-medium text-gray-700 dark:text-gray-200 mb-2", children: "Background" }), disableBackgroundChange && (jsx("div", { className: "p-3 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg text-center", children: jsx("div", { className: "text-blue-700 dark:text-blue-300 text-xs", title: "Disabled when video background is active", children: "\uD83D\uDD12 Background selection locked" }) })), !disableBackgroundChange && (jsx("div", { className: "grid grid-cols-6 gap-1", children: backgroundColors.map((color) => (jsx("button", { className: `w-6 h-6 rounded border-2 transition-all relative ${!hasToolAccess
37297
+ boxShadow: color === '#FFFFFF'
37298
+ ? 'inset 0 0 0 1px rgba(0,0,0,0.15)'
37299
+ : state.color === color
37300
+ ? '0 4px 12px rgba(234, 179, 8, 0.5), 0 0 0 2px rgba(234, 179, 8, 0.3)'
37301
+ : 'none'
37302
+ }, onClick: () => handleColorChange(color), disabled: !hasToolAccess, title: hasToolAccess ? (color === '#FFFFFF' ? 'White' : color) : 'Access restricted', children: state.color === color && (jsx("div", { className: "absolute inset-0 flex items-center justify-center", children: jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", stroke: color === '#FFFFFF' || color === '#EA580C' || color === '#DC2626' ? '#000000' : '#FFFFFF', strokeWidth: "3", strokeLinecap: "round", strokeLinejoin: "round", children: jsx("polyline", { points: "20 6 9 17 4 12" }) }) })) }, color))) })] }), jsxs("div", { className: "mb-4", children: [jsx("div", { className: "text-xs font-medium text-gray-700 dark:text-gray-200 mb-2", children: "Background" }), disableBackgroundChange && (jsx("div", { className: "p-3 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg text-center", children: jsx("div", { className: "text-blue-700 dark:text-blue-300 text-xs", title: "Disabled when video background is active", children: "\uD83D\uDD12 Background selection locked" }) })), !disableBackgroundChange && (jsx("div", { className: "grid grid-cols-6 gap-1", children: backgroundColors.map((color) => (jsx("button", { className: `w-6 h-6 rounded border-2 transition-all relative ${!hasToolAccess
37257
37303
  ? 'opacity-50 cursor-not-allowed border-gray-200 dark:border-gray-700'
37258
37304
  : state.backgroundColor === color
37259
37305
  ? 'border-blue-400 dark:border-blue-500 scale-110'
@@ -42435,10 +42481,57 @@ const Whiteboard = ({ roomId, isAdmin = false, allowedUsers = [], userId, transp
42435
42481
  }, [state.activeDrawings, state.lastClearTimestamp, dispatch]);
42436
42482
  // Export functions to pass to toolbar
42437
42483
  const handleExportImage = useCallback((format) => {
42438
- if (boardRef.current) {
42484
+ if (!boardRef.current) {
42485
+ return;
42486
+ }
42487
+ // If video is active, capture both video and canvas
42488
+ if (videoStream && videoRef.current) {
42489
+ const video = videoRef.current;
42490
+ const canvas = document.createElement('canvas');
42491
+ const ctx = canvas.getContext('2d');
42492
+ if (!ctx)
42493
+ return;
42494
+ // Set canvas size to match the whiteboard
42495
+ canvas.width = video.videoWidth || video.offsetWidth;
42496
+ canvas.height = video.videoHeight || video.offsetHeight;
42497
+ try {
42498
+ // Draw the video frame
42499
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
42500
+ // Get the Konva stage data URL
42501
+ const stageDataURL = boardRef.current.getStageDataURL(format);
42502
+ // Create an image from the stage
42503
+ const stageImage = new Image();
42504
+ stageImage.onload = () => {
42505
+ // Draw the annotations on top of the video
42506
+ ctx.drawImage(stageImage, 0, 0, canvas.width, canvas.height);
42507
+ // Convert to blob and download
42508
+ canvas.toBlob((blob) => {
42509
+ if (blob) {
42510
+ const timestamp = new Date().toISOString().slice(0, 16).replace('T', '_').replace(/:/g, '-');
42511
+ const filename = `whiteboard_video_annotation_${timestamp}.${format}`;
42512
+ const link = document.createElement('a');
42513
+ link.download = filename;
42514
+ link.href = URL.createObjectURL(blob);
42515
+ document.body.appendChild(link);
42516
+ link.click();
42517
+ document.body.removeChild(link);
42518
+ URL.revokeObjectURL(link.href);
42519
+ }
42520
+ }, format === 'png' ? 'image/png' : 'image/jpeg', 1.0);
42521
+ };
42522
+ stageImage.src = stageDataURL;
42523
+ }
42524
+ catch (error) {
42525
+ console.error('Failed to export video with annotations:', error);
42526
+ // Fallback to regular export
42527
+ boardRef.current.exportAsImage(format);
42528
+ }
42529
+ }
42530
+ else {
42531
+ // No video - regular export
42439
42532
  boardRef.current.exportAsImage(format);
42440
42533
  }
42441
- }, []);
42534
+ }, [videoStream]);
42442
42535
  const handleExportPDF = useCallback(() => {
42443
42536
  if (boardRef.current) {
42444
42537
  boardRef.current.exportAsPDF();