Skip to content

Commit 1ed4f75

Browse files
authored
feat: add DragHandle and useDraggable to make toolbar draggable (#880)
1 parent 13206a0 commit 1ed4f75

6 files changed

Lines changed: 484 additions & 7 deletions

File tree

.changeset/shiny-webs-write.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@knocklabs/client": patch
3+
"@knocklabs/react": patch
4+
---
5+
6+
[Guides] Add additional debug settings to guide client, and improvements to the guide toolbar V2
7+
8+
- Add `focusedGuideKeys` debug setting to pin a target guide during preview
9+
- Add `ignoreDisplayInterval` debug setting to ignore throttling during preview
10+
- Add `skipEngagementTracking` debug setting to skip sending engagement updates to the API during preview
11+
- Add control UIs to the guide toolbar V2 for the newly added debug settings
12+
- Add a drag handle to the guide toolbar for drag and drop

packages/react/src/modules/guide/components/Toolbar/KnockButton.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@ import "./styles.css";
55

66
type Props = {
77
onClick: () => void;
8+
positioned?: boolean;
89
};
910

10-
export const KnockButton = ({ onClick }: Props) => {
11+
export const KnockButton = ({ onClick, positioned = true }: Props) => {
1112
return (
1213
<Button
1314
onClick={onClick}
14-
position="fixed"
15-
top="4"
16-
right="4"
15+
{...(positioned && {
16+
position: "fixed" as const,
17+
top: "4" as const,
18+
right: "4" as const,
19+
style: { zIndex: TOOLBAR_Z_INDEX },
20+
})}
1721
bg="surface-2"
1822
shadow="3"
1923
rounded="3"
@@ -22,7 +26,6 @@ export const KnockButton = ({ onClick }: Props) => {
2226
variant="soft"
2327
data-tgph-appearance="dark"
2428
aria-label="Expand guide toolbar"
25-
style={{ zIndex: TOOLBAR_Z_INDEX }}
2629
>
2730
<svg
2831
width="40"
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Icon } from "@telegraph/icon";
2+
import { Box } from "@telegraph/layout";
3+
import { GripVertical } from "lucide-react";
4+
import React from "react";
5+
6+
// How far the drag handle protrudes beyond the toolbar's right edge (px)
7+
export const DRAG_HANDLE_OVERHANG = 16;
8+
9+
type DragHandleProps = {
10+
onPointerDown: (e: React.PointerEvent) => void;
11+
isDragging: boolean;
12+
};
13+
14+
export const DragHandle = ({ onPointerDown, isDragging }: DragHandleProps) => {
15+
return (
16+
<Box
17+
data-tgph-appearance="dark"
18+
onPointerDown={onPointerDown}
19+
borderRadius="2"
20+
position="absolute"
21+
style={{
22+
top: "9px",
23+
right: `-${DRAG_HANDLE_OVERHANG}px`,
24+
height: "24px",
25+
cursor: isDragging ? "grabbing" : "grab",
26+
touchAction: "none",
27+
userSelect: "none",
28+
}}
29+
>
30+
<Icon color="gray" size="1" icon={GripVertical} aria-hidden />
31+
</Box>
32+
);
33+
};

packages/react/src/modules/guide/components/Toolbar/V2/V2.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import { KnockButton } from "../KnockButton";
99
import { TOOLBAR_Z_INDEX } from "../shared";
1010
import "../styles.css";
1111

12+
import { DRAG_HANDLE_OVERHANG, DragHandle } from "./DragHandle";
1213
import { GuideContextDetails } from "./GuideContextDetails";
1314
import { GuideRow } from "./GuideRow";
1415
import {
1516
DisplayOption,
1617
GuidesListDisplaySelect,
1718
} from "./GuidesListDisplaySelect";
1819
import { detectToolbarParam } from "./helpers";
20+
import { useDraggable } from "./useDraggable";
1921
import {
2022
InspectionResult,
2123
useInspectGuideClientStore,
@@ -68,15 +70,32 @@ export const V2 = () => {
6870
};
6971
}, [isVisible, client]);
7072

73+
const containerRef = React.useRef<HTMLDivElement>(null);
74+
const { position, isDragging, handlePointerDown } = useDraggable({
75+
elementRef: containerRef,
76+
reclampDeps: [isCollapsed],
77+
rightPadding: DRAG_HANDLE_OVERHANG,
78+
initialPosition: { top: 16, right: 16 },
79+
});
80+
7181
const result = useInspectGuideClientStore();
7282
if (!result) {
7383
return null;
7484
}
7585

7686
return (
77-
<Box position="fixed" top="4" right="4" style={{ zIndex: TOOLBAR_Z_INDEX }}>
87+
<Box
88+
tgphRef={containerRef}
89+
position="fixed"
90+
style={{
91+
top: position.top + "px",
92+
right: position.right + "px",
93+
zIndex: TOOLBAR_Z_INDEX,
94+
}}
95+
>
96+
<DragHandle onPointerDown={handlePointerDown} isDragging={isDragging} />
7897
{isCollapsed ? (
79-
<KnockButton onClick={() => setIsCollapsed(false)} />
98+
<KnockButton onClick={() => setIsCollapsed(false)} positioned={false} />
8099
) : (
81100
<Stack
82101
direction="column"
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import React from "react";
2+
3+
// NOTE: This hook was generated entirely with Claude then lightly touched up,
4+
// and the behavior works correctly from testing it manually in the browser.
5+
6+
type Position = { top: number; right: number };
7+
8+
type UseDraggableOptions = {
9+
elementRef: React.RefObject<HTMLElement | null>;
10+
initialPosition?: Position;
11+
reclampDeps?: React.DependencyList;
12+
/** Extra space to reserve beyond the element's right edge (px). */
13+
rightPadding?: number;
14+
};
15+
16+
type UseDraggableReturn = {
17+
position: Position;
18+
isDragging: boolean;
19+
handlePointerDown: (e: React.PointerEvent) => void;
20+
};
21+
22+
const DEFAULT_POSITION: Position = { top: 16, right: 16 };
23+
24+
/**
25+
* @param rightPadding Extra space to reserve on the right edge (e.g. for a
26+
* drag handle that protrudes beyond the element). The element's left edge
27+
* is clamped so that `elementWidth + rightPadding` stays within the viewport.
28+
*/
29+
export function clampPosition(
30+
pos: Position,
31+
elementWidth: number,
32+
elementHeight: number,
33+
rightPadding = 0,
34+
): Position {
35+
const viewportWidth = window.innerWidth;
36+
const viewportHeight = window.innerHeight;
37+
38+
const totalWidth = elementWidth + rightPadding;
39+
const left = viewportWidth - pos.right - elementWidth;
40+
const clampedLeft = Math.max(0, Math.min(left, viewportWidth - totalWidth));
41+
const clampedTop = Math.max(
42+
0,
43+
Math.min(pos.top, viewportHeight - elementHeight),
44+
);
45+
const clampedRight = viewportWidth - clampedLeft - elementWidth;
46+
47+
return { top: clampedTop, right: clampedRight };
48+
}
49+
50+
export function useDraggable({
51+
elementRef,
52+
initialPosition = DEFAULT_POSITION,
53+
reclampDeps = [],
54+
rightPadding = 0,
55+
}: UseDraggableOptions): UseDraggableReturn {
56+
const [position, setPosition] = React.useState<Position>(initialPosition);
57+
const [isDragging, setIsDragging] = React.useState(false);
58+
59+
const positionRef = React.useRef(position);
60+
positionRef.current = position;
61+
62+
const startPointerRef = React.useRef({ x: 0, y: 0 });
63+
const startPositionRef = React.useRef<Position>({ top: 0, right: 0 });
64+
const rafIdRef = React.useRef<number | null>(null);
65+
const isDraggingRef = React.useRef(false);
66+
const cleanupListenersRef = React.useRef<(() => void) | null>(null);
67+
68+
const reclamp = React.useCallback(() => {
69+
const el = elementRef.current;
70+
if (!el) return;
71+
const rect = el.getBoundingClientRect();
72+
setPosition((prev) =>
73+
clampPosition(prev, rect.width, rect.height, rightPadding),
74+
);
75+
}, [elementRef, rightPadding]);
76+
77+
// Stable pointerdown handler
78+
const handlePointerDown = React.useCallback(
79+
(e: React.PointerEvent) => {
80+
e.preventDefault();
81+
startPointerRef.current = { x: e.clientX, y: e.clientY };
82+
startPositionRef.current = { ...positionRef.current };
83+
isDraggingRef.current = true;
84+
setIsDragging(true);
85+
86+
const onPointerMove = (moveEvent: PointerEvent) => {
87+
if (!isDraggingRef.current) return;
88+
89+
if (rafIdRef.current !== null) return;
90+
91+
rafIdRef.current = requestAnimationFrame(() => {
92+
rafIdRef.current = null;
93+
if (!isDraggingRef.current) return;
94+
95+
const dx = moveEvent.clientX - startPointerRef.current.x;
96+
const dy = moveEvent.clientY - startPointerRef.current.y;
97+
98+
const newPos: Position = {
99+
top: startPositionRef.current.top + dy,
100+
right: startPositionRef.current.right - dx,
101+
};
102+
103+
const el = elementRef.current;
104+
if (!el) return;
105+
const rect = el.getBoundingClientRect();
106+
const clamped = clampPosition(
107+
newPos,
108+
rect.width,
109+
rect.height,
110+
rightPadding,
111+
);
112+
setPosition(clamped);
113+
});
114+
};
115+
116+
const cleanup = () => {
117+
isDraggingRef.current = false;
118+
setIsDragging(false);
119+
if (rafIdRef.current !== null) {
120+
cancelAnimationFrame(rafIdRef.current);
121+
rafIdRef.current = null;
122+
}
123+
document.removeEventListener("pointermove", onPointerMove);
124+
document.removeEventListener("pointerup", onPointerUp);
125+
cleanupListenersRef.current = null;
126+
};
127+
128+
const onPointerUp = () => cleanup();
129+
130+
document.addEventListener("pointermove", onPointerMove);
131+
document.addEventListener("pointerup", onPointerUp);
132+
cleanupListenersRef.current = cleanup;
133+
},
134+
[elementRef, rightPadding],
135+
);
136+
137+
// Cleanup on unmount
138+
React.useEffect(() => {
139+
return () => {
140+
cleanupListenersRef.current?.();
141+
};
142+
}, []);
143+
144+
// Re-clamp on window resize
145+
React.useEffect(() => {
146+
const onResize = () => reclamp();
147+
window.addEventListener("resize", onResize);
148+
return () => window.removeEventListener("resize", onResize);
149+
}, [reclamp]);
150+
151+
// Re-clamp when deps change (e.g. collapse toggle)
152+
React.useEffect(() => {
153+
const id = requestAnimationFrame(() => reclamp());
154+
return () => cancelAnimationFrame(id);
155+
// eslint-disable-next-line react-hooks/exhaustive-deps
156+
}, reclampDeps);
157+
158+
return { position, isDragging, handlePointerDown };
159+
}

0 commit comments

Comments
 (0)