diff --git a/.changeset/afraid-cycles-shave.md b/.changeset/afraid-cycles-shave.md new file mode 100644 index 00000000..6183b46b --- /dev/null +++ b/.changeset/afraid-cycles-shave.md @@ -0,0 +1,5 @@ +--- +'leva': patch +--- + +feat: add `onDrag` / `onDragStart` / `onDragEnd` callbacks when dragging Leva panel. diff --git a/packages/leva/src/components/Leva/Filter.tsx b/packages/leva/src/components/Leva/Filter.tsx index bebfcb92..d6f6773a 100644 --- a/packages/leva/src/components/Leva/Filter.tsx +++ b/packages/leva/src/components/Leva/Filter.tsx @@ -49,20 +49,26 @@ const FilterInput = React.forwardRef(({ setFilter export type TitleWithFilterProps = FilterProps & FolderTitleProps & { - onDrag: (point: { x?: number | undefined; y?: number | undefined }) => void + onDrag: (point: { x?: number; y?: number }) => void + onDragStart: (point: { x?: number; y?: number }) => void + onDragEnd: (point: { x?: number; y?: number }) => void title: React.ReactNode drag: boolean filterEnabled: boolean + from?: { x?: number; y?: number } } export function TitleWithFilter({ setFilter, onDrag, + onDragStart, + onDragEnd, toggle, toggled, title, drag, filterEnabled, + from, }: TitleWithFilterProps) { const [filterShown, setShowFilter] = useState(false) const inputRef = useRef(null) @@ -72,7 +78,23 @@ export function TitleWithFilter({ else inputRef.current?.blur() }, [filterShown]) - const bind = useDrag(({ offset: [x, y] }) => onDrag({ x, y }), { filterTaps: true }) + const bind = useDrag( + ({ offset: [x, y], first, last }) => { + onDrag({ x, y }) + + if (first) { + onDragStart({ x, y }) + } + + if (last) { + onDragEnd({ x, y }) + } + }, + { + filterTaps: true, + from: ({ offset: [x, y] }) => [from?.x || x, from?.y || y], + } + ) useEffect(() => { const handleShortcut = (event: KeyboardEvent) => { diff --git a/packages/leva/src/components/Leva/LevaRoot.tsx b/packages/leva/src/components/Leva/LevaRoot.tsx index 4a30be84..83abc457 100644 --- a/packages/leva/src/components/Leva/LevaRoot.tsx +++ b/packages/leva/src/components/Leva/LevaRoot.tsx @@ -68,6 +68,22 @@ export type LevaRootProps = { * Toggle whether filtering should be enabled or disabled. */ filter?: boolean + /** + * The position(x and y coordinates) of the leva panel. + */ + position?: { x?: number; y?: number } + /** + * The callback is called when the leva panel is dragged. + */ + onDrag?: (position: { x?: number; y?: number }) => void + /** + * The callback is called when the leva panel starts to be dragged. + */ + onDragStart?: (position: { x?: number; y?: number }) => void + /** + * The callback is called when the leva panel stops being dragged. + */ + onDragEnd?: (position: { x?: number; y?: number }) => void } /** * If true, the copy button will be hidden @@ -128,6 +144,10 @@ const LevaCore = React.memo( title: undefined, drag: true, filter: true, + position: undefined, + onDrag: undefined, + onDragStart: undefined, + onDragEnd: undefined, }, hideCopyButton = false, toggled, @@ -145,6 +165,14 @@ const LevaCore = React.memo( const title = typeof titleBar === 'object' ? titleBar.title || undefined : undefined const drag = typeof titleBar === 'object' ? titleBar.drag ?? true : true const filterEnabled = typeof titleBar === 'object' ? titleBar.filter ?? true : true + const position = typeof titleBar === 'object' ? titleBar.position || undefined : undefined + const onDrag = typeof titleBar === 'object' ? titleBar.onDrag || undefined : undefined + const onDragStart = typeof titleBar === 'object' ? titleBar.onDragStart || undefined : undefined + const onDragEnd = typeof titleBar === 'object' ? titleBar.onDragEnd || undefined : undefined + + React.useEffect(() => { + set({ x: position?.x, y: position?.y }) + }, [position, set]) globalStyles() @@ -160,13 +188,19 @@ const LevaCore = React.memo( style={{ display: shouldShow ? 'block' : 'none' }}> {titleBar && ( { + set(point) + onDrag?.(point) + }} + onDragStart={(point) => onDragStart?.(point)} + onDragEnd={(point) => onDragEnd?.(point)} setFilter={setFilter} toggle={(flag?: boolean) => setToggle((t) => flag ?? !t)} toggled={toggled} title={title} drag={drag} filterEnabled={filterEnabled} + from={position} /> )} {shouldShow && ( diff --git a/packages/leva/stories/controlled-inputs.stories.tsx b/packages/leva/stories/controlled-inputs.stories.tsx index 65f3ebe1..97f81d51 100644 --- a/packages/leva/stories/controlled-inputs.stories.tsx +++ b/packages/leva/stories/controlled-inputs.stories.tsx @@ -68,8 +68,8 @@ export const OnChangeAndSet: Story = () => { useDrag( ({ first, last, offset: [x, y] }) => { - if (first) circleRef.current.style.cursor = 'grabbing' - if (last) circleRef.current.style.removeProperty('cursor') + if (first) circleRef.current!.style.cursor = 'grabbing' + if (last) circleRef.current!.style.removeProperty('cursor') set({ position: { x, y } }) }, { target: circleRef } diff --git a/packages/leva/stories/input-options.stories.tsx b/packages/leva/stories/input-options.stories.tsx index 6a24d0bb..c7292b02 100644 --- a/packages/leva/stories/input-options.stories.tsx +++ b/packages/leva/stories/input-options.stories.tsx @@ -76,14 +76,14 @@ export const Optional = () => { function A() { const renderRef = React.useRef(0) - const divRef = React.useRef(null) + const divRef = React.useRef(null) renderRef.current++ const data = useControls({ color: { value: '#f00', onChange: (v) => { - divRef.current.style.color = v - divRef.current.innerText = `Transient color is ${v}` + divRef.current!.style.color = v + divRef.current!.innerText = `Transient color is ${v}` }, }, }) @@ -127,7 +127,7 @@ export const OnChangeWithRender = ({ transient }) => { color: { value: '#f00', onChange: (value) => { - ref.current.innerHTML = value + ref.current!.innerHTML = value }, transient, }, @@ -149,7 +149,7 @@ OnChangeWithRender.args = { OnChangeWithRender.storyName = 'onChange With Render' export const OnChangeFromPanel = () => { - const ref = React.useRef() + const ref = React.useRef(null) const [, set] = useControls(() => ({ value: { value: 0.1, @@ -157,8 +157,8 @@ export const OnChangeFromPanel = () => { onChange: (value, path, context) => { const node = window.document.createElement('pre') node.innerText = JSON.stringify({ value, path, context }) - ref.current.appendChild(node) - ref.current.scrollTop = ref.current.scrollHeight + ref.current!.appendChild(node) + ref.current!.scrollTop = ref.current!.scrollHeight }, }, })) @@ -190,7 +190,7 @@ export const EnforceInputType = () => { export const OnEditStartOnEditEnd = () => { const [isEditing, setIsEditing] = React.useState(0) - const [editedInput, setEditedInput] = React.useState<{ value: any; path: string }>(null) + const [editedInput, setEditedInput] = React.useState<{ value: any; path: string } | null>(null) const onEditStart = (value, path, context) => { setIsEditing((i) => i + 1) @@ -218,7 +218,7 @@ export const OnEditStartOnEditEnd = () => {
         {isEditing === 0
           ? 'Not Editing'
-          : `Editing ${editedInput.path} with initial value ${String(editedInput.value)}`}
+          : `Editing ${editedInput!.path} with initial value ${String(editedInput!.value)}`}
       
) diff --git a/packages/leva/stories/panel-options.stories.tsx b/packages/leva/stories/panel-options.stories.tsx index 748a1c56..681e006b 100644 --- a/packages/leva/stories/panel-options.stories.tsx +++ b/packages/leva/stories/panel-options.stories.tsx @@ -73,6 +73,13 @@ export const Filter: Story = (args, context) => { } Filter.args = { filter: true } +export const PositionControlled: Story = (args, context) => { + const [{ position }, set] = useControls(() => ({ position: { x: -50, y: 50 } })) + + return Template({ titleBar: { drag: args.drag, position, onDrag: (point) => set({ position: point }) } }, context) +} +PositionControlled.args = { drag: true } + const Component = () => { const values = useControls({ value: 3 }) return ( @@ -83,7 +90,7 @@ const Component = () => { ) } -export const neverHide: Story = () => { +export const NeverHide: Story = () => { const [shown, setShown] = React.useState(true) return (