@ngenux/ngage-whiteboarding 1.0.9 → 1.0.10

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.esm.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
2
  import * as React from 'react';
3
- import React__default, { createContext, useReducer, useContext, useState, useRef, useMemo, useEffect, forwardRef, useCallback, useImperativeHandle, createElement } from 'react';
3
+ import React__default, { createContext, useRef, useReducer, useEffect, useContext, useState, useMemo, forwardRef, useCallback, useImperativeHandle, createElement } from 'react';
4
4
 
5
5
  var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
6
6
 
@@ -34911,6 +34911,21 @@ const whiteboardReducer = (state, action) => {
34911
34911
  userUndoStacks: {}, // Clear all user undo stacks as well
34912
34912
  };
34913
34913
  }
34914
+ case 'RESET_EMPTY_ROOM': {
34915
+ // Same visual reset as a fresh board, but lastClearTimestamp stays 0 so shapes from
34916
+ // sync_state (drawn before this client joined) are not rejected as pre-clear stale.
34917
+ return {
34918
+ ...state,
34919
+ shapes: [],
34920
+ history: [[]],
34921
+ historyIndex: 0,
34922
+ selectedShapeId: undefined,
34923
+ currentDrawingShape: undefined,
34924
+ activeDrawings: {},
34925
+ userUndoStacks: {},
34926
+ lastClearTimestamp: 0,
34927
+ };
34928
+ }
34914
34929
  case 'UNDO': {
34915
34930
  if (state.historyIndex > 0) {
34916
34931
  return {
@@ -35116,8 +35131,120 @@ const whiteboardReducer = (state, action) => {
35116
35131
  }
35117
35132
  };
35118
35133
  const WhiteboardContext = createContext(undefined);
35119
- const WhiteboardProvider = ({ children, webSocketUrl }) => {
35120
- const [state, dispatch] = useReducer(whiteboardReducer, initialState);
35134
+ // Helper function to get storage key for a room
35135
+ const getStorageKey = (roomId) => {
35136
+ return roomId ? `whiteboard-state-${roomId}` : 'whiteboard-state-default';
35137
+ };
35138
+ // Helper function to load persisted state from sessionStorage
35139
+ const loadPersistedState = (roomId) => {
35140
+ try {
35141
+ const key = getStorageKey(roomId);
35142
+ const persisted = sessionStorage.getItem(key);
35143
+ if (persisted) {
35144
+ const parsed = JSON.parse(persisted);
35145
+ return parsed;
35146
+ }
35147
+ }
35148
+ catch (error) {
35149
+ console.error('[PERSISTENCE] Failed to load persisted state:', error);
35150
+ }
35151
+ return null;
35152
+ };
35153
+ // Helper function to save state to sessionStorage
35154
+ const savePersistedState = (state, roomId) => {
35155
+ try {
35156
+ const key = getStorageKey(roomId);
35157
+ // Only persist essential state (shapes, history, background, canvas size)
35158
+ const stateToPersist = {
35159
+ shapes: state.shapes,
35160
+ history: state.history,
35161
+ historyIndex: state.historyIndex,
35162
+ backgroundColor: state.backgroundColor,
35163
+ canvasSize: state.canvasSize,
35164
+ lastClearTimestamp: state.lastClearTimestamp,
35165
+ userUndoStacks: state.userUndoStacks,
35166
+ };
35167
+ sessionStorage.setItem(key, JSON.stringify(stateToPersist));
35168
+ }
35169
+ catch (error) {
35170
+ console.error('[PERSISTENCE] Failed to save persisted state:', error);
35171
+ }
35172
+ };
35173
+ // Helper function to clear persisted state
35174
+ const clearPersistedState = (roomId) => {
35175
+ try {
35176
+ const key = getStorageKey(roomId);
35177
+ sessionStorage.removeItem(key);
35178
+ }
35179
+ catch (error) {
35180
+ console.error('[PERSISTENCE] Failed to clear persisted state:', error);
35181
+ }
35182
+ };
35183
+ const WhiteboardProvider = ({ children, webSocketUrl, roomId: initialRoomId }) => {
35184
+ const [currentRoomId, setCurrentRoomId] = React__default.useState(initialRoomId);
35185
+ // Track if a clear action was performed
35186
+ const clearActionRef = useRef(false);
35187
+ // Load persisted state if available, otherwise use initialState
35188
+ const getInitialState = () => {
35189
+ const persisted = loadPersistedState(currentRoomId);
35190
+ if (persisted) {
35191
+ // Merge persisted state with current initialState to ensure all properties exist
35192
+ return {
35193
+ ...initialState,
35194
+ ...persisted,
35195
+ // Reset transient state
35196
+ isDrawing: false,
35197
+ currentDrawingShape: undefined,
35198
+ selectedShapeId: undefined,
35199
+ activeDrawings: {},
35200
+ captureEnabled: true,
35201
+ };
35202
+ }
35203
+ return initialState;
35204
+ };
35205
+ const [state, dispatch] = useReducer((s, action) => {
35206
+ // Track clear action
35207
+ if (action.type === 'CLEAR_CANVAS') {
35208
+ clearActionRef.current = true;
35209
+ }
35210
+ return whiteboardReducer(s, action);
35211
+ }, getInitialState());
35212
+ const prevRoomIdRef = useRef(currentRoomId);
35213
+ // Save state to sessionStorage whenever it changes (debounced)
35214
+ useEffect(() => {
35215
+ const timeoutId = setTimeout(() => {
35216
+ // Only save empty shapes if a clear action was performed
35217
+ if (state.shapes.length > 0 || clearActionRef.current) {
35218
+ savePersistedState(state, currentRoomId);
35219
+ clearActionRef.current = false;
35220
+ }
35221
+ }, 500); // Debounce to avoid excessive writes
35222
+ return () => clearTimeout(timeoutId);
35223
+ }, [state.shapes, state.history, state.backgroundColor, state.canvasSize, state.lastClearTimestamp, currentRoomId]);
35224
+ // Handle room changes - load new room state or clear if switching rooms
35225
+ useEffect(() => {
35226
+ if (prevRoomIdRef.current !== currentRoomId) {
35227
+ // Save current room state before switching
35228
+ if (prevRoomIdRef.current) {
35229
+ savePersistedState(state, prevRoomIdRef.current);
35230
+ }
35231
+ // Load new room state
35232
+ const newRoomState = loadPersistedState(currentRoomId);
35233
+ if (newRoomState) {
35234
+ dispatch({ type: 'SET_SHAPES', payload: newRoomState.shapes || [] });
35235
+ dispatch({ type: 'SET_BACKGROUND_COLOR', payload: newRoomState.backgroundColor || '#FFFFFF' });
35236
+ if (newRoomState.lastClearTimestamp) {
35237
+ dispatch({ type: 'SET_CLEAR_TIMESTAMP', payload: newRoomState.lastClearTimestamp });
35238
+ }
35239
+ }
35240
+ else {
35241
+ // No persisted state for new room — empty locally but do not use CLEAR_CANVAS
35242
+ // (that sets lastClearTimestamp = now and breaks applying peer sync_state for pre-join strokes).
35243
+ dispatch({ type: 'RESET_EMPTY_ROOM' });
35244
+ }
35245
+ prevRoomIdRef.current = currentRoomId;
35246
+ }
35247
+ }, [currentRoomId, state]);
35121
35248
  const normalizePoint = (point) => {
35122
35249
  return {
35123
35250
  x: point.x / state.canvasSize.width,
@@ -35198,7 +35325,7 @@ const WhiteboardProvider = ({ children, webSocketUrl }) => {
35198
35325
  };
35199
35326
  };
35200
35327
  // Store reference to current queue action for state requests
35201
- let currentQueueAction = null;
35328
+ const currentQueueActionRef = useRef(null);
35202
35329
  // Type guard and shape normalizer to ensure we have a complete ShapeProps object
35203
35330
  const isCompleteShape = (obj) => {
35204
35331
  if (!obj) {
@@ -35254,10 +35381,10 @@ const WhiteboardProvider = ({ children, webSocketUrl }) => {
35254
35381
  return true;
35255
35382
  };
35256
35383
  const requestStateFromPeers = () => {
35257
- if (currentQueueAction) {
35384
+ if (currentQueueActionRef.current) {
35258
35385
  setTimeout(() => {
35259
- if (currentQueueAction) {
35260
- currentQueueAction({
35386
+ if (currentQueueActionRef.current) {
35387
+ currentQueueActionRef.current({
35261
35388
  type: 'request_state',
35262
35389
  payload: '',
35263
35390
  requesterId: state.userId,
@@ -35296,9 +35423,9 @@ const WhiteboardProvider = ({ children, webSocketUrl }) => {
35296
35423
  switch (action.type) {
35297
35424
  case 'request_state':
35298
35425
  // Another user is requesting current state, send our state to them
35299
- if (typeof action.payload === 'string' && action.requesterId && currentQueueAction) {
35426
+ if (typeof action.payload === 'string' && action.requesterId && currentQueueActionRef.current) {
35300
35427
  const currentState = getCurrentSyncState();
35301
- currentQueueAction({
35428
+ currentQueueActionRef.current({
35302
35429
  type: 'sync_state',
35303
35430
  payload: currentState,
35304
35431
  requesterId: action.requesterId, // Send specifically to the requester
@@ -35372,16 +35499,19 @@ const WhiteboardProvider = ({ children, webSocketUrl }) => {
35372
35499
  console.warn(`[APPLY_ACTION] Failed to delete shape - payload not string:`, action.payload);
35373
35500
  }
35374
35501
  break;
35375
- case 'clear':
35376
- // Clear all canvas content including active drawings
35502
+ case 'clear': {
35503
+ // All users (not just host) must clear their local state when a collaborative clear is received
35377
35504
  dispatch({ type: 'CLEAR_CANVAS' });
35378
35505
  dispatch({ type: 'CLEAR_ACTIVE_DRAWINGS' });
35506
+ // Clear persisted state in sessionStorage
35507
+ clearPersistedState(currentRoomId);
35379
35508
  // Update last clear time to prevent conflicting actions
35380
35509
  if (typeof action.payload === 'object' && action.payload !== null && 'timestamp' in action.payload) {
35381
35510
  const clearTimestamp = action.payload.timestamp;
35382
35511
  dispatch({ type: 'SET_CLEAR_TIMESTAMP', payload: clearTimestamp });
35383
35512
  }
35384
35513
  break;
35514
+ }
35385
35515
  case 'undo':
35386
35516
  case 'redo':
35387
35517
  if (Array.isArray(action.payload)) {
@@ -35557,8 +35687,9 @@ const WhiteboardProvider = ({ children, webSocketUrl }) => {
35557
35687
  getCurrentSyncState,
35558
35688
  requestStateFromPeers,
35559
35689
  setQueueAction: (queueAction) => {
35560
- currentQueueAction = queueAction;
35690
+ currentQueueActionRef.current = queueAction;
35561
35691
  },
35692
+ setCurrentRoomId,
35562
35693
  normalizePoint,
35563
35694
  denormalizePoint,
35564
35695
  webSocketUrl,
@@ -42433,8 +42564,8 @@ const waitForSocket = (webSocketUrl, timeoutMs = 5000) => {
42433
42564
  });
42434
42565
  };
42435
42566
 
42436
- const Whiteboard = ({ roomId, isAdmin = false, allowedUsers = [], userId, transparentBackground = false, videoStream }) => {
42437
- const { state, dispatch, setQueueAction, requestStateFromPeers, webSocketUrl } = useWhiteboard();
42567
+ const Whiteboard = ({ roomId, isAdmin = false, allowedUsers = [], userId, transparentBackground = false, videoStream, externalApiRef }) => {
42568
+ const { state, dispatch, setQueueAction, requestStateFromPeers, setCurrentRoomId, webSocketUrl } = useWhiteboard();
42438
42569
  const [lastCollaborativeAction, setLastCollaborativeAction] = useState(null);
42439
42570
  const [hasRequestedState, setHasRequestedState] = useState(false);
42440
42571
  const [lastStateRequestTime, setLastStateRequestTime] = useState(0);
@@ -42458,6 +42589,10 @@ const Whiteboard = ({ roomId, isAdmin = false, allowedUsers = [], userId, transp
42458
42589
  useEffect(() => {
42459
42590
  dispatch({ type: 'SET_USER_ID', payload: userId });
42460
42591
  }, [userId, dispatch]);
42592
+ // Set current roomId in context for persistence
42593
+ useEffect(() => {
42594
+ setCurrentRoomId(roomId);
42595
+ }, [roomId, setCurrentRoomId]);
42461
42596
  // Set background color based on transparentBackground prop or videoStream presence
42462
42597
  useEffect(() => {
42463
42598
  if (transparentBackground || videoStream) {
@@ -42593,9 +42728,10 @@ const Whiteboard = ({ roomId, isAdmin = false, allowedUsers = [], userId, transp
42593
42728
  setLastStateRequestTime(0);
42594
42729
  setIsRoomJoined(false);
42595
42730
  }, [roomId]);
42596
- // Retry state request if canvas is still empty after 3 seconds
42731
+ // Retry state request if canvas is still empty (e.g. first request raced before host had queueAction).
42732
+ // Intentionally does NOT gate on hasRequestedState — the initial effect sets that immediately while send is delayed 2s.
42597
42733
  useEffect(() => {
42598
- if (!isRoomJoined || !isSocketConnected || hasRequestedState || state.shapes.length > 0 || !queueAction) {
42734
+ if (!isRoomJoined || !isSocketConnected || state.shapes.length > 0 || !queueAction) {
42599
42735
  return;
42600
42736
  }
42601
42737
  const now = Date.now();
@@ -42606,9 +42742,9 @@ const Whiteboard = ({ roomId, isAdmin = false, allowedUsers = [], userId, transp
42606
42742
  const retryTimer = setTimeout(() => {
42607
42743
  requestStateFromPeers();
42608
42744
  setLastStateRequestTime(Date.now());
42609
- }, 3000);
42745
+ }, 4500);
42610
42746
  return () => clearTimeout(retryTimer);
42611
- }, [isRoomJoined, isSocketConnected, state.shapes.length, queueAction, requestStateFromPeers, lastStateRequestTime, userId]);
42747
+ }, [isRoomJoined, isSocketConnected, state.shapes.length, queueAction, requestStateFromPeers, roomId]);
42612
42748
  // Cleanup stale active drawings periodically
42613
42749
  useEffect(() => {
42614
42750
  const cleanupInterval = setInterval(() => {
@@ -42707,6 +42843,22 @@ const Whiteboard = ({ roomId, isAdmin = false, allowedUsers = [], userId, transp
42707
42843
  });
42708
42844
  }
42709
42845
  }, [isAdmin, dispatch, queueAction, userId]);
42846
+ // Expose a minimal imperative API for host apps that need to perform the same
42847
+ // collaborative clear as the toolbar button (e.g., on "stop screenshare with annotations").
42848
+ useEffect(() => {
42849
+ if (!externalApiRef) {
42850
+ return;
42851
+ }
42852
+ externalApiRef.current = {
42853
+ clear: handleClear,
42854
+ getRoomId: () => roomId,
42855
+ };
42856
+ return () => {
42857
+ if (externalApiRef.current?.getRoomId?.() === roomId) {
42858
+ externalApiRef.current = null;
42859
+ }
42860
+ };
42861
+ }, [externalApiRef, handleClear, roomId]);
42710
42862
  const handleLockToggle = useCallback(() => {
42711
42863
  if (!isAdmin) {
42712
42864
  return;
@@ -42789,28 +42941,6 @@ const Whiteboard = ({ roomId, isAdmin = false, allowedUsers = [], userId, transp
42789
42941
  leaveRoom(roomId);
42790
42942
  };
42791
42943
  }, [roomId]);
42792
- // Clear all canvases on component unmount if admin leaves
42793
- useEffect(() => {
42794
- return () => {
42795
- // If admin leaves, clear all users' canvases
42796
- if (isAdmin && queueAction) {
42797
- const clearTimestamp = Date.now();
42798
- // Clear local state immediately
42799
- dispatch({ type: 'CLEAR_CANVAS' });
42800
- dispatch({ type: 'CLEAR_ACTIVE_DRAWINGS' });
42801
- // Send clear action to all users
42802
- queueAction({
42803
- type: 'clear',
42804
- payload: {
42805
- timestamp: clearTimestamp,
42806
- adminId: userId,
42807
- },
42808
- userId: userId,
42809
- timestamp: clearTimestamp,
42810
- });
42811
- }
42812
- };
42813
- }, [isAdmin, userId, dispatch]);
42814
42944
  // Global cleanup on app unmount
42815
42945
  useEffect(() => {
42816
42946
  const handleBeforeUnload = () => {