@tldiagram/core-ui 1.89.7 → 1.91.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.
@@ -161,7 +161,7 @@ export declare function useCanvasInteractions({ viewId, canEdit, drawingMode: _d
161
161
  clientX: number;
162
162
  clientY: number;
163
163
  }) => void;
164
- showAddingElementAt: (clientX: number, clientY: number, expandResults?: boolean, mode?: "add" | "connect") => void;
164
+ showAddingElementAt: (clientX: number, clientY: number, expandResults?: boolean, mode?: "add" | "connect", forceConnect?: boolean) => void;
165
165
  onNodesChange: (changes: NodeChange[]) => void;
166
166
  onEdgesChange: (changes: EdgeChange[]) => void;
167
167
  onNodeDragStart: NodeDragHandler;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tldiagram/core-ui",
3
- "version": "1.89.7",
3
+ "version": "1.91.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,7 @@ interface ConnectorContextMenuProps {
26
26
  onDelete: (edgeId: number) => void
27
27
  }
28
28
 
29
- export const ConnectorContextMenu: React.FC<ConnectorContextMenuProps> = ({
29
+ export const ConnectorContextMenu: React.FC<ConnectorContextMenuProps> = React.memo(({
30
30
  menu,
31
31
  onEdit,
32
32
  onMoveSource,
@@ -68,14 +68,15 @@ export const ConnectorContextMenu: React.FC<ConnectorContextMenuProps> = ({
68
68
  </VStack>
69
69
  </Box>
70
70
  )
71
- }
71
+ })
72
+ ConnectorContextMenu.displayName = 'ConnectorContextMenu'
72
73
 
73
74
  interface CanvasContextMenuProps {
74
75
  menu: { x: number; y: number; flowX: number; flowY: number } | null
75
76
  onAddElement: (x: number, y: number) => void
76
77
  }
77
78
 
78
- export const CanvasContextMenu: React.FC<CanvasContextMenuProps> = ({ menu, onAddElement }) => {
79
+ export const CanvasContextMenu: React.FC<CanvasContextMenuProps> = React.memo(({ menu, onAddElement }) => {
79
80
  const { canEdit, snapToGrid, setSnapToGrid } = useViewEditorContext()
80
81
  if (!menu) return null
81
82
 
@@ -109,4 +110,5 @@ export const CanvasContextMenu: React.FC<CanvasContextMenuProps> = ({ menu, onAd
109
110
  </VStack>
110
111
  </Box>
111
112
  )
112
- }
113
+ })
114
+ CanvasContextMenu.displayName = 'CanvasContextMenu'
@@ -22,7 +22,7 @@ interface EditorOverlaysProps {
22
22
  rfNodes: RFNode[]
23
23
  }
24
24
 
25
- export const EditorOverlays: React.FC<EditorOverlaysProps> = ({
25
+ export const EditorOverlays: React.FC<EditorOverlaysProps> = React.memo(({
26
26
  connectGhostPos,
27
27
  clickConnectMode,
28
28
  clickConnectCursorPos,
@@ -169,4 +169,5 @@ export const EditorOverlays: React.FC<EditorOverlaysProps> = ({
169
169
 
170
170
  </>
171
171
  )
172
- }
172
+ })
173
+ EditorOverlays.displayName = 'EditorOverlays'
@@ -6,7 +6,7 @@ interface EmptyCanvasStateProps {
6
6
  hasNodes: boolean
7
7
  }
8
8
 
9
- export const EmptyCanvasState: React.FC<EmptyCanvasStateProps> = ({ isMobile, hasNodes }) => {
9
+ export const EmptyCanvasState: React.FC<EmptyCanvasStateProps> = React.memo(({ isMobile, hasNodes }) => {
10
10
  if (hasNodes) return null
11
11
 
12
12
  return (
@@ -39,4 +39,5 @@ export const EmptyCanvasState: React.FC<EmptyCanvasStateProps> = ({ isMobile, ha
39
39
  </Text>
40
40
  </Box>
41
41
  )
42
- }
42
+ })
43
+ EmptyCanvasState.displayName = 'EmptyCanvasState'
@@ -36,7 +36,6 @@ import {
36
36
  } from '../../../utils/edgeDistribution'
37
37
 
38
38
  const SNAP_RADIUS = 75
39
- const CONTEXT_BOUNDARY_INSET = 36
40
39
 
41
40
  interface CanvasInteractionOptions {
42
41
  viewId: number | null
@@ -260,37 +259,29 @@ export function useCanvasInteractions({
260
259
  const openConnectorPanelRef = useRef(openConnectorPanel)
261
260
  openConnectorPanelRef.current = openConnectorPanel
262
261
 
263
- const resolvePickerMode = useCallback((flowX: number, flowY: number, preferredMode: 'add' | 'connect') => {
262
+ const resolvePickerMode = useCallback((flowX: number, flowY: number, preferredMode: 'add' | 'connect', forceConnect = false) => {
264
263
  if (preferredMode !== 'connect') return preferredMode
264
+ if (forceConnect) return 'connect'
265
265
 
266
- const mainNodes = rfNodesRef.current.filter((node) => node.type === 'elementNode')
267
- if (mainNodes.length === 0) return preferredMode
268
-
269
- let minX = Infinity
270
- let minY = Infinity
271
- let maxX = -Infinity
272
- let maxY = -Infinity
273
-
274
- for (const node of mainNodes) {
275
- const width = node.width ?? 200
276
- const height = node.height ?? 90
277
- minX = Math.min(minX, node.position.x)
278
- minY = Math.min(minY, node.position.y)
279
- maxX = Math.max(maxX, node.position.x + width)
280
- maxY = Math.max(maxY, node.position.y + height)
281
- }
266
+ const boundaryNode = rfNodesRef.current.find((node) => node.type === 'ContextBoundaryElement')
267
+ if (!boundaryNode) return 'add'
268
+
269
+ const boundaryData = boundaryNode.data as { width?: number; height?: number } | undefined
270
+ const width = boundaryData?.width ?? boundaryNode.width
271
+ const height = boundaryData?.height ?? boundaryNode.height
272
+ if (width == null || height == null) return 'add'
282
273
 
283
274
  const withinBoundary =
284
- flowX >= minX - CONTEXT_BOUNDARY_INSET &&
285
- flowX <= maxX + CONTEXT_BOUNDARY_INSET &&
286
- flowY >= minY - CONTEXT_BOUNDARY_INSET &&
287
- flowY <= maxY + CONTEXT_BOUNDARY_INSET
275
+ flowX >= boundaryNode.position.x &&
276
+ flowX <= boundaryNode.position.x + width &&
277
+ flowY >= boundaryNode.position.y &&
278
+ flowY <= boundaryNode.position.y + height
288
279
 
289
280
  return withinBoundary ? 'add' : 'connect'
290
281
  }, [rfNodesRef])
291
282
 
292
283
  // ── showAddingElementAt ─────────────────────────────────────────────────────
293
- const showAddingElementAt = useCallback((clientX: number, clientY: number, expandResults = false, mode: 'add' | 'connect' = 'add') => {
284
+ const showAddingElementAt = useCallback((clientX: number, clientY: number, expandResults = false, mode: 'add' | 'connect' = 'add', forceConnect = false) => {
294
285
  const rect = containerRef.current?.getBoundingClientRect()
295
286
  if (!rect) return
296
287
  const flowPos = screenToFlowPositionRef.current({ x: clientX, y: clientY })
@@ -305,7 +296,7 @@ export function useCanvasInteractions({
305
296
  ? Math.max(100, Math.min(px, rect.width - 450))
306
297
  : Math.max(120, Math.min(px, rect.width - 120))
307
298
  const y = Math.max(40, Math.min(py, rect.height - 250))
308
- setAddingElementAt({ x, y, flowX, flowY, expandResults, mode: resolvePickerMode(flowX, flowY, mode) })
299
+ setAddingElementAt({ x, y, flowX, flowY, expandResults, mode: resolvePickerMode(flowX, flowY, mode, forceConnect) })
309
300
  }, [containerRef, snapToGrid, resolvePickerMode])
310
301
 
311
302
  // ── Inline element adder handlers ───────────────────────────────────────────
@@ -633,7 +624,7 @@ export function useCanvasInteractions({
633
624
  } else {
634
625
  setPendingConnectionSource(sourceElementId)
635
626
  suppressNextPaneClickRef.current = true
636
- showAddingElementAt(clientX, clientY, true, 'connect')
627
+ showAddingElementAt(clientX, clientY, true, 'connect', 'shiftKey' in event && event.shiftKey)
637
628
  }
638
629
  }, [canEdit, setConnectors, showAddingElementAt, rfNodesRef, viewIdRef])
639
630
 
@@ -913,7 +904,7 @@ export function useCanvasInteractions({
913
904
  } else {
914
905
  setInteractionSourceId(null)
915
906
  setPendingConnectionSource(sourceId)
916
- showAddingElementAt(e.clientX, e.clientY, true, 'connect')
907
+ showAddingElementAt(e.clientX, e.clientY, true, 'connect', e.shiftKey)
917
908
  }
918
909
  return
919
910
  }
@@ -112,7 +112,7 @@ export function useViewContextNeighbours({
112
112
  const centerY = (minY + maxY) / 2
113
113
  const boundaryW = maxX - minX
114
114
  const boundaryH = maxY - minY
115
- const totalInset = 100
115
+ const totalInset = 200
116
116
  const padding = 180
117
117
  const radiusX = boundaryW / 2 + padding
118
118
  const radiusY = boundaryH / 2 + padding
@@ -13,7 +13,7 @@ import ReactFlow, {
13
13
  useReactFlow,
14
14
  applyNodeChanges,
15
15
  } from 'reactflow'
16
- import type { EdgeMarker as RFEdgeMarker, Node as RFNode, NodeChange } from 'reactflow'
16
+ import type { Edge as RFEdge, EdgeMarker as RFEdgeMarker, Node as RFNode, NodeChange } from 'reactflow'
17
17
  import 'reactflow/dist/style.css'
18
18
  import { toPng, toSvg } from 'html-to-image'
19
19
  import {
@@ -94,6 +94,11 @@ const nodeTypes = {
94
94
  }
95
95
  const edgeTypes = { default: ViewBezierConnector, contextStraightConnector: ContextStraightConnector, proxyConnectorEdge: ProxyConnectorEdge }
96
96
  const EMPTY_LINKS: ViewConnector[] = []
97
+ const VIEW_EDITOR_MIN_ZOOM_FLOOR = 0.12
98
+ const VIEW_EDITOR_EMPTY_EXTENT_RATIO = 0.75
99
+ const VIEW_EDITOR_PAN_MARGIN_RATIO = 0.25
100
+ const VIEW_EDITOR_PAN_MARGIN_MIN = 180
101
+ const VIEW_EDITOR_PAN_MARGIN_MAX = 720
97
102
 
98
103
  function alphaColor(color: string, opacity: number): string {
99
104
  if (opacity >= 1) return color
@@ -703,64 +708,105 @@ function ViewEditorInner({
703
708
  const contextNodeIdsRef = useRef<Set<string>>(new Set())
704
709
  useEffect(() => {
705
710
  contextNodeIdsRef.current = new Set(contextNodes.map((n) => n.id))
706
- setLiveContextNodes((prev) =>
707
- contextNodes.map((n) => {
708
- const existing = prev.find((p) => p.id === n.id)
711
+ setLiveContextNodes((prev) => {
712
+ const prevById = new Map(prev.map((p) => [p.id, p]))
713
+ return contextNodes.map((n) => {
714
+ const existing = prevById.get(n.id)
709
715
  if (existing?.width != null && existing?.height != null) {
710
716
  return { ...n, width: existing.width, height: existing.height }
711
717
  }
712
718
  return n
713
719
  })
714
- )
720
+ })
715
721
  }, [contextNodes])
716
722
 
723
+ const fadedNodeCacheRef = useRef<WeakMap<RFNode, RFNode>>(new WeakMap())
724
+ const fadedEdgeCacheRef = useRef<WeakMap<RFEdge, RFEdge>>(new WeakMap())
725
+
717
726
  const flowNodes = useMemo(() => {
718
- const allNodes = [...liveContextNodes, ...rfNodes]
719
- const selectedNodeIds = new Set(allNodes.filter((n) => n.selected).map((n) => n.id))
727
+ const allNodes = liveContextNodes.length === 0
728
+ ? rfNodes
729
+ : rfNodes.length === 0
730
+ ? liveContextNodes
731
+ : [...liveContextNodes, ...rfNodes]
732
+
733
+ let hasNodeSel = false
734
+ const selectedNodeIds = new Set<string>()
735
+ for (const n of allNodes) {
736
+ if (n.selected) { selectedNodeIds.add(n.id); hasNodeSel = true }
737
+ }
738
+
739
+ const allEdges = contextConnectors.length === 0
740
+ ? rfEdges
741
+ : rfEdges.length === 0
742
+ ? contextConnectors
743
+ : [...contextConnectors, ...rfEdges]
720
744
 
721
- const allEdges = [...contextConnectors, ...rfEdges]
722
745
  const selectedEdgeEndPoints = new Set<string>()
723
- allEdges.forEach((e) => {
746
+ let hasEdgeSel = false
747
+ for (const e of allEdges) {
724
748
  if (e.selected) {
725
749
  selectedEdgeEndPoints.add(e.source)
726
750
  selectedEdgeEndPoints.add(e.target)
751
+ hasEdgeSel = true
727
752
  }
728
- })
753
+ }
729
754
 
730
755
  const neighborNodeIds = new Set<string>()
731
- if (selectedNodeIds.size > 0) {
732
- allEdges.forEach((e) => {
756
+ if (hasNodeSel) {
757
+ for (const e of allEdges) {
733
758
  if (selectedNodeIds.has(e.source)) neighborNodeIds.add(e.target)
734
759
  if (selectedNodeIds.has(e.target)) neighborNodeIds.add(e.source)
735
- })
760
+ }
736
761
  }
737
762
 
738
- if (selectedNodeIds.size === 0 && selectedEdgeEndPoints.size === 0) return allNodes
763
+ if (!hasNodeSel && !hasEdgeSel) return allNodes
739
764
 
765
+ const cache = fadedNodeCacheRef.current
740
766
  return allNodes.map((n) => {
741
767
  const isHighlighted = selectedNodeIds.has(n.id) || selectedEdgeEndPoints.has(n.id) || neighborNodeIds.has(n.id)
742
768
  if (isHighlighted) return n
743
- return {
769
+ const cached = cache.get(n)
770
+ if (cached) return cached
771
+ const faded: RFNode = {
744
772
  ...n,
745
773
  style: { ...n.style, opacity: (Number(n.style?.opacity ?? 1)) * 0.2 },
746
774
  }
775
+ cache.set(n, faded)
776
+ return faded
747
777
  })
748
778
  }, [liveContextNodes, rfNodes, contextConnectors, rfEdges])
749
779
 
750
780
  const flowEdges = useMemo(() => {
751
- const allEdges = [...contextConnectors, ...rfEdges]
752
- const allNodes = [...liveContextNodes, ...rfNodes]
753
- const selectedNodeIds = new Set(allNodes.filter((n) => n.selected).map((n) => n.id))
754
- const hasEdgeSelection = allEdges.some((e) => e.selected)
781
+ const allEdges = contextConnectors.length === 0
782
+ ? rfEdges
783
+ : rfEdges.length === 0
784
+ ? contextConnectors
785
+ : [...contextConnectors, ...rfEdges]
786
+ const allNodes = liveContextNodes.length === 0
787
+ ? rfNodes
788
+ : rfNodes.length === 0
789
+ ? liveContextNodes
790
+ : [...liveContextNodes, ...rfNodes]
791
+
792
+ const selectedNodeIds = new Set<string>()
793
+ let hasNodeSel = false
794
+ for (const n of allNodes) {
795
+ if (n.selected) { selectedNodeIds.add(n.id); hasNodeSel = true }
796
+ }
797
+ let hasEdgeSel = false
798
+ for (const e of allEdges) { if (e.selected) { hasEdgeSel = true; break } }
755
799
 
756
- if (selectedNodeIds.size === 0 && !hasEdgeSelection) return allEdges
800
+ if (!hasNodeSel && !hasEdgeSel) return allEdges
757
801
 
802
+ const cache = fadedEdgeCacheRef.current
758
803
  return allEdges.map((e) => {
759
804
  const isHighlighted = e.selected || selectedNodeIds.has(e.source) || selectedNodeIds.has(e.target)
760
805
  if (isHighlighted) return e
761
-
806
+ const cached = cache.get(e)
807
+ if (cached) return cached
762
808
  const multiplier = 0.2
763
- return {
809
+ const faded: RFEdge = {
764
810
  ...e,
765
811
  style: { ...e.style, opacity: (Number(e.style?.opacity ?? 0.8)) * multiplier },
766
812
  labelStyle: e.labelStyle ? { ...e.labelStyle, opacity: (Number(e.labelStyle.opacity ?? 1)) * multiplier } : undefined,
@@ -768,6 +814,8 @@ function ViewEditorInner({
768
814
  markerEnd: fadeMarker(e.markerEnd, multiplier),
769
815
  markerStart: fadeMarker(e.markerStart, multiplier),
770
816
  }
817
+ cache.set(e, faded)
818
+ return faded
771
819
  })
772
820
  }, [contextConnectors, rfEdges, liveContextNodes, rfNodes])
773
821
 
@@ -808,7 +856,7 @@ function ViewEditorInner({
808
856
  // ── FitView ────────────────────────────────────────────────────────────────
809
857
  const fitViewRef = useRef(safeFitView)
810
858
  fitViewRef.current = safeFitView
811
- const [computedMinZoom, setComputedMinZoom] = useState(0.05)
859
+ const [computedMinZoom, setComputedMinZoom] = useState(VIEW_EDITOR_MIN_ZOOM_FLOOR)
812
860
  const [computedTranslateExtent, setComputedTranslateExtent] = useState<[[number, number], [number, number]] | undefined>(undefined)
813
861
  const {
814
862
  clampedRevealProgress,
@@ -861,14 +909,22 @@ function ViewEditorInner({
861
909
  // ── Dynamic viewport bounds ────────────────────────────────────────────────
862
910
  useEffect(() => {
863
911
  const vw = window.innerWidth; const vh = window.innerHeight
864
- const emptyExtent: [[number, number], [number, number]] = [[-vw, -vh], [vw, vh]]
865
- if (flowNodes.length === 0 && drawingPaths.length === 0) {
866
- setComputedMinZoom((prev) => prev === 0.05 ? prev : 0.05)
912
+ const emptyExtent: [[number, number], [number, number]] = [
913
+ [-vw * VIEW_EDITOR_EMPTY_EXTENT_RATIO, -vh * VIEW_EDITOR_EMPTY_EXTENT_RATIO],
914
+ [vw * VIEW_EDITOR_EMPTY_EXTENT_RATIO, vh * VIEW_EDITOR_EMPTY_EXTENT_RATIO],
915
+ ]
916
+ const boundsNodes = liveContextNodes.length === 0
917
+ ? rfNodes
918
+ : rfNodes.length === 0
919
+ ? liveContextNodes
920
+ : [...liveContextNodes, ...rfNodes]
921
+ if (boundsNodes.length === 0 && drawingPaths.length === 0) {
922
+ setComputedMinZoom((prev) => prev === VIEW_EDITOR_MIN_ZOOM_FLOOR ? prev : VIEW_EDITOR_MIN_ZOOM_FLOOR)
867
923
  setComputedTranslateExtent((prev) => areTranslateExtentsEqual(prev, emptyExtent) ? prev : emptyExtent)
868
924
  return
869
925
  }
870
926
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
871
- for (const n of flowNodes) {
927
+ for (const n of boundsNodes) {
872
928
  minX = Math.min(minX, n.position.x); minY = Math.min(minY, n.position.y)
873
929
  maxX = Math.max(maxX, n.position.x + (n.width ?? 180)); maxY = Math.max(maxY, n.position.y + (n.height ?? 80))
874
930
  }
@@ -876,26 +932,26 @@ function ViewEditorInner({
876
932
  for (const pt of p.points) { minX = Math.min(minX, pt.x); minY = Math.min(minY, pt.y); maxX = Math.max(maxX, pt.x); maxY = Math.max(maxY, pt.y) }
877
933
  }
878
934
  if (!isFinite(minX)) {
879
- setComputedMinZoom((prev) => prev === 0.05 ? prev : 0.05)
935
+ setComputedMinZoom((prev) => prev === VIEW_EDITOR_MIN_ZOOM_FLOOR ? prev : VIEW_EDITOR_MIN_ZOOM_FLOOR)
880
936
  setComputedTranslateExtent((prev) => areTranslateExtentsEqual(prev, emptyExtent) ? prev : emptyExtent)
881
937
  return
882
938
  }
883
939
  const bboxW = maxX - minX; const bboxH = maxY - minY
884
940
  let minZoom = Math.sqrt((0.12 * vw * vh) / Math.max(1, bboxW * bboxH))
885
- if (!isFinite(minZoom) || isNaN(minZoom) || minZoom <= 0) minZoom = 0.05
886
- const nextMinZoom = Math.max(0.05, Math.min(minZoom, 1))
941
+ if (!isFinite(minZoom) || isNaN(minZoom) || minZoom <= 0) minZoom = VIEW_EDITOR_MIN_ZOOM_FLOOR
942
+ const nextMinZoom = Math.max(VIEW_EDITOR_MIN_ZOOM_FLOOR, Math.min(minZoom, 1))
887
943
  setComputedMinZoom((prev) => prev === nextMinZoom ? prev : nextMinZoom)
888
944
  // Extent must be ≥ viewport at minZoom (else pan locks). Center on content bbox.
889
- // Slack = content-proportional so user can always pan a bit past content edges.
945
+ // Keep only modest content-proportional slack so the canvas stays discoverable.
890
946
  const vwFlowMax = vw / nextMinZoom; const vhFlowMax = vh / nextMinZoom
891
- const slackX = Math.max(bboxW * 0.5, 400)
892
- const slackY = Math.max(bboxH * 0.5, 400)
893
- const spanX = Math.max(bboxW + 2 * slackX, vwFlowMax + 2 * slackX)
894
- const spanY = Math.max(bboxH + 2 * slackY, vhFlowMax + 2 * slackY)
947
+ const slackX = Math.min(Math.max(bboxW * VIEW_EDITOR_PAN_MARGIN_RATIO, VIEW_EDITOR_PAN_MARGIN_MIN), VIEW_EDITOR_PAN_MARGIN_MAX)
948
+ const slackY = Math.min(Math.max(bboxH * VIEW_EDITOR_PAN_MARGIN_RATIO, VIEW_EDITOR_PAN_MARGIN_MIN), VIEW_EDITOR_PAN_MARGIN_MAX)
949
+ const spanX = Math.max(bboxW + 2 * slackX, vwFlowMax)
950
+ const spanY = Math.max(bboxH + 2 * slackY, vhFlowMax)
895
951
  const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2
896
952
  const nextTranslateExtent: [[number, number], [number, number]] = [[cx - spanX / 2, cy - spanY / 2], [cx + spanX / 2, cy + spanY / 2]]
897
953
  setComputedTranslateExtent((prev) => areTranslateExtentsEqual(prev, nextTranslateExtent) ? prev : nextTranslateExtent)
898
- }, [flowNodes, drawingPaths])
954
+ }, [rfNodes, liveContextNodes, drawingPaths])
899
955
 
900
956
  // ── Keyboard shortcuts for drawing ────────────────────────────────────────
901
957
  useEffect(() => {
@@ -943,7 +999,27 @@ function ViewEditorInner({
943
999
  // ── Share ──────────────────────────────────────────────────────────────────
944
1000
  const onShare = useCallback(() => {}, [])
945
1001
 
1002
+ const handleExplorerHoverZoom = useCallback((elementId: number | null, type: 'in' | 'out' | null) => {
1003
+ setHoveredZoom(type && elementId ? { elementId, type } : null)
1004
+ }, [])
1005
+ const handleToggleExplorer = useCallback(() => setIsExplorerOpen((v) => !v), [])
1006
+ const handleCloseLibrary = useCallback(() => setLibraryOpen(false), [])
1007
+ const handleCreateNewLibraryRef = useRef<() => void>(() => {})
1008
+ const handleCreateNewLibrary = useCallback(() => handleCreateNewLibraryRef.current(), [])
1009
+ const handleFocusModeChange = useCallback((v: boolean) => setCrossBranchEnabled(!v), [setCrossBranchEnabled])
1010
+ const handleOpenExport = useCallback(() => exportModal.onOpen(), [exportModal])
1011
+ const handleConnectorSave = useCallback((updated: Connector) => {
1012
+ upsertConnectorGraphSnapshot(updated)
1013
+ setConnectors((prev) => prev.map((c) => (c.id === updated.id ? updated : c)))
1014
+ }, [setConnectors])
1015
+ const handleConnectorDeleteInPanel = useCallback((edgeId: number) => {
1016
+ if (viewId != null) removeConnectorGraphSnapshot(viewId, edgeId)
1017
+ setConnectors((prev) => prev.filter((c) => c.id !== edgeId))
1018
+ }, [setConnectors, viewId])
1019
+ const handleViewSave = useCallback((updated: ViewTreeNode) => setView(updated), [setView])
1020
+
946
1021
  // ── Library helpers ────────────────────────────────────────────────────────
1022
+ // Assigned below; referenced by memoized callbacks (e.g. ElementLibrary onCreateNew).
947
1023
  const handleAddElementAtCenter = useCallback((forceCenter = false) => {
948
1024
  if (!canEdit) return
949
1025
  const rect = containerRef.current?.getBoundingClientRect()
@@ -957,6 +1033,7 @@ function ViewEditorInner({
957
1033
  }
958
1034
  showAddingElementAt(cx, cy, true)
959
1035
  }, [canEdit, showAddingElementAt, lastMousePosRef])
1036
+ handleCreateNewLibraryRef.current = () => handleAddElementAtCenter(true)
960
1037
 
961
1038
  const handleTapAdd = useCallback(async (obj: WorkspaceElement) => {
962
1039
  if (!canEdit || !viewId || existingElementIds.has(obj.id)) return
@@ -1169,8 +1246,8 @@ function ViewEditorInner({
1169
1246
  treeNodes={treeData}
1170
1247
  linksMap={linksMap} viewElements={viewElements}
1171
1248
  onNavigate={canvas.stableOnNavigateToView}
1172
- onHoverZoom={(elementId, type) => setHoveredZoom(type && elementId ? { elementId, type } : null)}
1173
- isOpen={isExplorerOpen} onToggle={() => setIsExplorerOpen((v) => !v)}
1249
+ onHoverZoom={handleExplorerHoverZoom}
1250
+ isOpen={isExplorerOpen} onToggle={handleToggleExplorer}
1174
1251
  isMobile={isMobileLayout}
1175
1252
  activeTags={activeTags}
1176
1253
  setActiveTags={handleSetActiveTags}
@@ -1280,9 +1357,9 @@ function ViewEditorInner({
1280
1357
  hasDrawingPaths={drawingPaths.length > 0} drawingVisible={drawingVisible} setDrawingVisible={setDrawingVisible}
1281
1358
  extrasOpen={extrasOpen} setExtrasOpen={setExtrasOpen}
1282
1359
  focusMode={!crossBranchSettings.enabled}
1283
- onFocusModeChange={(v) => setCrossBranchEnabled(!v)}
1360
+ onFocusModeChange={handleFocusModeChange}
1284
1361
  disableImportExport={disableImportExport}
1285
- onImport={importModal.onOpen} onExport={() => exportModal.onOpen()} onShare={onShare}
1362
+ onImport={importModal.onOpen} onExport={handleOpenExport} onShare={onShare}
1286
1363
  allTags={availableTags}
1287
1364
  layers={layers}
1288
1365
  tagColors={tagColors}
@@ -1302,8 +1379,8 @@ function ViewEditorInner({
1302
1379
  <ElementLibrary
1303
1380
  existingElementIds={existingElementIds}
1304
1381
  existingElements={existingElements}
1305
- onCreateNew={() => handleAddElementAtCenter(true)} refresh={libraryRefresh}
1306
- isOpen={libraryOpen} onClose={() => setLibraryOpen(false)}
1382
+ onCreateNew={handleCreateNewLibrary} refresh={libraryRefresh}
1383
+ isOpen={libraryOpen} onClose={handleCloseLibrary}
1307
1384
  onTapAdd={canEdit ? handleTapAdd : undefined}
1308
1385
  onFindElement={handleFindElement}
1309
1386
  onTouchDrop={canEdit ? handleTouchDrop : undefined}
@@ -1326,14 +1403,8 @@ function ViewEditorInner({
1326
1403
  <ConnectorPanel
1327
1404
  isOpen={connectorPanel.isOpen} onClose={connectorPanel.onClose} connector={selectedEdge}
1328
1405
  orgId={''}
1329
- onSave={(updated: Connector) => {
1330
- upsertConnectorGraphSnapshot(updated)
1331
- setConnectors((prev) => prev.map((connector) => (connector.id === updated.id ? updated : connector)))
1332
- }} autoSave
1333
- onDelete={(edgeId: number) => {
1334
- if (viewId != null) removeConnectorGraphSnapshot(viewId, edgeId)
1335
- setConnectors((prev) => prev.filter((connector) => connector.id !== edgeId))
1336
- }}
1406
+ onSave={handleConnectorSave} autoSave
1407
+ onDelete={handleConnectorDeleteInPanel}
1337
1408
  hasBackdrop={isMobileLayout}
1338
1409
  connectorPanelAfterContentSlot={connectorPanelAfterContentSlot}
1339
1410
  />
@@ -1347,7 +1418,7 @@ function ViewEditorInner({
1347
1418
  <ViewPanel
1348
1419
  isOpen={viewDetails.isOpen} onClose={viewDetails.onClose}
1349
1420
  view={view as ViewTreeNode}
1350
- onSave={(updated) => setView(updated)} hasBackdrop={isMobileLayout}
1421
+ onSave={handleViewSave} hasBackdrop={isMobileLayout}
1351
1422
  />
1352
1423
 
1353
1424
  <ExportModal