@tldiagram/core-ui 1.94.5 → 1.95.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,9 +1,11 @@
1
+ import { type ZUICameraFrame } from '../components/ZUI';
1
2
  interface Props {
2
3
  sharedToken?: string;
3
4
  shareSlot?: React.ReactNode;
4
5
  }
5
6
  export interface InfiniteZoomHandle {
6
7
  focusDiagram(viewId: number): boolean;
8
+ setCameraFrame(frame: ZUICameraFrame): boolean;
7
9
  }
8
10
  declare const InfiniteZoom: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<InfiniteZoomHandle>>;
9
11
  export default InfiniteZoom;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tldiagram/core-ui",
3
- "version": "1.94.5",
3
+ "version": "1.95.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,18 +1,13 @@
1
- import { Box, HStack, Text } from '@chakra-ui/react'
2
- import { keyframes } from '@emotion/react'
1
+ import { Box, CloseButton, HStack, Text } from '@chakra-ui/react'
3
2
  import { ZoomInIcon } from './Icons'
4
3
 
5
4
  interface Props {
6
5
  isVisible: boolean
6
+ onClose?: () => void
7
7
  }
8
8
 
9
- const pulseGlow = keyframes`
10
- 0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.4); }
11
- 70% { box-shadow: 0 0 0 20px rgba(var(--accent-rgb), 0); }
12
- 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); }
13
- `
14
9
 
15
- export default function MiniZoomOnboarding({ isVisible }: Props) {
10
+ export default function MiniZoomOnboarding({ isVisible, onClose }: Props) {
16
11
  return (
17
12
  <Box
18
13
  position="absolute"
@@ -22,14 +17,14 @@ export default function MiniZoomOnboarding({ isVisible }: Props) {
22
17
  zIndex={100}
23
18
  opacity={isVisible ? 1 : 0}
24
19
  transition="all 0.8s cubic-bezier(0.16, 1, 0.3, 1)"
25
- pointerEvents="none"
20
+ pointerEvents={isVisible ? 'auto' : 'none'}
26
21
  >
27
22
  <Box
28
23
  className="glass"
29
24
  px={6}
25
+ pr={onClose ? 11 : 6}
30
26
  py={4}
31
27
  borderRadius="12px"
32
- animation={isVisible ? `${pulseGlow} 3s infinite` : 'none'}
33
28
  position="relative"
34
29
  overflow="hidden"
35
30
  border="1.5px solid rgba(var(--accent-rgb), 0.3)"
@@ -44,27 +39,39 @@ export default function MiniZoomOnboarding({ isVisible }: Props) {
44
39
  bg="var(--accent)"
45
40
  opacity={0.8}
46
41
  />
47
-
42
+ {onClose && (
43
+ <CloseButton
44
+ aria-label="Dismiss zoom hint"
45
+ position="absolute"
46
+ top={2}
47
+ right={2}
48
+ size="sm"
49
+ color="whiteAlpha.700"
50
+ _hover={{ color: 'white', bg: 'whiteAlpha.200' }}
51
+ onClick={onClose}
52
+ />
53
+ )}
54
+
48
55
  <HStack spacing={5} pl={3}>
49
56
  <Box color="var(--accent)">
50
57
  <ZoomInIcon size={24} />
51
58
  </Box>
52
59
  <Box>
53
- <Text
54
- fontSize="10px"
55
- color="var(--accent)"
56
- fontWeight="900"
57
- letterSpacing="0.15em"
58
- textTransform="uppercase"
60
+ <Text
61
+ fontSize="10px"
62
+ color="var(--accent)"
63
+ fontWeight="900"
64
+ letterSpacing="0.15em"
65
+ textTransform="uppercase"
59
66
  mb={0.5}
60
67
  opacity={0.9}
61
68
  >
62
- Pro Tip
69
+ Hint:
63
70
  </Text>
64
- <Text
65
- fontSize="15px"
66
- color="white"
67
- fontWeight="600"
71
+ <Text
72
+ fontSize="15px"
73
+ color="white"
74
+ fontWeight="600"
68
75
  whiteSpace="nowrap"
69
76
  letterSpacing="-0.01em"
70
77
  >
@@ -38,6 +38,12 @@ import { buildVisibleProxyConnectors, collectVisibleNodeAnchors, drawVisibleProx
38
38
  export interface ZUICanvasHandle {
39
39
  fitView(): void
40
40
  focusDiagram(viewId: number): boolean
41
+ setCameraFrame(frame: ZUICameraFrame): boolean
42
+ }
43
+
44
+ export interface ZUICameraFrame {
45
+ profile: 'detail-to-overview'
46
+ progress: number
41
47
  }
42
48
 
43
49
  interface Props {
@@ -45,6 +51,7 @@ interface Props {
45
51
  onReady?: () => void
46
52
  onZoom?: () => void
47
53
  onPan?: () => void
54
+ initialCameraFrame?: ZUICameraFrame
48
55
  highlightedTags?: string[]
49
56
  highlightColor?: string
50
57
  hiddenTags?: string[]
@@ -238,7 +245,82 @@ function easeOutQuart(t: number): number {
238
245
  return 1 - Math.pow(1 - t, 4)
239
246
  }
240
247
 
241
- export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({ data, onReady, onZoom, onPan, highlightedTags, highlightColor, hiddenTags, crossBranchSettings, hoverLocked = false }, ref) {
248
+ function clamp01(value: number): number {
249
+ return Math.max(0, Math.min(1, value))
250
+ }
251
+
252
+ function fitWorldRect(
253
+ rect: { x: number; y: number; w: number; h: number },
254
+ canvasW: number,
255
+ canvasH: number,
256
+ maxZoom: number,
257
+ padding: number,
258
+ ): ZUIViewState | null {
259
+ const bboxW = Math.max(1, rect.w)
260
+ const bboxH = Math.max(1, rect.h)
261
+ const zoom = Math.min(
262
+ (canvasW * (1 - padding * 2)) / bboxW,
263
+ (canvasH * (1 - padding * 2)) / bboxH,
264
+ maxZoom,
265
+ )
266
+ if (!Number.isFinite(zoom) || zoom <= 0) return null
267
+
268
+ return {
269
+ x: (canvasW - bboxW * zoom) / 2 - rect.x * zoom,
270
+ y: (canvasH - bboxH * zoom) / 2 - rect.y * zoom,
271
+ zoom,
272
+ }
273
+ }
274
+
275
+ function findFirstExpandableNode(groups: DiagramGroupLayout[]): PathItem | null {
276
+ for (const group of groups) {
277
+ const found = findFirstExpandableNodeInTree(group.nodes, 0, 0, 1, 0, 0)
278
+ if (found) return found
279
+ }
280
+ return null
281
+ }
282
+
283
+ function findFirstExpandableNodeInTree(
284
+ nodes: DiagramGroupLayout['nodes'],
285
+ parentAbsX: number,
286
+ parentAbsY: number,
287
+ parentAbsScale: number,
288
+ parentChildOffsetX: number,
289
+ parentChildOffsetY: number,
290
+ ): PathItem | null {
291
+ for (const node of nodes) {
292
+ const absX = parentAbsX + (node.worldX - parentChildOffsetX) * parentAbsScale
293
+ const absY = parentAbsY + (node.worldY - parentChildOffsetY) * parentAbsScale
294
+ const absW = node.worldW * parentAbsScale
295
+ const absH = node.worldH * parentAbsScale
296
+
297
+ if (node.children.length > 0) {
298
+ return {
299
+ id: node.id,
300
+ label: node.linkedDiagramLabel || node.label,
301
+ type: 'node',
302
+ isCircular: node.isCircular,
303
+ absX,
304
+ absY,
305
+ absW,
306
+ absH,
307
+ }
308
+ }
309
+
310
+ const found = findFirstExpandableNodeInTree(
311
+ node.children,
312
+ absX,
313
+ absY,
314
+ parentAbsScale * node.childScale,
315
+ node.childOffsetX,
316
+ node.childOffsetY,
317
+ )
318
+ if (found) return found
319
+ }
320
+ return null
321
+ }
322
+
323
+ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({ data, onReady, onZoom, onPan, initialCameraFrame, highlightedTags, highlightColor, hiddenTags, crossBranchSettings, hoverLocked = false }, ref) {
242
324
  const canvasRef = useRef<HTMLCanvasElement>(null)
243
325
  const containerRef = useRef<HTMLDivElement>(null)
244
326
  const cameraTransitionRef = useRef<number | null>(null)
@@ -445,6 +527,68 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
445
527
  return true
446
528
  }, [isMobileLayout, layout.groups, maxZoom, setHoveredItem, setViewState, viewStateRef])
447
529
 
530
+ const setCameraFrame = useCallback((frame: ZUICameraFrame) => {
531
+ if (frame.profile !== 'detail-to-overview') return false
532
+
533
+ const el = containerRef.current
534
+ if (!el) return false
535
+
536
+ const canvasW = el.offsetWidth
537
+ const canvasH = el.offsetHeight
538
+ if (canvasW === 0 || canvasH === 0) return false
539
+
540
+ const detailTarget = findFirstExpandableNode(layout.groups)
541
+ const overviewTarget = layout.groups[0]
542
+ if (!detailTarget || !overviewTarget) return false
543
+
544
+ const detail = fitWorldRect(
545
+ {
546
+ x: detailTarget.absX,
547
+ y: detailTarget.absY,
548
+ w: detailTarget.absW,
549
+ h: detailTarget.absH,
550
+ },
551
+ canvasW,
552
+ canvasH,
553
+ maxZoom,
554
+ 0.28,
555
+ )
556
+
557
+ const overview = fitWorldRect(
558
+ {
559
+ x: overviewTarget.worldX,
560
+ y: overviewTarget.worldY,
561
+ w: overviewTarget.worldW,
562
+ h: overviewTarget.worldH,
563
+ },
564
+ canvasW,
565
+ canvasH,
566
+ maxZoom,
567
+ 0.18,
568
+ )
569
+
570
+ if (!detail || !overview) return false
571
+
572
+ if (cameraTransitionRef.current !== null) {
573
+ cancelAnimationFrame(cameraTransitionRef.current)
574
+ cameraTransitionRef.current = null
575
+ }
576
+
577
+ setHoveredItem(null, true)
578
+ const t = easeOutQuart(clamp01(frame.progress))
579
+ setViewState({
580
+ x: detail.x + (overview.x - detail.x) * t,
581
+ y: detail.y + (overview.y - detail.y) * t,
582
+ zoom: detail.zoom + (overview.zoom - detail.zoom) * t,
583
+ })
584
+ return true
585
+ }, [layout.groups, maxZoom, setHoveredItem, setViewState])
586
+
587
+ const fitInitialView = useCallback((w: number, h: number) => {
588
+ if (initialCameraFrame && setCameraFrame(initialCameraFrame)) return
589
+ fitView(w, h, layout.bbox)
590
+ }, [fitView, initialCameraFrame, layout.bbox, setCameraFrame])
591
+
448
592
  useEffect(() => {
449
593
  return () => {
450
594
  if (cameraTransitionRef.current !== null) {
@@ -462,14 +606,13 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
462
606
  // Only set as initialized if we have valid dimensions
463
607
  if (w > 0 && h > 0) {
464
608
  setContainerSize({ w, h })
465
- fitView(w, h, layout.bbox)
609
+ fitInitialView(w, h)
466
610
  if (!initialized) {
467
611
  setInitialized(true)
468
612
  onReady?.()
469
613
  }
470
614
  }
471
- // eslint-disable-next-line react-hooks/exhaustive-deps
472
- }, [layout, initialized, onReady])
615
+ }, [initialized, onReady, fitInitialView])
473
616
 
474
617
  // ── Expose fitView to parent ─────────────────────────────────────
475
618
  useImperativeHandle(
@@ -482,8 +625,9 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
482
625
  fitView(el.offsetWidth, el.offsetHeight, layout.bbox)
483
626
  },
484
627
  focusDiagram,
628
+ setCameraFrame,
485
629
  }),
486
- [fitView, focusDiagram, layout.bbox, setHoveredItem],
630
+ [fitView, focusDiagram, layout.bbox, setCameraFrame, setHoveredItem],
487
631
  )
488
632
 
489
633
  // ── RAF render loop ──────────────────────────────────────────────
@@ -531,7 +675,7 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
531
675
 
532
676
  // Trigger initialization if it hasn't happened yet
533
677
  if (!initialized && w > 0 && h > 0) {
534
- fitView(w, h, layout.bbox)
678
+ fitInitialView(w, h)
535
679
  setInitialized(true)
536
680
  onReady?.()
537
681
  }
@@ -541,7 +685,7 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
541
685
  ro.observe(container)
542
686
  resize()
543
687
  return () => ro.disconnect()
544
- }, [initialized, layout, fitView, onReady])
688
+ }, [initialized, fitInitialView, onReady])
545
689
 
546
690
  useEffect(() => {
547
691
  if (!initialized) return // Don't start loop until initialized
@@ -1,3 +1,3 @@
1
1
  // src/components/ZUI/index.ts
2
2
  export { ZUICanvas } from './ZUICanvas'
3
- export type { ZUICanvasHandle } from './ZUICanvas'
3
+ export type { ZUICameraFrame, ZUICanvasHandle } from './ZUICanvas'
@@ -1,6 +1,6 @@
1
1
  // src/pages/InfiniteZoom.tsx Explore page holds the ZUI feature
2
2
  import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
3
- import { useNavigate, useParams } from 'react-router-dom'
3
+ import { useLocation, useNavigate, useParams } from 'react-router-dom'
4
4
  import {
5
5
  Box,
6
6
  Button,
@@ -24,7 +24,7 @@ import { FitViewIcon as FitViewSvg, TagsIcon, EyeIcon, EyeOffIcon, FocusIcon as
24
24
  import ExploreOnboarding from '../components/ExploreOnboarding'
25
25
  import ExplorePageOnboarding from '../components/ExplorePageOnboarding'
26
26
  import MiniZoomOnboarding from '../components/MiniZoomOnboarding'
27
- import { ZUICanvas, type ZUICanvasHandle } from '../components/ZUI'
27
+ import { ZUICanvas, type ZUICameraFrame, type ZUICanvasHandle } from '../components/ZUI'
28
28
  import { useCrossBranchContextSettings } from '../crossBranch/settings'
29
29
  import { primeWorkspaceGraphSnapshot } from '../crossBranch/store'
30
30
 
@@ -36,6 +36,7 @@ interface Props {
36
36
 
37
37
  export interface InfiniteZoomHandle {
38
38
  focusDiagram(viewId: number): boolean
39
+ setCameraFrame(frame: ZUICameraFrame): boolean
39
40
  }
40
41
 
41
42
  const MINI_ONBOARDING_KEY = 'shared_zoom_onboarding_dismissed'
@@ -43,11 +44,13 @@ const MINI_ONBOARDING_KEY = 'shared_zoom_onboarding_dismissed'
43
44
  // ── Inner component ────────────────────────────────────────────────
44
45
  function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<InfiniteZoomHandle>) {
45
46
  const navigate = useNavigate()
47
+ const location = useLocation()
46
48
 
47
49
  const [data, setData] = useState<ExploreData | null>(null)
48
50
  const [loading, setLoading] = useState(true)
49
51
  const [canvasReady, setCanvasReady] = useState(false)
50
52
  const [showMiniOnboarding, setShowMiniOnboarding] = useState(false)
53
+ const [miniOnboardingInteractionSeen, setMiniOnboardingInteractionSeen] = useState(false)
51
54
  const [tagColors] = useState<Record<string, import('../types').Tag>>({})
52
55
  const [layers, setLayers] = useState<ViewLayer[]>([])
53
56
  const [highlightedTags, setHighlightedTags] = useState<string[]>([])
@@ -58,10 +61,22 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
58
61
  const crossBranchSurface = sharedToken ? 'zui-shared' : 'zui'
59
62
  const { settings: crossBranchSettings, setEnabled: setCrossBranchEnabled } = useCrossBranchContextSettings(crossBranchSurface)
60
63
 
64
+ const cameraProfile = useMemo(() => new URLSearchParams(location.search).get('profile'), [location.search])
65
+ const isDetailToOverviewProfile = sharedToken && cameraProfile === 'detail-to-overview'
66
+
67
+ const initialCameraFrame = useMemo<ZUICameraFrame | undefined>(() => {
68
+ return isDetailToOverviewProfile
69
+ ? { profile: 'detail-to-overview', progress: 0 }
70
+ : undefined
71
+ }, [isDetailToOverviewProfile])
72
+
61
73
  useImperativeHandle(ref, () => ({
62
74
  focusDiagram(viewId: number) {
63
75
  return zuiRef.current?.focusDiagram(viewId) ?? false
64
76
  },
77
+ setCameraFrame(frame: ZUICameraFrame) {
78
+ return zuiRef.current?.setCameraFrame(frame) ?? false
79
+ },
65
80
  }), [])
66
81
 
67
82
  // ── No data or No content ────────────────────────────────────────
@@ -120,17 +135,32 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
120
135
  }, [])
121
136
 
122
137
  useEffect(() => {
138
+ if (isDetailToOverviewProfile) return
123
139
  if (sharedToken && canvasReady && !localStorage.getItem(MINI_ONBOARDING_KEY)) {
124
140
  setShowMiniOnboarding(true)
125
141
  }
126
- }, [sharedToken, canvasReady])
142
+ }, [sharedToken, canvasReady, isDetailToOverviewProfile])
127
143
 
128
- const handleInteraction = useCallback(() => {
144
+ const dismissMiniOnboarding = useCallback(() => {
129
145
  if (showMiniOnboarding) {
130
146
  setShowMiniOnboarding(false)
131
- localStorage.setItem(MINI_ONBOARDING_KEY, 'true')
147
+ if (!isDetailToOverviewProfile) {
148
+ localStorage.setItem(MINI_ONBOARDING_KEY, 'true')
149
+ }
132
150
  }
133
- }, [showMiniOnboarding])
151
+ }, [isDetailToOverviewProfile, showMiniOnboarding])
152
+
153
+ const showMiniOnboardingAfterCanvasInteraction = useCallback(() => {
154
+ if (!isDetailToOverviewProfile || miniOnboardingInteractionSeen) return
155
+ setMiniOnboardingInteractionSeen(true)
156
+ setShowMiniOnboarding(true)
157
+ }, [isDetailToOverviewProfile, miniOnboardingInteractionSeen])
158
+
159
+ const handleCanvasZoom = useCallback(() => {
160
+ setMiniOnboardingInteractionSeen(true)
161
+ dismissMiniOnboarding()
162
+ }, [dismissMiniOnboarding])
163
+
134
164
  useEffect(() => {
135
165
  const loader = sharedToken ? api.explore.loadShared(sharedToken) : api.explore.load()
136
166
  loader.then((d) => {
@@ -173,6 +203,24 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
173
203
  setCanvasReady(true)
174
204
  }, [])
175
205
 
206
+ useEffect(() => {
207
+ if (!sharedToken) return
208
+
209
+ const handleMessage = (event: MessageEvent) => {
210
+ const data = event.data as { type?: unknown; progress?: unknown; profile?: unknown } | null
211
+ if (!data || data.type !== 'tldiagram-zui-camera') return
212
+ if (data.profile !== 'detail-to-overview') return
213
+
214
+ const progress = Number(data.progress)
215
+ if (!Number.isFinite(progress)) return
216
+
217
+ zuiRef.current?.setCameraFrame({ profile: 'detail-to-overview', progress })
218
+ }
219
+
220
+ window.addEventListener('message', handleMessage)
221
+ return () => window.removeEventListener('message', handleMessage)
222
+ }, [sharedToken])
223
+
176
224
  if (!loading && (!data || (data.tree ?? []).length === 0 || !hasPlacements)) {
177
225
  const noDiagrams = !data || (data.tree ?? []).length === 0
178
226
  return (
@@ -221,8 +269,9 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
221
269
  ref={zuiRef}
222
270
  data={data}
223
271
  onReady={handleCanvasReady}
224
- onZoom={handleInteraction}
225
- onPan={handleInteraction}
272
+ onZoom={handleCanvasZoom}
273
+ onPan={showMiniOnboardingAfterCanvasInteraction}
274
+ initialCameraFrame={initialCameraFrame}
226
275
  highlightedTags={highlightedTags}
227
276
  highlightColor={highlightColor}
228
277
  hiddenTags={hiddenTags}
@@ -232,7 +281,7 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
232
281
 
233
282
  {/* Onboarding overlay */}
234
283
  {data && !sharedToken && <ExploreOnboarding hasLinkedNodes={!!(data.navigations?.length > 0)} />}
235
- <MiniZoomOnboarding isVisible={showMiniOnboarding} />
284
+ <MiniZoomOnboarding isVisible={showMiniOnboarding} onClose={dismissMiniOnboarding} />
236
285
 
237
286
  {/* Bottom toolbar */}
238
287
  <Box