@djangocfg/widget-diagram 0.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.
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/package.json +77 -0
- package/src/FloatingToolbar/FloatingToolbar.css +5 -0
- package/src/FloatingToolbar/actions/CopyAction.tsx +31 -0
- package/src/FloatingToolbar/actions/DownloadAction.tsx +51 -0
- package/src/FloatingToolbar/actions/ExpandAction.tsx +33 -0
- package/src/FloatingToolbar/actions/FullscreenAction.tsx +38 -0
- package/src/FloatingToolbar/actions/index.ts +4 -0
- package/src/FloatingToolbar/hooks/useScrollIsolation.ts +62 -0
- package/src/FloatingToolbar/index.tsx +184 -0
- package/src/Mermaid.client.tsx +97 -0
- package/src/builders/FlowDiagram/FlowDiagram.ts +96 -0
- package/src/builders/FlowDiagram/functions/getEdges.ts +50 -0
- package/src/builders/FlowDiagram/functions/getNodes.ts +43 -0
- package/src/builders/FlowDiagram/functions/getStyles.ts +90 -0
- package/src/builders/FlowDiagram/functions/index.ts +8 -0
- package/src/builders/FlowDiagram/index.ts +16 -0
- package/src/builders/FlowDiagram/types.ts +130 -0
- package/src/builders/JourneyDiagram/JourneyDiagram.ts +88 -0
- package/src/builders/JourneyDiagram/index.ts +12 -0
- package/src/builders/JourneyDiagram/types.ts +48 -0
- package/src/builders/SequenceDiagram/SequenceDiagram.ts +158 -0
- package/src/builders/SequenceDiagram/functions/getActivations.ts +30 -0
- package/src/builders/SequenceDiagram/functions/getBlocks.ts +112 -0
- package/src/builders/SequenceDiagram/functions/getMessages.ts +85 -0
- package/src/builders/SequenceDiagram/functions/getNotes.ts +94 -0
- package/src/builders/SequenceDiagram/functions/index.ts +16 -0
- package/src/builders/SequenceDiagram/index.ts +18 -0
- package/src/builders/SequenceDiagram/types.ts +192 -0
- package/src/builders/core/DiagramStore.ts +138 -0
- package/src/builders/core/index.ts +8 -0
- package/src/builders/core/sanitize.ts +83 -0
- package/src/builders/core/theme.ts +42 -0
- package/src/builders/core/types.ts +183 -0
- package/src/builders/index.ts +96 -0
- package/src/components/MermaidCodeViewer.tsx +95 -0
- package/src/components/MermaidErrorPanel.tsx +31 -0
- package/src/components/MermaidFullscreenModal.tsx +201 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useMermaidCleanup.ts +70 -0
- package/src/hooks/useMermaidFullscreen.ts +46 -0
- package/src/hooks/useMermaidRenderer.ts +329 -0
- package/src/hooks/useMermaidValidation.ts +97 -0
- package/src/index.tsx +79 -0
- package/src/lazy.tsx +40 -0
- package/src/mermaid.stories.tsx +217 -0
- package/src/types.ts +28 -0
- package/src/utils/mermaid-helpers.ts +157 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Reforms.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# @djangocfg/widget-diagram
|
|
2
|
+
|
|
3
|
+
Mermaid diagrams, both halves: typed builders that WRITE the source, and a
|
|
4
|
+
full-parser renderer that DRAWS it. Supports the diagram types covered by the builders (`FlowDiagram`,
|
|
5
|
+
`SequenceDiagram`, `JourneyDiagram`) plus anything else Mermaid parses (gantt,
|
|
6
|
+
class, state, ER, pie, mindmap, …) via the `chart` prop. Click-to-fullscreen
|
|
7
|
+
via the floating toolbar.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @djangocfg/widget-diagram @djangocfg/ui-core
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Which renderer
|
|
16
|
+
|
|
17
|
+
`@djangocfg/widget-media/diagram` is the OTHER one, and it is not a duplicate.
|
|
18
|
+
|
|
19
|
+
| | `widget-diagram` | `widget-media/diagram` |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Parser | `mermaid`, ~84 MB unpacked | `beautiful-mermaid`, ~2 MB |
|
|
22
|
+
| Covers | everything mermaid parses | flowchart, sequence, state, class, ER — source otherwise |
|
|
23
|
+
| For | a surface whose job IS the diagram | a chat transcript, where most messages have none |
|
|
24
|
+
|
|
25
|
+
Do not import both into one app.
|
|
26
|
+
|
|
27
|
+
## Builders without a renderer
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { FlowDiagram } from '@djangocfg/widget-diagram/builders';
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The `./builders` subpath holds no static edge to the parser, so a server pass,
|
|
34
|
+
a test or a CLI can write diagram source without resolving a renderer.
|
|
35
|
+
`src/entries.test.ts` walks the graph and holds that.
|
|
36
|
+
|
|
37
|
+
## Verify
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pnpm --filter @djangocfg/widget-diagram check-types test
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import Mermaid from '@djangocfg/widget-diagram';
|
|
45
|
+
|
|
46
|
+
<Mermaid chart={`
|
|
47
|
+
flowchart LR
|
|
48
|
+
A[Client] --> B[API] --> C[(DB)]
|
|
49
|
+
`} />
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Builder API:
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
import Mermaid, { FlowDiagram } from '@djangocfg/widget-diagram';
|
|
56
|
+
|
|
57
|
+
const chart = new FlowDiagram({ direction: 'LR' })
|
|
58
|
+
.node('a', 'Client')
|
|
59
|
+
.node('b', 'API')
|
|
60
|
+
.edge('a', 'b')
|
|
61
|
+
.build();
|
|
62
|
+
|
|
63
|
+
<Mermaid chart={chart} />
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Props
|
|
67
|
+
|
|
68
|
+
| Prop | Type | Default | Description |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
| `chart` | `string` | — | Mermaid source. |
|
|
71
|
+
| `isCompact` | `boolean` | `false` | Tighter padding for embedded contexts. |
|
|
72
|
+
| `fullscreen` | `boolean` | `true` | Click-to-fullscreen via the floating toolbar. |
|
|
73
|
+
| `scrollIsolation` | `boolean` | `false` | Lock page wheel while hovering. Off by default — Mermaid SVGs don't scroll, the overlay just steals wheel events. |
|
|
74
|
+
| `debounceMs` | `number` | `300` | Re-render debounce after `chart` changes. Lower for static diagrams, higher while streaming. |
|
|
75
|
+
| `className` | `string` | — | — |
|
|
76
|
+
|
|
77
|
+
## Notes
|
|
78
|
+
|
|
79
|
+
- **~800KB bundle** — entry point is already `lazy()`-wrapped with a `Suspense` spinner, so the cost is only paid when a diagram is actually mounted.
|
|
80
|
+
- Theming pulls from `useThemePalette` / `useStylePresets` / `useBoxColors` (exported alongside the builders) — Mermaid colors follow the active app theme; do not hard-code hex.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
Adapted from jalcoui (MIT).
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@djangocfg/widget-diagram",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Mermaid diagrams: typed builders that write the source, and a full-parser renderer that draws it",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mermaid",
|
|
7
|
+
"diagram",
|
|
8
|
+
"flowchart",
|
|
9
|
+
"sequence-diagram",
|
|
10
|
+
"builder",
|
|
11
|
+
"typescript"
|
|
12
|
+
],
|
|
13
|
+
"author": {
|
|
14
|
+
"name": "DjangoCFG",
|
|
15
|
+
"url": "https://djangocfg.com"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://djangocfg.com",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/markolofsen/django-cfg.git",
|
|
21
|
+
"directory": "widgets/diagram"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./src/index.tsx",
|
|
26
|
+
"types": "./src/index.tsx",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./src/index.tsx",
|
|
30
|
+
"default": "./src/index.tsx"
|
|
31
|
+
},
|
|
32
|
+
"./builders": {
|
|
33
|
+
"types": "./src/builders/index.ts",
|
|
34
|
+
"default": "./src/builders/index.ts"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"!src/**/*.test.ts",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"check": "tsc --noEmit",
|
|
45
|
+
"check-types": "tsc --noEmit",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"test:watch": "vitest"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@djangocfg/ui-core": "^2.1.542",
|
|
51
|
+
"lucide-react": "^0.545.0",
|
|
52
|
+
"react": "^19.0.0",
|
|
53
|
+
"react-dom": "^19.0.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@djangocfg/typescript-config": "^2.1.542",
|
|
57
|
+
"@djangocfg/ui-core": "^2.1.542",
|
|
58
|
+
"@storybook/react-vite": "^10.5.0",
|
|
59
|
+
"@types/node": "^25.9.5",
|
|
60
|
+
"@types/react": "19.2.15",
|
|
61
|
+
"@types/react-dom": "19.2.3",
|
|
62
|
+
"lucide-react": "0.545.0",
|
|
63
|
+
"react": "19.2.7",
|
|
64
|
+
"react-dom": "19.2.7",
|
|
65
|
+
"storybook": "^10.5.0",
|
|
66
|
+
"typescript": "^5.9.3",
|
|
67
|
+
"vitest": "^4.1.10"
|
|
68
|
+
},
|
|
69
|
+
"publishConfig": {
|
|
70
|
+
"access": "public"
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"@djangocfg/widget-kit": "^0.1.1",
|
|
74
|
+
"mermaid": "^11.16.0",
|
|
75
|
+
"react-zoom-pan-pinch": "^3.7.0"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import {
|
|
5
|
+
CopyButton,
|
|
6
|
+
Tooltip,
|
|
7
|
+
TooltipContent,
|
|
8
|
+
TooltipTrigger,
|
|
9
|
+
} from '@djangocfg/ui-core/components';
|
|
10
|
+
|
|
11
|
+
interface CopyActionProps {
|
|
12
|
+
value: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const BUTTON_CLASS = 'h-6 w-6 rounded-sm bg-muted/80 hover:bg-muted border border-border/50 backdrop-blur-sm';
|
|
17
|
+
|
|
18
|
+
export const CopyAction: React.FC<CopyActionProps> = ({ value, title = 'Copy' }) => (
|
|
19
|
+
<Tooltip>
|
|
20
|
+
<TooltipTrigger asChild>
|
|
21
|
+
<CopyButton
|
|
22
|
+
value={value}
|
|
23
|
+
variant="ghost"
|
|
24
|
+
size="icon"
|
|
25
|
+
className={BUTTON_CLASS}
|
|
26
|
+
iconClassName="h-3 w-3"
|
|
27
|
+
/>
|
|
28
|
+
</TooltipTrigger>
|
|
29
|
+
<TooltipContent side="top">{title}</TooltipContent>
|
|
30
|
+
</Tooltip>
|
|
31
|
+
);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Download } from 'lucide-react';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@djangocfg/ui-core/components';
|
|
7
|
+
|
|
8
|
+
interface DownloadActionProps {
|
|
9
|
+
value: string;
|
|
10
|
+
filename?: string;
|
|
11
|
+
mimeType?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BUTTON_CLASS = 'h-6 w-6 rounded-sm bg-muted/80 hover:bg-muted border border-border/50 backdrop-blur-sm';
|
|
16
|
+
|
|
17
|
+
export const DownloadAction: React.FC<DownloadActionProps> = ({
|
|
18
|
+
value,
|
|
19
|
+
filename = 'download.txt',
|
|
20
|
+
mimeType = 'text/plain',
|
|
21
|
+
title = 'Download',
|
|
22
|
+
}) => {
|
|
23
|
+
const handleDownload = () => {
|
|
24
|
+
const blob = new Blob([value], { type: mimeType });
|
|
25
|
+
const url = URL.createObjectURL(blob);
|
|
26
|
+
const a = document.createElement('a');
|
|
27
|
+
a.href = url;
|
|
28
|
+
a.download = filename;
|
|
29
|
+
document.body.appendChild(a);
|
|
30
|
+
a.click();
|
|
31
|
+
document.body.removeChild(a);
|
|
32
|
+
URL.revokeObjectURL(url);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Tooltip>
|
|
37
|
+
<TooltipTrigger asChild>
|
|
38
|
+
<Button
|
|
39
|
+
variant="ghost"
|
|
40
|
+
size="icon"
|
|
41
|
+
onClick={handleDownload}
|
|
42
|
+
aria-label={title}
|
|
43
|
+
className={BUTTON_CLASS}
|
|
44
|
+
>
|
|
45
|
+
<Download className="h-3 w-3" />
|
|
46
|
+
</Button>
|
|
47
|
+
</TooltipTrigger>
|
|
48
|
+
<TooltipContent side="top">{title}</TooltipContent>
|
|
49
|
+
</Tooltip>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ChevronDown, ChevronUp } from 'lucide-react';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@djangocfg/ui-core/components';
|
|
7
|
+
|
|
8
|
+
interface ExpandActionProps {
|
|
9
|
+
isExpanded: boolean;
|
|
10
|
+
onToggle: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const BUTTON_CLASS = 'h-6 w-6 rounded-sm bg-muted/80 hover:bg-muted border border-border/50 backdrop-blur-sm';
|
|
14
|
+
|
|
15
|
+
export const ExpandAction: React.FC<ExpandActionProps> = ({ isExpanded, onToggle }) => {
|
|
16
|
+
const label = isExpanded ? 'Collapse All' : 'Expand All';
|
|
17
|
+
return (
|
|
18
|
+
<Tooltip>
|
|
19
|
+
<TooltipTrigger asChild>
|
|
20
|
+
<Button
|
|
21
|
+
variant="ghost"
|
|
22
|
+
size="icon"
|
|
23
|
+
onClick={onToggle}
|
|
24
|
+
aria-label={label}
|
|
25
|
+
className={BUTTON_CLASS}
|
|
26
|
+
>
|
|
27
|
+
{isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
|
28
|
+
</Button>
|
|
29
|
+
</TooltipTrigger>
|
|
30
|
+
<TooltipContent side="top">{label}</TooltipContent>
|
|
31
|
+
</Tooltip>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Maximize2, Minimize2 } from 'lucide-react';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@djangocfg/ui-core/components';
|
|
7
|
+
|
|
8
|
+
interface FullscreenActionProps {
|
|
9
|
+
isFullscreen?: boolean;
|
|
10
|
+
onToggle: () => void;
|
|
11
|
+
title?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const BUTTON_CLASS = 'h-6 w-6 rounded-sm bg-muted/80 hover:bg-muted border border-border/50 backdrop-blur-sm';
|
|
15
|
+
|
|
16
|
+
export const FullscreenAction: React.FC<FullscreenActionProps> = ({
|
|
17
|
+
isFullscreen = false,
|
|
18
|
+
onToggle,
|
|
19
|
+
title,
|
|
20
|
+
}) => {
|
|
21
|
+
const label = title ?? (isFullscreen ? 'Exit fullscreen' : 'Fullscreen');
|
|
22
|
+
return (
|
|
23
|
+
<Tooltip>
|
|
24
|
+
<TooltipTrigger asChild>
|
|
25
|
+
<Button
|
|
26
|
+
variant="ghost"
|
|
27
|
+
size="icon"
|
|
28
|
+
onClick={onToggle}
|
|
29
|
+
aria-label={label}
|
|
30
|
+
className={BUTTON_CLASS}
|
|
31
|
+
>
|
|
32
|
+
{isFullscreen ? <Minimize2 className="h-3 w-3" /> : <Maximize2 className="h-3 w-3" />}
|
|
33
|
+
</Button>
|
|
34
|
+
</TooltipTrigger>
|
|
35
|
+
<TooltipContent side="top">{label}</TooltipContent>
|
|
36
|
+
</Tooltip>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
const UNLOCKED_CLASS = 'scroll-unlocked';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Scroll isolation — prevents the container from capturing wheel scroll
|
|
9
|
+
* until the user explicitly clicks inside it (like Google Maps).
|
|
10
|
+
*
|
|
11
|
+
* Locked (overlay visible): wheel events on container scroll the PAGE.
|
|
12
|
+
* Unlocked: normal scroll inside the container.
|
|
13
|
+
*
|
|
14
|
+
* Unlock: click anywhere inside the container.
|
|
15
|
+
* Re-lock: click outside the container.
|
|
16
|
+
*
|
|
17
|
+
* When unlocked, adds `scroll-unlocked` class to the container element
|
|
18
|
+
* so it can be styled (e.g. focus ring) via CSS.
|
|
19
|
+
*/
|
|
20
|
+
export function useScrollIsolation(
|
|
21
|
+
ref: React.RefObject<HTMLElement | null>,
|
|
22
|
+
enabled: boolean,
|
|
23
|
+
) {
|
|
24
|
+
const [locked, setLocked] = useState(true);
|
|
25
|
+
|
|
26
|
+
const unlock = useCallback(() => setLocked(false), []);
|
|
27
|
+
|
|
28
|
+
// Re-lock when clicking outside the container
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!enabled) return;
|
|
31
|
+
|
|
32
|
+
const handleDocClick = (e: MouseEvent) => {
|
|
33
|
+
const el = ref.current;
|
|
34
|
+
if (!el) return;
|
|
35
|
+
if (!el.contains(e.target as Node)) {
|
|
36
|
+
setLocked(true);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
document.addEventListener('click', handleDocClick, true);
|
|
41
|
+
return () => document.removeEventListener('click', handleDocClick, true);
|
|
42
|
+
}, [enabled, ref]);
|
|
43
|
+
|
|
44
|
+
// Toggle class on container to allow CSS-driven focus ring
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const el = ref.current;
|
|
47
|
+
if (!el || !enabled) return;
|
|
48
|
+
if (locked) {
|
|
49
|
+
el.classList.remove(UNLOCKED_CLASS);
|
|
50
|
+
} else {
|
|
51
|
+
el.classList.add(UNLOCKED_CLASS);
|
|
52
|
+
}
|
|
53
|
+
return () => el.classList.remove(UNLOCKED_CLASS);
|
|
54
|
+
}, [locked, enabled, ref]);
|
|
55
|
+
|
|
56
|
+
// Reset to locked when feature toggled on
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (enabled) setLocked(true);
|
|
59
|
+
}, [enabled]);
|
|
60
|
+
|
|
61
|
+
return { locked, unlock };
|
|
62
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import './FloatingToolbar.css';
|
|
6
|
+
import { useScrollIsolation } from './hooks/useScrollIsolation';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Track whether the container actually overflows. Scroll isolation
|
|
10
|
+
* (and its "Click to scroll" overlay) only makes sense when there is
|
|
11
|
+
* something to scroll — a fully-visible block must stay interactive.
|
|
12
|
+
*/
|
|
13
|
+
function useIsScrollable(ref: React.RefObject<HTMLElement | null>): boolean {
|
|
14
|
+
const [scrollable, setScrollable] = useState(false);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const el = ref.current;
|
|
18
|
+
if (!el) return;
|
|
19
|
+
|
|
20
|
+
const measure = () => {
|
|
21
|
+
// 1px tolerance for sub-pixel rounding.
|
|
22
|
+
setScrollable(
|
|
23
|
+
el.scrollHeight - el.clientHeight > 1 ||
|
|
24
|
+
el.scrollWidth - el.clientWidth > 1,
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
measure();
|
|
29
|
+
const observer = new ResizeObserver(measure);
|
|
30
|
+
observer.observe(el);
|
|
31
|
+
return () => observer.disconnect();
|
|
32
|
+
}, [ref]);
|
|
33
|
+
|
|
34
|
+
return scrollable;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FloatingToolbarProps {
|
|
38
|
+
/** Ref to the container element the toolbar anchors to */
|
|
39
|
+
containerRef: React.RefObject<HTMLElement | null>;
|
|
40
|
+
/** Action buttons to render (right side) */
|
|
41
|
+
children: React.ReactNode;
|
|
42
|
+
/** Optional label shown left of the buttons (e.g. language badge) */
|
|
43
|
+
label?: React.ReactNode;
|
|
44
|
+
/** Where to anchor relative to the container (default: bottom-right) */
|
|
45
|
+
position?: 'top-right' | 'bottom-right';
|
|
46
|
+
/** Offset from the edge in px (default: 8) */
|
|
47
|
+
offset?: number;
|
|
48
|
+
/** z-index (default: 30) */
|
|
49
|
+
zIndex?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Block wheel scroll inside the container until user clicks into it.
|
|
52
|
+
* Re-locks when mouse leaves. Like Google Maps scroll isolation.
|
|
53
|
+
* @default true
|
|
54
|
+
*/
|
|
55
|
+
scrollIsolation?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Hide the toolbar until the user hovers the container. ChatGPT /
|
|
58
|
+
* GitHub-style: chrome stays out of the way, appears on demand.
|
|
59
|
+
* Keyboard focus inside the container also reveals it (a11y).
|
|
60
|
+
* @default false (always visible — back-compat)
|
|
61
|
+
*/
|
|
62
|
+
autoHide?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Toolbar is anchored with `position: absolute` inside the container (PrettyCode, etc.).
|
|
67
|
+
* `position: fixed` + viewport coordinates breaks embedded layouts (e.g. chat composer overlap).
|
|
68
|
+
*/
|
|
69
|
+
export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
|
70
|
+
containerRef,
|
|
71
|
+
children,
|
|
72
|
+
label,
|
|
73
|
+
position = 'bottom-right',
|
|
74
|
+
offset = 8,
|
|
75
|
+
zIndex = 30,
|
|
76
|
+
scrollIsolation = true,
|
|
77
|
+
autoHide = false,
|
|
78
|
+
}) => {
|
|
79
|
+
// Isolation only engages when the container can actually scroll —
|
|
80
|
+
// a block that fully fits never shows the "Click to scroll" overlay.
|
|
81
|
+
const isScrollable = useIsScrollable(containerRef);
|
|
82
|
+
const isolationActive = scrollIsolation && isScrollable;
|
|
83
|
+
|
|
84
|
+
const { locked, unlock } = useScrollIsolation(containerRef, isolationActive);
|
|
85
|
+
const [overlayHovered, setOverlayHovered] = useState(false);
|
|
86
|
+
|
|
87
|
+
// Track container hover + focus for ChatGPT-style auto-hide. We watch
|
|
88
|
+
// `mouseenter/leave` and `focusin/out` on the container so keyboard
|
|
89
|
+
// users still see the toolbar — `:hover` alone would hide it from
|
|
90
|
+
// them entirely.
|
|
91
|
+
const [containerActive, setContainerActive] = useState(false);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!autoHide) return;
|
|
94
|
+
const el = containerRef.current;
|
|
95
|
+
if (!el) return;
|
|
96
|
+
const enter = () => setContainerActive(true);
|
|
97
|
+
const leave = () => setContainerActive(false);
|
|
98
|
+
el.addEventListener('mouseenter', enter);
|
|
99
|
+
el.addEventListener('mouseleave', leave);
|
|
100
|
+
el.addEventListener('focusin', enter);
|
|
101
|
+
el.addEventListener('focusout', leave);
|
|
102
|
+
return () => {
|
|
103
|
+
el.removeEventListener('mouseenter', enter);
|
|
104
|
+
el.removeEventListener('mouseleave', leave);
|
|
105
|
+
el.removeEventListener('focusin', enter);
|
|
106
|
+
el.removeEventListener('focusout', leave);
|
|
107
|
+
};
|
|
108
|
+
}, [autoHide, containerRef]);
|
|
109
|
+
|
|
110
|
+
const overlay =
|
|
111
|
+
isolationActive && locked ? (
|
|
112
|
+
<div
|
|
113
|
+
onClick={unlock}
|
|
114
|
+
onMouseEnter={() => setOverlayHovered(true)}
|
|
115
|
+
onMouseLeave={() => setOverlayHovered(false)}
|
|
116
|
+
style={{
|
|
117
|
+
position: 'absolute',
|
|
118
|
+
inset: 0,
|
|
119
|
+
zIndex: zIndex - 1,
|
|
120
|
+
cursor: 'pointer',
|
|
121
|
+
background: overlayHovered ? 'rgba(0,0,0,0.04)' : 'transparent',
|
|
122
|
+
display: 'flex',
|
|
123
|
+
alignItems: 'center',
|
|
124
|
+
justifyContent: 'center',
|
|
125
|
+
transition: 'background 150ms',
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
{overlayHovered && (
|
|
129
|
+
<span
|
|
130
|
+
style={{
|
|
131
|
+
fontSize: '0.75rem',
|
|
132
|
+
padding: '0.25rem 0.625rem',
|
|
133
|
+
borderRadius: '9999px',
|
|
134
|
+
background: 'rgba(0,0,0,0.55)',
|
|
135
|
+
color: '#fff',
|
|
136
|
+
pointerEvents: 'none',
|
|
137
|
+
userSelect: 'none',
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
Click to scroll
|
|
141
|
+
</span>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
) : null;
|
|
145
|
+
|
|
146
|
+
const positionStyle: React.CSSProperties =
|
|
147
|
+
position === 'bottom-right'
|
|
148
|
+
? { bottom: offset, right: offset }
|
|
149
|
+
: { top: offset, right: offset };
|
|
150
|
+
|
|
151
|
+
// Auto-hide: invisible until the container is hovered or has keyboard
|
|
152
|
+
// focus inside. Opacity transition keeps the reveal smooth instead of
|
|
153
|
+
// popping. `pointer-events: none` while hidden so the toolbar doesn't
|
|
154
|
+
// intercept clicks on whatever sits behind it.
|
|
155
|
+
const hidden = autoHide && !containerActive;
|
|
156
|
+
const toolbar = (
|
|
157
|
+
<div
|
|
158
|
+
className="flex items-center gap-1"
|
|
159
|
+
style={{
|
|
160
|
+
position: 'absolute',
|
|
161
|
+
...positionStyle,
|
|
162
|
+
zIndex,
|
|
163
|
+
opacity: hidden ? 0 : 1,
|
|
164
|
+
pointerEvents: hidden ? 'none' : undefined,
|
|
165
|
+
transition: 'opacity 120ms ease-out',
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
{label && (
|
|
169
|
+
<>
|
|
170
|
+
{label}
|
|
171
|
+
<div className="w-px h-4 bg-border/50 mx-0.5" />
|
|
172
|
+
</>
|
|
173
|
+
)}
|
|
174
|
+
{children}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<>
|
|
180
|
+
{overlay}
|
|
181
|
+
{toolbar}
|
|
182
|
+
</>
|
|
183
|
+
);
|
|
184
|
+
};
|