@likec4/diagram 1.7.3 → 1.8.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.
@@ -0,0 +1,253 @@
1
+ import { type ElementKind, type ElementNotation as ElementNotationData } from '@likec4/core'
2
+ import {
3
+ ActionIcon,
4
+ Badge,
5
+ Box,
6
+ Card,
7
+ Group,
8
+ Paper,
9
+ ScrollAreaAutosize,
10
+ Stack,
11
+ Tabs,
12
+ TabsList,
13
+ TabsPanel,
14
+ TabsTab,
15
+ Text,
16
+ ThemeIcon,
17
+ Tooltip
18
+ } from '@mantine/core'
19
+ import { useLocalStorage } from '@mantine/hooks'
20
+ import { IconAlertTriangle, IconArrowDownRight, IconHelpCircle } from '@tabler/icons-react'
21
+ import clsx from 'clsx'
22
+ import { AnimatePresence, m } from 'framer-motion'
23
+ import { useState } from 'react'
24
+ import { ceil, isNonNullish, isNullish } from 'remeda'
25
+ import { type DiagramState, useDiagramState, useDiagramStoreApi } from '../../hooks/useDiagramState'
26
+ import { vars } from '../../theme.css'
27
+ import { ElementShapeSvg } from '../../xyflow/nodes/element/ElementShapeSvg'
28
+ import * as css from './NotationPanel.css'
29
+
30
+ const ElementNotation = ({ value }: { value: ElementNotationData }) => {
31
+ const {
32
+ title,
33
+ color = 'primary',
34
+ shape = 'rectangle'
35
+ } = value
36
+ const [onlyKind, setOnlyKind] = useState<ElementKind | null>(null)
37
+ const diagramStore = useDiagramStoreApi()
38
+ const w = 300
39
+ const h = 200
40
+ return (
41
+ <Card
42
+ shadow="none"
43
+ px={'xs'}
44
+ py={'sm'}
45
+ className={css.elementNotation}
46
+ mod={{
47
+ 'data-likec4-color': color
48
+ }}
49
+ onMouseEnter={() => {
50
+ setOnlyKind(null)
51
+ diagramStore.getState().highlightByElementNotation(value)
52
+ }}
53
+ onMouseLeave={() => {
54
+ setOnlyKind(null)
55
+ diagramStore.setState({ dimmed: new Set() })
56
+ }}
57
+ >
58
+ <Group
59
+ gap={'sm'}
60
+ align="stretch"
61
+ wrap="nowrap"
62
+ >
63
+ <Box
64
+ flex={'0 0 70px'}
65
+ style={{
66
+ width: 70,
67
+ height: ceil(70 * (h / w), 0)
68
+ }}>
69
+ <svg
70
+ className={clsx(
71
+ css.shapeSvg
72
+ )}
73
+ viewBox={`0 0 ${w} ${h}`}
74
+ >
75
+ <ElementShapeSvg
76
+ shape={shape}
77
+ w={w}
78
+ h={h}
79
+ />
80
+ </svg>
81
+ </Box>
82
+ <Stack gap={4} flex={1}>
83
+ <Group gap={4} flex={'0 0 auto'}>
84
+ {value.kinds.map((kind) => (
85
+ <Badge
86
+ key={kind}
87
+ className={css.shapeBadge}
88
+ onMouseEnter={() => {
89
+ setOnlyKind(kind)
90
+ diagramStore.getState().highlightByElementNotation(value, kind)
91
+ }}
92
+ onMouseLeave={() => {
93
+ setOnlyKind(null)
94
+ diagramStore.getState().highlightByElementNotation(value)
95
+ }}
96
+ opacity={isNonNullish(onlyKind) && onlyKind !== kind ? 0.25 : 1}
97
+ color={isNonNullish(onlyKind) && onlyKind !== kind ? 'gray' : vars.element.fill}
98
+ >
99
+ {kind}
100
+ </Badge>
101
+ ))}
102
+ </Group>
103
+ <Text
104
+ component="div"
105
+ fz={'sm'}
106
+ fw={500}
107
+ lh="1.25"
108
+ style={{
109
+ textWrap: 'pretty'
110
+ }}>
111
+ {title}
112
+ </Text>
113
+ </Stack>
114
+ </Group>
115
+ </Card>
116
+ )
117
+ }
118
+
119
+ const selector = (s: DiagramState) => ({
120
+ id: s.view.id,
121
+ notations: s.view.notation?.elements ?? [],
122
+ height: s.xystore.getState().height,
123
+ isVisible: isNullish(s.focusedNodeId ?? s.activeDynamicViewStep),
124
+ target: s.getContainer()
125
+ })
126
+
127
+ export function NotationPanel() {
128
+ const {
129
+ id,
130
+ notations,
131
+ isVisible,
132
+ height,
133
+ target
134
+ } = useDiagramState(selector)
135
+ const [isCollapsed, setCollapsed] = useLocalStorage({
136
+ key: 'notation-panel-collapsed',
137
+ defaultValue: true
138
+ })
139
+ const hasNotations = notations.length > 0
140
+ const portalProps = target ? { portalProps: { target } } : { withinPortal: false }
141
+
142
+ return (
143
+ <AnimatePresence>
144
+ {!hasNotations && (
145
+ <m.div
146
+ key={'empty'}
147
+ initial={{ opacity: 0.75, translateX: '50%' }}
148
+ animate={{ opacity: 1, translateX: 0 }}
149
+ exit={{
150
+ translateX: '100%',
151
+ opacity: 0.6
152
+ }}
153
+ className={clsx('react-flow__panel', css.container)}>
154
+ <Tooltip label="Diagram has no notations" color="orange" {...portalProps}>
155
+ <ThemeIcon
156
+ size={'xl'}
157
+ variant="light"
158
+ color="orange"
159
+ radius={'md'}
160
+ >
161
+ <IconAlertTriangle />
162
+ </ThemeIcon>
163
+ </Tooltip>
164
+ </m.div>
165
+ )}
166
+ {hasNotations && isVisible && isCollapsed && (
167
+ <m.div
168
+ key={'collapsed'}
169
+ initial={{ opacity: 0.75, translateX: '50%' }}
170
+ animate={{ opacity: 1, translateX: 0 }}
171
+ exit={{
172
+ translateX: '100%',
173
+ opacity: 0.6
174
+ }}
175
+ className={clsx('react-flow__panel', css.container)}
176
+ >
177
+ <Tooltip label="Show notation" color="dark" fz={'xs'} {...portalProps}>
178
+ <ActionIcon
179
+ size={'xl'}
180
+ variant="light"
181
+ color="gray"
182
+ radius={'md'}
183
+ onClick={() => setCollapsed(false)}
184
+ >
185
+ <IconHelpCircle stroke={1.5} />
186
+ </ActionIcon>
187
+ </Tooltip>
188
+ </m.div>
189
+ )}
190
+
191
+ {hasNotations && isVisible && !isCollapsed && (
192
+ <m.div
193
+ key={id}
194
+ initial={{
195
+ opacity: 0.75,
196
+ // translateX: '50%',
197
+ scale: 0.2
198
+ }}
199
+ animate={{ opacity: 1, scale: 1 }}
200
+ exit={{
201
+ opacity: 0,
202
+ scale: 0.25
203
+ }}
204
+ className={clsx('react-flow__panel', css.container)}
205
+ style={{
206
+ transformOrigin: 'bottom right'
207
+ }}
208
+ >
209
+ <Paper
210
+ radius="sm"
211
+ withBorder
212
+ shadow="lg"
213
+ className={css.card}>
214
+ {/* <Text fz={'sm'} fw={500} c={'dimmed'} ml={'md'}>diagram notation</Text> */}
215
+ <Tabs defaultValue="first" radius={'xs'}>
216
+ <TabsList>
217
+ <ActionIcon
218
+ size={'md'}
219
+ variant="subtle"
220
+ color="gray"
221
+ ml={2}
222
+ style={{
223
+ alignSelf: 'center'
224
+ }}
225
+ onClick={() => setCollapsed(true)}
226
+ >
227
+ <IconArrowDownRight stroke={2} />
228
+ </ActionIcon>
229
+ <TabsTab value="first" fz={'xs'}>Elements</TabsTab>
230
+ <TabsTab value="second" fz={'xs'} disabled>
231
+ Relationships
232
+ </TabsTab>
233
+ </TabsList>
234
+
235
+ <TabsPanel value="first" className={css.tabPanel} hidden={isCollapsed}>
236
+ <ScrollAreaAutosize
237
+ viewportProps={{
238
+ style: {
239
+ maxHeight: height > 50 ? `min(40vh, ${height - 20}px)` : '40vh'
240
+ }
241
+ }}>
242
+ <Stack gap={0}>
243
+ {notations.map((n, i) => <ElementNotation key={i} value={n} />)}
244
+ </Stack>
245
+ </ScrollAreaAutosize>
246
+ </TabsPanel>
247
+ </Tabs>
248
+ </Paper>
249
+ </m.div>
250
+ )}
251
+ </AnimatePresence>
252
+ )
253
+ }
@@ -0,0 +1 @@
1
+ export { NotationPanel as default } from './NotationPanel'
@@ -133,21 +133,20 @@ export const TopLeftPanel = () => {
133
133
  showFitDiagram,
134
134
  showLayoutDriftWarning,
135
135
  showChangeAutoLayout,
136
- portalProps: _portalProps,
136
+ target,
137
137
  showGoToSource
138
138
  } = useDiagramState(s => {
139
139
  const isNotActive = s.activeDynamicViewStep === null && s.focusedNodeId === null
140
- const target = s.getContainer()
141
140
  return ({
142
141
  showNavigationButtons: !!s.onBurgerMenuClick || s.showNavigationButtons && !!s.onNavigateTo,
143
142
  showFitDiagram: s.fitViewEnabled && s.zoomable && s.viewportChanged,
144
143
  showLayoutDriftWarning: s.readonly !== true && s.view.hasLayoutDrift === true && isNotActive,
145
144
  showChangeAutoLayout: s.readonly !== true && !!s.onChange && isNotActive,
146
145
  showGoToSource: !!s.onOpenSourceView,
147
- portalProps: target ? { target } : undefined
146
+ target: s.getContainer()
148
147
  })
149
148
  })
150
- const portalProps = _portalProps ? { portalProps: _portalProps } : undefined
149
+ const portalProps = target ? { portalProps: { target } } : undefined
151
150
 
152
151
  return (
153
152
  <Stack
@@ -1,10 +1,10 @@
1
1
  import { ReactFlow, useOnViewportChange } from '@xyflow/react'
2
2
  import { shallowEqual } from 'fast-equals'
3
- import { type CSSProperties, type PropsWithChildren, useMemo } from 'react'
3
+ import { type CSSProperties, type PropsWithChildren, useMemo, useState } from 'react'
4
4
  import { useDiagramState } from '../hooks/useDiagramState'
5
5
  import { useXYStoreApi } from '../hooks/useXYFlow'
6
6
  import type { DiagramState } from '../state/diagramStore'
7
- import { MinZoom } from './const'
7
+ import { MaxZoom, MinZoom } from './const'
8
8
  import { RelationshipEdge } from './edges/RelationshipEdge'
9
9
  import { CompoundNode } from './nodes/compound'
10
10
  import { ElementNode } from './nodes/element'
@@ -87,6 +87,7 @@ export function XYFlow({
87
87
  translateX,
88
88
  translateY
89
89
  } = useDiagramState(selector, shallowEqual)
90
+ const [zoomOnDoubleClick, setZoomOnDoubleClick] = useState(zoomable)
90
91
 
91
92
  // useLogger('XYFlow',[
92
93
  // nodes,
@@ -110,6 +111,7 @@ export function XYFlow({
110
111
  if (x !== roundedX || y !== roundedY) {
111
112
  xyflowApi.setState({ transform: [roundedX, roundedY, zoom] })
112
113
  }
114
+ setZoomOnDoubleClick(zoomable && zoom < 1.1)
113
115
  }
114
116
  })
115
117
 
@@ -127,7 +129,7 @@ export function XYFlow({
127
129
  {...(!zoomable && {
128
130
  zoomActivationKeyCode: null
129
131
  })}
130
- maxZoom={zoomable ? 1.9 : 1}
132
+ maxZoom={zoomable ? MaxZoom : 1}
131
133
  minZoom={zoomable ? MinZoom : 1}
132
134
  fitView={fitView}
133
135
  fitViewOptions={useMemo(() => ({
@@ -162,7 +164,7 @@ export function XYFlow({
162
164
  onNodeDragStop: layoutConstraints.onNodeDragStop
163
165
  }}
164
166
  nodeDragThreshold={3}
165
- zoomOnDoubleClick={zoomable}
167
+ zoomOnDoubleClick={zoomOnDoubleClick}
166
168
  elevateNodesOnSelect={false} // or edges are not visible after select\
167
169
  selectNodesOnDrag={false}
168
170
  onNodesChange={onNodesChange}
@@ -28,7 +28,7 @@ function literalToEnum(value: XYBackgroundVariant): BackgroundVariant {
28
28
 
29
29
  export function XYFlowBackground({ background }: XYBackgroundProps) {
30
30
  if (typeof background === 'string') {
31
- return <Background variant={literalToEnum(background)} />
31
+ return <Background variant={literalToEnum(background)} size={2} gap={20} />
32
32
  }
33
33
  const { variant, ...rest } = background
34
34
  return <Background variant={literalToEnum(variant)} {...rest} />
@@ -58,7 +58,10 @@ export function useXYFlowEvents() {
58
58
  onDoubleClick: (event) => {
59
59
  const {
60
60
  onCanvasDblClick,
61
+ zoomable,
61
62
  xystore,
63
+ viewportChanged,
64
+ fitDiagram,
62
65
  resetLastClicked
63
66
  } = diagramApi.getState()
64
67
  resetLastClicked()
@@ -66,6 +69,9 @@ export function useXYFlowEvents() {
66
69
  if (!onCanvasDblClick) {
67
70
  event.stopPropagation()
68
71
  }
72
+ if (zoomable && viewportChanged) {
73
+ fitDiagram()
74
+ }
69
75
  onCanvasDblClick?.(event)
70
76
  },
71
77
  onPaneClick: (event) => {
@@ -6,6 +6,7 @@ import { useDiagramState } from '../hooks/useDiagramState'
6
6
  import type { LikeC4DiagramProperties } from '../LikeC4Diagram.props'
7
7
  import DiagramTitlePanel from '../ui/DiagramTitlePanel'
8
8
  import { DynamicViewWalkthrough } from '../ui/DynamicViewWalkthrough'
9
+ import NotationPanel from '../ui/notation'
9
10
  import OptionsPanel from '../ui/OptionsPanel'
10
11
  import { TopLeftPanel } from '../ui/top-left/TopLeftPanel'
11
12
  import { XYFlowBackground } from './XYFlowBackground'
@@ -23,11 +24,13 @@ export const XYFlowInner = memo(function XYFlowInnerR({
23
24
  showDiagramTitle
24
25
  }: XYFlowInnerProps) {
25
26
  const {
27
+ showNotations,
26
28
  isDynamicView,
27
29
  isDynamicViewActive,
28
30
  readonly,
29
31
  enableDynamicViewWalkthrough
30
32
  } = useDiagramState(s => ({
33
+ showNotations: s.showNotations,
31
34
  isDynamicView: s.isDynamicView,
32
35
  readonly: s.readonly,
33
36
  enableDynamicViewWalkthrough: s.enableDynamicViewWalkthrough,
@@ -37,7 +40,6 @@ export const XYFlowInner = memo(function XYFlowInnerR({
37
40
  const isBgWithPattern = background !== 'transparent' && background !== 'solid'
38
41
 
39
42
  const isDiagramTitleVisible = showDiagramTitle === true && !isDynamicViewActive
40
-
41
43
  return (
42
44
  <>
43
45
  {isBgWithPattern && <XYFlowBackground background={background} />}
@@ -46,6 +48,7 @@ export const XYFlowInner = memo(function XYFlowInnerR({
46
48
  {readonly !== true && <OptionsPanel />}
47
49
  {isDiagramTitleVisible && <DiagramTitlePanel />}
48
50
  {isDynamicView && enableDynamicViewWalkthrough && <DynamicViewWalkthrough />}
51
+ {showNotations && <NotationPanel />}
49
52
  {children}
50
53
  </>
51
54
  )
@@ -5,3 +5,4 @@ export const ZIndexes = {
5
5
  } as const
6
6
 
7
7
  export const MinZoom = 0.1
8
+ export const MaxZoom = 2
@@ -221,7 +221,7 @@ export const edgeLabel = style({
221
221
  })
222
222
 
223
223
  export const edgeLabelText = style({
224
- textAlign: 'left',
224
+ textAlign: 'center',
225
225
  whiteSpaceCollapse: 'preserve-breaks',
226
226
  fontSize: rem(14),
227
227
  lineHeight: 1.185
@@ -69,13 +69,13 @@ export function ElementLink({
69
69
  p={'xs'}>
70
70
  <Stack gap={'xs'}>
71
71
  {element.links.map((link) => (
72
- <Group key={link} wrap="nowrap" gap={'sm'}>
72
+ <Group key={link.url} wrap="nowrap" gap={'sm'}>
73
73
  <Box flex={'1'} style={{ overflow: 'clip', maxWidth: clamp(element.width, { min: 200, max: 400 }) }}>
74
- <Anchor href={link} target="_blank" fz="13" truncate="end">
75
- {link}
74
+ <Anchor href={link.url} target="_blank" fz="13" truncate="end">
75
+ {link.title || link.url}
76
76
  </Anchor>
77
77
  </Box>
78
- <CopyButton value={link}>
78
+ <CopyButton value={link.url}>
79
79
  {({ copied, copy }) => (
80
80
  <Button
81
81
  size="compact-xs"
@@ -41,8 +41,8 @@ export const dimmed = style({})
41
41
 
42
42
  globalStyle(`.react-flow__node-element:has(${dimmed})`, {
43
43
  opacity: 0.25,
44
- transition: 'opacity 600ms ease-in-out, filter 600ms ease-in-out',
45
- transitionDelay: '200ms',
44
+ transition: 'opacity 400ms ease-in-out, filter 500ms ease-in-out',
45
+ transitionDelay: '50ms',
46
46
  filter: `grayscale(0.85) ${fallbackVar(vars.safariAnimationHook, 'blur(2px)')}`,
47
47
  willChange: 'opacity, filter'
48
48
  })
@@ -19,6 +19,7 @@ export function NavigateToBtn({ xynodeId, className }: NavigateToBtnProps) {
19
19
  event.stopPropagation()
20
20
  diagramApi.getState().triggerOnNavigateTo(xynodeId, event)
21
21
  }}
22
+ role="button"
22
23
  onDoubleClick={event => event.stopPropagation()}
23
24
  >
24
25
  <IconZoomScan