@tldiagram/core-ui 1.94.5 → 1.95.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.
@@ -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.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 {
@@ -238,6 +244,81 @@ function easeOutQuart(t: number): number {
238
244
  return 1 - Math.pow(1 - t, 4)
239
245
  }
240
246
 
247
+ function clamp01(value: number): number {
248
+ return Math.max(0, Math.min(1, value))
249
+ }
250
+
251
+ function fitWorldRect(
252
+ rect: { x: number; y: number; w: number; h: number },
253
+ canvasW: number,
254
+ canvasH: number,
255
+ maxZoom: number,
256
+ padding: number,
257
+ ): ZUIViewState | null {
258
+ const bboxW = Math.max(1, rect.w)
259
+ const bboxH = Math.max(1, rect.h)
260
+ const zoom = Math.min(
261
+ (canvasW * (1 - padding * 2)) / bboxW,
262
+ (canvasH * (1 - padding * 2)) / bboxH,
263
+ maxZoom,
264
+ )
265
+ if (!Number.isFinite(zoom) || zoom <= 0) return null
266
+
267
+ return {
268
+ x: (canvasW - bboxW * zoom) / 2 - rect.x * zoom,
269
+ y: (canvasH - bboxH * zoom) / 2 - rect.y * zoom,
270
+ zoom,
271
+ }
272
+ }
273
+
274
+ function findFirstExpandableNode(groups: DiagramGroupLayout[]): PathItem | null {
275
+ for (const group of groups) {
276
+ const found = findFirstExpandableNodeInTree(group.nodes, 0, 0, 1, 0, 0)
277
+ if (found) return found
278
+ }
279
+ return null
280
+ }
281
+
282
+ function findFirstExpandableNodeInTree(
283
+ nodes: DiagramGroupLayout['nodes'],
284
+ parentAbsX: number,
285
+ parentAbsY: number,
286
+ parentAbsScale: number,
287
+ parentChildOffsetX: number,
288
+ parentChildOffsetY: number,
289
+ ): PathItem | null {
290
+ for (const node of nodes) {
291
+ const absX = parentAbsX + (node.worldX - parentChildOffsetX) * parentAbsScale
292
+ const absY = parentAbsY + (node.worldY - parentChildOffsetY) * parentAbsScale
293
+ const absW = node.worldW * parentAbsScale
294
+ const absH = node.worldH * parentAbsScale
295
+
296
+ if (node.children.length > 0) {
297
+ return {
298
+ id: node.id,
299
+ label: node.linkedDiagramLabel || node.label,
300
+ type: 'node',
301
+ isCircular: node.isCircular,
302
+ absX,
303
+ absY,
304
+ absW,
305
+ absH,
306
+ }
307
+ }
308
+
309
+ const found = findFirstExpandableNodeInTree(
310
+ node.children,
311
+ absX,
312
+ absY,
313
+ parentAbsScale * node.childScale,
314
+ node.childOffsetX,
315
+ node.childOffsetY,
316
+ )
317
+ if (found) return found
318
+ }
319
+ return null
320
+ }
321
+
241
322
  export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({ data, onReady, onZoom, onPan, highlightedTags, highlightColor, hiddenTags, crossBranchSettings, hoverLocked = false }, ref) {
242
323
  const canvasRef = useRef<HTMLCanvasElement>(null)
243
324
  const containerRef = useRef<HTMLDivElement>(null)
@@ -445,6 +526,63 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
445
526
  return true
446
527
  }, [isMobileLayout, layout.groups, maxZoom, setHoveredItem, setViewState, viewStateRef])
447
528
 
529
+ const setCameraFrame = useCallback((frame: ZUICameraFrame) => {
530
+ if (frame.profile !== 'detail-to-overview') return false
531
+
532
+ const el = containerRef.current
533
+ if (!el) return false
534
+
535
+ const canvasW = el.offsetWidth
536
+ const canvasH = el.offsetHeight
537
+ if (canvasW === 0 || canvasH === 0) return false
538
+
539
+ const detailTarget = findFirstExpandableNode(layout.groups)
540
+ const overviewTarget = layout.groups[0]
541
+ if (!detailTarget || !overviewTarget) return false
542
+
543
+ const detail = fitWorldRect(
544
+ {
545
+ x: detailTarget.absX,
546
+ y: detailTarget.absY,
547
+ w: detailTarget.absW,
548
+ h: detailTarget.absH,
549
+ },
550
+ canvasW,
551
+ canvasH,
552
+ maxZoom,
553
+ 0.28,
554
+ )
555
+
556
+ const overview = fitWorldRect(
557
+ {
558
+ x: overviewTarget.worldX,
559
+ y: overviewTarget.worldY,
560
+ w: overviewTarget.worldW,
561
+ h: overviewTarget.worldH,
562
+ },
563
+ canvasW,
564
+ canvasH,
565
+ maxZoom,
566
+ 0.18,
567
+ )
568
+
569
+ if (!detail || !overview) return false
570
+
571
+ if (cameraTransitionRef.current !== null) {
572
+ cancelAnimationFrame(cameraTransitionRef.current)
573
+ cameraTransitionRef.current = null
574
+ }
575
+
576
+ setHoveredItem(null, true)
577
+ const t = easeOutQuart(clamp01(frame.progress))
578
+ setViewState({
579
+ x: detail.x + (overview.x - detail.x) * t,
580
+ y: detail.y + (overview.y - detail.y) * t,
581
+ zoom: detail.zoom + (overview.zoom - detail.zoom) * t,
582
+ })
583
+ return true
584
+ }, [layout.groups, maxZoom, setHoveredItem, setViewState])
585
+
448
586
  useEffect(() => {
449
587
  return () => {
450
588
  if (cameraTransitionRef.current !== null) {
@@ -482,8 +620,9 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
482
620
  fitView(el.offsetWidth, el.offsetHeight, layout.bbox)
483
621
  },
484
622
  focusDiagram,
623
+ setCameraFrame,
485
624
  }),
486
- [fitView, focusDiagram, layout.bbox, setHoveredItem],
625
+ [fitView, focusDiagram, layout.bbox, setCameraFrame, setHoveredItem],
487
626
  )
488
627
 
489
628
  // ── RAF render loop ──────────────────────────────────────────────
@@ -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'
@@ -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'
@@ -62,6 +63,9 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
62
63
  focusDiagram(viewId: number) {
63
64
  return zuiRef.current?.focusDiagram(viewId) ?? false
64
65
  },
66
+ setCameraFrame(frame: ZUICameraFrame) {
67
+ return zuiRef.current?.setCameraFrame(frame) ?? false
68
+ },
65
69
  }), [])
66
70
 
67
71
  // ── No data or No content ────────────────────────────────────────
@@ -173,6 +177,24 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
173
177
  setCanvasReady(true)
174
178
  }, [])
175
179
 
180
+ useEffect(() => {
181
+ if (!sharedToken) return
182
+
183
+ const handleMessage = (event: MessageEvent) => {
184
+ const data = event.data as { type?: unknown; progress?: unknown; profile?: unknown } | null
185
+ if (!data || data.type !== 'tldiagram-zui-camera') return
186
+ if (data.profile !== 'detail-to-overview') return
187
+
188
+ const progress = Number(data.progress)
189
+ if (!Number.isFinite(progress)) return
190
+
191
+ zuiRef.current?.setCameraFrame({ profile: 'detail-to-overview', progress })
192
+ }
193
+
194
+ window.addEventListener('message', handleMessage)
195
+ return () => window.removeEventListener('message', handleMessage)
196
+ }, [sharedToken])
197
+
176
198
  if (!loading && (!data || (data.tree ?? []).length === 0 || !hasPlacements)) {
177
199
  const noDiagrams = !data || (data.tree ?? []).length === 0
178
200
  return (