@tldiagram/core-ui 2.0.2 → 2.0.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.
@@ -2,7 +2,7 @@ import React, { memo } from 'react'
2
2
  import type { ViewFloatingMenuSlots } from '../slots'
3
3
 
4
4
  import {
5
- HStack, Tooltip, Button, Box, Text, Popover, PopoverTrigger, Portal, PopoverContent, PopoverBody, IconButton, Slider, SliderTrack, SliderFilledTrack, SliderThumb, useDisclosure
5
+ HStack, Tooltip, Button, Box, Text, Popover, PopoverTrigger, Portal, PopoverContent, PopoverBody, IconButton, Slider, SliderTrack, SliderFilledTrack, SliderThumb, Switch, VStack, useDisclosure
6
6
  } from '@chakra-ui/react'
7
7
  import { DownloadIcon } from '@chakra-ui/icons'
8
8
  import {
@@ -15,12 +15,21 @@ import {
15
15
  CollapseExtrasIcon as CollapseExtrasSvg,
16
16
  FocusIcon as FocusSvg,
17
17
  TagsIcon,
18
+ ChevronDownIcon,
18
19
  } from './Icons'
19
20
  import { KbdHint } from './PanelUI'
20
21
  import { RedoSvg, UndoSvg } from './ViewDrawMenu'
21
22
  import { useViewEditorContext } from '../pages/ViewEditor/context'
22
23
  import type { Tag, ViewLayer } from '../types'
23
24
 
25
+ const DENSITY_STOPS = [
26
+ { value: -2, label: 'Quiet' },
27
+ { value: -1, label: 'Lean' },
28
+ { value: 0, label: 'Normal' },
29
+ { value: 1, label: 'Rich' },
30
+ { value: 2, label: 'Full' },
31
+ ] as const
32
+
24
33
  export interface ViewFloatingMenuProps extends ViewFloatingMenuSlots {
25
34
  handleAddElementAtCenter: () => void
26
35
  drawingMode: boolean
@@ -105,7 +114,11 @@ function ViewFloatingMenu({
105
114
  }: ViewFloatingMenuProps) {
106
115
  const { canEdit } = useViewEditorContext()
107
116
  const { isOpen: isTagsOpen, onClose: onTagsClose, onToggle: onTagsToggle } = useDisclosure()
117
+ const { isOpen: isFiltersOpen, onClose: onFiltersClose, onToggle: onFiltersToggle } = useDisclosure()
108
118
  const [draftDensityLevel, setDraftDensityLevel] = React.useState(densityLevel)
119
+ const activeDensityLabel = DENSITY_STOPS.find((stop) => stop.value === draftDensityLevel)?.label ?? 'Normal'
120
+ const showFilters = !hideFocusView || !!onDensityLevelChange
121
+ const hasActiveFilters = (!hideFocusView && focusMode) || (!!onDensityLevelChange && densityLevel !== 0)
109
122
 
110
123
  React.useEffect(() => {
111
124
  setDraftDensityLevel(densityLevel)
@@ -191,26 +204,152 @@ function ViewFloatingMenu({
191
204
  </>
192
205
  )}
193
206
 
194
- {!hideFocusView && (
207
+ {showFilters && (
195
208
  <>
196
209
  <Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
197
- <Tooltip label={focusMode ? 'Show context' : 'Focus on this view'} placement="top" openDelay={200}>
198
- <Button
199
- variant="ghost"
200
- h="28px"
201
- px={2.5}
202
- color={focusMode ? 'var(--accent)' : 'gray.300'}
203
- bg={focusMode ? 'rgba(var(--accent-rgb), 0.12)' : 'transparent'}
204
- _hover={{ bg: 'rgba(var(--accent-rgb), 0.12)', color: 'var(--accent)' }}
205
- onClick={() => onFocusModeChange(!focusMode)}
206
- >
207
- <HStack spacing={1.5}>
208
- <FocusSvg />
209
- <Text fontSize="11px" fontWeight="normal">Focus View</Text>
210
- <Box w="6px" h="6px" rounded="full" bg={focusMode ? 'var(--accent)' : 'gray.500'} />
211
- </HStack>
212
- </Button>
213
- </Tooltip>
210
+ <Popover isOpen={isFiltersOpen} onClose={onFiltersClose} placement="top" isLazy closeOnBlur>
211
+ <PopoverTrigger>
212
+ <Button
213
+ variant="ghost"
214
+ h="28px"
215
+ px={2.5}
216
+ color={isFiltersOpen || hasActiveFilters ? 'var(--accent)' : 'gray.300'}
217
+ bg={hasActiveFilters ? 'rgba(var(--accent-rgb), 0.12)' : 'transparent'}
218
+ _hover={{ bg: 'rgba(var(--accent-rgb), 0.12)', color: 'var(--accent)' }}
219
+ onClick={onFiltersToggle}
220
+ aria-label="Open filters"
221
+ >
222
+ <HStack spacing={1.5}>
223
+ <FocusSvg />
224
+ <Text fontSize="11px" fontWeight={hasActiveFilters ? 'semibold' : 'normal'}>Filters</Text>
225
+ {hasActiveFilters && <Box w="6px" h="6px" rounded="full" bg="var(--accent)" />}
226
+ <ChevronDownIcon size={10} strokeWidth={3.5} />
227
+ </HStack>
228
+ </Button>
229
+ </PopoverTrigger>
230
+ <Portal>
231
+ <PopoverContent
232
+ bg="linear-gradient(180deg, rgba(var(--bg-main-rgb), 0.98) 0%, rgba(var(--bg-main-rgb), 0.92) 100%)"
233
+ backdropFilter="blur(22px)"
234
+ borderColor="whiteAlpha.100"
235
+ boxShadow="0 18px 48px rgba(0,0,0,0.46), inset 0 1px 0 rgba(255,255,255,0.04)"
236
+ borderRadius="lg"
237
+ width="280px"
238
+ _focus={{ boxShadow: 'none' }}
239
+ >
240
+ <PopoverBody p={3}>
241
+ <VStack align="stretch" spacing={3}>
242
+ {!hideFocusView && (
243
+ <HStack
244
+ justify="space-between"
245
+ spacing={3}
246
+ px={2.5}
247
+ py={2}
248
+ rounded="md"
249
+ bg={focusMode ? 'rgba(var(--accent-rgb), 0.10)' : 'whiteAlpha.50'}
250
+ border="1px solid"
251
+ borderColor={focusMode ? 'rgba(var(--accent-rgb), 0.22)' : 'whiteAlpha.100'}
252
+ >
253
+ <HStack spacing={2.5} minW={0}>
254
+ <Box color={focusMode ? 'var(--accent)' : 'gray.400'} flexShrink={0}>
255
+ <FocusSvg />
256
+ </Box>
257
+ <Box minW={0}>
258
+ <Text fontSize="xs" fontWeight="semibold" color="whiteAlpha.900">Hide External</Text>
259
+ </Box>
260
+ </HStack>
261
+ <Switch
262
+ size="sm"
263
+ isChecked={focusMode}
264
+ onChange={(event) => onFocusModeChange(event.target.checked)}
265
+ colorScheme="teal"
266
+ flexShrink={0}
267
+ aria-label="Toggle external view"
268
+ />
269
+ </HStack>
270
+ )}
271
+
272
+ {onDensityLevelChange && (
273
+ <Box
274
+ px={2.5}
275
+ py={2.5}
276
+ rounded="md"
277
+ bg="whiteAlpha.50"
278
+ border="1px solid"
279
+ borderColor="whiteAlpha.100"
280
+ >
281
+ <HStack justify="space-between" mb={2.5}>
282
+ <Box>
283
+ <Text fontSize="xs" fontWeight="semibold" color="whiteAlpha.900">Density</Text>
284
+ <Text fontSize="10px" color="whiteAlpha.600">Control how much detail is shown</Text>
285
+ </Box>
286
+ <Text
287
+ fontSize="10px"
288
+ fontWeight="bold"
289
+ color="var(--accent)"
290
+ bg="rgba(var(--accent-rgb), 0.10)"
291
+ border="1px solid"
292
+ borderColor="rgba(var(--accent-rgb), 0.18)"
293
+ rounded="full"
294
+ px={2}
295
+ py={0.5}
296
+ >
297
+ {activeDensityLabel}
298
+ </Text>
299
+ </HStack>
300
+ <Box px={1} pt={1} pb={0.5}>
301
+ <Slider
302
+ aria-label="Density"
303
+ min={-2}
304
+ max={2}
305
+ step={1}
306
+ value={draftDensityLevel}
307
+ onChange={setDraftDensityLevel}
308
+ onChangeEnd={(value) => {
309
+ setDraftDensityLevel(value)
310
+ onDensityLevelChange(value)
311
+ }}
312
+ focusThumbOnChange={false}
313
+ >
314
+ <SliderTrack h="4px" bg="whiteAlpha.200">
315
+ <SliderFilledTrack bg="var(--accent)" />
316
+ </SliderTrack>
317
+ {DENSITY_STOPS.map((stop) => (
318
+ <Box
319
+ key={stop.value}
320
+ position="absolute"
321
+ left={`${((stop.value + 2) / 4) * 100}%`}
322
+ top="50%"
323
+ transform="translate(-50%, -50%)"
324
+ w={stop.value === draftDensityLevel ? '6px' : '2px'}
325
+ h={stop.value === draftDensityLevel ? '6px' : '10px'}
326
+ rounded="full"
327
+ bg={draftDensityLevel >= stop.value ? 'var(--accent)' : 'whiteAlpha.500'}
328
+ pointerEvents="none"
329
+ />
330
+ ))}
331
+ <SliderThumb boxSize="14px" bg="white" border="2px solid" borderColor="var(--accent)" />
332
+ </Slider>
333
+ <HStack justify="space-between" mt={2} px={0.5}>
334
+ {DENSITY_STOPS.map((stop) => (
335
+ <Text
336
+ key={stop.value}
337
+ fontSize="9px"
338
+ fontWeight={stop.value === draftDensityLevel ? 'bold' : 'medium'}
339
+ color={stop.value === draftDensityLevel ? 'whiteAlpha.900' : 'whiteAlpha.500'}
340
+ >
341
+ {stop.label}
342
+ </Text>
343
+ ))}
344
+ </HStack>
345
+ </Box>
346
+ </Box>
347
+ )}
348
+ </VStack>
349
+ </PopoverBody>
350
+ </PopoverContent>
351
+ </Portal>
352
+ </Popover>
214
353
  </>
215
354
  )}
216
355
  <Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
@@ -325,55 +464,6 @@ function ViewFloatingMenu({
325
464
  </>
326
465
  )}
327
466
 
328
- {onDensityLevelChange && (
329
- <>
330
- <Box w="1px" h="16px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
331
- <Tooltip label={`Density ${draftDensityLevel}`} placement="top" openDelay={200}>
332
- <Box
333
- w="92px"
334
- h="28px"
335
- px={2.5}
336
- display="flex"
337
- alignItems="center"
338
- bg="whiteAlpha.50"
339
- rounded="md"
340
- >
341
- <Slider
342
- aria-label="Density"
343
- min={-2}
344
- max={2}
345
- step={1}
346
- value={draftDensityLevel}
347
- onChange={setDraftDensityLevel}
348
- onChangeEnd={(value) => {
349
- setDraftDensityLevel(value)
350
- onDensityLevelChange(value)
351
- }}
352
- focusThumbOnChange={false}
353
- >
354
- <SliderTrack h="3px" bg="whiteAlpha.200">
355
- <SliderFilledTrack bg="var(--accent)" />
356
- </SliderTrack>
357
- {[-2, -1, 0, 1, 2].map((value) => (
358
- <Box
359
- key={value}
360
- position="absolute"
361
- left={`${((value + 2) / 4) * 100}%`}
362
- top="50%"
363
- transform="translate(-50%, -50%)"
364
- w="1px"
365
- h="9px"
366
- bg={draftDensityLevel >= value ? 'var(--accent)' : 'whiteAlpha.400'}
367
- pointerEvents="none"
368
- />
369
- ))}
370
- <SliderThumb boxSize="12px" bg="white" border="2px solid" borderColor="var(--accent)" />
371
- </Slider>
372
- </Box>
373
- </Tooltip>
374
- </>
375
- )}
376
-
377
467
  {/* Draw mode toggle */}
378
468
  <Tooltip
379
469
  label={drawingMode ? 'Exit drawing mode' : 'Draw on diagram'}
@@ -8,6 +8,7 @@ export interface ViewEditorDemoOptions {
8
8
  disableOnboarding?: boolean
9
9
  hideFocusView?: boolean
10
10
  hideExpandExtras?: boolean
11
+ defaultHiddenLayerTags?: string[]
11
12
  }
12
13
 
13
14
  export const DEMO_VIEW_EDITOR_OPTIONS: Omit<ViewEditorDemoOptions, 'revealProgress'> = {
@@ -16,6 +17,7 @@ export const DEMO_VIEW_EDITOR_OPTIONS: Omit<ViewEditorDemoOptions, 'revealProgre
16
17
  disableOnboarding: true,
17
18
  hideFocusView: true,
18
19
  hideExpandExtras: true,
20
+ defaultHiddenLayerTags: ['view_layers:admin', 'view_layers:ops'],
19
21
  }
20
22
 
21
23
 
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  // ─── Pages ───────────────────────────────────────────────────────────────────
11
- export { default as ViewEditor } from './pages/ViewEditor'
11
+ export { default as ViewEditor, type ViewEditorPermissions } from './pages/ViewEditor'
12
12
  export { default as ViewsPage } from './pages/Views'
13
13
  export { default as ViewsGrid } from './pages/ViewsGrid'
14
14
  export { default as Dependencies } from './pages/Dependencies'
@@ -257,12 +257,12 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
257
257
  {noDiagrams ? 'No diagrams to explore yet' : 'Your diagrams are empty'}
258
258
  </Text>
259
259
  <Text color="gray.500" fontSize="sm" maxW="400px">
260
- {noDiagrams
261
- ? 'Start by creating your first diagram in the workspace.'
260
+ {noDiagrams
261
+ ? 'Start by creating your first diagram in the workspace.'
262
262
  : 'Add elements to your diagrams in the editor to see them rendered on this infinite canvas.'}
263
263
  </Text>
264
264
  </VStack>
265
-
265
+
266
266
  {!sharedToken && (
267
267
  <Button size="sm" colorScheme="blue" onClick={() => navigate('/views')} borderRadius="full" px={6}>
268
268
  {noDiagrams ? 'Create First Diagram' : 'Go to Editor'}
@@ -349,7 +349,7 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
349
349
  onEnabledChange={setCrossBranchEnabled}
350
350
  onBudgetChange={setCrossBranchConnectorBudget}
351
351
  onPriorityChange={setCrossBranchConnectorPriority}
352
- label="Branches"
352
+ label="Filters"
353
353
  />
354
354
 
355
355
  {(allTags.length > 0 || layers.length > 0) && (
@@ -100,6 +100,8 @@ const nodeTypes = {
100
100
  const edgeTypes = { default: ViewBezierConnector, contextStraightConnector: ContextStraightConnector, proxyConnectorEdge: ProxyConnectorEdge }
101
101
  const EMPTY_LINKS: ViewConnector[] = []
102
102
  const VIEW_EDITOR_MIN_ZOOM_FLOOR = 0.12
103
+ const VIEW_EDITOR_INITIAL_FIT_PADDING = 0.25
104
+ const VIEW_EDITOR_FOCUS_FIT_PADDING = 0.35
103
105
  const VIEW_EDITOR_EMPTY_EXTENT_RATIO = 0.75
104
106
  const VIEW_EDITOR_PAN_MARGIN_RATIO = 0.25
105
107
  const VIEW_EDITOR_PAN_MARGIN_MIN = 180
@@ -159,6 +161,19 @@ function viewSnapshotsEqual(left: ViewMetadataSnapshot, right: ViewMetadataSnaps
159
161
  return left.id === right.id && left.name === right.name && (left.level_label ?? '') === (right.level_label ?? '')
160
162
  }
161
163
 
164
+ function nodesMatchCurrentView(nodes: RFNode[], elements: PlacedElement[], viewId: number | null) {
165
+ if (viewId === null || elements.length === 0) return false
166
+ if (!elements.every((element) => element.view_id === viewId)) return false
167
+
168
+ const nodesById = new Map(nodes.map((node) => [node.id, node]))
169
+ return elements.every((element) => {
170
+ const node = nodesById.get(String(element.element_id))
171
+ return node !== undefined &&
172
+ Math.abs(node.position.x - (element.position_x ?? 0)) < 0.5 &&
173
+ Math.abs(node.position.y - (element.position_y ?? 0)) < 0.5
174
+ })
175
+ }
176
+
162
177
  function alphaColor(color: string, opacity: number): string {
163
178
  if (opacity >= 1) return color
164
179
  return `color-mix(in srgb, ${color} ${Math.round(opacity * 100)}%, transparent)`
@@ -193,12 +208,21 @@ function canonicalNodePairKey(leftId: string, rightId: string) {
193
208
 
194
209
  // ─────────────────────────────────────────────────────────────────────────────
195
210
 
196
- interface Props extends CoreUISlots {
211
+ export interface ViewEditorPermissions {
212
+ canEdit?: boolean
213
+ isOwner?: boolean
214
+ isFreePlan?: boolean
215
+ }
216
+
217
+ interface Props extends CoreUISlots, ViewEditorPermissions {
197
218
  demoOptions?: ViewEditorDemoOptions
198
219
  }
199
220
 
200
221
  function ViewEditorInner({
201
222
  demoOptions,
223
+ canEdit = true,
224
+ isOwner = true,
225
+ isFreePlan = false,
202
226
  canvasOverlaySlot,
203
227
  toolbarSlot,
204
228
  shareSlot,
@@ -225,10 +249,6 @@ function ViewEditorInner({
225
249
  undo: undoViewEdit,
226
250
  redo: redoViewEdit,
227
251
  } = useViewEditHistory()
228
- const canEdit = true
229
- const isOwner = true
230
- const isFreePlan = false
231
-
232
252
  const setHeader = useSetHeader()
233
253
  const isMobileLayout = useBreakpointValue({ base: true, md: false }) ?? false
234
254
  const [densityLevel, setDensityLevel] = useState(0)
@@ -375,7 +395,7 @@ function ViewEditorInner({
375
395
  }, [])
376
396
 
377
397
  const [layers, setLayers] = useState<import('../../types').ViewLayer[]>([])
378
- const [hiddenLayerTags, setHiddenLayerTags] = useState<string[]>([])
398
+ const [hiddenLayerTags, setHiddenLayerTags] = useState<string[]>(() => demoOptions?.defaultHiddenLayerTags ?? [])
379
399
  const hiddenLayerTagsRef = useRef<string[]>([])
380
400
  hiddenLayerTagsRef.current = hiddenLayerTags
381
401
  const [hoveredLayerTags, setHoveredLayerTags] = useState<string[] | null>(null)
@@ -669,7 +689,7 @@ function ViewEditorInner({
669
689
  useEffect(() => {
670
690
  const unsub = vscodeBridge.onMessage(async (msg: ExtensionToWebviewMessage) => {
671
691
  if (msg.type === 'focus-element') {
672
- fitView({ nodes: [{ id: String(msg.elementId) }], duration: 800, padding: 100 })
692
+ fitView({ nodes: [{ id: String(msg.elementId) }], duration: 800, padding: VIEW_EDITOR_FOCUS_FIT_PADDING })
673
693
  } else if (msg.type === 'element-placed') {
674
694
  if (viewId === null) return
675
695
  try {
@@ -1274,6 +1294,7 @@ function ViewEditorInner({
1274
1294
  if (!rfReadyRef.current || !needsFitView.current) return
1275
1295
  const nodes = rfNodesRef.current
1276
1296
  if (nodes.length === 0) return
1297
+ if (!nodesMatchCurrentView(nodes, viewElementsRef.current, viewIdRef.current)) return
1277
1298
  if (!nodes.every((n) => typeof n.width === 'number' && n.width > 0 && typeof n.height === 'number' && n.height > 0)) return
1278
1299
 
1279
1300
  if (clampedRevealProgress !== null) {
@@ -1283,13 +1304,17 @@ function ViewEditorInner({
1283
1304
  return
1284
1305
  }
1285
1306
 
1286
- const ok = safeFitView({ duration: 0, padding: 400 })
1307
+ const ok = safeFitView({ duration: 0, padding: VIEW_EDITOR_INITIAL_FIT_PADDING, minZoom: computedMinZoom, maxZoom: 4 })
1287
1308
  if (ok) needsFitView.current = false
1288
1309
  else setTimeout(() => { if (needsFitView.current) maybeFitView() }, 50)
1289
- }, [applyDemoRevealViewport, clampedRevealProgress, safeFitView, rfNodesRef])
1310
+ }, [applyDemoRevealViewport, clampedRevealProgress, computedMinZoom, safeFitView, rfNodesRef, viewElementsRef, viewIdRef])
1290
1311
 
1291
1312
  const onRFInit = useCallback(() => { rfReadyRef.current = true; maybeFitView() }, [maybeFitView])
1292
1313
 
1314
+ useEffect(() => {
1315
+ needsFitView.current = true
1316
+ }, [viewId])
1317
+
1293
1318
  useEffect(() => { maybeFitView() }, [rfNodes, maybeFitView])
1294
1319
 
1295
1320
  useEffect(() => {
@@ -1310,7 +1335,6 @@ function ViewEditorInner({
1310
1335
  closeElementPanelRef.current()
1311
1336
  closeConnectorPanelRef.current()
1312
1337
  closeProxyConnectorPanelRef.current()
1313
- needsFitView.current = true
1314
1338
  }, [clearEditHistory, viewId])
1315
1339
 
1316
1340
  // ── Dynamic viewport bounds ────────────────────────────────────────────────