Improve drag-and-drop (#4824)
* Improve drag-and-drop * fixes * fix drop highlight showing on ghosted sidebar item
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")};
|
||||
`;
|
||||
|
||||
|
||||
81
app/components/Sidebar/components/DragPlaceholder.tsx
Normal file
81
app/components/Sidebar/components/DragPlaceholder.tsx
Normal 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;
|
||||
@@ -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")};
|
||||
`;
|
||||
|
||||
|
||||
@@ -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: "";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user