@lijian-ui/dsh-term 0.1.0 → 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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * AnimatedDock — a macOS-style magnification button group rendered into the
3
+ * conversation session header (the `conversation.session.header.utilities`
4
+ * slot, ordered to the LEFT of the official "Session log" button).
5
+ *
6
+ * This is a faithful port of the 21st.dev "Animated Dock" component: it uses
7
+ * framer-motion's `useMotionValue` + `useSpring` + `useTransform` so each
8
+ * circular icon grows (and pushes its neighbour) toward the cursor with real
9
+ * spring physics — the buttery feel you can't get from a CSS transition.
10
+ * Icons come from lucide-react. The dock itself is a frosted-glass pill.
11
+ *
12
+ * Two entries wire to the two right-side panels through window CustomEvents
13
+ * (a cross-plugin bridge so this component never imports the file-manager
14
+ * bundle):
15
+ *
16
+ * - terminal -> `dsh-dock:toggle-terminal` (handled in dsh-term index)
17
+ * - file -> `dsh-dock:toggle-filepanel` (handled in dsh-file-manager)
18
+ *
19
+ * Active (open) state is mirrored back through `dsh-dock:terminal-state` /
20
+ * `dsh-dock:filepanel-state` so the icons highlight in lockstep with the
21
+ * actual panels.
22
+ * @module dsh-term/client/AnimatedDock
23
+ */
24
+
25
+ import { useEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
26
+ import { motion, useMotionValue, useSpring, useTransform, type MotionValue } from 'framer-motion'
27
+ import { Terminal, FolderOpen } from 'lucide-react'
28
+ import styles from './animated-dock.module.css'
29
+
30
+ /** Cross-plugin event names (kept as literals to avoid cross-bundle imports). */
31
+ const EV = {
32
+ toggleTerminal: 'dsh-dock:toggle-terminal',
33
+ toggleFile: 'dsh-dock:toggle-filepanel',
34
+ terminalState: 'dsh-dock:terminal-state',
35
+ fileState: 'dsh-dock:filepanel-state',
36
+ } as const
37
+
38
+ /** Resting / peak icon size (px). Peak pushes the neighbour like a real dock. */
39
+ const BASE = 40
40
+ const MAX = 58
41
+ /** Cursor distance (px) over which magnification falls to zero. */
42
+ const DIST = 120
43
+ /** Spring tuning for the magnification. */
44
+ const DAMPING = 0.35
45
+ const STIFFNESS = 220
46
+
47
+ export function AnimatedDock(): ReactElement {
48
+ const mouseX = useMotionValue<number>(Infinity)
49
+ const [terminalActive, setTerminalActive] = useState(false)
50
+ const [fileActive, setFileActive] = useState(false)
51
+
52
+ // Mirror the real panel open-state back into the icon highlight.
53
+ useEffect(() => {
54
+ const onTerm = (e: Event): void => setTerminalActive(Boolean((e as CustomEvent).detail))
55
+ const onFile = (e: Event): void => setFileActive(Boolean((e as CustomEvent).detail))
56
+ window.addEventListener(EV.terminalState, onTerm)
57
+ window.addEventListener(EV.fileState, onFile)
58
+ return () => {
59
+ window.removeEventListener(EV.terminalState, onTerm)
60
+ window.removeEventListener(EV.fileState, onFile)
61
+ }
62
+ }, [])
63
+
64
+ return (
65
+ <motion.div
66
+ className={styles.dock}
67
+ onMouseMove={(e) => mouseX.set(e.clientX)}
68
+ onMouseLeave={() => mouseX.set(Infinity)}
69
+ aria-label="工具坞"
70
+ >
71
+ <DockItem
72
+ mouseX={mouseX}
73
+ active={terminalActive}
74
+ label="终端"
75
+ onClick={() => window.dispatchEvent(new CustomEvent(EV.toggleTerminal))}
76
+ >
77
+ <Terminal size={20} strokeWidth={2} />
78
+ </DockItem>
79
+ <DockItem
80
+ mouseX={mouseX}
81
+ active={fileActive}
82
+ label="文件面板"
83
+ onClick={() => window.dispatchEvent(new CustomEvent(EV.toggleFile))}
84
+ >
85
+ <FolderOpen size={20} strokeWidth={2} />
86
+ </DockItem>
87
+ </motion.div>
88
+ )
89
+ }
90
+
91
+ /** A single magnifying icon. Its width is a spring driven by cursor distance. */
92
+ function DockItem(props: {
93
+ mouseX: MotionValue<number>
94
+ active: boolean
95
+ label: string
96
+ onClick: () => void
97
+ children: ReactNode
98
+ }): ReactElement {
99
+ const ref = useRef<HTMLButtonElement>(null)
100
+ const distance = useTransform(props.mouseX, (val) => {
101
+ const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: BASE }
102
+ return val - bounds.x - bounds.width / 2
103
+ })
104
+ const width = useSpring(
105
+ useTransform(distance, [-DIST, 0, DIST], [BASE, MAX, BASE]),
106
+ { damping: DAMPING, stiffness: STIFFNESS },
107
+ )
108
+ return (
109
+ <motion.button
110
+ ref={ref}
111
+ type="button"
112
+ style={{ width }}
113
+ className={`${styles.item}${props.active ? ` ${styles.active}` : ''}`}
114
+ onClick={props.onClick}
115
+ aria-label={props.label}
116
+ aria-pressed={props.active}
117
+ title={props.label}
118
+ >
119
+ <span className={styles.tooltip}>{props.label}</span>
120
+ {props.children}
121
+ </motion.button>
122
+ )
123
+ }
@@ -0,0 +1,67 @@
1
+ /* Frosted-glass dock container — the macOS "pill" holding the magnifying icons. */
2
+ .dock {
3
+ display: inline-flex;
4
+ align-items: flex-end;
5
+ gap: 8px;
6
+ height: 40px;
7
+ padding: 0 10px 4px;
8
+ border-radius: 16px;
9
+ background: rgba(255, 255, 255, 0.08);
10
+ border: 1px solid rgba(255, 255, 255, 0.14);
11
+ backdrop-filter: blur(12px);
12
+ -webkit-backdrop-filter: blur(12px);
13
+ overflow: visible;
14
+ }
15
+
16
+ /* Circular icon button. Width is a framer-motion spring (set inline), so the
17
+ icon grows and pushes its neighbour like a real dock. aspect-ratio keeps the
18
+ height in lockstep with the animated width. */
19
+ .item {
20
+ position: relative;
21
+ aspect-ratio: 1 / 1;
22
+ display: inline-flex;
23
+ align-items: center;
24
+ justify-content: center;
25
+ border: none;
26
+ border-radius: 9999px;
27
+ background: rgba(255, 255, 255, 0.1);
28
+ color: var(--aion-fg-2, #c9cdd4);
29
+ cursor: pointer;
30
+ overflow: visible;
31
+ transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
32
+ }
33
+
34
+ .item:hover {
35
+ background: rgba(255, 255, 255, 0.22);
36
+ color: var(--aion-fg-1, #ffffff);
37
+ }
38
+
39
+ /* Open-panel highlight: primary tint + thin ring to mirror the live state. */
40
+ .item.active {
41
+ background: var(--aion-primary-soft, rgba(22, 93, 255, 0.28));
42
+ color: var(--aion-primary, #6ea0ff);
43
+ box-shadow: 0 0 0 1.5px var(--aion-primary, #6ea0ff);
44
+ }
45
+
46
+ /* Hover tooltip — the small label that floats above each icon (21st.dev style). */
47
+ .tooltip {
48
+ position: absolute;
49
+ top: -30px;
50
+ left: 50%;
51
+ transform: translateX(-50%);
52
+ padding: 2px 8px;
53
+ font-size: 12px;
54
+ line-height: 1.4;
55
+ white-space: nowrap;
56
+ color: var(--aion-fg-1, #ffffff);
57
+ background: rgba(20, 20, 30, 0.9);
58
+ border: 1px solid rgba(255, 255, 255, 0.12);
59
+ border-radius: 6px;
60
+ opacity: 0;
61
+ pointer-events: none;
62
+ transition: opacity 0.15s ease;
63
+ }
64
+
65
+ .item:hover .tooltip {
66
+ opacity: 1;
67
+ }