From 9ffd62d54ab57cb53c8b14b7b6686efa866b0807 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Thu, 15 Jan 2026 17:36:00 +0100 Subject: [PATCH] feat: implement presentation mode for touch table displays Add comprehensive presentation/viewer mode optimized for touch table interactions with clean UI and touch-friendly timeline navigation. State Management: - Add presentationMode toggle to settingsStore with localStorage persistence - Add preferPresentationMode to DocumentMetadata for per-document preferences - Auto-enter presentation mode when opening documents that prefer it - Add setDocumentPresentationPreference() helper to workspaceStore UI Components: - Create PresentationTimelineOverlay component with floating timeline control - Previous/Next navigation buttons with chevron icons - Horizontal scrollable state list - Only shows when document has 2+ states - Proper vertical alignment using flex items-stretch and centered content - Scales to ~10 states with max-w-screen-md (768px) container - Create presentation.css for touch optimizations (60px+ touch targets) UI Modifications: - App.tsx: Conditional rendering hides editing chrome in presentation mode - GraphEditor: Disable editing interactions, keep pan/zoom enabled - MenuBar: Add "Presentation Mode" menu item - Global shortcuts: F11 to toggle, Escape to exit presentation mode Tests: - Add presentation mode tests to settingsStore.test.ts - Add document preference tests to workspaceStore.test.ts - All 376 tests passing Co-Authored-By: Claude Sonnet 4.5 --- src/App.tsx | 76 ++++++----- src/components/Editor/GraphEditor.tsx | 46 ++++--- src/components/Menu/MenuBar.tsx | 14 ++ .../PresentationTimelineOverlay.tsx | 124 ++++++++++++++++++ src/hooks/useGlobalShortcuts.ts | 25 ++++ src/stores/settingsStore.test.ts | 47 +++++++ src/stores/settingsStore.ts | 9 ++ src/stores/workspace/types.ts | 4 + src/stores/workspaceStore.test.ts | 46 +++++++ src/stores/workspaceStore.ts | 22 ++++ src/styles/presentation.css | 73 +++++++++++ 11 files changed, 435 insertions(+), 51 deletions(-) create mode 100644 src/components/Presentation/PresentationTimelineOverlay.tsx create mode 100644 src/styles/presentation.css 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 Logo -

Constellation Analyzer

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

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; +}