@tldiagram/core-ui 1.94.2 → 1.94.4

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.
@@ -7,4 +7,4 @@ export interface InfiniteZoomHandle {
7
7
  }
8
8
  declare const InfiniteZoom: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<InfiniteZoomHandle>>;
9
9
  export default InfiniteZoom;
10
- export declare function SharedInfiniteZoom(props: Props): import("react/jsx-runtime").JSX.Element;
10
+ export declare const SharedInfiniteZoom: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<InfiniteZoomHandle>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tldiagram/core-ui",
3
- "version": "1.94.2",
3
+ "version": "1.94.4",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
package/src/api/client.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  ExploreData,
9
9
  LibraryElement,
10
10
  PlacedElement,
11
+ Tag,
11
12
  View,
12
13
  ViewConnector,
13
14
  ViewLayer,
@@ -91,6 +92,7 @@ interface ProtoDiagram {
91
92
  updatedAt?: string
92
93
  updated_at?: string
93
94
  parent_view_id?: number | null
95
+ parentViewId?: number | null
94
96
  children?: ProtoDiagram[]
95
97
  }
96
98
 
@@ -107,7 +109,9 @@ function mapDiagram(d: ProtoDiagram): ViewTreeNode {
107
109
  depth: d.depth ?? 0,
108
110
  created_at: d.createdAt ?? d.created_at ?? '',
109
111
  updated_at: d.updatedAt ?? d.updated_at ?? '',
110
- parent_view_id: d.parent_view_id ?? null,
112
+ parent_view_id: d.parentViewId != null || d.parent_view_id != null
113
+ ? Number(d.parentViewId ?? d.parent_view_id)
114
+ : null,
111
115
  children: (d.children ?? []).map(mapDiagram),
112
116
  }
113
117
  }
@@ -333,6 +337,12 @@ export const api = {
333
337
  },
334
338
 
335
339
  workspace: {
340
+ orgs: {
341
+ tagColors: {
342
+ list: (): Promise<Tag[]> => Promise.resolve([]),
343
+ },
344
+ },
345
+
336
346
  elements: {
337
347
  list: (params?: { limit?: number; offset?: number; search?: string }): Promise<LibraryElement[]> =>
338
348
  api.elements.list(params),
@@ -641,6 +651,84 @@ export const api = {
641
651
  password_required: false,
642
652
  }
643
653
  }),
654
+
655
+ loadShared: async (token: string, password?: string): Promise<ExploreData & { password_required?: boolean }> => {
656
+ const init: RequestInit = {
657
+ method: password ? 'POST' : 'GET',
658
+ headers: { 'Content-Type': 'application/json' },
659
+ }
660
+ if (password) {
661
+ init.body = JSON.stringify({ password })
662
+ }
663
+ const res = await fetch(apiUrl(`/shared/explore/${token}`), init)
664
+ if (!res.ok) {
665
+ throw new Error(`Failed to load shared diagram: ${res.statusText}`)
666
+ }
667
+ const data = await res.json() as {
668
+ tree: any[]
669
+ views: Record<string, { elements: any[]; connectors: any[] }>
670
+ password_required?: boolean
671
+ }
672
+
673
+ const tree = (data.tree ?? []).map(mapDiagram)
674
+ const views = Object.fromEntries(
675
+ Object.entries(data.views ?? {}).map(([key, value]) => [
676
+ key,
677
+ {
678
+ placements: (value.elements ?? []).map(protoPlacedElement),
679
+ connectors: (value.connectors ?? []).map(protoConnector),
680
+ },
681
+ ])
682
+ )
683
+
684
+ // Ensure that the share root is treated as a root (no parent) so that computeLayout
685
+ // picks it up even if it was nested in the original workspace.
686
+ const sharedRoot = tree.find(n => String(n.id) === String(data.views[token]?.elements?.[0]?.view_id ?? ''))
687
+ // Backend actually returns the shareToken.ViewID as the root of the tree it builds.
688
+ // We should find the node in 'tree' that has no parent *within the returned set*.
689
+ // For shared explore, the backend typically returns a tree starting at the shared view.
690
+ tree.forEach(node => {
691
+ // If the node's parent is not in our tree, it's a root for this shared view.
692
+ const parentInTree = tree.find(n => n.id === node.parent_view_id)
693
+ if (!parentInTree) {
694
+ node.parent_view_id = null
695
+ }
696
+ })
697
+ const navigations: ViewConnector[] = []
698
+ const elementToChildView = new Map<number, any>()
699
+ const allViews: any[] = []
700
+ const flatTree = (nodes: any[]) => {
701
+ nodes.forEach(n => {
702
+ allViews.push(n)
703
+ if (n.owner_element_id) elementToChildView.set(n.owner_element_id, n)
704
+ if (n.children) flatTree(n.children)
705
+ })
706
+ }
707
+ flatTree(tree)
708
+
709
+ Object.values(views).forEach((v: any) => {
710
+ v.placements.forEach((p: any) => {
711
+ const childView = elementToChildView.get(p.element_id)
712
+ if (childView) {
713
+ navigations.push({
714
+ id: 0,
715
+ element_id: p.element_id,
716
+ from_view_id: p.view_id,
717
+ to_view_id: childView.id,
718
+ to_view_name: childView.name,
719
+ relation_type: 'child',
720
+ })
721
+ }
722
+ })
723
+ })
724
+
725
+ return {
726
+ tree,
727
+ views,
728
+ navigations,
729
+ password_required: data.password_required,
730
+ }
731
+ },
644
732
  },
645
733
 
646
734
  import: {
@@ -1,13 +1,14 @@
1
1
  import { Box, Divider, Flex, HStack, Tag, Text, VStack } from '@chakra-ui/react'
2
- import { TYPE_COLORS, type PlacedElement } from '../types'
2
+ import { TYPE_COLORS, type PlacedElement, type Tag as TagType } from '../types'
3
3
 
4
4
  interface Props {
5
5
  data: PlacedElement & { hasChildLink?: boolean }
6
6
  /** Bounding rect of the node element in screen (viewport) coordinates */
7
7
  anchorRect: DOMRect
8
+ tagColors?: Record<string, TagType>
8
9
  }
9
10
 
10
- export default function NodeHoverCard({ data, anchorRect }: Props) {
11
+ export default function NodeHoverCard({ data, anchorRect, tagColors }: Props) {
11
12
  const color = TYPE_COLORS[data.kind ?? ''] ?? 'gray'
12
13
 
13
14
  // Position the card centred above the node using fixed coordinates so it
@@ -101,19 +102,23 @@ export default function NodeHoverCard({ data, anchorRect }: Props) {
101
102
  {/* Tags */}
102
103
  {data.tags && data.tags.length > 0 && (
103
104
  <HStack spacing={1} flexWrap="wrap">
104
- {data.tags.map((tag) => (
105
- <Tag
106
- key={tag}
107
- size="sm"
108
- variant="outline"
109
- colorScheme="gray"
110
- fontSize="9px"
111
- borderColor="rgba(255,255,255,0.1)"
112
- color="gray.500"
113
- >
114
- {tag}
115
- </Tag>
116
- ))}
105
+ {data.tags.map((tag) => {
106
+ const tagColor = tagColors?.[tag]?.color
107
+ return (
108
+ <Tag
109
+ key={tag}
110
+ size="sm"
111
+ variant="subtle"
112
+ bg={tagColor ? `color-mix(in srgb, ${tagColor} 12%, transparent)` : 'whiteAlpha.100'}
113
+ color={tagColor || 'gray.400'}
114
+ fontSize="9px"
115
+ borderColor={tagColor ? `color-mix(in srgb, ${tagColor} 30%, transparent)` : 'rgba(255,255,255,0.1)'}
116
+ borderWidth="1px"
117
+ >
118
+ {tag}
119
+ </Tag>
120
+ )
121
+ })}
117
122
  </HStack>
118
123
  )}
119
124
 
@@ -49,6 +49,8 @@ export interface ViewFloatingMenuProps extends ViewFloatingMenuSlots {
49
49
  setHighlightColor: (color: string | null) => void
50
50
 
51
51
  toolbarSlot?: React.ReactNode
52
+ hideFocusView?: boolean
53
+ hideExpandExtras?: boolean
52
54
  }
53
55
 
54
56
  /**
@@ -83,6 +85,8 @@ function ViewFloatingMenu({
83
85
  setHighlightColor,
84
86
  shareSlot,
85
87
  toolbarSlot,
88
+ hideFocusView = false,
89
+ hideExpandExtras = false,
86
90
  }: ViewFloatingMenuProps) {
87
91
  const { canEdit } = useViewEditorContext()
88
92
  const { isOpen: isTagsOpen, onClose: onTagsClose, onToggle: onTagsToggle } = useDisclosure()
@@ -127,24 +131,28 @@ function ViewFloatingMenu({
127
131
  </Button>
128
132
  </Tooltip>
129
133
 
130
- <Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
131
- <Tooltip label={focusMode ? 'Show context' : 'Focus on this view'} placement="top" openDelay={200}>
132
- <Button
133
- variant="ghost"
134
- h="28px"
135
- px={2.5}
136
- color={focusMode ? 'var(--accent)' : 'gray.300'}
137
- bg={focusMode ? 'rgba(var(--accent-rgb), 0.12)' : 'transparent'}
138
- _hover={{ bg: 'rgba(var(--accent-rgb), 0.12)', color: 'var(--accent)' }}
139
- onClick={() => onFocusModeChange(!focusMode)}
140
- >
141
- <HStack spacing={1.5}>
142
- <FocusSvg />
143
- <Text fontSize="11px" fontWeight="normal">Focus View</Text>
144
- <Box w="6px" h="6px" rounded="full" bg={focusMode ? 'var(--accent)' : 'gray.500'} />
145
- </HStack>
146
- </Button>
147
- </Tooltip>
134
+ {!hideFocusView && (
135
+ <>
136
+ <Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
137
+ <Tooltip label={focusMode ? 'Show context' : 'Focus on this view'} placement="top" openDelay={200}>
138
+ <Button
139
+ variant="ghost"
140
+ h="28px"
141
+ px={2.5}
142
+ color={focusMode ? 'var(--accent)' : 'gray.300'}
143
+ bg={focusMode ? 'rgba(var(--accent-rgb), 0.12)' : 'transparent'}
144
+ _hover={{ bg: 'rgba(var(--accent-rgb), 0.12)', color: 'var(--accent)' }}
145
+ onClick={() => onFocusModeChange(!focusMode)}
146
+ >
147
+ <HStack spacing={1.5}>
148
+ <FocusSvg />
149
+ <Text fontSize="11px" fontWeight="normal">Focus View</Text>
150
+ <Box w="6px" h="6px" rounded="full" bg={focusMode ? 'var(--accent)' : 'gray.500'} />
151
+ </HStack>
152
+ </Button>
153
+ </Tooltip>
154
+ </>
155
+ )}
148
156
  <Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
149
157
 
150
158
  {(allTags.length > 0 || layers.length > 0) && (
@@ -356,22 +364,26 @@ function ViewFloatingMenu({
356
364
  </>
357
365
  )}
358
366
 
359
- <Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
360
- <Button
361
- variant="ghost"
362
- h="28px"
363
- minW="36px"
364
- px={2}
365
- display="inline-flex"
366
- alignItems="center"
367
- justifyContent="center"
368
- color="gray.300"
369
- _hover={{ bg: 'rgba(var(--accent-rgb), 0.12)', color: 'var(--accent)' }}
370
- onClick={() => setExtrasOpen((prev) => !prev)}
371
- aria-label={extrasOpen ? 'Collapse extras' : 'Expand extras'}
372
- >
373
- {extrasOpen ? <CollapseExtrasSvg /> : <ExpandExtrasSvg />}
374
- </Button>
367
+ {!hideExpandExtras && (
368
+ <>
369
+ <Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
370
+ <Button
371
+ variant="ghost"
372
+ h="28px"
373
+ minW="36px"
374
+ px={2}
375
+ display="inline-flex"
376
+ alignItems="center"
377
+ justifyContent="center"
378
+ color="gray.300"
379
+ _hover={{ bg: 'rgba(var(--accent-rgb), 0.12)', color: 'var(--accent)' }}
380
+ onClick={() => setExtrasOpen((prev) => !prev)}
381
+ aria-label={extrasOpen ? 'Collapse extras' : 'Expand extras'}
382
+ >
383
+ {extrasOpen ? <CollapseExtrasSvg /> : <ExpandExtrasSvg />}
384
+ </Button>
385
+ </>
386
+ )}
375
387
  </HStack>
376
388
  )
377
389
  }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useRef, type MutableRefObject, type RefObject } from 'react'
1
+ import { useCallback, useEffect, useMemo, type MutableRefObject, type RefObject } from 'react'
2
2
  import { getNodesBounds, getViewportForBounds, type Node as RFNode, type ReactFlowInstance } from 'reactflow'
3
3
 
4
4
  export interface ViewEditorDemoOptions {
@@ -6,24 +6,19 @@ export interface ViewEditorDemoOptions {
6
6
  disableImportExport?: boolean
7
7
  hideFlowControls?: boolean
8
8
  disableOnboarding?: boolean
9
+ hideFocusView?: boolean
10
+ hideExpandExtras?: boolean
9
11
  }
10
12
 
11
13
  export const DEMO_VIEW_EDITOR_OPTIONS: Omit<ViewEditorDemoOptions, 'revealProgress'> = {
12
14
  disableImportExport: true,
13
15
  hideFlowControls: true,
14
16
  disableOnboarding: true,
17
+ hideFocusView: true,
18
+ hideExpandExtras: true,
15
19
  }
16
20
 
17
- function getCenteredViewport(bounds: { x: number; y: number; width: number; height: number }, width: number, height: number, zoom: number) {
18
- const centerX = bounds.x + bounds.width / 2
19
- const centerY = bounds.y + bounds.height / 2
20
21
 
21
- return {
22
- x: width / 2 - centerX * zoom,
23
- y: height / 2 - centerY * zoom,
24
- zoom,
25
- }
26
- }
27
22
 
28
23
  interface UseDemoRevealViewportArgs {
29
24
  demoOptions?: ViewEditorDemoOptions
@@ -44,19 +39,12 @@ export function useDemoRevealViewport({
44
39
  needsFitViewRef,
45
40
  computedMinZoom,
46
41
  setViewport,
47
- resetKey,
48
42
  }: UseDemoRevealViewportArgs) {
49
43
  const clampedRevealProgress = useMemo(() => {
50
44
  if (typeof demoOptions?.revealProgress !== 'number') return null
51
45
  return Math.max(0, Math.min(1, demoOptions.revealProgress))
52
46
  }, [demoOptions?.revealProgress])
53
47
 
54
- const revealZoomRef = useRef<number | null>(null)
55
-
56
- useEffect(() => {
57
- revealZoomRef.current = null
58
- }, [resetKey, clampedRevealProgress])
59
-
60
48
  const applyDemoRevealViewport = useCallback(() => {
61
49
  if (clampedRevealProgress === null) return false
62
50
 
@@ -73,24 +61,7 @@ export function useDemoRevealViewport({
73
61
  const fittedViewport = getViewportForBounds(bounds, width, height, computedMinZoom, 2, 0.1)
74
62
  if (![fittedViewport.x, fittedViewport.y, fittedViewport.zoom].every(Number.isFinite)) return false
75
63
 
76
- if (clampedRevealProgress >= 0.999) {
77
- revealZoomRef.current = fittedViewport.zoom
78
- setViewport(fittedViewport, { duration: 0 })
79
- return true
80
- }
81
-
82
- const fixedZoom = revealZoomRef.current ?? fittedViewport.zoom
83
- revealZoomRef.current = fixedZoom
84
-
85
- const centeredViewport = getCenteredViewport(bounds, width, height, fixedZoom)
86
- const reveal = 1 - Math.pow(1 - clampedRevealProgress, 3)
87
- const hiddenOffsetX = Math.max(width * 1.15, bounds.width * fixedZoom * 0.75)
88
-
89
- setViewport({
90
- x: centeredViewport.x + hiddenOffsetX * (1 - reveal),
91
- y: centeredViewport.y,
92
- zoom: fixedZoom,
93
- }, { duration: 0 })
64
+ setViewport(fittedViewport, { duration: 0 })
94
65
 
95
66
  return true
96
67
  }, [clampedRevealProgress, computedMinZoom, containerRef, rfNodesRef, setViewport])
@@ -107,4 +78,4 @@ export function useDemoRevealViewport({
107
78
  disableImportExport: demoOptions?.disableImportExport ?? false,
108
79
  hideFlowControls: demoOptions?.hideFlowControls ?? false,
109
80
  }
110
- }
81
+ }
@@ -132,7 +132,7 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
132
132
  }
133
133
  }, [showMiniOnboarding])
134
134
  useEffect(() => {
135
- const loader = api.explore.load()
135
+ const loader = sharedToken ? api.explore.loadShared(sharedToken) : api.explore.load()
136
136
  loader.then((d) => {
137
137
  if (d.password_required) {
138
138
  setLoading(false)
@@ -147,23 +147,27 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
147
147
  // Fetch tag colors and layers once data is loaded (authenticated users only).
148
148
  // Only fetch from root tree nodes child/nested diagrams would duplicate the same layers.
149
149
  useEffect(() => {
150
- if (!data) return
150
+ if (!data || sharedToken) return
151
151
  let cancelled = false
152
152
  const rootIds = (data.tree ?? []).map(n => n.id)
153
153
  const fetchTagData = async () => {
154
- const diagramLayers = await Promise.all(
155
- rootIds.map(id => api.workspace.views.layers.list(id)),
156
- )
157
- if (!cancelled) {
158
- // Deduplicate by layer ID in case of any API overlap
159
- const seen = new Set<number>()
160
- const unique = diagramLayers.flat().filter(l => seen.has(l.id) ? false : (seen.add(l.id), true))
161
- setLayers(unique)
154
+ try {
155
+ const diagramLayers = await Promise.all(
156
+ rootIds.map(id => api.workspace.views.layers.list(id)),
157
+ )
158
+ if (!cancelled) {
159
+ // Deduplicate by layer ID in case of any API overlap
160
+ const seen = new Set<number>()
161
+ const unique = diagramLayers.flat().filter(l => seen.has(l.id) ? false : (seen.add(l.id), true))
162
+ setLayers(unique)
163
+ }
164
+ } catch {
165
+ // intentionally empty: layers are not available for public shared pages
162
166
  }
163
167
  }
164
168
  void fetchTagData()
165
169
  return () => { cancelled = true }
166
- }, [data])
170
+ }, [data, sharedToken])
167
171
 
168
172
  const handleCanvasReady = useCallback(() => {
169
173
  setCanvasReady(true)
@@ -400,7 +404,8 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
400
404
  const InfiniteZoom = forwardRef<InfiniteZoomHandle, Props>(InfiniteZoomInner)
401
405
  export default InfiniteZoom
402
406
 
403
- export function SharedInfiniteZoom(props: Props) {
407
+ export const SharedInfiniteZoom = forwardRef<InfiniteZoomHandle, Props>((props, ref) => {
404
408
  const { token } = useParams()
405
- return <InfiniteZoomInner {...props} sharedToken={token} />
406
- }
409
+ const effectiveToken = props.sharedToken ?? token
410
+ return <InfiniteZoom {...props} ref={ref} sharedToken={effectiveToken} />
411
+ })
@@ -260,7 +260,15 @@ function ViewEditorInner({
260
260
  const [tagColors, setTagColors] = useState<Record<string, Tag>>({})
261
261
 
262
262
  useEffect(() => {
263
- // api.workspace.orgs.tagColors.list().then(setTagColors).catch(() => { /* skip */ })
263
+ api.workspace.orgs.tagColors.list().then((res) => {
264
+ if (Array.isArray(res)) {
265
+ const next: Record<string, Tag> = {}
266
+ res.forEach(t => { next[t.name] = t })
267
+ setTagColors(next)
268
+ } else {
269
+ setTagColors(res)
270
+ }
271
+ }).catch(() => { /* skip */ })
264
272
  }, [])
265
273
 
266
274
  const [layers, setLayers] = useState<import('../../types').ViewLayer[]>([])
@@ -1369,6 +1377,8 @@ function ViewEditorInner({
1369
1377
  setHighlightColor={setHoveredLayerColor}
1370
1378
  shareSlot={shareSlot}
1371
1379
  toolbarSlot={toolbarSlot}
1380
+ hideFocusView={demoOptions?.hideFocusView}
1381
+ hideExpandExtras={demoOptions?.hideExpandExtras}
1372
1382
  />
1373
1383
  </Box>
1374
1384
  </Flex>
@@ -52,6 +52,14 @@ const toast: CustomToast = (options: UseToastOptions) => {
52
52
  const status = options.status || 'error'
53
53
 
54
54
  if (status === 'error') {
55
+ // Silence error toasts if we are on a demo route
56
+ const isDemoRoute = window.location.pathname.includes('/demo') ||
57
+ window.location.pathname.includes('/app/demo');
58
+
59
+ if (isDemoRoute) {
60
+ return undefined
61
+ }
62
+
55
63
  const summary = getErrorSummary(options)
56
64
 
57
65
  // Check if an error toast is already active