From 0b6133efaecb62df83bd0a0e76e608f43f827925 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 18 Mar 2023 14:41:07 -0700 Subject: [PATCH] Good start to drag-n-drop sidebar! --- package-lock.json | 141 ++++++++++ package.json | 2 + src-web/components/Sidebar.tsx | 391 +++++++++++++++++++++------ src-web/components/core/Dropdown.tsx | 6 +- 4 files changed, 450 insertions(+), 90 deletions(-) diff --git a/package-lock.json b/package-lock.json index a23bbb41..3a5568c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,8 @@ "framer-motion": "^9.0.4", "parse-color": "^1.0.0", "react": "^18.2.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", "react-helmet-async": "^1.3.0", "react-router-dom": "^6.8.1", @@ -1763,6 +1765,21 @@ "@babel/runtime": "^7.13.10" } }, + "node_modules/@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==" + }, + "node_modules/@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" + }, "node_modules/@remix-run/router": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.4.0.tgz", @@ -3700,6 +3717,16 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "dev": true }, + "node_modules/dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "dependencies": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -5063,6 +5090,14 @@ "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/hyphenate-style-name": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", @@ -6405,6 +6440,43 @@ "node": ">=0.10.0" } }, + "node_modules/react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "dependencies": { + "dnd-core": "^16.0.1" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -6623,6 +6695,14 @@ "node": ">=8.10.0" } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", @@ -9226,6 +9306,21 @@ "@babel/runtime": "^7.13.10" } }, + "@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==" + }, + "@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==" + }, + "@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" + }, "@remix-run/router": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.4.0.tgz", @@ -10504,6 +10599,16 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "dev": true }, + "dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "requires": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -11536,6 +11641,14 @@ "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + }, "hyphenate-style-name": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", @@ -12473,6 +12586,26 @@ "loose-envify": "^1.1.0" } }, + "react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "requires": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + } + }, + "react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "requires": { + "dnd-core": "^16.0.1" + } + }, "react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -12630,6 +12763,14 @@ "picomatch": "^2.2.1" } }, + "redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, "regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", diff --git a/package.json b/package.json index bd76b7ca..9dc0fc6d 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ "framer-motion": "^9.0.4", "parse-color": "^1.0.0", "react": "^18.2.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", "react-helmet-async": "^1.3.0", "react-router-dom": "^6.8.1", diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx index 0abb65bb..1b0e7ca1 100644 --- a/src-web/components/Sidebar.tsx +++ b/src-web/components/Sidebar.tsx @@ -1,5 +1,10 @@ import classnames from 'classnames'; -import React, { useRef, useState } from 'react'; +import type { Identifier } from 'dnd-core'; +import type { CSSProperties } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import type { XYCoord } from 'react-dnd'; +import { DndProvider, useDrag, useDragLayer, useDrop } from 'react-dnd'; +import { getEmptyImage, HTML5Backend } from 'react-dnd-html5-backend'; import { useActiveRequest } from '../hooks/useActiveRequest'; import { useCreateRequest } from '../hooks/useCreateRequest'; import { useDeleteRequest } from '../hooks/useDeleteRequest'; @@ -24,13 +29,31 @@ const MIN_WIDTH = 110; const INITIAL_WIDTH = 200; const MAX_WIDTH = 500; +enum ItemTypes { + REQUEST = 'request', +} + export function Sidebar({ className }: Props) { - const [isDragging, setIsDragging] = useState(false); + return ( + + + + ); +} + +export function Container({ className }: Props) { + const [isResizing, setIsRisizing] = useState(false); const width = useKeyValue({ key: 'sidebar_width', initialValue: INITIAL_WIDTH }); + const sidebarRef = useRef(null); const requests = useRequests(); const activeRequest = useActiveRequest(); const createRequest = useCreateRequest({ navigateAfter: true }); const { appearance, toggleAppearance } = useTheme(); + const [items, setItems] = useState(requests.map((r) => ({ request: r, left: 0, top: 0 }))); + + useEffect(() => { + setItems(requests.map((r) => ({ request: r, left: 0, top: 0 }))); + }, [requests]); const moveState = useRef<{ move: (e: MouseEvent) => void; up: () => void } | null>(null); const unsub = () => { @@ -55,23 +78,36 @@ export function Sidebar({ className }: Props) { }, up: () => { unsub(); - setIsDragging(false); + setIsRisizing(false); }, }; document.documentElement.addEventListener('mousemove', moveState.current.move); document.documentElement.addEventListener('mouseup', moveState.current.up); - setIsDragging(true); + setIsRisizing(true); }; + const sidebarWidth = sidebarRef.current?.clientWidth ?? 0; + const handleMove = useCallback((dragIndex: number, hoverIndex: number) => { + setItems((oldItems) => { + const newItems = [...oldItems]; + const b = newItems[hoverIndex]!; + newItems[hoverIndex] = newItems[dragIndex]!; + newItems[dragIndex] = b; + return newItems; + }); + }, []); + return (
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
-
@@ -96,32 +132,43 @@ export function Sidebar({ className }: Props) { }} /> - - {requests.map((r) => ( - - ))} - {/**/} - - - + {items.map(({ request, top, left }, i) => ( + - + ))} + + +
); } -function SidebarItem({ request, active }: { request: HttpRequest; active: boolean }) { +interface SidebarItemProps { + request: HttpRequest; + sidebarWidth: number; + active?: boolean; + isDragging?: boolean; +} + +function SidebarItem({ request, active, sidebarWidth, isDragging }: SidebarItemProps) { const deleteRequest = useDeleteRequest(request); const updateRequest = useUpdateRequest(request); const [editing, setEditing] = useState(false); + const handleSubmitNameEdit = async (el: HTMLInputElement) => { await updateRequest.mutate({ name: el.value }); setEditing(false); @@ -133,72 +180,242 @@ function SidebarItem({ request, active }: { request: HttpRequest; active: boolea }; return ( -
  • - + , + }, + ]} + > + - {request.name || request.url || 'New Request'} - - )} - - , - }, - ]} - > - - - - + + + +
  • ); } + +type DraggableSidebarItemProps = SidebarItemProps & { + left: number; + top: number; + index: number; + onMove: (dragIndex: number, hoverIndex: number) => void; +}; + +type DragItem = { + request: HttpRequest; + index: number; + top: number; + left: number; +}; + +function getStyles(left: number, top: number, width: number): CSSProperties { + const transform = `translate3d(${left}px, ${top}px, 0)`; + return { + transform, + WebkitTransform: transform, + width, + }; +} + +function DraggableSidebarItem({ + index, + left, + top, + request, + active, + sidebarWidth, + onMove, +}: DraggableSidebarItemProps) { + const ref = useRef(null); + + const [{ handlerId }, drop] = useDrop({ + accept: ItemTypes.REQUEST, + collect: (monitor) => ({ handlerId: monitor.getHandlerId() }), + hover: (item, monitor) => { + if (!ref.current) return; + + const dragIndex = item.index; + const hoverIndex = index; + + // Don't replace items with themselves + if (dragIndex === hoverIndex) return; + + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; + + // Only perform the move when the mouse has crossed half of the items height + // When dragging downwards, only move when the cursor is below 50% + // When dragging upwards, only move when the cursor is above 50% + + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) return; + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) return; + + onMove(dragIndex, hoverIndex); + + // Note: we're mutating the monitor item here! Generally it's better to + // avoid mutations, but it's good here for the sake of performance to + // avoid expensive index searches. + item.index = hoverIndex; + }, + }); + + const [monitor, drag, preview] = useDrag( + () => ({ + type: ItemTypes.REQUEST, + item: () => ({ request, left, top, index }), + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [request, left, top, index], + ); + + useEffect(() => { + preview(getEmptyImage(), { captureDraggingState: true }); + }, []); + + const isDragging = !!monitor?.isDragging; + console.log('IS DRAGGING', isDragging); + drag(drop(ref)); + + return ( +
    + +
    + ); +} + +function getItemStyles( + initialOffset: XYCoord | null, + currentOffset: XYCoord | null, +): CSSProperties { + if (!initialOffset || !currentOffset) { + return { display: 'none' }; + } + + const { x, y } = currentOffset; + + const transform = `translate(${x}px, ${y}px)`; + return { + transform, + WebkitTransform: transform, + }; +} + +const CustomDragLayer = ({ sidebarWidth }: { sidebarWidth: number }) => { + const dragProps = useDragLayer((monitor) => ({ + item: monitor.getItem(), + itemType: monitor.getItemType(), + initialOffset: monitor.getInitialSourceClientOffset(), + currentOffset: monitor.getSourceClientOffset(), + isDragging: monitor.isDragging(), + })); + + const { itemType, isDragging, item, initialOffset, currentOffset } = dragProps; + + function renderItem() { + switch (itemType) { + case ItemTypes.REQUEST: + return ( + + ); + default: + return null; + } + } + + if (!isDragging) { + return null; + } + + return ( +
    +
    + {renderItem()} +
    +
    + ); +}; diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index 5d67a242..7e112b54 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -3,7 +3,7 @@ import { CheckIcon } from '@radix-ui/react-icons'; import classnames from 'classnames'; import { motion } from 'framer-motion'; import type { ForwardedRef, ReactElement, ReactNode } from 'react'; -import { forwardRef, useImperativeHandle, useLayoutEffect, useState } from 'react'; +import { forwardRef, memo, useImperativeHandle, useLayoutEffect, useState } from 'react'; export interface DropdownMenuRadioItem { label: string; @@ -64,7 +64,7 @@ export interface DropdownProps { )[]; } -export function Dropdown({ children, items }: DropdownProps) { +export const Dropdown = memo(function Dropdown({ children, items }: DropdownProps) { return ( {children} @@ -90,7 +90,7 @@ export function Dropdown({ children, items }: DropdownProps) { ); -} +}); interface DropdownMenuPortalProps { children: ReactNode;