diff --git a/src/App.tsx b/src/App.tsx
index f5cccfd..9238a64 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -14,9 +14,12 @@ import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts";
import { useDocumentHistory } from "./hooks/useDocumentHistory";
import { useWorkspaceStore } from "./stores/workspaceStore";
import { usePanelStore } from "./stores/panelStore";
+import { useSettingsStore } from "./stores/settingsStore";
import { useCreateDocument } from "./hooks/useCreateDocument";
import type { Actor, Relation, Group } from "./types";
import type { ExportOptions } from "./utils/graphExport";
+import PresentationTimelineOverlay from "./components/Presentation/PresentationTimelineOverlay";
+import "./styles/presentation.css";
/**
* App - Root application component
@@ -41,6 +44,7 @@ function AppContent() {
const { undo, redo } = useDocumentHistory();
const { activeDocumentId } = useWorkspaceStore();
const { leftPanelVisible, rightPanelVisible, bottomPanelVisible } = usePanelStore();
+ const { presentationMode } = useSettingsStore();
const { handleNewDocument, NewDocumentDialog } = useCreateDocument();
const [showDocumentManager, setShowDocumentManager] = useState(false);
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
@@ -129,40 +133,44 @@ function AppContent() {
]);
return (
-
- {/* Header */}
-
-
-
-

-
Constellation Analyzer
-
- Visual editor for analyzing actors and their relationships
-
+
+ {/* Header - Hide in presentation mode */}
+ {!presentationMode && (
+
+
+
+

+
Constellation Analyzer
+
+ Visual editor for analyzing actors and their relationships
+
+
-
-
+
+ )}
- {/* Menu Bar */}
-
setShowKeyboardHelp(true)}
- onFitView={handleFitView}
- onExport={exportCallbackRef.current || undefined}
- />
+ {/* Menu Bar - Hide in presentation mode */}
+ {!presentationMode && (
+ setShowKeyboardHelp(true)}
+ onFitView={handleFitView}
+ onExport={exportCallbackRef.current || undefined}
+ />
+ )}
- {/* Document Tabs */}
-
+ {/* Document Tabs - Hide in presentation mode */}
+ {!presentationMode && }
{/* Main content area with side panels and bottom panel */}
{/* Top section: Left panel, graph editor, right panel */}
- {/* Left Panel */}
- {leftPanelVisible && activeDocumentId && (
+ {/* Left Panel - Hide in presentation mode */}
+ {!presentationMode && leftPanelVisible && activeDocumentId && (
{
@@ -178,8 +186,9 @@ function AppContent() {
)}
{/* Center: Graph Editor */}
-
+
+
+ {/* Presentation Timeline Overlay - Floats inside graph editor */}
+ {presentationMode && activeDocumentId && (
+
+ )}
- {/* Right Panel */}
- {rightPanelVisible && activeDocumentId && (
+ {/* Right Panel - Hide in presentation mode */}
+ {!presentationMode && rightPanelVisible && activeDocumentId && (
- {/* Bottom Panel (Timeline) - show when bottomPanelVisible and there's an active document */}
- {bottomPanelVisible && activeDocumentId && (
+ {/* Bottom Panel (Timeline) - Hide in presentation mode, show regular timeline */}
+ {!presentationMode && bottomPanelVisible && activeDocumentId && (
)}
diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx
index 8b0d553..c54111c 100644
--- a/src/components/Editor/GraphEditor.tsx
+++ b/src/components/Editor/GraphEditor.tsx
@@ -52,6 +52,7 @@ const MIN_ZOOM = 0.1; // Allow zooming out to 10% for large charts
const MAX_ZOOM = 2.5; // Allow zooming in to 250%
interface GraphEditorProps {
+ presentationMode?: boolean;
selectedNode: Actor | null;
selectedEdge: Relation | null;
selectedGroup: Group | null;
@@ -76,7 +77,10 @@ interface GraphEditorProps {
*
* Usage: Core component that wraps React Flow with custom nodes and edges
*/
-const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect, onAddNodeRequest, onExportRequest }: GraphEditorProps) => {
+const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect, onAddNodeRequest, onExportRequest }: GraphEditorProps) => {
+ // Determine if editing is allowed
+ const isEditable = !presentationMode;
+
// Sync with workspace active document
const { activeDocumentId } = useActiveDocument();
const { saveViewport, getViewport } = useWorkspaceStore();
@@ -109,8 +113,6 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect,
showGrid,
snapToGrid,
gridSize,
- panOnDrag,
- zoomOnScroll,
selectedRelationType,
} = useEditorStore();
@@ -1070,33 +1072,37 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect,
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
- onConnect={handleConnect}
- onNodesDelete={handleNodesDelete}
- onEdgesDelete={handleEdgesDelete}
+ onConnect={isEditable ? handleConnect : undefined}
+ onNodesDelete={isEditable ? handleNodesDelete : undefined}
+ onEdgesDelete={isEditable ? handleEdgesDelete : undefined}
onNodeClick={handleNodeClick}
- onNodeDoubleClick={handleNodeDoubleClick}
+ onNodeDoubleClick={isEditable ? handleNodeDoubleClick : undefined}
onEdgeClick={handleEdgeClick}
- onNodeContextMenu={handleNodeContextMenu}
- onEdgeContextMenu={handleEdgeContextMenu}
- onPaneContextMenu={handlePaneContextMenu}
+ onNodeContextMenu={isEditable ? handleNodeContextMenu : undefined}
+ onEdgeContextMenu={isEditable ? handleEdgeContextMenu : undefined}
+ onPaneContextMenu={isEditable ? handlePaneContextMenu : undefined}
onPaneClick={handlePaneClick}
onMove={handleViewportChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionMode={ConnectionMode.Loose}
- connectOnClick={true}
+ connectOnClick={isEditable}
snapToGrid={snapToGrid}
snapGrid={[gridSize, gridSize]}
- panOnDrag={panOnDrag}
- zoomOnScroll={zoomOnScroll}
+ panOnDrag={true}
+ zoomOnScroll={true}
+ panOnScroll={presentationMode ? true : undefined}
+ nodesDraggable={isEditable}
+ nodesConnectable={isEditable}
+ elementsSelectable={isEditable}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
connectionRadius={0}
fitView
attributionPosition="bottom-left"
>
- {/* Background grid */}
- {showGrid && (
+ {/* Background grid - Hide in presentation mode */}
+ {!presentationMode && showGrid && (
)}
- {/* Zoom and pan controls */}
-
+ {/* Zoom and pan controls - Simplified in presentation mode */}
+
- {/* MiniMap for navigation */}
+ {/* MiniMap for navigation - Read-only in presentation mode */}
{
const actor = node as Actor;
@@ -1117,8 +1123,8 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect,
);
return nodeType?.color || "#6b7280";
}}
- pannable
- zoomable
+ pannable={isEditable}
+ zoomable={isEditable}
/>
diff --git a/src/components/Menu/MenuBar.tsx b/src/components/Menu/MenuBar.tsx
index ce62999..53efe61 100644
--- a/src/components/Menu/MenuBar.tsx
+++ b/src/components/Menu/MenuBar.tsx
@@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { useWorkspaceStore } from '../../stores/workspaceStore';
+import { useSettingsStore } from '../../stores/settingsStore';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import { useDocumentHistory } from '../../hooks/useDocumentHistory';
import DocumentManager from '../Workspace/DocumentManager';
@@ -56,6 +57,7 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onExport }) =>
const { clearGraph } = useGraphWithHistory();
const { undo, redo, canUndo, canRedo, undoDescription, redoDescription } = useDocumentHistory();
+ const { setPresentationMode } = useSettingsStore();
// Listen for custom event to close all menus (e.g., from graph canvas clicks, context menu opens)
useEffect(() => {
@@ -440,6 +442,18 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onExport }) =>
{getShortcutLabel('fit-view')}
)}
+
+ {/* Presentation Mode */}
+
)}
diff --git a/src/components/Presentation/PresentationTimelineOverlay.tsx b/src/components/Presentation/PresentationTimelineOverlay.tsx
new file mode 100644
index 0000000..c43d31b
--- /dev/null
+++ b/src/components/Presentation/PresentationTimelineOverlay.tsx
@@ -0,0 +1,124 @@
+import React, { useEffect, useRef } from 'react';
+import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
+import ChevronRightIcon from '@mui/icons-material/ChevronRight';
+import { useWorkspaceStore } from '../../stores/workspaceStore';
+import { useTimelineStore } from '../../stores/timelineStore';
+
+/**
+ * PresentationTimelineOverlay Component
+ *
+ * Floating timeline control for navigating between constellation states
+ * in presentation mode. Positioned like ReactFlow controls at the bottom center.
+ */
+
+const PresentationTimelineOverlay: React.FC = () => {
+ const { activeDocumentId } = useWorkspaceStore();
+ const { timelines, getAllStates, switchToState } = useTimelineStore();
+ const scrollContainerRef = useRef(null);
+
+ const timeline = activeDocumentId ? timelines.get(activeDocumentId) : null;
+ const states = getAllStates();
+ const currentStateId = timeline?.currentStateId;
+ const currentIndex = states.findIndex(s => s.id === currentStateId);
+
+ // Auto-scroll to current state on mount or when current state changes
+ useEffect(() => {
+ if (!scrollContainerRef.current || !currentStateId) return;
+
+ const currentButton = scrollContainerRef.current.querySelector(
+ `[data-state-id="${currentStateId}"]`
+ );
+ if (currentButton) {
+ currentButton.scrollIntoView({
+ behavior: 'smooth',
+ block: 'nearest',
+ inline: 'center',
+ });
+ }
+ }, [currentStateId]);
+
+ const handlePrevious = () => {
+ if (currentIndex > 0) {
+ switchToState(states[currentIndex - 1].id);
+ }
+ };
+
+ const handleNext = () => {
+ if (currentIndex < states.length - 1) {
+ switchToState(states[currentIndex + 1].id);
+ }
+ };
+
+ // Don't show anything if only one state (no navigation needed)
+ if (states.length <= 1) return null;
+
+ return (
+
+ {/* Timeline Navigation */}
+
+ {/* Previous Button */}
+
+
+ {/* State List - Horizontal Scrollable */}
+
+ {states.map((state) => (
+
+ ))}
+
+
+ {/* Next Button */}
+
+
+
+ );
+};
+
+export default PresentationTimelineOverlay;
diff --git a/src/hooks/useGlobalShortcuts.ts b/src/hooks/useGlobalShortcuts.ts
index 16ba7e3..3790a2c 100644
--- a/src/hooks/useGlobalShortcuts.ts
+++ b/src/hooks/useGlobalShortcuts.ts
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useWorkspaceStore } from "../stores/workspaceStore";
+import { useSettingsStore } from "../stores/settingsStore";
import type { KeyboardShortcut } from "./useKeyboardShortcutManager";
/**
@@ -29,6 +30,7 @@ export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) {
closeDocument,
saveDocument,
} = useWorkspaceStore();
+ const { presentationMode, setPresentationMode } = useSettingsStore();
useEffect(() => {
const shortcutDefinitions: KeyboardShortcut[] = [
@@ -165,6 +167,27 @@ export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) {
category: "View",
enabled: !!options.onFitView,
},
+ {
+ id: "toggle-presentation-mode",
+ description: "Toggle Presentation Mode",
+ key: "F11",
+ handler: () => {
+ setPresentationMode(!presentationMode);
+ },
+ category: "View",
+ },
+ {
+ id: "exit-presentation-mode",
+ description: "Exit Presentation Mode",
+ key: "Escape",
+ handler: () => {
+ if (presentationMode) {
+ setPresentationMode(false);
+ }
+ },
+ category: "View",
+ priority: 10, // Higher priority to handle before other Escape handlers
+ },
{
id: "show-help",
description: "Show Keyboard Shortcuts",
@@ -203,6 +226,8 @@ export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) {
switchToDocument,
closeDocument,
saveDocument,
+ presentationMode,
+ setPresentationMode,
options,
]);
}
diff --git a/src/stores/settingsStore.test.ts b/src/stores/settingsStore.test.ts
index f3db3d4..884fe3a 100644
--- a/src/stores/settingsStore.test.ts
+++ b/src/stores/settingsStore.test.ts
@@ -9,6 +9,7 @@ describe('settingsStore', () => {
// Reset store to initial state
useSettingsStore.setState({
autoZoomEnabled: true,
+ presentationMode: false,
});
});
@@ -17,6 +18,7 @@ describe('settingsStore', () => {
const state = useSettingsStore.getState();
expect(state.autoZoomEnabled).toBe(true);
+ expect(state.presentationMode).toBe(false);
});
});
@@ -127,6 +129,51 @@ describe('settingsStore', () => {
});
});
+ describe('setPresentationMode', () => {
+ it('should enable presentation mode', () => {
+ const { setPresentationMode } = useSettingsStore.getState();
+
+ setPresentationMode(true);
+
+ expect(useSettingsStore.getState().presentationMode).toBe(true);
+ });
+
+ it('should disable presentation mode', () => {
+ const { setPresentationMode } = useSettingsStore.getState();
+
+ setPresentationMode(true);
+ setPresentationMode(false);
+
+ expect(useSettingsStore.getState().presentationMode).toBe(false);
+ });
+
+ it('should toggle presentation mode multiple times', () => {
+ const { setPresentationMode } = useSettingsStore.getState();
+
+ setPresentationMode(true);
+ expect(useSettingsStore.getState().presentationMode).toBe(true);
+
+ setPresentationMode(false);
+ expect(useSettingsStore.getState().presentationMode).toBe(false);
+
+ setPresentationMode(true);
+ expect(useSettingsStore.getState().presentationMode).toBe(true);
+ });
+
+ it('should persist presentation mode to localStorage', () => {
+ const { setPresentationMode } = useSettingsStore.getState();
+
+ setPresentationMode(true);
+
+ // Check localStorage directly
+ const stored = localStorage.getItem('constellation-settings');
+ expect(stored).toBeTruthy();
+
+ const parsed = JSON.parse(stored!);
+ expect(parsed.state.presentationMode).toBe(true);
+ });
+ });
+
describe('Future Extensibility', () => {
it('should maintain backward compatibility when new settings are added', () => {
// Set current setting
diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts
index fb909ed..a019a12 100644
--- a/src/stores/settingsStore.ts
+++ b/src/stores/settingsStore.ts
@@ -16,6 +16,10 @@ interface SettingsState {
autoZoomEnabled: boolean;
setAutoZoomEnabled: (enabled: boolean) => void;
+ // Presentation Mode Settings
+ presentationMode: boolean;
+ setPresentationMode: (enabled: boolean) => void;
+
// Future settings can be added here
// Example:
// theme: 'light' | 'dark';
@@ -30,6 +34,11 @@ export const useSettingsStore = create()(
setAutoZoomEnabled: (enabled: boolean) =>
set({ autoZoomEnabled: enabled }),
+ // Presentation Mode Settings
+ presentationMode: false,
+ setPresentationMode: (enabled: boolean) =>
+ set({ presentationMode: enabled }),
+
// Future settings implementations go here
}),
{
diff --git a/src/stores/workspace/types.ts b/src/stores/workspace/types.ts
index 28b491b..c8f193e 100644
--- a/src/stores/workspace/types.ts
+++ b/src/stores/workspace/types.ts
@@ -20,6 +20,7 @@ export interface DocumentMetadata {
y: number;
zoom: number;
};
+ preferPresentationMode?: boolean; // Whether document should open in presentation mode
}
// Recent file entry
@@ -104,6 +105,9 @@ export interface WorkspaceActions {
saveViewport: (documentId: string, viewport: { x: number; y: number; zoom: number }) => void;
getViewport: (documentId: string) => { x: number; y: number; zoom: number } | undefined;
+ // Presentation mode operations
+ setDocumentPresentationPreference: (documentId: string, enabled: boolean) => void;
+
// Transaction helper (internal utility for atomic operations)
executeTypeTransaction: (
operation: () => T,
diff --git a/src/stores/workspaceStore.test.ts b/src/stores/workspaceStore.test.ts
index a966cc7..227cbf0 100644
--- a/src/stores/workspaceStore.test.ts
+++ b/src/stores/workspaceStore.test.ts
@@ -588,6 +588,52 @@ describe('workspaceStore', () => {
});
});
+ describe('Document Presentation Preference', () => {
+ describe('setDocumentPresentationPreference', () => {
+ it('should set document presentation preference', () => {
+ const { createDocument, setDocumentPresentationPreference } = useWorkspaceStore.getState();
+
+ const docId = createDocument('Test Doc');
+ setDocumentPresentationPreference(docId, true);
+
+ const metadata = useWorkspaceStore.getState().documentMetadata.get(docId);
+ expect(metadata?.preferPresentationMode).toBe(true);
+ });
+
+ it('should update existing document preference', () => {
+ const { createDocument, setDocumentPresentationPreference } = useWorkspaceStore.getState();
+
+ const docId = createDocument('Test Doc');
+ setDocumentPresentationPreference(docId, true);
+ setDocumentPresentationPreference(docId, false);
+
+ const metadata = useWorkspaceStore.getState().documentMetadata.get(docId);
+ expect(metadata?.preferPresentationMode).toBe(false);
+ });
+
+ it('should persist document presentation preference to storage', () => {
+ const { createDocument, setDocumentPresentationPreference } = useWorkspaceStore.getState();
+
+ const docId = createDocument('Test Doc');
+ setDocumentPresentationPreference(docId, true);
+
+ // Verify it persists by checking localStorage (using correct prefix)
+ const stored = localStorage.getItem(`constellation:meta:v1:${docId}`);
+ expect(stored).toBeTruthy();
+
+ const parsed = JSON.parse(stored!);
+ expect(parsed.preferPresentationMode).toBe(true);
+ });
+
+ it('should handle invalid document ID gracefully', () => {
+ const { setDocumentPresentationPreference } = useWorkspaceStore.getState();
+
+ // Should not throw error
+ expect(() => setDocumentPresentationPreference('invalid', true)).not.toThrow();
+ });
+ });
+ });
+
describe('Edge Cases', () => {
it('should handle rapid document creation', () => {
const { createDocument } = useWorkspaceStore.getState();
diff --git a/src/stores/workspaceStore.ts b/src/stores/workspaceStore.ts
index 913966f..05ddb50 100644
--- a/src/stores/workspaceStore.ts
+++ b/src/stores/workspaceStore.ts
@@ -25,6 +25,7 @@ import { useToastStore } from './toastStore';
import { useTimelineStore } from './timelineStore';
import { useGraphStore } from './graphStore';
import { useBibliographyStore } from './bibliographyStore';
+import { useSettingsStore } from './settingsStore';
import type { ConstellationState, Timeline } from '../types/timeline';
import { getCurrentGraphFromDocument } from './workspace/documentUtils';
// @ts-expect-error - citation.js doesn't have TypeScript definitions
@@ -552,6 +553,12 @@ export const useWorkspaceStore = create((set, get)
// Switch active document (opens it as a tab if not already open)
switchToDocument: (documentId: string) => {
get().loadDocument(documentId).then(() => {
+ // Check if document prefers presentation mode and auto-enter if so
+ const metadata = get().documentMetadata.get(documentId);
+ if (metadata?.preferPresentationMode) {
+ useSettingsStore.getState().setPresentationMode(true);
+ }
+
set((state) => {
// Add to documentOrder if not already there (reopen closed document)
const newOrder = state.documentOrder.includes(documentId)
@@ -970,6 +977,21 @@ export const useWorkspaceStore = create((set, get)
return metadata?.viewport;
},
+ // Set document presentation mode preference
+ setDocumentPresentationPreference: (documentId: string, enabled: boolean) => {
+ set((state) => {
+ const metadata = state.documentMetadata.get(documentId);
+ if (metadata) {
+ metadata.preferPresentationMode = enabled;
+ const newMetadata = new Map(state.documentMetadata);
+ newMetadata.set(documentId, metadata);
+ saveDocumentMetadata(documentId, metadata);
+ return { documentMetadata: newMetadata };
+ }
+ return {};
+ });
+ },
+
// ============================================================================
// TYPE MANAGEMENT - DOCUMENT-LEVEL OPERATIONS WITH TRANSACTIONS
// ============================================================================
diff --git a/src/styles/presentation.css b/src/styles/presentation.css
new file mode 100644
index 0000000..2a3d443
--- /dev/null
+++ b/src/styles/presentation.css
@@ -0,0 +1,73 @@
+/**
+ * Presentation Mode Touch Optimizations
+ *
+ * CSS styles to optimize the interface for touch table interactions
+ * in presentation mode.
+ */
+
+/* Touch optimization for presentation mode container */
+.presentation-mode {
+ /* Faster tap response - removes 300ms delay */
+ touch-action: manipulation;
+
+ /* Prevent text selection during touch interactions */
+ user-select: none;
+ -webkit-user-select: none;
+ -webkit-touch-callout: none;
+}
+
+/* Larger touch targets for better accessibility */
+.presentation-mode button,
+.presentation-mode .touch-target {
+ min-height: 60px;
+ min-width: 60px;
+}
+
+/* Smooth transitions for touch interactions */
+.presentation-mode .transition-all {
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 200ms;
+}
+
+/* Hide scrollbars in presentation mode for cleaner look */
+.presentation-mode ::-webkit-scrollbar {
+ display: none;
+}
+
+.presentation-mode {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+/* Ensure backdrop blur is supported */
+@supports (backdrop-filter: blur(8px)) {
+ .backdrop-blur-sm {
+ backdrop-filter: blur(4px);
+ }
+
+ .backdrop-blur-md {
+ backdrop-filter: blur(8px);
+ }
+}
+
+/* Fallback for browsers without backdrop-filter support */
+@supports not (backdrop-filter: blur(8px)) {
+ .backdrop-blur-sm {
+ background-color: rgba(0, 0, 0, 0.4) !important;
+ }
+
+ .backdrop-blur-md {
+ background-color: rgba(0, 0, 0, 0.5) !important;
+ }
+}
+
+/* Touch-optimized button feedback */
+.presentation-mode button:active {
+ transform: scale(0.95);
+ transition-duration: 100ms;
+}
+
+/* Prevent double-tap zoom on buttons */
+.presentation-mode button {
+ touch-action: manipulation;
+}