diff --git a/app/components/Sidebar/App.tsx b/app/components/Sidebar/App.tsx index 05c9bcc16..887b079a8 100644 --- a/app/components/Sidebar/App.tsx +++ b/app/components/Sidebar/App.tsx @@ -25,6 +25,7 @@ import TeamLogo from "../TeamLogo"; import Sidebar from "./Sidebar"; import ArchiveLink from "./components/ArchiveLink"; import Collections from "./components/Collections"; +import DragPlaceholder from "./components/DragPlaceholder"; import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton"; import HistoryNavigation from "./components/HistoryNavigation"; import Section from "./components/Section"; @@ -61,6 +62,8 @@ function AppSidebar() { {dndArea && ( + + {(props: HeaderButtonProps) => ( - {isDraggingAnyDocument && can.update && ( + {isDraggingAnyDocument && can.update && manualSort && ( ({ ...node, @@ -149,12 +151,13 @@ function InnerDocumentLink( collect: (monitor) => ({ isDragging: monitor.isDragging(), }), - canDrag: () => - policies.abilities(node.id).move || - policies.abilities(node.id).archive || - policies.abilities(node.id).delete, + canDrag: () => can.move || can.archive || can.delete, }); + React.useEffect(() => { + preview(getEmptyImage(), { captureDraggingState: true }); + }, [preview]); + const hoverExpanding = React.useRef>(); // We set a timeout when the user first starts hovering over the document link, @@ -179,10 +182,11 @@ function InnerDocumentLink( await documents.move(item.id, collection.id, node.id); setExpanded(true); }, - canDrop: (_item, monitor) => + canDrop: (item, monitor) => !isDraft && !!pathToNode && - !pathToNode.includes(monitor.getItem().id), + !pathToNode.includes(monitor.getItem().id) && + item.id !== node.id, hover: (_item, monitor) => { // Enables expansion of document children when hovering over the document // for more than half a second. @@ -283,7 +287,6 @@ function InnerDocumentLink( (activeDocument?.id === node.id ? activeDocument.title : node.title) || t("Untitled"); - const can = policies.abilities(node.id); const isExpanded = expanded && !isDragging; const hasChildren = nodeChildren.length > 0; @@ -376,12 +379,8 @@ function InnerDocumentLink( - {isDraggingAnyDocument && ( - + {isDraggingAnyDocument && manualSort && ( + )} @@ -404,7 +403,8 @@ function InnerDocumentLink( } const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>` - opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)}; + transition: opacity 250ms ease; + opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.1 : 1)}; pointer-events: ${(props) => (props.$isMoving ? "none" : "all")}; `; diff --git a/app/components/Sidebar/components/DragPlaceholder.tsx b/app/components/Sidebar/components/DragPlaceholder.tsx new file mode 100644 index 000000000..289997b20 --- /dev/null +++ b/app/components/Sidebar/components/DragPlaceholder.tsx @@ -0,0 +1,81 @@ +import * as React from "react"; +import { useDragLayer, XYCoord } from "react-dnd"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import useStores from "~/hooks/useStores"; +import SidebarLink from "./SidebarLink"; + +const layerStyles: React.CSSProperties = { + position: "fixed", + pointerEvents: "none", + zIndex: 100, + left: 0, + top: 0, + width: "100%", + height: "100%", +}; + +function getItemStyles( + initialOffset: XYCoord | null, + currentOffset: XYCoord | null, + sidebarWidth: number +) { + if (!initialOffset || !currentOffset) { + return { + display: "none", + }; + } + const { y } = currentOffset; + const x = Math.max( + initialOffset.x, + Math.min(initialOffset.x + sidebarWidth / 4, currentOffset.x) + ); + + const transform = `translate(${x}px, ${y}px)`; + return { + width: sidebarWidth - 24, + transform, + WebkitTransform: transform, + }; +} + +const DragPlaceholder = () => { + const { t } = useTranslation(); + const { ui } = useStores(); + + const { isDragging, item, initialOffset, currentOffset } = useDragLayer( + (monitor) => ({ + item: monitor.getItem(), + itemType: monitor.getItemType(), + initialOffset: monitor.getInitialSourceClientOffset(), + currentOffset: monitor.getSourceClientOffset(), + isDragging: monitor.isDragging(), + }) + ); + + if (!isDragging || !currentOffset) { + return null; + } + + return ( +
+
+ +
+
+ ); +}; + +const GhostLink = styled(SidebarLink)` + transition: box-shadow 250ms ease-in-out; + box-shadow: rgb(0 0 0 / 30%) 0px 4px 15px; + opacity: 0.95; +`; + +export default DragPlaceholder; diff --git a/app/components/Sidebar/components/DraggableCollectionLink.tsx b/app/components/Sidebar/components/DraggableCollectionLink.tsx index 00c1fd47e..54d1eb9aa 100644 --- a/app/components/Sidebar/components/DraggableCollectionLink.tsx +++ b/app/components/Sidebar/components/DraggableCollectionLink.tsx @@ -2,10 +2,12 @@ import fractionalIndex from "fractional-index"; import { observer } from "mobx-react"; import * as React from "react"; import { useDrop, useDrag, DropTargetMonitor } from "react-dnd"; +import { getEmptyImage } from "react-dnd-html5-backend"; import { useLocation } from "react-router-dom"; import styled from "styled-components"; import Collection from "~/models/Collection"; import Document from "~/models/Document"; +import CollectionIcon from "~/components/Icons/CollectionIcon"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import CollectionLink from "./CollectionLink"; @@ -62,26 +64,28 @@ function DraggableCollectionLink({ }, collect: (monitor: DropTargetMonitor) => ({ isCollectionDropping: monitor.isOver(), - isDraggingAnyCollection: monitor.getItemType() === "collection", + isDraggingAnyCollection: monitor.canDrop(), }), }); // Drag to reorder collection - const [{ isCollectionDragging }, dragToReorderCollection] = useDrag({ + const [{ isDragging }, dragToReorderCollection, preview] = useDrag({ type: "collection", - item: () => { - return { - id: collection.id, - }; - }, - collect: (monitor) => ({ - isCollectionDragging: monitor.isDragging(), + item: () => ({ + id: collection.id, + title: collection.name, + icon: , }), - canDrag: () => { - return can.move; - }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + canDrag: () => can.move, }); + React.useEffect(() => { + preview(getEmptyImage(), { captureDraggingState: false }); + }, [preview]); + // If the current collection is active and relevant to the sidebar section we // are in then expand it automatically React.useEffect(() => { @@ -95,14 +99,14 @@ function DraggableCollectionLink({ setExpanded((e) => !e); }, []); - const displayChildDocuments = expanded && !isCollectionDragging; + const displayChildDocuments = expanded && !isDragging; return ( <> ` - opacity: ${(props) => (props.$isDragging ? 0.5 : 1)}; + transition: opacity 250ms ease; + opacity: ${(props) => (props.$isDragging ? 0.1 : 1)}; pointer-events: ${(props) => (props.$isDragging ? "none" : "auto")}; `; diff --git a/app/components/Sidebar/components/DropCursor.tsx b/app/components/Sidebar/components/DropCursor.tsx index d8bab427a..8b04f1fcf 100644 --- a/app/components/Sidebar/components/DropCursor.tsx +++ b/app/components/Sidebar/components/DropCursor.tsx @@ -2,27 +2,18 @@ import * as React from "react"; import styled from "styled-components"; type Props = { - disabled?: boolean; isActiveDrop: boolean; innerRef: React.Ref; position?: "top"; }; -function DropCursor({ isActiveDrop, innerRef, position, disabled }: Props) { - return ( - - ); +function DropCursor({ isActiveDrop, innerRef, position }: Props) { + return ; } // transparent hover zone with a thin visible band vertically centered const Cursor = styled.div<{ isOver?: boolean; - disabled?: boolean; position?: "top"; }>` opacity: ${(props) => (props.isOver ? 1 : 0)}; @@ -36,10 +27,7 @@ const Cursor = styled.div<{ ${(props) => (props.position === "top" ? "top: -7px;" : "bottom: -7px;")} ::after { - background: ${(props) => - props.disabled - ? props.theme.sidebarActiveBackground - : props.theme.slateDark}; + background: ${(props) => props.theme.slateDark}; position: absolute; top: 6px; content: ""; diff --git a/app/components/Sidebar/components/Starred.tsx b/app/components/Sidebar/components/Starred.tsx index 4ee247968..da4d79d02 100644 --- a/app/components/Sidebar/components/Starred.tsx +++ b/app/components/Sidebar/components/Starred.tsx @@ -55,8 +55,10 @@ function Starred() { // Drop to reorder document const [{ isOverReorder, isDraggingAnyStar }, dropToReorder] = useDrop({ accept: "star", - drop: async (item: Star) => { - item?.save({ index: fractionalIndex(null, stars.orderedData[0].index) }); + drop: async (item: { star: Star }) => { + item.star.save({ + index: fractionalIndex(null, stars.orderedData[0].index), + }); }, collect: (monitor) => ({ isOverReorder: !!monitor.isOver(), diff --git a/app/components/Sidebar/components/StarredLink.tsx b/app/components/Sidebar/components/StarredLink.tsx index 368441c91..30560b34d 100644 --- a/app/components/Sidebar/components/StarredLink.tsx +++ b/app/components/Sidebar/components/StarredLink.tsx @@ -5,11 +5,13 @@ import { StarredIcon } from "outline-icons"; import * as React from "react"; import { useEffect, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; +import { getEmptyImage } from "react-dnd-html5-backend"; import { useLocation } from "react-router-dom"; import styled, { useTheme } from "styled-components"; import parseTitle from "@shared/utils/parseTitle"; import Star from "~/models/Star"; import Fade from "~/components/Fade"; +import CollectionIcon from "~/components/Icons/CollectionIcon"; import EmojiIcon from "~/components/Icons/EmojiIcon"; import useBoolean from "~/hooks/useBoolean"; import useStores from "~/hooks/useStores"; @@ -33,8 +35,45 @@ function useLocationStateStarred() { return location.state?.starred; } -function StarredLink({ star }: Props) { +function useLabelAndIcon({ documentId, collectionId }: Star) { + const { collections, documents } = useStores(); const theme = useTheme(); + + if (documentId) { + const document = documents.get(documentId); + if (document) { + const { emoji } = parseTitle(document?.title); + + return { + label: emoji + ? document.title.replace(emoji, "") + : document.titleWithDefault, + icon: emoji ? ( + + ) : ( + + ), + }; + } + } + + if (collectionId) { + const collection = collections.get(collectionId); + if (collection) { + return { + label: collection.name, + icon: , + }; + } + } + + return { + label: "", + icon: , + }; +} + +function StarredLink({ star }: Props) { const { ui, collections, documents } = useStores(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const { documentId, collectionId } = star; @@ -69,23 +108,33 @@ function StarredLink({ star }: Props) { [] ); + const { label, icon } = useLabelAndIcon(star); + // Draggable - const [{ isDragging }, drag] = useDrag({ + const [{ isDragging }, drag, preview] = useDrag({ type: "star", - item: () => star, + item: () => ({ + star, + title: label, + icon, + }), collect: (monitor) => ({ isDragging: !!monitor.isDragging(), }), canDrag: () => true, }); + React.useEffect(() => { + preview(getEmptyImage(), { captureDraggingState: true }); + }, [preview]); + // Drop to reorder const [{ isOverReorder, isDraggingAny }, dropToReorder] = useDrop({ accept: "star", - drop: (item: Star) => { + drop: (item: { star: Star }) => { const next = star?.next(); - item?.save({ + item.star.save({ index: fractionalIndex(star?.index || null, next?.index || null), }); }, @@ -104,10 +153,6 @@ function StarredLink({ star }: Props) { } const collection = collections.get(document.collectionId); - const { emoji } = parseTitle(document.title); - const label = emoji - ? document.title.replace(emoji, "") - : document.titleWithDefault; const childDocuments = collection ? collection.getDocumentChildren(documentId) : []; @@ -124,13 +169,7 @@ function StarredLink({ star }: Props) { }} expanded={hasChildDocuments && !isDragging ? expanded : undefined} onDisclosureClick={handleDisclosureClick} - icon={ - emoji ? ( - - ) : ( - - ) - } + icon={icon} isActive={(match, location: Location<{ starred?: boolean }>) => !!match && location.state?.starred === true } @@ -202,7 +241,8 @@ function StarredLink({ star }: Props) { const Draggable = styled.div<{ $isDragging?: boolean }>` position: relative; - opacity: ${(props) => (props.$isDragging ? 0.5 : 1)}; + transition: opacity 250ms ease; + opacity: ${(props) => (props.$isDragging ? 0.1 : 1)}; `; export default observer(StarredLink);