react-edge-dock 1.0.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/LICENSE +21 -0
- package/README.md +41 -0
- package/dist/EdgeDock.d.ts +17 -0
- package/dist/EdgeDock.d.ts.map +1 -0
- package/dist/EdgeDock.js +69 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/types.d.ts +109 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/useEdgeDock.d.ts +6 -0
- package/dist/useEdgeDock.d.ts.map +1 -0
- package/dist/useEdgeDock.js +356 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 [Your Name]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# react-edge-dock
|
|
2
|
+
|
|
3
|
+
A zero-dependency React + TypeScript library for customizable draggable edge-docked floating buttons with popup support.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🎯 Zero dependencies (React only)
|
|
8
|
+
- 🎨 Fully customizable and headless
|
|
9
|
+
- 📱 Touch and pointer event support
|
|
10
|
+
- 🎬 Smooth animations with spring physics
|
|
11
|
+
- 📦 TypeScript first with full type safety
|
|
12
|
+
- 🎮 Multiple docking modes (free, auto, manual)
|
|
13
|
+
- 💡 Smart popup positioning
|
|
14
|
+
- âš¡ Performance optimized with transform: translate3d
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install react-edge-dock
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { EdgeDock } from 'react-edge-dock';
|
|
26
|
+
|
|
27
|
+
function App() {
|
|
28
|
+
return (
|
|
29
|
+
<EdgeDock
|
|
30
|
+
dockMode="auto"
|
|
31
|
+
animation={true}
|
|
32
|
+
button={<button>🚀</button>}
|
|
33
|
+
popup={<div>Your content here</div>}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## API
|
|
40
|
+
|
|
41
|
+
See the [example.tsx](./example.tsx) file for more detailed usage examples.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { EdgeDockProps } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* EdgeDock - A customizable draggable edge-docked floating button with popup
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <EdgeDock
|
|
9
|
+
* dockMode="auto"
|
|
10
|
+
* animation={true}
|
|
11
|
+
* button={<button>Click me</button>}
|
|
12
|
+
* popup={<div>Popup content</div>}
|
|
13
|
+
* />
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare function EdgeDock(props: EdgeDockProps): React.JSX.Element;
|
|
17
|
+
//# sourceMappingURL=EdgeDock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EdgeDock.d.ts","sourceRoot":"","sources":["../src/EdgeDock.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAE7C;;;;;;;;;;;;GAYG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,qBAkF5C"}
|
package/dist/EdgeDock.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useEdgeDock } from './useEdgeDock';
|
|
3
|
+
/**
|
|
4
|
+
* EdgeDock - A customizable draggable edge-docked floating button with popup
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <EdgeDock
|
|
9
|
+
* dockMode="auto"
|
|
10
|
+
* animation={true}
|
|
11
|
+
* button={<button>Click me</button>}
|
|
12
|
+
* popup={<div>Popup content</div>}
|
|
13
|
+
* />
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export function EdgeDock(props) {
|
|
17
|
+
const { button, popup, className, style, dockMode, dockEdge, position, animation, popupGap, zIndex, onDockChange, isPopupOpen, onPopupChange, } = props;
|
|
18
|
+
const { state, buttonRef, popupRef, closePopup, buttonStyles, popupStyles, buttonProps, } = useEdgeDock({
|
|
19
|
+
dockMode,
|
|
20
|
+
dockEdge,
|
|
21
|
+
position,
|
|
22
|
+
animation,
|
|
23
|
+
popupGap,
|
|
24
|
+
zIndex,
|
|
25
|
+
onDockChange,
|
|
26
|
+
isPopupOpen,
|
|
27
|
+
onPopupChange,
|
|
28
|
+
});
|
|
29
|
+
// Render button content
|
|
30
|
+
const renderButton = () => {
|
|
31
|
+
if (typeof button === 'function') {
|
|
32
|
+
return button(state);
|
|
33
|
+
}
|
|
34
|
+
return button || React.createElement(DefaultButton, null);
|
|
35
|
+
};
|
|
36
|
+
// Render popup content
|
|
37
|
+
const renderPopup = () => {
|
|
38
|
+
if (!popup)
|
|
39
|
+
return null;
|
|
40
|
+
if (typeof popup === 'function') {
|
|
41
|
+
return popup(state, closePopup);
|
|
42
|
+
}
|
|
43
|
+
return popup;
|
|
44
|
+
};
|
|
45
|
+
return (React.createElement(React.Fragment, null,
|
|
46
|
+
React.createElement("div", { ref: buttonRef, className: className, style: style ? { ...buttonStyles, ...style } : buttonStyles, onPointerDown: buttonProps.onPointerDown, onClick: buttonProps.onClick },
|
|
47
|
+
React.createElement("div", { style: buttonProps.style }, renderButton())),
|
|
48
|
+
popup && (React.createElement("div", { ref: popupRef, style: popupStyles, onClick: (e) => e.stopPropagation() }, renderPopup()))));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Default button component
|
|
52
|
+
*/
|
|
53
|
+
function DefaultButton() {
|
|
54
|
+
return (React.createElement("div", { style: {
|
|
55
|
+
width: 48,
|
|
56
|
+
height: 48,
|
|
57
|
+
borderRadius: '50%',
|
|
58
|
+
backgroundColor: '#0070f3',
|
|
59
|
+
color: 'white',
|
|
60
|
+
display: 'flex',
|
|
61
|
+
alignItems: 'center',
|
|
62
|
+
justifyContent: 'center',
|
|
63
|
+
fontSize: 24,
|
|
64
|
+
fontWeight: 'bold',
|
|
65
|
+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
|
66
|
+
cursor: 'pointer',
|
|
67
|
+
userSelect: 'none',
|
|
68
|
+
} }, "\u26A1"));
|
|
69
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* react-edge-dock
|
|
3
|
+
* A zero-dependency React + TypeScript library for customizable draggable edge-docked floating buttons
|
|
4
|
+
*/
|
|
5
|
+
export { EdgeDock } from './EdgeDock';
|
|
6
|
+
export { useEdgeDock } from './useEdgeDock';
|
|
7
|
+
export type { DockEdge, DockMode, Position, DockState, EdgeDockConfig, EdgeDockProps, UseEdgeDockReturn, ViewportBounds, ElementDimensions, } from './types';
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,YAAY,EACV,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,cAAc,EACd,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,iBAAiB,GAClB,MAAM,SAAS,CAAC"}
|
package/dist/index.js
ADDED
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Dock edge position
|
|
4
|
+
*/
|
|
5
|
+
export type DockEdge = 'left' | 'right' | 'top' | 'bottom';
|
|
6
|
+
/**
|
|
7
|
+
* Dock mode determines how the button snaps
|
|
8
|
+
*/
|
|
9
|
+
export type DockMode = 'free' | 'auto' | 'manual';
|
|
10
|
+
/**
|
|
11
|
+
* 2D position coordinates
|
|
12
|
+
*/
|
|
13
|
+
export interface Position {
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Dock state information
|
|
19
|
+
*/
|
|
20
|
+
export interface DockState {
|
|
21
|
+
/** Current position in pixels */
|
|
22
|
+
position: Position;
|
|
23
|
+
/** Current docked edge (null if in free mode) */
|
|
24
|
+
dockedEdge: DockEdge | null;
|
|
25
|
+
/** Whether the button is currently being dragged */
|
|
26
|
+
isDragging: boolean;
|
|
27
|
+
/** Whether the popup is currently open */
|
|
28
|
+
isPopupOpen: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Configuration for EdgeDock component
|
|
32
|
+
*/
|
|
33
|
+
export interface EdgeDockConfig {
|
|
34
|
+
/** Docking behavior mode */
|
|
35
|
+
dockMode?: DockMode;
|
|
36
|
+
/** Fixed edge for manual dock mode */
|
|
37
|
+
dockEdge?: DockEdge;
|
|
38
|
+
/** Initial or controlled position */
|
|
39
|
+
position?: Position;
|
|
40
|
+
/** Enable snap animations */
|
|
41
|
+
animation?: boolean;
|
|
42
|
+
/** Gap between button and popup in pixels */
|
|
43
|
+
popupGap?: number;
|
|
44
|
+
/** z-index for the dock container */
|
|
45
|
+
zIndex?: number;
|
|
46
|
+
/** Callback when dock state changes */
|
|
47
|
+
onDockChange?: (state: DockState) => void;
|
|
48
|
+
/** Controlled popup open state */
|
|
49
|
+
isPopupOpen?: boolean;
|
|
50
|
+
/** Callback when popup state changes */
|
|
51
|
+
onPopupChange?: (isOpen: boolean) => void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Props for EdgeDock component
|
|
55
|
+
*/
|
|
56
|
+
export interface EdgeDockProps extends EdgeDockConfig {
|
|
57
|
+
/** Custom button element or render prop */
|
|
58
|
+
button?: ReactNode | ((state: DockState) => ReactNode);
|
|
59
|
+
/** Custom popup content or render prop */
|
|
60
|
+
popup?: ReactNode | ((state: DockState, close: () => void) => ReactNode);
|
|
61
|
+
/** Additional CSS class for container */
|
|
62
|
+
className?: string;
|
|
63
|
+
/** Additional inline styles for container */
|
|
64
|
+
style?: React.CSSProperties;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Return type for useEdgeDock hook
|
|
68
|
+
*/
|
|
69
|
+
export interface UseEdgeDockReturn {
|
|
70
|
+
/** Current dock state */
|
|
71
|
+
state: DockState;
|
|
72
|
+
/** Ref to attach to draggable button element */
|
|
73
|
+
buttonRef: React.RefObject<HTMLDivElement>;
|
|
74
|
+
/** Ref to attach to popup element */
|
|
75
|
+
popupRef: React.RefObject<HTMLDivElement>;
|
|
76
|
+
/** Toggle popup open/closed */
|
|
77
|
+
togglePopup: () => void;
|
|
78
|
+
/** Close popup */
|
|
79
|
+
closePopup: () => void;
|
|
80
|
+
/** Open popup */
|
|
81
|
+
openPopup: () => void;
|
|
82
|
+
/** Set position programmatically */
|
|
83
|
+
setPosition: (position: Position) => void;
|
|
84
|
+
/** Inline styles for button container */
|
|
85
|
+
buttonStyles: React.CSSProperties;
|
|
86
|
+
/** Inline styles for popup container */
|
|
87
|
+
popupStyles: React.CSSProperties;
|
|
88
|
+
/** Props to spread on button for drag handling */
|
|
89
|
+
buttonProps: {
|
|
90
|
+
onPointerDown: (e: React.PointerEvent) => void;
|
|
91
|
+
onClick: (e: React.MouseEvent) => void;
|
|
92
|
+
style: React.CSSProperties;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Internal viewport bounds
|
|
97
|
+
*/
|
|
98
|
+
export interface ViewportBounds {
|
|
99
|
+
width: number;
|
|
100
|
+
height: number;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Internal element dimensions
|
|
104
|
+
*/
|
|
105
|
+
export interface ElementDimensions {
|
|
106
|
+
width: number;
|
|
107
|
+
height: number;
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useEdgeDock.d.ts","sourceRoot":"","sources":["../src/useEdgeDock.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,cAAc,EAId,iBAAiB,EAGlB,MAAM,SAAS,CAAC;AA4IjB;;GAEG;AACH,wBAAgB,WAAW,CAAC,MAAM,GAAE,cAAmB,GAAG,iBAAiB,CAiT1E"}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Calculate which edge is closest to the given position
|
|
4
|
+
*/
|
|
5
|
+
function getClosestEdge(pos, viewport) {
|
|
6
|
+
const distanceToLeft = pos.x;
|
|
7
|
+
const distanceToRight = viewport.width - pos.x;
|
|
8
|
+
const distanceToTop = pos.y;
|
|
9
|
+
const distanceToBottom = viewport.height - pos.y;
|
|
10
|
+
const minDistance = Math.min(distanceToLeft, distanceToRight, distanceToTop, distanceToBottom);
|
|
11
|
+
if (minDistance === distanceToLeft)
|
|
12
|
+
return 'left';
|
|
13
|
+
if (minDistance === distanceToRight)
|
|
14
|
+
return 'right';
|
|
15
|
+
if (minDistance === distanceToTop)
|
|
16
|
+
return 'top';
|
|
17
|
+
return 'bottom';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Snap position to edge based on dock mode
|
|
21
|
+
*/
|
|
22
|
+
function snapToEdge(pos, edge, viewport, buttonDimensions) {
|
|
23
|
+
const halfWidth = buttonDimensions.width / 2;
|
|
24
|
+
const halfHeight = buttonDimensions.height / 2;
|
|
25
|
+
switch (edge) {
|
|
26
|
+
case 'left':
|
|
27
|
+
return {
|
|
28
|
+
x: halfWidth,
|
|
29
|
+
y: Math.max(halfHeight, Math.min(viewport.height - halfHeight, pos.y)),
|
|
30
|
+
};
|
|
31
|
+
case 'right':
|
|
32
|
+
return {
|
|
33
|
+
x: viewport.width - halfWidth,
|
|
34
|
+
y: Math.max(halfHeight, Math.min(viewport.height - halfHeight, pos.y)),
|
|
35
|
+
};
|
|
36
|
+
case 'top':
|
|
37
|
+
return {
|
|
38
|
+
x: Math.max(halfWidth, Math.min(viewport.width - halfWidth, pos.x)),
|
|
39
|
+
y: halfHeight,
|
|
40
|
+
};
|
|
41
|
+
case 'bottom':
|
|
42
|
+
return {
|
|
43
|
+
x: Math.max(halfWidth, Math.min(viewport.width - halfWidth, pos.x)),
|
|
44
|
+
y: viewport.height - halfHeight,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Constrain position within viewport bounds
|
|
50
|
+
*/
|
|
51
|
+
function constrainToViewport(pos, viewport, buttonDimensions) {
|
|
52
|
+
const halfWidth = buttonDimensions.width / 2;
|
|
53
|
+
const halfHeight = buttonDimensions.height / 2;
|
|
54
|
+
return {
|
|
55
|
+
x: Math.max(halfWidth, Math.min(viewport.width - halfWidth, pos.x)),
|
|
56
|
+
y: Math.max(halfHeight, Math.min(viewport.height - halfHeight, pos.y)),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Calculate popup position based on button position
|
|
61
|
+
*/
|
|
62
|
+
function calculatePopupPosition(buttonPos, buttonDimensions, popupDimensions, viewport, gap) {
|
|
63
|
+
const halfButtonWidth = buttonDimensions.width / 2;
|
|
64
|
+
const halfButtonHeight = buttonDimensions.height / 2;
|
|
65
|
+
// Determine optimal placement
|
|
66
|
+
const spaceRight = viewport.width - (buttonPos.x + halfButtonWidth);
|
|
67
|
+
const spaceLeft = buttonPos.x - halfButtonWidth;
|
|
68
|
+
const spaceBottom = viewport.height - (buttonPos.y + halfButtonHeight);
|
|
69
|
+
const spaceTop = buttonPos.y - halfButtonHeight;
|
|
70
|
+
let x = buttonPos.x;
|
|
71
|
+
let y = buttonPos.y;
|
|
72
|
+
let origin = 'center';
|
|
73
|
+
// Horizontal positioning
|
|
74
|
+
if (spaceRight >= popupDimensions.width + gap) {
|
|
75
|
+
// Open to the right
|
|
76
|
+
x = buttonPos.x + halfButtonWidth + gap;
|
|
77
|
+
origin = 'left';
|
|
78
|
+
}
|
|
79
|
+
else if (spaceLeft >= popupDimensions.width + gap) {
|
|
80
|
+
// Open to the left
|
|
81
|
+
x = buttonPos.x - halfButtonWidth - gap - popupDimensions.width;
|
|
82
|
+
origin = 'right';
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// Center horizontally
|
|
86
|
+
x = Math.max(0, Math.min(viewport.width - popupDimensions.width, buttonPos.x - popupDimensions.width / 2));
|
|
87
|
+
}
|
|
88
|
+
// Vertical positioning
|
|
89
|
+
if (spaceBottom >= popupDimensions.height + gap) {
|
|
90
|
+
// Open downward
|
|
91
|
+
y = buttonPos.y + halfButtonHeight + gap;
|
|
92
|
+
origin = origin === 'left' || origin === 'right' ? `${origin} top` : 'top';
|
|
93
|
+
}
|
|
94
|
+
else if (spaceTop >= popupDimensions.height + gap) {
|
|
95
|
+
// Open upward
|
|
96
|
+
y = buttonPos.y - halfButtonHeight - gap - popupDimensions.height;
|
|
97
|
+
origin = origin === 'left' || origin === 'right' ? `${origin} bottom` : 'bottom';
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// Center vertically
|
|
101
|
+
y = Math.max(0, Math.min(viewport.height - popupDimensions.height, buttonPos.y - popupDimensions.height / 2));
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
position: { x, y },
|
|
105
|
+
origin,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Main hook for edge dock functionality
|
|
110
|
+
*/
|
|
111
|
+
export function useEdgeDock(config = {}) {
|
|
112
|
+
const { dockMode = 'auto', dockEdge, position: controlledPosition, animation = true, popupGap = 12, zIndex = 9999, onDockChange, isPopupOpen: controlledPopupOpen, onPopupChange, } = config;
|
|
113
|
+
const buttonRef = useRef(null);
|
|
114
|
+
const popupRef = useRef(null);
|
|
115
|
+
// Internal state
|
|
116
|
+
const [position, setPositionInternal] = useState(controlledPosition || { x: window.innerWidth - 60, y: window.innerHeight - 60 });
|
|
117
|
+
const [dockedEdge, setDockedEdge] = useState(null);
|
|
118
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
119
|
+
const [isPopupOpenInternal, setIsPopupOpenInternal] = useState(false);
|
|
120
|
+
const [isAnimating, setIsAnimating] = useState(false);
|
|
121
|
+
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 });
|
|
122
|
+
const [popupOrigin, setPopupOrigin] = useState('center');
|
|
123
|
+
// Drag state
|
|
124
|
+
const dragStateRef = useRef({
|
|
125
|
+
isDragging: false,
|
|
126
|
+
startX: 0,
|
|
127
|
+
startY: 0,
|
|
128
|
+
startPosX: 0,
|
|
129
|
+
startPosY: 0,
|
|
130
|
+
hasMoved: false,
|
|
131
|
+
});
|
|
132
|
+
const isPopupOpen = controlledPopupOpen ?? isPopupOpenInternal;
|
|
133
|
+
// Get current state
|
|
134
|
+
const state = {
|
|
135
|
+
position,
|
|
136
|
+
dockedEdge,
|
|
137
|
+
isDragging,
|
|
138
|
+
isPopupOpen,
|
|
139
|
+
};
|
|
140
|
+
// Update controlled position
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (controlledPosition) {
|
|
143
|
+
setPositionInternal(controlledPosition);
|
|
144
|
+
}
|
|
145
|
+
}, [controlledPosition]);
|
|
146
|
+
// Calculate popup position when it opens or button moves
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (isPopupOpen && buttonRef.current && popupRef.current) {
|
|
149
|
+
const buttonRect = buttonRef.current.getBoundingClientRect();
|
|
150
|
+
const popupRect = popupRef.current.getBoundingClientRect();
|
|
151
|
+
const result = calculatePopupPosition(position, { width: buttonRect.width, height: buttonRect.height }, { width: popupRect.width, height: popupRect.height }, { width: window.innerWidth, height: window.innerHeight }, popupGap);
|
|
152
|
+
setPopupPosition(result.position);
|
|
153
|
+
setPopupOrigin(result.origin);
|
|
154
|
+
}
|
|
155
|
+
}, [isPopupOpen, position, popupGap]);
|
|
156
|
+
// Notify state changes
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (onDockChange) {
|
|
159
|
+
onDockChange(state);
|
|
160
|
+
}
|
|
161
|
+
}, [position, dockedEdge, isDragging, isPopupOpen]);
|
|
162
|
+
// Set position with constraints
|
|
163
|
+
const setPosition = useCallback((newPos) => {
|
|
164
|
+
if (!buttonRef.current)
|
|
165
|
+
return;
|
|
166
|
+
const buttonRect = buttonRef.current.getBoundingClientRect();
|
|
167
|
+
const viewport = { width: window.innerWidth, height: window.innerHeight };
|
|
168
|
+
const buttonDimensions = { width: buttonRect.width, height: buttonRect.height };
|
|
169
|
+
let finalPos = constrainToViewport(newPos, viewport, buttonDimensions);
|
|
170
|
+
if (dockMode === 'auto') {
|
|
171
|
+
const edge = getClosestEdge(finalPos, viewport);
|
|
172
|
+
finalPos = snapToEdge(finalPos, edge, viewport, buttonDimensions);
|
|
173
|
+
setDockedEdge(edge);
|
|
174
|
+
}
|
|
175
|
+
else if (dockMode === 'manual' && dockEdge) {
|
|
176
|
+
finalPos = snapToEdge(finalPos, dockEdge, viewport, buttonDimensions);
|
|
177
|
+
setDockedEdge(dockEdge);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
setDockedEdge(null);
|
|
181
|
+
}
|
|
182
|
+
setPositionInternal(finalPos);
|
|
183
|
+
}, [dockMode, dockEdge]);
|
|
184
|
+
// Toggle popup
|
|
185
|
+
const togglePopup = useCallback(() => {
|
|
186
|
+
const newState = !isPopupOpen;
|
|
187
|
+
if (controlledPopupOpen === undefined) {
|
|
188
|
+
setIsPopupOpenInternal(newState);
|
|
189
|
+
}
|
|
190
|
+
onPopupChange?.(newState);
|
|
191
|
+
}, [isPopupOpen, controlledPopupOpen, onPopupChange]);
|
|
192
|
+
const closePopup = useCallback(() => {
|
|
193
|
+
if (controlledPopupOpen === undefined) {
|
|
194
|
+
setIsPopupOpenInternal(false);
|
|
195
|
+
}
|
|
196
|
+
onPopupChange?.(false);
|
|
197
|
+
}, [controlledPopupOpen, onPopupChange]);
|
|
198
|
+
const openPopup = useCallback(() => {
|
|
199
|
+
if (controlledPopupOpen === undefined) {
|
|
200
|
+
setIsPopupOpenInternal(true);
|
|
201
|
+
}
|
|
202
|
+
onPopupChange?.(true);
|
|
203
|
+
}, [controlledPopupOpen, onPopupChange]);
|
|
204
|
+
// Pointer down handler
|
|
205
|
+
const handlePointerDown = useCallback((e) => {
|
|
206
|
+
if (!buttonRef.current)
|
|
207
|
+
return;
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
e.stopPropagation();
|
|
210
|
+
const target = e.currentTarget;
|
|
211
|
+
target.setPointerCapture(e.pointerId);
|
|
212
|
+
dragStateRef.current = {
|
|
213
|
+
isDragging: true,
|
|
214
|
+
startX: e.clientX,
|
|
215
|
+
startY: e.clientY,
|
|
216
|
+
startPosX: position.x,
|
|
217
|
+
startPosY: position.y,
|
|
218
|
+
hasMoved: false,
|
|
219
|
+
};
|
|
220
|
+
setIsDragging(true);
|
|
221
|
+
setIsAnimating(false);
|
|
222
|
+
// Close popup when starting to drag
|
|
223
|
+
if (isPopupOpen) {
|
|
224
|
+
closePopup();
|
|
225
|
+
}
|
|
226
|
+
}, [position, isPopupOpen, closePopup]);
|
|
227
|
+
// Pointer move handler
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
const handlePointerMove = (e) => {
|
|
230
|
+
if (!dragStateRef.current.isDragging || !buttonRef.current)
|
|
231
|
+
return;
|
|
232
|
+
const deltaX = e.clientX - dragStateRef.current.startX;
|
|
233
|
+
const deltaY = e.clientY - dragStateRef.current.startY;
|
|
234
|
+
// Mark as moved if dragged more than 5px
|
|
235
|
+
if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
|
|
236
|
+
dragStateRef.current.hasMoved = true;
|
|
237
|
+
}
|
|
238
|
+
const newPos = {
|
|
239
|
+
x: dragStateRef.current.startPosX + deltaX,
|
|
240
|
+
y: dragStateRef.current.startPosY + deltaY,
|
|
241
|
+
};
|
|
242
|
+
const buttonRect = buttonRef.current.getBoundingClientRect();
|
|
243
|
+
const viewport = { width: window.innerWidth, height: window.innerHeight };
|
|
244
|
+
const buttonDimensions = { width: buttonRect.width, height: buttonRect.height };
|
|
245
|
+
// During drag, only constrain to viewport (no snapping)
|
|
246
|
+
const constrainedPos = constrainToViewport(newPos, viewport, buttonDimensions);
|
|
247
|
+
setPositionInternal(constrainedPos);
|
|
248
|
+
};
|
|
249
|
+
const handlePointerUp = () => {
|
|
250
|
+
if (!dragStateRef.current.isDragging)
|
|
251
|
+
return;
|
|
252
|
+
dragStateRef.current.isDragging = false;
|
|
253
|
+
setIsDragging(false);
|
|
254
|
+
// Apply snapping after drag ends
|
|
255
|
+
if (animation) {
|
|
256
|
+
setIsAnimating(true);
|
|
257
|
+
}
|
|
258
|
+
// Snap to edge if needed
|
|
259
|
+
if (buttonRef.current) {
|
|
260
|
+
const buttonRect = buttonRef.current.getBoundingClientRect();
|
|
261
|
+
const viewport = { width: window.innerWidth, height: window.innerHeight };
|
|
262
|
+
const buttonDimensions = { width: buttonRect.width, height: buttonRect.height };
|
|
263
|
+
let finalPos = position;
|
|
264
|
+
if (dockMode === 'auto') {
|
|
265
|
+
const edge = getClosestEdge(position, viewport);
|
|
266
|
+
finalPos = snapToEdge(position, edge, viewport, buttonDimensions);
|
|
267
|
+
setDockedEdge(edge);
|
|
268
|
+
}
|
|
269
|
+
else if (dockMode === 'manual' && dockEdge) {
|
|
270
|
+
finalPos = snapToEdge(position, dockEdge, viewport, buttonDimensions);
|
|
271
|
+
setDockedEdge(dockEdge);
|
|
272
|
+
}
|
|
273
|
+
setPositionInternal(finalPos);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
document.addEventListener('pointermove', handlePointerMove);
|
|
277
|
+
document.addEventListener('pointerup', handlePointerUp);
|
|
278
|
+
return () => {
|
|
279
|
+
document.removeEventListener('pointermove', handlePointerMove);
|
|
280
|
+
document.removeEventListener('pointerup', handlePointerUp);
|
|
281
|
+
};
|
|
282
|
+
}, [position, dockMode, dockEdge, animation]);
|
|
283
|
+
// Click handler (only trigger if not dragged)
|
|
284
|
+
const handleClick = useCallback((e) => {
|
|
285
|
+
if (dragStateRef.current.hasMoved) {
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
e.stopPropagation();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
togglePopup();
|
|
291
|
+
}, [togglePopup]);
|
|
292
|
+
// Handle window resize
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
const handleResize = () => {
|
|
295
|
+
if (buttonRef.current) {
|
|
296
|
+
const buttonRect = buttonRef.current.getBoundingClientRect();
|
|
297
|
+
const viewport = { width: window.innerWidth, height: window.innerHeight };
|
|
298
|
+
const buttonDimensions = { width: buttonRect.width, height: buttonRect.height };
|
|
299
|
+
let newPos = constrainToViewport(position, viewport, buttonDimensions);
|
|
300
|
+
if (dockMode === 'auto') {
|
|
301
|
+
const edge = getClosestEdge(newPos, viewport);
|
|
302
|
+
newPos = snapToEdge(newPos, edge, viewport, buttonDimensions);
|
|
303
|
+
setDockedEdge(edge);
|
|
304
|
+
}
|
|
305
|
+
else if (dockMode === 'manual' && dockEdge) {
|
|
306
|
+
newPos = snapToEdge(newPos, dockEdge, viewport, buttonDimensions);
|
|
307
|
+
}
|
|
308
|
+
setPositionInternal(newPos);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
window.addEventListener('resize', handleResize);
|
|
312
|
+
return () => window.removeEventListener('resize', handleResize);
|
|
313
|
+
}, [position, dockMode, dockEdge]);
|
|
314
|
+
// Button styles
|
|
315
|
+
const buttonStyles = {
|
|
316
|
+
position: 'fixed',
|
|
317
|
+
left: 0,
|
|
318
|
+
top: 0,
|
|
319
|
+
transform: `translate3d(${position.x}px, ${position.y}px, 0) translate(-50%, -50%)`,
|
|
320
|
+
transition: isAnimating && animation ? 'transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)' : 'none',
|
|
321
|
+
cursor: isDragging ? 'grabbing' : 'grab',
|
|
322
|
+
touchAction: 'none',
|
|
323
|
+
userSelect: 'none',
|
|
324
|
+
zIndex,
|
|
325
|
+
willChange: isDragging ? 'transform' : 'auto',
|
|
326
|
+
};
|
|
327
|
+
// Popup styles
|
|
328
|
+
const popupStyles = {
|
|
329
|
+
position: 'fixed',
|
|
330
|
+
left: popupPosition.x,
|
|
331
|
+
top: popupPosition.y,
|
|
332
|
+
zIndex: zIndex + 1,
|
|
333
|
+
opacity: isPopupOpen ? 1 : 0,
|
|
334
|
+
pointerEvents: isPopupOpen ? 'auto' : 'none',
|
|
335
|
+
transformOrigin: popupOrigin,
|
|
336
|
+
transform: isPopupOpen ? 'scale(1)' : 'scale(0.95)',
|
|
337
|
+
transition: animation ? 'opacity 0.2s ease, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)' : 'none',
|
|
338
|
+
};
|
|
339
|
+
const buttonProps = {
|
|
340
|
+
onPointerDown: handlePointerDown,
|
|
341
|
+
onClick: handleClick,
|
|
342
|
+
style: { userSelect: 'none', touchAction: 'none' },
|
|
343
|
+
};
|
|
344
|
+
return {
|
|
345
|
+
state,
|
|
346
|
+
buttonRef,
|
|
347
|
+
popupRef,
|
|
348
|
+
togglePopup,
|
|
349
|
+
closePopup,
|
|
350
|
+
openPopup,
|
|
351
|
+
setPosition,
|
|
352
|
+
buttonStyles,
|
|
353
|
+
popupStyles,
|
|
354
|
+
buttonProps,
|
|
355
|
+
};
|
|
356
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-edge-dock",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A zero-dependency React TypeScript library for customizable draggable edge-docked floating buttons with popup support",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.esm.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/yourusername/react-edge-dock.git"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/yourusername/react-edge-dock/issues"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/yourusername/react-edge-dock#readme",
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"dev": "tsc --watch",
|
|
23
|
+
"clean": "rm -rf dist",
|
|
24
|
+
"prepublishOnly": "npm run clean && npm run build",
|
|
25
|
+
"prepack": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"react",
|
|
29
|
+
"typescript",
|
|
30
|
+
"draggable",
|
|
31
|
+
"floating-button",
|
|
32
|
+
"edge-dock",
|
|
33
|
+
"popup",
|
|
34
|
+
"overlay",
|
|
35
|
+
"headless"
|
|
36
|
+
],
|
|
37
|
+
"author": "",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
41
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/react": "^18.2.0",
|
|
45
|
+
"@types/react-dom": "^18.2.0",
|
|
46
|
+
"typescript": "^5.3.0"
|
|
47
|
+
}
|
|
48
|
+
}
|