import { Location } from "history"; 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"; import { NavigationNode } from "@shared/types"; import { sortNavigationNodes } from "@shared/utils/collections"; import { DocumentValidation } from "@shared/validations"; import Collection from "~/models/Collection"; import Document from "~/models/Document"; import Fade from "~/components/Fade"; import NudeButton from "~/components/NudeButton"; import Tooltip from "~/components/Tooltip"; import useBoolean from "~/hooks/useBoolean"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; import DocumentMenu from "~/menus/DocumentMenu"; import { newDocumentPath } from "~/utils/routeHelpers"; import DropCursor from "./DropCursor"; import DropToImport from "./DropToImport"; import EditableTitle, { RefHandle } from "./EditableTitle"; import Folder from "./Folder"; import Relative from "./Relative"; import SidebarLink, { DragObject } from "./SidebarLink"; import { useStarredContext } from "./StarredContext"; type Props = { node: NavigationNode; collection?: Collection; activeDocument: Document | null | undefined; prefetchDocument?: (documentId: string) => Promise; isDraft?: boolean; depth: number; index: number; parentId?: string; }; function InnerDocumentLink( { node, collection, activeDocument, prefetchDocument, isDraft, depth, index, parentId, }: Props, ref: React.RefObject ) { const { showToast } = useToasts(); const { documents, policies } = useStores(); const { t } = useTranslation(); const canUpdate = usePolicy(node.id).update; const isActiveDocument = activeDocument && activeDocument.id === node.id; const hasChildDocuments = !!node.children.length || activeDocument?.parentDocumentId === node.id; const document = documents.get(node.id); const { fetchChildDocuments } = documents; const [isEditing, setIsEditing] = React.useState(false); const editableTitleRef = React.useRef(null); const inStarredSection = useStarredContext(); React.useEffect(() => { if (isActiveDocument && hasChildDocuments) { void fetchChildDocuments(node.id); } }, [fetchChildDocuments, node.id, hasChildDocuments, isActiveDocument]); const pathToNode = React.useMemo( () => collection?.pathToDocument(node.id).map((entry) => entry.id), [collection, node] ); const showChildren = React.useMemo( () => !!( hasChildDocuments && activeDocument && collection && (collection .pathToDocument(activeDocument.id) .map((entry) => entry.id) .includes(node.id) || isActiveDocument) ), [hasChildDocuments, activeDocument, isActiveDocument, node, collection] ); const [expanded, setExpanded] = React.useState(showChildren); React.useEffect(() => { if (showChildren) { setExpanded(showChildren); } }, [showChildren]); // when the last child document is removed auto-close the local folder state React.useEffect(() => { if (expanded && !hasChildDocuments) { setExpanded(false); } }, [expanded, hasChildDocuments]); const handleDisclosureClick = React.useCallback( (ev) => { ev?.preventDefault(); setExpanded(!expanded); }, [expanded] ); const handlePrefetch = React.useCallback(() => { void prefetchDocument?.(node.id); }, [prefetchDocument, node]); const handleTitleChange = React.useCallback( async (title: string) => { if (!document) { return; } await documents.update({ id: document.id, title, }); }, [documents, document] ); 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, preview] = useDrag({ type: "document", item: () => ({ ...node, depth, active: isActiveDocument, collectionId: collection?.id || "", }), collect: (monitor) => ({ isDragging: monitor.isDragging(), }), 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, // to trigger expansion of children. Clear this timeout when they stop hovering. const resetHoverExpanding = React.useCallback(() => { if (hoverExpanding.current) { clearTimeout(hoverExpanding.current); hoverExpanding.current = undefined; } }, []); // Drop to re-parent const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({ accept: "document", drop: async (item: DragObject, monitor) => { if (monitor.didDrop()) { return; } if (!collection) { return; } await documents.move(item.id, collection.id, node.id); setExpanded(true); }, canDrop: (item, monitor) => !isDraft && !!pathToNode && !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. if ( hasChildDocuments && monitor.canDrop() && monitor.isOver({ shallow: true, }) ) { if (!hoverExpanding.current) { hoverExpanding.current = setTimeout(() => { hoverExpanding.current = undefined; if ( monitor.isOver({ shallow: true, }) ) { setExpanded(true); } }, 500); } } }, collect: (monitor) => ({ isOverReparent: monitor.isOver({ shallow: true, }), canDropToReparent: monitor.canDrop(), }), }); // Drop to reorder const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({ accept: "document", drop: (item: DragObject) => { if (!manualSort) { showToast( t( "You can't reorder documents in an alphabetically sorted collection" ), { type: "info", timeout: 5000, } ); return; } if (!collection) { return; } if (item.id === node.id) { return; } if (expanded) { void documents.move(item.id, collection.id, node.id, 0); return; } void documents.move(item.id, collection.id, parentId, index + 1); }, collect: (monitor) => ({ isOverReorder: monitor.isOver(), isDraggingAnyDocument: monitor.canDrop(), }), }); const nodeChildren = React.useMemo(() => { const insertDraftDocument = activeDocument?.isDraft && activeDocument?.isActive && activeDocument?.parentDocumentId === node.id; return collection && insertDraftDocument ? sortNavigationNodes( [activeDocument?.asNavigationNode, ...node.children], collection.sort, false ) : node.children; }, [ activeDocument?.isActive, activeDocument?.isDraft, activeDocument?.parentDocumentId, activeDocument?.asNavigationNode, collection, node, ]); const title = (activeDocument?.id === node.id ? activeDocument.title : node.title) || t("Untitled"); const isExpanded = expanded && !isDragging; const hasChildren = nodeChildren.length > 0; const handleKeyDown = React.useCallback( (ev: React.KeyboardEvent) => { if (!hasChildren) { return; } if (ev.key === "ArrowRight" && !expanded) { setExpanded(true); } if (ev.key === "ArrowLeft" && expanded) { setExpanded(false); } }, [hasChildren, expanded] ); return ( <>
} isActive={(match, location: Location<{ starred?: boolean }>) => ((document && location.pathname.endsWith(document.urlId)) || !!match) && location.state?.starred === inStarredSection } isActiveDrop={isOverReparent && canDropToReparent} depth={depth} exact={false} showActions={menuOpen} scrollIntoViewIfNeeded={!inStarredSection} isDraft={isDraft} ref={ref} menu={ document && !isMoving && !isEditing && !isDraggingAnyDocument ? ( {can.createChildDocument && ( )} editableTitleRef.current?.setIsEditing(true) } onOpen={handleMenuOpen} onClose={handleMenuClose} /> ) : undefined } />
{isDraggingAnyDocument && manualSort && ( )}
{nodeChildren.map((childNode, index) => ( ))} ); } const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>` transition: opacity 250ms ease; opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.1 : 1)}; pointer-events: ${(props) => (props.$isMoving ? "none" : "all")}; `; const DocumentLink = observer(React.forwardRef(InnerDocumentLink)); export default DocumentLink;