likec4 0.51.0 → 0.53.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,174 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { isEqualSimple } from "@react-hookz/deep-equal/esnext";
3
+ import { useToggle } from "@react-hookz/web/esm";
4
+ import { useSpring } from "@react-spring/konva";
5
+ import { lighten, mix, toHex } from "khroma";
6
+ import { memo } from "react";
7
+ import { AnimatedCircle, AnimatedGroup, Group } from "../../konva.js";
8
+ import { ZoomInIcon } from "../icons/index.js";
9
+ import { DiagramGesture } from "../state/index.js";
10
+ import { mouseDefault, mousePointer } from "../utils.js";
11
+ export const NodeZoomBtn = memo(({ animate, node, theme, isHovered: _isHovered, onNodeClick }) => {
12
+ const size = 30;
13
+ const halfSize = size / 2;
14
+ const colors = theme.elements[node.color];
15
+ let zoomInIconY;
16
+ switch (node.shape) {
17
+ case "browser":
18
+ case "mobile":
19
+ zoomInIconY = node.size.height - 20;
20
+ break;
21
+ default:
22
+ zoomInIconY = node.size.height - 16;
23
+ }
24
+ const fill = toHex(mix(colors.fill, colors.stroke, 65));
25
+ const onOver = toHex(mix(colors.fill, colors.stroke, 75));
26
+ const [isOver, toggleOver] = useToggle(false);
27
+ const isHovered = _isHovered || isOver;
28
+ const props = useSpring({
29
+ to: {
30
+ fill: isOver ? onOver : fill,
31
+ opacity: isOver ? 1 : 0,
32
+ y: zoomInIconY + (isOver ? 2 : 0),
33
+ scale: isOver ? 1.38 : 1,
34
+ // shadowBlur: isOver ? 6 : 4,
35
+ shadowOpacity: isOver ? 0.3 : 0.15
36
+ // shadowOffsetY: isOver ? 8 : 6
37
+ },
38
+ delay: isHovered && !isOver ? 100 : 0,
39
+ immediate: !animate
40
+ });
41
+ return /* @__PURE__ */ jsxs(
42
+ AnimatedGroup,
43
+ {
44
+ x: node.size.width / 2,
45
+ y: props.y,
46
+ offsetX: halfSize,
47
+ offsetY: halfSize,
48
+ scaleX: props.scale,
49
+ scaleY: props.scale,
50
+ width: size,
51
+ height: size,
52
+ onPointerEnter: (e) => {
53
+ toggleOver(true);
54
+ mousePointer(e);
55
+ },
56
+ onPointerLeave: (e) => {
57
+ toggleOver(false);
58
+ mouseDefault(e);
59
+ },
60
+ onPointerClick: (e) => {
61
+ if (DiagramGesture.isDragging || e.evt.button !== 0) {
62
+ return;
63
+ }
64
+ e.cancelBubble = true;
65
+ onNodeClick(node, e);
66
+ },
67
+ children: [
68
+ /* @__PURE__ */ jsx(
69
+ AnimatedCircle,
70
+ {
71
+ x: halfSize,
72
+ y: halfSize,
73
+ radius: halfSize,
74
+ fill: props.fill,
75
+ shadowBlur: 4,
76
+ shadowOpacity: props.shadowOpacity,
77
+ shadowOffsetX: 2,
78
+ shadowOffsetY: 6,
79
+ shadowColor: theme.shadow,
80
+ shadowEnabled: isHovered,
81
+ perfectDrawEnabled: false,
82
+ opacity: props.opacity,
83
+ hitStrokeWidth: halfSize
84
+ }
85
+ ),
86
+ /* @__PURE__ */ jsx(ZoomInIcon, { size: 16, x: halfSize, y: halfSize })
87
+ ]
88
+ }
89
+ );
90
+ }, isEqualSimple);
91
+ NodeZoomBtn.displayName = "NodeZoomBtn";
92
+ export const CompoundZoomBtn = memo(({
93
+ animate,
94
+ node,
95
+ theme,
96
+ ctrl,
97
+ isHovered: _isHovered,
98
+ onNodeClick
99
+ }) => {
100
+ const size = 28;
101
+ const [isOver, toggleOver] = useToggle(false);
102
+ const halfSize = size / 2;
103
+ const fill = toHex(lighten(ctrl.springs.fill.get(), 10));
104
+ const isHovered = _isHovered || isOver;
105
+ const props = useSpring({
106
+ to: {
107
+ opacity: isOver ? 1 : 0,
108
+ x: halfSize + 4 - (isOver ? 4 : 0),
109
+ y: halfSize + 6 - (isOver ? 4 : 0),
110
+ scale: isOver ? 1.35 : 1,
111
+ // shadowBlur: isOver ? 6 : 4,
112
+ shadowOpacity: isOver ? 0.3 : 0.15
113
+ // shadowOffsetY: isOver ? 8 : 6
114
+ },
115
+ delay: isHovered && !isOver ? 100 : 0,
116
+ // delay: isOver ? 150 : (isHovered ? 70 : 0),
117
+ immediate: !animate
118
+ });
119
+ return /* @__PURE__ */ jsx(
120
+ Group,
121
+ {
122
+ onPointerEnter: (e) => {
123
+ toggleOver(true);
124
+ mousePointer(e);
125
+ },
126
+ onPointerLeave: (e) => {
127
+ toggleOver(false);
128
+ mouseDefault(e);
129
+ },
130
+ onPointerClick: (e) => {
131
+ if (DiagramGesture.isDragging || e.evt.button !== 0) {
132
+ return;
133
+ }
134
+ e.cancelBubble = true;
135
+ onNodeClick(node, e);
136
+ },
137
+ children: /* @__PURE__ */ jsxs(
138
+ AnimatedGroup,
139
+ {
140
+ x: props.x,
141
+ y: props.y,
142
+ offsetX: halfSize,
143
+ offsetY: halfSize,
144
+ scaleX: props.scale,
145
+ scaleY: props.scale,
146
+ width: size,
147
+ height: size,
148
+ children: [
149
+ /* @__PURE__ */ jsx(
150
+ AnimatedCircle,
151
+ {
152
+ x: halfSize,
153
+ y: halfSize,
154
+ radius: halfSize,
155
+ fill,
156
+ shadowBlur: 4,
157
+ shadowOpacity: props.shadowOpacity,
158
+ shadowOffsetX: 2,
159
+ shadowOffsetY: 6,
160
+ shadowColor: theme.shadow,
161
+ shadowEnabled: isHovered,
162
+ perfectDrawEnabled: false,
163
+ opacity: props.opacity,
164
+ hitStrokeWidth: halfSize
165
+ }
166
+ ),
167
+ /* @__PURE__ */ jsx(ZoomInIcon, { size: 16, x: halfSize, y: halfSize })
168
+ ]
169
+ }
170
+ )
171
+ }
172
+ );
173
+ }, isEqualSimple);
174
+ CompoundZoomBtn.displayName = "CompoundZoomBtn";
@@ -0,0 +1,2 @@
1
+ export * from "./NodeLinkBtn.js";
2
+ export * from "./NodeZoomBtn.js";
@@ -9,9 +9,9 @@ export function CompoundShape({ node, theme, springs, labelOffsetX = 0 }) {
9
9
  cornerRadius: 4,
10
10
  shadowColor: theme.shadow,
11
11
  shadowBlur: node.level > 0 ? 20 : 10,
12
- shadowOpacity: node.level > 0 ? 0.35 : 0.8,
12
+ shadowOpacity: node.level > 0 ? 0.35 : 0.6,
13
13
  shadowOffsetX: 0,
14
- shadowOffsetY: 4,
14
+ shadowOffsetY: 5,
15
15
  shadowEnabled: springs.opacity.to((v) => v > 0.7),
16
16
  width: springs.width,
17
17
  height: springs.height,
@@ -35,14 +35,15 @@ function EdgeLabelBg({
35
35
  isHovered,
36
36
  springs
37
37
  }) {
38
- const padding = 2;
38
+ const paddingX = 2;
39
+ const paddingY = 1;
39
40
  const props = useSpring({
40
41
  to: {
41
- x: labelBBox.x - padding,
42
- y: labelBBox.y - padding,
43
- width: labelBBox.width + padding * 2,
44
- height: labelBBox.height + padding * 2,
45
- opacity: isHovered ? 0.75 : 0.65
42
+ x: labelBBox.x - paddingX,
43
+ y: labelBBox.y - paddingY,
44
+ width: labelBBox.width + paddingX * 2,
45
+ height: labelBBox.height + paddingY * 2,
46
+ opacity: isHovered ? 0.8 : 0.55
46
47
  },
47
48
  immediate: !animate
48
49
  });
@@ -42,10 +42,10 @@ export const useNodeSpringsFn = (theme) => {
42
42
  export const useShadowSprings = (isHovered = false, theme, springs) => {
43
43
  const [values] = useSpring(
44
44
  {
45
- shadowBlur: isHovered ? 30 : 12,
46
- shadowOpacity: isHovered ? 0.5 : 0.35,
45
+ shadowBlur: isHovered ? 30 : 10,
46
+ shadowOpacity: isHovered ? 0.5 : 0.3,
47
47
  shadowOffsetX: 0,
48
- shadowOffsetY: isHovered ? 16 : 4,
48
+ shadowOffsetY: isHovered ? 16 : 5,
49
49
  shadowColor: theme.shadow
50
50
  },
51
51
  [isHovered, theme]
@@ -24,7 +24,7 @@ export const hoveredNodeAtom = atom(
24
24
  if (equals(_prev, _next)) {
25
25
  return false;
26
26
  }
27
- const timeout = !!_next && !!_prev ? 120 : 175;
27
+ const timeout = !!_next && !!_prev ? 50 : 120;
28
28
  if (_next != null) {
29
29
  clearTimeout(get(edgeTimeoutAtom));
30
30
  scheduleHoveredEdge(set, null, timeout);
@@ -57,9 +57,8 @@ export const hoveredEdgeAtom = atom(
57
57
  if (equals(_prev, _next)) {
58
58
  return false;
59
59
  }
60
- let timeout = 175;
60
+ const timeout = !!_next && !!_prev ? 50 : 120;
61
61
  if (_next != null) {
62
- timeout = _prev != null ? 120 : 300;
63
62
  clearTimeout(get(nodeTimeoutAtom));
64
63
  scheduleHoveredNode(set, null, timeout);
65
64
  }
@@ -12,3 +12,36 @@ export function mouseDefault(e) {
12
12
  }
13
13
  }
14
14
  export const isNumber = is(Number);
15
+ export const getBoundingRect = (elements) => {
16
+ let minX = Infinity;
17
+ let minY = Infinity;
18
+ let maxX = -Infinity;
19
+ let maxY = -Infinity;
20
+ for (const element of elements) {
21
+ if ("size" in element && "position" in element) {
22
+ minX = Math.min(minX, element.position[0]);
23
+ minY = Math.min(minY, element.position[1]);
24
+ maxX = Math.max(maxX, element.position[0] + element.size.width);
25
+ maxY = Math.max(maxY, element.position[1] + element.size.height);
26
+ continue;
27
+ }
28
+ element.points.forEach(([x, y]) => {
29
+ minX = Math.min(minX, x);
30
+ minY = Math.min(minY, y);
31
+ maxX = Math.max(maxX, x);
32
+ maxY = Math.max(maxY, y);
33
+ });
34
+ if (element.labelBBox) {
35
+ minX = Math.min(minX, element.labelBBox.x);
36
+ minY = Math.min(minY, element.labelBBox.y);
37
+ maxX = Math.max(maxX, element.labelBBox.x + element.labelBBox.width);
38
+ maxY = Math.max(maxY, element.labelBBox.y + element.labelBBox.height);
39
+ }
40
+ }
41
+ return {
42
+ x: minX,
43
+ y: minY,
44
+ width: maxX - minX,
45
+ height: maxY - minY
46
+ };
47
+ };
@@ -9,29 +9,22 @@ import { useRoute } from './router';
9
9
  const Routes = () => {
10
10
  const r = useDeferredValue(useRoute());
11
11
  const theme = r.params?.theme;
12
- let page = null;
13
- switch (r.route) {
14
- case 'view': {
15
- page = (<ViewPage key='view' viewId={r.params.viewId} viewMode={r.params.mode} showUI={r.showUI}/>);
16
- break;
12
+ const page = () => {
13
+ switch (r.route) {
14
+ case 'view':
15
+ return <ViewPage viewId={r.params.viewId} viewMode={r.params.mode} showUI={r.showUI}/>;
16
+ case 'export':
17
+ return <ExportPage viewId={r.params.viewId} padding={r.params.padding}/>;
18
+ case 'embed':
19
+ return (<EmbedPage viewId={r.params.viewId} padding={r.params.padding} transparentBg={isNil(r.params.theme)}/>);
20
+ case 'index':
21
+ return <IndexPage />;
22
+ default:
23
+ nonexhaustive(r);
17
24
  }
18
- case 'export': {
19
- page = <ExportPage key='export' viewId={r.params.viewId} padding={r.params.padding}/>;
20
- break;
21
- }
22
- case 'embed': {
23
- page = (<EmbedPage key='embed' viewId={r.params.viewId} padding={r.params.padding} transparentBg={isNil(r.params.theme)}/>);
24
- break;
25
- }
26
- case 'index': {
27
- page = <IndexPage key='index'/>;
28
- break;
29
- }
30
- default:
31
- nonexhaustive(r);
32
- }
25
+ };
33
26
  return (<Theme hasBackground={!!theme} accentColor='indigo' radius='small' appearance={theme ?? 'inherit'}>
34
- {page}
27
+ {page()}
35
28
  <Fragment key='ui'>{r.showUI && <Sidebar />}</Fragment>
36
29
  </Theme>);
37
30
  };
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { lazy } from 'react';
3
3
  export * from './ViewAsReact';
4
- export const ViewAs = lazy(() => import('./other-formats'));
4
+ export const ViewAs = lazy(async () => await import('./other-formats'));
5
5
  // export const ViewAs = {
6
6
  // Dot: ViewAsDot,
7
7
  // D2: ViewAsD2,
@@ -1,18 +1,57 @@
1
- import { Box, Code, ScrollArea } from '@radix-ui/themes';
1
+ import { Box, Button, Code, ScrollArea } from '@radix-ui/themes';
2
+ import { useAsync } from '@react-hookz/web/esm';
2
3
  import { d2Source } from 'virtual:likec4/d2-sources';
3
4
  import { CopyToClipboard } from '../../../components';
5
+ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
6
+ const fetchFromKroki = async (d2) => {
7
+ const res = await fetch('https://kroki.io/d2/svg', {
8
+ method: 'POST',
9
+ cache: 'force-cache',
10
+ body: JSON.stringify({
11
+ diagram_source: d2,
12
+ // diagram_options: {
13
+ // theme: 'colorblind-clear'
14
+ // },
15
+ output_format: 'svg'
16
+ }),
17
+ headers: {
18
+ 'Content-Type': 'application/json'
19
+ }
20
+ });
21
+ return await res.text();
22
+ };
4
23
  export default function ViewAsD2({ viewId }) {
5
24
  const src = d2Source(viewId);
6
- return (<>
7
- <ScrollArea scrollbars='both'>
8
- <Box asChild display={'block'} p='2' style={{
9
- whiteSpace: 'pre'
25
+ const [krokiSvg, { execute }] = useAsync(fetchFromKroki, null);
26
+ return (<PanelGroup direction='horizontal' autoSaveId='ViewAsD2'>
27
+ <Panel minSizePixels={100}>
28
+ <ScrollArea scrollbars='both'>
29
+ <Box asChild display={'block'} p='2' style={{
30
+ whiteSpace: 'pre',
31
+ minHeight: '100%'
10
32
  }}>
11
- <Code variant='soft' autoFocus>
12
- {src}
13
- </Code>
14
- </Box>
15
- </ScrollArea>
16
- <CopyToClipboard text={src}/>
17
- </>);
33
+ <Code variant='soft' autoFocus>
34
+ {src}
35
+ </Code>
36
+ </Box>
37
+ <CopyToClipboard text={src}/>
38
+ </ScrollArea>
39
+ </Panel>
40
+ <PanelResizeHandle style={{
41
+ width: 10
42
+ }}/>
43
+ <Panel minSizePixels={100}>
44
+ <ScrollArea scrollbars='both'>
45
+ {krokiSvg.status !== 'success' && (<>
46
+ <Button disabled={krokiSvg.status === 'loading'} onClick={() => void execute(src)}>
47
+ {krokiSvg.status === 'loading' ? 'Loading...' : 'Render with Kroki'}
48
+ </Button>
49
+ {krokiSvg.status === 'error' && <Box>{krokiSvg.error?.message}</Box>}
50
+ </>)}
51
+ {krokiSvg.status === 'success' && (<Box grow={'1'} asChild className={'svg-container'}>
52
+ {!krokiSvg.result ? (<Box>Empty result</Box>) : (<div dangerouslySetInnerHTML={{ __html: krokiSvg.result }}></div>)}
53
+ </Box>)}
54
+ </ScrollArea>
55
+ </Panel>
56
+ </PanelGroup>);
18
57
  }
@@ -1,33 +1,37 @@
1
- import { Box, Code, Grid, ScrollArea } from '@radix-ui/themes';
1
+ import { Box, Code, ScrollArea } from '@radix-ui/themes';
2
2
  import { dotSource, svgSource } from 'virtual:likec4/dot-sources';
3
- import styles from '../view-page.module.css';
4
3
  import { CopyToClipboard } from '../../../components';
4
+ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
5
5
  export default function ViewAsDot({ viewId }) {
6
6
  const dot = dotSource(viewId);
7
- return (<Grid
8
- //@ts-expect-error TODO: fails on columns prop due to `exactOptionalPropertyTypes: true` in tsconfig
9
- columns='2' gap='2' shrink='1' grow='1'>
10
- <Box py={'2'} position={'relative'} style={{
11
- overflow: 'scroll'
12
- }}>
7
+ return (<PanelGroup direction='horizontal' autoSaveId='viewAsDot'>
8
+ <Panel minSizePixels={100}>
13
9
  <ScrollArea scrollbars='both'>
14
10
  <Box asChild display={'block'} p='2' style={{
15
- whiteSpace: 'pre'
11
+ whiteSpace: 'pre',
12
+ minHeight: '100%'
16
13
  }}>
17
14
  <Code variant='soft' autoFocus>
18
15
  {dot}
19
16
  </Code>
20
17
  </Box>
18
+ <CopyToClipboard text={dot}/>
21
19
  </ScrollArea>
22
- <CopyToClipboard text={dot}/>
23
- </Box>
24
- <Box py={'2'} style={{
20
+ </Panel>
21
+ <PanelResizeHandle style={{
22
+ width: 10
23
+ }}/>
24
+ <Panel minSizePixels={100}>
25
+ <ScrollArea scrollbars='both'>
26
+ <Box py={'2'} style={{
25
27
  overflow: 'scroll',
26
28
  overscrollBehavior: 'none'
27
29
  }}>
28
- <Box asChild position={'relative'} className={styles.dotSvg}>
29
- <div dangerouslySetInnerHTML={{ __html: svgSource(viewId) }}></div>
30
- </Box>
31
- </Box>
32
- </Grid>);
30
+ <Box asChild position={'relative'} className={'svg-container'}>
31
+ <div dangerouslySetInnerHTML={{ __html: svgSource(viewId) }}></div>
32
+ </Box>
33
+ </Box>
34
+ </ScrollArea>
35
+ </Panel>
36
+ </PanelGroup>);
33
37
  }
@@ -1,18 +1,46 @@
1
1
  import { Box, Code, ScrollArea } from '@radix-ui/themes';
2
+ import { useAsync } from '@react-hookz/web/esm';
3
+ import { useEffect } from 'react';
2
4
  import { mmdSource } from 'virtual:likec4/mmd-sources';
3
5
  import { CopyToClipboard } from '../../../components';
6
+ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
7
+ const renderSvg = async (viewId, diagram) => {
8
+ const { default: mermaid } = await import('https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs');
9
+ mermaid.initialize({
10
+ theme: 'dark'
11
+ });
12
+ const { svg } = await mermaid.render(viewId, diagram);
13
+ return svg;
14
+ };
4
15
  export default function ViewAsMmd({ viewId }) {
5
16
  const src = mmdSource(viewId);
6
- return (<>
7
- <ScrollArea scrollbars='both'>
8
- <Box asChild display={'block'} p='2' style={{
9
- whiteSpace: 'pre'
17
+ const [mmdSvg, { execute }] = useAsync(renderSvg, null);
18
+ useEffect(() => {
19
+ void execute(viewId, src);
20
+ }, [src]);
21
+ return (<PanelGroup direction='horizontal' autoSaveId='ViewAsD2'>
22
+ <Panel minSizePixels={100}>
23
+ <ScrollArea scrollbars='both'>
24
+ <Box asChild display={'block'} p='2' style={{
25
+ whiteSpace: 'pre',
26
+ minHeight: '100%'
10
27
  }}>
11
- <Code variant='soft' autoFocus>
12
- {src}
13
- </Code>
14
- </Box>
15
- </ScrollArea>
16
- <CopyToClipboard text={src}/>
17
- </>);
28
+ <Code variant='soft' autoFocus>
29
+ {src}
30
+ </Code>
31
+ </Box>
32
+ <CopyToClipboard text={src}/>
33
+ </ScrollArea>
34
+ </Panel>
35
+ <PanelResizeHandle style={{
36
+ width: 10
37
+ }}/>
38
+ <Panel minSizePixels={100}>
39
+ <ScrollArea scrollbars='both'>
40
+ {mmdSvg.result && (<Box grow={'1'} asChild position={'relative'} className={'svg-container'}>
41
+ <div dangerouslySetInnerHTML={{ __html: mmdSvg.result }}></div>
42
+ </Box>)}
43
+ </ScrollArea>
44
+ </Panel>
45
+ </PanelGroup>);
18
46
  }
@@ -9,6 +9,7 @@ export default function ViewDiagramInOtherFormats({ viewId, viewMode }) {
9
9
  <Tabs.Root value={viewMode} onValueChange={mode => mode !== viewMode && updateSearchParams({ mode: mode })}>
10
10
  <Box asChild shrink={'0'} grow={'0'}>
11
11
  <Tabs.List>
12
+ <Tabs.Trigger value='react'>React</Tabs.Trigger>
12
13
  <Tabs.Trigger value='dot'>Graphviz</Tabs.Trigger>
13
14
  <Tabs.Trigger value='mmd'>Mermaid</Tabs.Trigger>
14
15
  <Tabs.Trigger value='d2'>D2</Tabs.Trigger>
@@ -16,6 +17,8 @@ export default function ViewDiagramInOtherFormats({ viewId, viewMode }) {
16
17
  </Box>
17
18
 
18
19
  <Box p='2' className={styles.otherFormats} position={'relative'}>
20
+ <Tabs.Content value='react'>{''}</Tabs.Content>
21
+
19
22
  <Tabs.Content value='dot'>
20
23
  <ViewAsDot viewId={viewId}/>
21
24
  </Tabs.Content>
@@ -56,7 +56,8 @@
56
56
  }
57
57
  } */
58
58
 
59
- .dotSvg {
59
+ :global(.svg-container) {
60
+ min-width: 300px;
60
61
  & > svg {
61
62
  width: 100%;
62
63
  height: auto;
@@ -67,7 +68,7 @@
67
68
  display: flex;
68
69
  align-items: stretch;
69
70
  flex: 1 1 auto;
70
- overflow: scroll;
71
+ overflow: auto;
71
72
 
72
73
  & > :global(.rt-TabsContent) {
73
74
  display: flex;
@@ -77,5 +78,9 @@
77
78
  &[hidden] {
78
79
  display: none;
79
80
  }
81
+
82
+ & :global(.rt-ScrollAreaViewport) > div {
83
+ height: inherit;
84
+ }
80
85
  }
81
86
  }