mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-26 23:43:40 +00:00
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:
parent
63ec8eb2e3
commit
9ffd62d54a
11 changed files with 435 additions and 51 deletions
76
src/App.tsx
76
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 (
|
||||
<div className="flex flex-col h-screen bg-gray-100">
|
||||
{/* Header */}
|
||||
<header className="bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg">
|
||||
<div className="px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="favicon.svg"
|
||||
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">
|
||||
Visual editor for analyzing actors and their relationships
|
||||
</span>
|
||||
<div className={`flex flex-col h-screen bg-gray-100 ${presentationMode ? 'presentation-mode' : ''}`}>
|
||||
{/* Header - Hide in presentation mode */}
|
||||
{!presentationMode && (
|
||||
<header className="bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg">
|
||||
<div className="px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="favicon.svg"
|
||||
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">
|
||||
Visual editor for analyzing actors and their relationships
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Menu Bar */}
|
||||
<MenuBar
|
||||
onOpenHelp={() => setShowKeyboardHelp(true)}
|
||||
onFitView={handleFitView}
|
||||
onExport={exportCallbackRef.current || undefined}
|
||||
/>
|
||||
{/* Menu Bar - Hide in presentation mode */}
|
||||
{!presentationMode && (
|
||||
<MenuBar
|
||||
onOpenHelp={() => setShowKeyboardHelp(true)}
|
||||
onFitView={handleFitView}
|
||||
onExport={exportCallbackRef.current || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Document Tabs */}
|
||||
<DocumentTabs />
|
||||
{/* Document Tabs - Hide in presentation mode */}
|
||||
{!presentationMode && <DocumentTabs />}
|
||||
|
||||
{/* Main content area with side panels and bottom panel */}
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* Top section: Left panel, graph editor, right panel */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{/* Left Panel */}
|
||||
{leftPanelVisible && activeDocumentId && (
|
||||
{/* Left Panel - Hide in presentation mode */}
|
||||
{!presentationMode && leftPanelVisible && activeDocumentId && (
|
||||
<LeftPanel
|
||||
ref={leftPanelRef}
|
||||
onDeselectAll={() => {
|
||||
|
|
@ -178,8 +186,9 @@ function AppContent() {
|
|||
)}
|
||||
|
||||
{/* Center: Graph Editor */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<GraphEditor
|
||||
presentationMode={presentationMode}
|
||||
selectedNode={selectedNode}
|
||||
selectedEdge={selectedEdge}
|
||||
selectedGroup={selectedGroup}
|
||||
|
|
@ -242,10 +251,15 @@ function AppContent() {
|
|||
exportCallbackRef.current = callback;
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Presentation Timeline Overlay - Floats inside graph editor */}
|
||||
{presentationMode && activeDocumentId && (
|
||||
<PresentationTimelineOverlay />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Panel */}
|
||||
{rightPanelVisible && activeDocumentId && (
|
||||
{/* Right Panel - Hide in presentation mode */}
|
||||
{!presentationMode && rightPanelVisible && activeDocumentId && (
|
||||
<RightPanel
|
||||
selectedNode={selectedNode}
|
||||
selectedEdge={selectedEdge}
|
||||
|
|
@ -265,8 +279,8 @@ function AppContent() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<BottomPanel />
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={gridSize}
|
||||
|
|
@ -1105,10 +1111,10 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onGroupSelect, onMultiSelect,
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Zoom and pan controls */}
|
||||
<Controls />
|
||||
{/* Zoom and pan controls - Simplified in presentation mode */}
|
||||
<Controls showInteractive={isEditable} />
|
||||
|
||||
{/* MiniMap for navigation */}
|
||||
{/* MiniMap for navigation - Read-only in presentation mode */}
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
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}
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MenuBarProps> = ({ 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<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
|
|||
<span className="text-xs text-gray-400">{getShortcutLabel('fit-view')}</span>
|
||||
)}
|
||||
</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>
|
||||
|
|
|
|||
124
src/components/Presentation/PresentationTimelineOverlay.tsx
Normal file
124
src/components/Presentation/PresentationTimelineOverlay.tsx
Normal 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;
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<SettingsState>()(
|
|||
setAutoZoomEnabled: (enabled: boolean) =>
|
||||
set({ autoZoomEnabled: enabled }),
|
||||
|
||||
// Presentation Mode Settings
|
||||
presentationMode: false,
|
||||
setPresentationMode: (enabled: boolean) =>
|
||||
set({ presentationMode: enabled }),
|
||||
|
||||
// Future settings implementations go here
|
||||
}),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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: <T>(
|
||||
operation: () => T,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<Workspace & WorkspaceActions>((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<Workspace & WorkspaceActions>((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
|
||||
// ============================================================================
|
||||
|
|
|
|||
73
src/styles/presentation.css
Normal file
73
src/styles/presentation.css
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue