import { create } from 'zustand' import { temporal } from 'zundo' import { shallow } from 'zustand/shallow' import { immer } from 'zustand/middleware/immer' import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType, TreeMarkdownValidationError, TreeMarkdownValidation } from '@/types' import { treeStructureToMarkdownPreview } from '@/lib/treeMarkdownSync' import { safeGetItem, safeSetItem, safeRemoveItem } from '@/lib/utils' // Throttle helper: captures first call immediately, then throttles subsequent calls // eslint-disable-next-line @typescript-eslint/no-explicit-any function throttle void>(fn: T, ms: number): T { let lastCall = 0 let timeout: ReturnType | null = null // eslint-disable-next-line @typescript-eslint/no-explicit-any return ((...args: any[]) => { const now = Date.now() if (now - lastCall >= ms) { lastCall = now fn(...args) } else { // Schedule a trailing call to capture the final state if (timeout) clearTimeout(timeout) timeout = setTimeout(() => { lastCall = Date.now() fn(...args) }, ms - (now - lastCall)) } }) as T } // Validation error interface export interface ValidationError { nodeId?: string field?: string message: string severity: 'error' | 'warning' } // Draft storage key const DRAFT_STORAGE_KEY = 'tree-editor-draft' // Check if a tree is effectively empty (just initialized, no real content) const isEmptyTree = (tree: TreeStructure | null): boolean => { if (!tree) return true if (tree.question && tree.question.trim()) return false if (tree.children && tree.children.length > 0) return false return true } // Starter template for new trees in Code Mode — all @refs are valid within the template const CODE_MODE_STARTER_TEMPLATE = `--- name: My New Tree description: A troubleshooting decision tree --- --- id: root type: decision --- # What type of issue is the user experiencing? > Select the category that best matches the reported problem - [A] Option A \u2192 @option_a_action - [B] Option B \u2192 @option_b_action --- id: option_a_action type: action parent: root --- ## Investigate Option A Describe the investigation steps here. \`\`\`commands example-command --flag \`\`\` **Expected:** Describe expected results here \u2192 @resolution --- id: option_b_action type: action parent: root --- ## Investigate Option B Describe the investigation steps here. \u2192 @resolution --- id: resolution type: solution parent: root --- ## Resolution 1. Document findings 2. Apply the fix 3. Verify the issue is resolved ` // Helper to generate unique IDs const generateId = () => crypto.randomUUID() // Helper to find a node in the tree structure (exported for drag validation) export const findNodeInTree = ( nodeId: string, structure: TreeStructure | null ): TreeStructure | null => { if (!structure) return null if (structure.id === nodeId) return structure if (structure.children) { for (const child of structure.children) { const found = findNodeInTree(nodeId, child) if (found) return found } } return null } /** Collect all nodes in the tree as a flat list with depth info. */ export function collectAllNodesFlat( root: TreeStructure | null ): Array<{ id: string; label: string; type: string; depth: number }> { if (!root) return [] const result: Array<{ id: string; label: string; type: string; depth: number }> = [] function walk(node: TreeStructure, depth: number) { const label = node.type === 'decision' ? (node.question || 'Untitled Decision') : (node.title || `Untitled ${node.type}`) result.push({ id: node.id, label, type: node.type, depth }) node.children?.forEach(child => walk(child, depth + 1)) } walk(root, 0) return result } // Helper to find parent of a node const findParentNode = ( nodeId: string, structure: TreeStructure | null, parent: TreeStructure | null = null ): TreeStructure | null => { if (!structure) return null if (structure.id === nodeId) return parent if (structure.children) { for (const child of structure.children) { const found = findParentNode(nodeId, child, structure) if (found) return found } } return null } // Helper to get all node IDs const getAllNodeIds = (structure: TreeStructure | null): string[] => { if (!structure) return [] const ids = [structure.id] if (structure.children) { for (const child of structure.children) { ids.push(...getAllNodeIds(child)) } } return ids } // Helper to deep clone a node const deepCloneNode = (node: TreeStructure): TreeStructure => { const clone: TreeStructure = { ...node, id: generateId() } // Update title/question to indicate it's a copy if (clone.question) { clone.question = `${clone.question} (Copy)` } else if (clone.title) { clone.title = `${clone.title} (Copy)` } // Clone options with new IDs if (clone.options) { clone.options = clone.options.map(opt => ({ ...opt, id: generateId(), next_node_id: '' // Clear references - user must reassign })) } // Clear next_node_id - user must reassign if (clone.next_node_id) { clone.next_node_id = '' } // Clone children recursively if (clone.children) { clone.children = clone.children.map(child => deepCloneNode(child)) } return clone } interface TreeEditorState { // Tree data treeId: string | null // null for new tree name: string description: string category: string categoryId: string | null tags: string[] isPublic: boolean treeStructure: TreeStructure | null originalTree: Tree | null // For comparison in edit mode // UI state selectedNodeId: string | null isDirty: boolean isLoading: boolean isSaving: boolean validationErrors: ValidationError[] // Code Mode state editorMode: 'form' | 'code' markdownSource: string | null markdownValidationErrors: TreeMarkdownValidationError[] isMarkdownValid: boolean isValidating: boolean lastValidTreeFromMarkdown: TreeStructure | null // Auto-save state lastSavedAt: Date | null draftSavedAt: Date | null hasDraft: boolean // Actions - Initialization initNewTree: () => void loadTree: (tree: Tree) => void loadDraft: () => boolean discardDraft: () => void reset: () => void // Actions - Metadata setName: (name: string) => void setDescription: (description: string) => void setCategory: (category: string) => void setCategoryId: (categoryId: string | null) => void setTags: (tags: string[]) => void addTag: (tag: string) => void removeTag: (tag: string) => void setIsPublic: (isPublic: boolean) => void // Actions - Node CRUD addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => string updateNode: (nodeId: string, updates: Partial) => void deleteNode: (nodeId: string) => void duplicateNode: (nodeId: string) => string | null // Actions - Node ordering reorderNodes: (parentId: string, fromIndex: number, toIndex: number) => void moveNode: (nodeId: string, targetParentId: string, targetIndex: number) => void reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => void // Actions - Selection selectNode: (nodeId: string | null) => void // Actions - Validation validate: () => ValidationError[] clearValidation: () => void // Actions - Save/Draft autoSaveDraft: () => void markSaved: () => void getTreeForSave: () => TreeCreate | TreeUpdate // Actions - Code Mode setEditorMode: (mode: 'form' | 'code') => void setMarkdownSource: (markdown: string) => void setMarkdownValidationResult: (result: TreeMarkdownValidation) => void syncMarkdownToTree: () => void syncTreeToMarkdown: () => void // Actions - State setLoading: (loading: boolean) => void setSaving: (saving: boolean) => void // Helpers findNode: (nodeId: string) => TreeStructure | null getAllNodeIds: () => string[] getAvailableTargetNodes: (excludeNodeId?: string) => Array<{ id: string; label: string; type: NodeType }> } // Create store with immer and temporal (undo/redo) middleware export const useTreeEditorStore = create()( temporal( immer((set, get) => ({ // Initial state treeId: null, name: '', description: '', category: '', categoryId: null, tags: [], isPublic: false, treeStructure: null, originalTree: null, selectedNodeId: null, isDirty: false, isLoading: false, isSaving: false, validationErrors: [], editorMode: 'form', markdownSource: null, markdownValidationErrors: [], isMarkdownValid: true, isValidating: false, lastValidTreeFromMarkdown: null, lastSavedAt: null, draftSavedAt: null, hasDraft: false, // Check for existing draft on init initNewTree: () => { const hasDraft = safeGetItem(DRAFT_STORAGE_KEY) !== null set((state) => { state.treeId = null state.name = '' state.description = '' state.category = '' state.categoryId = null state.tags = [] state.isPublic = false state.treeStructure = { id: 'root', type: 'decision', question: '', options: [], children: [] } state.originalTree = null state.selectedNodeId = 'root' state.isDirty = false state.isLoading = false state.isSaving = false state.validationErrors = [] state.editorMode = 'form' state.markdownSource = null state.markdownValidationErrors = [] state.isMarkdownValid = true state.isValidating = false state.lastValidTreeFromMarkdown = null state.lastSavedAt = null state.draftSavedAt = null state.hasDraft = hasDraft }) }, loadTree: (tree: Tree) => { set((state) => { state.treeId = tree.id state.name = tree.name state.description = tree.description || '' state.category = tree.category || '' state.categoryId = tree.category_id || null state.tags = tree.tags || [] state.isPublic = tree.is_public || false state.treeStructure = tree.tree_structure state.originalTree = tree state.selectedNodeId = tree.tree_structure?.id || null state.isDirty = false state.isLoading = false state.validationErrors = [] state.lastSavedAt = new Date() state.draftSavedAt = null state.hasDraft = false }) }, loadDraft: () => { const draftJson = safeGetItem(DRAFT_STORAGE_KEY) if (!draftJson) return false try { const draft = JSON.parse(draftJson) set((state) => { state.treeId = draft.treeId || null state.name = draft.name || '' state.description = draft.description || '' state.category = draft.category || '' state.categoryId = draft.categoryId || null state.tags = draft.tags || [] state.isPublic = draft.isPublic || false state.treeStructure = draft.treeStructure || null state.isDirty = true state.draftSavedAt = draft.savedAt ? new Date(draft.savedAt) : null state.hasDraft = false }) return true } catch { safeRemoveItem(DRAFT_STORAGE_KEY) return false } }, discardDraft: () => { safeRemoveItem(DRAFT_STORAGE_KEY) set((state) => { state.hasDraft = false }) }, reset: () => { set((state) => { state.treeId = null state.name = '' state.description = '' state.category = '' state.categoryId = null state.tags = [] state.isPublic = false state.treeStructure = null state.originalTree = null state.selectedNodeId = null state.isDirty = false state.isLoading = false state.isSaving = false state.validationErrors = [] state.editorMode = 'form' state.markdownSource = null state.markdownValidationErrors = [] state.isMarkdownValid = true state.isValidating = false state.lastValidTreeFromMarkdown = null state.lastSavedAt = null state.draftSavedAt = null state.hasDraft = false }) }, // Metadata actions setName: (name: string) => { set((state) => { state.name = name state.isDirty = true }) get().autoSaveDraft() }, setDescription: (description: string) => { set((state) => { state.description = description state.isDirty = true }) get().autoSaveDraft() }, setCategory: (category: string) => { set((state) => { state.category = category state.isDirty = true }) get().autoSaveDraft() }, setCategoryId: (categoryId: string | null) => { set((state) => { state.categoryId = categoryId state.isDirty = true }) get().autoSaveDraft() }, setTags: (tags: string[]) => { set((state) => { state.tags = tags state.isDirty = true }) get().autoSaveDraft() }, addTag: (tag: string) => { set((state) => { if (!state.tags.includes(tag)) { state.tags.push(tag) state.isDirty = true } }) get().autoSaveDraft() }, removeTag: (tag: string) => { set((state) => { state.tags = state.tags.filter(t => t !== tag) state.isDirty = true }) get().autoSaveDraft() }, setIsPublic: (isPublic: boolean) => { set((state) => { state.isPublic = isPublic state.isDirty = true }) get().autoSaveDraft() }, // Node CRUD addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => { const newId = generateId() const newNode: TreeStructure = { id: newId, type, ...(type === 'decision' && { question: '', options: [], children: [] }), ...(type === 'action' && { title: '', description: '' }), ...(type === 'solution' && { title: '', description: '' }) } set((state) => { if (!parentId) { // Adding as root state.treeStructure = newNode } else { // Find parent and add to children const parent = findNodeInTree(parentId, state.treeStructure) if (parent) { if (!parent.children) { parent.children = [] } if (insertIndex !== undefined && insertIndex >= 0) { parent.children.splice(insertIndex, 0, newNode) } else { parent.children.push(newNode) } } } state.selectedNodeId = newId state.isDirty = true }) get().autoSaveDraft() return newId }, updateNode: (nodeId: string, updates: Partial) => { set((state) => { const node = findNodeInTree(nodeId, state.treeStructure) if (node) { Object.assign(node, updates) state.isDirty = true } }) get().autoSaveDraft() }, deleteNode: (nodeId: string) => { if (nodeId === 'root') { // Don't allow deleting root, just clear it set((state) => { if (state.treeStructure) { state.treeStructure.question = '' state.treeStructure.options = [] state.treeStructure.children = [] } state.isDirty = true }) get().autoSaveDraft() return } set((state) => { const parent = findParentNode(nodeId, state.treeStructure) if (parent && parent.children) { const index = parent.children.findIndex(c => c.id === nodeId) if (index !== -1) { parent.children.splice(index, 1) } } // Clear selection if deleted node was selected if (state.selectedNodeId === nodeId) { state.selectedNodeId = parent?.id || 'root' } state.isDirty = true }) get().autoSaveDraft() }, duplicateNode: (nodeId: string) => { const state = get() const node = findNodeInTree(nodeId, state.treeStructure) if (!node) return null const clonedNode = deepCloneNode(node) // Find parent and add cloned node as sibling const parent = findParentNode(nodeId, state.treeStructure) set((s) => { if (parent && parent.children) { const index = parent.children.findIndex(c => c.id === nodeId) parent.children.splice(index + 1, 0, clonedNode) } else if (nodeId === 'root') { // Can't duplicate root - just select it return } s.selectedNodeId = clonedNode.id s.isDirty = true }) get().autoSaveDraft() return clonedNode.id }, // Reordering reorderNodes: (parentId: string, fromIndex: number, toIndex: number) => { set((state) => { const parent = findNodeInTree(parentId, state.treeStructure) if (parent && parent.children && parent.children.length > 0) { const [moved] = parent.children.splice(fromIndex, 1) parent.children.splice(toIndex, 0, moved) state.isDirty = true } }) get().autoSaveDraft() }, moveNode: (nodeId: string, targetParentId: string, targetIndex: number) => { set((state) => { // Find and remove from current parent const currentParent = findParentNode(nodeId, state.treeStructure) if (!currentParent?.children) return const sourceIndex = currentParent.children.findIndex(c => c.id === nodeId) if (sourceIndex === -1) return const [movedNode] = currentParent.children.splice(sourceIndex, 1) // Find target parent and insert const targetParent = findNodeInTree(targetParentId, state.treeStructure) if (!targetParent) return if (!targetParent.children) { targetParent.children = [] } // Adjust index if moving within same parent and source was before target let adjustedIndex = targetIndex if (currentParent.id === targetParent.id && sourceIndex < targetIndex) { adjustedIndex = targetIndex - 1 } targetParent.children.splice(adjustedIndex, 0, movedNode) state.isDirty = true }) get().autoSaveDraft() }, reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => { set((state) => { const node = findNodeInTree(nodeId, state.treeStructure) if (node && node.options && node.options.length > 0) { const [moved] = node.options.splice(fromIndex, 1) node.options.splice(toIndex, 0, moved) state.isDirty = true } }) get().autoSaveDraft() }, // Selection selectNode: (nodeId: string | null) => { set((state) => { state.selectedNodeId = nodeId }) }, // Validation validate: () => { const state = get() const errors: ValidationError[] = [] // Check tree name if (!state.name.trim()) { errors.push({ message: 'Tree name is required', severity: 'error' }) } // Check tree structure exists if (!state.treeStructure) { errors.push({ message: 'Tree must have at least one node', severity: 'error' }) set((s) => { s.validationErrors = errors }) return errors } const allNodeIds = getAllNodeIds(state.treeStructure) const referencedIds = new Set() let hasSolution = false // Traverse and validate all nodes const validateNode = (node: TreeStructure) => { // Check type-specific required fields if (node.type === 'decision') { if (!node.question?.trim()) { errors.push({ nodeId: node.id, field: 'question', message: `Decision node "${node.id}" requires a question`, severity: 'error' }) } if (!node.options || node.options.length === 0) { errors.push({ nodeId: node.id, field: 'options', message: `Decision node "${node.id}" requires at least one option`, severity: 'error' }) } // Decision nodes with children must have at least 2 branches if (node.children && node.children.length > 0 && node.children.length < 2) { errors.push({ nodeId: node.id, field: 'children', message: `Decision node "${node.id}" must have at least 2 branches`, severity: 'error' }) } if (node.options && node.options.length > 0) { // Validate options node.options.forEach((opt, i) => { if (!opt.label?.trim()) { errors.push({ nodeId: node.id, field: `options[${i}].label`, message: `Option ${i + 1} in "${node.id}" requires a label`, severity: 'error' }) } if (opt.next_node_id) { referencedIds.add(opt.next_node_id) if (!allNodeIds.includes(opt.next_node_id)) { errors.push({ nodeId: node.id, field: `options[${i}].next_node_id`, message: `Option "${opt.label}" references non-existent node "${opt.next_node_id}"`, severity: 'error' }) } } }) } } if (node.type === 'action') { if (!node.title?.trim()) { errors.push({ nodeId: node.id, field: 'title', message: `Action node "${node.id}" requires a title`, severity: 'error' }) } if (node.next_node_id) { referencedIds.add(node.next_node_id) if (!allNodeIds.includes(node.next_node_id)) { errors.push({ nodeId: node.id, field: 'next_node_id', message: `Action "${node.title}" references non-existent node "${node.next_node_id}"`, severity: 'error' }) } } } if (node.type === 'solution') { hasSolution = true if (!node.title?.trim()) { errors.push({ nodeId: node.id, field: 'title', message: `Solution node "${node.id}" requires a title`, severity: 'error' }) } } // Validate children if (node.children) { node.children.forEach(child => validateNode(child)) } } validateNode(state.treeStructure) // Check for circular references in next_node_id chains const detectCircularRefs = (startId: string, visited: Set = new Set()): boolean => { if (visited.has(startId)) return true visited.add(startId) const node = findNodeInTree(startId, state.treeStructure) if (!node) return false // Check options if (node.options) { for (const opt of node.options) { if (opt.next_node_id && detectCircularRefs(opt.next_node_id, new Set(visited))) { errors.push({ nodeId: node.id, message: `This path loops back to an earlier node via "${opt.label}"`, severity: 'warning' }) return true } } } // Check next_node_id if (node.next_node_id && detectCircularRefs(node.next_node_id, new Set(visited))) { errors.push({ nodeId: node.id, message: `This node loops back to an earlier node ("${node.title || node.id}")`, severity: 'warning' }) return true } return false } // Run from root detectCircularRefs('root') // Check for at least one solution if (!hasSolution) { errors.push({ message: 'Tree must have at least one solution (terminal) node', severity: 'error' }) } // Check for orphaned nodes (not root and not referenced) allNodeIds.forEach(id => { if (id !== state.treeStructure?.id && !referencedIds.has(id)) { // Check if it's a direct child of another node (via children array) let isChildOfAny = false const checkIfChild = (node: TreeStructure) => { if (node.children?.some(c => c.id === id)) { isChildOfAny = true } node.children?.forEach(checkIfChild) } checkIfChild(state.treeStructure!) if (!isChildOfAny) { errors.push({ nodeId: id, message: `Node "${id}" is orphaned (not reachable from root)`, severity: 'warning' }) } } }) set((s) => { s.validationErrors = errors }) return errors }, clearValidation: () => { set((state) => { state.validationErrors = [] }) }, // Auto-save draft (debounced externally, called after each change) autoSaveDraft: () => { const state = get() const draft = { treeId: state.treeId, name: state.name, description: state.description, category: state.category, categoryId: state.categoryId, tags: state.tags, isPublic: state.isPublic, treeStructure: state.treeStructure, savedAt: new Date().toISOString() } safeSetItem(DRAFT_STORAGE_KEY, JSON.stringify(draft)) set((s) => { s.draftSavedAt = new Date() }) }, markSaved: () => { safeRemoveItem(DRAFT_STORAGE_KEY) set((state) => { state.isDirty = false state.lastSavedAt = new Date() state.draftSavedAt = null state.hasDraft = false }) }, getTreeForSave: (): TreeCreate | TreeUpdate => { const state = get() return { name: state.name, description: state.description || undefined, category: state.category || undefined, category_id: state.categoryId || undefined, tags: state.tags.length > 0 ? state.tags : undefined, is_public: state.isPublic, tree_structure: state.treeStructure! } }, // Code Mode actions setEditorMode: (mode: 'form' | 'code') => { const current = get() if (mode === current.editorMode) return if (mode === 'code') { // Form → Code: generate markdown from tree structure (synchronous) const { treeStructure, name, description, category, tags } = current if (isEmptyTree(treeStructure) && !name.trim()) { // New empty tree: use starter template set((state) => { state.markdownSource = CODE_MODE_STARTER_TEMPLATE state.markdownValidationErrors = [] state.isMarkdownValid = false // needs validation state.isValidating = false state.editorMode = mode }) } else if (treeStructure) { const metadata = { name, description, category, tags } const md = treeStructureToMarkdownPreview(treeStructure, metadata) set((state) => { state.markdownSource = md state.markdownValidationErrors = [] state.isMarkdownValid = true state.isValidating = false state.editorMode = mode }) } else { set((state) => { state.editorMode = mode }) } } else { // Code → Form: apply last valid parse to tree structure get().syncMarkdownToTree() set((state) => { state.editorMode = mode }) } }, setMarkdownSource: (markdown: string) => { set((state) => { state.markdownSource = markdown state.isDirty = true state.isValidating = true }) }, setMarkdownValidationResult: (result: TreeMarkdownValidation) => { set((state) => { state.markdownValidationErrors = result.errors state.isMarkdownValid = result.valid state.isValidating = false if (result.valid && result.tree_structure) { state.lastValidTreeFromMarkdown = result.tree_structure as TreeStructure // Live sync: apply valid parsed tree to treeStructure immediately state.treeStructure = result.tree_structure as TreeStructure } // Apply metadata from parsed markdown (bidirectional sync) if (result.metadata) { if (result.metadata.name !== undefined) state.name = result.metadata.name if (result.metadata.description !== undefined) state.description = result.metadata.description if (result.metadata.category !== undefined) state.category = result.metadata.category if (result.metadata.tags !== undefined) state.tags = result.metadata.tags } }) }, syncMarkdownToTree: () => { const { lastValidTreeFromMarkdown, isMarkdownValid } = get() if (isMarkdownValid && lastValidTreeFromMarkdown) { set((state) => { state.treeStructure = lastValidTreeFromMarkdown state.markdownSource = null state.isDirty = true }) } }, syncTreeToMarkdown: () => { const { treeStructure, name, description, category, tags } = get() if (treeStructure) { const metadata = { name, description, category, tags } const md = treeStructureToMarkdownPreview(treeStructure, metadata) set((state) => { state.markdownSource = md state.markdownValidationErrors = [] state.isMarkdownValid = true state.isValidating = false }) } }, setLoading: (loading: boolean) => { set((state) => { state.isLoading = loading }) }, setSaving: (saving: boolean) => { set((state) => { state.isSaving = saving }) }, // Helpers findNode: (nodeId: string) => { return findNodeInTree(nodeId, get().treeStructure) }, getAllNodeIds: () => { return getAllNodeIds(get().treeStructure) }, getAvailableTargetNodes: (excludeNodeId?: string) => { const state = get() const allIds = getAllNodeIds(state.treeStructure) return allIds .filter(id => id !== excludeNodeId) .map(id => { const node = findNodeInTree(id, state.treeStructure) let type: NodeType = 'decision' let title = '' if (node) { type = node.type if (node.question) title = node.question.slice(0, 50) else if (node.title) title = node.title.slice(0, 50) } // Use short ID format, but 'root' stays as-is const shortId = id === 'root' ? 'root' : id.slice(0, 8) + '...' // Format: "Title (shortId)" or just "(shortId)" if no title // For root without a question, show "Root Question (root)" let label: string if (id === 'root') { label = title ? `${title} (root)` : 'Root Question (root)' } else if (title) { label = `${title} (${shortId})` } else { // No title yet - show placeholder with ID const typeName = type === 'decision' ? 'Untitled Question' : `Untitled ${type}` label = `${typeName} (${shortId})` } return { id, label, type } }) } })), { // Zundo options for undo/redo limit: 50, // Keep last 50 states partialize: (state) => ({ // Only track these fields in history name: state.name, description: state.description, category: state.category, categoryId: state.categoryId, tags: state.tags, isPublic: state.isPublic, treeStructure: state.treeStructure }), // Skip no-op entries where partialized fields haven't changed equality: (pastState, currentState) => shallow(pastState, currentState), // Throttle history captures: collapse rapid changes (typing, validation) into ~one entry per 3s // This makes Flow Mode undo revert meaningful chunks (whole field edits) instead of single characters handleSet: (handleSet) => throttle((state) => { handleSet(state) }, 3000), } ) ) // Export temporal store for undo/redo access // Use with: useStore(useTreeEditorStore.temporal, selector) export const useTreeEditorTemporal = useTreeEditorStore.temporal export default useTreeEditorStore