import FuzzySearch from "fuzzy-search"; import { concat, difference, fill, filter, flatten, includes, map, } from "es-toolkit/compat"; 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 styled, { useTheme } from "styled-components"; import breakpoint from "styled-components-breakpoint"; import Icon from "@shared/components/Icon"; import type { NavigationNode } from "@shared/types"; import { isModKey } from "@shared/utils/keyboard"; import { ancestors, descendants, flattenTree } from "@shared/utils/tree"; import DocumentExplorerNode from "./DocumentExplorerNode"; import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult"; import Flex from "~/components/Flex"; import CollectionIcon from "~/components/Icons/CollectionIcon"; import InputSearch from "~/components/InputSearch"; import Text from "~/components/Text"; import useMobile from "~/hooks/useMobile"; import useStores from "~/hooks/useStores"; 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[]; /** Automatically expand to and select item with the given id */ defaultValue?: string; /** Whether to show child documents */ showDocuments?: boolean; }; const VERTICAL_PADDING = 6; const HORIZONTAL_PADDING = 24; const innerElementType = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(function innerElementType( { style, ...rest }: React.HTMLAttributes, ref ) { return (
); }); function DocumentExplorer({ onSubmit, onSelect, items, defaultValue, showDocuments, }: Props) { const isMobile = useMobile(); const { collections, documents } = useStores(); const { t } = useTranslation(); const theme = useTheme(); const [searchTerm, setSearchTerm] = React.useState(); const [selectedNode, selectNode] = React.useState( () => { if (!defaultValue) { return null; } // Search through all nodes in the tree, not just top-level items const allNodes = flatten(items.map(flattenTree)); const node = allNodes.find((item) => item.id === defaultValue); return node || null; } ); const [activeNode, setActiveNode] = React.useState(0); const [expandedNodes, setExpandedNodes] = React.useState(() => { if (defaultValue) { // Search through all nodes in the tree, not just top-level items const allNodes = flatten(items.map(flattenTree)); const node = allNodes.find((item) => item.id === defaultValue); if (node) { return ancestors(node).map((ancestorNode) => ancestorNode.id); } } return []; }); const [itemRefs, setItemRefs] = React.useState< React.RefObject[] >([]); const inputSearchRef = React.useRef( null ); const listRef = React.useRef>(null); const searchIndex = React.useMemo( () => new FuzzySearch(flatten(items.map(flattenTree)), ["title"], { caseSensitive: false, }), [items] ); React.useEffect(() => { if (searchTerm) { selectNode(null); setExpandedNodes([]); } setActiveNode(0); }, [searchTerm]); React.useEffect(() => { setItemRefs((existingItemRefs) => map( fill(Array(items.length), 0), (_, i) => existingItemRefs[i] || React.createRef() ) ); }, [items.length]); 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.flatMap(includeDescendants); } const nodes = getNodes(); React.useEffect(() => { onSelect(selectedNode); }, [selectedNode, onSelect]); React.useEffect(() => { if (defaultValue && selectedNode && listRef) { const index = nodes.findIndex((node) => node.id === selectedNode.id); if (index > 0) { setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultValue]); const baseDepth = nodes.reduce( (min, node) => (node.depth ? Math.min(min, node.depth) : min), Infinity ); const normalizedBaseDepth = (baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1); const scrollNodeIntoView = React.useCallback((node: number) => { listRef.current?.scrollToItem(node, "smart"); }, []); const handleSearch = (ev: React.ChangeEvent) => { setSearchTerm(ev.target.value); }; const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id); const preserveScrollOffset = (itemCount: number) => { if (listRef.current) { const { height, itemSize } = listRef.current.props; const { scrollOffset } = listRef.current.state as { scrollOffset: number; }; const itemsHeight = itemCount * itemSize; const offset = itemsHeight < Number(height) ? 0 : scrollOffset; setTimeout(() => listRef.current?.scrollTo(offset), 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, (n) => !includes(descendantIds, n.id)); preserveScrollOffset(newNodes.length); }; 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)); preserveScrollOffset(newNodes.length); }; 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 || (showDocuments !== false && 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 renderedIcon, title: string, icon: string | undefined, color: string | undefined, path; if (isCollection) { const col = collections.get(node.collectionId as string); renderedIcon = col && ( ); title = node.title; } else { const doc = documents.get(node.id); icon = doc?.icon ?? node.icon ?? node.emoji; color = doc?.color ?? node.color; title = doc?.title ?? node.title; if (icon) { renderedIcon = ( ); } else if (doc?.isStarred) { renderedIcon = ; } else { renderedIcon = ; } path = ancestors(node) .map((a) => a.title) .join(" / "); } return searchTerm ? ( setActiveNode(index)} onClick={() => toggleSelect(index)} icon={renderedIcon} 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={renderedIcon} title={title} depth={(node.depth ?? 0) - normalizedBaseDepth} 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; } } }; 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).attrs({ round: true })` margin-bottom: 4px; padding-left: 24px; padding-right: 24px; `; const ListContainer = styled.div` height: 65vh; ${breakpoint("tablet")` height: 40vh; `} `; export default observer(DocumentExplorer);