-
Notifications
You must be signed in to change notification settings - Fork 14
feat: add DragHandle and useDraggable to make toolbar draggable #880
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| --- | ||
| "@knocklabs/client": patch | ||
| "@knocklabs/react": patch | ||
| --- | ||
|
|
||
| [Guides] Add additional debug settings to guide client, and improvements to the guide toolbar V2 | ||
|
|
||
| - Add `focusedGuideKeys` debug setting to pin a target guide during preview | ||
| - Add `ignoreDisplayInterval` debug setting to ignore throttling during preview | ||
| - Add `skipEngagementTracking` debug setting to skip sending engagement updates to the API during preview | ||
| - Add control UIs to the guide toolbar V2 for the newly added debug settings | ||
| - Add a drag handle to the guide toolbar for drag and drop |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { Icon } from "@telegraph/icon"; | ||
| import { Box } from "@telegraph/layout"; | ||
| import { GripVertical } from "lucide-react"; | ||
| import React from "react"; | ||
|
|
||
| // How far the drag handle protrudes beyond the toolbar's right edge (px) | ||
| export const DRAG_HANDLE_OVERHANG = 16; | ||
|
|
||
| type DragHandleProps = { | ||
| onPointerDown: (e: React.PointerEvent) => void; | ||
| isDragging: boolean; | ||
| }; | ||
|
|
||
| export const DragHandle = ({ onPointerDown, isDragging }: DragHandleProps) => { | ||
| return ( | ||
| <Box | ||
| data-tgph-appearance="dark" | ||
| onPointerDown={onPointerDown} | ||
| borderRadius="2" | ||
| position="absolute" | ||
| style={{ | ||
| top: "9px", | ||
| right: `-${DRAG_HANDLE_OVERHANG}px`, | ||
| height: "24px", | ||
| cursor: isDragging ? "grabbing" : "grab", | ||
| touchAction: "none", | ||
| userSelect: "none", | ||
| }} | ||
| > | ||
| <Icon color="gray" size="1" icon={GripVertical} aria-hidden /> | ||
| </Box> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| import React from "react"; | ||
|
|
||
| // NOTE: This hook was generated entirely with Claude then lightly touched up, | ||
| // and the behavior works correctly from testing it manually in the browser. | ||
|
|
||
| type Position = { top: number; right: number }; | ||
|
|
||
| type UseDraggableOptions = { | ||
| elementRef: React.RefObject<HTMLElement | null>; | ||
| initialPosition?: Position; | ||
| reclampDeps?: React.DependencyList; | ||
| /** Extra space to reserve beyond the element's right edge (px). */ | ||
| rightPadding?: number; | ||
| }; | ||
|
|
||
| type UseDraggableReturn = { | ||
| position: Position; | ||
| isDragging: boolean; | ||
| handlePointerDown: (e: React.PointerEvent) => void; | ||
| }; | ||
|
|
||
| const DEFAULT_POSITION: Position = { top: 16, right: 16 }; | ||
|
|
||
| /** | ||
| * @param rightPadding Extra space to reserve on the right edge (e.g. for a | ||
| * drag handle that protrudes beyond the element). The element's left edge | ||
| * is clamped so that `elementWidth + rightPadding` stays within the viewport. | ||
| */ | ||
| export function clampPosition( | ||
| pos: Position, | ||
| elementWidth: number, | ||
| elementHeight: number, | ||
| rightPadding = 0, | ||
| ): Position { | ||
| const viewportWidth = window.innerWidth; | ||
| const viewportHeight = window.innerHeight; | ||
|
|
||
| const totalWidth = elementWidth + rightPadding; | ||
| const left = viewportWidth - pos.right - elementWidth; | ||
| const clampedLeft = Math.max(0, Math.min(left, viewportWidth - totalWidth)); | ||
| const clampedTop = Math.max( | ||
| 0, | ||
| Math.min(pos.top, viewportHeight - elementHeight), | ||
| ); | ||
| const clampedRight = viewportWidth - clampedLeft - elementWidth; | ||
|
|
||
| return { top: clampedTop, right: clampedRight }; | ||
| } | ||
|
|
||
| export function useDraggable({ | ||
| elementRef, | ||
| initialPosition = DEFAULT_POSITION, | ||
| reclampDeps = [], | ||
| rightPadding = 0, | ||
| }: UseDraggableOptions): UseDraggableReturn { | ||
| const [position, setPosition] = React.useState<Position>(initialPosition); | ||
| const [isDragging, setIsDragging] = React.useState(false); | ||
|
|
||
| const positionRef = React.useRef(position); | ||
| positionRef.current = position; | ||
|
|
||
| const startPointerRef = React.useRef({ x: 0, y: 0 }); | ||
| const startPositionRef = React.useRef<Position>({ top: 0, right: 0 }); | ||
| const rafIdRef = React.useRef<number | null>(null); | ||
| const isDraggingRef = React.useRef(false); | ||
| const cleanupListenersRef = React.useRef<(() => void) | null>(null); | ||
|
|
||
| const reclamp = React.useCallback(() => { | ||
| const el = elementRef.current; | ||
| if (!el) return; | ||
| const rect = el.getBoundingClientRect(); | ||
| setPosition((prev) => | ||
| clampPosition(prev, rect.width, rect.height, rightPadding), | ||
| ); | ||
| }, [elementRef, rightPadding]); | ||
|
|
||
| // Stable pointerdown handler | ||
| const handlePointerDown = React.useCallback( | ||
| (e: React.PointerEvent) => { | ||
| e.preventDefault(); | ||
| startPointerRef.current = { x: e.clientX, y: e.clientY }; | ||
| startPositionRef.current = { ...positionRef.current }; | ||
| isDraggingRef.current = true; | ||
| setIsDragging(true); | ||
|
|
||
| const onPointerMove = (moveEvent: PointerEvent) => { | ||
| if (!isDraggingRef.current) return; | ||
|
|
||
| if (rafIdRef.current !== null) return; | ||
|
|
||
| rafIdRef.current = requestAnimationFrame(() => { | ||
| rafIdRef.current = null; | ||
| if (!isDraggingRef.current) return; | ||
|
|
||
| const dx = moveEvent.clientX - startPointerRef.current.x; | ||
| const dy = moveEvent.clientY - startPointerRef.current.y; | ||
|
|
||
| const newPos: Position = { | ||
| top: startPositionRef.current.top + dy, | ||
| right: startPositionRef.current.right - dx, | ||
| }; | ||
|
|
||
| const el = elementRef.current; | ||
| if (!el) return; | ||
| const rect = el.getBoundingClientRect(); | ||
| const clamped = clampPosition( | ||
| newPos, | ||
| rect.width, | ||
| rect.height, | ||
| rightPadding, | ||
| ); | ||
| setPosition(clamped); | ||
| }); | ||
| }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. RAF throttle uses stale event, losing final drag positionLow Severity The RAF throttle in Additional Locations (1)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds benign enough, can come back if a real issue. |
||
|
|
||
| const cleanup = () => { | ||
| isDraggingRef.current = false; | ||
| setIsDragging(false); | ||
| if (rafIdRef.current !== null) { | ||
| cancelAnimationFrame(rafIdRef.current); | ||
| rafIdRef.current = null; | ||
| } | ||
| document.removeEventListener("pointermove", onPointerMove); | ||
| document.removeEventListener("pointerup", onPointerUp); | ||
| cleanupListenersRef.current = null; | ||
| }; | ||
|
|
||
| const onPointerUp = () => cleanup(); | ||
|
|
||
| document.addEventListener("pointermove", onPointerMove); | ||
| document.addEventListener("pointerup", onPointerUp); | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| cleanupListenersRef.current = cleanup; | ||
| }, | ||
| [elementRef, rightPadding], | ||
| ); | ||
|
|
||
| // Cleanup on unmount | ||
| React.useEffect(() => { | ||
| return () => { | ||
| cleanupListenersRef.current?.(); | ||
| }; | ||
| }, []); | ||
thomaswhyyou marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // Re-clamp on window resize | ||
| React.useEffect(() => { | ||
| const onResize = () => reclamp(); | ||
| window.addEventListener("resize", onResize); | ||
| return () => window.removeEventListener("resize", onResize); | ||
| }, [reclamp]); | ||
|
|
||
| // Re-clamp when deps change (e.g. collapse toggle) | ||
| React.useEffect(() => { | ||
| const id = requestAnimationFrame(() => reclamp()); | ||
| return () => cancelAnimationFrame(id); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, reclampDeps); | ||
|
|
||
| return { position, isDragging, handlePointerDown }; | ||
| } | ||


Uh oh!
There was an error while loading. Please reload this page.