feat: Drag collection into starred section to star

This commit is contained in:
Tom Moor
2023-12-19 10:27:31 -05:00
parent c1b2d3c4a7
commit 6616276e4b
7 changed files with 165 additions and 150 deletions

View File

@@ -1,7 +1,5 @@
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import Star from "~/models/Star"; import Star from "~/models/Star";
@@ -13,50 +11,22 @@ import DropCursor from "./DropCursor";
import Header from "./Header"; import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections"; import PlaceholderCollections from "./PlaceholderCollections";
import Relative from "./Relative"; import Relative from "./Relative";
import SidebarLink, { DragObject } from "./SidebarLink"; import SidebarLink from "./SidebarLink";
import StarredContext from "./StarredContext"; import StarredContext from "./StarredContext";
import StarredLink from "./StarredLink"; import StarredLink from "./StarredLink";
import { useDropToCreateStar, useDropToReorderStar } from "./useDragAndDrop";
const STARRED_PAGINATION_LIMIT = 10; const STARRED_PAGINATION_LIMIT = 10;
function Starred() { function Starred() {
const { documents, stars } = useStores(); const { stars } = useStores();
const { t } = useTranslation(); const { t } = useTranslation();
const { loading, next, end, error, page } = usePaginatedRequest<Star>( const { loading, next, end, error, page } = usePaginatedRequest<Star>(
stars.fetchPage, stars.fetchPage
{
limit: STARRED_PAGINATION_LIMIT,
}
); );
const [reorderStarMonitor, dropToReorder] = useDropToReorderStar();
// Drop to reorder star const [createStarMonitor, dropToStarRef] = useDropToCreateStar();
const [{ isOverReorder, isDraggingAnyStar }, dropToReorder] = useDrop({
accept: "star",
drop: async (item: { star: Star }) => {
void item.star.save({
index: fractionalIndex(null, stars.orderedData[0].index),
});
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
isDraggingAnyStar: monitor.getItemType() === "star",
}),
});
// Drop to star document
const [{ documentIsOverReorder, isDraggingAnyDocument }, dropToStar] =
useDrop({
accept: "document",
drop: async (item: DragObject) => {
const document = documents.get(item.id);
await document?.star(fractionalIndex(null, stars.orderedData[0].index));
},
collect: (monitor) => ({
documentIsOverReorder: !!monitor.isOver(),
isDraggingAnyDocument: monitor.getItemType() === "document",
}),
});
React.useEffect(() => { React.useEffect(() => {
if (error) { if (error) {
@@ -73,17 +43,17 @@ function Starred() {
<Flex column> <Flex column>
<Header id="starred" title={t("Starred")}> <Header id="starred" title={t("Starred")}>
<Relative> <Relative>
{isDraggingAnyStar && ( {reorderStarMonitor.isDragging && (
<DropCursor <DropCursor
isActiveDrop={isOverReorder} isActiveDrop={reorderStarMonitor.isOverCursor}
innerRef={dropToReorder} innerRef={dropToReorder}
position="top" position="top"
/> />
)} )}
{isDraggingAnyDocument && ( {createStarMonitor.isDragging && (
<DropCursor <DropCursor
isActiveDrop={documentIsOverReorder} isActiveDrop={createStarMonitor.isOverCursor}
innerRef={dropToStar} innerRef={dropToStarRef}
position="top" position="top"
/> />
)} )}

View File

@@ -1,17 +1,12 @@
import fractionalIndex from "fractional-index"; import fractionalIndex from "fractional-index";
import { Location } from "history"; import { Location } from "history";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { StarredIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useEffect, useState } 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 { useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components"; import styled from "styled-components";
import Star from "~/models/Star"; import Star from "~/models/Star";
import Fade from "~/components/Fade"; import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useBoolean from "~/hooks/useBoolean"; import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu"; import DocumentMenu from "~/menus/DocumentMenu";
@@ -21,7 +16,13 @@ import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor"; import DropCursor from "./DropCursor";
import Folder from "./Folder"; import Folder from "./Folder";
import Relative from "./Relative"; import Relative from "./Relative";
import SidebarLink, { DragObject } from "./SidebarLink"; import SidebarLink from "./SidebarLink";
import {
useDragStar,
useDropToCreateStar,
useDropToReorderStar,
} from "./useDragAndDrop";
import { useStarLabelAndIcon } from "./useStarLabelAndIcon";
type Props = { type Props = {
star: Star; star: Star;
@@ -34,40 +35,6 @@ function useLocationStateStarred() {
return location.state?.starred; return location.state?.starred;
} }
function useLabelAndIcon({ documentId, collectionId }: Star) {
const { collections, documents } = useStores();
const theme = useTheme();
if (documentId) {
const document = documents.get(documentId);
if (document) {
return {
label: document.titleWithDefault,
icon: document.emoji ? (
<EmojiIcon emoji={document.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) { function StarredLink({ star }: Props) {
const { ui, collections, documents } = useStores(); const { ui, collections, documents } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
@@ -99,70 +66,29 @@ function StarredLink({ star }: Props) {
[] []
); );
const { label, icon } = useLabelAndIcon(star); const getIndex = () => {
// Draggable
const [{ isDragging }, drag, preview] = useDrag({
type: "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: Star }) => {
const next = star?.next(); const next = star?.next();
return fractionalIndex(star?.index || null, next?.index || null);
void item.star.save({ };
index: fractionalIndex(star?.index || null, next?.index || null), const { label, icon } = useStarLabelAndIcon(star);
}); const [{ isDragging }, draggableRef] = useDragStar(star);
}, const [reorderStarMonitor, dropToReorderRef] = useDropToReorderStar(getIndex);
collect: (monitor) => ({ const [createStarMonitor, dropToStarRef] = useDropToCreateStar(getIndex);
isOverReorder: !!monitor.isOver(),
isDraggingAny: !!monitor.canDrop(),
}),
});
// Drop to star document
const [{ documentIsOverReorder, isDraggingAnyDocument }, dropToStar] =
useDrop({
accept: "document",
drop: async (item: DragObject) => {
const next = star?.next();
const document = documents.get(item.id);
await document?.star(
fractionalIndex(star?.index || null, next?.index || null)
);
},
collect: (monitor) => ({
documentIsOverReorder: !!monitor.isOver(),
isDraggingAnyDocument: monitor.getItemType() === "document",
}),
});
const displayChildDocuments = expanded && !isDragging; const displayChildDocuments = expanded && !isDragging;
const cursor = ( const cursor = (
<> <>
{isDraggingAny && ( {reorderStarMonitor.isDragging && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
{isDraggingAnyDocument && (
<DropCursor <DropCursor
isActiveDrop={documentIsOverReorder} isActiveDrop={reorderStarMonitor.isOverCursor}
innerRef={dropToStar} innerRef={dropToReorderRef}
/>
)}
{createStarMonitor.isDragging && (
<DropCursor
isActiveDrop={createStarMonitor.isOverCursor}
innerRef={dropToStarRef}
/> />
)} )}
</> </>
@@ -174,10 +100,6 @@ function StarredLink({ star }: Props) {
return null; return null;
} }
const { emoji } = document;
const label = emoji
? document.title.replace(emoji, "")
: document.titleWithDefault;
const collection = document.collectionId const collection = document.collectionId
? collections.get(document.collectionId) ? collections.get(document.collectionId)
: undefined; : undefined;
@@ -188,7 +110,7 @@ function StarredLink({ star }: Props) {
return ( return (
<> <>
<Draggable key={star.id} ref={drag} $isDragging={isDragging}> <Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
<SidebarLink <SidebarLink
depth={0} depth={0}
to={{ to={{
@@ -240,13 +162,13 @@ function StarredLink({ star }: Props) {
if (collection) { if (collection) {
return ( return (
<> <>
<Draggable key={star?.id} ref={drag} $isDragging={isDragging}> <Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
<CollectionLink <CollectionLink
collection={collection} collection={collection}
expanded={isDragging ? undefined : displayChildDocuments} expanded={isDragging ? undefined : displayChildDocuments}
activeDocument={documents.active} activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick} onDisclosureClick={handleDisclosureClick}
isDraggingAnyCollection={isDraggingAny} isDraggingAnyCollection={reorderStarMonitor.isDragging}
/> />
</Draggable> </Draggable>
<Relative> <Relative>

View File

@@ -0,0 +1,81 @@
import fractionalIndex from "fractional-index";
import * as React from "react";
import { ConnectDragSource, useDrag, useDrop } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend";
import Star from "~/models/Star";
import useStores from "~/hooks/useStores";
import { DragObject } from "./SidebarLink";
import { useStarLabelAndIcon } from "./useStarLabelAndIcon";
/**
* Hook for shared logic that allows dragging a Starred item
*
* @param star The related Star model.
*/
export function useDragStar(
star: Star
): [{ isDragging: boolean }, ConnectDragSource] {
const { label: title, icon } = useStarLabelAndIcon(star);
const [{ isDragging }, draggableRef, preview] = useDrag({
type: "star",
item: () => ({ icon, title, star }),
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
canDrag: () => true,
});
React.useEffect(() => {
preview(getEmptyImage(), { captureDraggingState: true });
}, [preview]);
return [{ isDragging }, draggableRef];
}
/**
* Hook for shared logic that allows dropping documents and collections to create a star
*
* @param getIndex A function to get the index of the current item where the star should be inserted.
*/
export function useDropToCreateStar(getIndex?: () => string) {
const { documents, stars, collections } = useStores();
return useDrop({
accept: ["document", "collection"],
drop: async (item: DragObject) => {
const model = documents.get(item.id) ?? collections?.get(item.id);
await model?.star(
getIndex?.() ?? fractionalIndex(null, stars.orderedData[0].index)
);
},
collect: (monitor) => ({
isOverCursor: !!monitor.isOver(),
isDragging: ["document", "collection"].includes(
String(monitor.getItemType())
),
}),
});
}
/**
* Hook for shared logic that allows dropping stars to reorder
*
* @param getIndex A function to get the index of the current item where the star should be inserted.
*/
export function useDropToReorderStar(getIndex?: () => string) {
const { stars } = useStores();
return useDrop({
accept: "star",
drop: async (item: { star: Star }) => {
void item.star.save({
index:
getIndex?.() ?? fractionalIndex(null, stars.orderedData[0].index),
});
},
collect: (monitor) => ({
isOverCursor: !!monitor.isOver(),
isDragging: monitor.getItemType() === "star",
}),
});
}

View File

@@ -0,0 +1,41 @@
import { StarredIcon } from "outline-icons";
import * as React from "react";
import { useTheme } from "styled-components";
import Star from "~/models/Star";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import useStores from "~/hooks/useStores";
export function useStarLabelAndIcon({ documentId, collectionId }: Star) {
const { collections, documents } = useStores();
const theme = useTheme();
if (documentId) {
const document = documents.get(documentId);
if (document) {
return {
label: document.titleWithDefault,
icon: document.emoji ? (
<EmojiIcon emoji={document.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} />,
};
}

View File

@@ -32,7 +32,7 @@ const DEFAULT_LIMIT = 10;
*/ */
export default function usePaginatedRequest<T = unknown>( export default function usePaginatedRequest<T = unknown>(
requestFn: (params?: PaginationParams | undefined) => Promise<T[]>, requestFn: (params?: PaginationParams | undefined) => Promise<T[]>,
params: PaginationParams params: PaginationParams = {}
): RequestResponse<T> { ): RequestResponse<T> {
const [data, setData] = React.useState<T[]>(); const [data, setData] = React.useState<T[]>();
const [offset, setOffset] = React.useState(INITIAL_OFFSET); const [offset, setOffset] = React.useState(INITIAL_OFFSET);

View File

@@ -275,7 +275,7 @@ export default class Collection extends ParanoidModel {
} }
@action @action
star = async () => this.store.star(this); star = async (index?: string) => this.store.star(this, index);
@action @action
unstar = async () => this.store.unstar(this); unstar = async () => this.store.unstar(this);

View File

@@ -196,9 +196,10 @@ export default class CollectionsStore extends Store<Collection> {
); );
} }
star = async (collection: Collection) => { star = async (collection: Collection, index?: string) => {
await this.rootStore.stars.create({ await this.rootStore.stars.create({
collectionId: collection.id, collectionId: collection.id,
index,
}); });
}; };