@ngenux/ngage-whiteboarding 1.0.5 → 1.0.7

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/index.js CHANGED
@@ -35923,7 +35923,7 @@ const Arrow = React.memo(({ shapeProps, isSelected, onSelect, onUpdate, }) => {
35923
35923
  });
35924
35924
 
35925
35925
  const ErasedShape = ({ shapeProps, isSelected, onSelect, onUpdate, }) => {
35926
- const [imageElement, setImageElement] = React.useState(null);
35926
+ const [bitmap, setBitmap] = React.useState(null);
35927
35927
  const canvasRef = React.useRef(null);
35928
35928
  // Memoize bounds calculation to avoid unnecessary recalculations
35929
35929
  const bounds = React.useMemo(() => calculateShapeBounds(shapeProps), [shapeProps.points, shapeProps.strokeWidth, shapeProps.erasePaths]);
@@ -35953,22 +35953,24 @@ const ErasedShape = ({ shapeProps, isSelected, onSelect, onUpdate, }) => {
35953
35953
  });
35954
35954
  }
35955
35955
  ctx.restore();
35956
- // Convert canvas to image
35957
- const img = new window.Image();
35958
- img.onload = () => {
35959
- setImageElement(img);
35960
- };
35961
- img.onerror = () => {
35962
- console.error('[ErasedShape] Failed to create image from canvas');
35963
- setImageElement(null);
35964
- };
35965
- img.src = canvas.toDataURL();
35956
+ // Use canvas directly as Konva image - no async
35957
+ setBitmap(canvas);
35966
35958
  return () => {
35967
35959
  if (canvasRef.current) {
35968
35960
  canvasRef.current.remove();
35961
+ canvasRef.current = null;
35969
35962
  }
35970
35963
  };
35971
- }, [shapeProps, bounds]); // Re-render when shape or bounds change
35964
+ }, [
35965
+ bounds,
35966
+ shapeProps.points,
35967
+ shapeProps.erasePaths,
35968
+ shapeProps.stroke,
35969
+ shapeProps.strokeWidth,
35970
+ shapeProps.opacity,
35971
+ shapeProps.strokeStyle,
35972
+ shapeProps.type
35973
+ ]);
35972
35974
  // Calculate bounds of the shape including erase paths
35973
35975
  function calculateShapeBounds(shape) {
35974
35976
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -36087,11 +36089,48 @@ const ErasedShape = ({ shapeProps, isSelected, onSelect, onUpdate, }) => {
36087
36089
  ctx.stroke();
36088
36090
  }
36089
36091
  };
36090
- if (!imageElement) {
36091
- return null; // Still rendering
36092
- }
36093
36092
  const padding = 50;
36094
- return (jsxRuntime.jsx(Image$1, { image: imageElement, x: bounds.minX - padding, y: bounds.minY - padding, draggable: isSelected, onClick: onSelect, onTap: onSelect, onDragEnd: (e) => {
36093
+ // Render original shape as fallback while bitmap is being created
36094
+ if (!bitmap) {
36095
+ const commonProps = {
36096
+ onClick: onSelect,
36097
+ onTap: onSelect,
36098
+ listening: true,
36099
+ stroke: shapeProps.stroke,
36100
+ strokeWidth: shapeProps.strokeWidth,
36101
+ opacity: shapeProps.opacity,
36102
+ dash: shapeProps.strokeStyle === 'dashed' ? [5, 5] : shapeProps.strokeStyle === 'dotted' ? [2, 3] : undefined,
36103
+ };
36104
+ switch (shapeProps.type) {
36105
+ case 'pencil':
36106
+ return (jsxRuntime.jsx(Line$1, { points: shapeProps.points.flatMap(p => [p.x, p.y]), lineCap: "round", lineJoin: "round", ...commonProps }));
36107
+ case 'line':
36108
+ if (shapeProps.points.length >= 2) {
36109
+ return (jsxRuntime.jsx(Line$1, { points: [
36110
+ shapeProps.points[0].x,
36111
+ shapeProps.points[0].y,
36112
+ shapeProps.points[1].x,
36113
+ shapeProps.points[1].y,
36114
+ ], ...commonProps }));
36115
+ }
36116
+ return null;
36117
+ case 'rectangle':
36118
+ if (shapeProps.points.length >= 2) {
36119
+ const [start, end] = shapeProps.points;
36120
+ return (jsxRuntime.jsx(Rect, { x: start.x, y: start.y, width: end.x - start.x, height: end.y - start.y, ...commonProps }));
36121
+ }
36122
+ return null;
36123
+ case 'ellipse':
36124
+ if (shapeProps.points.length >= 2) {
36125
+ const [start, end] = shapeProps.points;
36126
+ return (jsxRuntime.jsx(Ellipse$1, { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2, radiusX: Math.abs(end.x - start.x) / 2, radiusY: Math.abs(end.y - start.y) / 2, ...commonProps }));
36127
+ }
36128
+ return null;
36129
+ default:
36130
+ return null;
36131
+ }
36132
+ }
36133
+ return (jsxRuntime.jsx(Image$1, { image: bitmap, x: bounds.minX - padding, y: bounds.minY - padding, draggable: isSelected, onClick: onSelect, onTap: onSelect, onDragEnd: (e) => {
36095
36134
  const newX = e.target.x();
36096
36135
  const newY = e.target.y();
36097
36136
  const deltaX = newX - (bounds.minX - padding);
@@ -36131,6 +36170,10 @@ const BoardComponent = React.forwardRef(({ roomId = 'default-room', queueAction,
36131
36170
  const containerRef = React.useRef(null);
36132
36171
  const lastPointerPosition = React.useRef(null);
36133
36172
  const mouseMoveThrottleRef = React.useRef(null);
36173
+ // NEW: Eraser preview state for real-time visual feedback
36174
+ const [eraserPreviewPoints, setEraserPreviewPoints] = React.useState([]);
36175
+ const [keepPreviewVisible, setKeepPreviewVisible] = React.useState(false);
36176
+ const [justErasedIds, setJustErasedIds] = React.useState(new Set());
36134
36177
  // Find shapes that intersect with the erase path
36135
36178
  const findIntersectingShapes = (erasePath, shapes) => {
36136
36179
  const eraseRadius = 10; // Half of eraser width
@@ -36221,8 +36264,20 @@ const BoardComponent = React.forwardRef(({ roomId = 'default-room', queueAction,
36221
36264
  setCurrentShapeId(null);
36222
36265
  setCurrentDrawingSessionId(null);
36223
36266
  lastPointerPosition.current = null;
36267
+ setEraserPreviewPoints([]); // Clear eraser preview
36268
+ setKeepPreviewVisible(false);
36269
+ setJustErasedIds(new Set());
36224
36270
  }
36225
36271
  }, [hasToolAccess, state.isDrawing, dispatch]);
36272
+ // Clear justErasedIds after one frame to complete transition
36273
+ React.useEffect(() => {
36274
+ if (justErasedIds.size > 0) {
36275
+ const id = requestAnimationFrame(() => {
36276
+ setJustErasedIds(new Set());
36277
+ });
36278
+ return () => cancelAnimationFrame(id);
36279
+ }
36280
+ }, [justErasedIds]);
36226
36281
  // Memoized export functionality for performance
36227
36282
  const exportAsImage = React.useCallback((format = 'png') => {
36228
36283
  if (!stageRef.current) {
@@ -36385,6 +36440,7 @@ const BoardComponent = React.forwardRef(({ roomId = 'default-room', queueAction,
36385
36440
  if (state.tool === 'eraser') {
36386
36441
  dispatch({ type: 'SET_DRAWING', payload: true });
36387
36442
  setCurrentPoints([pos]);
36443
+ setEraserPreviewPoints([pos]); // NEW: start preview stroke
36388
36444
  setCurrentShapeId('erasing'); // Special ID for erasing mode
36389
36445
  return;
36390
36446
  }
@@ -36433,6 +36489,9 @@ const BoardComponent = React.forwardRef(({ roomId = 'default-room', queueAction,
36433
36489
  setCurrentShapeId(null);
36434
36490
  setCurrentDrawingSessionId(null);
36435
36491
  lastPointerPosition.current = null;
36492
+ setEraserPreviewPoints([]); // Clear eraser preview
36493
+ setKeepPreviewVisible(false);
36494
+ setJustErasedIds(new Set());
36436
36495
  }
36437
36496
  return;
36438
36497
  }
@@ -36450,10 +36509,41 @@ const BoardComponent = React.forwardRef(({ roomId = 'default-room', queueAction,
36450
36509
  return; // Ultra-sensitive for maximum smoothness
36451
36510
  }
36452
36511
  lastPointerPosition.current = pos;
36453
- // Handle eraser tool - just collect points
36512
+ // Handle eraser tool - collect points and stream erase in real-time
36454
36513
  if (state.tool === 'eraser') {
36455
36514
  const newPoints = [...currentPoints, pos];
36456
36515
  setCurrentPoints(newPoints);
36516
+ setEraserPreviewPoints(newPoints); // Live preview
36517
+ // Create segment from last point to current point for real-time streaming
36518
+ const segment = currentPoints.length > 0
36519
+ ? [currentPoints[currentPoints.length - 1], pos]
36520
+ : [pos];
36521
+ // Apply segment to intersecting shapes in real-time
36522
+ const intersectingShapes = findIntersectingShapes(segment, state.shapes);
36523
+ if (intersectingShapes.length > 0) {
36524
+ const timestamp = Date.now();
36525
+ intersectingShapes.forEach((shape, index) => {
36526
+ const updatedShape = {
36527
+ ...shape,
36528
+ erasePaths: [...(shape.erasePaths || []), segment],
36529
+ };
36530
+ // Update the shape locally
36531
+ dispatch({ type: 'UPDATE_SHAPE', payload: updatedShape });
36532
+ // Queue erase action for real-time collaboration
36533
+ if (queueAction) {
36534
+ const erasePayload = {
36535
+ shapeId: shape.id,
36536
+ erasePath: segment,
36537
+ timestamp: timestamp + index,
36538
+ };
36539
+ queueAction({
36540
+ type: 'erase',
36541
+ payload: erasePayload,
36542
+ timestamp: timestamp + index,
36543
+ });
36544
+ }
36545
+ });
36546
+ }
36457
36547
  return;
36458
36548
  }
36459
36549
  let newPoints;
@@ -36503,7 +36593,7 @@ const BoardComponent = React.forwardRef(({ roomId = 'default-room', queueAction,
36503
36593
  };
36504
36594
  queueAction(continueAction);
36505
36595
  mouseMoveThrottleRef.current = null;
36506
- }, 8); // Optimized to ~120fps for ultra-smooth real-time collaboration
36596
+ }, 1); // Maximum frequency for ultra-smooth real-time collaboration
36507
36597
  }
36508
36598
  }, [hasToolAccess, state.isDrawing, state.tool, state.userId, state.color, state.strokeWidth, state.strokeStyle, state.opacity, currentShapeId, currentDrawingSessionId, currentPoints, queueAction, dispatch]);
36509
36599
  const handleMouseUp = React.useCallback(() => {
@@ -36516,6 +36606,9 @@ const BoardComponent = React.forwardRef(({ roomId = 'default-room', queueAction,
36516
36606
  setCurrentShapeId(null);
36517
36607
  setCurrentDrawingSessionId(null);
36518
36608
  lastPointerPosition.current = null;
36609
+ setEraserPreviewPoints([]); // Clear eraser preview
36610
+ setKeepPreviewVisible(false);
36611
+ setJustErasedIds(new Set());
36519
36612
  }
36520
36613
  return;
36521
36614
  }
@@ -36531,36 +36624,23 @@ const BoardComponent = React.forwardRef(({ roomId = 'default-room', queueAction,
36531
36624
  setCurrentDrawingSessionId(null);
36532
36625
  return;
36533
36626
  }
36534
- // Handle eraser tool - apply erase to intersecting shapes
36627
+ // Handle eraser tool - cleanup on mouse up (erasing already done in mouse move)
36535
36628
  if (state.tool === 'eraser') {
36536
- // Find shapes that intersect with the erase path
36537
- const intersectingShapes = findIntersectingShapes(currentPoints, state.shapes);
36538
- // Apply erase path to each intersecting shape
36539
- intersectingShapes.forEach(shape => {
36540
- const updatedShape = {
36541
- ...shape,
36542
- erasePaths: [...(shape.erasePaths || []), currentPoints],
36543
- };
36544
- // Update the shape locally
36545
- dispatch({ type: 'UPDATE_SHAPE', payload: updatedShape });
36546
- // Queue erase action for collaboration
36547
- if (queueAction) {
36548
- const erasePayload = {
36549
- shapeId: shape.id,
36550
- erasePath: currentPoints,
36551
- timestamp: Date.now(),
36552
- };
36553
- queueAction({
36554
- type: 'erase',
36555
- payload: erasePayload,
36556
- });
36557
- }
36558
- });
36629
+ // Stop drawing state
36630
+ dispatch({ type: 'SET_DRAWING', payload: false });
36559
36631
  // Reset erasing state
36560
36632
  setCurrentPoints([]);
36561
36633
  setCurrentShapeId(null);
36562
36634
  setCurrentDrawingSessionId(null);
36563
36635
  lastPointerPosition.current = null;
36636
+ // Clear preview after a brief delay to ensure smooth transition
36637
+ requestAnimationFrame(() => {
36638
+ requestAnimationFrame(() => {
36639
+ setEraserPreviewPoints([]);
36640
+ setKeepPreviewVisible(false);
36641
+ setJustErasedIds(new Set());
36642
+ });
36643
+ });
36564
36644
  return;
36565
36645
  }
36566
36646
  // Handle regular drawing tools
@@ -36627,31 +36707,39 @@ const BoardComponent = React.forwardRef(({ roomId = 'default-room', queueAction,
36627
36707
  if (shape.isEraser) {
36628
36708
  return null;
36629
36709
  }
36630
- // Use ErasedShape component for shapes that have erase paths applied
36631
- if (shape.erasePaths && shape.erasePaths.length > 0) {
36632
- return (jsxRuntime.jsx(ErasedShape, { shapeProps: shape, isSelected: shape.isSelected || false, onSelect: () => handleShapeClick(shape.id), onUpdate: handleShapeUpdate }));
36633
- }
36634
- // Use original shape components for shapes without erase operations
36635
36710
  const commonProps = {
36636
36711
  shapeProps: shape,
36637
36712
  isSelected: shape.isSelected || false,
36638
36713
  onSelect: () => handleShapeClick(shape.id),
36639
36714
  onUpdate: handleShapeUpdate,
36640
36715
  };
36641
- switch (shape.type) {
36642
- case 'rectangle':
36643
- return jsxRuntime.jsx(Rectangle, { ...commonProps });
36644
- case 'ellipse':
36645
- return jsxRuntime.jsx(Ellipse, { ...commonProps });
36646
- case 'line':
36647
- return jsxRuntime.jsx(Line, { ...commonProps });
36648
- case 'pencil':
36649
- return jsxRuntime.jsx(FreehandDrawing, { ...commonProps });
36650
- case 'arrow':
36651
- return jsxRuntime.jsx(Arrow, { ...commonProps });
36652
- default:
36653
- return null;
36716
+ // Render original shape component
36717
+ const OriginalShape = () => {
36718
+ switch (shape.type) {
36719
+ case 'rectangle':
36720
+ return jsxRuntime.jsx(Rectangle, { ...commonProps });
36721
+ case 'ellipse':
36722
+ return jsxRuntime.jsx(Ellipse, { ...commonProps });
36723
+ case 'line':
36724
+ return jsxRuntime.jsx(Line, { ...commonProps });
36725
+ case 'pencil':
36726
+ return jsxRuntime.jsx(FreehandDrawing, { ...commonProps });
36727
+ case 'arrow':
36728
+ return jsxRuntime.jsx(Arrow, { ...commonProps });
36729
+ default:
36730
+ return null;
36731
+ }
36732
+ };
36733
+ // Use ErasedShape component for shapes that have erase paths applied
36734
+ if (shape.erasePaths && shape.erasePaths.length > 0) {
36735
+ // If this shape just got erased, render both to prevent flicker
36736
+ if (justErasedIds.has(shape.id)) {
36737
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(OriginalShape, {}), jsxRuntime.jsx(ErasedShape, { shapeProps: shape, isSelected: shape.isSelected || false, onSelect: () => handleShapeClick(shape.id), onUpdate: handleShapeUpdate })] }));
36738
+ }
36739
+ return (jsxRuntime.jsx(ErasedShape, { shapeProps: shape, isSelected: shape.isSelected || false, onSelect: () => handleShapeClick(shape.id), onUpdate: handleShapeUpdate }));
36654
36740
  }
36741
+ // Use original shape components for shapes without erase operations
36742
+ return jsxRuntime.jsx(OriginalShape, {});
36655
36743
  }, (prevProps, nextProps) => {
36656
36744
  // Ultra-precise comparison to prevent unnecessary re-renders during simultaneous drawing
36657
36745
  const prevShape = prevProps.shape;
@@ -36758,11 +36846,11 @@ const BoardComponent = React.forwardRef(({ roomId = 'default-room', queueAction,
36758
36846
  state.tool === 'select' ? 'default' :
36759
36847
  state.tool === 'pan' ? 'grab' : 'crosshair'
36760
36848
  }), [hasToolAccess, state.tool]);
36761
- return (jsxRuntime.jsx("div", { ref: containerRef, className: "w-full h-full relative", style: { backgroundColor: state.backgroundColor }, children: jsxRuntime.jsx(Stage, { ref: stageRef, width: size.width, height: size.height, onMouseDown: handleMouseDown, onMousemove: handleMouseMove, onMouseup: handleMouseUp, onTouchStart: handleMouseDown, onTouchMove: handleMouseMove, onTouchEnd: handleMouseUp, style: cursorStyle, children: jsxRuntime.jsxs(Layer, { children: [React.useMemo(() => {
36762
- if (state.backgroundColor === 'transparent')
36763
- return null;
36764
- return (jsxRuntime.jsx(Rect, { x: 0, y: 0, width: size.width, height: size.height, fill: state.backgroundColor, listening: false }));
36765
- }, [state.backgroundColor, size.width, size.height]), renderedShapes, renderedActiveDrawings, renderCurrentShape] }) }) }));
36849
+ return (jsxRuntime.jsx("div", { ref: containerRef, className: "w-full h-full relative", style: { backgroundColor: state.backgroundColor }, children: jsxRuntime.jsxs(Stage, { ref: stageRef, width: size.width, height: size.height, onMouseDown: handleMouseDown, onMousemove: handleMouseMove, onMouseup: handleMouseUp, onTouchStart: handleMouseDown, onTouchMove: handleMouseMove, onTouchEnd: handleMouseUp, style: cursorStyle, children: [jsxRuntime.jsxs(Layer, { children: [React.useMemo(() => {
36850
+ if (state.backgroundColor === 'transparent')
36851
+ return null;
36852
+ return (jsxRuntime.jsx(Rect, { x: 0, y: 0, width: size.width, height: size.height, fill: state.backgroundColor, listening: false }));
36853
+ }, [state.backgroundColor, size.width, size.height]), renderedShapes, renderedActiveDrawings, renderCurrentShape] }), jsxRuntime.jsx(Layer, { listening: false, children: eraserPreviewPoints.length > 0 && (state.isDrawing || keepPreviewVisible) && state.tool === 'eraser' && (jsxRuntime.jsx(Line$1, { points: eraserPreviewPoints.flatMap(p => [p.x, p.y]), stroke: state.backgroundColor === 'transparent' ? '#FFFFFF' : state.backgroundColor, strokeWidth: 20, lineCap: "round", lineJoin: "round", opacity: 1.0, listening: false, perfectDrawEnabled: false })) })] }) }));
36766
36854
  });
36767
36855
  // Memoize the Board component to prevent unnecessary re-renders
36768
36856
  const Board = React.memo(BoardComponent, (prevProps, nextProps) => {
@@ -37169,13 +37257,15 @@ const LeftSidebar = ({ queueAction, hasToolAccess = false, shouldBeOpenByDefault
37169
37257
  setIsInitialized(true);
37170
37258
  }
37171
37259
  }, [state]);
37172
- // Set white as default stroke color when video background is active
37260
+ // Set white as default stroke color when video background is first activated (only once)
37261
+ const hasVideoBackgroundRef = React.useRef(false);
37173
37262
  React.useEffect(() => {
37174
- if (hasVideoBackground && state.color === '#000000') {
37175
- // Only change if currently black, to avoid overriding user's choice
37263
+ // Only auto-switch to white on first video activation, and only if currently black
37264
+ if (hasVideoBackground && !hasVideoBackgroundRef.current && state.color === '#000000') {
37176
37265
  dispatch({ type: 'SET_COLOR', payload: '#FFFFFF' });
37177
37266
  }
37178
- }, [hasVideoBackground, state.color, dispatch]);
37267
+ hasVideoBackgroundRef.current = hasVideoBackground;
37268
+ }, [hasVideoBackground, dispatch]);
37179
37269
  // Track initial access grant
37180
37270
  React.useEffect(() => {
37181
37271
  if (hasToolAccess && !hasEverHadAccess) {
@@ -37707,15 +37797,15 @@ const useCollaborativeWhiteboard = (roomId, callbacks) => {
37707
37797
  totalActions: 0,
37708
37798
  });
37709
37799
  // Throttling configuration - Ultra-optimized for smooth real-time collaboration
37710
- const THROTTLE_DELAY = 30; // ms - Reduced delay for faster transmission
37711
- const MAX_ACTIONS_PER_BATCH = 15; // Smaller batches for faster transmission
37800
+ const THROTTLE_DELAY = 10; // ms - Minimal delay for faster transmission
37801
+ const MAX_ACTIONS_PER_BATCH = 8; // Smaller batches for faster transmission
37712
37802
  const MAX_MESSAGE_SIZE = 500; // characters - matches API constraint
37713
- const MAX_MESSAGES_PER_SECOND = 15; // Increased rate for smoother collaboration
37803
+ const MAX_MESSAGES_PER_SECOND = 30; // Maximum rate for smoother collaboration
37714
37804
  const MAX_PAYLOAD_SIZE = 1024; // bytes (1KB) - matches API constraint
37715
37805
  // Drawing-specific throttling for ultra-smooth real-time collaboration
37716
- const DRAWING_THROTTLE_DELAY = 8; // ms - 120fps for ultra-smooth drawing actions
37717
- const DRAWING_BATCH_SIZE = 5; // Much smaller batches for immediate transmission
37718
- const DRAWING_IMMEDIATE_THRESHOLD = 2; // Send immediately if we have 2+ drawing actions
37806
+ const DRAWING_THROTTLE_DELAY = 1; // ms - Maximum frequency for ultra-smooth drawing actions
37807
+ const DRAWING_BATCH_SIZE = 2; // Minimal batches for immediate transmission
37808
+ const DRAWING_IMMEDIATE_THRESHOLD = 1; // Send immediately if we have 1+ drawing actions
37719
37809
  // Message rate limiting
37720
37810
  const messageTimestampsRef = React.useRef([]);
37721
37811
  const isRateLimited = React.useCallback(() => {
@@ -37879,7 +37969,7 @@ const useCollaborativeWhiteboard = (roomId, callbacks) => {
37879
37969
  let delay = THROTTLE_DELAY;
37880
37970
  if (remainingDrawingActions.length > 0) {
37881
37971
  delay = remainingDrawingActions.length >= DRAWING_IMMEDIATE_THRESHOLD ?
37882
- Math.max(DRAWING_THROTTLE_DELAY / 2, 4) : // Even faster for multiple actions
37972
+ Math.max(DRAWING_THROTTLE_DELAY / 2, 2) : // Even faster for multiple actions
37883
37973
  DRAWING_THROTTLE_DELAY;
37884
37974
  }
37885
37975
  throttleTimerRef.current = setTimeout(() => {
@@ -38022,7 +38112,7 @@ const useCollaborativeWhiteboard = (roomId, callbacks) => {
38022
38112
  clearTimeout(throttleTimerRef.current);
38023
38113
  throttleTimerRef.current = setTimeout(() => {
38024
38114
  transmitRef.current?.();
38025
- }, Math.max(DRAWING_THROTTLE_DELAY / 4, 2)); // Ultra-fast transmission
38115
+ }, 1); // Ultra-fast transmission - 1ms for maximum responsiveness
38026
38116
  }
38027
38117
  }
38028
38118
  }, [state.canvasSize, state.userId, roomId, callbacks, sendWithConstraints]);
@@ -38042,7 +38132,11 @@ const useCollaborativeWhiteboard = (roomId, callbacks) => {
38042
38132
  // Process each action in the batch with duplicate prevention
38043
38133
  parsedData.actions.forEach(action => {
38044
38134
  // Create unique action ID for deduplication
38045
- const actionId = `${action.type}-${parsedData.userId}-${action.timestamp || Date.now()}`;
38135
+ // For erase actions, include shapeId to allow multiple erases in same stroke
38136
+ let actionId = `${action.type}-${parsedData.userId}-${action.timestamp || Date.now()}`;
38137
+ if (action.type === 'erase' && action.payload && typeof action.payload === 'object' && 'shapeId' in action.payload) {
38138
+ actionId = `${action.type}-${parsedData.userId}-${action.payload.shapeId}-${action.timestamp || Date.now()}`;
38139
+ }
38046
38140
  // Skip if we've already processed this action (prevents shape loss from duplicate processing)
38047
38141
  if (processedActionsRef.current.has(actionId)) {
38048
38142
  return;