mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
Removes duplicate confirmation dialogs when deleting documents. Previously, users were asked twice: once by the UI component's custom dialog and again by window.confirm in the store. Changes: - Remove window.confirm from workspaceStore deleteDocument function - Add useConfirm hook to DocumentTabs component for delete action - Consistent confirmation UX across DocumentManager and DocumentTabs Both components now show the same styled confirmation dialog with proper handling of unsaved changes warnings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
891 lines
27 KiB
TypeScript
891 lines
27 KiB
TypeScript
import { create } from 'zustand';
|
|
import type { ConstellationDocument } from './persistence/types';
|
|
import type { Workspace, WorkspaceActions, DocumentMetadata, WorkspaceSettings } from './workspace/types';
|
|
import { createDocument as createDocumentHelper, serializeActors, serializeRelations } from './persistence/saver';
|
|
import { selectFileForImport, exportDocumentToFile } from './persistence/fileIO';
|
|
import {
|
|
generateWorkspaceId,
|
|
generateDocumentId,
|
|
saveWorkspaceState,
|
|
loadWorkspaceState,
|
|
saveDocumentToStorage,
|
|
loadDocumentFromStorage,
|
|
deleteDocumentFromStorage,
|
|
saveDocumentMetadata,
|
|
loadDocumentMetadata,
|
|
loadAllDocumentMetadata,
|
|
clearWorkspaceStorage,
|
|
} from './workspace/persistence';
|
|
import { migrateToWorkspace, needsMigration } from './workspace/migration';
|
|
import {
|
|
exportAllDocumentsAsZip,
|
|
exportWorkspace as exportWorkspaceToZip,
|
|
selectWorkspaceZipForImport,
|
|
} from './workspace/workspaceIO';
|
|
import { useToastStore } from './toastStore';
|
|
import { useTimelineStore } from './timelineStore';
|
|
import { useGraphStore } from './graphStore';
|
|
import type { ConstellationState, Timeline } from '../types/timeline';
|
|
import { getCurrentGraphFromDocument } from './persistence/loader';
|
|
|
|
/**
|
|
* Workspace Store
|
|
*
|
|
* Manages multiple documents, tabs, and workspace-level settings
|
|
*/
|
|
|
|
// Default workspace settings
|
|
const defaultSettings: WorkspaceSettings = {
|
|
maxOpenDocuments: 10,
|
|
autoSaveEnabled: true,
|
|
defaultNodeTypes: [
|
|
{ id: 'person', label: 'Person', color: '#3b82f6', icon: 'Person', description: 'Individual person' },
|
|
{ id: 'organization', label: 'Organization', color: '#10b981', icon: 'Business', description: 'Company or group' },
|
|
{ id: 'system', label: 'System', color: '#f59e0b', icon: 'Computer', description: 'Technical system' },
|
|
{ id: 'concept', label: 'Concept', color: '#8b5cf6', icon: 'Lightbulb', description: 'Abstract concept' },
|
|
],
|
|
defaultEdgeTypes: [
|
|
{ id: 'collaborates', label: 'Collaborates', color: '#3b82f6', style: 'solid' },
|
|
{ id: 'reports-to', label: 'Reports To', color: '#10b981', style: 'solid' },
|
|
{ id: 'depends-on', label: 'Depends On', color: '#f59e0b', style: 'dashed' },
|
|
{ id: 'influences', label: 'Influences', color: '#8b5cf6', style: 'dotted' },
|
|
],
|
|
recentFiles: [],
|
|
};
|
|
|
|
// Initialize workspace
|
|
function initializeWorkspace(): Workspace {
|
|
// Check if migration is needed
|
|
if (needsMigration()) {
|
|
console.log('Migration needed, migrating legacy data...');
|
|
const migratedState = migrateToWorkspace();
|
|
if (migratedState) {
|
|
// Load migrated document
|
|
const docId = migratedState.activeDocumentId!;
|
|
const doc = loadDocumentFromStorage(docId);
|
|
const meta = loadDocumentMetadata(docId);
|
|
|
|
return {
|
|
...migratedState,
|
|
documents: doc ? new Map([[docId, doc]]) : new Map(),
|
|
documentMetadata: meta ? new Map([[docId, meta]]) : new Map(),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Try to load existing workspace
|
|
const savedState = loadWorkspaceState();
|
|
if (savedState) {
|
|
// Load all document metadata
|
|
const metadata = loadAllDocumentMetadata();
|
|
|
|
// Load active document if exists
|
|
const documents = new Map<string, ConstellationDocument>();
|
|
if (savedState.activeDocumentId) {
|
|
const doc = loadDocumentFromStorage(savedState.activeDocumentId);
|
|
if (doc) {
|
|
documents.set(savedState.activeDocumentId, doc);
|
|
|
|
// Load timeline if it exists
|
|
if (doc.timeline) {
|
|
useTimelineStore.getState().loadTimeline(savedState.activeDocumentId, doc.timeline as unknown as Timeline);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
...savedState,
|
|
documents,
|
|
documentMetadata: metadata,
|
|
};
|
|
}
|
|
|
|
// Create new workspace with no documents (start with empty state)
|
|
const workspaceId = generateWorkspaceId();
|
|
|
|
// Save initial state
|
|
const initialState = {
|
|
workspaceId,
|
|
workspaceName: 'My Workspace',
|
|
documentOrder: [],
|
|
activeDocumentId: null,
|
|
settings: defaultSettings,
|
|
};
|
|
|
|
saveWorkspaceState(initialState);
|
|
|
|
return {
|
|
...initialState,
|
|
documents: new Map(),
|
|
documentMetadata: new Map(),
|
|
};
|
|
}
|
|
|
|
export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get) => ({
|
|
...initializeWorkspace(),
|
|
|
|
// Create new document
|
|
createDocument: (title = 'Untitled Analysis') => {
|
|
const state = get();
|
|
const documentId = generateDocumentId();
|
|
const now = new Date().toISOString();
|
|
|
|
// Create copies of the default types using spread to avoid any circular references from store
|
|
const nodeTypes = state.settings.defaultNodeTypes.map(nt => ({ ...nt }));
|
|
const edgeTypes = state.settings.defaultEdgeTypes.map(et => ({ ...et }));
|
|
|
|
const newDoc = createDocumentHelper(
|
|
[],
|
|
[],
|
|
nodeTypes,
|
|
edgeTypes
|
|
);
|
|
newDoc.metadata.documentId = documentId;
|
|
newDoc.metadata.title = title;
|
|
|
|
const metadata: DocumentMetadata = {
|
|
id: documentId,
|
|
title,
|
|
isDirty: false,
|
|
lastModified: now,
|
|
};
|
|
|
|
// Save document
|
|
saveDocumentToStorage(documentId, newDoc);
|
|
saveDocumentMetadata(documentId, metadata);
|
|
|
|
// Load the timeline from the newly created document into timelineStore
|
|
useTimelineStore.getState().loadTimeline(documentId, newDoc.timeline as unknown as Timeline);
|
|
|
|
// Update workspace
|
|
set((state) => {
|
|
const newDocuments = new Map(state.documents);
|
|
newDocuments.set(documentId, newDoc);
|
|
|
|
const newMetadata = new Map(state.documentMetadata);
|
|
newMetadata.set(documentId, metadata);
|
|
|
|
const newOrder = [...state.documentOrder, documentId];
|
|
|
|
const newState = {
|
|
documents: newDocuments,
|
|
documentMetadata: newMetadata,
|
|
documentOrder: newOrder,
|
|
activeDocumentId: documentId,
|
|
};
|
|
|
|
// Save workspace state
|
|
saveWorkspaceState({
|
|
workspaceId: state.workspaceId,
|
|
workspaceName: state.workspaceName,
|
|
documentOrder: newOrder,
|
|
activeDocumentId: documentId,
|
|
settings: state.settings,
|
|
});
|
|
|
|
return newState;
|
|
});
|
|
|
|
useToastStore.getState().showToast(`Document "${title}" created`, 'success');
|
|
|
|
return documentId;
|
|
},
|
|
|
|
// Create new document from existing document's types (template)
|
|
createDocumentFromTemplate: (sourceDocumentId: string, title = 'Untitled Analysis') => {
|
|
const state = get();
|
|
const sourceDoc = state.documents.get(sourceDocumentId);
|
|
|
|
if (!sourceDoc) {
|
|
console.error(`Source document ${sourceDocumentId} not found`);
|
|
return '';
|
|
}
|
|
|
|
const documentId = generateDocumentId();
|
|
const now = new Date().toISOString();
|
|
|
|
// Get node and edge types from source document's current graph
|
|
const sourceGraph = getCurrentGraphFromDocument(sourceDoc);
|
|
if (!sourceGraph) {
|
|
console.error('Failed to get graph from source document');
|
|
return '';
|
|
}
|
|
|
|
// Create new document with the same node and edge types, but no actors/relations
|
|
const newDoc = createDocumentHelper(
|
|
[],
|
|
[],
|
|
sourceGraph.nodeTypes,
|
|
sourceGraph.edgeTypes
|
|
);
|
|
newDoc.metadata.documentId = documentId;
|
|
newDoc.metadata.title = title;
|
|
|
|
const metadata: DocumentMetadata = {
|
|
id: documentId,
|
|
title,
|
|
isDirty: false,
|
|
lastModified: now,
|
|
};
|
|
|
|
// Save document
|
|
saveDocumentToStorage(documentId, newDoc);
|
|
saveDocumentMetadata(documentId, metadata);
|
|
|
|
// Load the timeline from the newly created document into timelineStore
|
|
useTimelineStore.getState().loadTimeline(documentId, newDoc.timeline as unknown as Timeline);
|
|
|
|
// Update workspace
|
|
set((state) => {
|
|
const newDocuments = new Map(state.documents);
|
|
newDocuments.set(documentId, newDoc);
|
|
|
|
const newMetadata = new Map(state.documentMetadata);
|
|
newMetadata.set(documentId, metadata);
|
|
|
|
const newOrder = [...state.documentOrder, documentId];
|
|
|
|
const newState = {
|
|
documents: newDocuments,
|
|
documentMetadata: newMetadata,
|
|
documentOrder: newOrder,
|
|
activeDocumentId: documentId,
|
|
};
|
|
|
|
// Save workspace state
|
|
saveWorkspaceState({
|
|
workspaceId: state.workspaceId,
|
|
workspaceName: state.workspaceName,
|
|
documentOrder: newOrder,
|
|
activeDocumentId: documentId,
|
|
settings: state.settings,
|
|
});
|
|
|
|
return newState;
|
|
});
|
|
|
|
return documentId;
|
|
},
|
|
|
|
// Load document from storage (if not already loaded)
|
|
loadDocument: async (documentId: string) => {
|
|
const state = get();
|
|
|
|
// Check if already loaded
|
|
if (state.documents.has(documentId)) {
|
|
return;
|
|
}
|
|
|
|
// Load from storage
|
|
const doc = loadDocumentFromStorage(documentId);
|
|
if (!doc) {
|
|
console.error(`Document ${documentId} not found`);
|
|
return;
|
|
}
|
|
|
|
// Load timeline if it exists
|
|
if (doc.timeline) {
|
|
useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as Timeline);
|
|
}
|
|
|
|
set((state) => {
|
|
const newDocuments = new Map(state.documents);
|
|
newDocuments.set(documentId, doc);
|
|
|
|
return { documents: newDocuments };
|
|
});
|
|
},
|
|
|
|
// Unload document from memory (but keep in storage and tab list)
|
|
unloadDocument: (documentId: string) => {
|
|
const state = get();
|
|
|
|
// Don't unload if it's the active document
|
|
if (documentId === state.activeDocumentId) {
|
|
console.warn('Cannot unload active document');
|
|
return;
|
|
}
|
|
|
|
// Don't unload if document has unsaved changes
|
|
const metadata = state.documentMetadata.get(documentId);
|
|
if (metadata?.isDirty) {
|
|
console.warn(`Cannot unload document with unsaved changes: ${documentId}`);
|
|
return;
|
|
}
|
|
|
|
set((state) => {
|
|
const newDocuments = new Map(state.documents);
|
|
newDocuments.delete(documentId);
|
|
|
|
return { documents: newDocuments };
|
|
});
|
|
},
|
|
|
|
// Close document (unload from memory, but keep in storage)
|
|
closeDocument: (documentId: string) => {
|
|
const state = get();
|
|
|
|
// Check for unsaved changes
|
|
const metadata = state.documentMetadata.get(documentId);
|
|
if (metadata?.isDirty) {
|
|
const confirmed = window.confirm(
|
|
`"${metadata.title}" has unsaved changes. Close anyway?`
|
|
);
|
|
if (!confirmed) return false;
|
|
}
|
|
|
|
set((state) => {
|
|
const newDocuments = new Map(state.documents);
|
|
newDocuments.delete(documentId);
|
|
|
|
const newOrder = state.documentOrder.filter(id => id !== documentId);
|
|
const newActiveId = state.activeDocumentId === documentId
|
|
? (newOrder[0] || null)
|
|
: state.activeDocumentId;
|
|
|
|
// Save workspace state
|
|
saveWorkspaceState({
|
|
workspaceId: state.workspaceId,
|
|
workspaceName: state.workspaceName,
|
|
documentOrder: newOrder,
|
|
activeDocumentId: newActiveId,
|
|
settings: state.settings,
|
|
});
|
|
|
|
return {
|
|
documents: newDocuments,
|
|
documentOrder: newOrder,
|
|
activeDocumentId: newActiveId,
|
|
};
|
|
});
|
|
|
|
return true;
|
|
},
|
|
|
|
// Delete document (remove from storage)
|
|
deleteDocument: (documentId: string) => {
|
|
const state = get();
|
|
|
|
const metadata = state.documentMetadata.get(documentId);
|
|
const docTitle = metadata?.title || 'Untitled';
|
|
|
|
// Delete from storage
|
|
deleteDocumentFromStorage(documentId);
|
|
|
|
set((state) => {
|
|
const newDocuments = new Map(state.documents);
|
|
newDocuments.delete(documentId);
|
|
|
|
const newMetadata = new Map(state.documentMetadata);
|
|
newMetadata.delete(documentId);
|
|
|
|
const newOrder = state.documentOrder.filter(id => id !== documentId);
|
|
const newActiveId = state.activeDocumentId === documentId
|
|
? (newOrder[0] || null)
|
|
: state.activeDocumentId;
|
|
|
|
// Save workspace state
|
|
saveWorkspaceState({
|
|
workspaceId: state.workspaceId,
|
|
workspaceName: state.workspaceName,
|
|
documentOrder: newOrder,
|
|
activeDocumentId: newActiveId,
|
|
settings: state.settings,
|
|
});
|
|
|
|
return {
|
|
documents: newDocuments,
|
|
documentMetadata: newMetadata,
|
|
documentOrder: newOrder,
|
|
activeDocumentId: newActiveId,
|
|
};
|
|
});
|
|
|
|
useToastStore.getState().showToast(`Document "${docTitle}" deleted`, 'info');
|
|
|
|
return true;
|
|
},
|
|
|
|
// Rename document
|
|
renameDocument: (documentId: string, newTitle: string) => {
|
|
set((state) => {
|
|
const doc = state.documents.get(documentId);
|
|
if (doc) {
|
|
doc.metadata.title = newTitle;
|
|
saveDocumentToStorage(documentId, doc);
|
|
}
|
|
|
|
const metadata = state.documentMetadata.get(documentId);
|
|
if (metadata) {
|
|
metadata.title = newTitle;
|
|
metadata.lastModified = new Date().toISOString();
|
|
saveDocumentMetadata(documentId, metadata);
|
|
|
|
const newMetadata = new Map(state.documentMetadata);
|
|
newMetadata.set(documentId, metadata);
|
|
|
|
return { documentMetadata: newMetadata };
|
|
}
|
|
|
|
return {};
|
|
});
|
|
|
|
useToastStore.getState().showToast(`Document renamed to "${newTitle}"`, 'success');
|
|
},
|
|
|
|
// Duplicate document
|
|
duplicateDocument: (documentId: string) => {
|
|
const state = get();
|
|
const sourceDoc = state.documents.get(documentId);
|
|
if (!sourceDoc) {
|
|
console.error(`Document ${documentId} not found`);
|
|
useToastStore.getState().showToast('Failed to duplicate: Document not found', 'error');
|
|
return '';
|
|
}
|
|
|
|
const newDocumentId = generateDocumentId();
|
|
const sourceMeta = state.documentMetadata.get(documentId);
|
|
const newTitle = `${sourceMeta?.title || 'Untitled'} (Copy)`;
|
|
|
|
const duplicatedDoc: ConstellationDocument = {
|
|
...sourceDoc,
|
|
metadata: {
|
|
...sourceDoc.metadata,
|
|
documentId: newDocumentId,
|
|
title: newTitle,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
};
|
|
|
|
const metadata: DocumentMetadata = {
|
|
id: newDocumentId,
|
|
title: newTitle,
|
|
isDirty: false,
|
|
lastModified: new Date().toISOString(),
|
|
};
|
|
|
|
// Save
|
|
saveDocumentToStorage(newDocumentId, duplicatedDoc);
|
|
saveDocumentMetadata(newDocumentId, metadata);
|
|
|
|
// Update workspace
|
|
set((state) => {
|
|
const newDocuments = new Map(state.documents);
|
|
newDocuments.set(newDocumentId, duplicatedDoc);
|
|
|
|
const newMetadata = new Map(state.documentMetadata);
|
|
newMetadata.set(newDocumentId, metadata);
|
|
|
|
const newOrder = [...state.documentOrder, newDocumentId];
|
|
|
|
saveWorkspaceState({
|
|
workspaceId: state.workspaceId,
|
|
workspaceName: state.workspaceName,
|
|
documentOrder: newOrder,
|
|
activeDocumentId: state.activeDocumentId,
|
|
settings: state.settings,
|
|
});
|
|
|
|
return {
|
|
documents: newDocuments,
|
|
documentMetadata: newMetadata,
|
|
documentOrder: newOrder,
|
|
};
|
|
});
|
|
|
|
// Initialize timeline for duplicated document - always copy the timeline
|
|
// since all documents now have timelines
|
|
useTimelineStore.getState().loadTimeline(newDocumentId, duplicatedDoc.timeline as unknown as Timeline);
|
|
|
|
useToastStore.getState().showToast(`Document duplicated as "${newTitle}"`, 'success');
|
|
|
|
return newDocumentId;
|
|
},
|
|
|
|
// Switch active document (opens it as a tab if not already open)
|
|
switchToDocument: (documentId: string) => {
|
|
get().loadDocument(documentId).then(() => {
|
|
set((state) => {
|
|
// Add to documentOrder if not already there (reopen closed document)
|
|
const newOrder = state.documentOrder.includes(documentId)
|
|
? state.documentOrder
|
|
: [...state.documentOrder, documentId];
|
|
|
|
saveWorkspaceState({
|
|
workspaceId: state.workspaceId,
|
|
workspaceName: state.workspaceName,
|
|
documentOrder: newOrder,
|
|
activeDocumentId: documentId,
|
|
settings: state.settings,
|
|
});
|
|
|
|
return {
|
|
documentOrder: newOrder,
|
|
activeDocumentId: documentId,
|
|
};
|
|
});
|
|
});
|
|
},
|
|
|
|
// Reorder documents
|
|
reorderDocuments: (newOrder: string[]) => {
|
|
set((state) => {
|
|
saveWorkspaceState({
|
|
workspaceId: state.workspaceId,
|
|
workspaceName: state.workspaceName,
|
|
documentOrder: newOrder,
|
|
activeDocumentId: state.activeDocumentId,
|
|
settings: state.settings,
|
|
});
|
|
|
|
return { documentOrder: newOrder };
|
|
});
|
|
},
|
|
|
|
// Import document from file
|
|
importDocumentFromFile: async () => {
|
|
return new Promise((resolve) => {
|
|
selectFileForImport(
|
|
(data) => {
|
|
const documentId = generateDocumentId();
|
|
const now = new Date().toISOString();
|
|
|
|
// Serialize actors and relations for storage
|
|
const serializedNodes = serializeActors(data.nodes);
|
|
const serializedEdges = serializeRelations(data.edges);
|
|
|
|
const importedDoc = createDocumentHelper(
|
|
serializedNodes,
|
|
serializedEdges,
|
|
data.nodeTypes,
|
|
data.edgeTypes
|
|
);
|
|
importedDoc.metadata.documentId = documentId;
|
|
importedDoc.metadata.title = 'Imported Analysis';
|
|
|
|
const metadata: DocumentMetadata = {
|
|
id: documentId,
|
|
title: 'Imported Analysis',
|
|
isDirty: false,
|
|
lastModified: now,
|
|
};
|
|
|
|
saveDocumentToStorage(documentId, importedDoc);
|
|
saveDocumentMetadata(documentId, metadata);
|
|
|
|
// Load the timeline from the imported document into timelineStore
|
|
useTimelineStore.getState().loadTimeline(documentId, importedDoc.timeline as unknown as Timeline);
|
|
|
|
set((state) => {
|
|
const newDocuments = new Map(state.documents);
|
|
newDocuments.set(documentId, importedDoc);
|
|
|
|
const newMetadata = new Map(state.documentMetadata);
|
|
newMetadata.set(documentId, metadata);
|
|
|
|
const newOrder = [...state.documentOrder, documentId];
|
|
|
|
saveWorkspaceState({
|
|
workspaceId: state.workspaceId,
|
|
workspaceName: state.workspaceName,
|
|
documentOrder: newOrder,
|
|
activeDocumentId: documentId,
|
|
settings: state.settings,
|
|
});
|
|
|
|
return {
|
|
documents: newDocuments,
|
|
documentMetadata: newMetadata,
|
|
documentOrder: newOrder,
|
|
activeDocumentId: documentId,
|
|
};
|
|
});
|
|
|
|
// Show success toast
|
|
useToastStore.getState().showToast('Document imported successfully', 'success');
|
|
|
|
resolve(documentId);
|
|
},
|
|
(error) => {
|
|
// Show error toast
|
|
useToastStore.getState().showToast(`Failed to import file: ${error}`, 'error', 5000);
|
|
resolve(null);
|
|
}
|
|
);
|
|
});
|
|
},
|
|
|
|
// Export document to file
|
|
exportDocument: (documentId: string) => {
|
|
const doc = get().documents.get(documentId);
|
|
if (!doc) {
|
|
console.error(`Document ${documentId} not found`);
|
|
useToastStore.getState().showToast('Failed to export: Document not found', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Ensure timeline is up-to-date before exporting (similar to saveDocument)
|
|
const timelineState = useTimelineStore.getState();
|
|
const timeline = timelineState.timelines.get(documentId);
|
|
|
|
if (timeline) {
|
|
// Serialize timeline (convert Map to object)
|
|
const serializedStates: Record<string, ConstellationState> = {};
|
|
timeline.states.forEach((state: ConstellationState, id: string) => {
|
|
serializedStates[id] = state;
|
|
});
|
|
|
|
doc.timeline = {
|
|
states: serializedStates,
|
|
currentStateId: timeline.currentStateId,
|
|
rootStateId: timeline.rootStateId,
|
|
};
|
|
}
|
|
|
|
// Export the complete document with all timeline states
|
|
exportDocumentToFile(doc);
|
|
useToastStore.getState().showToast('Document exported successfully', 'success');
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
useToastStore.getState().showToast(`Failed to export document: ${message}`, 'error', 5000);
|
|
}
|
|
},
|
|
|
|
// Save workspace
|
|
saveWorkspace: () => {
|
|
const state = get();
|
|
saveWorkspaceState({
|
|
workspaceId: state.workspaceId,
|
|
workspaceName: state.workspaceName,
|
|
documentOrder: state.documentOrder,
|
|
activeDocumentId: state.activeDocumentId,
|
|
settings: state.settings,
|
|
});
|
|
},
|
|
|
|
// Load workspace
|
|
loadWorkspace: () => {
|
|
const loadedState = loadWorkspaceState();
|
|
if (loadedState) {
|
|
const metadata = loadAllDocumentMetadata();
|
|
set({
|
|
...loadedState,
|
|
documentMetadata: metadata,
|
|
documents: new Map(), // Documents loaded on demand
|
|
});
|
|
}
|
|
},
|
|
|
|
// Clear workspace
|
|
clearWorkspace: () => {
|
|
const confirmed = window.confirm(
|
|
'Are you sure you want to clear the entire workspace? This will delete all documents and cannot be undone.'
|
|
);
|
|
if (!confirmed) return;
|
|
|
|
clearWorkspaceStorage();
|
|
|
|
// Re-initialize with fresh workspace
|
|
const newState = initializeWorkspace();
|
|
set(newState);
|
|
},
|
|
|
|
// Get active document
|
|
getActiveDocument: () => {
|
|
const state = get();
|
|
if (!state.activeDocumentId) return null;
|
|
return state.documents.get(state.activeDocumentId) || null;
|
|
},
|
|
|
|
// Mark document as dirty
|
|
markDocumentDirty: (documentId: string) => {
|
|
set((state) => {
|
|
const metadata = state.documentMetadata.get(documentId);
|
|
if (metadata && !metadata.isDirty) {
|
|
metadata.isDirty = true;
|
|
const newMetadata = new Map(state.documentMetadata);
|
|
newMetadata.set(documentId, metadata);
|
|
saveDocumentMetadata(documentId, metadata);
|
|
return { documentMetadata: newMetadata };
|
|
}
|
|
return {};
|
|
});
|
|
},
|
|
|
|
// Save document
|
|
saveDocument: (documentId: string) => {
|
|
const state = get();
|
|
const doc = state.documents.get(documentId);
|
|
if (doc) {
|
|
doc.metadata.updatedAt = new Date().toISOString();
|
|
|
|
// Save global node and edge types from graph store
|
|
const graphStore = useGraphStore.getState();
|
|
doc.nodeTypes = graphStore.nodeTypes;
|
|
doc.edgeTypes = graphStore.edgeTypes;
|
|
|
|
// Save timeline data if exists
|
|
const timelineState = useTimelineStore.getState();
|
|
const timeline = timelineState.timelines.get(documentId);
|
|
|
|
if (timeline) {
|
|
// Serialize timeline (convert Map to object)
|
|
const serializedStates: Record<string, ConstellationState> = {};
|
|
timeline.states.forEach((state: ConstellationState, id: string) => {
|
|
serializedStates[id] = state;
|
|
});
|
|
|
|
doc.timeline = {
|
|
states: serializedStates,
|
|
currentStateId: timeline.currentStateId,
|
|
rootStateId: timeline.rootStateId,
|
|
};
|
|
}
|
|
|
|
saveDocumentToStorage(documentId, doc);
|
|
|
|
const metadata = state.documentMetadata.get(documentId);
|
|
if (metadata) {
|
|
metadata.isDirty = false;
|
|
metadata.lastModified = doc.metadata.updatedAt;
|
|
saveDocumentMetadata(documentId, metadata);
|
|
|
|
set((state) => {
|
|
const newMetadata = new Map(state.documentMetadata);
|
|
newMetadata.set(documentId, metadata);
|
|
return { documentMetadata: newMetadata };
|
|
});
|
|
}
|
|
}
|
|
},
|
|
|
|
// Export all documents as ZIP
|
|
exportAllDocumentsAsZip: async () => {
|
|
const state = get();
|
|
|
|
try {
|
|
// Ensure all documents are loaded
|
|
const allDocs = new Map<string, ConstellationDocument>();
|
|
for (const docId of state.documentOrder) {
|
|
let doc = state.documents.get(docId);
|
|
if (!doc) {
|
|
const loadedDoc = loadDocumentFromStorage(docId);
|
|
if (loadedDoc) {
|
|
doc = loadedDoc;
|
|
}
|
|
}
|
|
if (doc) {
|
|
allDocs.set(docId, doc);
|
|
}
|
|
}
|
|
|
|
await exportAllDocumentsAsZip(allDocs, state.workspaceName);
|
|
useToastStore.getState().showToast('All documents exported successfully', 'success');
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
useToastStore.getState().showToast(`Failed to export documents: ${message}`, 'error', 5000);
|
|
}
|
|
},
|
|
|
|
// Export workspace
|
|
exportWorkspace: async () => {
|
|
const state = get();
|
|
|
|
try {
|
|
const loadDoc = async (id: string): Promise<ConstellationDocument | null> => {
|
|
return loadDocumentFromStorage(id);
|
|
};
|
|
|
|
await exportWorkspaceToZip(
|
|
{
|
|
workspaceId: state.workspaceId,
|
|
workspaceName: state.workspaceName,
|
|
documentOrder: state.documentOrder,
|
|
activeDocumentId: state.activeDocumentId,
|
|
settings: state.settings,
|
|
},
|
|
state.documents,
|
|
state.documentOrder,
|
|
loadDoc
|
|
);
|
|
useToastStore.getState().showToast('Workspace exported successfully', 'success');
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
useToastStore.getState().showToast(`Failed to export workspace: ${message}`, 'error', 5000);
|
|
}
|
|
},
|
|
|
|
// Import workspace
|
|
importWorkspace: async () => {
|
|
return new Promise((resolve) => {
|
|
selectWorkspaceZipForImport(
|
|
(data) => {
|
|
const { workspaceState, documents } = data;
|
|
|
|
// Save workspace state
|
|
saveWorkspaceState(workspaceState);
|
|
|
|
// Save all documents
|
|
documents.forEach((doc, docId) => {
|
|
saveDocumentToStorage(docId, doc);
|
|
|
|
const metadata = {
|
|
id: docId,
|
|
title: doc.metadata.title || 'Untitled',
|
|
isDirty: false,
|
|
lastModified: doc.metadata.updatedAt || new Date().toISOString(),
|
|
};
|
|
saveDocumentMetadata(docId, metadata);
|
|
});
|
|
|
|
// Load metadata for all documents
|
|
const allMetadata = loadAllDocumentMetadata();
|
|
|
|
// Load active document
|
|
const activeDoc = workspaceState.activeDocumentId
|
|
? documents.get(workspaceState.activeDocumentId)
|
|
: null;
|
|
|
|
set({
|
|
...workspaceState,
|
|
documents: activeDoc && workspaceState.activeDocumentId
|
|
? new Map([[workspaceState.activeDocumentId, activeDoc]])
|
|
: new Map(),
|
|
documentMetadata: allMetadata,
|
|
});
|
|
|
|
useToastStore.getState().showToast('Workspace imported successfully', 'success');
|
|
resolve();
|
|
},
|
|
(error) => {
|
|
useToastStore.getState().showToast(`Failed to import workspace: ${error}`, 'error', 5000);
|
|
resolve();
|
|
}
|
|
);
|
|
});
|
|
},
|
|
|
|
// Save viewport state for a document
|
|
saveViewport: (documentId: string, viewport: { x: number; y: number; zoom: number }) => {
|
|
set((state) => {
|
|
const metadata = state.documentMetadata.get(documentId);
|
|
if (metadata) {
|
|
metadata.viewport = viewport;
|
|
const newMetadata = new Map(state.documentMetadata);
|
|
newMetadata.set(documentId, metadata);
|
|
saveDocumentMetadata(documentId, metadata);
|
|
return { documentMetadata: newMetadata };
|
|
}
|
|
return {};
|
|
});
|
|
},
|
|
|
|
// Get viewport state for a document
|
|
getViewport: (documentId: string) => {
|
|
const state = get();
|
|
const metadata = state.documentMetadata.get(documentId);
|
|
return metadata?.viewport;
|
|
},
|
|
}));
|