@beforegolive/floating-ball 0.1.0

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/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @beforegolive/floating-ball
2
+
3
+ React 悬浮球组件,预编译样式,零依赖配置。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ npm install @beforegolive/floating-ball
9
+ ```
10
+
11
+ ## 使用
12
+
13
+ ```tsx
14
+ import FloatingBall from '@beforegolive/floating-ball';
15
+
16
+ function App() {
17
+ return (
18
+ <>
19
+ <FloatingBall
20
+ version="1.0.0"
21
+ buildTime="2026-04-06"
22
+ theme="light"
23
+ />
24
+ </>
25
+ );
26
+ }
27
+ ```
28
+
29
+ ## API
30
+
31
+ | Prop | 类型 | 默认值 | 说明 |
32
+ |------|------|--------|------|
33
+ | `version` | `string` | 必填 | 版本号 |
34
+ | `buildTime` | `string` | 必填 | 构建时间 |
35
+ | `theme` | `'light' \| 'dark'` | `'light'` | 主题色 |
36
+ | `position` | `{ x: number, y: number }` | 右下角 | 初始位置 |
37
+ | `menuItems` | `MenuItem[]` | 默认菜单 | 自定义菜单项 |
38
+ | `storageKey` | `string` | `'floating-ball-position'` | localStorage key |
39
+
40
+ ## peerDependencies
41
+
42
+ - react >= 18.0.0
43
+ - react-dom >= 18.0.0
44
+ - react-router-dom >= 6.0.0
package/index.html ADDED
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Floating Ball Demo</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/demo.tsx"></script>
11
+ </body>
12
+ </html>
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@beforegolive/floating-ball",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "dev": "vite",
16
+ "demo": "vite --open",
17
+ "build": "vite build && cp dist/src/index.d.ts dist/index.d.ts",
18
+ "preview": "vite preview"
19
+ },
20
+ "peerDependencies": {
21
+ "react": ">=18.0.0",
22
+ "react-dom": ">=18.0.0",
23
+ "react-router-dom": ">=6.0.0"
24
+ },
25
+ "dependencies": {
26
+ "goober": "^2.1.18"
27
+ },
28
+ "devDependencies": {
29
+ "@types/react": "^19.1.0",
30
+ "@types/react-dom": "^19.1.0",
31
+ "@vitejs/plugin-react": "^4.4.1",
32
+ "typescript": "^5.9.3",
33
+ "vite": "^6.3.5",
34
+ "vite-plugin-dts": "^4.5.4"
35
+ }
36
+ }
@@ -0,0 +1,357 @@
1
+ import { useState, useRef, useEffect, useCallback } from "react";
2
+ import { css, setup } from "goober";
3
+ import type { FloatingBallProps, MenuItem, Position, VersionInfo } from "../types";
4
+
5
+ // 初始化 goober
6
+ setup(css);
7
+
8
+ // 样式定义
9
+ const ballStyle = css`
10
+ position: fixed;
11
+ display: flex;
12
+ flex-direction: column;
13
+ align-items: center;
14
+ justify-content: center;
15
+ cursor: grab;
16
+ user-select: none;
17
+ color: white;
18
+ font-size: 10px;
19
+ border: 2px solid rgba(255, 255, 255, 0.9);
20
+ backdrop-filter: blur(10px);
21
+ -webkit-backdrop-filter: blur(10px);
22
+ opacity: 0.85;
23
+ &:active {
24
+ cursor: grabbing;
25
+ }
26
+ `;
27
+
28
+ const menuOverlayStyle = css`
29
+ position: fixed;
30
+ inset: 0;
31
+ z-index: 40;
32
+ `;
33
+
34
+ const menuStyle = css`
35
+ position: fixed;
36
+ z-index: 50;
37
+ background-color: #ffffff;
38
+ border-radius: 8px;
39
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
40
+ border: 1px solid rgba(0, 0, 0, 0.1);
41
+ padding: 8px 0;
42
+ min-width: 144px;
43
+ `;
44
+
45
+ const menuItemStyle = css`
46
+ width: 100%;
47
+ padding: 8px 16px;
48
+ display: flex;
49
+ align-items: center;
50
+ gap: 12px;
51
+ font-size: 14px;
52
+ color: #333;
53
+ cursor: pointer;
54
+ background: none;
55
+ border: none;
56
+ text-align: left;
57
+ &:hover {
58
+ background-color: rgba(0, 0, 0, 0.05);
59
+ }
60
+ `;
61
+
62
+ interface InternalProps extends FloatingBallProps {
63
+ versionInfo?: VersionInfo;
64
+ }
65
+
66
+ const DEFAULT_WIDTH = 80;
67
+ const DEFAULT_HEIGHT = 32;
68
+ const DEFAULT_BORDER_RADIUS = 12;
69
+
70
+ const FloatingBall = ({
71
+ extraMenuItems,
72
+ onClick,
73
+ storageKey = "floating-ball-position",
74
+ defaultPosition = { x: 16, y: 16 },
75
+ width = DEFAULT_WIDTH,
76
+ height = DEFAULT_HEIGHT,
77
+ borderRadius = DEFAULT_BORDER_RADIUS,
78
+ bgColor = "rgb(34, 139, 34)",
79
+ className = "",
80
+ zIndex = 2999,
81
+ versionInfo,
82
+ }: InternalProps) => {
83
+ const [position, setPosition] = useState<Position>(() => {
84
+ const saved = localStorage.getItem(storageKey);
85
+ if (saved) {
86
+ try {
87
+ return JSON.parse(saved);
88
+ } catch {
89
+ return defaultPosition;
90
+ }
91
+ }
92
+ return defaultPosition;
93
+ });
94
+
95
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
96
+ const isDraggingRef = useRef(false);
97
+ const dragStartRef = useRef({ x: 0, y: 0 });
98
+ const positionRef = useRef(position);
99
+ const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
100
+ const touchHandledRef = useRef(false);
101
+ const doubleClickFiredRef = useRef(false);
102
+ const ballRef = useRef<HTMLDivElement>(null);
103
+ const lastTapRef = useRef<{ time: number; x: number; y: number } | null>(null);
104
+
105
+ useEffect(() => {
106
+ positionRef.current = position;
107
+ }, [position]);
108
+
109
+ const handleDragStart = useCallback((clientX: number, clientY: number) => {
110
+ isDraggingRef.current = false;
111
+ dragStartRef.current = { x: clientX, y: clientY };
112
+ }, []);
113
+
114
+ const handleDragMove = useCallback(
115
+ (clientX: number, clientY: number) => {
116
+ const dx = clientX - dragStartRef.current.x;
117
+ const dy = clientY - dragStartRef.current.y;
118
+ if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
119
+ isDraggingRef.current = true;
120
+ }
121
+ if (isDraggingRef.current) {
122
+ const newX = Math.max(0, Math.min(window.innerWidth - width, positionRef.current.x + dx));
123
+ const newY = Math.max(0, Math.min(window.innerHeight - height, positionRef.current.y + dy));
124
+ setPosition({ x: newX, y: newY });
125
+ dragStartRef.current = { x: clientX, y: clientY };
126
+ }
127
+ },
128
+ [width, height]
129
+ );
130
+
131
+ const handleDragEnd = useCallback(() => {
132
+ if (isDraggingRef.current) {
133
+ localStorage.setItem(storageKey, JSON.stringify(positionRef.current));
134
+ }
135
+ }, [storageKey]);
136
+
137
+ const handleMouseDown = useCallback(
138
+ (e: React.MouseEvent) => {
139
+ if (e.button !== 0) return;
140
+ handleDragStart(e.clientX, e.clientY);
141
+
142
+ const handleMouseMove = (moveEvent: MouseEvent) => {
143
+ handleDragMove(moveEvent.clientX, moveEvent.clientY);
144
+ };
145
+
146
+ const handleMouseUp = () => {
147
+ handleDragEnd();
148
+ isDraggingRef.current = false;
149
+ document.removeEventListener("mousemove", handleMouseMove);
150
+ document.removeEventListener("mouseup", handleMouseUp);
151
+ };
152
+
153
+ document.addEventListener("mousemove", handleMouseMove);
154
+ document.addEventListener("mouseup", handleMouseUp);
155
+ },
156
+ [handleDragStart, handleDragMove, handleDragEnd]
157
+ );
158
+
159
+ const handleClick = useCallback(() => {
160
+ if (touchHandledRef.current) {
161
+ return;
162
+ }
163
+
164
+ if (onClick === false) return;
165
+
166
+ if (extraMenuItems && extraMenuItems.length > 0) {
167
+ if (clickTimerRef.current) {
168
+ clearTimeout(clickTimerRef.current);
169
+ }
170
+
171
+ clickTimerRef.current = setTimeout(() => {
172
+ clickTimerRef.current = null;
173
+ if (!isDraggingRef.current && !doubleClickFiredRef.current) {
174
+ onClick ? onClick() : window.location.reload();
175
+ }
176
+ }, 300);
177
+ } else {
178
+ if (!isDraggingRef.current) {
179
+ onClick ? onClick() : window.location.reload();
180
+ }
181
+ }
182
+ }, [onClick, extraMenuItems]);
183
+
184
+ const handleDoubleClick = useCallback(() => {
185
+ if (clickTimerRef.current) {
186
+ clearTimeout(clickTimerRef.current);
187
+ clickTimerRef.current = null;
188
+ }
189
+ if (!isDraggingRef.current && extraMenuItems && extraMenuItems.length > 0) {
190
+ setIsMenuOpen(true);
191
+ }
192
+ doubleClickFiredRef.current = true;
193
+ setTimeout(() => {
194
+ doubleClickFiredRef.current = false;
195
+ }, 400);
196
+ }, [extraMenuItems]);
197
+
198
+ const handleMouseDownBall = useCallback(
199
+ (e: React.MouseEvent) => {
200
+ e.stopPropagation();
201
+ handleMouseDown(e);
202
+ },
203
+ [handleMouseDown]
204
+ );
205
+
206
+ useEffect(() => {
207
+ const ball = ballRef.current;
208
+ if (!ball) return;
209
+
210
+ const onTouchStart = (e: TouchEvent) => {
211
+ if (e.touches.length !== 1) return;
212
+ handleDragStart(e.touches[0].clientX, e.touches[0].clientY);
213
+ };
214
+ const onTouchMove = (e: TouchEvent) => {
215
+ if (e.touches.length !== 1) return;
216
+ handleDragMove(e.touches[0].clientX, e.touches[0].clientY);
217
+ if (isDraggingRef.current) {
218
+ e.preventDefault();
219
+ }
220
+ };
221
+ const onTouchEnd = (_e: TouchEvent) => {
222
+ handleDragEnd();
223
+ if (!isDraggingRef.current) {
224
+ const now = Date.now();
225
+ const lastTap = lastTapRef.current;
226
+ const isMobileDoubleTap =
227
+ lastTap &&
228
+ now - lastTap.time < 300 &&
229
+ Math.abs(position.x - lastTap.x) < 10 &&
230
+ Math.abs(position.y - lastTap.y) < 10;
231
+
232
+ if (isMobileDoubleTap) {
233
+ lastTapRef.current = null;
234
+ if (clickTimerRef.current) {
235
+ clearTimeout(clickTimerRef.current);
236
+ clickTimerRef.current = null;
237
+ }
238
+ if (extraMenuItems && extraMenuItems.length > 0) {
239
+ setIsMenuOpen(true);
240
+ }
241
+ doubleClickFiredRef.current = true;
242
+ setTimeout(() => {
243
+ doubleClickFiredRef.current = false;
244
+ }, 400);
245
+ touchHandledRef.current = true;
246
+ return;
247
+ }
248
+
249
+ lastTapRef.current = { time: now, x: position.x, y: position.y };
250
+
251
+ if (clickTimerRef.current) {
252
+ clearTimeout(clickTimerRef.current);
253
+ }
254
+
255
+ if (extraMenuItems && extraMenuItems.length > 0) {
256
+ clickTimerRef.current = setTimeout(() => {
257
+ clickTimerRef.current = null;
258
+ lastTapRef.current = null;
259
+ if (!doubleClickFiredRef.current) {
260
+ onClick ? onClick() : window.location.reload();
261
+ }
262
+ }, 300);
263
+ } else {
264
+ onClick ? onClick() : window.location.reload();
265
+ }
266
+
267
+ touchHandledRef.current = true;
268
+ }
269
+ };
270
+ const onDblClick = () => {
271
+ handleDoubleClick();
272
+ };
273
+
274
+ ball.addEventListener("touchstart", onTouchStart, { passive: false });
275
+ ball.addEventListener("touchmove", onTouchMove, { passive: false });
276
+ ball.addEventListener("touchend", onTouchEnd);
277
+ ball.addEventListener("dblclick", onDblClick);
278
+
279
+ return () => {
280
+ ball.removeEventListener("touchstart", onTouchStart);
281
+ ball.removeEventListener("touchmove", onTouchMove);
282
+ ball.removeEventListener("touchend", onTouchEnd);
283
+ ball.removeEventListener("dblclick", onDblClick);
284
+ };
285
+ }, [handleDragStart, handleDragMove, handleDragEnd, handleDoubleClick]);
286
+
287
+ const defaultMenuItems: MenuItem[] = [
288
+ { label: "刷新", icon: "🔄", action: () => window.location.reload() },
289
+ { label: "回到首页", icon: "🏠", action: () => { window.location.href = "/"; } },
290
+ ];
291
+ const menuItems: MenuItem[] = [...defaultMenuItems, ...(extraMenuItems || [])];
292
+
293
+ const closeMenu = useCallback(() => {
294
+ setIsMenuOpen(false);
295
+ }, []);
296
+
297
+ return (
298
+ <>
299
+ <div
300
+ ref={ballRef}
301
+ className={ballStyle}
302
+ style={{
303
+ left: position.x,
304
+ top: position.y,
305
+ width,
306
+ height,
307
+ borderRadius,
308
+ zIndex,
309
+ backgroundColor: bgColor,
310
+ }}
311
+ onMouseDown={handleMouseDownBall}
312
+ onClick={handleClick}
313
+ >
314
+ {versionInfo?.buildTime && (
315
+ <span style={{ opacity: 0.8, lineHeight: 1.1, textAlign: "center", margin: 0 }}>
316
+ {versionInfo.buildTime}
317
+ </span>
318
+ )}
319
+ {versionInfo?.version && (
320
+ <span style={{ opacity: 0.8, lineHeight: 1.1, textAlign: "center", margin: 0, fontWeight: "bold" }}>
321
+ v{versionInfo.version}
322
+ </span>
323
+ )}
324
+ </div>
325
+
326
+ {isMenuOpen && menuItems.length > 0 && (
327
+ <>
328
+ <div className={menuOverlayStyle} onClick={closeMenu} />
329
+ <div
330
+ className={menuStyle}
331
+ style={{
332
+ left: Math.max(16, Math.min(position.x, window.innerWidth - 160)),
333
+ top: position.y + height + 8,
334
+ }}
335
+ >
336
+ {menuItems.map((item, index) => (
337
+ <button
338
+ key={`${item.label}-${index}`}
339
+ className={menuItemStyle}
340
+ onClick={() => {
341
+ setIsMenuOpen(false);
342
+ item.action();
343
+ }}
344
+ >
345
+ {item.icon && <span>{item.icon}</span>}
346
+ <span>{item.label}</span>
347
+ </button>
348
+ ))}
349
+ </div>
350
+ </>
351
+ )}
352
+ </>
353
+ );
354
+ };
355
+
356
+ export default FloatingBall;
357
+ export type { FloatingBallProps, MenuItem, Position, VersionInfo };
package/src/demo.tsx ADDED
@@ -0,0 +1,64 @@
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import { BrowserRouter, useNavigate } from 'react-router-dom';
4
+ import FloatingBall from './FloatingBall';
5
+
6
+ const VERSION = '1.0.0';
7
+ const now = new Date();
8
+ const BUILD_TIME = `${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}(${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')})`;
9
+
10
+ function DemoApp() {
11
+ const navigate = useNavigate();
12
+
13
+ const extraMenuItems = [
14
+ {
15
+ label: '打开笔记页面',
16
+ icon: '📝',
17
+ action: () => {
18
+ navigate('/notes');
19
+ },
20
+ },
21
+ ];
22
+
23
+ return (
24
+ <div style={{ padding: '20px', fontFamily: 'system-ui' }}>
25
+ <h1>Floating Ball Demo</h1>
26
+ <p>点击悬浮球:刷新页面</p>
27
+ <p>双击悬浮球:打开菜单</p>
28
+ <p>拖拽悬浮球:移动位置</p>
29
+
30
+ <div style={{ marginTop: '100px' }}>
31
+ <h2>页面内容</h2>
32
+ <p>这是演示页面,用于测试悬浮球组件。</p>
33
+ </div>
34
+
35
+ <FloatingBall
36
+ extraMenuItems={extraMenuItems}
37
+ storageKey="floating-ball-position"
38
+ defaultPosition={{ x: 16, y: 16 }}
39
+ width={80}
40
+ height={32}
41
+ borderRadius={12}
42
+ bgColor="rgb(34, 139, 34)"
43
+ versionInfo={{
44
+ version: VERSION,
45
+ buildTime: BUILD_TIME,
46
+ }}
47
+ />
48
+ </div>
49
+ );
50
+ }
51
+
52
+ function App() {
53
+ return (
54
+ <BrowserRouter>
55
+ <DemoApp />
56
+ </BrowserRouter>
57
+ );
58
+ }
59
+
60
+ ReactDOM.createRoot(document.getElementById('root')!).render(
61
+ <React.StrictMode>
62
+ <App />
63
+ </React.StrictMode>
64
+ );
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default } from './FloatingBall';
2
+ export type { FloatingBallProps, MenuItem, Position, VersionInfo } from '../types';
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "resolveJsonModule": true,
10
+ "isolatedModules": true,
11
+ "jsx": "react-jsx",
12
+ "strict": true,
13
+ "noUnusedLocals": true,
14
+ "noUnusedParameters": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "declaration": true,
17
+ "declarationDir": "./dist",
18
+ "noEmit": false
19
+ },
20
+ "include": ["src", "types"]
21
+ }
package/types/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ export interface MenuItem {
2
+ /** 显示的标签 */
3
+ label: string;
4
+ /** 图标(emoji 或 HTML) */
5
+ icon?: string;
6
+ /** 点击后的行为 */
7
+ action: () => void;
8
+ }
9
+
10
+ export interface FloatingBallProps {
11
+ /** 附加菜单项(常驻项:刷新、回到首页) */
12
+ extraMenuItems?: MenuItem[];
13
+ /** 刷新菜单项的点击行为 */
14
+ onRefresh?: () => void;
15
+ /** 回到首页菜单项的点击行为 */
16
+ onGoHome?: () => void;
17
+ /** 单击行为(默认刷新页面),设为 false 禁用单击 */
18
+ onClick?: (() => void) | false;
19
+ /** localStorage 存储位置持久化的 key */
20
+ storageKey?: string;
21
+ /** 默认位置 */
22
+ defaultPosition?: Position;
23
+ /** 悬浮球尺寸 */
24
+ width?: number;
25
+ height?: number;
26
+ /** 圆角 */
27
+ borderRadius?: number;
28
+ /** 背景色,默认蓝色 */
29
+ bgColor?: string;
30
+ /** 样式类名 */
31
+ className?: string;
32
+ /** 层级 */
33
+ zIndex?: number;
34
+ }
35
+
36
+ export interface Position {
37
+ x: number;
38
+ y: number;
39
+ }
40
+
41
+ export interface VersionInfo {
42
+ version?: string;
43
+ buildTime?: string;
44
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import dts from 'vite-plugin-dts'
4
+ import { resolve } from 'path'
5
+
6
+ export default defineConfig({
7
+ plugins: [react(), dts({
8
+ include: 'src/index.ts',
9
+ })],
10
+ build: {
11
+ lib: {
12
+ entry: resolve(__dirname, 'src/index.ts'),
13
+ name: 'FloatingBall',
14
+ formats: ['es'],
15
+ fileName: 'index',
16
+ },
17
+ rollupOptions: {
18
+ external: ['react', 'react-dom', 'react-router-dom'],
19
+ output: {
20
+ globals: {
21
+ react: 'React',
22
+ 'react-dom': 'ReactDOM',
23
+ 'react-router-dom': 'ReactRouterDOM',
24
+ },
25
+ },
26
+ },
27
+ },
28
+ server: {
29
+ open: true,
30
+ },
31
+ })