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 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2026-01-15 17:36:00 +01:00
parent 63ec8eb2e3
commit 9ffd62d54a
11 changed files with 435 additions and 51 deletions

View file

@ -14,9 +14,12 @@ import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts";
import { useDocumentHistory } from "./hooks/useDocumentHistory"; import { useDocumentHistory } from "./hooks/useDocumentHistory";
import { useWorkspaceStore } from "./stores/workspaceStore"; import { useWorkspaceStore } from "./stores/workspaceStore";
import { usePanelStore } from "./stores/panelStore"; import { usePanelStore } from "./stores/panelStore";
import { useSettingsStore } from "./stores/settingsStore";
import { useCreateDocument } from "./hooks/useCreateDocument"; import { useCreateDocument } from "./hooks/useCreateDocument";
import type { Actor, Relation, Group } from "./types"; import type { Actor, Relation, Group } from "./types";
import type { ExportOptions } from "./utils/graphExport"; import type { ExportOptions } from "./utils/graphExport";
import PresentationTimelineOverlay from "./components/Presentation/PresentationTimelineOverlay";
import "./styles/presentation.css";
/** /**
* App - Root application component * App - Root application component
@ -41,6 +44,7 @@ function AppContent() {
const { undo, redo } = useDocumentHistory(); const { undo, redo } = useDocumentHistory();
const { activeDocumentId } = useWorkspaceStore(); const { activeDocumentId } = useWorkspaceStore();
const { leftPanelVisible, rightPanelVisible, bottomPanelVisible } = usePanelStore(); const { leftPanelVisible, rightPanelVisible, bottomPanelVisible } = usePanelStore();
const { presentationMode } = useSettingsStore();
const { handleNewDocument, NewDocumentDialog } = useCreateDocument(); const { handleNewDocument, NewDocumentDialog } = useCreateDocument();
const [showDocumentManager, setShowDocumentManager] = useState(false); const [showDocumentManager, setShowDocumentManager] = useState(false);
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false); const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
@ -129,40 +133,44 @@ function AppContent() {
]); ]);
return ( return (
<div className="flex flex-col h-screen bg-gray-100"> <div className={`flex flex-col h-screen bg-gray-100 ${presentationMode ? 'presentation-mode' : ''}`}>
{/* Header */} {/* Header - Hide in presentation mode */}
<header className="bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg"> {!presentationMode && (
<div className="px-6 py-3"> <header className="bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg">
<div className="flex items-center gap-3"> <div className="px-6 py-3">
<img <div className="flex items-center gap-3">
src="favicon.svg" <img
alt="Constellation Analyzer Logo" src="favicon.svg"
className="w-8 h-8" alt="Constellation Analyzer Logo"
/> className="w-8 h-8"
<h1 className="text-xl font-bold">Constellation Analyzer</h1> />
<span className="text-blue-100 text-sm border-l border-blue-400 pl-3"> <h1 className="text-xl font-bold">Constellation Analyzer</h1>
Visual editor for analyzing actors and their relationships <span className="text-blue-100 text-sm border-l border-blue-400 pl-3">
</span> Visual editor for analyzing actors and their relationships
</span>
</div>
</div> </div>
</div> </header>
</header> )}
{/* Menu Bar */} {/* Menu Bar - Hide in presentation mode */}
<MenuBar {!presentationMode && (
onOpenHelp={() => setShowKeyboardHelp(true)} <MenuBar
onFitView={handleFitView} onOpenHelp={() => setShowKeyboardHelp(true)}
onExport={exportCallbackRef.current || undefined} onFitView={handleFitView}
/> onExport={exportCallbackRef.current || undefined}
/>
)}
{/* Document Tabs */} {/* Document Tabs - Hide in presentation mode */}
<DocumentTabs /> {!presentationMode && <DocumentTabs />}
{/* Main content area with side panels and bottom panel */} {/* Main content area with side panels and bottom panel */}
<main className="flex-1 overflow-hidden flex flex-col"> <main className="flex-1 overflow-hidden flex flex-col">
{/* Top section: Left panel, graph editor, right panel */} {/* Top section: Left panel, graph editor, right panel */}
<div className="flex-1 overflow-hidden flex"> <div className="flex-1 overflow-hidden flex">
{/* Left Panel */} {/* Left Panel - Hide in presentation mode */}
{leftPanelVisible && activeDocumentId && ( {!presentationMode && leftPanelVisible && activeDocumentId && (
<LeftPanel <LeftPanel
ref={leftPanelRef} ref={leftPanelRef}
onDeselectAll={() => { onDeselectAll={() => {
@ -178,8 +186,9 @@ function AppContent() {
)} )}
{/* Center: Graph Editor */} {/* Center: Graph Editor */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden relative">
<GraphEditor <GraphEditor
presentationMode={presentationMode}
selectedNode={selectedNode} selectedNode={selectedNode}
selectedEdge={selectedEdge} selectedEdge={selectedEdge}
selectedGroup={selectedGroup} selectedGroup={selectedGroup}
@ -242,10 +251,15 @@ function AppContent() {
exportCallbackRef.current = callback; exportCallbackRef.current = callback;
}} }}
/> />
{/* Presentation Timeline Overlay - Floats inside graph editor */}
{presentationMode && activeDocumentId && (
<PresentationTimelineOverlay />
)}
</div> </div>
{/* Right Panel */} {/* Right Panel - Hide in presentation mode */}
{rightPanelVisible && activeDocumentId && ( {!presentationMode && rightPanelVisible && activeDocumentId && (
<RightPanel <RightPanel
selectedNode={selectedNode} selectedNode={selectedNode}
selectedEdge={selectedEdge} selectedEdge={selectedEdge}
@ -265,8 +279,8 @@ function AppContent() {
)} )}
</div> </div>
{/* Bottom Panel (Timeline) - show when bottomPanelVisible and there's an active document */} {/* Bottom Panel (Timeline) - Hide in presentation mode, show regular timeline */}
{bottomPanelVisible && activeDocumentId && ( {!presentationMode && bottomPanelVisible && activeDocumentId && (
<BottomPanel /> <BottomPanel />
)} )}
</main> </main>

View file

@ -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% const MAX_ZOOM = 2.5; // Allow zooming in to 250%
interface GraphEditorProps { interface GraphEditorProps {
presentationMode?: boolean;
selectedNode: Actor | null; selectedNode: Actor | null;
selectedEdge: Relation | null; selectedEdge: Relation | null;
selectedGroup: Group | null; selectedGroup: Group | null;
@ -76,7 +77,10 @@ interface GraphEditorProps {
* *
* Usage: Core component that wraps React Flow with custom nodes and edges * 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 // Sync with workspace active document
const { activeDocumentId } = useActiveDocument(); const { activeDocumentId } = useActiveDocument();
const { saveViewport, getViewport } = useWorkspaceStore(); const { saveViewport, getViewport } = useWorkspaceStore();
@ -109,8 +113,6 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect,
showGrid, showGrid,
snapToGrid, snapToGrid,
gridSize, gridSize,
panOnDrag,
zoomOnScroll,
selectedRelationType, selectedRelationType,
} = useEditorStore(); } = useEditorStore();
@ -1070,33 +1072,37 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect,
edges={edges} edges={edges}
onNodesChange={handleNodesChange} onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange} onEdgesChange={handleEdgesChange}
onConnect={handleConnect} onConnect={isEditable ? handleConnect : undefined}
onNodesDelete={handleNodesDelete} onNodesDelete={isEditable ? handleNodesDelete : undefined}
onEdgesDelete={handleEdgesDelete} onEdgesDelete={isEditable ? handleEdgesDelete : undefined}
onNodeClick={handleNodeClick} onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick} onNodeDoubleClick={isEditable ? handleNodeDoubleClick : undefined}
onEdgeClick={handleEdgeClick} onEdgeClick={handleEdgeClick}
onNodeContextMenu={handleNodeContextMenu} onNodeContextMenu={isEditable ? handleNodeContextMenu : undefined}
onEdgeContextMenu={handleEdgeContextMenu} onEdgeContextMenu={isEditable ? handleEdgeContextMenu : undefined}
onPaneContextMenu={handlePaneContextMenu} onPaneContextMenu={isEditable ? handlePaneContextMenu : undefined}
onPaneClick={handlePaneClick} onPaneClick={handlePaneClick}
onMove={handleViewportChange} onMove={handleViewportChange}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
connectionMode={ConnectionMode.Loose} connectionMode={ConnectionMode.Loose}
connectOnClick={true} connectOnClick={isEditable}
snapToGrid={snapToGrid} snapToGrid={snapToGrid}
snapGrid={[gridSize, gridSize]} snapGrid={[gridSize, gridSize]}
panOnDrag={panOnDrag} panOnDrag={true}
zoomOnScroll={zoomOnScroll} zoomOnScroll={true}
panOnScroll={presentationMode ? true : undefined}
nodesDraggable={isEditable}
nodesConnectable={isEditable}
elementsSelectable={isEditable}
minZoom={MIN_ZOOM} minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM} maxZoom={MAX_ZOOM}
connectionRadius={0} connectionRadius={0}
fitView fitView
attributionPosition="bottom-left" attributionPosition="bottom-left"
> >
{/* Background grid */} {/* Background grid - Hide in presentation mode */}
{showGrid && ( {!presentationMode && showGrid && (
<Background <Background
variant={BackgroundVariant.Dots} variant={BackgroundVariant.Dots}
gap={gridSize} gap={gridSize}
@ -1105,10 +1111,10 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect,
/> />
)} )}
{/* Zoom and pan controls */} {/* Zoom and pan controls - Simplified in presentation mode */}
<Controls /> <Controls showInteractive={isEditable} />
{/* MiniMap for navigation */} {/* MiniMap for navigation - Read-only in presentation mode */}
<MiniMap <MiniMap
nodeColor={(node) => { nodeColor={(node) => {
const actor = node as Actor; const actor = node as Actor;
@ -1117,8 +1123,8 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect,
); );
return nodeType?.color || "#6b7280"; return nodeType?.color || "#6b7280";
}} }}
pannable pannable={isEditable}
zoomable zoomable={isEditable}
/> />
</ReactFlow> </ReactFlow>

View file

@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { useWorkspaceStore } from '../../stores/workspaceStore'; import { useWorkspaceStore } from '../../stores/workspaceStore';
import { useSettingsStore } from '../../stores/settingsStore';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory'; import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import { useDocumentHistory } from '../../hooks/useDocumentHistory'; import { useDocumentHistory } from '../../hooks/useDocumentHistory';
import DocumentManager from '../Workspace/DocumentManager'; import DocumentManager from '../Workspace/DocumentManager';
@ -56,6 +57,7 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
const { clearGraph } = useGraphWithHistory(); const { clearGraph } = useGraphWithHistory();
const { undo, redo, canUndo, canRedo, undoDescription, redoDescription } = useDocumentHistory(); 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) // Listen for custom event to close all menus (e.g., from graph canvas clicks, context menu opens)
useEffect(() => { useEffect(() => {
@ -440,6 +442,18 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
<span className="text-xs text-gray-400">{getShortcutLabel('fit-view')}</span> <span className="text-xs text-gray-400">{getShortcutLabel('fit-view')}</span>
)} )}
</button> </button>
{/* Presentation Mode */}
<button
onClick={() => {
setPresentationMode(true);
closeMenu();
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between"
>
<span>Presentation Mode</span>
<span className="text-xs text-gray-400">F11</span>
</button>
</div> </div>
)} )}
</div> </div>

View file

@ -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<HTMLDivElement>(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 (
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 z-50">
{/* Timeline Navigation */}
<div className="bg-white rounded-lg shadow-xl border border-gray-200 flex items-stretch">
{/* Previous Button */}
<button
onClick={handlePrevious}
disabled={currentIndex === 0}
className={`px-3 py-2 rounded-l-lg touch-manipulation transition-colors flex items-center justify-center ${
currentIndex === 0
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-700 hover:bg-gray-100'
}`}
aria-label="Previous State"
>
<ChevronLeftIcon sx={{ fontSize: 28 }} />
</button>
{/* State List - Horizontal Scrollable */}
<div
ref={scrollContainerRef}
className="flex gap-2 overflow-x-auto py-2 px-3 max-w-screen-md"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
>
{states.map((state) => (
<button
key={state.id}
data-state-id={state.id}
onClick={() => switchToState(state.id)}
className={`
px-6 py-3 rounded-md whitespace-nowrap
touch-manipulation cursor-pointer
transition-all duration-200 text-sm
flex items-center justify-center
${
state.id === currentStateId
? 'bg-blue-500 text-white font-semibold shadow-md'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}
`}
aria-label={`Navigate to ${state.label}`}
aria-current={state.id === currentStateId ? 'true' : undefined}
>
{state.label}
</button>
))}
</div>
{/* Next Button */}
<button
onClick={handleNext}
disabled={currentIndex === states.length - 1}
className={`px-3 py-2 rounded-r-lg touch-manipulation transition-colors flex items-center justify-center ${
currentIndex === states.length - 1
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-700 hover:bg-gray-100'
}`}
aria-label="Next State"
>
<ChevronRightIcon sx={{ fontSize: 28 }} />
</button>
</div>
</div>
);
};
export default PresentationTimelineOverlay;

View file

@ -1,6 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useWorkspaceStore } from "../stores/workspaceStore"; import { useWorkspaceStore } from "../stores/workspaceStore";
import { useSettingsStore } from "../stores/settingsStore";
import type { KeyboardShortcut } from "./useKeyboardShortcutManager"; import type { KeyboardShortcut } from "./useKeyboardShortcutManager";
/** /**
@ -29,6 +30,7 @@ export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) {
closeDocument, closeDocument,
saveDocument, saveDocument,
} = useWorkspaceStore(); } = useWorkspaceStore();
const { presentationMode, setPresentationMode } = useSettingsStore();
useEffect(() => { useEffect(() => {
const shortcutDefinitions: KeyboardShortcut[] = [ const shortcutDefinitions: KeyboardShortcut[] = [
@ -165,6 +167,27 @@ export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) {
category: "View", category: "View",
enabled: !!options.onFitView, 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", id: "show-help",
description: "Show Keyboard Shortcuts", description: "Show Keyboard Shortcuts",
@ -203,6 +226,8 @@ export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) {
switchToDocument, switchToDocument,
closeDocument, closeDocument,
saveDocument, saveDocument,
presentationMode,
setPresentationMode,
options, options,
]); ]);
} }

View file

@ -9,6 +9,7 @@ describe('settingsStore', () => {
// Reset store to initial state // Reset store to initial state
useSettingsStore.setState({ useSettingsStore.setState({
autoZoomEnabled: true, autoZoomEnabled: true,
presentationMode: false,
}); });
}); });
@ -17,6 +18,7 @@ describe('settingsStore', () => {
const state = useSettingsStore.getState(); const state = useSettingsStore.getState();
expect(state.autoZoomEnabled).toBe(true); 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', () => { describe('Future Extensibility', () => {
it('should maintain backward compatibility when new settings are added', () => { it('should maintain backward compatibility when new settings are added', () => {
// Set current setting // Set current setting

View file

@ -16,6 +16,10 @@ interface SettingsState {
autoZoomEnabled: boolean; autoZoomEnabled: boolean;
setAutoZoomEnabled: (enabled: boolean) => void; setAutoZoomEnabled: (enabled: boolean) => void;
// Presentation Mode Settings
presentationMode: boolean;
setPresentationMode: (enabled: boolean) => void;
// Future settings can be added here // Future settings can be added here
// Example: // Example:
// theme: 'light' | 'dark'; // theme: 'light' | 'dark';
@ -30,6 +34,11 @@ export const useSettingsStore = create<SettingsState>()(
setAutoZoomEnabled: (enabled: boolean) => setAutoZoomEnabled: (enabled: boolean) =>
set({ autoZoomEnabled: enabled }), set({ autoZoomEnabled: enabled }),
// Presentation Mode Settings
presentationMode: false,
setPresentationMode: (enabled: boolean) =>
set({ presentationMode: enabled }),
// Future settings implementations go here // Future settings implementations go here
}), }),
{ {

View file

@ -20,6 +20,7 @@ export interface DocumentMetadata {
y: number; y: number;
zoom: number; zoom: number;
}; };
preferPresentationMode?: boolean; // Whether document should open in presentation mode
} }
// Recent file entry // Recent file entry
@ -104,6 +105,9 @@ export interface WorkspaceActions {
saveViewport: (documentId: string, viewport: { x: number; y: number; zoom: number }) => void; saveViewport: (documentId: string, viewport: { x: number; y: number; zoom: number }) => void;
getViewport: (documentId: string) => { x: number; y: number; zoom: number } | undefined; 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) // Transaction helper (internal utility for atomic operations)
executeTypeTransaction: <T>( executeTypeTransaction: <T>(
operation: () => T, operation: () => T,

View file

@ -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', () => { describe('Edge Cases', () => {
it('should handle rapid document creation', () => { it('should handle rapid document creation', () => {
const { createDocument } = useWorkspaceStore.getState(); const { createDocument } = useWorkspaceStore.getState();

View file

@ -25,6 +25,7 @@ import { useToastStore } from './toastStore';
import { useTimelineStore } from './timelineStore'; import { useTimelineStore } from './timelineStore';
import { useGraphStore } from './graphStore'; import { useGraphStore } from './graphStore';
import { useBibliographyStore } from './bibliographyStore'; import { useBibliographyStore } from './bibliographyStore';
import { useSettingsStore } from './settingsStore';
import type { ConstellationState, Timeline } from '../types/timeline'; import type { ConstellationState, Timeline } from '../types/timeline';
import { getCurrentGraphFromDocument } from './workspace/documentUtils'; import { getCurrentGraphFromDocument } from './workspace/documentUtils';
// @ts-expect-error - citation.js doesn't have TypeScript definitions // @ts-expect-error - citation.js doesn't have TypeScript definitions
@ -552,6 +553,12 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
// Switch active document (opens it as a tab if not already open) // Switch active document (opens it as a tab if not already open)
switchToDocument: (documentId: string) => { switchToDocument: (documentId: string) => {
get().loadDocument(documentId).then(() => { 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) => { set((state) => {
// Add to documentOrder if not already there (reopen closed document) // Add to documentOrder if not already there (reopen closed document)
const newOrder = state.documentOrder.includes(documentId) const newOrder = state.documentOrder.includes(documentId)
@ -970,6 +977,21 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
return metadata?.viewport; 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 // TYPE MANAGEMENT - DOCUMENT-LEVEL OPERATIONS WITH TRANSACTIONS
// ============================================================================ // ============================================================================

View file

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