@tldiagram/core-ui 1.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/App.d.ts +1 -0
- package/dist/api/client.d.ts +143 -0
- package/dist/api/transport-vscode.d.ts +8 -0
- package/dist/api/transport.d.ts +1 -0
- package/dist/components/CodePreviewPanel-vscode.d.ts +7 -0
- package/dist/components/CodePreviewPanel.d.ts +9 -0
- package/dist/components/ConfirmDialog.d.ts +12 -0
- package/dist/components/ConnectorPanel.d.ts +21 -0
- package/dist/components/ContextBoundaryElement.d.ts +11 -0
- package/dist/components/ContextNeighborElement.d.ts +29 -0
- package/dist/components/ContextStraightConnector.d.ts +4 -0
- package/dist/components/CrossBranchControls.d.ts +9 -0
- package/dist/components/DependenciesOnboarding.d.ts +5 -0
- package/dist/components/DrawingCanvas.d.ts +39 -0
- package/dist/components/ElementLibrary-vscode.d.ts +7 -0
- package/dist/components/ElementLibrary.d.ts +22 -0
- package/dist/components/ElementNode.d.ts +36 -0
- package/dist/components/ElementPanel.d.ts +25 -0
- package/dist/components/ExploreOnboarding.d.ts +5 -0
- package/dist/components/ExplorePageOnboarding.d.ts +5 -0
- package/dist/components/ExportModal.d.ts +16 -0
- package/dist/components/FloatingEdge.d.ts +9 -0
- package/dist/components/GitSourceLinker.d.ts +8 -0
- package/dist/components/HeaderContext.d.ts +16 -0
- package/dist/components/Icons.d.ts +95 -0
- package/dist/components/ImportModal.d.ts +10 -0
- package/dist/components/InlineElementAdder.d.ts +17 -0
- package/dist/components/LayoutSection.d.ts +7 -0
- package/dist/components/LocalSourceLinker.d.ts +8 -0
- package/dist/components/MiniZoomOnboarding.d.ts +5 -0
- package/dist/components/NavBreadcrumb.d.ts +6 -0
- package/dist/components/NodeBody.d.ts +12 -0
- package/dist/components/NodeContainer.d.ts +8 -0
- package/dist/components/NodeHoverCard.d.ts +10 -0
- package/dist/components/PanelHeader.d.ts +8 -0
- package/dist/components/PanelUI.d.ts +3 -0
- package/dist/components/ProxyConnectorEdge.d.ts +4 -0
- package/dist/components/ProxyConnectorPanel.d.ts +9 -0
- package/dist/components/SafeBackground.d.ts +13 -0
- package/dist/components/ScrollIndicatorWrapper.d.ts +8 -0
- package/dist/components/SetChildModal.d.ts +10 -0
- package/dist/components/SetParentModal.d.ts +10 -0
- package/dist/components/SlidingPanel.d.ts +16 -0
- package/dist/components/TagUpsert.d.ts +8 -0
- package/dist/components/TopMenuBar.d.ts +8 -0
- package/dist/components/ViewBezierConnector.d.ts +4 -0
- package/dist/components/ViewDrawMenu.d.ts +22 -0
- package/dist/components/ViewEditorEdgeLabelLayout.d.ts +16 -0
- package/dist/components/ViewEditorOnboarding.d.ts +5 -0
- package/dist/components/ViewExplorer/TagManager/ColorPicker.d.ts +7 -0
- package/dist/components/ViewExplorer/TagManager/GroupNamingPopover.d.ts +10 -0
- package/dist/components/ViewExplorer/TagManager/LayerItem.d.ts +27 -0
- package/dist/components/ViewExplorer/TagManager/TagItem.d.ts +25 -0
- package/dist/components/ViewExplorer/TagManager/index.d.ts +21 -0
- package/dist/components/ViewExplorer/ViewNavigator.d.ts +11 -0
- package/dist/components/ViewExplorer/ViewSearch.d.ts +8 -0
- package/dist/components/ViewExplorer/ViewTree.d.ts +18 -0
- package/dist/components/ViewExplorer/index.d.ts +31 -0
- package/dist/components/ViewExplorer/types.d.ts +11 -0
- package/dist/components/ViewExplorer/utils.d.ts +6 -0
- package/dist/components/ViewExplorer-vscode.d.ts +6 -0
- package/dist/components/ViewFloatingMenu-vscode.d.ts +27 -0
- package/dist/components/ViewFloatingMenu.d.ts +39 -0
- package/dist/components/ViewGridNode.d.ts +29 -0
- package/dist/components/ViewHeaderButton.d.ts +11 -0
- package/dist/components/ViewPanel.d.ts +18 -0
- package/dist/components/ViewsGridOnboarding.d.ts +5 -0
- package/dist/components/ZUI/ZUICanvas.d.ts +18 -0
- package/dist/components/ZUI/index.d.ts +2 -0
- package/dist/components/ZUI/layout.d.ts +18 -0
- package/dist/components/ZUI/proxy.d.ts +25 -0
- package/dist/components/ZUI/renderer.d.ts +30 -0
- package/dist/components/ZUI/types.d.ts +140 -0
- package/dist/components/ZUI/useZUIInteraction.d.ts +21 -0
- package/dist/config/runtime-vscode.d.ts +22 -0
- package/dist/config/runtime.d.ts +5 -0
- package/dist/constants/colors.d.ts +27 -0
- package/dist/constants/diagramColors.d.ts +1 -0
- package/dist/context/ThemeContext.d.ts +27 -0
- package/dist/crossBranch/graph.d.ts +13 -0
- package/dist/crossBranch/resolve.d.ts +22 -0
- package/dist/crossBranch/settings.d.ts +6 -0
- package/dist/crossBranch/store.d.ts +11 -0
- package/dist/crossBranch/types.d.ts +96 -0
- package/dist/demo/DemoPage.d.ts +9 -0
- package/dist/demo/seed.d.ts +9 -0
- package/dist/demo/store.d.ts +137 -0
- package/dist/demo/viewEditor.d.ts +26 -0
- package/dist/favicon.svg +35 -0
- package/dist/hooks/useSafeFitView.d.ts +16 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +115 -0
- package/dist/index.js +19966 -0
- package/dist/lib/vscodeBridge-vscode.d.ts +13 -0
- package/dist/lib/vscodeBridge.d.ts +5 -0
- package/dist/logo-120.png +0 -0
- package/dist/logo-bw.png +0 -0
- package/dist/logo-bw.svg +15 -0
- package/dist/logo-text.svg +51 -0
- package/dist/logo.svg +35 -0
- package/dist/pages/AppearanceSettings.d.ts +3 -0
- package/dist/pages/Dependencies.d.ts +1 -0
- package/dist/pages/InfiniteZoom.d.ts +7 -0
- package/dist/pages/Settings.d.ts +7 -0
- package/dist/pages/ViewEditor/components/EditorMenus.d.ts +24 -0
- package/dist/pages/ViewEditor/components/EditorOverlays.d.ts +30 -0
- package/dist/pages/ViewEditor/components/EmptyCanvasState.d.ts +7 -0
- package/dist/pages/ViewEditor/context.d.ts +13 -0
- package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +201 -0
- package/dist/pages/ViewEditor/hooks/useDrawingEngine.d.ts +40 -0
- package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +20 -0
- package/dist/pages/ViewEditor/hooks/useViewData.d.ts +74 -0
- package/dist/pages/ViewEditor/index.d.ts +8 -0
- package/dist/pages/ViewEditor/utils.d.ts +14 -0
- package/dist/pages/Views.d.ts +6 -0
- package/dist/pages/ViewsGrid.d.ts +6 -0
- package/dist/pkg/importer/mermaid.d.ts +7 -0
- package/dist/pkg/importer/mermaid.test.d.ts +1 -0
- package/dist/platform/PlatformContext.d.ts +6 -0
- package/dist/platform/context.d.ts +3 -0
- package/dist/platform/local.d.ts +2 -0
- package/dist/platform/types.d.ts +17 -0
- package/dist/slots.d.ts +67 -0
- package/dist/theme.d.ts +2 -0
- package/dist/types/index.d.ts +193 -0
- package/dist/types/vscode-messages.d.ts +60 -0
- package/dist/utils/edgeDistribution.d.ts +34 -0
- package/dist/utils/githubApi.d.ts +4 -0
- package/dist/utils/githubCache.d.ts +17 -0
- package/dist/utils/ids.d.ts +2 -0
- package/dist/utils/technologyCatalog.d.ts +15 -0
- package/dist/utils/toast.d.ts +15 -0
- package/dist/utils/treesitter.d.ts +13 -0
- package/dist/utils/url.d.ts +12 -0
- package/package.json +159 -0
- package/src/App.tsx +141 -0
- package/src/api/client.ts +618 -0
- package/src/api/transport-vscode.ts +28 -0
- package/src/api/transport.ts +7 -0
- package/src/assets/logo-mark.svg +31 -0
- package/src/assets/logo-wordmark.svg +22 -0
- package/src/assets/logo.svg +35 -0
- package/src/components/CodePreviewPanel-vscode.tsx +85 -0
- package/src/components/CodePreviewPanel.tsx +384 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/ConnectorPanel.tsx +403 -0
- package/src/components/ContextBoundaryElement.tsx +35 -0
- package/src/components/ContextNeighborElement.tsx +282 -0
- package/src/components/ContextStraightConnector.tsx +144 -0
- package/src/components/CrossBranchControls.tsx +105 -0
- package/src/components/DependenciesOnboarding.tsx +427 -0
- package/src/components/DrawingCanvas.tsx +391 -0
- package/src/components/ElementLibrary-vscode.tsx +9 -0
- package/src/components/ElementLibrary.tsx +512 -0
- package/src/components/ElementNode.tsx +1033 -0
- package/src/components/ElementPanel.tsx +928 -0
- package/src/components/ExploreOnboarding.tsx +347 -0
- package/src/components/ExplorePageOnboarding.tsx +383 -0
- package/src/components/ExportModal.tsx +132 -0
- package/src/components/FloatingEdge.tsx +115 -0
- package/src/components/GitSourceLinker.tsx +1053 -0
- package/src/components/HeaderContext.tsx +30 -0
- package/src/components/Icons.tsx +245 -0
- package/src/components/ImportModal.tsx +219 -0
- package/src/components/InlineElementAdder.tsx +216 -0
- package/src/components/LayoutSection.tsx +624 -0
- package/src/components/LocalSourceLinker.tsx +330 -0
- package/src/components/MiniZoomOnboarding.tsx +78 -0
- package/src/components/NavBreadcrumb.tsx +24 -0
- package/src/components/NodeBody.tsx +89 -0
- package/src/components/NodeContainer.tsx +58 -0
- package/src/components/NodeHoverCard.tsx +135 -0
- package/src/components/PanelHeader.tsx +36 -0
- package/src/components/PanelUI.tsx +24 -0
- package/src/components/ProxyConnectorEdge.tsx +169 -0
- package/src/components/ProxyConnectorPanel.tsx +130 -0
- package/src/components/SafeBackground.tsx +19 -0
- package/src/components/ScrollIndicatorWrapper.tsx +117 -0
- package/src/components/SetChildModal.tsx +191 -0
- package/src/components/SetParentModal.tsx +187 -0
- package/src/components/SlidingPanel.tsx +114 -0
- package/src/components/TagUpsert.tsx +142 -0
- package/src/components/TopMenuBar.tsx +380 -0
- package/src/components/ViewBezierConnector.tsx +143 -0
- package/src/components/ViewDrawMenu.tsx +270 -0
- package/src/components/ViewEditorEdgeLabelLayout.ts +189 -0
- package/src/components/ViewEditorOnboarding.tsx +445 -0
- package/src/components/ViewExplorer/TagManager/ColorPicker.tsx +49 -0
- package/src/components/ViewExplorer/TagManager/GroupNamingPopover.tsx +96 -0
- package/src/components/ViewExplorer/TagManager/LayerItem.tsx +228 -0
- package/src/components/ViewExplorer/TagManager/TagItem.tsx +242 -0
- package/src/components/ViewExplorer/TagManager/index.tsx +418 -0
- package/src/components/ViewExplorer/ViewNavigator.tsx +121 -0
- package/src/components/ViewExplorer/ViewSearch.tsx +33 -0
- package/src/components/ViewExplorer/ViewTree.tsx +98 -0
- package/src/components/ViewExplorer/index.tsx +384 -0
- package/src/components/ViewExplorer/types.ts +13 -0
- package/src/components/ViewExplorer/utils.ts +56 -0
- package/src/components/ViewExplorer-vscode.tsx +8 -0
- package/src/components/ViewFloatingMenu-vscode.tsx +248 -0
- package/src/components/ViewFloatingMenu.tsx +379 -0
- package/src/components/ViewGridNode.tsx +451 -0
- package/src/components/ViewHeaderButton.tsx +60 -0
- package/src/components/ViewPanel.tsx +162 -0
- package/src/components/ViewsGridOnboarding.tsx +400 -0
- package/src/components/ZUI/ZUICanvas.tsx +853 -0
- package/src/components/ZUI/index.ts +3 -0
- package/src/components/ZUI/layout.ts +323 -0
- package/src/components/ZUI/proxy.ts +278 -0
- package/src/components/ZUI/renderer.ts +1189 -0
- package/src/components/ZUI/types.ts +150 -0
- package/src/components/ZUI/useZUIInteraction.ts +720 -0
- package/src/config/runtime-vscode.ts +46 -0
- package/src/config/runtime.ts +30 -0
- package/src/constants/colors.ts +80 -0
- package/src/constants/diagramColors.ts +9 -0
- package/src/context/ThemeContext.tsx +158 -0
- package/src/crossBranch/graph.ts +207 -0
- package/src/crossBranch/resolve.ts +643 -0
- package/src/crossBranch/settings.ts +59 -0
- package/src/crossBranch/store.ts +71 -0
- package/src/crossBranch/types.ts +102 -0
- package/src/demo/DemoPage.tsx +184 -0
- package/src/demo/seed.ts +67 -0
- package/src/demo/store.ts +536 -0
- package/src/demo/viewEditor.ts +110 -0
- package/src/hooks/useSafeFitView.ts +60 -0
- package/src/index.css +309 -0
- package/src/index.ts +184 -0
- package/src/kafka-ss.png +0 -0
- package/src/lib/vscodeBridge-vscode.ts +27 -0
- package/src/lib/vscodeBridge.ts +7 -0
- package/src/main.tsx +46 -0
- package/src/pages/AppearanceSettings.tsx +135 -0
- package/src/pages/Dependencies.tsx +926 -0
- package/src/pages/InfiniteZoom.tsx +404 -0
- package/src/pages/Settings.tsx +91 -0
- package/src/pages/ViewEditor/EDGE_DISTRIBUTION.md +64 -0
- package/src/pages/ViewEditor/components/EditorMenus.tsx +112 -0
- package/src/pages/ViewEditor/components/EditorOverlays.tsx +172 -0
- package/src/pages/ViewEditor/components/EmptyCanvasState.tsx +42 -0
- package/src/pages/ViewEditor/context.tsx +21 -0
- package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +1349 -0
- package/src/pages/ViewEditor/hooks/useDrawingEngine.ts +127 -0
- package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +501 -0
- package/src/pages/ViewEditor/hooks/useViewData.ts +491 -0
- package/src/pages/ViewEditor/index.tsx +1366 -0
- package/src/pages/ViewEditor/utils.ts +88 -0
- package/src/pages/Views.tsx +171 -0
- package/src/pages/ViewsGrid.tsx +1310 -0
- package/src/pkg/importer/mermaid.test.ts +141 -0
- package/src/pkg/importer/mermaid.ts +76 -0
- package/src/platform/PlatformContext.tsx +17 -0
- package/src/platform/context.ts +9 -0
- package/src/platform/local.tsx +15 -0
- package/src/platform/types.ts +19 -0
- package/src/slots.ts +92 -0
- package/src/styles/editor-panels.css +66 -0
- package/src/styles/theme.css +56 -0
- package/src/theme.ts +336 -0
- package/src/types/index.ts +234 -0
- package/src/types/offline-ambient.d.ts +14 -0
- package/src/types/vscode-messages.ts +32 -0
- package/src/utils/edgeDistribution.ts +103 -0
- package/src/utils/githubApi.ts +121 -0
- package/src/utils/githubCache.ts +108 -0
- package/src/utils/ids.ts +9 -0
- package/src/utils/technologyCatalog.ts +143 -0
- package/src/utils/toast.ts +100 -0
- package/src/utils/treesitter.ts +147 -0
- package/src/utils/url.ts +72 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,1310 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { SafeBackground } from '../components/SafeBackground'
|
|
4
|
+
import { Text as HeaderText } from '@chakra-ui/react'
|
|
5
|
+
import ReactFlow, {
|
|
6
|
+
BackgroundVariant,
|
|
7
|
+
ReactFlowProvider,
|
|
8
|
+
useReactFlow,
|
|
9
|
+
useStore,
|
|
10
|
+
type Edge as RFEdge,
|
|
11
|
+
type Node as RFNode,
|
|
12
|
+
} from 'reactflow'
|
|
13
|
+
import FloatingEdge from '../components/FloatingEdge'
|
|
14
|
+
import 'reactflow/dist/style.css'
|
|
15
|
+
import { useSetHeader } from '../components/HeaderContext'
|
|
16
|
+
import {
|
|
17
|
+
Box,
|
|
18
|
+
Button,
|
|
19
|
+
Flex,
|
|
20
|
+
FormControl,
|
|
21
|
+
FormLabel,
|
|
22
|
+
Heading,
|
|
23
|
+
HStack,
|
|
24
|
+
IconButton,
|
|
25
|
+
Input,
|
|
26
|
+
InputGroup,
|
|
27
|
+
InputLeftElement,
|
|
28
|
+
InputRightElement,
|
|
29
|
+
Modal,
|
|
30
|
+
ModalBody,
|
|
31
|
+
ModalContent,
|
|
32
|
+
ModalFooter,
|
|
33
|
+
ModalHeader,
|
|
34
|
+
ModalOverlay,
|
|
35
|
+
Spinner,
|
|
36
|
+
Text,
|
|
37
|
+
useDisclosure,
|
|
38
|
+
useBreakpointValue,
|
|
39
|
+
} from '@chakra-ui/react'
|
|
40
|
+
import { motion, AnimatePresence } from 'framer-motion'
|
|
41
|
+
import { SearchIcon, CloseIcon, AddIcon } from '@chakra-ui/icons'
|
|
42
|
+
import { api } from '../api/client'
|
|
43
|
+
import { toast } from '../utils/toast'
|
|
44
|
+
import type { ViewTreeNode } from '../types'
|
|
45
|
+
import ViewPanel from '../components/ViewPanel'
|
|
46
|
+
import ConfirmDialog from '../components/ConfirmDialog'
|
|
47
|
+
import ViewGridNode, { type ViewGridNodeData } from '../components/ViewGridNode'
|
|
48
|
+
import { useAccentColor } from '../context/ThemeContext'
|
|
49
|
+
import { hexToRgba } from '../constants/colors'
|
|
50
|
+
|
|
51
|
+
// ── Tree helpers ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function flattenTree(roots: ViewTreeNode[]): ViewTreeNode[] {
|
|
54
|
+
const result: ViewTreeNode[] = []
|
|
55
|
+
const traverse = (node: ViewTreeNode) => {
|
|
56
|
+
result.push(node)
|
|
57
|
+
node.children.forEach(traverse)
|
|
58
|
+
}
|
|
59
|
+
roots.forEach(traverse)
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Layout algorithm ──────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const CELL_W = 260
|
|
66
|
+
const CELL_H = 150
|
|
67
|
+
const GAP_H = 80
|
|
68
|
+
const GAP_V = 120
|
|
69
|
+
|
|
70
|
+
function subtreeWidth(node: ViewTreeNode): number {
|
|
71
|
+
if (node.children.length === 0) return 1
|
|
72
|
+
return node.children.reduce((sum, c) => sum + subtreeWidth(c), 0)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildDescendantSets(roots: ViewTreeNode[]): Map<number, Set<number>> {
|
|
76
|
+
const map = new Map<number, Set<number>>()
|
|
77
|
+
|
|
78
|
+
function visit(node: ViewTreeNode): Set<number> {
|
|
79
|
+
const set = new Set([node.id])
|
|
80
|
+
node.children.forEach((child) => {
|
|
81
|
+
const childSet = visit(child)
|
|
82
|
+
childSet.forEach((id) => set.add(id))
|
|
83
|
+
})
|
|
84
|
+
map.set(node.id, set)
|
|
85
|
+
return set
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
roots.forEach(visit)
|
|
89
|
+
return map
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Compute layout positions.
|
|
94
|
+
*
|
|
95
|
+
* Y-axis: node.depth (= node.level) - honours manual level overrides so a
|
|
96
|
+
* diagram at L2 is always rendered in the L2 row even if its parent
|
|
97
|
+
* is at L0.
|
|
98
|
+
*
|
|
99
|
+
* X-axis: column derived from the tree-walk (pre-order rank within each
|
|
100
|
+
* level band), then a de-overlap pass shifts any colliding nodes and
|
|
101
|
+
* their subtrees rightward so nothing overlaps on the same row.
|
|
102
|
+
*/
|
|
103
|
+
function computeLayout(roots: ViewTreeNode[]): Map<number, { x: number; y: number }> {
|
|
104
|
+
const positions = new Map<number, { x: number; y: number }>()
|
|
105
|
+
const flat: ViewTreeNode[] = []
|
|
106
|
+
const visit = (n: ViewTreeNode) => { flat.push(n); n.children.forEach(visit) }
|
|
107
|
+
roots.forEach(visit)
|
|
108
|
+
|
|
109
|
+
if (flat.length === 0) return positions
|
|
110
|
+
|
|
111
|
+
// ── Step 1: initial column assignment via tree walk ─────────────────────────
|
|
112
|
+
function layoutNode(node: ViewTreeNode, startCol: number) {
|
|
113
|
+
const w = subtreeWidth(node)
|
|
114
|
+
const centerCol = startCol + (w - 1) / 2
|
|
115
|
+
positions.set(node.id, {
|
|
116
|
+
x: centerCol * (CELL_W + GAP_H),
|
|
117
|
+
y: node.depth * (CELL_H + GAP_V),
|
|
118
|
+
})
|
|
119
|
+
let childStart = startCol
|
|
120
|
+
for (const child of node.children) {
|
|
121
|
+
layoutNode(child, childStart)
|
|
122
|
+
childStart += subtreeWidth(child)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
let col = 0
|
|
126
|
+
for (const root of roots) {
|
|
127
|
+
layoutNode(root, col)
|
|
128
|
+
col += subtreeWidth(root)
|
|
129
|
+
}
|
|
130
|
+
// ── Step 2: build descendant sets so we can shift whole subtrees ────────────
|
|
131
|
+
const descendants = buildDescendantSets(roots)
|
|
132
|
+
|
|
133
|
+
// ── Step 3: de-overlap pass - per Y row (top-down), fix X collisions ────────
|
|
134
|
+
const STEP = CELL_W + GAP_H
|
|
135
|
+
const byY = new Map<number, number[]>()
|
|
136
|
+
flat.forEach((n) => {
|
|
137
|
+
const y = n.depth * (CELL_H + GAP_V)
|
|
138
|
+
if (!byY.has(y)) byY.set(y, [])
|
|
139
|
+
byY.get(y)!.push(n.id)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Process rows top-down (ascending Y) so parent shifts propagate downward first
|
|
143
|
+
const sortedYRows = Array.from(byY.entries()).sort(([ya], [yb]) => ya - yb)
|
|
144
|
+
|
|
145
|
+
for (const [rowY, ids] of sortedYRows) {
|
|
146
|
+
// Snapshot original X values before any mutations in this row -
|
|
147
|
+
// this prevents a just-shifted node's new position from cascading
|
|
148
|
+
// into the next comparison and wrongly pushing correct neighbors right.
|
|
149
|
+
const origX = new Map<number, number>(ids.map((id) => [id, positions.get(id)?.x ?? 0]))
|
|
150
|
+
ids.sort((a, b) => (origX.get(a) ?? 0) - (origX.get(b) ?? 0))
|
|
151
|
+
|
|
152
|
+
let rightmostX = origX.get(ids[0]) ?? 0
|
|
153
|
+
|
|
154
|
+
for (let i = 1; i < ids.length; i++) {
|
|
155
|
+
const originalX = origX.get(ids[i]) ?? 0
|
|
156
|
+
const placedX = Math.max(originalX, rightmostX + STEP)
|
|
157
|
+
|
|
158
|
+
if (placedX > originalX) {
|
|
159
|
+
const delta = placedX - originalX
|
|
160
|
+
const toShift = descendants.get(ids[i]) ?? new Set([ids[i]])
|
|
161
|
+
toShift.forEach((sid) => {
|
|
162
|
+
const p = positions.get(sid)
|
|
163
|
+
if (!p) return
|
|
164
|
+
if (p.y === rowY && sid !== ids[i]) return
|
|
165
|
+
positions.set(sid, { x: p.x + delta, y: p.y })
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
rightmostX = placedX
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return positions
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
function DepthBoundaryNode({ data }: { data: { width: number; depth: number; isReparenting?: boolean; onLevelClick?: () => void; isActive?: boolean } }) {
|
|
181
|
+
return (
|
|
182
|
+
<Box
|
|
183
|
+
w={`${data.width}px`}
|
|
184
|
+
h="20px"
|
|
185
|
+
position="relative"
|
|
186
|
+
pointerEvents={data.isReparenting ? 'auto' : 'none'}
|
|
187
|
+
userSelect="none"
|
|
188
|
+
display="flex"
|
|
189
|
+
alignItems="center"
|
|
190
|
+
cursor={data.isReparenting ? 'crosshair' : 'default'}
|
|
191
|
+
onClick={(e) => {
|
|
192
|
+
if (data.isReparenting && data.onLevelClick) {
|
|
193
|
+
e.stopPropagation()
|
|
194
|
+
data.onLevelClick()
|
|
195
|
+
}
|
|
196
|
+
}}
|
|
197
|
+
transition="background 0.2s"
|
|
198
|
+
_hover={data.isReparenting ? { bg: 'whiteAlpha.50' } : undefined}
|
|
199
|
+
>
|
|
200
|
+
<Box
|
|
201
|
+
w="100%"
|
|
202
|
+
h="1px"
|
|
203
|
+
borderTop="1px dashed"
|
|
204
|
+
borderColor={data.isActive ? 'whiteAlpha.900' : (data.isReparenting ? 'var(--accent)' : 'whiteAlpha.400')}
|
|
205
|
+
opacity={data.isActive ? 1 : (data.isReparenting ? 0.8 : 0.4)}
|
|
206
|
+
transition="all 0.2s"
|
|
207
|
+
/>
|
|
208
|
+
</Box>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function ViewGridSidebar({ maxDepth, isReparenting, onLevelClick, activeLevel }: { maxDepth: number; isReparenting: boolean; onLevelClick: (level: number) => void; activeLevel?: number | null }) {
|
|
213
|
+
const rowHeight = CELL_H + GAP_V
|
|
214
|
+
const levelCount = Math.max(maxDepth + 2, 4)
|
|
215
|
+
const transform = useStore((s) => s.transform)
|
|
216
|
+
const [, translateY, zoom] = transform
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<Box
|
|
220
|
+
position="absolute"
|
|
221
|
+
left={0}
|
|
222
|
+
top={0}
|
|
223
|
+
bottom={0}
|
|
224
|
+
w="120px"
|
|
225
|
+
pointerEvents="none"
|
|
226
|
+
zIndex={10}
|
|
227
|
+
overflow="hidden"
|
|
228
|
+
>
|
|
229
|
+
|
|
230
|
+
{/* Layers Container - follows the zoom and pan of the grid */}
|
|
231
|
+
<Box
|
|
232
|
+
position="absolute"
|
|
233
|
+
left={0}
|
|
234
|
+
right={0}
|
|
235
|
+
top={`${translateY}px`}
|
|
236
|
+
transform={`scale(${zoom})`}
|
|
237
|
+
transformOrigin="top left"
|
|
238
|
+
h={`${levelCount * rowHeight}px`}
|
|
239
|
+
>
|
|
240
|
+
{Array.from({ length: levelCount }).map((_, i) => {
|
|
241
|
+
const isActive = activeLevel === i
|
|
242
|
+
return (
|
|
243
|
+
<Flex
|
|
244
|
+
key={`layer-${i}`}
|
|
245
|
+
position="absolute"
|
|
246
|
+
top={`${i * rowHeight + 75}px`}
|
|
247
|
+
transform="translateY(-50%)"
|
|
248
|
+
left="0"
|
|
249
|
+
right="20px"
|
|
250
|
+
h="140px"
|
|
251
|
+
align="center"
|
|
252
|
+
justify="flex-end"
|
|
253
|
+
cursor={isReparenting ? 'pointer' : 'default'}
|
|
254
|
+
pointerEvents="auto"
|
|
255
|
+
onClick={(e) => {
|
|
256
|
+
if (isReparenting) {
|
|
257
|
+
e.stopPropagation()
|
|
258
|
+
onLevelClick(i)
|
|
259
|
+
}
|
|
260
|
+
}}
|
|
261
|
+
transition="all 0.4s cubic-bezier(0.4, 0, 0.2, 1)"
|
|
262
|
+
role="group"
|
|
263
|
+
_hover={isReparenting ? { transform: 'translateY(-50%) scale(1.05)', bg: 'whiteAlpha.50' } : {}}
|
|
264
|
+
>
|
|
265
|
+
{/* Technical Tick */}
|
|
266
|
+
<Box
|
|
267
|
+
position="absolute"
|
|
268
|
+
top="50%"
|
|
269
|
+
right="-20px"
|
|
270
|
+
w={isReparenting || isActive ? "40px" : "20px"}
|
|
271
|
+
h="1px"
|
|
272
|
+
bg={isActive ? 'whiteAlpha.900' : (isReparenting ? 'var(--accent)' : "whiteAlpha.400")}
|
|
273
|
+
transition="all 0.4s"
|
|
274
|
+
_after={{
|
|
275
|
+
content: '""',
|
|
276
|
+
position: 'absolute',
|
|
277
|
+
top: '-2.5px',
|
|
278
|
+
right: '0',
|
|
279
|
+
w: '6px',
|
|
280
|
+
h: '6px',
|
|
281
|
+
borderRadius: 'full',
|
|
282
|
+
bg: isActive ? 'whiteAlpha.900' : (isReparenting ? 'var(--accent)' : "whiteAlpha.400"),
|
|
283
|
+
}}
|
|
284
|
+
/>
|
|
285
|
+
|
|
286
|
+
<Box textAlign="left" pr={2}>
|
|
287
|
+
<Heading
|
|
288
|
+
fontSize="100px"
|
|
289
|
+
fontWeight="900"
|
|
290
|
+
color={isActive ? 'whiteAlpha.900' : (isReparenting ? 'var(--accent)' : "whiteAlpha.100")}
|
|
291
|
+
fontFamily="heading"
|
|
292
|
+
lineHeight="1"
|
|
293
|
+
letterSpacing="-0.06em"
|
|
294
|
+
transition="all 0.4s"
|
|
295
|
+
style={{
|
|
296
|
+
WebkitTextStroke: i === 0 || isReparenting || isActive ? 'none' : '1px rgba(255,255,255,0.1)',
|
|
297
|
+
}}
|
|
298
|
+
_groupHover={(isReparenting || isActive) ? { transform: 'scale(1.1)' } : {}}
|
|
299
|
+
>
|
|
300
|
+
{i}
|
|
301
|
+
</Heading>
|
|
302
|
+
</Box>
|
|
303
|
+
</Flex>
|
|
304
|
+
)
|
|
305
|
+
})}
|
|
306
|
+
</Box>
|
|
307
|
+
</Box>
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Depth boundary separator nodes ──────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
// ── Node types (stable module-level constant) ─────────────────────────────────
|
|
315
|
+
|
|
316
|
+
const NODE_TYPES = { diagramGrid: ViewGridNode, depthBoundary: DepthBoundaryNode }
|
|
317
|
+
const EDGE_TYPES = { floating: FloatingEdge }
|
|
318
|
+
|
|
319
|
+
// Hierarchy edges: muted neutral - structure without color noise
|
|
320
|
+
const HIERARCHY_EDGE_COLOR = 'rgba(255,255,255,0.2)'
|
|
321
|
+
|
|
322
|
+
// ── Props ─────────────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
interface Props {
|
|
325
|
+
onShare?: (viewId: number) => void
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Root component - provides ReactFlow context ───────────────────────────────
|
|
329
|
+
|
|
330
|
+
export default function ViewsGrid({ onShare }: Props) {
|
|
331
|
+
return (
|
|
332
|
+
<ReactFlowProvider>
|
|
333
|
+
<ViewGridInner onShare={onShare} />
|
|
334
|
+
</ReactFlowProvider>
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Inner component - has access to useReactFlow() ────────────────────────────
|
|
339
|
+
|
|
340
|
+
function ViewGridInner({ onShare }: Props) {
|
|
341
|
+
const isMobileLayout = useBreakpointValue({ base: true, md: false }) ?? false
|
|
342
|
+
const navigate = useNavigate()
|
|
343
|
+
const { accent } = useAccentColor()
|
|
344
|
+
const canEdit = true
|
|
345
|
+
const setHeader = useSetHeader()
|
|
346
|
+
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
setHeader({ node: <HeaderText fontWeight="medium" fontSize="sm" color="gray.300">View Hierarchy</HeaderText> })
|
|
349
|
+
return () => setHeader(null)
|
|
350
|
+
}, [setHeader])
|
|
351
|
+
|
|
352
|
+
const { setCenter, getViewport, zoomIn, zoomOut } = useReactFlow()
|
|
353
|
+
const rfContainerRef = useRef<HTMLDivElement>(null)
|
|
354
|
+
|
|
355
|
+
// ── Trackpad gesture detection: suppress zoom during two-finger pan ────────
|
|
356
|
+
const touchStateRef = useRef<{ lastMultiTouchWheelTime: number }>({
|
|
357
|
+
lastMultiTouchWheelTime: 0,
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
// Native capture-phase wheel listener so we intercept before ReactFlow's
|
|
361
|
+
// internal handlers. passive:false lets us call preventDefault().
|
|
362
|
+
useEffect(() => {
|
|
363
|
+
const el = rfContainerRef.current
|
|
364
|
+
if (!el) return
|
|
365
|
+
function onWheel(e: WheelEvent) {
|
|
366
|
+
// Track multi-touch wheel events (deltaX !== 0 indicates two-finger contact)
|
|
367
|
+
if (e.deltaX !== 0) {
|
|
368
|
+
touchStateRef.current.lastMultiTouchWheelTime = Date.now()
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// If we just finished a multi-touch gesture, suppress zoom for ~1000ms (trackpad momentum can last longer)
|
|
372
|
+
const isRecentMultiTouch = Date.now() - touchStateRef.current.lastMultiTouchWheelTime < 1000
|
|
373
|
+
|
|
374
|
+
// Only zoom on notched wheel (mouse), not trackpad
|
|
375
|
+
const isNotchedWheel = !e.ctrlKey && e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 20
|
|
376
|
+
const isMouseWheel = e.deltaMode !== 0 || isNotchedWheel
|
|
377
|
+
|
|
378
|
+
if (isMouseWheel && !isRecentMultiTouch) {
|
|
379
|
+
e.preventDefault()
|
|
380
|
+
e.stopPropagation()
|
|
381
|
+
if (e.deltaY > 0) zoomOut()
|
|
382
|
+
else zoomIn()
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
el.addEventListener('wheel', onWheel, { passive: false, capture: true })
|
|
386
|
+
return () => el.removeEventListener('wheel', onWheel, { capture: true })
|
|
387
|
+
}, [zoomIn, zoomOut])
|
|
388
|
+
|
|
389
|
+
// ── Core state ──────────────────────────────────────────────────────────────
|
|
390
|
+
const [treeData, setTreeData] = useState<ViewTreeNode[]>([])
|
|
391
|
+
const [loading, setLoading] = useState(true)
|
|
392
|
+
|
|
393
|
+
// ── Derived tree structures ─────────────────────────────────────────────────
|
|
394
|
+
const roots = useMemo(() => treeData, [treeData])
|
|
395
|
+
const flatTree = useMemo(() => flattenTree(roots), [roots])
|
|
396
|
+
|
|
397
|
+
const [focusedId, setFocusedId] = useState<number | null>(null)
|
|
398
|
+
const [searchTerm, setSearchTerm] = useState('')
|
|
399
|
+
const [searchResults, setSearchResults] = useState<ViewTreeNode[]>([])
|
|
400
|
+
const [activeSearchIndex, setActiveSearchIndex] = useState(-1)
|
|
401
|
+
|
|
402
|
+
const handleSearch = (term: string) => {
|
|
403
|
+
setSearchTerm(term)
|
|
404
|
+
if (term.trim().length < 3) {
|
|
405
|
+
setSearchResults([])
|
|
406
|
+
setActiveSearchIndex(-1)
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const matches = flatTree
|
|
411
|
+
.filter(n => n.name.toLowerCase().includes(term.toLowerCase()))
|
|
412
|
+
.slice(0, 5)
|
|
413
|
+
|
|
414
|
+
setSearchResults(matches)
|
|
415
|
+
if (matches.length > 0) {
|
|
416
|
+
setActiveSearchIndex(0)
|
|
417
|
+
setFocusedId(matches[0].id)
|
|
418
|
+
} else {
|
|
419
|
+
setActiveSearchIndex(-1)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
|
424
|
+
if (searchResults.length === 0) return
|
|
425
|
+
|
|
426
|
+
if (e.key === 'ArrowDown') {
|
|
427
|
+
e.preventDefault()
|
|
428
|
+
const nextIndex = (activeSearchIndex + 1) % searchResults.length
|
|
429
|
+
setActiveSearchIndex(nextIndex)
|
|
430
|
+
setFocusedId(searchResults[nextIndex].id)
|
|
431
|
+
} else if (e.key === 'ArrowUp') {
|
|
432
|
+
e.preventDefault()
|
|
433
|
+
const nextIndex = (activeSearchIndex - 1 + searchResults.length) % searchResults.length
|
|
434
|
+
setActiveSearchIndex(nextIndex)
|
|
435
|
+
setFocusedId(searchResults[nextIndex].id)
|
|
436
|
+
} else if (e.key === 'Enter') {
|
|
437
|
+
if (activeSearchIndex >= 0) {
|
|
438
|
+
navigate(`/views/${searchResults[activeSearchIndex].id}`)
|
|
439
|
+
}
|
|
440
|
+
} else if (e.key === 'Escape') {
|
|
441
|
+
setSearchResults([])
|
|
442
|
+
setActiveSearchIndex(-1)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Rename
|
|
447
|
+
const [editingId, setEditingId] = useState<number | null>(null)
|
|
448
|
+
const [editName, setEditName] = useState('')
|
|
449
|
+
|
|
450
|
+
// Counts cache
|
|
451
|
+
const [countsByView, setCountsByDiagram] = useState<Record<number, { nodes: number; edges: number }>>({})
|
|
452
|
+
|
|
453
|
+
// Onboarding wizard
|
|
454
|
+
const [onboardingStep, setOnboardingStep] = useState<0 | 1 | 2>(0)
|
|
455
|
+
const [onboardingName, setOnboardingName] = useState('My First Diagram')
|
|
456
|
+
const [onboardingViewId, setOnboardingDiagramId] = useState<number | null>(null)
|
|
457
|
+
const [onboardingCreating, setOnboardingCreating] = useState(false)
|
|
458
|
+
|
|
459
|
+
// Details drawer
|
|
460
|
+
const [detailsView, setDetailsDiagram] = useState<ViewTreeNode | null>(null)
|
|
461
|
+
const [detailsLoading, setDetailsLoading] = useState(false)
|
|
462
|
+
const { isOpen: isDetailsOpen, onOpen: onDetailsOpen, onClose: onDetailsClose } = useDisclosure()
|
|
463
|
+
|
|
464
|
+
// New diagram creation
|
|
465
|
+
const { isOpen: isCreateOpen, onOpen: onCreateOpen, onClose: onCreateClose } = useDisclosure()
|
|
466
|
+
const [newName, setNewName] = useState('')
|
|
467
|
+
const [isCreating, setIsCreating] = useState(false)
|
|
468
|
+
|
|
469
|
+
const handleCreate = async () => {
|
|
470
|
+
if (!newName.trim()) return
|
|
471
|
+
setIsCreating(true)
|
|
472
|
+
try {
|
|
473
|
+
const d = await api.workspace.views.create({ name: newName.trim() })
|
|
474
|
+
await refresh()
|
|
475
|
+
navigate(`/views/${d.id}`)
|
|
476
|
+
onCreateClose()
|
|
477
|
+
setNewName('')
|
|
478
|
+
} catch (err: unknown) {
|
|
479
|
+
toast({ title: 'Failed to create diagram', description: err instanceof Error ? err.message : 'An unexpected error occurred', status: 'error' })
|
|
480
|
+
} finally {
|
|
481
|
+
setIsCreating(false)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Delete dialog
|
|
486
|
+
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null)
|
|
487
|
+
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure()
|
|
488
|
+
|
|
489
|
+
// Level change mode
|
|
490
|
+
const [levelEditingNodeId, setLevelEditingNodeId] = useState<number | null>(null)
|
|
491
|
+
|
|
492
|
+
// Share modal
|
|
493
|
+
// ── Data fetching ───────────────────────────────────────────────────────────
|
|
494
|
+
const refresh = useCallback(async () => {
|
|
495
|
+
const tree = await api.workspace.views.tree().catch(() => null)
|
|
496
|
+
if (tree) {
|
|
497
|
+
setTreeData(tree)
|
|
498
|
+
if (tree.length === 0 && !localStorage.getItem('onboarding_shown')) {
|
|
499
|
+
localStorage.setItem('onboarding_shown', '1')
|
|
500
|
+
setOnboardingStep(1)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
setLoading(false)
|
|
504
|
+
}, [])
|
|
505
|
+
|
|
506
|
+
useEffect(() => { refresh() }, [refresh])
|
|
507
|
+
|
|
508
|
+
// Fetch node/edge counts
|
|
509
|
+
useEffect(() => {
|
|
510
|
+
let cancelled = false
|
|
511
|
+
const ids = flatTree.map((n) => n.id)
|
|
512
|
+
if (ids.length === 0) { setCountsByDiagram({}); return }
|
|
513
|
+
; (async () => {
|
|
514
|
+
const next: Record<number, { nodes: number; edges: number }> = {}
|
|
515
|
+
await Promise.all(
|
|
516
|
+
ids.map(async (id) => {
|
|
517
|
+
try {
|
|
518
|
+
const [objs, edges] = await Promise.all([
|
|
519
|
+
api.workspace.views.placements.list(id),
|
|
520
|
+
api.workspace.connectors.list(id),
|
|
521
|
+
])
|
|
522
|
+
next[id] = { nodes: objs.length, edges: edges.length }
|
|
523
|
+
} catch { /* ignore per-diagram failure */ }
|
|
524
|
+
})
|
|
525
|
+
)
|
|
526
|
+
if (!cancelled) setCountsByDiagram((prev) => ({ ...prev, ...next }))
|
|
527
|
+
})()
|
|
528
|
+
return () => { cancelled = true }
|
|
529
|
+
}, [flatTree])
|
|
530
|
+
|
|
531
|
+
// ── Rename ──────────────────────────────────────────────────────────────────
|
|
532
|
+
const startEdit = useCallback((id: number, name: string) => {
|
|
533
|
+
setEditingId(id)
|
|
534
|
+
setEditName(name)
|
|
535
|
+
}, [])
|
|
536
|
+
|
|
537
|
+
const commitEdit = useCallback(async () => {
|
|
538
|
+
const id = editingId
|
|
539
|
+
const name = editName.trim()
|
|
540
|
+
setEditingId(null)
|
|
541
|
+
if (id === null || !name) return
|
|
542
|
+
const prev = treeData.find((n) => n.id === id)
|
|
543
|
+
if (!prev || prev.name === name) return
|
|
544
|
+
setTreeData((d) => d.map((n) => (n.id === id ? { ...n, name } : n)))
|
|
545
|
+
await api.workspace.views.rename(id, name).catch(() =>
|
|
546
|
+
setTreeData((d) => d.map((n) => (n.id === id ? { ...n, name: prev.name } : n)))
|
|
547
|
+
)
|
|
548
|
+
}, [editingId, editName, treeData])
|
|
549
|
+
|
|
550
|
+
const cancelEdit = useCallback(() => setEditingId(null), [])
|
|
551
|
+
|
|
552
|
+
// ── Details ─────────────────────────────────────────────────────────────────
|
|
553
|
+
const handleDetailsOpen = useCallback(async (diagId: number) => {
|
|
554
|
+
setDetailsLoading(true)
|
|
555
|
+
onDetailsOpen()
|
|
556
|
+
try {
|
|
557
|
+
const d = await api.workspace.views.get(diagId)
|
|
558
|
+
setDetailsDiagram(d)
|
|
559
|
+
} catch { /* ignore */ } finally {
|
|
560
|
+
setDetailsLoading(false)
|
|
561
|
+
}
|
|
562
|
+
}, [onDetailsOpen])
|
|
563
|
+
|
|
564
|
+
const handleDetailsSave = useCallback((updated: ViewTreeNode) => {
|
|
565
|
+
setTreeData((prev) =>
|
|
566
|
+
prev.map((n) =>
|
|
567
|
+
n.id === updated.id
|
|
568
|
+
? { ...n, name: updated.name, level_label: updated.level_label }
|
|
569
|
+
: n
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
}, [])
|
|
573
|
+
|
|
574
|
+
// ── Delete ──────────────────────────────────────────────────────────────────
|
|
575
|
+
const handleDeleteConfirm = async () => {
|
|
576
|
+
if (!deleteTargetId) return
|
|
577
|
+
try {
|
|
578
|
+
await api.workspace.views.delete('', deleteTargetId)
|
|
579
|
+
setTreeData((prev) => prev.filter((n) => n.id !== deleteTargetId))
|
|
580
|
+
} catch { /* ignore */ }
|
|
581
|
+
onDeleteClose()
|
|
582
|
+
setDeleteTargetId(null)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const handleSetLevel = useCallback(async (level: number) => {
|
|
586
|
+
if (!levelEditingNodeId) return
|
|
587
|
+
const id = levelEditingNodeId
|
|
588
|
+
const node = treeData.find((n) => n.id === id)
|
|
589
|
+
if (!node) return
|
|
590
|
+
|
|
591
|
+
// Validate: must be strictly greater than parent's level
|
|
592
|
+
if (node.parent_view_id !== null) {
|
|
593
|
+
const parent = treeData.find((n) => n.id === node.parent_view_id)
|
|
594
|
+
if (parent && level <= parent.level) {
|
|
595
|
+
toast({ title: `Level must be > parent's level (L${parent.level})`, status: 'warning', duration: 3000, isClosable: true })
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Validate: must be strictly less than all direct children's levels
|
|
601
|
+
const childLevels = treeData.filter((n) => n.parent_view_id === id).map((n) => n.level)
|
|
602
|
+
if (childLevels.length > 0 && level >= Math.min(...childLevels)) {
|
|
603
|
+
toast({ title: `Level must be < children's levels (min L${Math.min(...childLevels)})`, status: 'warning', duration: 3000, isClosable: true })
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
setLevelEditingNodeId(null)
|
|
608
|
+
// Optimistically update locally
|
|
609
|
+
setTreeData((d) => d.map((n) => (n.id === id ? { ...n, level } : n)))
|
|
610
|
+
try {
|
|
611
|
+
await api.workspace.views.setLevel(id, level)
|
|
612
|
+
} catch {
|
|
613
|
+
// global error toast will show
|
|
614
|
+
}
|
|
615
|
+
await refresh()
|
|
616
|
+
}, [levelEditingNodeId, treeData, refresh])
|
|
617
|
+
|
|
618
|
+
const handleOnboardingCreate = async () => {
|
|
619
|
+
setOnboardingCreating(true)
|
|
620
|
+
try {
|
|
621
|
+
const d = await api.workspace.views.create({ name: onboardingName.trim() || 'My First Diagram' })
|
|
622
|
+
setOnboardingDiagramId(d.id)
|
|
623
|
+
await refresh()
|
|
624
|
+
setOnboardingStep(2)
|
|
625
|
+
} catch { /* ignore */ } finally {
|
|
626
|
+
setOnboardingCreating(false)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── RF nodes - pure derivation, no useState/useEffect ───────────────────────
|
|
631
|
+
const layoutPositions = useMemo(() => computeLayout(roots), [roots])
|
|
632
|
+
|
|
633
|
+
// Stable during drag (layoutPositions only changes after treeData refresh, never on mouse moves)
|
|
634
|
+
const computedMinZoom = useMemo(() => {
|
|
635
|
+
if (layoutPositions.size === 0) return 0.2
|
|
636
|
+
let minY = Infinity, maxY = -Infinity
|
|
637
|
+
layoutPositions.forEach(({ y }) => {
|
|
638
|
+
if (y < minY) minY = y
|
|
639
|
+
if (y + CELL_H > maxY) maxY = y + CELL_H
|
|
640
|
+
})
|
|
641
|
+
const bboxH = maxY - minY
|
|
642
|
+
let z = window.innerHeight / (Math.max(1, bboxH) * 1.2)
|
|
643
|
+
if (!isFinite(z) || isNaN(z) || z <= 0) z = 0.1
|
|
644
|
+
return Math.max(0.05, Math.min(z, 0.8))
|
|
645
|
+
}, [layoutPositions])
|
|
646
|
+
|
|
647
|
+
const computedTranslateExtent = useMemo((): [[number, number], [number, number]] | undefined => {
|
|
648
|
+
if (layoutPositions.size === 0) return undefined
|
|
649
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
|
650
|
+
layoutPositions.forEach(({ x, y }) => {
|
|
651
|
+
if (x < minX) minX = x
|
|
652
|
+
if (y < minY) minY = y
|
|
653
|
+
if (x + CELL_W > maxX) maxX = x + CELL_W
|
|
654
|
+
if (y + CELL_H > maxY) maxY = y + CELL_H
|
|
655
|
+
})
|
|
656
|
+
const panMarginX = Math.max(window.innerWidth, 1000)
|
|
657
|
+
const panMarginY = Math.max(window.innerHeight, 1000)
|
|
658
|
+
return [
|
|
659
|
+
[minX - panMarginX, minY - panMarginY],
|
|
660
|
+
[maxX + panMarginX, maxY + panMarginY],
|
|
661
|
+
]
|
|
662
|
+
}, [layoutPositions])
|
|
663
|
+
const maxDepth = useMemo(
|
|
664
|
+
() => flatTree.reduce((max, n) => Math.max(max, n.depth), 0),
|
|
665
|
+
[flatTree]
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
// ── WASD navigation targets (IDs of the 4 navigable neighbors) ─────────────
|
|
669
|
+
const wasdTargets = useMemo(() => {
|
|
670
|
+
if (focusedId === null) return {} as Record<number, 'w' | 'a' | 's' | 'd'>
|
|
671
|
+
const node = flatTree.find((n) => n.id === focusedId)
|
|
672
|
+
if (!node) return {} as Record<number, 'w' | 'a' | 's' | 'd'>
|
|
673
|
+
const siblings = flatTree.filter((n) => n.parent_view_id === node.parent_view_id)
|
|
674
|
+
const idx = siblings.findIndex((n) => n.id === focusedId)
|
|
675
|
+
const targets: Record<number, 'w' | 'a' | 's' | 'd'> = {}
|
|
676
|
+
if (node.parent_view_id !== null) targets[node.parent_view_id] = 'w'
|
|
677
|
+
const firstChild = flatTree.find((n) => n.parent_view_id === focusedId)
|
|
678
|
+
if (firstChild) targets[firstChild.id] = 's'
|
|
679
|
+
if (idx > 0) targets[siblings[idx - 1].id] = 'a'
|
|
680
|
+
if (idx < siblings.length - 1) targets[siblings[idx + 1].id] = 'd'
|
|
681
|
+
return targets
|
|
682
|
+
}, [focusedId, flatTree])
|
|
683
|
+
|
|
684
|
+
const rfNodes = useMemo((): RFNode[] =>
|
|
685
|
+
flatTree.map((n): RFNode => ({
|
|
686
|
+
id: String(n.id),
|
|
687
|
+
type: 'diagramGrid',
|
|
688
|
+
position: layoutPositions.get(n.id) ?? { x: 0, y: 0 },
|
|
689
|
+
data: {
|
|
690
|
+
id: n.id,
|
|
691
|
+
name: n.name,
|
|
692
|
+
level_label: n.level_label,
|
|
693
|
+
counts: countsByView[n.id],
|
|
694
|
+
focused: focusedId === n.id,
|
|
695
|
+
canEdit,
|
|
696
|
+
isEditing: editingId === n.id,
|
|
697
|
+
editName,
|
|
698
|
+
onFocus: () => setFocusedId(n.id),
|
|
699
|
+
onOpen: () => navigate(`/views/${n.id}`),
|
|
700
|
+
onStartRename: () => startEdit(n.id, n.name),
|
|
701
|
+
onDetails: () => handleDetailsOpen(n.id),
|
|
702
|
+
onDelete: () => { setDeleteTargetId(n.id); onDeleteOpen() },
|
|
703
|
+
onShare: onShare ? () => onShare(n.id) : () => {},
|
|
704
|
+
onEditNameChange: setEditName,
|
|
705
|
+
onEditCommit: commitEdit,
|
|
706
|
+
onEditCancel: cancelEdit,
|
|
707
|
+
isMobile: isMobileLayout,
|
|
708
|
+
wasdKey: wasdTargets[n.id],
|
|
709
|
+
} satisfies ViewGridNodeData,
|
|
710
|
+
draggable: false,
|
|
711
|
+
})),
|
|
712
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
713
|
+
[flatTree, layoutPositions, focusedId, countsByView,
|
|
714
|
+
editingId, editName, canEdit, navigate, startEdit, handleDetailsOpen,
|
|
715
|
+
commitEdit, cancelEdit, onDeleteOpen,
|
|
716
|
+
wasdTargets, levelEditingNodeId]
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
// ── Depth boundary separator nodes ──────────────────────────────────────────
|
|
720
|
+
const depthBoundaryNodes = useMemo((): RFNode[] => {
|
|
721
|
+
if (levelEditingNodeId === null || maxDepth < 1 || layoutPositions.size === 0) return []
|
|
722
|
+
let minX = Infinity, maxX = -Infinity
|
|
723
|
+
layoutPositions.forEach(({ x }) => {
|
|
724
|
+
if (x < minX) minX = x
|
|
725
|
+
if (x + CELL_W > maxX) maxX = x + CELL_W
|
|
726
|
+
})
|
|
727
|
+
const startX = minX - 3 * GAP_H
|
|
728
|
+
const totalW = maxX - minX + 5 * GAP_H
|
|
729
|
+
const editingNode = flatTree.find((n) => n.id === levelEditingNodeId)
|
|
730
|
+
const activeLevel = editingNode?.level ?? null
|
|
731
|
+
|
|
732
|
+
return Array.from({ length: maxDepth + 2 }, (_, i) => {
|
|
733
|
+
const depth = i
|
|
734
|
+
return {
|
|
735
|
+
id: `__depth_${depth}`,
|
|
736
|
+
type: 'depthBoundary',
|
|
737
|
+
position: { x: startX, y: depth * (CELL_H + GAP_V) - GAP_V / 2 - 8 },
|
|
738
|
+
data: {
|
|
739
|
+
width: totalW,
|
|
740
|
+
depth,
|
|
741
|
+
isReparenting: true,
|
|
742
|
+
onLevelClick: () => handleSetLevel(depth),
|
|
743
|
+
isActive: activeLevel === depth || activeLevel === depth - 1,
|
|
744
|
+
},
|
|
745
|
+
draggable: false,
|
|
746
|
+
selectable: false,
|
|
747
|
+
focusable: false,
|
|
748
|
+
style: { zIndex: 0 },
|
|
749
|
+
} as RFNode
|
|
750
|
+
})
|
|
751
|
+
}, [maxDepth, layoutPositions, levelEditingNodeId, flatTree, handleSetLevel])
|
|
752
|
+
|
|
753
|
+
const allRfNodes = useMemo(
|
|
754
|
+
() => levelEditingNodeId !== null ? [...depthBoundaryNodes, ...rfNodes] : rfNodes,
|
|
755
|
+
[rfNodes, depthBoundaryNodes, levelEditingNodeId]
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
// ── RF edges ────────────────────────────────────────────────────────────────
|
|
759
|
+
const rfEdges = useMemo((): RFEdge[] =>
|
|
760
|
+
flatTree
|
|
761
|
+
.filter((n) => n.parent_view_id)
|
|
762
|
+
.map((n) => ({
|
|
763
|
+
id: `${n.parent_view_id}-${n.id}`,
|
|
764
|
+
source: String(n.parent_view_id!),
|
|
765
|
+
target: String(n.id),
|
|
766
|
+
type: 'floating',
|
|
767
|
+
animated: false,
|
|
768
|
+
data: { color: HIERARCHY_EDGE_COLOR, dashed: false },
|
|
769
|
+
})),
|
|
770
|
+
[flatTree]
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
const allRfEdges = rfEdges
|
|
774
|
+
|
|
775
|
+
// ── WASD keyboard navigation ────────────────────────────────────────────────
|
|
776
|
+
useEffect(() => {
|
|
777
|
+
const handler = (e: KeyboardEvent) => {
|
|
778
|
+
const tag = (e.target as HTMLElement).tagName
|
|
779
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
|
780
|
+
|
|
781
|
+
if (e.key === 'Escape') {
|
|
782
|
+
if (levelEditingNodeId !== null) {
|
|
783
|
+
setLevelEditingNodeId(null)
|
|
784
|
+
} else {
|
|
785
|
+
setFocusedId(null)
|
|
786
|
+
}
|
|
787
|
+
return
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (e.key === 'Enter' && focusedId) { navigate(`/views/${focusedId}`); return }
|
|
791
|
+
|
|
792
|
+
const isNav = ['w', 'W', 's', 'S', 'a', 'A', 'd', 'D'].includes(e.key)
|
|
793
|
+
if (!isNav) return
|
|
794
|
+
|
|
795
|
+
// Auto-select first card if nothing is focused yet
|
|
796
|
+
if (!focusedId) {
|
|
797
|
+
if (flatTree.length > 0) setFocusedId(flatTree[0].id)
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const node = flatTree.find((n) => n.id === focusedId)
|
|
802
|
+
if (!node) return
|
|
803
|
+
|
|
804
|
+
let nextId: number | null = null
|
|
805
|
+
if (e.key === 'w' || e.key === 'W') {
|
|
806
|
+
nextId = node.parent_view_id ?? null
|
|
807
|
+
} else if (e.key === 's' || e.key === 'S') {
|
|
808
|
+
nextId = flatTree.find((n) => n.parent_view_id === focusedId)?.id ?? null
|
|
809
|
+
} else if (e.key === 'a' || e.key === 'A') {
|
|
810
|
+
const siblings = flatTree.filter((n) => n.parent_view_id === node.parent_view_id)
|
|
811
|
+
const idx = siblings.findIndex((n) => n.id === focusedId)
|
|
812
|
+
nextId = idx > 0 ? siblings[idx - 1].id : null
|
|
813
|
+
} else if (e.key === 'd' || e.key === 'D') {
|
|
814
|
+
const siblings = flatTree.filter((n) => n.parent_view_id === node.parent_view_id)
|
|
815
|
+
const idx = siblings.findIndex((n) => n.id === focusedId)
|
|
816
|
+
nextId = idx < siblings.length - 1 ? siblings[idx + 1].id : null
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (nextId) setFocusedId(nextId)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
window.addEventListener('keydown', handler)
|
|
823
|
+
return () => window.removeEventListener('keydown', handler)
|
|
824
|
+
}, [focusedId, flatTree, navigate, levelEditingNodeId])
|
|
825
|
+
|
|
826
|
+
// ── Camera: pan to focused node only when it's out of view ──────────────────
|
|
827
|
+
useEffect(() => {
|
|
828
|
+
if (!focusedId) return
|
|
829
|
+
const pos = layoutPositions.get(focusedId)
|
|
830
|
+
if (!pos) return
|
|
831
|
+
const t = setTimeout(() => {
|
|
832
|
+
const { x: vpX, y: vpY, zoom } = getViewport()
|
|
833
|
+
// Convert node screen-space bounds and check if comfortably inside the viewport
|
|
834
|
+
const margin = 80
|
|
835
|
+
const sl = pos.x * zoom + vpX
|
|
836
|
+
const st = pos.y * zoom + vpY
|
|
837
|
+
const sr = (pos.x + CELL_W) * zoom + vpX
|
|
838
|
+
const sb = (pos.y + CELL_H) * zoom + vpY
|
|
839
|
+
const cw = window.innerWidth
|
|
840
|
+
const ch = window.innerHeight
|
|
841
|
+
const inView = sl > margin && st > margin && sr < cw - margin && sb < ch - margin
|
|
842
|
+
if (inView) return
|
|
843
|
+
setCenter(
|
|
844
|
+
pos.x + CELL_W / 2,
|
|
845
|
+
pos.y + CELL_H / 2,
|
|
846
|
+
{ duration: 650, zoom: Math.max(zoom, 0.75) }
|
|
847
|
+
)
|
|
848
|
+
}, 30)
|
|
849
|
+
return () => clearTimeout(t)
|
|
850
|
+
}, [focusedId, layoutPositions, setCenter, getViewport])
|
|
851
|
+
|
|
852
|
+
// ── Render ──────────────────────────────────────────────────────────────────
|
|
853
|
+
if (loading) {
|
|
854
|
+
return <Flex h="full" align="center" justify="center"><Spinner size="xl" /></Flex>
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return (
|
|
858
|
+
<Box h="full" display="flex" flexDir="column" position="relative">
|
|
859
|
+
{/* Canvas */}
|
|
860
|
+
<Box flex={1} position="relative">
|
|
861
|
+
{/* Floating Search Menu - bottom on desktop, top on mobile */}
|
|
862
|
+
<Box
|
|
863
|
+
position="absolute"
|
|
864
|
+
{...(isMobileLayout
|
|
865
|
+
? { top: "66px", left: "50%", transform: "translateX(-50%)" }
|
|
866
|
+
: { bottom: "calc(env(safe-area-inset-bottom, 0px) + var(--topbar-h-total) + 60px)", left: "50%", transform: "translateX(-50%)" }
|
|
867
|
+
)}
|
|
868
|
+
zIndex={100}
|
|
869
|
+
pointerEvents="auto"
|
|
870
|
+
>
|
|
871
|
+
<motion.div
|
|
872
|
+
initial={{ y: isMobileLayout ? -20 : 20, opacity: 0 }}
|
|
873
|
+
animate={{ y: 0, opacity: 1 }}
|
|
874
|
+
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
|
875
|
+
>
|
|
876
|
+
<AnimatePresence>
|
|
877
|
+
{searchResults.length > 0 && (
|
|
878
|
+
<motion.div
|
|
879
|
+
initial={{ opacity: 0, y: 8, scale: 0.98 }}
|
|
880
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
881
|
+
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
|
882
|
+
transition={{ duration: 0.2, ease: "easeOut" }}
|
|
883
|
+
style={{
|
|
884
|
+
position: 'absolute',
|
|
885
|
+
...(isMobileLayout
|
|
886
|
+
? { top: '100%', marginTop: '8px' }
|
|
887
|
+
: { bottom: '100%', marginBottom: '12px' }
|
|
888
|
+
),
|
|
889
|
+
left: 0,
|
|
890
|
+
right: 0,
|
|
891
|
+
zIndex: 110,
|
|
892
|
+
}}
|
|
893
|
+
>
|
|
894
|
+
<Box
|
|
895
|
+
bg="var(--bg-panel)"
|
|
896
|
+
backdropFilter="blur(24px) saturate(180%)"
|
|
897
|
+
border="1px solid"
|
|
898
|
+
borderColor="var(--border-main)"
|
|
899
|
+
borderRadius="10px"
|
|
900
|
+
overflow="hidden"
|
|
901
|
+
boxShadow="0 20px 50px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05)"
|
|
902
|
+
>
|
|
903
|
+
{searchResults.map((result, idx) => (
|
|
904
|
+
<Flex
|
|
905
|
+
key={result.id}
|
|
906
|
+
px={4}
|
|
907
|
+
py={2.5}
|
|
908
|
+
align="center"
|
|
909
|
+
gap={3}
|
|
910
|
+
cursor="pointer"
|
|
911
|
+
bg={idx === activeSearchIndex ? 'whiteAlpha.100' : 'transparent'}
|
|
912
|
+
_hover={{ bg: 'whiteAlpha.50' }}
|
|
913
|
+
onClick={() => {
|
|
914
|
+
setFocusedId(result.id)
|
|
915
|
+
navigate(`/views/${result.id}`)
|
|
916
|
+
setSearchResults([])
|
|
917
|
+
}}
|
|
918
|
+
transition="all 0.15s ease"
|
|
919
|
+
>
|
|
920
|
+
<Box
|
|
921
|
+
w="6px"
|
|
922
|
+
h="6px"
|
|
923
|
+
borderRadius="full"
|
|
924
|
+
bg={idx === activeSearchIndex ? 'var(--accent)' : 'whiteAlpha.300'}
|
|
925
|
+
boxShadow={idx === activeSearchIndex ? `0 0 10px var(--accent)` : 'none'}
|
|
926
|
+
transition="all 0.2s"
|
|
927
|
+
/>
|
|
928
|
+
<Box flex={1} minW={0}>
|
|
929
|
+
<Text color="white" fontSize="xs" fontWeight="600" isTruncated>
|
|
930
|
+
{result.name}
|
|
931
|
+
</Text>
|
|
932
|
+
<Text color="whiteAlpha.500" fontSize="10px" textTransform="uppercase" letterSpacing="0.05em">
|
|
933
|
+
Level {result.level} • {result.level_label || 'Diagram'}
|
|
934
|
+
</Text>
|
|
935
|
+
</Box>
|
|
936
|
+
{idx === activeSearchIndex && (
|
|
937
|
+
<HStack spacing={1} opacity={0.8}>
|
|
938
|
+
<Text color="var(--accent)" fontSize="9px" fontWeight="800" letterSpacing="0.1em">
|
|
939
|
+
OPEN
|
|
940
|
+
</Text>
|
|
941
|
+
<Text color="whiteAlpha.400" fontSize="9px">↵</Text>
|
|
942
|
+
</HStack>
|
|
943
|
+
)}
|
|
944
|
+
</Flex>
|
|
945
|
+
))}
|
|
946
|
+
</Box>
|
|
947
|
+
</motion.div>
|
|
948
|
+
)}
|
|
949
|
+
</AnimatePresence>
|
|
950
|
+
|
|
951
|
+
<Flex
|
|
952
|
+
bg="var(--bg-header)"
|
|
953
|
+
backdropFilter="blur(24px) saturate(180%)"
|
|
954
|
+
border="1px solid"
|
|
955
|
+
borderColor="var(--border-main)"
|
|
956
|
+
borderRadius="10px"
|
|
957
|
+
pl={4}
|
|
958
|
+
pr={1.5}
|
|
959
|
+
py={1.5}
|
|
960
|
+
gap={3}
|
|
961
|
+
boxShadow="0 10px 30px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.05)"
|
|
962
|
+
align="center"
|
|
963
|
+
minW={isMobileLayout ? "280px" : "380px"}
|
|
964
|
+
w={isMobileLayout ? "calc(100vw - 48px)" : undefined}
|
|
965
|
+
>
|
|
966
|
+
<InputGroup size="sm" flex={1}>
|
|
967
|
+
<InputLeftElement pointerEvents="none" h="full">
|
|
968
|
+
<SearchIcon color="whiteAlpha.400" fontSize="10px" />
|
|
969
|
+
</InputLeftElement>
|
|
970
|
+
<Input
|
|
971
|
+
placeholder="Jump to diagram..."
|
|
972
|
+
value={searchTerm}
|
|
973
|
+
onChange={(e) => handleSearch(e.target.value)}
|
|
974
|
+
onKeyDown={handleSearchKeyDown}
|
|
975
|
+
variant="unstyled"
|
|
976
|
+
fontSize="xs"
|
|
977
|
+
color="white"
|
|
978
|
+
h="32px"
|
|
979
|
+
_placeholder={{ color: 'whiteAlpha.300' }}
|
|
980
|
+
/>
|
|
981
|
+
{searchTerm && (
|
|
982
|
+
<InputRightElement h="full">
|
|
983
|
+
<IconButton
|
|
984
|
+
aria-label="Clear search"
|
|
985
|
+
icon={<CloseIcon fontSize="8px" />}
|
|
986
|
+
size="xs"
|
|
987
|
+
variant="ghost"
|
|
988
|
+
color="whiteAlpha.400"
|
|
989
|
+
_hover={{ color: 'white', bg: 'transparent' }}
|
|
990
|
+
onClick={() => handleSearch('')}
|
|
991
|
+
/>
|
|
992
|
+
</InputRightElement>
|
|
993
|
+
)}
|
|
994
|
+
</InputGroup>
|
|
995
|
+
|
|
996
|
+
{canEdit && (
|
|
997
|
+
<Button
|
|
998
|
+
size="sm"
|
|
999
|
+
h="32px"
|
|
1000
|
+
leftIcon={<AddIcon fontSize="9px" />}
|
|
1001
|
+
bg="var(--accent)"
|
|
1002
|
+
color="white"
|
|
1003
|
+
_hover={{
|
|
1004
|
+
bg: "var(--accent)",
|
|
1005
|
+
filter: "brightness(1.1)",
|
|
1006
|
+
transform: 'translateY(-1px)',
|
|
1007
|
+
boxShadow: `0 0 20px ${hexToRgba(accent, 0.4)}`
|
|
1008
|
+
}}
|
|
1009
|
+
_active={{ transform: 'translateY(0)', filter: "brightness(0.9)" }}
|
|
1010
|
+
variant="solid"
|
|
1011
|
+
borderRadius="lg"
|
|
1012
|
+
px={4}
|
|
1013
|
+
fontSize="xs"
|
|
1014
|
+
fontWeight="bold"
|
|
1015
|
+
letterSpacing="0.02em"
|
|
1016
|
+
onClick={() => {
|
|
1017
|
+
setNewName('')
|
|
1018
|
+
onCreateOpen()
|
|
1019
|
+
}}
|
|
1020
|
+
boxShadow={`0 4px 12px ${hexToRgba(accent, 0.2)}`}
|
|
1021
|
+
transition="all 0.2s cubic-bezier(0.4, 0, 0.2, 1)"
|
|
1022
|
+
>
|
|
1023
|
+
NEW
|
|
1024
|
+
</Button>
|
|
1025
|
+
)}
|
|
1026
|
+
</Flex>
|
|
1027
|
+
</motion.div>
|
|
1028
|
+
</Box>
|
|
1029
|
+
|
|
1030
|
+
{/* Level change overlay banner */}
|
|
1031
|
+
{levelEditingNodeId && (
|
|
1032
|
+
<Flex
|
|
1033
|
+
position="absolute"
|
|
1034
|
+
top={6}
|
|
1035
|
+
left="50%"
|
|
1036
|
+
transform="translateX(-50%)"
|
|
1037
|
+
bg="rgba(15, 23, 42, 0.85)"
|
|
1038
|
+
border="1px solid var(--accent)"
|
|
1039
|
+
boxShadow="0 8px 32px rgba(0,0,0,0.6), 0 0 24px rgba(var(--accent-rgb), 0.3)"
|
|
1040
|
+
borderRadius="full"
|
|
1041
|
+
px={6}
|
|
1042
|
+
py={3}
|
|
1043
|
+
zIndex={100}
|
|
1044
|
+
align="center"
|
|
1045
|
+
gap={6}
|
|
1046
|
+
backdropFilter="blur(12px)"
|
|
1047
|
+
>
|
|
1048
|
+
<Flex align="center" gap={3}>
|
|
1049
|
+
<Box w={2} h={2} borderRadius="full" bg="var(--accent)" boxShadow="0 0 8px var(--accent)" />
|
|
1050
|
+
<Text color="gray.200" fontSize="sm" fontWeight="medium">
|
|
1051
|
+
Changing level for <Text as="span" color="white" fontWeight="bold">"{flatTree.find(n => n.id === levelEditingNodeId)?.name}"</Text>
|
|
1052
|
+
</Text>
|
|
1053
|
+
</Flex>
|
|
1054
|
+
<Box w="1px" h="16px" bg="whiteAlpha.300" />
|
|
1055
|
+
<Text color="gray.400" fontSize="sm">
|
|
1056
|
+
Click an L0-L9 level band to set diagram depth
|
|
1057
|
+
</Text>
|
|
1058
|
+
<Flex gap={2}>
|
|
1059
|
+
<Button size="xs" variant="ghost" color="gray.400" _hover={{ color: 'white', bg: 'whiteAlpha.200' }} onClick={() => setLevelEditingNodeId(null)}>
|
|
1060
|
+
Cancel
|
|
1061
|
+
</Button>
|
|
1062
|
+
</Flex>
|
|
1063
|
+
</Flex>
|
|
1064
|
+
)}
|
|
1065
|
+
|
|
1066
|
+
{levelEditingNodeId !== null && (
|
|
1067
|
+
<ViewGridSidebar
|
|
1068
|
+
maxDepth={maxDepth}
|
|
1069
|
+
isReparenting={true}
|
|
1070
|
+
onLevelClick={handleSetLevel}
|
|
1071
|
+
activeLevel={flatTree.find((n) => n.id === levelEditingNodeId)?.level ?? null}
|
|
1072
|
+
/>
|
|
1073
|
+
)}
|
|
1074
|
+
|
|
1075
|
+
<Box
|
|
1076
|
+
ref={rfContainerRef}
|
|
1077
|
+
position="relative"
|
|
1078
|
+
w="full"
|
|
1079
|
+
h="full"
|
|
1080
|
+
>
|
|
1081
|
+
<ReactFlow
|
|
1082
|
+
nodes={allRfNodes}
|
|
1083
|
+
edges={allRfEdges}
|
|
1084
|
+
nodeTypes={NODE_TYPES}
|
|
1085
|
+
edgeTypes={EDGE_TYPES}
|
|
1086
|
+
onlyRenderVisibleElements
|
|
1087
|
+
fitView
|
|
1088
|
+
fitViewOptions={{ padding: 0.15, minZoom: 0.8, maxZoom: 1.2 }}
|
|
1089
|
+
panOnScroll={!isMobileLayout}
|
|
1090
|
+
zoomOnScroll={false}
|
|
1091
|
+
zoomOnPinch
|
|
1092
|
+
minZoom={computedMinZoom}
|
|
1093
|
+
maxZoom={2}
|
|
1094
|
+
translateExtent={computedTranslateExtent}
|
|
1095
|
+
nodesDraggable={false}
|
|
1096
|
+
nodesConnectable={false}
|
|
1097
|
+
onPaneClick={() => {
|
|
1098
|
+
setFocusedId(null)
|
|
1099
|
+
}}
|
|
1100
|
+
style={{
|
|
1101
|
+
background: 'var(--bg-canvas)',
|
|
1102
|
+
boxShadow: 'inset 0 0 100px rgba(0,0,0,0.6)'
|
|
1103
|
+
}}
|
|
1104
|
+
>
|
|
1105
|
+
{/* Micro dots for high precision technical feel */}
|
|
1106
|
+
<SafeBackground id="micro" variant={BackgroundVariant.Dots} gap={20} size={1} color={hexToRgba(accent, 0.2)} />
|
|
1107
|
+
{/* Minor cell grid for regular structural spacing */}
|
|
1108
|
+
</ReactFlow>
|
|
1109
|
+
</Box>
|
|
1110
|
+
|
|
1111
|
+
{/* Empty state overlay */}
|
|
1112
|
+
{roots.length === 0 && (
|
|
1113
|
+
<Flex
|
|
1114
|
+
position="absolute"
|
|
1115
|
+
inset={0}
|
|
1116
|
+
align="center"
|
|
1117
|
+
justify="center"
|
|
1118
|
+
pointerEvents="none"
|
|
1119
|
+
>
|
|
1120
|
+
<Box textAlign="center">
|
|
1121
|
+
<Text color="gray.600" fontSize="sm" mb={1}>No views yet.</Text>
|
|
1122
|
+
{canEdit && (
|
|
1123
|
+
<>
|
|
1124
|
+
<Text color="gray.700" fontSize="xs" mb={4}>Click "New Diagram" to get started.</Text>
|
|
1125
|
+
|
|
1126
|
+
</>
|
|
1127
|
+
)}
|
|
1128
|
+
</Box>
|
|
1129
|
+
</Flex>
|
|
1130
|
+
)}
|
|
1131
|
+
|
|
1132
|
+
</Box>
|
|
1133
|
+
|
|
1134
|
+
{/* Legend + keyboard hint */}
|
|
1135
|
+
<Box
|
|
1136
|
+
position="fixed"
|
|
1137
|
+
bottom={0}
|
|
1138
|
+
left={0}
|
|
1139
|
+
right={0}
|
|
1140
|
+
zIndex={20}
|
|
1141
|
+
pointerEvents="none"
|
|
1142
|
+
pb={3}
|
|
1143
|
+
>
|
|
1144
|
+
{/* Edge type legend */}
|
|
1145
|
+
<Flex justify="center" align="center" gap={4} mb="3px">
|
|
1146
|
+
<HStack spacing={1}>
|
|
1147
|
+
<Box w="18px" style={{ borderTop: '1px solid rgba(255,255,255,0.2)' }} />
|
|
1148
|
+
<Text fontSize="9px" color="gray.700" letterSpacing="0.05em" lineHeight={1}>hierarchy link</Text>
|
|
1149
|
+
</HStack>
|
|
1150
|
+
</Flex>
|
|
1151
|
+
<Text fontSize="11px" color="gray.700" userSelect="none" letterSpacing="0.03em" textAlign="center">
|
|
1152
|
+
Click=Select · W↑ S↓ A← D→ · Enter=Open · Esc=Deselect
|
|
1153
|
+
</Text>
|
|
1154
|
+
</Box>
|
|
1155
|
+
|
|
1156
|
+
{/* Confirm Delete Dialog */}
|
|
1157
|
+
<ConfirmDialog
|
|
1158
|
+
isOpen={isDeleteOpen}
|
|
1159
|
+
onClose={onDeleteClose}
|
|
1160
|
+
onConfirm={handleDeleteConfirm}
|
|
1161
|
+
title="Delete diagram"
|
|
1162
|
+
body="Are you sure you want to delete this diagram? This action cannot be undone."
|
|
1163
|
+
confirmLabel="Delete"
|
|
1164
|
+
confirmColorScheme="red"
|
|
1165
|
+
/>
|
|
1166
|
+
|
|
1167
|
+
{/* Create Diagram Modal */}
|
|
1168
|
+
<Modal
|
|
1169
|
+
isOpen={isCreateOpen}
|
|
1170
|
+
onClose={onCreateClose}
|
|
1171
|
+
isCentered
|
|
1172
|
+
size="sm"
|
|
1173
|
+
>
|
|
1174
|
+
<ModalOverlay bg="blackAlpha.700" backdropFilter="blur(4px)" />
|
|
1175
|
+
<ModalContent
|
|
1176
|
+
bg="var(--bg-panel)"
|
|
1177
|
+
border="1px solid"
|
|
1178
|
+
borderColor="var(--border-main)"
|
|
1179
|
+
borderRadius="xl"
|
|
1180
|
+
boxShadow="0 24px 64px rgba(0,0,0,0.8)"
|
|
1181
|
+
>
|
|
1182
|
+
<ModalHeader color="gray.100" pb={1} fontSize="md">Create New Diagram</ModalHeader>
|
|
1183
|
+
<ModalBody>
|
|
1184
|
+
<FormControl id="new-view-name">
|
|
1185
|
+
<FormLabel fontSize="xs" color="gray.500" textTransform="uppercase" letterSpacing="0.05em">
|
|
1186
|
+
Diagram Name
|
|
1187
|
+
</FormLabel>
|
|
1188
|
+
<Input
|
|
1189
|
+
name="name"
|
|
1190
|
+
value={newName}
|
|
1191
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
1192
|
+
size="sm"
|
|
1193
|
+
bg="whiteAlpha.50"
|
|
1194
|
+
border="1px solid"
|
|
1195
|
+
borderColor="whiteAlpha.100"
|
|
1196
|
+
_hover={{ borderColor: 'whiteAlpha.300' }}
|
|
1197
|
+
_focus={{ borderColor: 'var(--accent)', boxShadow: '0 0 0 1px var(--accent)' }}
|
|
1198
|
+
autoFocus
|
|
1199
|
+
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
|
1200
|
+
placeholder="My New Architecture"
|
|
1201
|
+
/>
|
|
1202
|
+
</FormControl>
|
|
1203
|
+
</ModalBody>
|
|
1204
|
+
<ModalFooter gap={2} pt={6}>
|
|
1205
|
+
<Button size="sm" variant="ghost" color="gray.500" _hover={{ color: 'white', bg: 'whiteAlpha.100' }} onClick={onCreateClose}>
|
|
1206
|
+
Cancel
|
|
1207
|
+
</Button>
|
|
1208
|
+
<Button
|
|
1209
|
+
size="sm"
|
|
1210
|
+
bg="var(--accent)"
|
|
1211
|
+
color="white"
|
|
1212
|
+
_hover={{ bg: "var(--accent)", filter: "brightness(1.1)" }}
|
|
1213
|
+
_active={{ bg: "var(--accent)", filter: "brightness(0.9)" }}
|
|
1214
|
+
isLoading={isCreating}
|
|
1215
|
+
isDisabled={!newName.trim()}
|
|
1216
|
+
onClick={handleCreate}
|
|
1217
|
+
borderRadius="lg"
|
|
1218
|
+
px={6}
|
|
1219
|
+
>
|
|
1220
|
+
Create
|
|
1221
|
+
</Button>
|
|
1222
|
+
</ModalFooter>
|
|
1223
|
+
</ModalContent>
|
|
1224
|
+
</Modal>
|
|
1225
|
+
|
|
1226
|
+
{/* Details Drawer */}
|
|
1227
|
+
<ViewPanel
|
|
1228
|
+
isOpen={isDetailsOpen && !detailsLoading}
|
|
1229
|
+
onClose={onDetailsClose}
|
|
1230
|
+
view={detailsView}
|
|
1231
|
+
canEdit={canEdit}
|
|
1232
|
+
onSave={handleDetailsSave}
|
|
1233
|
+
hasBackdrop={isMobileLayout}
|
|
1234
|
+
/>
|
|
1235
|
+
|
|
1236
|
+
{/* Feature tutorial */}
|
|
1237
|
+
|
|
1238
|
+
{/* Onboarding Wizard */}
|
|
1239
|
+
<Modal
|
|
1240
|
+
isOpen={onboardingStep === 1 || onboardingStep === 2}
|
|
1241
|
+
onClose={() => setOnboardingStep(0)}
|
|
1242
|
+
isCentered
|
|
1243
|
+
size="sm"
|
|
1244
|
+
>
|
|
1245
|
+
<ModalOverlay bg="blackAlpha.700" />
|
|
1246
|
+
<ModalContent bg="var(--bg-panel)" border="1px solid" borderColor="var(--border-main)">
|
|
1247
|
+
{onboardingStep === 1 && (
|
|
1248
|
+
<>
|
|
1249
|
+
<ModalHeader color="gray.100" pb={1}>Welcome to tldiagram!</ModalHeader>
|
|
1250
|
+
<ModalBody>
|
|
1251
|
+
<Text fontSize="sm" color="gray.400" mb={4}>
|
|
1252
|
+
Start by creating your first diagram.
|
|
1253
|
+
</Text>
|
|
1254
|
+
<FormControl id="onboarding-view-name">
|
|
1255
|
+
<FormLabel fontSize="xs" color="gray.500" textTransform="uppercase">
|
|
1256
|
+
Diagram Name
|
|
1257
|
+
</FormLabel>
|
|
1258
|
+
<Input
|
|
1259
|
+
name="name"
|
|
1260
|
+
value={onboardingName}
|
|
1261
|
+
onChange={(e) => setOnboardingName(e.target.value)}
|
|
1262
|
+
size="sm"
|
|
1263
|
+
autoFocus
|
|
1264
|
+
onKeyDown={(e) => e.key === 'Enter' && handleOnboardingCreate()}
|
|
1265
|
+
/>
|
|
1266
|
+
</FormControl>
|
|
1267
|
+
</ModalBody>
|
|
1268
|
+
<ModalFooter gap={2}>
|
|
1269
|
+
<Button size="sm" variant="ghost" color="gray.500" onClick={() => setOnboardingStep(0)}>
|
|
1270
|
+
Skip
|
|
1271
|
+
</Button>
|
|
1272
|
+
<Button
|
|
1273
|
+
size="sm"
|
|
1274
|
+
colorScheme="blue"
|
|
1275
|
+
isLoading={onboardingCreating}
|
|
1276
|
+
isDisabled={!onboardingName.trim()}
|
|
1277
|
+
onClick={handleOnboardingCreate}
|
|
1278
|
+
>
|
|
1279
|
+
Create Diagram
|
|
1280
|
+
</Button>
|
|
1281
|
+
</ModalFooter>
|
|
1282
|
+
</>
|
|
1283
|
+
)}
|
|
1284
|
+
{onboardingStep === 2 && (
|
|
1285
|
+
<>
|
|
1286
|
+
<ModalHeader color="gray.100" pb={1}>Your diagram is ready!</ModalHeader>
|
|
1287
|
+
<ModalBody>
|
|
1288
|
+
<Text fontSize="sm" color="gray.400">
|
|
1289
|
+
Next, add elements to your diagram to start building your architecture.
|
|
1290
|
+
</Text>
|
|
1291
|
+
</ModalBody>
|
|
1292
|
+
<ModalFooter>
|
|
1293
|
+
<Button
|
|
1294
|
+
size="sm"
|
|
1295
|
+
colorScheme="blue"
|
|
1296
|
+
onClick={() => {
|
|
1297
|
+
setOnboardingStep(0)
|
|
1298
|
+
if (onboardingViewId !== null) navigate(`/views/${onboardingViewId}`)
|
|
1299
|
+
}}
|
|
1300
|
+
>
|
|
1301
|
+
Start Building
|
|
1302
|
+
</Button>
|
|
1303
|
+
</ModalFooter>
|
|
1304
|
+
</>
|
|
1305
|
+
)}
|
|
1306
|
+
</ModalContent>
|
|
1307
|
+
</Modal>
|
|
1308
|
+
</Box>
|
|
1309
|
+
)
|
|
1310
|
+
}
|