@tldiagram/core-ui 1.93.0 → 1.94.1
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/components/ElementNode.d.ts +5 -1
- package/dist/components/ZUI/ZUICanvas.d.ts +1 -0
- package/dist/index.js +9285 -9742
- package/dist/pages/InfiniteZoom.d.ts +5 -2
- package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +9 -3
- package/dist/pages/ViewEditor/hooks/useCanvasInteractions.test.d.ts +1 -0
- package/dist/pages/ViewEditor/hooks/useViewData.d.ts +7 -3
- package/dist/pages/ViewsGrid.d.ts +9 -1
- package/dist/store/useStore.d.ts +2 -0
- package/package.json +6 -5
- package/src/components/ElementNode.tsx +10 -1
- package/src/components/ElementPanel.tsx +1 -2
- package/src/components/LayoutSection.tsx +27 -11
- package/src/components/ZUI/ZUICanvas.tsx +138 -1
- package/src/pages/InfiniteZoom.tsx +14 -5
- package/src/pages/ViewEditor/context.tsx +4 -1
- package/src/pages/ViewEditor/hooks/useCanvasInteractions.test.ts +30 -0
- package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +139 -42
- package/src/pages/ViewEditor/hooks/useViewData.ts +4 -3
- package/src/pages/ViewEditor/index.tsx +24 -40
- package/src/pages/Views.tsx +552 -83
- package/src/pages/ViewsGrid.tsx +26 -337
- package/src/store/useStore.test.ts +13 -0
- package/src/store/useStore.ts +42 -0
package/src/pages/ViewsGrid.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
|
2
2
|
import { useNavigate } from 'react-router-dom'
|
|
3
3
|
import { SafeBackground } from '../components/SafeBackground'
|
|
4
4
|
import { Text as HeaderText } from '@chakra-ui/react'
|
|
@@ -21,11 +21,7 @@ import {
|
|
|
21
21
|
FormLabel,
|
|
22
22
|
Heading,
|
|
23
23
|
HStack,
|
|
24
|
-
IconButton,
|
|
25
24
|
Input,
|
|
26
|
-
InputGroup,
|
|
27
|
-
InputLeftElement,
|
|
28
|
-
InputRightElement,
|
|
29
25
|
Modal,
|
|
30
26
|
ModalBody,
|
|
31
27
|
ModalContent,
|
|
@@ -37,8 +33,6 @@ import {
|
|
|
37
33
|
useDisclosure,
|
|
38
34
|
useBreakpointValue,
|
|
39
35
|
} from '@chakra-ui/react'
|
|
40
|
-
import { motion, AnimatePresence } from 'framer-motion'
|
|
41
|
-
import { SearchIcon, CloseIcon, AddIcon } from '@chakra-ui/icons'
|
|
42
36
|
import { api } from '../api/client'
|
|
43
37
|
import { toast } from '../utils/toast'
|
|
44
38
|
import type { ViewTreeNode } from '../types'
|
|
@@ -323,21 +317,27 @@ const HIERARCHY_EDGE_COLOR = 'rgba(255,255,255,0.2)'
|
|
|
323
317
|
|
|
324
318
|
interface Props {
|
|
325
319
|
onShare?: (viewId: number) => void
|
|
320
|
+
treeData: ViewTreeNode[]
|
|
321
|
+
loading: boolean
|
|
322
|
+
focusedId: number | null
|
|
323
|
+
onFocusChange: (viewId: number | null) => void
|
|
324
|
+
setTreeData: Dispatch<SetStateAction<ViewTreeNode[]>>
|
|
325
|
+
refreshTree: () => Promise<void>
|
|
326
326
|
}
|
|
327
327
|
|
|
328
328
|
// ── Root component - provides ReactFlow context ───────────────────────────────
|
|
329
329
|
|
|
330
|
-
export default function ViewsGrid(
|
|
330
|
+
export default function ViewsGrid(props: Props) {
|
|
331
331
|
return (
|
|
332
332
|
<ReactFlowProvider>
|
|
333
|
-
<ViewGridInner
|
|
333
|
+
<ViewGridInner {...props} />
|
|
334
334
|
</ReactFlowProvider>
|
|
335
335
|
)
|
|
336
336
|
}
|
|
337
337
|
|
|
338
338
|
// ── Inner component - has access to useReactFlow() ────────────────────────────
|
|
339
339
|
|
|
340
|
-
function ViewGridInner({ onShare }: Props) {
|
|
340
|
+
function ViewGridInner({ onShare, treeData, loading, focusedId, onFocusChange, setTreeData, refreshTree }: Props) {
|
|
341
341
|
const isMobileLayout = useBreakpointValue({ base: true, md: false }) ?? false
|
|
342
342
|
const navigate = useNavigate()
|
|
343
343
|
const { accent } = useAccentColor()
|
|
@@ -386,63 +386,10 @@ function ViewGridInner({ onShare }: Props) {
|
|
|
386
386
|
return () => el.removeEventListener('wheel', onWheel, { capture: true })
|
|
387
387
|
}, [zoomIn, zoomOut])
|
|
388
388
|
|
|
389
|
-
// ── Core state ──────────────────────────────────────────────────────────────
|
|
390
|
-
const [treeData, setTreeData] = useState<ViewTreeNode[]>([])
|
|
391
|
-
const [loading, setLoading] = useState(true)
|
|
392
|
-
|
|
393
389
|
// ── Derived tree structures ─────────────────────────────────────────────────
|
|
394
390
|
const roots = useMemo(() => treeData, [treeData])
|
|
395
391
|
const flatTree = useMemo(() => flattenTree(roots), [roots])
|
|
396
392
|
|
|
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
393
|
// Rename
|
|
447
394
|
const [editingId, setEditingId] = useState<number | null>(null)
|
|
448
395
|
const [editName, setEditName] = useState('')
|
|
@@ -461,27 +408,6 @@ function ViewGridInner({ onShare }: Props) {
|
|
|
461
408
|
const [detailsLoading, setDetailsLoading] = useState(false)
|
|
462
409
|
const { isOpen: isDetailsOpen, onOpen: onDetailsOpen, onClose: onDetailsClose } = useDisclosure()
|
|
463
410
|
|
|
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
411
|
// Delete dialog
|
|
486
412
|
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null)
|
|
487
413
|
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure()
|
|
@@ -489,21 +415,12 @@ function ViewGridInner({ onShare }: Props) {
|
|
|
489
415
|
// Level change mode
|
|
490
416
|
const [levelEditingNodeId, setLevelEditingNodeId] = useState<number | null>(null)
|
|
491
417
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
}
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
if (treeData.length === 0 && !loading && !localStorage.getItem('onboarding_shown')) {
|
|
420
|
+
localStorage.setItem('onboarding_shown', '1')
|
|
421
|
+
setOnboardingStep(1)
|
|
502
422
|
}
|
|
503
|
-
|
|
504
|
-
}, [])
|
|
505
|
-
|
|
506
|
-
useEffect(() => { refresh() }, [refresh])
|
|
423
|
+
}, [loading, treeData.length])
|
|
507
424
|
|
|
508
425
|
// Fetch node/edge counts
|
|
509
426
|
useEffect(() => {
|
|
@@ -545,7 +462,7 @@ function ViewGridInner({ onShare }: Props) {
|
|
|
545
462
|
await api.workspace.views.rename(id, name).catch(() =>
|
|
546
463
|
setTreeData((d) => d.map((n) => (n.id === id ? { ...n, name: prev.name } : n)))
|
|
547
464
|
)
|
|
548
|
-
}, [editingId, editName, treeData])
|
|
465
|
+
}, [editingId, editName, setTreeData, treeData])
|
|
549
466
|
|
|
550
467
|
const cancelEdit = useCallback(() => setEditingId(null), [])
|
|
551
468
|
|
|
@@ -569,7 +486,7 @@ function ViewGridInner({ onShare }: Props) {
|
|
|
569
486
|
: n
|
|
570
487
|
)
|
|
571
488
|
)
|
|
572
|
-
}, [])
|
|
489
|
+
}, [setTreeData])
|
|
573
490
|
|
|
574
491
|
// ── Delete ──────────────────────────────────────────────────────────────────
|
|
575
492
|
const handleDeleteConfirm = async () => {
|
|
@@ -612,15 +529,15 @@ function ViewGridInner({ onShare }: Props) {
|
|
|
612
529
|
} catch {
|
|
613
530
|
// global error toast will show
|
|
614
531
|
}
|
|
615
|
-
await
|
|
616
|
-
}, [levelEditingNodeId, treeData,
|
|
532
|
+
await refreshTree()
|
|
533
|
+
}, [levelEditingNodeId, treeData, refreshTree, setTreeData])
|
|
617
534
|
|
|
618
535
|
const handleOnboardingCreate = async () => {
|
|
619
536
|
setOnboardingCreating(true)
|
|
620
537
|
try {
|
|
621
538
|
const d = await api.workspace.views.create({ name: onboardingName.trim() || 'My First Diagram' })
|
|
622
539
|
setOnboardingDiagramId(d.id)
|
|
623
|
-
await
|
|
540
|
+
await refreshTree()
|
|
624
541
|
setOnboardingStep(2)
|
|
625
542
|
} catch { /* ignore */ } finally {
|
|
626
543
|
setOnboardingCreating(false)
|
|
@@ -695,7 +612,7 @@ function ViewGridInner({ onShare }: Props) {
|
|
|
695
612
|
canEdit,
|
|
696
613
|
isEditing: editingId === n.id,
|
|
697
614
|
editName,
|
|
698
|
-
onFocus: () =>
|
|
615
|
+
onFocus: () => onFocusChange(n.id),
|
|
699
616
|
onOpen: () => navigate(`/views/${n.id}`),
|
|
700
617
|
onStartRename: () => startEdit(n.id, n.name),
|
|
701
618
|
onDetails: () => handleDetailsOpen(n.id),
|
|
@@ -782,7 +699,7 @@ function ViewGridInner({ onShare }: Props) {
|
|
|
782
699
|
if (levelEditingNodeId !== null) {
|
|
783
700
|
setLevelEditingNodeId(null)
|
|
784
701
|
} else {
|
|
785
|
-
|
|
702
|
+
onFocusChange(null)
|
|
786
703
|
}
|
|
787
704
|
return
|
|
788
705
|
}
|
|
@@ -794,7 +711,7 @@ function ViewGridInner({ onShare }: Props) {
|
|
|
794
711
|
|
|
795
712
|
// Auto-select first card if nothing is focused yet
|
|
796
713
|
if (!focusedId) {
|
|
797
|
-
if (flatTree.length > 0)
|
|
714
|
+
if (flatTree.length > 0) onFocusChange(flatTree[0].id)
|
|
798
715
|
return
|
|
799
716
|
}
|
|
800
717
|
|
|
@@ -816,12 +733,12 @@ function ViewGridInner({ onShare }: Props) {
|
|
|
816
733
|
nextId = idx < siblings.length - 1 ? siblings[idx + 1].id : null
|
|
817
734
|
}
|
|
818
735
|
|
|
819
|
-
if (nextId)
|
|
736
|
+
if (nextId) onFocusChange(nextId)
|
|
820
737
|
}
|
|
821
738
|
|
|
822
739
|
window.addEventListener('keydown', handler)
|
|
823
740
|
return () => window.removeEventListener('keydown', handler)
|
|
824
|
-
}, [focusedId, flatTree, navigate, levelEditingNodeId])
|
|
741
|
+
}, [focusedId, flatTree, navigate, levelEditingNodeId, onFocusChange])
|
|
825
742
|
|
|
826
743
|
// ── Camera: pan to focused node only when it's out of view ──────────────────
|
|
827
744
|
useEffect(() => {
|
|
@@ -858,175 +775,6 @@ function ViewGridInner({ onShare }: Props) {
|
|
|
858
775
|
<Box h="full" display="flex" flexDir="column" position="relative">
|
|
859
776
|
{/* Canvas */}
|
|
860
777
|
<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
778
|
{/* Level change overlay banner */}
|
|
1031
779
|
{levelEditingNodeId && (
|
|
1032
780
|
<Flex
|
|
@@ -1095,7 +843,7 @@ function ViewGridInner({ onShare }: Props) {
|
|
|
1095
843
|
nodesDraggable={false}
|
|
1096
844
|
nodesConnectable={false}
|
|
1097
845
|
onPaneClick={() => {
|
|
1098
|
-
|
|
846
|
+
onFocusChange(null)
|
|
1099
847
|
}}
|
|
1100
848
|
style={{
|
|
1101
849
|
background: 'var(--bg-canvas)',
|
|
@@ -1164,65 +912,6 @@ function ViewGridInner({ onShare }: Props) {
|
|
|
1164
912
|
confirmColorScheme="red"
|
|
1165
913
|
/>
|
|
1166
914
|
|
|
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
915
|
{/* Details Drawer */}
|
|
1227
916
|
<ViewPanel
|
|
1228
917
|
isOpen={isDetailsOpen && !detailsLoading}
|
|
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
|
|
2
2
|
import type { Connector, LibraryElement, PlacedElement, ViewTreeNode } from '../types'
|
|
3
3
|
import {
|
|
4
4
|
buildViewContentLinks,
|
|
5
|
+
buildElementLibraryItems,
|
|
5
6
|
canvasSelectors,
|
|
6
7
|
emptyViewEditorUiState,
|
|
7
8
|
findViewByOwner,
|
|
@@ -151,6 +152,18 @@ describe('pure view helpers', () => {
|
|
|
151
152
|
expect(merged[1]).toBe(elements[1])
|
|
152
153
|
})
|
|
153
154
|
|
|
155
|
+
it('keeps library items available after removing their canvas placement', () => {
|
|
156
|
+
const onCanvas = element(10)
|
|
157
|
+
const libraryItems = buildElementLibraryItems([libraryElement(10), libraryElement(20)], [onCanvas])
|
|
158
|
+
|
|
159
|
+
expect(libraryItems.map((item) => item.id)).toEqual([10, 20])
|
|
160
|
+
expect(libraryItems[0]).toMatchObject({ id: 10, name: onCanvas.name, created_at: '2024-01-01' })
|
|
161
|
+
|
|
162
|
+
const afterRemoval = buildElementLibraryItems([libraryElement(10), libraryElement(20)], removePlacedElement([onCanvas], 10))
|
|
163
|
+
expect(afterRemoval.map((item) => item.id)).toEqual([10, 20])
|
|
164
|
+
expect(afterRemoval[0]).toMatchObject({ id: 10, name: 'Saved' })
|
|
165
|
+
})
|
|
166
|
+
|
|
154
167
|
it('upserts and removes connectors', () => {
|
|
155
168
|
const first = connector(1)
|
|
156
169
|
const second = { ...connector(2), label: 'two' }
|
package/src/store/useStore.ts
CHANGED
|
@@ -180,6 +180,48 @@ export function removePlacedElement(elements: PlacedElement[], elementId: number
|
|
|
180
180
|
return elements.filter((element) => element.element_id !== elementId)
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
export function placedElementToLibraryElement(element: PlacedElement): LibraryElement {
|
|
184
|
+
return {
|
|
185
|
+
id: element.element_id,
|
|
186
|
+
name: element.name,
|
|
187
|
+
kind: element.kind,
|
|
188
|
+
description: element.description,
|
|
189
|
+
technology: element.technology,
|
|
190
|
+
url: element.url,
|
|
191
|
+
logo_url: element.logo_url,
|
|
192
|
+
technology_connectors: element.technology_connectors,
|
|
193
|
+
tags: element.tags,
|
|
194
|
+
repo: element.repo,
|
|
195
|
+
branch: element.branch,
|
|
196
|
+
file_path: element.file_path,
|
|
197
|
+
language: element.language,
|
|
198
|
+
created_at: '',
|
|
199
|
+
updated_at: '',
|
|
200
|
+
has_view: element.has_view,
|
|
201
|
+
view_label: element.view_label,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function buildElementLibraryItems(allElements: LibraryElement[], viewElements: PlacedElement[]): LibraryElement[] {
|
|
206
|
+
const byId = new Map<number, LibraryElement>()
|
|
207
|
+
allElements.forEach((element) => byId.set(element.id, element))
|
|
208
|
+
viewElements.forEach((element) => {
|
|
209
|
+
const placed = placedElementToLibraryElement(element)
|
|
210
|
+
const existing = byId.get(placed.id)
|
|
211
|
+
byId.set(placed.id, existing
|
|
212
|
+
? {
|
|
213
|
+
...existing,
|
|
214
|
+
...placed,
|
|
215
|
+
created_at: existing.created_at,
|
|
216
|
+
updated_at: existing.updated_at,
|
|
217
|
+
has_view: existing.has_view,
|
|
218
|
+
view_label: existing.view_label,
|
|
219
|
+
}
|
|
220
|
+
: placed)
|
|
221
|
+
})
|
|
222
|
+
return Array.from(byId.values())
|
|
223
|
+
}
|
|
224
|
+
|
|
183
225
|
export function mergeSavedElementIntoPlacements(elements: PlacedElement[], saved: LibraryElement): PlacedElement[] {
|
|
184
226
|
return elements.map((element) =>
|
|
185
227
|
element.element_id === saved.id
|