Improve drag-and-drop (#4824)

* Improve drag-and-drop

* fixes

* fix drop highlight showing on ghosted sidebar item
This commit is contained in:
Tom Moor
2023-02-04 12:00:32 -08:00
committed by GitHub
parent 239e9e294d
commit 0b6c9d1838
8 changed files with 184 additions and 66 deletions

View File

@@ -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() {
<HistoryNavigation />
{dndArea && (
<DndProvider backend={HTML5Backend} options={html5Options}>
<DragPlaceholder />
<OrganizationMenu>
{(props: HeaderButtonProps) => (
<HeaderButton

View File

@@ -63,9 +63,8 @@ function CollectionLinkChildren({
return (
<Folder expanded={expanded}>
{isDraggingAnyDocument && can.update && (
{isDraggingAnyDocument && can.update && manualSort && (
<DropCursor
disabled={!manualSort}
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"

View File

@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useDrag, useDrop } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
@@ -136,9 +137,10 @@ function InnerDocumentLink(
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const isMoving = documents.movingDocumentId === node.id;
const manualSort = collection?.sort.field === "index";
const can = policies.abilities(node.id);
// Draggable
const [{ isDragging }, drag] = useDrag({
const [{ isDragging }, drag, preview] = useDrag({
type: "document",
item: () => ({
...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<ReturnType<typeof setTimeout>>();
// 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<DragObject>().id),
!pathToNode.includes(monitor.getItem<DragObject>().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(
</DropToImport>
</div>
</Draggable>
{isDraggingAnyDocument && (
<DropCursor
disabled={!manualSort}
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
/>
{isDraggingAnyDocument && manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Relative>
<Folder expanded={expanded && !isDragging}>
@@ -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")};
`;

View File

@@ -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 (
<div style={layerStyles}>
<div style={getItemStyles(initialOffset, currentOffset, ui.sidebarWidth)}>
<GhostLink
icon={item.icon}
label={item.title || t("Untitled")}
isDraft={item.isDraft}
depth={item.depth}
active
/>
</div>
</div>
);
};
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;

View File

@@ -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<Collection, Collection>) => ({
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: <CollectionIcon collection={collection} />,
}),
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 (
<>
<Draggable
key={collection.id}
ref={dragToReorderCollection}
$isDragging={isCollectionDragging}
$isDragging={isDragging}
>
<CollectionLink
collection={collection}
@@ -130,7 +134,8 @@ function DraggableCollectionLink({
}
const Draggable = styled("div")<{ $isDragging: boolean }>`
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")};
`;

View File

@@ -2,27 +2,18 @@ import * as React from "react";
import styled from "styled-components";
type Props = {
disabled?: boolean;
isActiveDrop: boolean;
innerRef: React.Ref<HTMLDivElement>;
position?: "top";
};
function DropCursor({ isActiveDrop, innerRef, position, disabled }: Props) {
return (
<Cursor
isOver={isActiveDrop}
disabled={disabled}
ref={innerRef}
position={position}
/>
);
function DropCursor({ isActiveDrop, innerRef, position }: Props) {
return <Cursor isOver={isActiveDrop} ref={innerRef} position={position} />;
}
// 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: "";

View File

@@ -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(),

View File

@@ -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 ? (
<EmojiIcon emoji={emoji} />
) : (
<StarredIcon color={theme.yellow} />
),
};
}
}
if (collectionId) {
const collection = collections.get(collectionId);
if (collection) {
return {
label: collection.name,
icon: <CollectionIcon collection={collection} />,
};
}
}
return {
label: "",
icon: <StarredIcon color={theme.yellow} />,
};
}
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 ? (
<EmojiIcon emoji={emoji} />
) : (
<StarredIcon color={theme.yellow} />
)
}
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);