import FuzzySearch from "fuzzy-search"; import concat from "lodash/concat"; import difference from "lodash/difference"; import fill from "lodash/fill"; import filter from "lodash/filter"; import includes from "lodash/includes"; import map from "lodash/map"; import { observer } from "mobx-react"; import { StarredIcon, DocumentIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import AutoSizer from "react-virtualized-auto-sizer"; import { FixedSizeList as List } from "react-window"; import scrollIntoView from "smooth-scroll-into-view-if-needed"; import styled, { useTheme } from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { NavigationNode } from "@shared/types"; import DocumentExplorerNode from "~/components/DocumentExplorerNode"; import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult"; import Flex from "~/components/Flex"; import CollectionIcon from "~/components/Icons/CollectionIcon"; import EmojiIcon from "~/components/Icons/EmojiIcon"; import { Outline } from "~/components/Input"; import InputSearch from "~/components/InputSearch"; import Text from "~/components/Text"; import useMobile from "~/hooks/useMobile"; import useStores from "~/hooks/useStores"; import { isModKey } from "~/utils/keyboard"; import { ancestors, descendants } from "~/utils/tree"; type Props = { /** Action taken upon submission of selected item, could be publish, move etc. */ onSubmit: () => void; /** A side-effect of item selection */ onSelect: (item: NavigationNode | null) => void; /** Items to be shown in explorer */ items: NavigationNode[]; }; function DocumentExplorer({ onSubmit, onSelect, items }: Props) { const isMobile = useMobile(); const { collections, documents } = useStores(); const { t } = useTranslation(); const theme = useTheme(); const [searchTerm, setSearchTerm] = React.useState(); const [selectedNode, selectNode] = React.useState( null ); const [initialScrollOffset, setInitialScrollOffset] = React.useState(0); const [activeNode, setActiveNode] = React.useState(0); const [expandedNodes, setExpandedNodes] = React.useState([]); const [itemRefs, setItemRefs] = React.useState< React.RefObject[] >([]); const inputSearchRef = React.useRef( null ); const listRef = React.useRef>(null); const VERTICAL_PADDING = 6; const HORIZONTAL_PADDING = 24; const searchIndex = React.useMemo( () => new FuzzySearch(items, ["title"], { caseSensitive: false, }), [items] ); React.useEffect(() => { if (searchTerm) { selectNode(null); setExpandedNodes([]); } setActiveNode(0); }, [searchTerm]); React.useEffect(() => { setItemRefs((itemRefs) => map( fill(Array(items.length), 0), (_, i) => itemRefs[i] || React.createRef() ) ); }, [items.length]); React.useEffect(() => { onSelect(selectedNode); }, [selectedNode, onSelect]); function getNodes() { function includeDescendants(item: NavigationNode): NavigationNode[] { return expandedNodes.includes(item.id) ? [item, ...descendants(item, 1).flatMap(includeDescendants)] : [item]; } return searchTerm ? searchIndex.search(searchTerm) : items .filter((item) => item.type === "collection") .flatMap(includeDescendants); } const nodes = getNodes(); const scrollNodeIntoView = React.useCallback( (node: number) => { if (itemRefs[node] && itemRefs[node].current) { scrollIntoView(itemRefs[node].current as HTMLSpanElement, { behavior: "auto", block: "center", }); } }, [itemRefs] ); const handleSearch = (ev: React.ChangeEvent) => { setSearchTerm(ev.target.value); }; const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id); const calculateInitialScrollOffset = (itemCount: number) => { if (listRef.current) { const { height, itemSize } = listRef.current.props; const { scrollOffset } = listRef.current.state as { scrollOffset: number; }; const itemsHeight = itemCount * itemSize; return itemsHeight < Number(height) ? 0 : scrollOffset; } return 0; }; const collapse = (node: number) => { const descendantIds = descendants(nodes[node]).map((des) => des.id); setExpandedNodes( difference(expandedNodes, [...descendantIds, nodes[node].id]) ); // remove children const newNodes = filter(nodes, (node) => !includes(descendantIds, node.id)); const scrollOffset = calculateInitialScrollOffset(newNodes.length); setInitialScrollOffset(scrollOffset); }; const expand = (node: number) => { setExpandedNodes(concat(expandedNodes, nodes[node].id)); // add children const newNodes = nodes.slice(); newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1)); const scrollOffset = calculateInitialScrollOffset(newNodes.length); setInitialScrollOffset(scrollOffset); }; React.useEffect(() => { collections.orderedData .filter( (collection) => expandedNodes.includes(collection.id) || searchTerm ) .forEach((collection) => { void collection.fetchDocuments(); }); }, [collections, expandedNodes, searchTerm]); const isSelected = (node: number) => { if (!selectedNode) { return false; } const selectedNodeId = selectedNode.id; const nodeId = nodes[node].id; return selectedNodeId === nodeId; }; const hasChildren = (node: number) => nodes[node].children.length > 0 || nodes[node].type === "collection"; const toggleCollapse = (node: number) => { if (!hasChildren(node)) { return; } if (isExpanded(node)) { collapse(node); } else { expand(node); } }; const toggleSelect = (node: number) => { if (isSelected(node)) { selectNode(null); } else { selectNode(nodes[node]); } }; const ListItem = observer( ({ index, data, style, }: { index: number; data: NavigationNode[]; style: React.CSSProperties; }) => { const node = data[index]; const isCollection = node.type === "collection"; let icon, title: string, emoji: string | undefined, path; if (isCollection) { const col = collections.get(node.collectionId as string); icon = col && ( ); title = node.title; } else { const doc = documents.get(node.id); emoji = doc?.emoji ?? node.emoji; title = doc?.title ?? node.title; if (emoji) { icon = ; } else if (doc?.isStarred) { icon = ; } else { icon = ; } path = ancestors(node) .map((a) => a.title) .join(" / "); } return searchTerm ? ( setActiveNode(index)} onClick={() => toggleSelect(index)} icon={icon} title={title} path={path} /> ) : ( setActiveNode(index)} onClick={() => toggleSelect(index)} onDisclosureClick={(ev) => { ev.stopPropagation(); toggleCollapse(index); }} selected={isSelected(index)} active={activeNode === index} expanded={isExpanded(index)} icon={icon} title={title} depth={node.depth as number} hasChildren={hasChildren(index)} ref={itemRefs[index]} /> ); } ); const focusSearchInput = () => { inputSearchRef.current?.focus(); }; const next = () => Math.min(activeNode + 1, nodes.length - 1); const prev = () => Math.max(activeNode - 1, 0); const handleKeyDown = (ev: React.KeyboardEvent) => { switch (ev.key) { case "ArrowDown": { ev.preventDefault(); ev.stopPropagation(); setActiveNode(next()); scrollNodeIntoView(next()); break; } case "ArrowUp": { ev.preventDefault(); ev.stopPropagation(); if (activeNode === 0) { focusSearchInput(); } else { setActiveNode(prev()); scrollNodeIntoView(prev()); } break; } case "ArrowLeft": { if (!searchTerm && isExpanded(activeNode)) { toggleCollapse(activeNode); } break; } case "ArrowRight": { if (!searchTerm) { toggleCollapse(activeNode); // let the nodes re-render first and then scroll setTimeout(() => scrollNodeIntoView(activeNode), 0); } break; } case "Enter": { if (isModKey(ev)) { onSubmit(); } else { toggleSelect(activeNode); } break; } } }; const innerElementType = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(function innerElementType( { style, ...rest }: React.HTMLAttributes, ref ) { return (
); }); return ( {nodes.length ? ( {({ width, height }: { width: number; height: number }) => ( results[index].id} > {ListItem} )} ) : ( {t("No results found")}. )} ); } const Container = styled.div``; const FlexContainer = styled(Flex)` height: 100%; align-items: center; justify-content: center; `; const ListSearch = styled(InputSearch)` ${Outline} { border-radius: 16px; } margin-bottom: 4px; padding-left: 24px; padding-right: 24px; `; const ListContainer = styled.div` height: 65vh; ${breakpoint("tablet")` height: 40vh; `} `; export default observer(DocumentExplorer);