@lijian-ui/dsh-term 0.1.0 → 0.1.2

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,132 @@
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 scales toward the cursor with real spring physics — the
9
+ * buttery feel you can't get from a CSS transition. Magnification is driven by
10
+ * `transform: scale()` (NOT `width`), so it never reflows the layout and the
11
+ * dock stays perfectly stable under the cursor.
12
+ * Icons come from lucide-react. The dock itself is a frosted-glass pill.
13
+ *
14
+ * Two entries wire to the two right-side panels through window CustomEvents
15
+ * (a cross-plugin bridge so this component never imports the file-manager
16
+ * bundle):
17
+ *
18
+ * - terminal -> `dsh-dock:toggle-terminal` (handled in dsh-term index)
19
+ * - file -> `dsh-dock:toggle-filepanel` (handled in dsh-file-manager)
20
+ *
21
+ * Active (open) state is mirrored back through `dsh-dock:terminal-state` /
22
+ * `dsh-dock:filepanel-state` so the icons highlight in lockstep with the
23
+ * actual panels.
24
+ * @module dsh-term/client/AnimatedDock
25
+ */
26
+
27
+ import { useEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
28
+ import { motion, useMotionValue, useSpring, useTransform, type MotionValue } from 'framer-motion'
29
+ import { Terminal, FolderOpen } from 'lucide-react'
30
+ import styles from './animated-dock.module.css'
31
+
32
+ /** Cross-plugin event names (kept as literals to avoid cross-bundle imports). */
33
+ const EV = {
34
+ toggleTerminal: 'dsh-dock:toggle-terminal',
35
+ toggleFile: 'dsh-dock:toggle-filepanel',
36
+ terminalState: 'dsh-dock:terminal-state',
37
+ fileState: 'dsh-dock:filepanel-state',
38
+ } as const
39
+
40
+ /** Resting / peak icon size (px). */
41
+ const BASE = 40
42
+ const MAX = 58
43
+ /** Magnification factor at the cursor (MAX / BASE). Driven via CSS `scale()`
44
+ * so it never reflows the layout — this is what removed the old "jitter". */
45
+ const MAX_SCALE = MAX / BASE
46
+ /** Cursor distance (px) over which magnification falls to zero. */
47
+ const DIST = 120
48
+ /** Spring tuning. Near-critically damped (ratio ≈ 0.94) so the icon settles
49
+ * smoothly with essentially no overshoot. The old `damping: 0.35` was
50
+ * effectively zero damping and caused the wild oscillation. */
51
+ const DAMPING = 28
52
+ const STIFFNESS = 220
53
+
54
+ export function AnimatedDock(): ReactElement {
55
+ const mouseX = useMotionValue<number>(Infinity)
56
+ const [terminalActive, setTerminalActive] = useState(false)
57
+ const [fileActive, setFileActive] = useState(false)
58
+
59
+ // Mirror the real panel open-state back into the icon highlight.
60
+ useEffect(() => {
61
+ const onTerm = (e: Event): void => setTerminalActive(Boolean((e as CustomEvent).detail))
62
+ const onFile = (e: Event): void => setFileActive(Boolean((e as CustomEvent).detail))
63
+ window.addEventListener(EV.terminalState, onTerm)
64
+ window.addEventListener(EV.fileState, onFile)
65
+ return () => {
66
+ window.removeEventListener(EV.terminalState, onTerm)
67
+ window.removeEventListener(EV.fileState, onFile)
68
+ }
69
+ }, [])
70
+
71
+ return (
72
+ <motion.div
73
+ className={styles.dock}
74
+ onMouseMove={(e) => mouseX.set(e.clientX)}
75
+ onMouseLeave={() => mouseX.set(Infinity)}
76
+ aria-label="工具坞"
77
+ >
78
+ <DockItem
79
+ mouseX={mouseX}
80
+ active={terminalActive}
81
+ label="终端"
82
+ onClick={() => window.dispatchEvent(new CustomEvent(EV.toggleTerminal))}
83
+ >
84
+ <Terminal size={20} strokeWidth={2} />
85
+ </DockItem>
86
+ <DockItem
87
+ mouseX={mouseX}
88
+ active={fileActive}
89
+ label="文件面板"
90
+ onClick={() => window.dispatchEvent(new CustomEvent(EV.toggleFile))}
91
+ >
92
+ <FolderOpen size={20} strokeWidth={2} />
93
+ </DockItem>
94
+ </motion.div>
95
+ )
96
+ }
97
+
98
+ /** A single magnifying icon. Its `scale` is a spring driven by cursor distance.
99
+ * Because we animate `transform` (not `width`), the layout never reflows, so
100
+ * there is no neighbour-push feedback loop and the dock stays rock-steady. */
101
+ function DockItem(props: {
102
+ mouseX: MotionValue<number>
103
+ active: boolean
104
+ label: string
105
+ onClick: () => void
106
+ children: ReactNode
107
+ }): ReactElement {
108
+ const ref = useRef<HTMLButtonElement>(null)
109
+ const distance = useTransform(props.mouseX, (val) => {
110
+ const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: BASE }
111
+ return val - bounds.x - bounds.width / 2
112
+ })
113
+ const scale = useSpring(
114
+ useTransform(distance, [-DIST, 0, DIST], [1, MAX_SCALE, 1]),
115
+ { damping: DAMPING, stiffness: STIFFNESS },
116
+ )
117
+ return (
118
+ <motion.button
119
+ ref={ref}
120
+ type="button"
121
+ style={{ scale }}
122
+ className={`${styles.item}${props.active ? ` ${styles.active}` : ''}`}
123
+ onClick={props.onClick}
124
+ aria-label={props.label}
125
+ aria-pressed={props.active}
126
+ title={props.label}
127
+ >
128
+ <span className={styles.tooltip}>{props.label}</span>
129
+ {props.children}
130
+ </motion.button>
131
+ )
132
+ }
@@ -0,0 +1,71 @@
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(0, 0, 0, 0.85);
10
+ border: 1px solid rgba(255, 255, 255, 0.12);
11
+ backdrop-filter: blur(12px);
12
+ -webkit-backdrop-filter: blur(12px);
13
+ overflow: visible;
14
+ }
15
+
16
+ /* Circular icon button. The framer-motion spring sets `transform: scale()`
17
+ inline (fixed 40×40 base box; the visual size grows via scale, never via
18
+ layout). `transform-origin: center bottom` makes it rise off the shelf like a
19
+ real dock without ever reflowing its neighbours. */
20
+ .item {
21
+ position: relative;
22
+ width: 40px;
23
+ height: 40px;
24
+ display: inline-flex;
25
+ align-items: center;
26
+ justify-content: center;
27
+ border: none;
28
+ border-radius: 9999px;
29
+ background: #000000;
30
+ color: #ffffff;
31
+ cursor: pointer;
32
+ overflow: visible;
33
+ transform-origin: center bottom;
34
+ will-change: transform;
35
+ transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
36
+ }
37
+
38
+ .item:hover {
39
+ background: #1a1a1a;
40
+ color: #ffffff;
41
+ }
42
+
43
+ /* Open-panel highlight: primary tint + thin ring to mirror the live state. */
44
+ .item.active {
45
+ background: var(--aion-primary, #6ea0ff);
46
+ color: #ffffff;
47
+ box-shadow: 0 0 0 1.5px var(--aion-primary, #6ea0ff);
48
+ }
49
+
50
+ /* Hover tooltip — the small label that floats above each icon (21st.dev style). */
51
+ .tooltip {
52
+ position: absolute;
53
+ top: -30px;
54
+ left: 50%;
55
+ transform: translateX(-50%);
56
+ padding: 2px 8px;
57
+ font-size: 12px;
58
+ line-height: 1.4;
59
+ white-space: nowrap;
60
+ color: var(--aion-fg-1, #ffffff);
61
+ background: rgba(20, 20, 30, 0.9);
62
+ border: 1px solid rgba(255, 255, 255, 0.12);
63
+ border-radius: 6px;
64
+ opacity: 0;
65
+ pointer-events: none;
66
+ transition: opacity 0.15s ease;
67
+ }
68
+
69
+ .item:hover .tooltip {
70
+ opacity: 1;
71
+ }