@likec4/diagram 1.1.0 → 1.1.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.
@@ -0,0 +1,139 @@
1
+ import { Anchor, Box, Button, CopyButton, Group, Paper, Spoiler, Stack, Text } from '@mantine/core'
2
+ import { useToggle } from '@react-hookz/web'
3
+ import clsx from 'clsx'
4
+ import { AnimatePresence, motion } from 'framer-motion'
5
+ import { type DiagramState, useDiagramState } from '../state'
6
+ import * as css from './DiagramTitlePanel.css'
7
+ import { useLocalStorage } from '@mantine/hooks'
8
+
9
+ const selector = (s: DiagramState) => ({
10
+ id: s.view.id,
11
+ title: s.view.title ?? 'untitled',
12
+ description: s.view.description,
13
+ links: s.view.links
14
+ })
15
+
16
+ export default function DiagramTitlePanel() {
17
+ const { id, title, description, links } = useDiagramState(selector)
18
+ const [isCollapsed, setCollapsed] = useLocalStorage({
19
+ key: 'diagram-title-panel-collapsed',
20
+ defaultValue: false
21
+ })
22
+ const toggle = () => setCollapsed(v => !v)
23
+
24
+ return (
25
+ <AnimatePresence mode="wait">
26
+ <motion.div
27
+ key={id}
28
+ initial={{ opacity: 0.05, scale: 0.7 }}
29
+ animate={{ opacity: 1, scale: 1 }}
30
+ exit={{
31
+ opacity: 0.05,
32
+ scale: 0.6
33
+ }}
34
+ className={clsx('react-flow__panel', css.container)}
35
+ style={{
36
+ transformOrigin: 'left center'
37
+ }}
38
+ >
39
+ <Paper
40
+ radius="sm"
41
+ withBorder={!isCollapsed}
42
+ className={css.paper}
43
+ p={isCollapsed ? 'sm' : 'md'}
44
+ onDoubleClick={e => e.stopPropagation()}>
45
+ <Stack gap={'sm'} justify="stretch" align="stretch">
46
+ <Group justify="stretch" wrap="nowrap" onClick={toggle} style={{ cursor: 'pointer' }}>
47
+ <Text
48
+ component={'div'}
49
+ flex={'1'}
50
+ size={isCollapsed ? 'sm' : 'lg'}
51
+ fw={500}
52
+ lh={1.1}
53
+ className={css.title}
54
+ >
55
+ {title}
56
+ </Text>
57
+ <Text
58
+ hidden={isCollapsed}
59
+ component={'div'}
60
+ flex={'0 0 auto'}
61
+ inline
62
+ size="xs"
63
+ fz={9}
64
+ fw={500}
65
+ c={'dimmed'}
66
+ style={{
67
+ userSelect: 'all'
68
+ }}>
69
+ <span style={{ userSelect: 'none' }}>id:{' '}</span>
70
+ {id}
71
+ </Text>
72
+ </Group>
73
+ {!isCollapsed && (
74
+ <>
75
+ {description && (
76
+ <Spoiler
77
+ maxHeight={42}
78
+ showLabel={<Button color="gray" variant="light" fz={'10'} size="compact-xs">show more</Button>}
79
+ hideLabel={<Button color="gray" variant="light" fz={'10'} size="compact-xs">hide</Button>}>
80
+ <Text
81
+ component={'div'}
82
+ size="sm"
83
+ className={css.description}>
84
+ {description || 'no description'}
85
+ </Text>
86
+ </Spoiler>
87
+ )}
88
+ {!description && (
89
+ <Text
90
+ component={'div'}
91
+ size="xs"
92
+ c={'dimmed'}>
93
+ no description
94
+ </Text>
95
+ )}
96
+ {links && (
97
+ <Stack
98
+ gap={3}
99
+ justify="stretch"
100
+ align="stretch">
101
+ {links.map((link) => (
102
+ <Group key={link} wrap="nowrap" align='center' gap={'sm'}>
103
+ <Box flex={'1'} style={{ overflow: 'hidden' }}>
104
+ <Anchor
105
+ href={link}
106
+ target="_blank"
107
+ fz="xs"
108
+ truncate="end"
109
+ display={'inline-block'}
110
+ w={'100%'}>
111
+ {link}
112
+ </Anchor>
113
+ </Box>
114
+ <CopyButton value={link}>
115
+ {({ copied, copy }) => (
116
+ <Button
117
+ size="compact-xs"
118
+ fz={'10'}
119
+ variant="light"
120
+ onClick={copy}
121
+ color={copied
122
+ ? 'teal'
123
+ : 'gray'}>
124
+ {copied ? 'copied' : 'copy'}
125
+ </Button>
126
+ )}
127
+ </CopyButton>
128
+ </Group>
129
+ ))}
130
+ </Stack>
131
+ )}
132
+ </>
133
+ )}
134
+ </Stack>
135
+ </Paper>
136
+ </motion.div>
137
+ </AnimatePresence>
138
+ )
139
+ }
@@ -3,11 +3,11 @@ import { useOnSelectionChange } from '@xyflow/react'
3
3
  import clsx from 'clsx'
4
4
  import { shallowEqual } from 'fast-equals'
5
5
  import { AnimatePresence, motion } from 'framer-motion'
6
- import { memo, useState } from 'react'
7
- import { NodeOptions } from './options/NodeOptions'
6
+ import { useState } from 'react'
8
7
  import * as styles from './OptionsPanel.css'
8
+ import { NodeOptions } from './options/NodeOptions'
9
9
 
10
- const OptionsPanelMemo = /* @__PURE__ */ memo(function OptionsPanel() {
10
+ export default function OptionsPanel() {
11
11
  const [selectedNodes, setSelectedNodes] = useState([] as string[])
12
12
 
13
13
  useOnSelectionChange({
@@ -16,26 +16,9 @@ const OptionsPanelMemo = /* @__PURE__ */ memo(function OptionsPanel() {
16
16
  setSelectedNodes(prev => {
17
17
  return shallowEqual(prev, next) ? prev : next
18
18
  })
19
- // if (nodes.length === 0 && edges.length === 0) {
20
- // setSelectedNodes([])
21
- // // setSelectedEdges([])
22
- // return
23
- // }
24
- // const selected = new Set([
25
- // ...nodes.map((n) => n.id),
26
- // ...edges.flatMap((edge) => [
27
- // edge.source,
28
- // edge.target
29
- // ])
30
- // ])
31
- // setSelectedNodes([...selected])
32
- // setSelectedEdges(edges.map(e => e.id))
33
19
  }
34
20
  })
35
21
 
36
- // useUpdateEffect(() => {
37
- // setSelectedNodes([])
38
- // }, [viewId])
39
22
 
40
23
  return (
41
24
  <AnimatePresence mode="wait">
@@ -60,6 +43,4 @@ const OptionsPanelMemo = /* @__PURE__ */ memo(function OptionsPanel() {
60
43
  )}
61
44
  </AnimatePresence>
62
45
  )
63
- })
64
-
65
- export default OptionsPanelMemo
46
+ }
@@ -1,8 +1,9 @@
1
1
  import {
2
+ ElementShapes,
2
3
  defaultTheme,
4
+ invariant,
5
+ type BorderStyle,
3
6
  type ElementShape,
4
- ElementShapes,
5
- type Fqn,
6
7
  type NonEmptyArray,
7
8
  type ThemeColor
8
9
  } from '@likec4/core'
@@ -12,14 +13,18 @@ import {
12
13
  ColorSwatch,
13
14
  Divider,
14
15
  Flex,
15
- rem,
16
+ SegmentedControl,
16
17
  Select,
18
+ Slider,
17
19
  Stack,
18
20
  Text,
19
21
  Tooltip,
20
- TooltipGroup
22
+ TooltipGroup,
23
+ rem
21
24
  } from '@mantine/core'
25
+ import { useEffect, useState } from 'react'
22
26
  import { hasAtLeast, keys, takeWhile } from 'remeda'
27
+ import type { Changes } from '../../LikeC4Diagram.props'
23
28
  import { useDiagramStoreApi } from '../../state'
24
29
  import { useXYNodesData } from '../../xyflow/hooks'
25
30
  import { XYFlowNode } from '../../xyflow/types'
@@ -52,15 +57,34 @@ export type ColorKey = typeof colors[0]['key']
52
57
  type XYNodesData = Pick<XYFlowNode, 'id' | 'data' | 'type'>
53
58
 
54
59
  export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] }) {
60
+ // export function NodeOptions(props: { nodes: XYFlowNode[] }) {
55
61
  const diagramApi = useDiagramStoreApi()
62
+ // const nodes = useXYNodesData(props.nodes.map(node => node.id))
56
63
  const nodes = useXYNodesData(selectedNodeIds)
57
64
  if (!hasAtLeast(nodes, 1)) {
58
65
  return null
59
66
  }
67
+ if (nodes.length !== selectedNodeIds.length) {
68
+ throw new Error('NodeOptions: nodes and props.nodes should have the same length')
69
+ }
60
70
  // Makes sense to show shape option only if there is at least one element node
61
71
  const showShapeOption = nodes.some(node => node.type === 'element')
62
72
 
63
73
  const [firstNode, ...rest] = nodes
74
+
75
+ // Makes sense to show opacity option only if there is at least one compound node
76
+ const showOpacityOption = rest.length === 0 && firstNode.type === 'compound'
77
+
78
+ const triggerChange = (style: Changes.ChangeElementStyle['style']) => {
79
+ const targets = nodes.map(node => node.data.element.id)
80
+ invariant(hasAtLeast(targets, 1), 'At least one target is required')
81
+ diagramApi.getState().triggerOnChange([{
82
+ op: 'change-element-style',
83
+ style,
84
+ targets
85
+ }])
86
+ }
87
+
64
88
  return (
65
89
  <Stack>
66
90
  <Box maw={200}>
@@ -72,39 +96,33 @@ export function NodeOptions({ selectedNodeIds }: { selectedNodeIds: string[] })
72
96
  <ShapeOption
73
97
  nodes={nodes}
74
98
  onShapeChange={(shape: ElementShape) => {
75
- const targets = [] as Fqn[]
76
- for (const nd of nodes) {
77
- if (nd.data.element.shape !== shape) {
78
- targets.push(nd.data.element.id)
79
- }
80
- }
81
- if (hasAtLeast(targets, 1)) {
82
- diagramApi.getState().triggerOnChange([{
83
- op: 'change-shape',
84
- shape,
85
- targets
86
- }])
87
- }
99
+ triggerChange({ shape })
88
100
  }} />
89
101
  )}
90
102
  <Colors
91
103
  nodes={nodes}
92
104
  onColorChange={(color: ColorKey | ThemeColorKey) => {
93
- const targets = [] as Fqn[]
94
- for (const nd of nodes) {
95
- if (nd.data.element.color !== color) {
96
- targets.push(nd.data.element.id)
97
- }
98
- }
99
- if (hasAtLeast(targets, 1)) {
100
- diagramApi.getState().triggerOnChange([{
101
- op: 'change-color',
102
- color,
103
- targets
104
- }])
105
- }
105
+ triggerChange({ color })
106
106
  }} />
107
107
  <NavigateToOption nodes={nodes} />
108
+ <Box key={firstNode.id}>
109
+ {showOpacityOption && (
110
+ <>
111
+ <Divider label="opacity and border" labelPosition="left" />
112
+ <OpacityOption
113
+ node={firstNode}
114
+ onOpacityChange={(opacity: number) => {
115
+ triggerChange({ opacity })
116
+ }} />
117
+ <BorderStyleOption
118
+ node={firstNode}
119
+ onChange={(border: BorderStyle) => {
120
+ triggerChange({ border })
121
+ }}
122
+ />
123
+ </>
124
+ )}
125
+ </Box>
108
126
  </Stack>
109
127
  )
110
128
  }
@@ -251,3 +269,62 @@ function NavigateToOption({
251
269
  </Box>
252
270
  )
253
271
  }
272
+
273
+ function OpacityOption({
274
+ node,
275
+ onOpacityChange
276
+ }: {
277
+ node: XYNodesData
278
+ onOpacityChange: (opacity: number) => void
279
+ }) {
280
+ let selectedOpacity = node.data.element.style.opacity ?? 100
281
+ const [value, setValue] = useState(selectedOpacity)
282
+ useEffect(() => {
283
+ setValue(selectedOpacity)
284
+ }, [selectedOpacity])
285
+
286
+ return (
287
+ <Slider
288
+ mt={'xs'}
289
+ size={'sm'}
290
+ color={'dark'}
291
+ value={value}
292
+ onChange={setValue}
293
+ onChangeEnd={onOpacityChange} />
294
+ )
295
+ }
296
+
297
+ function BorderStyleOption({
298
+ node,
299
+ onChange
300
+ }: {
301
+ node: XYNodesData
302
+ onChange: (borderStyle: BorderStyle) => void
303
+ }) {
304
+ let selecteBorderStyle = node.data.element.style.border ?? 'dashed'
305
+ const [value, setValue] = useState(selecteBorderStyle)
306
+ useEffect(() => {
307
+ setValue(selecteBorderStyle)
308
+ }, [selecteBorderStyle])
309
+
310
+ return (
311
+ <Box mt={'md'}>
312
+ <SegmentedControl
313
+ size="xs"
314
+ fullWidth
315
+ withItemsBorders={false}
316
+ value={value}
317
+ onChange={v => {
318
+ setValue(v as BorderStyle)
319
+ onChange(v as BorderStyle)
320
+ }}
321
+ data={[
322
+ { label: 'Solid', value: 'solid' },
323
+ { label: 'Dashed', value: 'dashed' },
324
+ { label: 'Dotted', value: 'dotted' },
325
+ { label: 'None', value: 'none' }
326
+ ]}
327
+ />
328
+ </Box>
329
+ )
330
+ }
@@ -66,7 +66,7 @@ function FitViewOnDiagramChanges() {
66
66
  s => s.viewportChanged,
67
67
  setViewportMoved
68
68
  ),
69
- [diagramApi]
69
+ [diagramApi, setViewportMoved]
70
70
  )
71
71
 
72
72
  useRafEffect(
@@ -77,7 +77,7 @@ function FitViewOnDiagramChanges() {
77
77
  diagramApi.getState().fitDiagram()
78
78
  setProcessed(pendingChangeId)
79
79
  },
80
- [isReady, processedChangeId, pendingChangeId]
80
+ [isReady, processedChangeId, pendingChangeId, diagramApi]
81
81
  )
82
82
 
83
83
  if (!viewportMoved && isReady) {
@@ -0,0 +1,57 @@
1
+ import type { EdgeChange, NodeChange } from '@xyflow/react'
2
+ import { useUpdateEffect } from '../hooks'
3
+ import { useDiagramState } from '../state/useDiagramStore'
4
+ import { useXYStoreApi } from './hooks/useXYFlow'
5
+ import type { XYFlowEdge, XYFlowNode } from './types'
6
+
7
+ export function SelectEdgesOnNodeFocus() {
8
+ const focusedNodeId = useDiagramState(s => s.focusedNodeId)
9
+ const xyflowApi = useXYStoreApi()
10
+
11
+ useUpdateEffect(() => {
12
+ const edgeChanges = [] as EdgeChange<XYFlowEdge>[]
13
+ const nodeChanges = [] as NodeChange<XYFlowNode>[]
14
+ const { edges, nodes, triggerNodeChanges, triggerEdgeChanges } = xyflowApi.getState()
15
+ if (focusedNodeId) {
16
+ for (const edge of edges) {
17
+ if (edge.source === focusedNodeId || edge.target === focusedNodeId) {
18
+ edgeChanges.push({
19
+ id: edge.id,
20
+ type: 'select',
21
+ selected: true
22
+ })
23
+ } else if (edge.selected) {
24
+ edgeChanges.push({
25
+ id: edge.id,
26
+ type: 'select',
27
+ selected: false
28
+ })
29
+ }
30
+ }
31
+ for (const node of nodes) {
32
+ if (node.selected && node.id !== focusedNodeId) {
33
+ nodeChanges.push({
34
+ id: node.id,
35
+ type: 'select',
36
+ selected: false
37
+ })
38
+ }
39
+ }
40
+ nodeChanges.push({
41
+ id: focusedNodeId,
42
+ type: 'select',
43
+ selected: true
44
+ })
45
+ if (edgeChanges.length > 0) {
46
+ triggerEdgeChanges(edgeChanges)
47
+ }
48
+ if (nodeChanges.length > 0) {
49
+ triggerNodeChanges(nodeChanges)
50
+ }
51
+ } else {
52
+ xyflowApi.getState().resetSelectedElements()
53
+ }
54
+ }, [focusedNodeId])
55
+
56
+ return null
57
+ }
@@ -27,14 +27,20 @@ export const SyncWithDiagram = memo(() => {
27
27
  state => ({
28
28
  viewId: state.view.id,
29
29
  nodes: state.view.nodes,
30
- edges: state.view.edges,
31
- draggable: state.nodesDraggable,
32
- selectable: state.nodesSelectable
30
+ edges: state.view.edges
33
31
  }),
34
32
  // listener
35
- ({ viewId, nodes, edges, ...opts }) => {
36
- const { lastOnNavigate } = diagramStoreApi.getState()
37
- const updates = diagramViewToXYFlowData({ nodes, edges }, opts)
33
+ ({ viewId, nodes, edges }) => {
34
+ const {
35
+ lastOnNavigate,
36
+ nodesDraggable,
37
+ nodesSelectable,
38
+ focusedNodeId
39
+ } = diagramStoreApi.getState()
40
+ const updates = diagramViewToXYFlowData({ nodes, edges }, {
41
+ draggable: nodesDraggable,
42
+ selectable: nodesSelectable || focusedNodeId !== null
43
+ })
38
44
 
39
45
  xyflowRef.current.setNodes(prev =>
40
46
  updates.nodes.map(update => {
@@ -1,5 +1,6 @@
1
1
  import { useMantineColorScheme } from '@mantine/core'
2
- import { Controls, ReactFlow } from '@xyflow/react'
2
+ import { Controls, PanOnScrollMode, ReactFlow } from '@xyflow/react'
3
+ import { DEV } from 'esm-env'
3
4
  import { type CSSProperties, memo, type PropsWithChildren } from 'react'
4
5
  import type { SetNonNullable, Simplify } from 'type-fest'
5
6
  import type { LikeC4DiagramProperties } from '../LikeC4Diagram.props'
@@ -9,6 +10,7 @@ import { RelationshipEdge } from './edges/RelationshipEdge'
9
10
  import { useLayoutConstraints } from './hooks/useLayoutConstraints'
10
11
  import { CompoundNode } from './nodes/compound'
11
12
  import { ElementNode } from './nodes/element'
13
+ import { SelectEdgesOnNodeFocus } from './SelectEdgesOnNodeFocus'
12
14
  import { XYFlowEdge, XYFlowNode } from './types'
13
15
  import { XYFlowBackground } from './XYFlowBackground'
14
16
  import { useXYFlowEvents } from './XYFlowEvents'
@@ -41,7 +43,7 @@ type XYFlowWrapperProps = Simplify<
41
43
  >
42
44
 
43
45
  const selector = (s: DiagramState) => ({
44
- nodesSelectable: s.nodesSelectable,
46
+ nodesSelectable: s.nodesSelectable || s.focusedNodeId !== null,
45
47
  nodesDraggable: s.nodesDraggable,
46
48
  fitView: s.fitViewEnabled,
47
49
  fitViewPadding: s.fitViewPadding,
@@ -137,6 +139,7 @@ function XYFlowWrapper({
137
139
  onNodeClick={handlers.onNodeClick}
138
140
  onNodeDoubleClick={handlers.onNodeDoubleClick}
139
141
  onEdgeClick={handlers.onEdgeClick}
142
+ onViewportChange={handlers.onViewportChange}
140
143
  onInit={() => {
141
144
  diagramApi.setState({ initialized: true }, false, 'initialized')
142
145
  }}
@@ -150,7 +153,8 @@ function XYFlowWrapper({
150
153
  onPaneContextMenu: handlers.onPaneContextMenu
151
154
  })}>
152
155
  {isBgWithPattern && <XYFlowBackground background={background} />}
153
- {controls && <Controls />}
156
+ {controls && <Controls position={'bottom-right'} />}
157
+ {zoomable && !editor.hasOnNodeClick && <SelectEdgesOnNodeFocus />}
154
158
  {children}
155
159
  </ReactFlow>
156
160
  )