diff --git a/browser/CHANGELOG.md b/browser/CHANGELOG.md index c4214d0c5..3b9412360 100644 --- a/browser/CHANGELOG.md +++ b/browser/CHANGELOG.md @@ -9,6 +9,7 @@ This changelog covers all three packages, as they are (for now) updated as a who - [#841](https://github.com/atomicdata-dev/atomic-server/issues/841) Add better inputs for `Timestamp` and `Date` datatypes. - [#842](https://github.com/atomicdata-dev/atomic-server/issues/842) Add media picker for properties with classtype file. - [#850](https://github.com/atomicdata-dev/atomic-server/issues/850) Add drag & drop sorting to ResourceArray inputs. +- [#757](https://github.com/atomicdata-dev/atomic-server/issues/757) Add drag & drop sorting to sidebar. ## v0.37.0 diff --git a/browser/data-browser/package.json b/browser/data-browser/package.json index f7f7dd010..e98bca8cb 100644 --- a/browser/data-browser/package.json +++ b/browser/data-browser/package.json @@ -9,9 +9,9 @@ "@bugsnag/js": "^7.16.5", "@bugsnag/plugin-react": "^7.16.5", "@dagrejs/dagre": "^1.0.2", - "@dnd-kit/core": "^6.0.5", - "@dnd-kit/sortable": "^7.0.1", - "@dnd-kit/utilities": "^3.2.0", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/react": "^1.1.1", "@emotion/is-prop-valid": "^1.2.1", "@radix-ui/react-popover": "^1.0.6", diff --git a/browser/data-browser/src/components/AtomicLink.tsx b/browser/data-browser/src/components/AtomicLink.tsx index 709717e3c..cf4a608f6 100644 --- a/browser/data-browser/src/components/AtomicLink.tsx +++ b/browser/data-browser/src/components/AtomicLink.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import { ReactNode, forwardRef } from 'react'; import { styled } from 'styled-components'; import { constructOpenURL, pathToURL } from '../helpers/navigation'; import { FaExternalLinkAlt } from 'react-icons/fa'; @@ -26,80 +26,79 @@ export interface AtomicLinkProps * Renders a link. Either a subject or a href is required. You can wrap this * around other components and pass the `clean` prop to skip styling. */ -export const AtomicLink = ({ - children, - clean, - subject, - path, - href, - untabbable, - className, - ...props -}: AtomicLinkProps): JSX.Element => { - const navigate = useNavigateWithTransition(); - - if (!subject && !href && !path) { - return ( - - No `subject`, `path` or `href` passed to this AtomicLink. - - ); - } +export const AtomicLink = forwardRef( + ( + { children, clean, subject, path, href, untabbable, className, ...props }, + ref, + ): JSX.Element => { + const navigate = useNavigateWithTransition(); + + if (!subject && !href && !path) { + return ( + + No `subject`, `path` or `href` passed to this AtomicLink. + + ); + } - let isOnCurrentPage: boolean; + let isOnCurrentPage: boolean; - try { - isOnCurrentPage = subject - ? window.location.toString() === constructOpenURL(subject) - : false; - } catch (e) { - return {subject}; - } - - const handleClick = (e: React.MouseEvent) => { - if (href) { - // When there is a regular URL, let the browser handle it - return; + try { + isOnCurrentPage = subject + ? window.location.toString() === constructOpenURL(subject) + : false; + } catch (e) { + return {subject}; } - e.preventDefault(); + const handleClick = (e: React.MouseEvent) => { + if (href) { + // When there is a regular URL, let the browser handle it + return; + } - if (path) { - navigate(path); + e.preventDefault(); - return; - } + if (path) { + navigate(path); - if (subject) { - if (isOnCurrentPage) { return; } - navigate(constructOpenURL(subject)); - } - }; - - const hrefConstructed = href || subject || pathToURL(path!); - - return ( - - {children} - {href && !clean && } - - ); -}; + if (subject) { + if (isOnCurrentPage) { + return; + } + + navigate(constructOpenURL(subject)); + } + }; + + const hrefConstructed = href || subject || pathToURL(path!); + + return ( + + {children} + {href && !clean && } + + ); + }, +); + +AtomicLink.displayName = 'AtomicLink'; type LinkViewProps = { disabled?: boolean; diff --git a/browser/data-browser/src/components/Card.tsx b/browser/data-browser/src/components/Card.tsx index 86dab2d3f..f1e3f1326 100644 --- a/browser/data-browser/src/components/Card.tsx +++ b/browser/data-browser/src/components/Card.tsx @@ -1,5 +1,5 @@ import { styled } from 'styled-components'; -import { transitionName } from '../helpers/transitionName'; +import { getTransitionStyle } from '../helpers/transitionName'; type CardProps = { /** Adds a colorful border */ @@ -9,7 +9,10 @@ type CardProps = { }; /** A Card with a border. */ -export const Card = styled.div` +export const Card = styled.div.attrs(p => ({ + // When we render a lot of cards it is more performant to use styles instead of classes when each card has a unique style + style: getTransitionStyle('resource-page', p.about), +}))` background-color: ${props => props.theme.colors.bg}; border: solid 1px @@ -24,8 +27,6 @@ export const Card = styled.div` border-radius: ${props => props.theme.radius}; max-height: ${props => (props.small ? '10rem' : 'none')}; overflow: ${props => (props.small ? 'hidden' : 'visible')}; - - ${p => transitionName('resource-page', p.about)}; `; export interface CardRowProps { diff --git a/browser/data-browser/src/components/SideBar/AppMenu.tsx b/browser/data-browser/src/components/SideBar/AppMenu.tsx index 36a2731ce..e94d8cd0a 100644 --- a/browser/data-browser/src/components/SideBar/AppMenu.tsx +++ b/browser/data-browser/src/components/SideBar/AppMenu.tsx @@ -67,7 +67,7 @@ export function AppMenu({ onItemClick }: AppMenuProps): JSX.Element { } label='Settings' - helper='Edit the theme (t)' + helper='Change client settings (t)' path={paths.themeSettings} onClick={onItemClick} /> diff --git a/browser/data-browser/src/components/SideBar/ResourceSideBar/DropEdge.tsx b/browser/data-browser/src/components/SideBar/ResourceSideBar/DropEdge.tsx new file mode 100644 index 000000000..a03929f66 --- /dev/null +++ b/browser/data-browser/src/components/SideBar/ResourceSideBar/DropEdge.tsx @@ -0,0 +1,60 @@ +import { useDndMonitor, useDroppable } from '@dnd-kit/core'; +import { styled } from 'styled-components'; +import { useState } from 'react'; +import { transition } from '../../../helpers/transition'; +import { SideBarDropData } from '../useSidebarDnd'; + +interface DropEdgeProps { + parentHierarchy: string[]; + position: number; +} + +export function DropEdge({ + parentHierarchy, + position, +}: DropEdgeProps): React.JSX.Element { + if (parentHierarchy.length === 0) { + throw new Error('renderedHierargy should not be empty'); + } + + const [activeDraggedSubject, setDraggingSubject] = useState(); + + const parent = parentHierarchy.at(-1)!; + + useDndMonitor({ + onDragStart: event => setDraggingSubject(event.active.id as string), + onDragEnd: () => setDraggingSubject(undefined), + }); + + const data: SideBarDropData = { + parent, + position, + }; + + const { setNodeRef, isOver } = useDroppable({ + id: `${parent}-${position}`, + data, + }); + + const shouldRender = + !!activeDraggedSubject && !parentHierarchy.includes(activeDraggedSubject); + + return ( + + ); +} + +const DropEdgeElement = styled.div<{ visible: boolean; active: boolean }>` + display: ${p => (p.visible ? 'block' : 'none')}; + position: absolute; + left: 0; + height: 3px; + border-radius: 1.5px; + transform: scaleX(${p => (p.active ? 1 : 0.9)}); + background: ${p => p.theme.colors.main}; + opacity: ${p => (p.active ? 1 : 0)}; + z-index: 2; + width: calc(var(--width) - 2rem); + + ${transition('opacity', 'transform')} +`; diff --git a/browser/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx b/browser/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx index edbff7d91..ec1de2c1f 100644 --- a/browser/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx +++ b/browser/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx @@ -1,18 +1,24 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { Fragment, useEffect, useMemo, useState } from 'react'; import { useString, useResource, useTitle, urls, useArray } from '@tomic/react'; import { useCurrentSubject } from '../../../helpers/useCurrentSubject'; import { SideBarItem } from '../SideBarItem'; import { AtomicLink } from '../../AtomicLink'; import { styled } from 'styled-components'; import { Details } from '../../Details'; -import { FloatingActions, floatingHoverStyles } from './FloatingActions'; import { errorLookStyle } from '../../ErrorLook'; import { LoaderInline } from '../../Loader'; -import { getIconForClass } from '../../../views/FolderPage/iconMap'; import { FaExclamationTriangle } from 'react-icons/fa'; +import { useDraggable } from '@dnd-kit/core'; +import { SidebarItemTitle } from './SidebarItemTitle'; +import { TextWrapper } from './shared'; +import { DropEdge } from './DropEdge'; +import { SideBarDragData } from '../useSidebarDnd'; +import { transparentize } from 'polished'; +import { transition } from '../../../helpers/transition'; interface ResourceSideBarProps { subject: string; + renderedHierargy: string[]; ancestry: string[]; /** When a SideBar item is clicked, we should close the SideBar (on mobile devices) */ onClick?: () => unknown; @@ -21,13 +27,16 @@ interface ResourceSideBarProps { /** Renders a Resource as a nav item for in the sidebar. */ export function ResourceSideBar({ subject, + renderedHierargy, ancestry, onClick, }: ResourceSideBarProps): JSX.Element { - const spanRef = useRef(null); + if (renderedHierargy.length === 0) { + throw new Error('renderedHierargy should not be empty'); + } + const resource = useResource(subject, { allowIncomplete: true }); const [currentUrl] = useCurrentSubject(); - const [title] = useTitle(resource); const [description] = useString(resource, urls.properties.description); @@ -37,8 +46,30 @@ export function ResourceSideBar({ const [subResources] = useArray(resource, urls.properties.subResources); const hasSubResources = subResources.length > 0; - const [classType] = useArray(resource, urls.properties.isA); - const Icon = getIconForClass(classType[0]!); + const dragData: SideBarDragData = { + renderedUnder: renderedHierargy.at(-1)!, + }; + + const { + setNodeRef, + listeners, + attributes, + over, + active: draggingNode, + } = useDraggable({ + id: subject, + data: dragData, + }); + + const isDragging = draggingNode?.id === subject; + + useEffect(() => { + if (isDragging) { + setOpen(false); + } + }, [isDragging]); + + const isHoveringOver = over?.data.current?.parent === subject; useEffect(() => { if (ancestry.includes(subject) && ancestry[0] !== subject) { @@ -46,25 +77,18 @@ export function ResourceSideBar({ } }, [ancestry]); + const hierarchyWithItself = [...renderedHierargy, subject]; + const TitleComp = useMemo( () => ( - - - - - - {title} - - - - - + ), [subject, active, onClick, description, title], ); @@ -85,12 +109,7 @@ export function ResourceSideBar({ if (resource.error) { return ( - + Resource with error @@ -101,28 +120,41 @@ export function ResourceSideBar({ } return ( -
- {hasSubResources && - subResources.map(child => ( - - ))} -
+ +
+ + {hasSubResources && + subResources.map((child, index) => ( + + + + + ))} +
+
); } -const ActionWrapper = styled.div` - position: relative; - display: flex; - width: 100%; - margin-left: -0.7rem; - ${floatingHoverStyles} +const Wrapper = styled.div<{ highlight: boolean }>` + background-color: ${p => + p.highlight ? transparentize(0.9, p.theme.colors.main) : 'none'}; + + border-radius: ${({ theme }) => theme.radius}; + ${transition('background-color')} `; const StyledLink = styled(AtomicLink)` @@ -131,12 +163,6 @@ const StyledLink = styled(AtomicLink)` white-space: nowrap; `; -const TextWrapper = styled.span` - display: inline-flex; - align-items: center; - gap: 0.4rem; -`; - const SideBarErrorWrapper = styled(TextWrapper)` margin-left: 1.3rem; ${errorLookStyle} diff --git a/browser/data-browser/src/components/SideBar/ResourceSideBar/SidebarItemTitle.tsx b/browser/data-browser/src/components/SideBar/ResourceSideBar/SidebarItemTitle.tsx new file mode 100644 index 000000000..4ec5b9587 --- /dev/null +++ b/browser/data-browser/src/components/SideBar/ResourceSideBar/SidebarItemTitle.tsx @@ -0,0 +1,150 @@ +import { forwardRef } from 'react'; +import { styled, css, keyframes } from 'styled-components'; +import { SideBarItem } from '../SideBarItem'; +import { FloatingActions, floatingHoverStyles } from './FloatingActions'; +import { getIconForClass } from '../../../views/FolderPage/iconMap'; +import { useResource, useArray, core, useString } from '@tomic/react'; +import { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities'; +import { DraggableAttributes } from '@dnd-kit/core'; +import { StyledLink, TextWrapper } from './shared'; +import { getTransitionName } from '../../../helpers/transitionName'; +import { useSettings } from '../../../helpers/AppSettings'; +import { IconButton } from '../../IconButton/IconButton'; +import { FaGripVertical } from 'react-icons/fa6'; + +interface SidebarItemTitleProps { + subject: string; + active?: boolean; + listeners?: SyntheticListenerMap; + attributes?: DraggableAttributes; + hideActionButtons?: boolean; + isDragging?: boolean; + onClick?: () => unknown; +} + +export const SidebarItemTitle = forwardRef< + HTMLAnchorElement, + SidebarItemTitleProps +>( + ( + { + subject, + active, + listeners, + attributes, + hideActionButtons, + isDragging, + onClick, + }, + ref, + ): React.JSX.Element => { + const resource = useResource(subject); + const { sidebarKeyboardDndEnabled } = useSettings(); + const [classType] = useArray(resource, core.properties.isA); + const [description] = useString(resource, core.properties.description); + const Icon = getIconForClass(classType[0]!); + + return ( + + {sidebarKeyboardDndEnabled ? ( + + + + + + + + {resource.title} + + + + ) : ( + + + + + {resource.title} + + + + )} + {!hideActionButtons && } + + ); + }, +); + +SidebarItemTitle.displayName = 'SidebarItemTitle'; + +const lift = keyframes` + from { + box-shadow: var(--aw-box-shadow-start); + scale: 0.9; + } to { + box-shadow: var(--aw-box-shadow-end); + scale: 1; + } +`; + +const StyledIconButton = styled(IconButton)` + --button-padding: 0; +`; + +const ActionWrapper = styled.div<{ isDragging?: boolean }>` + --aw-box-shadow-start: 0 0 0 0px rgba(0, 0, 0, 0.1); + --aw-box-shadow-end: 0 0 0 1px ${p => p.theme.colors.main}, + ${p => p.theme.boxShadowSoft}; + + display: flex; + width: 100%; + margin-left: -0.7rem; + ${floatingHoverStyles} + border-radius: ${p => p.theme.radius}; + ${p => + p.isDragging && + css` + animation: ${lift} 0.2s ease-in-out forwards; + opacity: 0.9; + `} + + ${StyledIconButton} svg:last-of-type { + display: none; + visibility: hidden; + } + + &:focus-within, + &:hover { + ${StyledIconButton} svg:first-of-type { + display: none; + visibility: hidden; + } + ${StyledIconButton} svg:last-of-type { + display: block; + visibility: visible; + cursor: grab; + } + } +`; diff --git a/browser/data-browser/src/components/SideBar/ResourceSideBar/shared.ts b/browser/data-browser/src/components/SideBar/ResourceSideBar/shared.ts new file mode 100644 index 000000000..339689045 --- /dev/null +++ b/browser/data-browser/src/components/SideBar/ResourceSideBar/shared.ts @@ -0,0 +1,13 @@ +import { styled } from 'styled-components'; +import { AtomicLink } from '../../AtomicLink'; + +export const StyledLink = styled(AtomicLink)` + flex: 1; + overflow: hidden; + white-space: nowrap; +`; +export const TextWrapper = styled.span` + display: inline-flex; + align-items: center; + gap: 0.4rem; +`; diff --git a/browser/data-browser/src/components/SideBar/SideBarDrive.tsx b/browser/data-browser/src/components/SideBar/SideBarDrive.tsx index 9f329c44d..30ee17991 100644 --- a/browser/data-browser/src/components/SideBar/SideBarDrive.tsx +++ b/browser/data-browser/src/components/SideBar/SideBarDrive.tsx @@ -6,7 +6,7 @@ import { useStore, useTitle, } from '@tomic/react'; -import { useEffect, useState } from 'react'; +import { Fragment, useEffect, useState } from 'react'; import { FaPlus } from 'react-icons/fa'; import { useNavigate } from 'react-router-dom'; import { styled } from 'styled-components'; @@ -23,18 +23,34 @@ import { IconButton } from '../IconButton/IconButton'; import { Row } from '../Row'; import { useCurrentSubject } from '../../helpers/useCurrentSubject'; import { ScrollArea } from '../ScrollArea'; +import { useSidebarDnd } from './useSidebarDnd'; +import { DndContext, DragOverlay } from '@dnd-kit/core'; +import { SidebarItemTitle } from './ResourceSideBar/SidebarItemTitle'; +import { DropEdge } from './ResourceSideBar/DropEdge'; +import { createPortal } from 'react-dom'; interface SideBarDriveProps { /** Closes the sidebar on small screen devices */ handleClickItem: () => unknown; + onIsRearangingChange: (isRearanging: boolean) => void; } /** Shows the current Drive, it's children and an option to change to a different Drive */ export function SideBarDrive({ handleClickItem, + onIsRearangingChange, }: SideBarDriveProps): JSX.Element { const store = useStore(); const { drive, agent } = useSettings(); + const { + handleDragStart, + handleDragEnd, + draggingResource, + sensors, + animateDrop, + dndExplanation, + announcements, + } = useSidebarDnd(onIsRearangingChange); const driveResource = useResource(drive); const [subResources] = useArray(driveResource, urls.properties.subResources); const [title] = useTitle(driveResource); @@ -79,31 +95,59 @@ export function SideBarDrive({ - - - {driveResource.isReady() ? ( - subResources.map(child => { - return ( - - ); - }) - ) : driveResource.loading ? null : ( - - {driveResource.error && - (driveResource.isUnauthorized() - ? agent - ? 'unauthorized' - : driveResource.error.message - : driveResource.error.message)} - - )} - - + + + + + {driveResource.isReady() ? ( + subResources.map((child, index) => { + return ( + + + + + ); + }) + ) : driveResource.loading ? null : ( + + {driveResource.error && + (driveResource.isUnauthorized() + ? agent + ? 'unauthorized' + : driveResource.error.message + : driveResource.error.message)} + + )} + + + {createPortal( + + {draggingResource && ( + + )} + , + document.body, + )} + ); } @@ -126,6 +170,7 @@ const SideBarErr = styled(ErrorLook)` const ListWrapper = styled.div` overflow-x: hidden; + position: relative; margin-left: 0.5rem; `; diff --git a/browser/data-browser/src/components/SideBar/SideBarItem.ts b/browser/data-browser/src/components/SideBar/SideBarItem.ts index e91e78b12..0a84943ce 100644 --- a/browser/data-browser/src/components/SideBar/SideBarItem.ts +++ b/browser/data-browser/src/components/SideBar/SideBarItem.ts @@ -24,7 +24,7 @@ export const SideBarItem = styled('span')` color: ${p => (p.disabled ? p.theme.colors.main : p.theme.colors.text)}; } &:active { - background-color: ${p => p.theme.colors.bg2}; + opacity: 0.5; } svg { diff --git a/browser/data-browser/src/components/SideBar/index.tsx b/browser/data-browser/src/components/SideBar/index.tsx index 8871065e4..357ead724 100644 --- a/browser/data-browser/src/components/SideBar/index.tsx +++ b/browser/data-browser/src/components/SideBar/index.tsx @@ -21,6 +21,8 @@ export const SIDEBAR_TOGGLE_WIDTH = 600; const SideBarDriveMemo = React.memo(SideBarDrive); export function SideBar(): JSX.Element { + const [isRearanging, setIsRearanging] = React.useState(false); + const { drive, sideBarLocked, setSideBarLocked } = useSettings(); const [ref, hoveringOverSideBar, listeners] = useHover(); // Check if the window is small enough to hide the sidebar @@ -29,11 +31,12 @@ export function SideBar(): JSX.Element { true, ); - const { size, targetRef, dragAreaRef, isDragging } = useResizable({ - initialSize: 300, - minSize: 200, - maxSize: 2000, - }); + const { size, targetRef, dragAreaRef, isDragging, dragAreaListeners } = + useResizable({ + initialSize: 300, + minSize: 200, + maxSize: 2000, + }); const { enabledPanels } = usePanelList(); @@ -65,7 +68,11 @@ export function SideBar(): JSX.Element { > {/* The key is set to make sure the component is re-loaded when the baseURL changes */} - + {enabledPanels.has(Panel.Ontologies) && ( @@ -83,7 +90,13 @@ export function SideBar(): JSX.Element { - + {!isRearanging && ( + + )} setSideBarLocked(false)} diff --git a/browser/data-browser/src/components/SideBar/useSidebarDnd.ts b/browser/data-browser/src/components/SideBar/useSidebarDnd.ts new file mode 100644 index 000000000..3dca390ea --- /dev/null +++ b/browser/data-browser/src/components/SideBar/useSidebarDnd.ts @@ -0,0 +1,265 @@ +import { + Announcements, + DragEndEvent, + DragStartEvent, + DropAnimationFunction, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { Resource, Store, core, dataBrowser, useStore } from '@tomic/react'; +import { useCallback, useState } from 'react'; +import { getTransitionName } from '../../helpers/transitionName'; +import { useSettings } from '../../helpers/AppSettings'; + +export type SideBarDropData = { + parent: string; + position: number; +}; + +export type SideBarDragData = { + renderedUnder: string; +}; + +async function moveItemInSameParent( + store: Store, + parent: Resource, + subject: string, + toPosition: number, +) { + const subResources = parent.get(dataBrowser.properties.subResources) ?? []; + + const fromPosition = subResources.indexOf(subject); + const newArray = [...subResources]; + const [removed] = newArray.splice(fromPosition, 1); + newArray.splice( + toPosition > fromPosition ? toPosition - 1 : toPosition, + 0, + removed, + ); + + await parent.set(dataBrowser.properties.subResources, newArray, store); + + await parent.save(store); +} + +async function moveItemBetweenParents( + store: Store, + oldParent: Resource, + newParent: Resource, + resource: Resource, + position: number, +) { + const oldSubResources = + oldParent.get(dataBrowser.properties.subResources) ?? []; + await oldParent.set( + dataBrowser.properties.subResources, + oldSubResources.filter(subject => subject !== resource.getSubject()), + store, + ); + + const newSubResources = + newParent.get(dataBrowser.properties.subResources) ?? []; + + await newParent.set( + dataBrowser.properties.subResources, + newSubResources.toSpliced(position, 0, resource.getSubject()), + store, + ); + + await resource.set(core.properties.parent, newParent.getSubject(), store); + + await oldParent.save(store); + await newParent.save(store); + await resource.save(store); +} + +export const useSidebarDnd = ( + onIsRearangingChange: (isRearanging: boolean) => void, +) => { + const store = useStore(); + const { sidebarKeyboardDndEnabled } = useSettings(); + + const keyboardSensor = useSensor(KeyboardSensor); + + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { + distance: 10, + }, + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 250, + tolerance: 5, + }, + }), + sidebarKeyboardDndEnabled ? keyboardSensor : undefined, + ); + + const [draggingResource, setDraggingResource] = useState(); + const [waitForSavePromise, setWaitForSavePromise] = useState>(); + + const animateDrop: DropAnimationFunction = useCallback( + ({ active, dragOverlay, transform }) => { + if (!active || !dragOverlay) { + return; + } + + return new Promise(resolve => { + waitForSavePromise?.then(() => { + const targetNode = document.querySelector( + `[data-sidebar-id="${getTransitionName( + 'sidebar', + active.id as string, + )}"]`, + ) as HTMLElement; + + if (!targetNode) { + return resolve(); + } + + targetNode.style.opacity = '0'; + + const { top: originTop, left: originLeft } = dragOverlay.rect; + const { x: originTransformX, y: originTransformY } = transform; + + const { top: targetTop, left: targetLeft } = + targetNode.getBoundingClientRect(); + + const targetTransformX = targetLeft - originLeft + originTransformX; + const targetTransformY = targetTop - originTop + originTransformY; + + const dropAnimation = dragOverlay.node.animate( + [ + { + transform: `translate(${originTransformX}px, ${originTransformY}px)`, + }, + { + transform: `translate(${targetTransformX}px, ${targetTransformY}px)`, + }, + ], + { + duration: 300, + easing: 'cubic-bezier(0.2, 0, 0, 1)', + }, + ); + + dropAnimation.onfinish = () => { + targetNode.style.opacity = '1'; + resolve(); + }; + }); + }); + }, + [waitForSavePromise], + ); + + const handleDragStart = (event: DragStartEvent) => { + onIsRearangingChange(true); + setDraggingResource(event.active.id as string); + }; + + const handleDragEnd = async (event: DragEndEvent) => { + if (!event.over) { + setDraggingResource(undefined); + onIsRearangingChange(false); + setWaitForSavePromise(Promise.resolve()); + + return; + } + + const subject = event.active.id as string; + const { renderedUnder } = event.active.data + .current as unknown as SideBarDragData; + const { position, parent: dropParent } = event.over.data + .current as unknown as SideBarDropData; + + const newParent = store.getResourceLoading(dropParent); + const oldParent = store.getResourceLoading(renderedUnder); + const resource = store.getResourceLoading(subject); + + // The user should not be able to nest a folder inside itself. + if (subject === dropParent) { + onIsRearangingChange(false); + setDraggingResource(undefined); + setWaitForSavePromise(Promise.resolve()); + + return; + } + + let promise: Promise; + + if (renderedUnder === dropParent) { + promise = moveItemInSameParent(store, newParent, subject, position); + } else { + promise = moveItemBetweenParents( + store, + oldParent, + newParent, + resource, + position, + ); + } + + setWaitForSavePromise(promise); + await promise; + setDraggingResource(undefined); + onIsRearangingChange(false); + }; + + const dndExplanation: string = sidebarKeyboardDndEnabled + ? 'To rearange items, press space or enter to start dragging. While dragging, use the arrow keys to move the item in any given direction. Press space or enter again to drop the item in its new position, or press escape to cancel.' + : 'Keyboard support for drag and drop is disabled. Enable it in the settings.'; + + const announcements: Announcements = { + onDragStart: ({ active }) => { + const resource = store.getResourceLoading(active.id as string); + + return `Picked up ${resource.title}`; + }, + onDragOver: ({ active, over }) => { + if (!over || !over.data.current) { + return; + } + + const dragResource = store.getResourceLoading(active.id as string); + const dropResource = store.getResourceLoading(over.data.current.parent); + const pos = over.data.current.position as number; + + return `Draggable item ${ + dragResource.title + } was moved over droppable area in ${dropResource.title} at position ${ + pos + 1 + }`; + }, + onDragEnd: ({ active, over }) => { + if (!over || !over.data.current) { + return `Dragging canceled`; + } + + const dragResource = store.getResourceLoading(active.id as string); + const dropResource = store.getResourceLoading(over.data.current.parent); + const pos = over.data.current.position as number; + + return `${dragResource.title} was moved to ${ + dropResource.title + } at position ${pos + 1}`; + }, + onDragCancel: () => { + return `Dragging canceled`; + }, + }; + + return { + handleDragStart, + handleDragEnd, + draggingResource, + sensors, + animateDrop, + dndExplanation, + announcements, + }; +}; diff --git a/browser/data-browser/src/components/TableEditor/TableHeading.tsx b/browser/data-browser/src/components/TableEditor/TableHeading.tsx index 657da5ea1..38acfc1e1 100644 --- a/browser/data-browser/src/components/TableEditor/TableHeading.tsx +++ b/browser/data-browser/src/components/TableEditor/TableHeading.tsx @@ -36,11 +36,12 @@ export function TableHeading({ data: { index }, }); - const { targetRef, dragAreaRef, isDragging } = useResizable({ - initialSize: DEFAULT_SIZE_PX, - minSize: 100, - onResize: size => onResize(index, `${size}px`), - }); + const { targetRef, dragAreaRef, isDragging, dragAreaListeners } = + useResizable({ + initialSize: DEFAULT_SIZE_PX, + minSize: 100, + onResize: size => onResize(index, `${size}px`), + }); const { setIsDragging } = useTableEditorContext(); @@ -67,7 +68,11 @@ export function TableHeading({ dragAttributes={attributes} /> {isReordering && } - + ); } diff --git a/browser/data-browser/src/components/forms/FilePicker/FilePickerDialog.tsx b/browser/data-browser/src/components/forms/FilePicker/FilePickerDialog.tsx index 689135e0c..3f7952a67 100644 --- a/browser/data-browser/src/components/forms/FilePicker/FilePickerDialog.tsx +++ b/browser/data-browser/src/components/forms/FilePicker/FilePickerDialog.tsx @@ -9,7 +9,7 @@ import { import { InputStyled, InputWrapper } from '../InputStyles'; import { FaPlus, FaSearch } from 'react-icons/fa'; import { core, server, useServerSearch } from '@tomic/react'; -import styled from 'styled-components'; +import { styled } from 'styled-components'; import { FilePickerItem } from './FIlePickerItem'; import { Button } from '../../Button'; import { Row } from '../../Row'; diff --git a/browser/data-browser/src/components/forms/FilePicker/SelectedFileLayout.tsx b/browser/data-browser/src/components/forms/FilePicker/SelectedFileLayout.tsx index 8cb428312..3179afd84 100644 --- a/browser/data-browser/src/components/forms/FilePicker/SelectedFileLayout.tsx +++ b/browser/data-browser/src/components/forms/FilePicker/SelectedFileLayout.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren } from 'react'; import { FaTimes } from 'react-icons/fa'; -import styled from 'styled-components'; +import { styled } from 'styled-components'; import { IconButton } from '../../IconButton/IconButton'; import { Row } from '../../Row'; diff --git a/browser/data-browser/src/helpers/AppSettings.tsx b/browser/data-browser/src/helpers/AppSettings.tsx index 8606e75e3..4bda03922 100644 --- a/browser/data-browser/src/helpers/AppSettings.tsx +++ b/browser/data-browser/src/helpers/AppSettings.tsx @@ -27,9 +27,9 @@ export const AppSettingsContextProvider = ( const [darkMode, setDarkMode, darkModeSetting] = useDarkMode(); const [mainColor, setMainColor] = useLocalStorage('mainColor', '#1b50d8'); const [navbarTop, setNavbarTop] = useLocalStorage('navbarTop', false); - const [viewTransitionsEnabled, setViewTransitionsEnabled] = useLocalStorage( - 'viewTransitionsEnabled', - true, + const [viewTransitionsDisabled, setViewTransitionsDisabled] = useLocalStorage( + 'viewTransitionsDisabled', + false, ); const [navbarFloating, setNavbarFloating] = useLocalStorage( 'navbarFloating', @@ -40,6 +40,9 @@ export const AppSettingsContextProvider = ( window.innerWidth > SIDEBAR_TOGGLE_WIDTH, ); + const [sidebarKeyboardDndEnabled, setSidebarKeyboardDndEnabled] = + useLocalStorage('sidebarKeyboardDndEnabled', false); + const [agent, setAgent] = useCurrentAgent(); const [baseURL, setBaseURL] = useServerURL(); const [drive, innerSetDrive] = useLocalStorage('drive', baseURL); @@ -83,8 +86,10 @@ export const AppSettingsContextProvider = ( setSideBarLocked, agent, setAgent: setAgentAndShowToast, - viewTransitionsEnabled, - setViewTransitionsEnabled, + viewTransitionsDisabled, + setViewTransitionsDisabled, + sidebarKeyboardDndEnabled, + setSidebarKeyboardDndEnabled, }), [ drive, @@ -102,8 +107,10 @@ export const AppSettingsContextProvider = ( setSideBarLocked, agent, setAgentAndShowToast, - viewTransitionsEnabled, - setViewTransitionsEnabled, + viewTransitionsDisabled, + setViewTransitionsDisabled, + sidebarKeyboardDndEnabled, + setSidebarKeyboardDndEnabled, ], ); @@ -142,8 +149,10 @@ export interface AppSettings { agent: Agent | undefined; setAgent: (a: Agent | undefined) => void; /** If the app should use view transitions */ - viewTransitionsEnabled: boolean; - setViewTransitionsEnabled: (b: boolean) => void; + viewTransitionsDisabled: boolean; + setViewTransitionsDisabled: (b: boolean) => void; + sidebarKeyboardDndEnabled: boolean; + setSidebarKeyboardDndEnabled: (b: boolean) => void; } const initialState: AppSettings = { @@ -162,8 +171,10 @@ const initialState: AppSettings = { setSideBarLocked: () => undefined, agent: undefined, setAgent: () => undefined, - viewTransitionsEnabled: true, - setViewTransitionsEnabled: () => undefined, + viewTransitionsDisabled: true, + setViewTransitionsDisabled: () => undefined, + sidebarKeyboardDndEnabled: false, + setSidebarKeyboardDndEnabled: () => undefined, }; /** Hook for using App Settings, such as theme and darkmode */ diff --git a/browser/data-browser/src/helpers/transitionName.ts b/browser/data-browser/src/helpers/transitionName.ts index 95423f0c0..537b9361f 100644 --- a/browser/data-browser/src/helpers/transitionName.ts +++ b/browser/data-browser/src/helpers/transitionName.ts @@ -36,3 +36,17 @@ export function transitionName(tag: string, subject: string | undefined) { return `view-transition-name: ${name}`; } + +export function getTransitionStyle(tag: string, subject: string | undefined) { + let name: string; + + try { + name = getTransitionName(tag, subject); + } catch (e) { + return {}; + } + + return { + viewTransitionName: name, + }; +} diff --git a/browser/data-browser/src/hooks/useNavigateWithTransition.ts b/browser/data-browser/src/hooks/useNavigateWithTransition.ts index e0dfe5a1d..94c24ab3a 100644 --- a/browser/data-browser/src/hooks/useNavigateWithTransition.ts +++ b/browser/data-browser/src/hooks/useNavigateWithTransition.ts @@ -9,12 +9,12 @@ const wait = (ms: number) => new Promise(r => setTimeout(r, ms)); */ export function useNavigateWithTransition() { const navigate = useNavigate(); - const { viewTransitionsEnabled } = useSettings(); + const { viewTransitionsDisabled } = useSettings(); const navigateWithTransition = useCallback( (to: string | number) => { // @ts-ignore - if (!viewTransitionsEnabled || !document.startViewTransition) { + if (viewTransitionsDisabled || !document.startViewTransition) { //@ts-ignore navigate(to); diff --git a/browser/data-browser/src/hooks/useResizable.ts b/browser/data-browser/src/hooks/useResizable.ts index 26d6c7505..b522c3d1d 100644 --- a/browser/data-browser/src/hooks/useResizable.ts +++ b/browser/data-browser/src/hooks/useResizable.ts @@ -1,11 +1,19 @@ import { transparentize } from 'polished'; -import { useEffect, useId, useRef, useState } from 'react'; +import { + MouseEventHandler, + useCallback, + useEffect, + useId, + useRef, + useState, +} from 'react'; import { styled } from 'styled-components'; interface UseResizeResult { size: string; targetRef: React.RefObject; dragAreaRef: React.RefObject; + dragAreaListeners: Pick, 'onMouseDown'>; isDragging: boolean; } @@ -86,6 +94,14 @@ export function useResizable({ }); }); + const onMouseDown: MouseEventHandler = useCallback(e => { + e.stopPropagation(); + + if (e.target !== dragAreaRef.current) return; + + setDragging(true); + }, []); + useEffect(() => { if (!targetRef.current || !dragAreaRef.current) { return () => { @@ -93,24 +109,13 @@ export function useResizable({ }; } - const mouseDown = (e: MouseEvent) => { - e.stopPropagation(); - - if (e.target !== dragAreaRef.current) return; - - setDragging(true); - }; - const mouseUp = () => { setDragging(false); }; - dragAreaRef.current.addEventListener('mousedown', mouseDown); - window.addEventListener('mouseup', mouseUp); return () => { - dragAreaRef.current?.removeEventListener('mousedown', mouseDown); window.removeEventListener('mouseup', mouseUp); cleanup(styleId); }; @@ -135,6 +140,9 @@ export function useResizable({ targetRef, dragAreaRef, isDragging: dragging, + dragAreaListeners: { + onMouseDown, + }, }; } diff --git a/browser/data-browser/src/routes/SettingsTheme.tsx b/browser/data-browser/src/routes/SettingsTheme.tsx index e51d39398..ad1b7f72e 100644 --- a/browser/data-browser/src/routes/SettingsTheme.tsx +++ b/browser/data-browser/src/routes/SettingsTheme.tsx @@ -15,8 +15,10 @@ export const SettingsTheme: React.FunctionComponent = () => { const { darkModeSetting, setDarkMode, - viewTransitionsEnabled, - setViewTransitionsEnabled, + viewTransitionsDisabled, + setViewTransitionsDisabled, + sidebarKeyboardDndEnabled, + setSidebarKeyboardDndEnabled, } = useSettings(); const { enabledPanels, enablePanel, disablePanel } = usePanelList(); @@ -72,13 +74,20 @@ export const SettingsTheme: React.FunctionComponent = () => { />{' '} Enable Ontology panel - Animations + Accessibility setViewTransitionsEnabled(checked)} + checked={viewTransitionsDisabled} + onChange={checked => setViewTransitionsDisabled(checked)} />{' '} - Enable view transitions + Disable page transition animations + + + setSidebarKeyboardDndEnabled(checked)} + />{' '} + Enable keyboard drag & drop in sidebar diff --git a/browser/data-browser/src/views/FolderPage/GridItem/components.tsx b/browser/data-browser/src/views/FolderPage/GridItem/components.tsx index 85896e15d..ff4ee03ed 100644 --- a/browser/data-browser/src/views/FolderPage/GridItem/components.tsx +++ b/browser/data-browser/src/views/FolderPage/GridItem/components.tsx @@ -1,8 +1,10 @@ import { styled } from 'styled-components'; -import { transitionName } from '../../../helpers/transitionName'; +import { getTransitionStyle } from '../../../helpers/transitionName'; import { ViewTransitionProps } from '../../../helpers/ViewTransitionProps'; -export const GridCard = styled.div` +export const GridCard = styled.div.attrs(p => ({ + style: getTransitionStyle('resource-page', p.subject), +}))` grid-area: card; background-color: ${p => p.theme.colors.bg1}; border-radius: ${p => p.theme.radius}; @@ -10,7 +12,6 @@ export const GridCard = styled.div` box-shadow: var(--shadow), var(--interaction-shadow); border: 1px solid ${p => p.theme.colors.bg2}; transition: border 0.1s ease-in-out, box-shadow 0.1s ease-in-out; - ${props => transitionName('resource-page', props.subject)}; `; export const GridItemWrapper = styled.a` @@ -44,7 +45,9 @@ export const GridItemWrapper = styled.a` } `; -export const GridItemTitle = styled.div` +export const GridItemTitle = styled.div.attrs(p => ({ + style: getTransitionStyle('page-title', p.subject), +}))` grid-area: title; font-size: 1rem; text-align: center; @@ -53,7 +56,6 @@ export const GridItemTitle = styled.div` text-overflow: ellipsis; padding-inline: 0.5rem; transition: color 0.1s ease-in-out; - ${props => transitionName('page-title', props.subject)}; `; export const GridItemDescription = styled.div` diff --git a/browser/e2e/tests/test-utils.ts b/browser/e2e/tests/test-utils.ts index f0502fa4a..b9f75ff53 100644 --- a/browser/e2e/tests/test-utils.ts +++ b/browser/e2e/tests/test-utils.ts @@ -72,12 +72,12 @@ export async function setTitle(page: Page, title: string) { } export async function disableViewTransition(page: Page) { - await page.click('text=Settings'); - const checkbox = page.getByLabel('Enable view transition'); + await page.getByRole('link', { name: 'Settings' }).click(); + const checkbox = page.getByLabel('Disable page transition animations'); await expect(checkbox).toBeVisible(); - await checkbox.uncheck(); + await checkbox.check(); await page.goBack(); } diff --git a/browser/pnpm-lock.yaml b/browser/pnpm-lock.yaml index 51dece24a..7b85ed7fa 100644 --- a/browser/pnpm-lock.yaml +++ b/browser/pnpm-lock.yaml @@ -128,14 +128,14 @@ importers: specifier: ^1.0.2 version: 1.0.2 '@dnd-kit/core': - specifier: ^6.0.5 - version: 6.0.8(react-dom@18.2.0)(react@18.2.0) + specifier: ^6.1.0 + version: 6.1.0(react-dom@18.2.0)(react@18.2.0) '@dnd-kit/sortable': - specifier: ^7.0.1 - version: 7.0.2(@dnd-kit/core@6.0.8)(react@18.2.0) + specifier: ^8.0.0 + version: 8.0.0(@dnd-kit/core@6.1.0)(react@18.2.0) '@dnd-kit/utilities': - specifier: ^3.2.0 - version: 3.2.1(react@18.2.0) + specifier: ^3.2.2 + version: 3.2.2(react@18.2.0) '@emoji-mart/react': specifier: ^1.1.1 version: 1.1.1(emoji-mart@5.5.2)(react@18.2.0) @@ -1756,8 +1756,8 @@ packages: node-source-walk: 6.0.2 dev: true - /@dnd-kit/accessibility@3.0.1(react@18.2.0): - resolution: {integrity: sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==} + /@dnd-kit/accessibility@3.1.0(react@18.2.0): + resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==} peerDependencies: react: '>=16.8.0' dependencies: @@ -1765,33 +1765,33 @@ packages: tslib: 2.6.1 dev: false - /@dnd-kit/core@6.0.8(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==} + /@dnd-kit/core@6.1.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' dependencies: - '@dnd-kit/accessibility': 3.0.1(react@18.2.0) - '@dnd-kit/utilities': 3.2.1(react@18.2.0) + '@dnd-kit/accessibility': 3.1.0(react@18.2.0) + '@dnd-kit/utilities': 3.2.2(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) tslib: 2.6.1 dev: false - /@dnd-kit/sortable@7.0.2(@dnd-kit/core@6.0.8)(react@18.2.0): - resolution: {integrity: sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==} + /@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.1.0)(react@18.2.0): + resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==} peerDependencies: - '@dnd-kit/core': ^6.0.7 + '@dnd-kit/core': ^6.1.0 react: '>=16.8.0' dependencies: - '@dnd-kit/core': 6.0.8(react-dom@18.2.0)(react@18.2.0) - '@dnd-kit/utilities': 3.2.1(react@18.2.0) + '@dnd-kit/core': 6.1.0(react-dom@18.2.0)(react@18.2.0) + '@dnd-kit/utilities': 3.2.2(react@18.2.0) react: 18.2.0 tslib: 2.6.1 dev: false - /@dnd-kit/utilities@3.2.1(react@18.2.0): - resolution: {integrity: sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==} + /@dnd-kit/utilities@3.2.2(react@18.2.0): + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} peerDependencies: react: '>=16.8.0' dependencies: