@djangocfg/ui-tools 2.1.210 → 2.1.213
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/dist/JsonTree-EFWAIIUV.mjs +5 -0
- package/dist/{JsonTree-5CRPHRP5.mjs.map → JsonTree-EFWAIIUV.mjs.map} +1 -1
- package/dist/JsonTree-LULSGFAW.cjs +11 -0
- package/dist/{JsonTree-IIVOSSVZ.cjs.map → JsonTree-LULSGFAW.cjs.map} +1 -1
- package/dist/JsonTree-MLET23ZA.css +6 -0
- package/dist/JsonTree-MLET23ZA.css.map +1 -0
- package/dist/{Mermaid.client-HG24D5KB.mjs → Mermaid.client-5EKULAUZ.mjs} +12 -22
- package/dist/Mermaid.client-5EKULAUZ.mjs.map +1 -0
- package/dist/Mermaid.client-DDXWXZXY.css +6 -0
- package/dist/Mermaid.client-DDXWXZXY.css.map +1 -0
- package/dist/{Mermaid.client-2TAFAXPW.cjs → Mermaid.client-Z25LMNPA.cjs} +12 -22
- package/dist/Mermaid.client-Z25LMNPA.cjs.map +1 -0
- package/dist/{PlaygroundLayout-XYMJBNT4.cjs → PlaygroundLayout-F47LFRYB.cjs} +21 -20
- package/dist/PlaygroundLayout-F47LFRYB.cjs.map +1 -0
- package/dist/{PlaygroundLayout-DWRNA2QM.mjs → PlaygroundLayout-NFNVEPIL.mjs} +5 -4
- package/dist/PlaygroundLayout-NFNVEPIL.mjs.map +1 -0
- package/dist/PlaygroundLayout-O52C6HK5.css +6 -0
- package/dist/PlaygroundLayout-O52C6HK5.css.map +1 -0
- package/dist/PrettyCode.client-GWFAIVFN.css +6 -0
- package/dist/PrettyCode.client-GWFAIVFN.css.map +1 -0
- package/dist/{PrettyCode.client-TCVEDDCJ.cjs → PrettyCode.client-VH6C6OC5.cjs} +15 -18
- package/dist/PrettyCode.client-VH6C6OC5.cjs.map +1 -0
- package/dist/{PrettyCode.client-RHOS5D6V.mjs → PrettyCode.client-VNTMCHXR.mjs} +16 -19
- package/dist/PrettyCode.client-VNTMCHXR.mjs.map +1 -0
- package/dist/{chunk-QP6QAK3F.cjs → chunk-3IIZ5ESQ.cjs} +3 -3
- package/dist/{chunk-QP6QAK3F.cjs.map → chunk-3IIZ5ESQ.cjs.map} +1 -1
- package/dist/chunk-3Q2X3CYQ.mjs +208 -0
- package/dist/chunk-3Q2X3CYQ.mjs.map +1 -0
- package/dist/chunk-G2A6SX5L.cjs +235 -0
- package/dist/chunk-G2A6SX5L.cjs.map +1 -0
- package/dist/chunk-LJ2VEBBZ.cjs +210 -0
- package/dist/chunk-LJ2VEBBZ.cjs.map +1 -0
- package/dist/chunk-MBFBVGUP.mjs +229 -0
- package/dist/chunk-MBFBVGUP.mjs.map +1 -0
- package/dist/{chunk-52WXASRO.mjs → chunk-SQLEKTT2.mjs} +8 -8
- package/dist/chunk-SQLEKTT2.mjs.map +1 -0
- package/dist/{chunk-FXFTJW2Y.mjs → chunk-TROOIHOR.mjs} +3 -3
- package/dist/{chunk-FXFTJW2Y.mjs.map → chunk-TROOIHOR.mjs.map} +1 -1
- package/dist/{chunk-DF6ORMTF.cjs → chunk-ZQX7KIC5.cjs} +8 -8
- package/dist/chunk-ZQX7KIC5.cjs.map +1 -0
- package/dist/{components-4Z2JIRZI.cjs → components-4FGDYL7K.cjs} +12 -12
- package/dist/{components-4Z2JIRZI.cjs.map → components-4FGDYL7K.cjs.map} +1 -1
- package/dist/{components-YWYUZQIH.mjs → components-MPEPRVCT.mjs} +3 -3
- package/dist/{components-YWYUZQIH.mjs.map → components-MPEPRVCT.mjs.map} +1 -1
- package/dist/index.cjs +53 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +6 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.cts +8 -7
- package/dist/index.d.ts +8 -7
- package/dist/index.mjs +14 -13
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/components/FloatingToolbar/FloatingToolbar.css +5 -0
- package/src/components/FloatingToolbar/actions/CopyAction.tsx +22 -0
- package/src/components/FloatingToolbar/actions/DownloadAction.tsx +46 -0
- package/src/components/FloatingToolbar/actions/ExpandAction.tsx +25 -0
- package/src/components/FloatingToolbar/actions/FullscreenAction.tsx +30 -0
- package/src/components/FloatingToolbar/actions/index.ts +4 -0
- package/src/components/FloatingToolbar/hooks/useElementCorner.ts +84 -0
- package/src/components/FloatingToolbar/hooks/useScrollIsolation.ts +62 -0
- package/src/components/FloatingToolbar/index.tsx +131 -0
- package/src/styles/index.css +1 -0
- package/src/tools/AudioPlayer/components/HybridCompactPlayer.tsx +1 -1
- package/src/tools/AudioPlayer/components/HybridWaveform.tsx +10 -10
- package/src/tools/JsonTree/JsonTree.story.tsx +3 -2
- package/src/tools/JsonTree/README.md +60 -0
- package/src/tools/JsonTree/components/JsonContent.tsx +98 -0
- package/src/tools/JsonTree/components/JsonToolbar.tsx +88 -0
- package/src/tools/JsonTree/hooks/useElementCorner.ts +84 -0
- package/src/tools/JsonTree/hooks/useJsonExpand.ts +50 -0
- package/src/tools/JsonTree/hooks/useNavbarHeight.ts +83 -0
- package/src/tools/JsonTree/index.tsx +46 -278
- package/src/tools/JsonTree/types.ts +65 -0
- package/src/tools/Mermaid/Mermaid.client.tsx +12 -20
- package/src/tools/PrettyCode/PrettyCode.client.tsx +19 -19
- package/src/tools/PrettyCode/PrettyCode.story.tsx +476 -41
- package/dist/JsonTree-5CRPHRP5.mjs +0 -4
- package/dist/JsonTree-IIVOSSVZ.cjs +0 -10
- package/dist/Mermaid.client-2TAFAXPW.cjs.map +0 -1
- package/dist/Mermaid.client-HG24D5KB.mjs.map +0 -1
- package/dist/PlaygroundLayout-DWRNA2QM.mjs.map +0 -1
- package/dist/PlaygroundLayout-XYMJBNT4.cjs.map +0 -1
- package/dist/PrettyCode.client-RHOS5D6V.mjs.map +0 -1
- package/dist/PrettyCode.client-TCVEDDCJ.cjs.map +0 -1
- package/dist/chunk-52WXASRO.mjs.map +0 -1
- package/dist/chunk-DF6ORMTF.cjs.map +0 -1
- package/dist/chunk-DFWXRCIC.cjs +0 -242
- package/dist/chunk-DFWXRCIC.cjs.map +0 -1
- package/dist/chunk-XXFYTIQK.mjs +0 -240
- package/dist/chunk-XXFYTIQK.mjs.map +0 -1
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { CopyButton } from '@djangocfg/ui-core/components';
|
|
5
|
+
|
|
6
|
+
interface CopyActionProps {
|
|
7
|
+
value: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const BUTTON_CLASS = 'h-6 w-6 rounded-sm bg-muted/80 hover:bg-muted border border-border/50 backdrop-blur-sm';
|
|
12
|
+
|
|
13
|
+
export const CopyAction: React.FC<CopyActionProps> = ({ value, title = 'Copy' }) => (
|
|
14
|
+
<CopyButton
|
|
15
|
+
value={value}
|
|
16
|
+
variant="ghost"
|
|
17
|
+
size="icon"
|
|
18
|
+
className={BUTTON_CLASS}
|
|
19
|
+
iconClassName="h-3 w-3"
|
|
20
|
+
title={title}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Download } from 'lucide-react';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button } 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
|
+
<Button
|
|
37
|
+
variant="ghost"
|
|
38
|
+
size="icon"
|
|
39
|
+
onClick={handleDownload}
|
|
40
|
+
className={BUTTON_CLASS}
|
|
41
|
+
title={title}
|
|
42
|
+
>
|
|
43
|
+
<Download className="h-3 w-3" />
|
|
44
|
+
</Button>
|
|
45
|
+
);
|
|
46
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ChevronDown, ChevronUp } from 'lucide-react';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button } 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
|
+
<Button
|
|
17
|
+
variant="ghost"
|
|
18
|
+
size="icon"
|
|
19
|
+
onClick={onToggle}
|
|
20
|
+
className={BUTTON_CLASS}
|
|
21
|
+
title={isExpanded ? 'Collapse All' : 'Expand All'}
|
|
22
|
+
>
|
|
23
|
+
{isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
|
24
|
+
</Button>
|
|
25
|
+
);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Maximize2, Minimize2 } from 'lucide-react';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button } 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
|
+
<Button
|
|
22
|
+
variant="ghost"
|
|
23
|
+
size="icon"
|
|
24
|
+
onClick={onToggle}
|
|
25
|
+
className={BUTTON_CLASS}
|
|
26
|
+
title={title ?? (isFullscreen ? 'Exit fullscreen' : 'Fullscreen')}
|
|
27
|
+
>
|
|
28
|
+
{isFullscreen ? <Minimize2 className="h-3 w-3" /> : <Maximize2 className="h-3 w-3" />}
|
|
29
|
+
</Button>
|
|
30
|
+
);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
interface Corner {
|
|
6
|
+
top: number;
|
|
7
|
+
right: number;
|
|
8
|
+
bottom: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Tracks the top-right corner of a referenced element in viewport coordinates.
|
|
13
|
+
* Returns { top, right } in px — ready for `position: fixed` toolbar placement.
|
|
14
|
+
*
|
|
15
|
+
* Uses `visualViewport` for accurate viewport width — correctly handles:
|
|
16
|
+
* - DevTools panel open (docked left/right/bottom)
|
|
17
|
+
* - Browser zoom
|
|
18
|
+
* - Mobile virtual keyboard / pinch-zoom
|
|
19
|
+
* - Scrollbars
|
|
20
|
+
*
|
|
21
|
+
* Falls back to `document.documentElement.clientWidth` (excludes scrollbars,
|
|
22
|
+
* matches `position: fixed` coordinate space) when visualViewport is unavailable.
|
|
23
|
+
*
|
|
24
|
+
* Updates on:
|
|
25
|
+
* - Any ancestor scroll (capture phase)
|
|
26
|
+
* - visualViewport resize/scroll
|
|
27
|
+
* - ResizeObserver on the element itself
|
|
28
|
+
*/
|
|
29
|
+
export function useElementCorner(ref: React.RefObject<HTMLElement | null>) {
|
|
30
|
+
const [corner, setCorner] = useState<Corner | null>(null);
|
|
31
|
+
const updateRef = useRef<() => void>(() => {});
|
|
32
|
+
|
|
33
|
+
updateRef.current = () => {
|
|
34
|
+
if (!ref.current) return;
|
|
35
|
+
const rect = ref.current.getBoundingClientRect();
|
|
36
|
+
|
|
37
|
+
// `position: fixed` is relative to the layout viewport (document.documentElement.clientWidth),
|
|
38
|
+
// NOT window.innerWidth (which includes scrollbar gutter).
|
|
39
|
+
// visualViewport.width is the visible area — same as layout viewport when no zoom/keyboard.
|
|
40
|
+
const viewportWidth =
|
|
41
|
+
window.visualViewport?.width ?? document.documentElement.clientWidth;
|
|
42
|
+
|
|
43
|
+
setCorner({
|
|
44
|
+
top: rect.top,
|
|
45
|
+
right: viewportWidth - rect.right,
|
|
46
|
+
bottom: rect.bottom,
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const handler = () => updateRef.current();
|
|
52
|
+
|
|
53
|
+
handler();
|
|
54
|
+
|
|
55
|
+
// Element size changes
|
|
56
|
+
const ro = new ResizeObserver(handler);
|
|
57
|
+
if (ref.current) ro.observe(ref.current);
|
|
58
|
+
|
|
59
|
+
// Any ancestor scroll (capture catches all, including overflow:auto containers)
|
|
60
|
+
window.addEventListener('scroll', handler, { capture: true, passive: true });
|
|
61
|
+
|
|
62
|
+
// visualViewport handles: DevTools resize, browser zoom, mobile keyboard
|
|
63
|
+
const vv = window.visualViewport;
|
|
64
|
+
if (vv) {
|
|
65
|
+
vv.addEventListener('resize', handler);
|
|
66
|
+
vv.addEventListener('scroll', handler);
|
|
67
|
+
} else {
|
|
68
|
+
window.addEventListener('resize', handler, { passive: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
ro.disconnect();
|
|
73
|
+
window.removeEventListener('scroll', handler, { capture: true });
|
|
74
|
+
if (vv) {
|
|
75
|
+
vv.removeEventListener('resize', handler);
|
|
76
|
+
vv.removeEventListener('scroll', handler);
|
|
77
|
+
} else {
|
|
78
|
+
window.removeEventListener('resize', handler);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}, [ref]);
|
|
82
|
+
|
|
83
|
+
return corner;
|
|
84
|
+
}
|
|
@@ -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,131 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import './FloatingToolbar.css';
|
|
6
|
+
import { useElementCorner } from './hooks/useElementCorner';
|
|
7
|
+
import { useScrollIsolation } from './hooks/useScrollIsolation';
|
|
8
|
+
|
|
9
|
+
export interface FloatingToolbarProps {
|
|
10
|
+
/** Ref to the container element the toolbar anchors to */
|
|
11
|
+
containerRef: React.RefObject<HTMLElement | null>;
|
|
12
|
+
/** Action buttons to render (right side) */
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
/** Optional label shown left of the buttons (e.g. language badge) */
|
|
15
|
+
label?: React.ReactNode;
|
|
16
|
+
/** Where to anchor relative to the container (default: bottom-right) */
|
|
17
|
+
position?: 'top-right' | 'bottom-right';
|
|
18
|
+
/** Offset from the edge in px (default: 8) */
|
|
19
|
+
offset?: number;
|
|
20
|
+
/** z-index (default: 30) */
|
|
21
|
+
zIndex?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Block wheel scroll inside the container until user clicks into it.
|
|
24
|
+
* Re-locks when mouse leaves. Like Google Maps scroll isolation.
|
|
25
|
+
* @default true
|
|
26
|
+
*/
|
|
27
|
+
scrollIsolation?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MIN_HEIGHT = 40; // minimum container height to show toolbar
|
|
31
|
+
|
|
32
|
+
export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
|
33
|
+
containerRef,
|
|
34
|
+
children,
|
|
35
|
+
label,
|
|
36
|
+
position = 'bottom-right',
|
|
37
|
+
offset = 8,
|
|
38
|
+
zIndex = 30,
|
|
39
|
+
scrollIsolation = true,
|
|
40
|
+
}) => {
|
|
41
|
+
const corner = useElementCorner(containerRef);
|
|
42
|
+
const { locked, unlock } = useScrollIsolation(containerRef, scrollIsolation);
|
|
43
|
+
const [overlayHovered, setOverlayHovered] = useState(false);
|
|
44
|
+
|
|
45
|
+
// Overlay always renders (even before corner is measured) — it's position:absolute
|
|
46
|
+
// inside the container so it works immediately on mount.
|
|
47
|
+
// It absorbs wheel events (pointer-events cover the container) so the page scrolls normally.
|
|
48
|
+
// Click anywhere inside unlocks it; click outside re-locks it.
|
|
49
|
+
const overlay =
|
|
50
|
+
scrollIsolation && locked ? (
|
|
51
|
+
<div
|
|
52
|
+
onClick={unlock}
|
|
53
|
+
onMouseEnter={() => setOverlayHovered(true)}
|
|
54
|
+
onMouseLeave={() => setOverlayHovered(false)}
|
|
55
|
+
style={{
|
|
56
|
+
position: 'absolute',
|
|
57
|
+
inset: 0,
|
|
58
|
+
zIndex: zIndex - 1,
|
|
59
|
+
cursor: 'pointer',
|
|
60
|
+
background: overlayHovered ? 'rgba(0,0,0,0.04)' : 'transparent',
|
|
61
|
+
display: 'flex',
|
|
62
|
+
alignItems: 'center',
|
|
63
|
+
justifyContent: 'center',
|
|
64
|
+
transition: 'background 150ms',
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{overlayHovered && (
|
|
68
|
+
<span style={{
|
|
69
|
+
fontSize: '0.75rem',
|
|
70
|
+
padding: '0.25rem 0.625rem',
|
|
71
|
+
borderRadius: '9999px',
|
|
72
|
+
background: 'rgba(0,0,0,0.55)',
|
|
73
|
+
color: '#fff',
|
|
74
|
+
pointerEvents: 'none',
|
|
75
|
+
userSelect: 'none',
|
|
76
|
+
}}>
|
|
77
|
+
Click to scroll
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
) : null;
|
|
82
|
+
|
|
83
|
+
// Toolbar is position:fixed and needs measured coords — skip until ready
|
|
84
|
+
let toolbar: React.ReactNode = null;
|
|
85
|
+
if (corner) {
|
|
86
|
+
const { top, right, bottom } = corner;
|
|
87
|
+
const viewportHeight = window.visualViewport?.height ?? document.documentElement.clientHeight;
|
|
88
|
+
|
|
89
|
+
const inView = bottom > 0 && top < viewportHeight && bottom - top >= MIN_HEIGHT;
|
|
90
|
+
|
|
91
|
+
if (inView) {
|
|
92
|
+
const style: React.CSSProperties = {
|
|
93
|
+
position: 'fixed',
|
|
94
|
+
right: right + offset,
|
|
95
|
+
zIndex,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (position === 'bottom-right') {
|
|
99
|
+
const clampedBottom = Math.min(bottom - offset, viewportHeight - offset);
|
|
100
|
+
style.bottom = viewportHeight - clampedBottom;
|
|
101
|
+
} else {
|
|
102
|
+
if (top >= 0) {
|
|
103
|
+
style.top = top + offset;
|
|
104
|
+
} else {
|
|
105
|
+
// top of block scrolled above viewport — hide toolbar
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (style.bottom !== undefined || style.top !== undefined) {
|
|
110
|
+
toolbar = (
|
|
111
|
+
<div className="flex items-center gap-1" style={style}>
|
|
112
|
+
{label && (
|
|
113
|
+
<>
|
|
114
|
+
{label}
|
|
115
|
+
<div className="w-px h-4 bg-border/50 mx-0.5" />
|
|
116
|
+
</>
|
|
117
|
+
)}
|
|
118
|
+
{children}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<>
|
|
127
|
+
{overlay}
|
|
128
|
+
{toolbar}
|
|
129
|
+
</>
|
|
130
|
+
);
|
|
131
|
+
};
|
package/src/styles/index.css
CHANGED
|
@@ -120,7 +120,7 @@ function HybridCompactPlayerInner({ title, waveformMode, showTimer, buttonSize,
|
|
|
120
120
|
<div className={cn('flex items-center gap-2 w-full', className)}>
|
|
121
121
|
{/* Play / Pause */}
|
|
122
122
|
<Button
|
|
123
|
-
variant="
|
|
123
|
+
variant="ghost"
|
|
124
124
|
size="icon"
|
|
125
125
|
className={cn(BUTTON_SIZE[buttonSize], 'flex-shrink-0')}
|
|
126
126
|
onClick={controls.togglePlay}
|
|
@@ -184,24 +184,24 @@ export const HybridWaveform = memo(function HybridWaveform({
|
|
|
184
184
|
|
|
185
185
|
ctx.clearRect(0, 0, width, canvasHeight);
|
|
186
186
|
|
|
187
|
-
// Draw
|
|
187
|
+
// Draw progress bar
|
|
188
|
+
const progress = state.duration > 0 ? state.currentTime / state.duration : 0;
|
|
189
|
+
const progressWidth = width * progress;
|
|
190
|
+
|
|
191
|
+
// Background track
|
|
192
|
+
ctx.fillStyle = resolvedWaveColor;
|
|
193
|
+
ctx.fillRect(0, canvasHeight / 2 - 2 * dpr, width, 4 * dpr);
|
|
194
|
+
|
|
195
|
+
// Buffered region (thin strip at bottom)
|
|
188
196
|
if (state.buffered && state.duration > 0) {
|
|
189
197
|
ctx.fillStyle = resolvedBufferedColor;
|
|
190
198
|
for (let i = 0; i < state.buffered.length; i++) {
|
|
191
199
|
const start = (state.buffered.start(i) / state.duration) * width;
|
|
192
200
|
const end = (state.buffered.end(i) / state.duration) * width;
|
|
193
|
-
ctx.fillRect(start,
|
|
201
|
+
ctx.fillRect(start, canvasHeight - 2 * dpr, end - start, 2 * dpr);
|
|
194
202
|
}
|
|
195
203
|
}
|
|
196
204
|
|
|
197
|
-
// Draw progress bar
|
|
198
|
-
const progress = state.duration > 0 ? state.currentTime / state.duration : 0;
|
|
199
|
-
const progressWidth = width * progress;
|
|
200
|
-
|
|
201
|
-
// Background
|
|
202
|
-
ctx.fillStyle = resolvedWaveColor;
|
|
203
|
-
ctx.fillRect(0, canvasHeight / 2 - 2 * dpr, width, 4 * dpr);
|
|
204
|
-
|
|
205
205
|
// Progress
|
|
206
206
|
ctx.fillStyle = resolvedProgressColor;
|
|
207
207
|
ctx.fillRect(0, canvasHeight / 2 - 2 * dpr, progressWidth, 4 * dpr);
|
|
@@ -103,6 +103,7 @@ export const Interactive = () => {
|
|
|
103
103
|
maxAutoExpandDepth,
|
|
104
104
|
showExpandControls,
|
|
105
105
|
showActionButtons,
|
|
106
|
+
className: 'h-full',
|
|
106
107
|
}}
|
|
107
108
|
/>
|
|
108
109
|
</div>
|
|
@@ -129,12 +130,12 @@ export const DeepNesting = () => (
|
|
|
129
130
|
|
|
130
131
|
export const CollapsedByDefault = () => (
|
|
131
132
|
<div className="max-w-2xl h-96">
|
|
132
|
-
<JsonTree data={apiResponse} config={{ maxAutoExpandDepth: 0 }} />
|
|
133
|
+
<JsonTree data={apiResponse} config={{ maxAutoExpandDepth: 0, className: 'h-full' }} />
|
|
133
134
|
</div>
|
|
134
135
|
);
|
|
135
136
|
|
|
136
137
|
export const WithMaxDepth = () => (
|
|
137
138
|
<div className="max-w-2xl h-96">
|
|
138
|
-
<JsonTree data={apiResponse} config={{ maxAutoExpandDepth: 2 }} />
|
|
139
|
+
<JsonTree data={apiResponse} config={{ maxAutoExpandDepth: 2, className: 'h-full' }} />
|
|
139
140
|
</div>
|
|
140
141
|
);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# JsonTree
|
|
2
|
+
|
|
3
|
+
Interactive JSON tree viewer with expand/collapse, copy, and download.
|
|
4
|
+
|
|
5
|
+
## Structure
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
JsonTree/
|
|
9
|
+
├── index.tsx # Main component (thin orchestrator)
|
|
10
|
+
├── types.ts # Types, interfaces, mode presets
|
|
11
|
+
├── hooks/
|
|
12
|
+
│ ├── useElementCorner.ts # Tracks element's viewport top-right corner for fixed toolbar
|
|
13
|
+
│ └── useJsonExpand.ts # Expand/collapse state + shouldExpandNodeInitially logic
|
|
14
|
+
├── components/
|
|
15
|
+
│ ├── JsonToolbar.tsx # Floating icon buttons (expand, copy, download)
|
|
16
|
+
│ └── JsonContent.tsx # JSONTree wrapper with theme, getItemString, postprocessValue
|
|
17
|
+
├── lazy.tsx # Lazy-loaded export
|
|
18
|
+
├── JsonTree.story.tsx # Storybook stories
|
|
19
|
+
└── README.md
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import JsonTree from '@djangocfg/ui-tools/JsonTree';
|
|
26
|
+
|
|
27
|
+
<JsonTree data={myObject} />
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Props
|
|
31
|
+
|
|
32
|
+
| Prop | Type | Default | Description |
|
|
33
|
+
|------|------|---------|-------------|
|
|
34
|
+
| `data` | `unknown` | — | JSON data to display |
|
|
35
|
+
| `mode` | `'full' \| 'compact' \| 'inline'` | `'full'` | Display mode |
|
|
36
|
+
| `title` | `string` | — | Optional title (full mode only) |
|
|
37
|
+
| `config` | `JsonTreeConfig` | `{}` | Fine-grained config overrides |
|
|
38
|
+
| `jsonTreeProps` | `Partial<CommonExternalProps>` | `{}` | Pass-through to react-json-tree |
|
|
39
|
+
|
|
40
|
+
## Config options
|
|
41
|
+
|
|
42
|
+
| Option | Default | Description |
|
|
43
|
+
|--------|---------|-------------|
|
|
44
|
+
| `maxAutoExpandDepth` | `2` | Auto-expand depth (0 = collapsed by default) |
|
|
45
|
+
| `maxAutoExpandArrayItems` | `10` | Arrays ≤ N items are auto-expanded |
|
|
46
|
+
| `maxAutoExpandObjectKeys` | `5` | Objects ≤ N keys are auto-expanded |
|
|
47
|
+
| `maxStringLength` | `200` | Truncate long strings |
|
|
48
|
+
| `collectionLimit` | `50` | Max items rendered per collection |
|
|
49
|
+
| `showCollectionInfo` | `true` | Show `(N keys)` / `(N items)` labels |
|
|
50
|
+
| `showExpandControls` | `true` (full) | Show expand/collapse button |
|
|
51
|
+
| `showActionButtons` | `true` (full) | Show copy/download buttons |
|
|
52
|
+
| `preserveKeyOrder` | `true` | Preserve object key insertion order |
|
|
53
|
+
|
|
54
|
+
## Toolbar positioning
|
|
55
|
+
|
|
56
|
+
The toolbar uses `position: fixed` with coordinates from `useElementCorner` (via `getBoundingClientRect`).
|
|
57
|
+
This works correctly in any scroll context — inline, in panels, modals, or full-page — because:
|
|
58
|
+
- Fixed positioning ignores all ancestor `overflow` settings
|
|
59
|
+
- Coordinates are recalculated on every scroll (capture phase) and resize via `ResizeObserver`
|
|
60
|
+
- Toolbar visually follows the block's top-right corner as the page scrolls
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { CommonExternalProps, JSONTree } from 'react-json-tree';
|
|
5
|
+
|
|
6
|
+
const JSON_TREE_THEME = {
|
|
7
|
+
scheme: 'djangocfg-dark',
|
|
8
|
+
base00: 'transparent',
|
|
9
|
+
base01: '#1a1a1a',
|
|
10
|
+
base02: '#2a2a2a',
|
|
11
|
+
base03: '#6b7280',
|
|
12
|
+
base04: '#9ca3af',
|
|
13
|
+
base05: '#e5e7eb',
|
|
14
|
+
base06: '#f3f4f6',
|
|
15
|
+
base07: '#ffffff',
|
|
16
|
+
base08: '#ef4444', // null, undefined
|
|
17
|
+
base09: '#f97316', // numbers
|
|
18
|
+
base0A: '#eab308', // strings
|
|
19
|
+
base0B: '#22c55e', // booleans
|
|
20
|
+
base0C: '#06b6d4', // dates, regex
|
|
21
|
+
base0D: '#3b82f6', // keys
|
|
22
|
+
base0E: '#a855f7', // functions
|
|
23
|
+
base0F: '#f43f5e', // deprecations
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
interface JsonContentProps {
|
|
27
|
+
data: unknown;
|
|
28
|
+
renderKey: number;
|
|
29
|
+
title?: string;
|
|
30
|
+
showTitle: boolean;
|
|
31
|
+
padding: string;
|
|
32
|
+
collectionLimit: number;
|
|
33
|
+
preserveKeyOrder: boolean;
|
|
34
|
+
showCollectionInfo: boolean;
|
|
35
|
+
maxStringLength: number;
|
|
36
|
+
shouldExpandNodeInitially: (keyPath: readonly (string | number)[], nodeData: unknown, level: number) => boolean;
|
|
37
|
+
jsonTreeProps?: Partial<CommonExternalProps>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const JsonContent: React.FC<JsonContentProps> = ({
|
|
41
|
+
data,
|
|
42
|
+
renderKey,
|
|
43
|
+
title,
|
|
44
|
+
showTitle,
|
|
45
|
+
padding,
|
|
46
|
+
collectionLimit,
|
|
47
|
+
preserveKeyOrder,
|
|
48
|
+
showCollectionInfo,
|
|
49
|
+
maxStringLength,
|
|
50
|
+
shouldExpandNodeInitially,
|
|
51
|
+
jsonTreeProps = {},
|
|
52
|
+
}) => {
|
|
53
|
+
const getItemString = showCollectionInfo
|
|
54
|
+
? (nodeType: string, nodeData: unknown) => {
|
|
55
|
+
if (nodeType === 'Array') {
|
|
56
|
+
const length = Array.isArray(nodeData) ? nodeData.length : 0;
|
|
57
|
+
return length > 0 ? <span className="text-muted-foreground text-sm">({length} items)</span> : null;
|
|
58
|
+
}
|
|
59
|
+
if (nodeType === 'Object') {
|
|
60
|
+
const keys = nodeData && typeof nodeData === 'object' ? Object.keys(nodeData) : [];
|
|
61
|
+
return keys.length > 0 ? <span className="text-muted-foreground text-sm">({keys.length} keys)</span> : null;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
: () => null;
|
|
66
|
+
|
|
67
|
+
const postprocessValue = (value: unknown) => {
|
|
68
|
+
if (typeof value === 'string' && value.length > maxStringLength) {
|
|
69
|
+
return value.substring(0, maxStringLength) + '... (truncated)';
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const isCustomNode = (value: unknown) =>
|
|
75
|
+
typeof value === 'string' && (value.startsWith('http://') || value.startsWith('https://'));
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className={`overflow-auto h-full ${padding}`}>
|
|
79
|
+
{showTitle && title && (
|
|
80
|
+
<h6 className="text-sm font-semibold text-foreground mb-2">{title}</h6>
|
|
81
|
+
)}
|
|
82
|
+
<JSONTree
|
|
83
|
+
key={renderKey}
|
|
84
|
+
data={data}
|
|
85
|
+
theme={JSON_TREE_THEME}
|
|
86
|
+
invertTheme={false}
|
|
87
|
+
hideRoot={true}
|
|
88
|
+
shouldExpandNodeInitially={shouldExpandNodeInitially}
|
|
89
|
+
getItemString={getItemString}
|
|
90
|
+
postprocessValue={postprocessValue}
|
|
91
|
+
isCustomNode={isCustomNode}
|
|
92
|
+
collectionLimit={collectionLimit}
|
|
93
|
+
sortObjectKeys={!preserveKeyOrder}
|
|
94
|
+
{...jsonTreeProps}
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
};
|