mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-13 02:18:16 +01:00
# Description of Changes - Add manual redaction and added it to the right rail in the viewer --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react';
|
|
import { WorkbenchType, getDefaultWorkbench } from '@app/types/workbench';
|
|
import { ToolId, isValidToolId } from '@app/types/toolId';
|
|
import { useToolRegistry } from '@app/contexts/ToolRegistryContext';
|
|
|
|
/**
|
|
* NavigationContext - Complete navigation management system
|
|
*
|
|
* Handles navigation modes, navigation guards for unsaved changes,
|
|
* and breadcrumb/history navigation. Separated from FileContext to
|
|
* maintain clear separation of concerns.
|
|
*/
|
|
|
|
// Navigation state
|
|
interface NavigationContextState {
|
|
workbench: WorkbenchType;
|
|
selectedTool: ToolId | null;
|
|
hasUnsavedChanges: boolean;
|
|
pendingNavigation: (() => void) | null;
|
|
showNavigationWarning: boolean;
|
|
}
|
|
|
|
// Navigation actions
|
|
type NavigationAction =
|
|
| { type: 'SET_WORKBENCH'; payload: { workbench: WorkbenchType } }
|
|
| { type: 'SET_SELECTED_TOOL'; payload: { toolId: ToolId | null } }
|
|
| { type: 'SET_TOOL_AND_WORKBENCH'; payload: { toolId: ToolId | null; workbench: WorkbenchType } }
|
|
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
|
|
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
|
|
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } };
|
|
|
|
// Navigation reducer
|
|
const navigationReducer = (state: NavigationContextState, action: NavigationAction): NavigationContextState => {
|
|
switch (action.type) {
|
|
case 'SET_WORKBENCH':
|
|
return { ...state, workbench: action.payload.workbench };
|
|
|
|
case 'SET_SELECTED_TOOL':
|
|
return { ...state, selectedTool: action.payload.toolId };
|
|
|
|
case 'SET_TOOL_AND_WORKBENCH':
|
|
return {
|
|
...state,
|
|
selectedTool: action.payload.toolId,
|
|
workbench: action.payload.workbench
|
|
};
|
|
|
|
case 'SET_UNSAVED_CHANGES':
|
|
return { ...state, hasUnsavedChanges: action.payload.hasChanges };
|
|
|
|
case 'SET_PENDING_NAVIGATION':
|
|
return { ...state, pendingNavigation: action.payload.navigationFn };
|
|
|
|
case 'SHOW_NAVIGATION_WARNING':
|
|
return { ...state, showNavigationWarning: action.payload.show };
|
|
|
|
default:
|
|
return state;
|
|
}
|
|
};
|
|
|
|
// Initial state
|
|
const initialState: NavigationContextState = {
|
|
workbench: getDefaultWorkbench(),
|
|
selectedTool: null,
|
|
hasUnsavedChanges: false,
|
|
pendingNavigation: null,
|
|
showNavigationWarning: false
|
|
};
|
|
|
|
// Navigation context actions interface
|
|
export interface NavigationContextActions {
|
|
setWorkbench: (workbench: WorkbenchType) => void;
|
|
setSelectedTool: (toolId: ToolId | null) => void;
|
|
setToolAndWorkbench: (toolId: ToolId | null, workbench: WorkbenchType) => void;
|
|
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
|
registerUnsavedChangesChecker: (checker: () => boolean) => void;
|
|
unregisterUnsavedChangesChecker: () => void;
|
|
showNavigationWarning: (show: boolean) => void;
|
|
requestNavigation: (navigationFn: () => void) => void;
|
|
confirmNavigation: () => void;
|
|
cancelNavigation: () => void;
|
|
clearToolSelection: () => void;
|
|
handleToolSelect: (toolId: string) => void;
|
|
}
|
|
|
|
// Context state values
|
|
export interface NavigationContextStateValue {
|
|
workbench: WorkbenchType;
|
|
selectedTool: ToolId | null;
|
|
hasUnsavedChanges: boolean;
|
|
pendingNavigation: (() => void) | null;
|
|
showNavigationWarning: boolean;
|
|
}
|
|
|
|
export interface NavigationContextActionsValue {
|
|
actions: NavigationContextActions;
|
|
}
|
|
|
|
// Create contexts
|
|
const NavigationStateContext = createContext<NavigationContextStateValue | undefined>(undefined);
|
|
const NavigationActionsContext = createContext<NavigationContextActionsValue | undefined>(undefined);
|
|
|
|
// Provider component
|
|
export const NavigationProvider: React.FC<{
|
|
children: React.ReactNode;
|
|
enableUrlSync?: boolean;
|
|
}> = ({ children }) => {
|
|
const [state, dispatch] = useReducer(navigationReducer, initialState);
|
|
const { allTools: toolRegistry } = useToolRegistry();
|
|
const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null);
|
|
|
|
// Memoize individual callbacks
|
|
const setWorkbench = useCallback((workbench: WorkbenchType) => {
|
|
// Check for unsaved changes using registered checker or state
|
|
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
|
|
console.log('[NavigationContext] setWorkbench:', {
|
|
from: state.workbench,
|
|
to: workbench,
|
|
hasChecker: !!unsavedChangesCheckerRef.current,
|
|
hasUnsavedChanges
|
|
});
|
|
|
|
// If we're leaving pageEditor, viewer, or custom workbench and have unsaved changes, request navigation
|
|
const leavingWorkbenchWithChanges =
|
|
(state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) ||
|
|
(state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges) ||
|
|
(state.workbench.startsWith('custom:') && workbench !== state.workbench && hasUnsavedChanges);
|
|
|
|
if (leavingWorkbenchWithChanges) {
|
|
// Update state to reflect unsaved changes so modal knows
|
|
if (!state.hasUnsavedChanges) {
|
|
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges: true } });
|
|
}
|
|
const performWorkbenchChange = () => {
|
|
// When leaving a custom workbench, clear the selected tool
|
|
console.log('[NavigationContext] performWorkbenchChange executing', {
|
|
from: state.workbench,
|
|
to: workbench,
|
|
isCustom: state.workbench.startsWith('custom:')
|
|
});
|
|
if (state.workbench.startsWith('custom:')) {
|
|
console.log('[NavigationContext] Clearing tool and changing workbench to:', workbench);
|
|
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench } });
|
|
} else {
|
|
console.log('[NavigationContext] Just changing workbench to:', workbench);
|
|
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
|
|
}
|
|
};
|
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performWorkbenchChange } });
|
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
|
} else {
|
|
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
|
|
}
|
|
}, [state.workbench, state.hasUnsavedChanges]);
|
|
|
|
const setSelectedTool = useCallback((toolId: ToolId | null) => {
|
|
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolId } });
|
|
}, []);
|
|
|
|
const setToolAndWorkbench = useCallback((toolId: ToolId | null, workbench: WorkbenchType) => {
|
|
// Check for unsaved changes using registered checker or state
|
|
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
|
|
|
|
// If we're leaving pageEditor, viewer, or custom workbench and have unsaved changes, request navigation
|
|
const leavingWorkbenchWithChanges =
|
|
(state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) ||
|
|
(state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges) ||
|
|
(state.workbench.startsWith('custom:') && workbench !== state.workbench && hasUnsavedChanges);
|
|
|
|
if (leavingWorkbenchWithChanges) {
|
|
const performWorkbenchChange = () => {
|
|
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
|
|
};
|
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performWorkbenchChange } });
|
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
|
} else {
|
|
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
|
|
}
|
|
}, [state.workbench, state.hasUnsavedChanges]);
|
|
|
|
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
|
|
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
|
}, []);
|
|
|
|
const registerUnsavedChangesChecker = useCallback((checker: () => boolean) => {
|
|
unsavedChangesCheckerRef.current = checker;
|
|
}, []);
|
|
|
|
const unregisterUnsavedChangesChecker = useCallback(() => {
|
|
unsavedChangesCheckerRef.current = null;
|
|
}, []);
|
|
|
|
const showNavigationWarning = useCallback((show: boolean) => {
|
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } });
|
|
}, []);
|
|
|
|
const requestNavigation = useCallback((navigationFn: () => void) => {
|
|
if (!state.hasUnsavedChanges) {
|
|
navigationFn();
|
|
return;
|
|
}
|
|
|
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
|
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
|
}, [state.hasUnsavedChanges]);
|
|
|
|
const confirmNavigation = useCallback(() => {
|
|
console.log('[NavigationContext] confirmNavigation called', {
|
|
hasPendingNav: !!state.pendingNavigation,
|
|
currentWorkbench: state.workbench,
|
|
currentTool: state.selectedTool
|
|
});
|
|
if (state.pendingNavigation) {
|
|
state.pendingNavigation();
|
|
}
|
|
|
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
|
console.log('[NavigationContext] confirmNavigation completed');
|
|
}, [state.pendingNavigation, state.workbench, state.selectedTool]);
|
|
|
|
const cancelNavigation = useCallback(() => {
|
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
|
}, []);
|
|
|
|
const clearToolSelection = useCallback(() => {
|
|
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
|
|
}, []);
|
|
|
|
const handleToolSelect = useCallback((toolId: string) => {
|
|
const performToolSelect = () => {
|
|
if (toolId === 'allTools') {
|
|
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
|
|
return;
|
|
}
|
|
|
|
if (toolId === 'read' || toolId === 'view-pdf') {
|
|
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: 'viewer' } });
|
|
return;
|
|
}
|
|
|
|
// Look up the tool in the registry to get its proper workbench
|
|
const tool = isValidToolId(toolId)? toolRegistry[toolId] : null;
|
|
const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench();
|
|
|
|
// Validate toolId and convert to ToolId type
|
|
const validToolId = isValidToolId(toolId) ? toolId : null;
|
|
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } });
|
|
};
|
|
|
|
// Check for unsaved changes using registered checker or state
|
|
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
|
|
|
|
// If switching away from current tool and have unsaved changes, show warning
|
|
if (hasUnsavedChanges && state.selectedTool && state.selectedTool !== toolId) {
|
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performToolSelect } });
|
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
|
} else {
|
|
performToolSelect();
|
|
}
|
|
}, [toolRegistry, state.hasUnsavedChanges, state.selectedTool]);
|
|
|
|
// Memoize the actions object to prevent unnecessary context updates
|
|
// This is critical to avoid infinite loops when effects depend on actions
|
|
const actions: NavigationContextActions = useMemo(() => ({
|
|
setWorkbench,
|
|
setSelectedTool,
|
|
setToolAndWorkbench,
|
|
setHasUnsavedChanges,
|
|
registerUnsavedChangesChecker,
|
|
unregisterUnsavedChangesChecker,
|
|
showNavigationWarning,
|
|
requestNavigation,
|
|
confirmNavigation,
|
|
cancelNavigation,
|
|
clearToolSelection,
|
|
handleToolSelect,
|
|
}), [
|
|
setWorkbench,
|
|
setSelectedTool,
|
|
setToolAndWorkbench,
|
|
setHasUnsavedChanges,
|
|
registerUnsavedChangesChecker,
|
|
unregisterUnsavedChangesChecker,
|
|
showNavigationWarning,
|
|
requestNavigation,
|
|
confirmNavigation,
|
|
cancelNavigation,
|
|
clearToolSelection,
|
|
handleToolSelect,
|
|
]);
|
|
|
|
const stateValue: NavigationContextStateValue = {
|
|
workbench: state.workbench,
|
|
selectedTool: state.selectedTool,
|
|
hasUnsavedChanges: state.hasUnsavedChanges,
|
|
pendingNavigation: state.pendingNavigation,
|
|
showNavigationWarning: state.showNavigationWarning
|
|
};
|
|
|
|
// Also memoize the context value to prevent unnecessary re-renders
|
|
const actionsValue: NavigationContextActionsValue = useMemo(() => ({
|
|
actions
|
|
}), [actions]);
|
|
|
|
return (
|
|
<NavigationStateContext.Provider value={stateValue}>
|
|
<NavigationActionsContext.Provider value={actionsValue}>
|
|
{children}
|
|
</NavigationActionsContext.Provider>
|
|
</NavigationStateContext.Provider>
|
|
);
|
|
};
|
|
|
|
// Navigation hooks
|
|
export const useNavigationState = () => {
|
|
const context = useContext(NavigationStateContext);
|
|
if (context === undefined) {
|
|
throw new Error('useNavigationState must be used within NavigationProvider');
|
|
}
|
|
return context;
|
|
};
|
|
|
|
export const useNavigationActions = () => {
|
|
const context = useContext(NavigationActionsContext);
|
|
if (context === undefined) {
|
|
throw new Error('useNavigationActions must be used within NavigationProvider');
|
|
}
|
|
return context;
|
|
};
|
|
|
|
// Combined hook for convenience
|
|
export const useNavigation = () => {
|
|
const state = useNavigationState();
|
|
const { actions } = useNavigationActions();
|
|
return { ...state, ...actions };
|
|
};
|
|
|
|
// Navigation guard hook (equivalent to old useFileNavigation)
|
|
export const useNavigationGuard = () => {
|
|
const state = useNavigationState();
|
|
const { actions } = useNavigationActions();
|
|
|
|
return {
|
|
pendingNavigation: state.pendingNavigation,
|
|
showNavigationWarning: state.showNavigationWarning,
|
|
hasUnsavedChanges: state.hasUnsavedChanges,
|
|
requestNavigation: actions.requestNavigation,
|
|
confirmNavigation: actions.confirmNavigation,
|
|
cancelNavigation: actions.cancelNavigation,
|
|
setHasUnsavedChanges: actions.setHasUnsavedChanges,
|
|
setShowNavigationWarning: actions.showNavigationWarning,
|
|
registerUnsavedChangesChecker: actions.registerUnsavedChangesChecker,
|
|
unregisterUnsavedChangesChecker: actions.unregisterUnsavedChangesChecker
|
|
};
|
|
};
|