@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.
@@ -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({ onShare }: Props) {
330
+ export default function ViewsGrid(props: Props) {
331
331
  return (
332
332
  <ReactFlowProvider>
333
- <ViewGridInner onShare={onShare} />
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
- // 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
- }
418
+ useEffect(() => {
419
+ if (treeData.length === 0 && !loading && !localStorage.getItem('onboarding_shown')) {
420
+ localStorage.setItem('onboarding_shown', '1')
421
+ setOnboardingStep(1)
502
422
  }
503
- setLoading(false)
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 refresh()
616
- }, [levelEditingNodeId, treeData, refresh])
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 refresh()
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: () => setFocusedId(n.id),
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
- setFocusedId(null)
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) setFocusedId(flatTree[0].id)
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) setFocusedId(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
- setFocusedId(null)
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' }
@@ -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