/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * BCF (BIM Collaboration Format) state slice * * Manages BCF topics, comments, and viewpoints for issue tracking. */ import type { StateCreator } from 'zustand'; import type { BCFProject, BCFTopic, BCFComment, BCFViewpoint, } from '@ifc-lite/bcf'; // ============================================================================ // Types // ============================================================================ export interface BCFSliceState { /** Current BCF project */ bcfProject: BCFProject | null; /** Currently active topic GUID */ activeTopicId: string | null; /** Currently active viewpoint GUID */ activeViewpointId: string | null; /** BCF panel visibility */ bcfPanelVisible: boolean; /** Loading state */ bcfLoading: boolean; /** Error message */ bcfError: string | null; /** Default author for new topics/comments */ bcfAuthor: string; /** Whether 3D overlay markers are shown in the viewport */ bcfOverlayVisible: boolean; } export interface BCFSlice extends BCFSliceState { // Project actions setBcfProject: (project: BCFProject | null) => void; clearBcfProject: () => void; // Topic actions setActiveTopic: (topicId: string | null) => void; addTopic: (topic: BCFTopic) => void; updateTopic: (topicId: string, updates: Partial) => void; deleteTopic: (topicId: string) => void; // Comment actions addComment: (topicId: string, comment: BCFComment) => void; updateComment: (topicId: string, commentGuid: string, updates: Partial) => void; deleteComment: (topicId: string, commentGuid: string) => void; // Viewpoint actions setActiveViewpoint: (viewpointId: string | null) => void; addViewpoint: (topicId: string, viewpoint: BCFViewpoint) => void; updateViewpoint: (topicId: string, viewpointGuid: string, updates: Partial) => void; deleteViewpoint: (topicId: string, viewpointGuid: string) => void; // UI actions setBcfPanelVisible: (visible: boolean) => void; toggleBcfPanel: () => void; setBcfLoading: (loading: boolean) => void; setBcfError: (error: string | null) => void; setBcfAuthor: (author: string) => void; setBcfOverlayVisible: (visible: boolean) => void; toggleBcfOverlay: () => void; // Utility getters getActiveTopic: () => BCFTopic | null; getActiveViewpoint: () => BCFViewpoint | null; getTopics: () => BCFTopic[]; } // ============================================================================ // Initial State // ============================================================================ const getDefaultBcfAuthor = (): string => { // Try to get from localStorage (may fail in private browsing) try { if (typeof localStorage !== 'undefined') { const stored = localStorage.getItem('bcf-author'); if (stored) return stored; } } catch { // localStorage not available (private browsing, storage quota, etc.) } return 'user@example.com'; }; // ============================================================================ // Slice Creator // ============================================================================ export const createBcfSlice: StateCreator = (set, get) => ({ // Initial state bcfProject: null, activeTopicId: null, activeViewpointId: null, bcfPanelVisible: false, bcfLoading: false, bcfError: null, bcfAuthor: getDefaultBcfAuthor(), bcfOverlayVisible: false, // Project actions setBcfProject: (bcfProject) => set({ bcfProject, activeTopicId: null, activeViewpointId: null, bcfError: null, }), clearBcfProject: () => set({ bcfProject: null, activeTopicId: null, activeViewpointId: null, bcfError: null, }), // Topic actions setActiveTopic: (activeTopicId) => set({ activeTopicId, activeViewpointId: null, // Reset viewpoint when changing topic }), addTopic: (topic) => set((state) => { if (!state.bcfProject) return state; const newTopics = new Map(state.bcfProject.topics); newTopics.set(topic.guid, topic); return { bcfProject: { ...state.bcfProject, topics: newTopics, }, activeTopicId: topic.guid, // Auto-select new topic }; }), updateTopic: (topicId, updates) => set((state) => { if (!state.bcfProject) return state; const topic = state.bcfProject.topics.get(topicId); if (!topic) return state; const newTopics = new Map(state.bcfProject.topics); newTopics.set(topicId, { ...topic, ...updates, modifiedDate: new Date().toISOString(), }); return { bcfProject: { ...state.bcfProject, topics: newTopics, }, }; }), deleteTopic: (topicId) => set((state) => { if (!state.bcfProject) return state; const newTopics = new Map(state.bcfProject.topics); newTopics.delete(topicId); return { bcfProject: { ...state.bcfProject, topics: newTopics, }, // Clear active topic if it was deleted activeTopicId: state.activeTopicId === topicId ? null : state.activeTopicId, activeViewpointId: state.activeTopicId === topicId ? null : state.activeViewpointId, }; }), // Comment actions addComment: (topicId, comment) => set((state) => { if (!state.bcfProject) return state; const topic = state.bcfProject.topics.get(topicId); if (!topic) return state; const newTopics = new Map(state.bcfProject.topics); newTopics.set(topicId, { ...topic, comments: [...topic.comments, comment], modifiedDate: new Date().toISOString(), }); return { bcfProject: { ...state.bcfProject, topics: newTopics, }, }; }), updateComment: (topicId, commentGuid, updates) => set((state) => { if (!state.bcfProject) return state; const topic = state.bcfProject.topics.get(topicId); if (!topic) return state; const commentIndex = topic.comments.findIndex((c) => c.guid === commentGuid); if (commentIndex === -1) return state; const newComments = [...topic.comments]; newComments[commentIndex] = { ...newComments[commentIndex], ...updates, modifiedDate: new Date().toISOString(), }; const newTopics = new Map(state.bcfProject.topics); newTopics.set(topicId, { ...topic, comments: newComments, modifiedDate: new Date().toISOString(), }); return { bcfProject: { ...state.bcfProject, topics: newTopics, }, }; }), deleteComment: (topicId, commentGuid) => set((state) => { if (!state.bcfProject) return state; const topic = state.bcfProject.topics.get(topicId); if (!topic) return state; const newTopics = new Map(state.bcfProject.topics); newTopics.set(topicId, { ...topic, comments: topic.comments.filter((c) => c.guid !== commentGuid), modifiedDate: new Date().toISOString(), }); return { bcfProject: { ...state.bcfProject, topics: newTopics, }, }; }), // Viewpoint actions setActiveViewpoint: (activeViewpointId) => set({ activeViewpointId }), addViewpoint: (topicId, viewpoint) => set((state) => { if (!state.bcfProject) return state; const topic = state.bcfProject.topics.get(topicId); if (!topic) return state; const newTopics = new Map(state.bcfProject.topics); newTopics.set(topicId, { ...topic, viewpoints: [...topic.viewpoints, viewpoint], modifiedDate: new Date().toISOString(), }); return { bcfProject: { ...state.bcfProject, topics: newTopics, }, activeViewpointId: viewpoint.guid, // Auto-select new viewpoint }; }), updateViewpoint: (topicId, viewpointGuid, updates) => set((state) => { if (!state.bcfProject) return state; const topic = state.bcfProject.topics.get(topicId); if (!topic) return state; const viewpointIndex = topic.viewpoints.findIndex((v) => v.guid === viewpointGuid); if (viewpointIndex === -1) return state; const newViewpoints = [...topic.viewpoints]; newViewpoints[viewpointIndex] = { ...newViewpoints[viewpointIndex], ...updates, }; const newTopics = new Map(state.bcfProject.topics); newTopics.set(topicId, { ...topic, viewpoints: newViewpoints, modifiedDate: new Date().toISOString(), }); return { bcfProject: { ...state.bcfProject, topics: newTopics, }, }; }), deleteViewpoint: (topicId, viewpointGuid) => set((state) => { if (!state.bcfProject) return state; const topic = state.bcfProject.topics.get(topicId); if (!topic) return state; const newTopics = new Map(state.bcfProject.topics); newTopics.set(topicId, { ...topic, viewpoints: topic.viewpoints.filter((v) => v.guid !== viewpointGuid), modifiedDate: new Date().toISOString(), }); return { bcfProject: { ...state.bcfProject, topics: newTopics, }, // Clear active viewpoint if it was deleted activeViewpointId: state.activeViewpointId === viewpointGuid ? null : state.activeViewpointId, }; }), // UI actions setBcfPanelVisible: (bcfPanelVisible) => set({ bcfPanelVisible }), toggleBcfPanel: () => set((state) => ({ bcfPanelVisible: !state.bcfPanelVisible })), setBcfLoading: (bcfLoading) => set({ bcfLoading }), setBcfError: (bcfError) => set({ bcfError }), setBcfAuthor: (bcfAuthor) => { // Persist to localStorage (may fail in private browsing) try { if (typeof localStorage !== 'undefined') { localStorage.setItem('bcf-author', bcfAuthor); } } catch { // localStorage not available } set({ bcfAuthor }); }, setBcfOverlayVisible: (bcfOverlayVisible) => set({ bcfOverlayVisible }), toggleBcfOverlay: () => set((state) => ({ bcfOverlayVisible: !state.bcfOverlayVisible })), // Utility getters getActiveTopic: () => { const state = get(); if (!state.bcfProject || !state.activeTopicId) return null; return state.bcfProject.topics.get(state.activeTopicId) || null; }, getActiveViewpoint: () => { const state = get(); const topic = state.getActiveTopic(); if (!topic || !state.activeViewpointId) return null; return topic.viewpoints.find((v) => v.guid === state.activeViewpointId) || null; }, getTopics: () => { const state = get(); if (!state.bcfProject) return []; return Array.from(state.bcfProject.topics.values()); }, });