mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Refactor drag-and-drop to support dragging from document lists (#12587)
* Allow dragging documents from list views into the sidebar Previously the react-dnd provider was scoped to the sidebar, so only sidebar rows could be dragged. This lifts the DndProvider up to the authenticated layout so both the main content and the sidebar share a single drag-and-drop context, and makes DocumentListItem a drag source. Now any document in search results or paginated lists (Home, Drafts, Collections, etc.) can be dragged into the sidebar to move it between collections, reparent it under another document, star it, or archive it — reusing the existing sidebar drop targets. * Make the whole Starred section a drop target to star documents Previously the only "create star" drop targets in the Starred section were the thin cursors between items, so dragging a document onto the section header or a starred row showed the drop cursor but did nothing. Wrap the section in a catch-all drop target (mirroring the Archive section) so dropping anywhere in Starred stars the document, while the precise inter-item cursors still control ordering. A didDrop guard on useDropToCreateStar prevents the catch-all from double-starring when a nested cursor already handled the drop, and the hover highlight uses a shallow isOver check so it only lights up when not over a nested target. * Let document list drag ghost follow the cursor The sidebar drag placeholder tethers the ghost near its starting x so it stays aligned with the sidebar during reordering. When a drag starts out in the main content (a document list item), that clamp pinned the ghost to a narrow band, making it look stuck in a small area. Thread a constrainToSidebar flag through the drag item (true for sidebar drags, false for document list drags) and let the placeholder follow the cursor freely when the drag originated outside the sidebar. * Clarify constrainToSidebar JSDoc to match placeholder behavior The placeholder treats an unset flag as tethered (constrainToSidebar !== false), so external drags must set it explicitly to false rather than leaving it unset. Update the comment to reflect that. * css
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
@@ -104,14 +106,16 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
<DocumentContextProvider>
|
||||
<RightSidebarProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</DndProvider>
|
||||
</PortalContext.Provider>
|
||||
</RightSidebarProvider>
|
||||
</DocumentContextProvider>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
@@ -27,6 +28,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
||||
import { useDragDocument } from "./Sidebar/hooks/useDragAndDrop";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import { ContextMenu } from "./Menu/ContextMenu";
|
||||
@@ -98,6 +100,23 @@ function DocumentListItem(
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
|
||||
|
||||
const [{ isDragging }, draggableRef] = useDragDocument(
|
||||
document.asNavigationNode,
|
||||
0,
|
||||
document,
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
const mergedRef = React.useMemo(
|
||||
() =>
|
||||
mergeRefs<HTMLAnchorElement>([
|
||||
itemRef,
|
||||
draggableRef,
|
||||
] as React.Ref<HTMLAnchorElement>[]),
|
||||
[itemRef, draggableRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
@@ -114,9 +133,10 @@ function DocumentListItem(
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
ref={mergedRef}
|
||||
dir={document.dir}
|
||||
$isStarred={document.isStarred}
|
||||
$isDragging={isDragging}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
@@ -227,6 +247,7 @@ const Actions = styled(EventBoundary)`
|
||||
|
||||
const DocumentLink = styled(Link)<{
|
||||
$isStarred?: boolean;
|
||||
$isDragging?: boolean;
|
||||
$menuOpen?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
@@ -237,6 +258,8 @@ const DocumentLink = styled(Link)<{
|
||||
max-height: 50vh;
|
||||
width: calc(100vw - 8px);
|
||||
cursor: var(--pointer);
|
||||
transition: opacity 250ms ease;
|
||||
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
DragActiveProvider,
|
||||
SidebarScrollProvider,
|
||||
@@ -62,15 +60,6 @@ function AppSidebar() {
|
||||
}
|
||||
}, [documents, collections, user.isViewer]);
|
||||
|
||||
const [dndArea, setDndArea] = useState();
|
||||
const handleSidebarRef = useCallback((node) => setDndArea(node), []);
|
||||
const html5Options = useMemo(
|
||||
() => ({
|
||||
rootElement: dndArea,
|
||||
}),
|
||||
[dndArea]
|
||||
);
|
||||
|
||||
// Scrollable reads ref.current internally for its shadow/ResizeObserver
|
||||
// logic, so we must pass an object ref — a callback ref would leave those
|
||||
// reads undefined. We mirror the attached node into state so the
|
||||
@@ -82,83 +71,79 @@ function AppSidebar() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Sidebar hidden={!ui.readyToShow} ref={handleSidebarRef}>
|
||||
{dndArea && (
|
||||
<DndProvider backend={HTML5Backend} options={html5Options}>
|
||||
<DragActiveProvider>
|
||||
<DragPlaceholder />
|
||||
<Sidebar hidden={!ui.readyToShow}>
|
||||
<DragActiveProvider>
|
||||
<DragPlaceholder />
|
||||
|
||||
<TeamMenu>
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={24} alt={t("Logo")} />}
|
||||
<TeamMenu>
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={24} alt={t("Logo")} />}
|
||||
>
|
||||
{isMobile ? null : (
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
>
|
||||
{isMobile ? null : (
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
aria-label={
|
||||
ui.sidebarCollapsed
|
||||
? t("Expand sidebar")
|
||||
: t("Collapse sidebar")
|
||||
}
|
||||
style={{ paddingInline: 4 }}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</SidebarButton>
|
||||
</TeamMenu>
|
||||
<Overflow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={homePath()}
|
||||
icon={<HomeIcon />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
aria-label={
|
||||
ui.sidebarCollapsed
|
||||
? t("Expand sidebar")
|
||||
: t("Collapse sidebar")
|
||||
}
|
||||
style={{ paddingInline: 4 }}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={searchPath()}
|
||||
icon={<SearchIcon />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
onClick={handleSearchClick}
|
||||
/>
|
||||
{can.createDocument && <DraftsLink />}
|
||||
</Tooltip>
|
||||
)}
|
||||
</SidebarButton>
|
||||
</TeamMenu>
|
||||
<Overflow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={homePath()}
|
||||
icon={<HomeIcon />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={searchPath()}
|
||||
icon={<SearchIcon />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
onClick={handleSearchClick}
|
||||
/>
|
||||
{can.createDocument && <DraftsLink />}
|
||||
</Section>
|
||||
</Overflow>
|
||||
<Scrollable flex shadow ref={scrollRef}>
|
||||
<SidebarScrollProvider value={scrollArea}>
|
||||
<Section>
|
||||
<Starred />
|
||||
</Section>
|
||||
<Section>
|
||||
<SharedWithMe />
|
||||
</Section>
|
||||
<Section>
|
||||
<Collections />
|
||||
</Section>
|
||||
{can.createDocument && (
|
||||
<Section auto>
|
||||
<ArchiveLink />
|
||||
</Section>
|
||||
</Overflow>
|
||||
<Scrollable flex shadow ref={scrollRef}>
|
||||
<SidebarScrollProvider value={scrollArea}>
|
||||
<Section>
|
||||
<Starred />
|
||||
</Section>
|
||||
<Section>
|
||||
<SharedWithMe />
|
||||
</Section>
|
||||
<Section>
|
||||
<Collections />
|
||||
</Section>
|
||||
{can.createDocument && (
|
||||
<Section auto>
|
||||
<ArchiveLink />
|
||||
</Section>
|
||||
)}
|
||||
<Section>
|
||||
{can.createDocument && <TrashLink />}
|
||||
<SidebarAction action={inviteUser} />
|
||||
</Section>
|
||||
</SidebarScrollProvider>
|
||||
</Scrollable>
|
||||
</DragActiveProvider>
|
||||
</DndProvider>
|
||||
)}
|
||||
)}
|
||||
<Section>
|
||||
{can.createDocument && <TrashLink />}
|
||||
<SidebarAction action={inviteUser} />
|
||||
</Section>
|
||||
</SidebarScrollProvider>
|
||||
</Scrollable>
|
||||
</DragActiveProvider>
|
||||
<HistoryNavigation />
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,8 @@ const layerStyles: React.CSSProperties = {
|
||||
function getItemStyles(
|
||||
initialOffset: XYCoord | null,
|
||||
currentOffset: XYCoord | null,
|
||||
sidebarWidth: number
|
||||
sidebarWidth: number,
|
||||
constrainToSidebar: boolean
|
||||
) {
|
||||
if (!initialOffset || !currentOffset) {
|
||||
return {
|
||||
@@ -27,10 +28,14 @@ function getItemStyles(
|
||||
};
|
||||
}
|
||||
const { y } = currentOffset;
|
||||
const x = Math.max(
|
||||
initialOffset.x,
|
||||
Math.min(initialOffset.x + sidebarWidth / 4, currentOffset.x)
|
||||
);
|
||||
// Sidebar drags keep the ghost tethered near its origin, but drags from
|
||||
// outside the sidebar should follow the cursor freely.
|
||||
const x = constrainToSidebar
|
||||
? Math.max(
|
||||
initialOffset.x,
|
||||
Math.min(initialOffset.x + sidebarWidth / 4, currentOffset.x)
|
||||
)
|
||||
: currentOffset.x;
|
||||
|
||||
const transform = `translate(${x}px, ${y}px)`;
|
||||
return {
|
||||
@@ -60,7 +65,14 @@ const DragPlaceholder = () => {
|
||||
|
||||
return (
|
||||
<div style={layerStyles}>
|
||||
<div style={getItemStyles(initialOffset, currentOffset, ui.sidebarWidth)}>
|
||||
<div
|
||||
style={getItemStyles(
|
||||
initialOffset,
|
||||
currentOffset,
|
||||
ui.sidebarWidth,
|
||||
item.constrainToSidebar !== false
|
||||
)}
|
||||
>
|
||||
<GhostLink
|
||||
icon={item.icon}
|
||||
label={item.title || t("Untitled")}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { observer } from "mobx-react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type Star from "~/models/Star";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -29,6 +31,7 @@ function Starred() {
|
||||
);
|
||||
const [reorderStarProps, dropToReorder] = useDropToReorderStar();
|
||||
const [createStarProps, dropToStarRef] = useDropToCreateStar();
|
||||
const [sectionStarProps, dropToSectionRef] = useDropToCreateStar();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -42,46 +45,64 @@ function Starred() {
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!loading && !end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
</DelayedMount>
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
</Header>
|
||||
<Section
|
||||
ref={dropToSectionRef}
|
||||
$isActiveDrop={
|
||||
sectionStarProps.isDragging && sectionStarProps.isOverCursor
|
||||
}
|
||||
>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!loading && !end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
</DelayedMount>
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
</Header>
|
||||
</Section>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const Section = styled.div<{ $isActiveDrop?: boolean }>`
|
||||
border-radius: 8px;
|
||||
transition: background 100ms ease-in-out;
|
||||
|
||||
${(props) =>
|
||||
props.$isActiveDrop &&
|
||||
css`
|
||||
background: ${s("sidebarActiveBackground")};
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(Starred);
|
||||
|
||||
@@ -22,6 +22,12 @@ import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
export type DragObject = NavigationNode & {
|
||||
depth: number;
|
||||
collectionId: string;
|
||||
/**
|
||||
* Whether the drag ghost should stay tethered to the sidebar. Defaults to
|
||||
* tethered when unset — the placeholder only lets the ghost follow the
|
||||
* cursor when this is explicitly `false` (e.g. drags from a document list).
|
||||
*/
|
||||
constrainToSidebar?: boolean;
|
||||
};
|
||||
|
||||
function useHover(
|
||||
@@ -105,6 +111,12 @@ export function useDropToCreateStar(getIndex?: () => string) {
|
||||
>({
|
||||
accept,
|
||||
drop: async (item, monitor) => {
|
||||
// A more specific drop target (e.g. a reorder cursor) has already
|
||||
// handled this drop, so avoid creating a duplicate star.
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = monitor.getItemType();
|
||||
let model;
|
||||
|
||||
@@ -122,7 +134,7 @@ export function useDropToCreateStar(getIndex?: () => string) {
|
||||
);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverCursor: !!monitor.isOver(),
|
||||
isOverCursor: !!monitor.isOver({ shallow: true }),
|
||||
isDragging: accept.includes(String(monitor.getItemType())),
|
||||
}),
|
||||
});
|
||||
@@ -163,12 +175,16 @@ export function useDropToReorderStar(getIndex?: () => string) {
|
||||
* @param depth The depth of the node in the sidebar.
|
||||
* @param document The related Document model.
|
||||
* @param isEditing Whether the sidebar item is currently being edited.
|
||||
* @param constrainToSidebar Whether the drag ghost should stay tethered to the
|
||||
* sidebar. Defaults to true; pass false when dragging from outside the sidebar
|
||||
* (e.g. a document list) so the ghost follows the cursor.
|
||||
*/
|
||||
export function useDragDocument(
|
||||
node: NavigationNode,
|
||||
depth: number,
|
||||
document?: Document,
|
||||
isEditing?: boolean
|
||||
isEditing?: boolean,
|
||||
constrainToSidebar = true
|
||||
) {
|
||||
const icon = document?.icon || node.icon || node.emoji;
|
||||
const color = document?.color || node.color;
|
||||
@@ -188,6 +204,7 @@ export function useDragDocument(
|
||||
<Icon initial={initial} value={icon} color={color} />
|
||||
) : undefined,
|
||||
collectionId: document?.collectionId || "",
|
||||
constrainToSidebar,
|
||||
}) as DragObject,
|
||||
canDrag: () => !!document?.isActive && !isEditing,
|
||||
collect: (monitor) => ({
|
||||
|
||||
Reference in New Issue
Block a user