@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.
- package/lib/client.js +43846 -110
- package/lib/client.js.map +1 -1
- package/lib/tsconfig.client.tsbuildinfo +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/term/AnimatedDock.d.ts +26 -0
- package/lib/types/client/term/AnimatedDock.d.ts.map +1 -0
- package/package.json +3 -1
- package/src/client/index.ts +26 -0
- package/src/client/term/AnimatedDock.tsx +123 -0
- package/src/client/term/animated-dock.module.css +67 -0
|
@@ -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
|
+
}
|