diff --git a/app/components/DocumentBreadcrumb.tsx b/app/components/DocumentBreadcrumb.tsx index f26f0b1d2..d0832f7fb 100644 --- a/app/components/DocumentBreadcrumb.tsx +++ b/app/components/DocumentBreadcrumb.tsx @@ -3,11 +3,12 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; +import type { NavigationNode } from "@shared/types"; import Document from "~/models/Document"; import Breadcrumb from "~/components/Breadcrumb"; import CollectionIcon from "~/components/Icons/CollectionIcon"; import useStores from "~/hooks/useStores"; -import { MenuInternalLink, NavigationNode } from "~/types"; +import { MenuInternalLink } from "~/types"; import { collectionUrl } from "~/utils/routeHelpers"; type Props = { diff --git a/app/components/DocumentExplorer.tsx b/app/components/DocumentExplorer.tsx new file mode 100644 index 000000000..ccfc6105c --- /dev/null +++ b/app/components/DocumentExplorer.tsx @@ -0,0 +1,375 @@ +import FuzzySearch from "fuzzy-search"; +import { includes, difference, concat, filter, flatten } from "lodash"; +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 { NavigationNode } from "@shared/types"; +import parseTitle from "@shared/utils/parseTitle"; +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 useCollectionTrees from "~/hooks/useCollectionTrees"; +import useMobile from "~/hooks/useMobile"; +import useStores from "~/hooks/useStores"; +import { isModKey } from "~/utils/keyboard"; +import { flattenTree, 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; +}; + +function DocumentExplorer({ onSubmit, onSelect }: Props) { + const isMobile = useMobile(); + const { collections, documents } = useStores(); + const { t } = useTranslation(); + const theme = useTheme(); + const collectionTrees = useCollectionTrees(); + + const [searchTerm, setSearchTerm] = React.useState(); + const [selectedNode, selectNode] = React.useState( + null + ); + const [initialScrollOffset, setInitialScrollOffset] = React.useState( + 0 + ); + const [nodes, setNodes] = React.useState([]); + const [activeNode, setActiveNode] = React.useState(0); + const [expandedNodes, setExpandedNodes] = React.useState([]); + + const inputSearchRef = React.useRef( + null + ); + const listRef = React.useRef>(null); + + const VERTICAL_PADDING = 6; + const HORIZONTAL_PADDING = 24; + + const allNodes = React.useMemo( + () => flatten(collectionTrees.map(flattenTree)), + [collectionTrees] + ); + + const searchIndex = React.useMemo(() => { + return new FuzzySearch(allNodes, ["title"], { + caseSensitive: false, + }); + }, [allNodes]); + + React.useEffect(() => { + if (searchTerm) { + selectNode(null); + setExpandedNodes([]); + } + setActiveNode(0); + }, [searchTerm]); + + React.useEffect(() => { + let results; + + if (searchTerm) { + results = searchIndex.search(searchTerm); + } else { + results = allNodes.filter((r) => r.type === "collection"); + } + + setInitialScrollOffset(0); + setNodes(results); + }, [searchTerm, allNodes, searchIndex]); + + React.useEffect(() => { + onSelect(selectedNode); + }, [selectedNode, onSelect]); + + const handleSearch = (ev: React.ChangeEvent) => { + setSearchTerm(ev.target.value); + }; + + const isExpanded = (node: number) => { + return 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 < 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); + setNodes(newNodes); + }; + + 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); + setNodes(newNodes); + }; + + const isSelected = (node: number) => { + if (!selectedNode) { + return false; + } + const selectedNodeId = selectedNode.id; + const nodeId = nodes[node].id; + + return selectedNodeId === nodeId; + }; + + const toggleCollapse = (node: number) => { + if (isExpanded(node)) { + collapse(node); + } else { + expand(node); + } + }; + + const toggleSelect = (node: number) => { + if (isSelected(node)) { + selectNode(null); + } else { + selectNode(nodes[node]); + } + }; + + const ListItem = ({ + index, + data, + style, + }: { + index: number; + data: NavigationNode[]; + style: React.CSSProperties; + }) => { + const node = data[index]; + const isCollection = node.type === "collection"; + let icon, title, path; + + if (isCollection) { + const col = collections.get(node.collectionId as string); + icon = col && ( + + ); + title = node.title; + } else { + const doc = documents.get(node.id); + const { strippedTitle, emoji } = parseTitle(node.title); + title = strippedTitle; + + if (emoji) { + icon = ; + } else if (doc?.isStarred) { + icon = ; + } else { + icon = ; + } + + path = ancestors(node) + .map((a) => parseTitle(a.title).strippedTitle) + .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={node.children.length > 0} + /> + ); + }; + + const focusSearchInput = () => { + inputSearchRef.current?.focus(); + }; + + const next = () => { + return Math.min(activeNode + 1, nodes.length - 1); + }; + + const prev = () => { + return Math.max(activeNode - 1, 0); + }; + + const handleKeyDown = (ev: React.KeyboardEvent) => { + switch (ev.key) { + case "ArrowDown": { + ev.preventDefault(); + setActiveNode(next()); + break; + } + case "ArrowUp": { + ev.preventDefault(); + if (activeNode === 0) { + focusSearchInput(); + } else { + setActiveNode(prev()); + } + break; + } + case "ArrowLeft": { + if (!searchTerm && isExpanded(activeNode)) { + toggleCollapse(activeNode); + } + break; + } + case "ArrowRight": { + if (!searchTerm) { + toggleCollapse(activeNode); + } + break; + } + case "Enter": { + if (isModKey(ev)) { + onSubmit(); + } else { + toggleSelect(activeNode); + } + break; + } + } + }; + + const innerElementType = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes + >(({ style, ...rest }, ref) => ( +
+ )); + + 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); diff --git a/app/components/PublishLocation.tsx b/app/components/DocumentExplorerNode.tsx similarity index 66% rename from app/components/PublishLocation.tsx rename to app/components/DocumentExplorerNode.tsx index 6d3293715..e3eb6bfd7 100644 --- a/app/components/PublishLocation.tsx +++ b/app/components/DocumentExplorerNode.tsx @@ -7,49 +7,40 @@ import breakpoint from "styled-components-breakpoint"; import Flex from "~/components/Flex"; import Disclosure from "~/components/Sidebar/components/Disclosure"; import Text from "~/components/Text"; -import { ancestors } from "~/utils/tree"; type Props = { - location: any; selected: boolean; active: boolean; style: React.CSSProperties; - isSearchResult: boolean; expanded: boolean; icon?: React.ReactNode; + title: string; + depth: number; + hasChildren: boolean; onDisclosureClick: (ev: React.MouseEvent) => void; onPointerMove: (ev: React.MouseEvent) => void; onClick: (ev: React.MouseEvent) => void; }; -function PublishLocation({ - location, +function DocumentExplorerNode({ selected, active, style, - isSearchResult, expanded, + icon, + title, + depth, + hasChildren, onDisclosureClick, onPointerMove, onClick, - icon, }: Props) { const { t } = useTranslation(); const OFFSET = 12; const ICON_SIZE = 24; - const hasChildren = location.children.length > 0; - const isCollection = location.data.type === "collection"; - - const width = location.depth - ? location.depth * ICON_SIZE + OFFSET - : ICON_SIZE; - - const path = (location: any) => - ancestors(location) - .map((a) => a.data.title) - .join(" / "); + const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE; const ref = React.useCallback( (node: HTMLSpanElement | null) => { @@ -65,7 +56,7 @@ function PublishLocation({ ); return ( - - {!isSearchResult && ( - - {hasChildren && ( - - )} - - )} + + {hasChildren && ( + + )} + {icon} - {location.data.title || t("Untitled")} - {isSearchResult && !isCollection && ( - - {path(location)} - - )} - + {title || t("Untitled")} + ); } const Title = styled(Text)` white-space: nowrap; overflow: hidden; + text-overflow: ellipsis; margin: 0 4px 0 4px; color: inherit; `; -const Path = styled(Text)<{ $selected: boolean }>` - padding-top: 3px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin: 0 4px 0 8px; - color: ${(props) => - props.$selected ? props.theme.white50 : props.theme.textTertiary}; -`; - const StyledDisclosure = styled(Disclosure)` position: relative; left: auto; @@ -125,7 +100,7 @@ const Spacer = styled(Flex)<{ width: number }>` width: ${(props) => props.width}px; `; -const Row = styled.span<{ +export const Node = styled.span<{ active: boolean; selected: boolean; style: React.CSSProperties; @@ -167,4 +142,4 @@ const Row = styled.span<{ `} `; -export default observer(PublishLocation); +export default observer(DocumentExplorerNode); diff --git a/app/components/DocumentExplorerSearchResult.tsx b/app/components/DocumentExplorerSearchResult.tsx new file mode 100644 index 000000000..094be170f --- /dev/null +++ b/app/components/DocumentExplorerSearchResult.tsx @@ -0,0 +1,85 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import scrollIntoView from "smooth-scroll-into-view-if-needed"; +import styled from "styled-components"; +import { Node as SearchResult } from "~/components/DocumentExplorerNode"; +import Flex from "~/components/Flex"; +import Text from "~/components/Text"; + +type Props = { + selected: boolean; + active: boolean; + style: React.CSSProperties; + icon?: React.ReactNode; + title: string; + path?: string; + + onPointerMove: (ev: React.MouseEvent) => void; + onClick: (ev: React.MouseEvent) => void; +}; + +function DocumentExplorerSearchResult({ + selected, + active, + style, + icon, + title, + path, + onPointerMove, + onClick, +}: Props) { + const { t } = useTranslation(); + + const ref = React.useCallback( + (node: HTMLSpanElement | null) => { + if (active && node) { + scrollIntoView(node, { + scrollMode: "if-needed", + behavior: "auto", + block: "nearest", + }); + } + }, + [active] + ); + + return ( + + {icon} + + {title || t("Untitled")} + + {path} + + + + ); +} + +const Title = styled(Text)` + flex-shrink: 0; + white-space: nowrap; + margin: 0 4px 0 4px; + color: inherit; +`; + +const Path = styled(Text)<{ $selected: boolean }>` + padding-top: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0 4px 0 8px; + color: ${(props) => + props.$selected ? props.theme.white50 : props.theme.textTertiary}; +`; + +export default observer(DocumentExplorerSearchResult); diff --git a/app/components/Sidebar/Shared.tsx b/app/components/Sidebar/Shared.tsx index 0f6fb5546..90508cd9c 100644 --- a/app/components/Sidebar/Shared.tsx +++ b/app/components/Sidebar/Shared.tsx @@ -2,11 +2,11 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; +import { NavigationNode } from "@shared/types"; import Team from "~/models/Team"; import Scrollable from "~/components/Scrollable"; import SearchPopover from "~/components/SearchPopover"; import useStores from "~/hooks/useStores"; -import { NavigationNode } from "~/types"; import history from "~/utils/history"; import { homePath, sharedDocumentPath } from "~/utils/routeHelpers"; import TeamLogo from "../TeamLogo"; diff --git a/app/components/Sidebar/components/CollectionLink.tsx b/app/components/Sidebar/components/CollectionLink.tsx index 09a189b7a..fa78d3d8f 100644 --- a/app/components/Sidebar/components/CollectionLink.tsx +++ b/app/components/Sidebar/components/CollectionLink.tsx @@ -5,6 +5,7 @@ import * as React from "react"; import { useDrop } from "react-dnd"; import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; +import { NavigationNode } from "@shared/types"; import Collection from "~/models/Collection"; import Document from "~/models/Document"; import DocumentReparent from "~/scenes/DocumentReparent"; @@ -17,7 +18,6 @@ import useBoolean from "~/hooks/useBoolean"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import CollectionMenu from "~/menus/CollectionMenu"; -import { NavigationNode } from "~/types"; import DropToImport from "./DropToImport"; import EditableTitle from "./EditableTitle"; import Relative from "./Relative"; diff --git a/app/components/Sidebar/components/DocumentLink.tsx b/app/components/Sidebar/components/DocumentLink.tsx index 8b3eec687..28b2ad2ff 100644 --- a/app/components/Sidebar/components/DocumentLink.tsx +++ b/app/components/Sidebar/components/DocumentLink.tsx @@ -6,6 +6,7 @@ import { useDrag, useDrop } from "react-dnd"; 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"; @@ -18,7 +19,6 @@ import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; import DocumentMenu from "~/menus/DocumentMenu"; -import { NavigationNode } from "~/types"; import { newDocumentPath } from "~/utils/routeHelpers"; import DropCursor from "./DropCursor"; import DropToImport from "./DropToImport"; diff --git a/app/components/Sidebar/components/SharedDocumentLink.tsx b/app/components/Sidebar/components/SharedDocumentLink.tsx index 4a709907f..938fcb64b 100644 --- a/app/components/Sidebar/components/SharedDocumentLink.tsx +++ b/app/components/Sidebar/components/SharedDocumentLink.tsx @@ -1,10 +1,10 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; +import { NavigationNode } from "@shared/types"; import Collection from "~/models/Collection"; import Document from "~/models/Document"; import useStores from "~/hooks/useStores"; -import { NavigationNode } from "~/types"; import { sharedDocumentPath } from "~/utils/routeHelpers"; import Disclosure from "./Disclosure"; import SidebarLink from "./SidebarLink"; diff --git a/app/components/Sidebar/components/SidebarLink.tsx b/app/components/Sidebar/components/SidebarLink.tsx index 44e20293b..1ab6cd9b4 100644 --- a/app/components/Sidebar/components/SidebarLink.tsx +++ b/app/components/Sidebar/components/SidebarLink.tsx @@ -2,10 +2,10 @@ import { LocationDescriptor } from "history"; import * as React from "react"; import styled, { useTheme, css } from "styled-components"; import breakpoint from "styled-components-breakpoint"; +import { NavigationNode } from "@shared/types"; import EventBoundary from "~/components/EventBoundary"; import NudeButton from "~/components/NudeButton"; import { undraggableOnDesktop } from "~/styles"; -import { NavigationNode } from "~/types"; import Disclosure from "./Disclosure"; import NavLink, { Props as NavLinkProps } from "./NavLink"; diff --git a/app/hooks/useCollectionTrees.ts b/app/hooks/useCollectionTrees.ts new file mode 100644 index 000000000..fe7ea419f --- /dev/null +++ b/app/hooks/useCollectionTrees.ts @@ -0,0 +1,82 @@ +import * as React from "react"; +import { NavigationNode, NavigationNodeType } from "@shared/types"; +import Collection from "~/models/Collection"; +import useStores from "~/hooks/useStores"; + +/** + * React hook that modifies the document structure + * of all collections present in store. Adds extra attributes + * like type, depth and parent to each of the nodes in document + * structure. + * + * @return {NavigationNode[]} collectionTrees root collection nodes of modified trees + */ +export default function useCollectionTrees(): NavigationNode[] { + const { collections } = useStores(); + + const getCollectionTree = (collection: Collection): NavigationNode => { + const addType = (node: NavigationNode): NavigationNode => { + if (node.children.length > 0) { + node.children = node.children.map(addType); + } + + node.type = node.type ? node.type : NavigationNodeType.Document; + return node; + }; + + const addParent = ( + node: NavigationNode, + parent: NavigationNode | null = null + ): NavigationNode => { + if (node.children.length > 0) { + node.children = node.children.map((child) => addParent(child, node)); + } + + node.parent = parent; + return node; + }; + + const addDepth = (node: NavigationNode, depth = 0): NavigationNode => { + if (node.children.length > 0) { + node.children = node.children.map((child) => + addDepth(child, depth + 1) + ); + } + + node.depth = depth; + return node; + }; + + const addCollectionId = ( + node: NavigationNode, + collectionId = collection.id + ): NavigationNode => { + if (node.children.length > 0) { + node.children = node.children.map((child) => + addCollectionId(child, collectionId) + ); + } + + node.collectionId = collectionId; + return node; + }; + + const collectionNode: NavigationNode = { + id: collection.id, + title: collection.name, + url: collection.url, + type: NavigationNodeType.Collection, + children: collection.documents || [], + parent: null, + }; + + return addParent(addCollectionId(addDepth(addType(collectionNode)))); + }; + + const collectionTrees = React.useMemo( + () => collections.orderedData.map(getCollectionTree), + [collections.orderedData] + ); + + return collectionTrees; +} diff --git a/app/models/Collection.ts b/app/models/Collection.ts index 21569f0e4..696bb3401 100644 --- a/app/models/Collection.ts +++ b/app/models/Collection.ts @@ -1,11 +1,14 @@ import { trim } from "lodash"; import { action, computed, observable } from "mobx"; -import { CollectionPermission, FileOperationFormat } from "@shared/types"; +import { + CollectionPermission, + FileOperationFormat, + NavigationNode, +} from "@shared/types"; import { sortNavigationNodes } from "@shared/utils/collections"; import CollectionsStore from "~/stores/CollectionsStore"; import Document from "~/models/Document"; import ParanoidModel from "~/models/ParanoidModel"; -import { NavigationNode } from "~/types"; import { client } from "~/utils/ApiClient"; import Field from "./decorators/Field"; diff --git a/app/models/Document.ts b/app/models/Document.ts index 47e9c257e..d77ba2783 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -2,12 +2,12 @@ import { addDays, differenceInDays } from "date-fns"; import { floor } from "lodash"; import { action, autorun, computed, observable, set } from "mobx"; import { ExportContentType } from "@shared/types"; +import type { NavigationNode } from "@shared/types"; import Storage from "@shared/utils/Storage"; import parseTitle from "@shared/utils/parseTitle"; import { isRTL } from "@shared/utils/rtl"; import DocumentsStore from "~/stores/DocumentsStore"; import User from "~/models/User"; -import type { NavigationNode } from "~/types"; import { client } from "~/utils/ApiClient"; import ParanoidModel from "./ParanoidModel"; import View from "./View"; diff --git a/app/scenes/Document/Shared.tsx b/app/scenes/Document/Shared.tsx index d061dc51c..3d1395793 100644 --- a/app/scenes/Document/Shared.tsx +++ b/app/scenes/Document/Shared.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { RouteComponentProps, useLocation, Redirect } from "react-router-dom"; import styled, { useTheme } from "styled-components"; import { setCookie } from "tiny-cookie"; +import { NavigationNode } from "@shared/types"; import DocumentModel from "~/models/Document"; import Team from "~/models/Team"; import Error404 from "~/scenes/Error404"; @@ -16,7 +17,6 @@ import Text from "~/components/Text"; import env from "~/env"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; -import { NavigationNode } from "~/types"; import { AuthorizationError, OfflineError } from "~/utils/errors"; import isCloudHosted from "~/utils/isCloudHosted"; import { changeLanguage, detectLanguage } from "~/utils/language"; diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index 3194b13e5..4658510d0 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -1,13 +1,13 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useLocation, RouteComponentProps, StaticContext } from "react-router"; +import { NavigationNode } from "@shared/types"; import Document from "~/models/Document"; import Revision from "~/models/Revision"; import Error404 from "~/scenes/Error404"; import ErrorOffline from "~/scenes/ErrorOffline"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; -import { NavigationNode } from "~/types"; import Logger from "~/utils/Logger"; import { NotFoundError, OfflineError } from "~/utils/errors"; import history from "~/utils/history"; diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index ab8a51bd2..190956667 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -15,6 +15,7 @@ import { import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { Heading } from "@shared/editor/lib/getHeadings"; +import { NavigationNode } from "@shared/types"; import { parseDomain } from "@shared/utils/domains"; import getTasks from "@shared/utils/getTasks"; import RootStore from "~/stores/RootStore"; @@ -33,7 +34,6 @@ import PlaceholderDocument from "~/components/PlaceholderDocument"; import RegisterKeyDown from "~/components/RegisterKeyDown"; import withStores from "~/components/withStores"; import type { Editor as TEditor } from "~/editor"; -import { NavigationNode } from "~/types"; import { client } from "~/utils/ApiClient"; import { replaceTitleVariables } from "~/utils/date"; import { emojiToUrl } from "~/utils/emoji"; diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index e487182d7..8af338b01 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -11,6 +11,7 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import styled from "styled-components"; +import { NavigationNode } from "@shared/types"; import { Theme } from "~/stores/UiStore"; import Document from "~/models/Document"; import { Action, Separator } from "~/components/Actions"; @@ -30,7 +31,6 @@ import DocumentMenu from "~/menus/DocumentMenu"; import NewChildDocumentMenu from "~/menus/NewChildDocumentMenu"; import TableOfContentsMenu from "~/menus/TableOfContentsMenu"; import TemplatesMenu from "~/menus/TemplatesMenu"; -import { NavigationNode } from "~/types"; import { metaDisplay } from "~/utils/keyboard"; import { newDocumentPath, editDocumentUrl } from "~/utils/routeHelpers"; import ObservingBanner from "./ObservingBanner"; diff --git a/app/scenes/Document/components/PublicBreadcrumb.tsx b/app/scenes/Document/components/PublicBreadcrumb.tsx index 027221b75..639c624a8 100644 --- a/app/scenes/Document/components/PublicBreadcrumb.tsx +++ b/app/scenes/Document/components/PublicBreadcrumb.tsx @@ -1,6 +1,7 @@ import * as React from "react"; +import { NavigationNode } from "@shared/types"; import Breadcrumb from "~/components/Breadcrumb"; -import { MenuInternalLink, NavigationNode } from "~/types"; +import { MenuInternalLink } from "~/types"; import { sharedDocumentPath } from "~/utils/routeHelpers"; type Props = { diff --git a/app/scenes/Document/components/PublicReferences.tsx b/app/scenes/Document/components/PublicReferences.tsx index 1071e38d6..cecb82c04 100644 --- a/app/scenes/Document/components/PublicReferences.tsx +++ b/app/scenes/Document/components/PublicReferences.tsx @@ -1,8 +1,8 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; +import { NavigationNode } from "@shared/types"; import Subheading from "~/components/Subheading"; -import { NavigationNode } from "~/types"; import ReferenceListItem from "./ReferenceListItem"; type Props = { diff --git a/app/scenes/Document/components/ReferenceListItem.tsx b/app/scenes/Document/components/ReferenceListItem.tsx index c1fa99b82..882a7d999 100644 --- a/app/scenes/Document/components/ReferenceListItem.tsx +++ b/app/scenes/Document/components/ReferenceListItem.tsx @@ -3,12 +3,12 @@ import { DocumentIcon } from "outline-icons"; import * as React from "react"; import { Link } from "react-router-dom"; import styled from "styled-components"; +import { NavigationNode } from "@shared/types"; import parseTitle from "@shared/utils/parseTitle"; import Document from "~/models/Document"; import Flex from "~/components/Flex"; import EmojiIcon from "~/components/Icons/EmojiIcon"; import { hover } from "~/styles"; -import { NavigationNode } from "~/types"; import { sharedDocumentPath } from "~/utils/routeHelpers"; type Props = { diff --git a/app/scenes/DocumentPublish.tsx b/app/scenes/DocumentPublish.tsx index 1fc761fcb..fbdfa6de8 100644 --- a/app/scenes/DocumentPublish.tsx +++ b/app/scenes/DocumentPublish.tsx @@ -1,27 +1,15 @@ -import FuzzySearch from "fuzzy-search"; -import { includes, difference, concat, filter } from "lodash"; import { observer } from "mobx-react"; -import { StarredIcon, DocumentIcon } from "outline-icons"; import * as React from "react"; import { useTranslation, Trans } 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 styled from "styled-components"; +import { NavigationNode } from "@shared/types"; import Document from "~/models/Document"; import Button from "~/components/Button"; +import DocumentExplorer from "~/components/DocumentExplorer"; 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 PublishLocation from "~/components/PublishLocation"; import Text from "~/components/Text"; -import useMobile from "~/hooks/useMobile"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; -import { isModKey } from "~/utils/keyboard"; -import { flattenTree, descendants } from "~/utils/tree"; type Props = { /** Document to publish */ @@ -29,144 +17,16 @@ type Props = { }; function DocumentPublish({ document }: Props) { - const isMobile = useMobile(); - const [searchTerm, setSearchTerm] = React.useState(); - const [selectedLocation, setLocation] = React.useState(); - const [initialScrollOffset, setInitialScrollOffset] = React.useState( - 0 - ); - const { collections, documents } = useStores(); + const { dialogs } = useStores(); const { showToast } = useToasts(); - const theme = useTheme(); - const [items, setItems] = React.useState( - flattenTree(collections.tree.root).slice(1) - ); - const [activeItem, setActiveItem] = React.useState(0); - const [expandedItems, setExpandedItems] = React.useState([]); - const inputSearchRef = React.useRef( + const { t } = useTranslation(); + + const [selectedPath, selectPath] = React.useState( null ); - const { t } = useTranslation(); - const { dialogs } = useStores(); - const listRef = React.useRef>(null); - const VERTICAL_PADDING = 6; - const HORIZONTAL_PADDING = 24; - - const nextItem = () => { - return Math.min(activeItem + 1, items.length - 1); - }; - - const prevItem = () => { - return Math.max(activeItem - 1, 0); - }; - - const searchIndex = React.useMemo(() => { - const data = flattenTree(collections.tree.root).slice(1); - - return new FuzzySearch(data, ["data.title"], { - caseSensitive: false, - }); - }, [collections.tree]); - - React.useEffect(() => { - if (searchTerm) { - setLocation(null); - setExpandedItems([]); - } - setActiveItem(0); - }, [searchTerm]); - - React.useEffect(() => { - let results = flattenTree(collections.tree.root).slice(1); - - if (collections.isLoaded) { - if (searchTerm) { - results = searchIndex.search(searchTerm); - } else { - results = results.filter((r) => r.data.type === "collection"); - } - } - - setInitialScrollOffset(0); - setItems(results); - }, [document, collections, searchTerm, searchIndex]); - - const handleSearch = (ev: React.ChangeEvent) => { - setSearchTerm(ev.target.value); - }; - - const isExpanded = (index: number) => { - const item = items[index]; - return includes(expandedItems, item.data.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 < height ? 0 : scrollOffset; - } - return 0; - }; - - const collapse = (item: number) => { - const descendantIds = descendants(items[item]).map((des) => des.data.id); - setExpandedItems( - difference(expandedItems, [...descendantIds, items[item].data.id]) - ); - - // remove children - const newItems = filter( - items, - (item: any) => !includes(descendantIds, item.data.id) - ); - const scrollOffset = calculateInitialScrollOffset(newItems.length); - setInitialScrollOffset(scrollOffset); - setItems(newItems); - }; - - const expand = (item: number) => { - setExpandedItems(concat(expandedItems, items[item].data.id)); - - // add children - const newItems = items.slice(); - newItems.splice(item + 1, 0, ...descendants(items[item], 1)); - const scrollOffset = calculateInitialScrollOffset(newItems.length); - setInitialScrollOffset(scrollOffset); - setItems(newItems); - }; - - const isSelected = (item: number) => { - if (!selectedLocation) { - return false; - } - const selectedItemId = selectedLocation.data.id; - const itemId = items[item].data.id; - - return selectedItemId === itemId; - }; - - const toggleCollapse = (item: number) => { - if (isExpanded(item)) { - collapse(item); - } else { - expand(item); - } - }; - - const toggleSelect = (item: number) => { - if (isSelected(item)) { - setLocation(null); - } else { - setLocation(items[item]); - } - }; const publish = async () => { - if (!selectedLocation) { + if (!selectedPath) { showToast(t("Select a location to publish"), { type: "info", }); @@ -174,11 +34,9 @@ function DocumentPublish({ document }: Props) { } try { - const { - collectionId, - type, - id: parentDocumentId, - } = selectedLocation.data; + const { type, id: parentDocumentId } = selectedPath; + + const collectionId = selectedPath.collectionId as string; // Also move it under if selected path corresponds to another doc if (type === "document") { @@ -200,176 +58,26 @@ function DocumentPublish({ document }: Props) { } }; - const row = ({ - index, - data, - style, - }: { - index: number; - data: any[]; - style: React.CSSProperties; - }) => { - const result = data[index]; - const isCollection = result.data.type === "collection"; - let icon; - - if (isCollection) { - const col = collections.get(result.data.collectionId); - icon = col && ( - - ); - } else { - const doc = documents.get(result.data.id); - const { emoji } = result.data; - if (emoji) { - icon = ; - } else if (doc?.isStarred) { - icon = ; - } else { - icon = ; - } - } - - return ( - setActiveItem(index)} - onClick={() => toggleSelect(index)} - onDisclosureClick={(ev) => { - ev.stopPropagation(); - toggleCollapse(index); - }} - location={result} - selected={isSelected(index)} - active={activeItem === index} - expanded={isExpanded(index)} - icon={icon} - isSearchResult={!!searchTerm} - /> - ); - }; - - if (!document || !collections.isLoaded) { - return null; - } - - const focusSearchInput = () => { - inputSearchRef.current?.focus(); - }; - - const handleKeyDown = (ev: React.KeyboardEvent) => { - switch (ev.key) { - case "ArrowDown": { - ev.preventDefault(); - setActiveItem(nextItem()); - break; - } - case "ArrowUp": { - ev.preventDefault(); - if (activeItem === 0) { - focusSearchInput(); - } else { - setActiveItem(prevItem()); - } - break; - } - case "ArrowLeft": { - if (!searchTerm && isExpanded(activeItem)) { - toggleCollapse(activeItem); - } - break; - } - case "ArrowRight": { - if (!searchTerm) { - toggleCollapse(activeItem); - } - break; - } - case "Enter": { - if (isModKey(ev)) { - publish(); - } else { - toggleSelect(activeItem); - } - break; - } - } - }; - - const innerElementType = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes - >(({ style, ...rest }, ref) => ( -
- )); - return ( - - - {items.length ? ( - - - {({ width, height }: { width: number; height: number }) => ( - - results[index].data.id} - > - {row} - - - )} - - - ) : ( - - {t("No results found")}. - - )} + +
- {selectedLocation ? ( - + + {selectedPath ? ( , }} /> - - ) : ( - - {t("Select a location to publish")} - - )} -
@@ -377,25 +85,6 @@ function DocumentPublish({ document }: Props) { ); } -const NoResults = styled(Flex)` - align-items: center; - justify-content: center; - height: 65vh; - - ${breakpoint("tablet")` - height: 40vh; - `} -`; - -const Search = styled(InputSearch)` - ${Outline} { - border-radius: 16px; - } - margin-bottom: 4px; - padding-left: 24px; - padding-right: 24px; -`; - const FlexContainer = styled(Flex)` margin-left: -24px; margin-right: -24px; @@ -403,14 +92,6 @@ const FlexContainer = styled(Flex)` outline: none; `; -const Results = styled.div` - height: 65vh; - - ${breakpoint("tablet")` - height: 40vh; - `} -`; - const Footer = styled(Flex)` height: 64px; border-top: 1px solid ${(props) => props.theme.horizontalRule}; @@ -418,7 +99,7 @@ const Footer = styled(Flex)` padding-right: 24px; `; -const SelectedLocation = styled(Text)` +const StyledText = styled(Text)` white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/app/scenes/DocumentReparent.tsx b/app/scenes/DocumentReparent.tsx index 080549392..617bac878 100644 --- a/app/scenes/DocumentReparent.tsx +++ b/app/scenes/DocumentReparent.tsx @@ -2,14 +2,13 @@ import { observer } from "mobx-react"; import { useState } from "react"; import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; -import { CollectionPermission } from "@shared/types"; +import { CollectionPermission, NavigationNode } from "@shared/types"; import Collection from "~/models/Collection"; import Button from "~/components/Button"; import Flex from "~/components/Flex"; import Text from "~/components/Text"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; -import { NavigationNode } from "~/types"; type Props = { item: diff --git a/app/stores/CollectionsStore.ts b/app/stores/CollectionsStore.ts index beeb08385..31c1aab30 100644 --- a/app/stores/CollectionsStore.ts +++ b/app/stores/CollectionsStore.ts @@ -1,10 +1,12 @@ import invariant from "invariant"; -import { concat, find, last, isEmpty } from "lodash"; +import { concat, find, last } from "lodash"; import { computed, action } from "mobx"; -import { CollectionPermission, FileOperationFormat } from "@shared/types"; -import parseTitle from "@shared/utils/parseTitle"; +import { + CollectionPermission, + FileOperationFormat, + NavigationNode, +} from "@shared/types"; import Collection from "~/models/Collection"; -import { NavigationNode } from "~/types"; import { client } from "~/utils/ApiClient"; import { AuthorizationError, NotFoundError } from "~/utils/errors"; import BaseStore from "./BaseStore"; @@ -100,76 +102,6 @@ export default class CollectionsStore extends BaseStore { }); } - @computed - get tree() { - const subtree = (node: any) => { - const isDocument = node.data.type === DocumentPathItemType.Document; - if (isDocument) { - const { strippedTitle, emoji } = parseTitle(node.data.title); - node.data.title = strippedTitle; - if (emoji) { - node.data.emoji = emoji; - } - } - const root: any = { - data: { - id: node.data.id, - title: node.data.name || node.data.title, - type: node.data.type, - collectionId: - node.data.type === DocumentPathItemType.Collection - ? node.data.id - : node.data.collectionId, - emoji: node.data.emoji, - }, - children: [], - parent: node.parent, - depth: node.depth, - }; - !isEmpty(node.children) && - node.children.forEach((child: any) => { - root.children.push( - subtree({ - data: { - ...child, - type: DocumentPathItemType.Document, - collectionId: root.data.collectionId, - }, - parent: root, - children: child.children || [], - depth: root.depth + 1, - }).root - ); - }); - return { root }; - }; - - const root: any = { - data: null, - parent: null, - children: [], - depth: -1, - }; - - if (this.isLoaded) { - this.orderedData.forEach((collection) => { - root.children.push( - subtree({ - data: { - ...collection, - type: DocumentPathItemType.Collection, - }, - children: collection.documents || [], - parent: root, - depth: root.depth + 1, - }).root - ); - }); - } - - return { root }; - } - @action import = async (attachmentId: string, format?: string) => { await client.post("/collections.import", { diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts index ffee4c7ec..df0e1523a 100644 --- a/app/stores/DocumentsStore.ts +++ b/app/stores/DocumentsStore.ts @@ -2,7 +2,7 @@ import path from "path"; import invariant from "invariant"; import { find, orderBy, filter, compact, omitBy } from "lodash"; import { observable, action, computed, runInAction } from "mobx"; -import { DateFilter } from "@shared/types"; +import { DateFilter, NavigationNode } from "@shared/types"; import { subtractDate } from "@shared/utils/date"; import { bytesToHumanReadable } from "@shared/utils/files"; import naturalSort from "@shared/utils/naturalSort"; @@ -12,12 +12,7 @@ import RootStore from "~/stores/RootStore"; import Document from "~/models/Document"; import Team from "~/models/Team"; import env from "~/env"; -import { - FetchOptions, - PaginationParams, - SearchResult, - NavigationNode, -} from "~/types"; +import { FetchOptions, PaginationParams, SearchResult } from "~/types"; import { client } from "~/utils/ApiClient"; type FetchPageParams = PaginationParams & { diff --git a/app/types.ts b/app/types.ts index 45bf2e12b..628c0711d 100644 --- a/app/types.ts +++ b/app/types.ts @@ -140,14 +140,6 @@ export type FetchOptions = { force?: boolean; }; -export type NavigationNode = { - id: string; - title: string; - url: string; - children: NavigationNode[]; - isDraft?: boolean; -}; - export type CollectionSort = { field: string; direction: "asc" | "desc"; diff --git a/app/utils/tree.ts b/app/utils/tree.ts index 313c376e0..33ccdde76 100644 --- a/app/utils/tree.ts +++ b/app/utils/tree.ts @@ -1,32 +1,34 @@ -import { flatten } from "lodash"; +import { NavigationNode } from "@shared/types"; -export const flattenTree = (root: any) => { - const flattened: any[] = []; +export const flattenTree = (root: NavigationNode) => { + const flattened: NavigationNode[] = []; if (!root) { return flattened; } flattened.push(root); - root.children.forEach((child: any) => { - flattened.push(flattenTree(child)); + root.children.forEach((child) => { + flattened.push(...flattenTree(child)); }); - return flatten(flattened); + return flattened; }; -export const ancestors = (node: any) => { - const ancestors: any[] = []; +export const ancestors = (node: NavigationNode) => { + const ancestors: NavigationNode[] = []; while (node.parent !== null) { ancestors.unshift(node); - node = node.parent; + node = node.parent as NavigationNode; } return ancestors; }; -export const descendants = (node: any, depth = 0) => { +export const descendants = (node: NavigationNode, depth = 0) => { const allDescendants = flattenTree(node).slice(1); return depth === 0 ? allDescendants - : allDescendants.filter((d) => d.depth <= node.depth + depth); + : allDescendants.filter( + (d) => (d.depth as number) <= (node.depth as number) + depth + ); }; diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 287f34428..667d653d1 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -21,12 +21,12 @@ import { Length as SimpleLength, } from "sequelize-typescript"; import isUUID from "validator/lib/isUUID"; -import { CollectionPermission } from "@shared/types"; +import { CollectionPermission, NavigationNode } from "@shared/types"; import { sortNavigationNodes } from "@shared/utils/collections"; import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers"; import { CollectionValidation } from "@shared/validations"; import slugify from "@server/utils/slugify"; -import type { NavigationNode, CollectionSort } from "~/types"; +import type { CollectionSort } from "~/types"; import CollectionGroup from "./CollectionGroup"; import CollectionUser from "./CollectionUser"; import Document from "./Document"; diff --git a/server/models/Document.ts b/server/models/Document.ts index 563053d2c..026e15507 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -31,12 +31,12 @@ import { } from "sequelize-typescript"; import MarkdownSerializer from "slate-md-serializer"; import isUUID from "validator/lib/isUUID"; +import type { NavigationNode } from "@shared/types"; import getTasks from "@shared/utils/getTasks"; import parseTitle from "@shared/utils/parseTitle"; import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers"; import { DocumentValidation } from "@shared/validations"; import slugify from "@server/utils/slugify"; -import type { NavigationNode } from "~/types"; import Backlink from "./Backlink"; import Collection from "./Collection"; import FileOperation from "./FileOperation"; diff --git a/server/queues/tasks/ExportDocumentTreeTask.ts b/server/queues/tasks/ExportDocumentTreeTask.ts index edaa0ed90..471319033 100644 --- a/server/queues/tasks/ExportDocumentTreeTask.ts +++ b/server/queues/tasks/ExportDocumentTreeTask.ts @@ -1,6 +1,6 @@ import path from "path"; import JSZip from "jszip"; -import { FileOperationFormat } from "@shared/types"; +import { FileOperationFormat, NavigationNode } from "@shared/types"; import Logger from "@server/logging/Logger"; import { Collection } from "@server/models"; import Attachment from "@server/models/Attachment"; @@ -10,7 +10,6 @@ import ZipHelper from "@server/utils/ZipHelper"; import { serializeFilename } from "@server/utils/fs"; import parseAttachmentIds from "@server/utils/parseAttachmentIds"; import { getFileByKey } from "@server/utils/s3"; -import { NavigationNode } from "~/types"; import ExportTask from "./ExportTask"; export default abstract class ExportDocumentTreeTask extends ExportTask { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index bd1611d1b..7f83cf7e1 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -113,6 +113,9 @@ "Default collection": "Default collection", "Deleted Collection": "Deleted Collection", "Unpin": "Unpin", + "Search collections & documents": "Search collections & documents", + "No results found": "No results found", + "Untitled": "Untitled", "New": "New", "Only visible to you": "Only visible to you", "Draft": "Draft", @@ -199,7 +202,6 @@ "Click to retry": "Click to retry", "Back": "Back", "Documents": "Documents", - "Untitled": "Untitled", "Results": "Results", "No results for {{query}}": "No results for {{query}}", "Logo": "Logo", @@ -508,13 +510,11 @@ "Document moved": "Document moved", "Current location": "Current location", "Choose a new location": "Choose a new location", - "Search collections & documents": "Search collections & documents", "Couldn’t create the document, try again?": "Couldn’t create the document, try again?", "Document permanently deleted": "Document permanently deleted", "Are you sure you want to permanently delete the {{ documentTitle }} document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the {{ documentTitle }} document? This action is immediate and cannot be undone.", "Select a location to publish": "Select a location to publish", "Couldn’t publish the document, try again?": "Couldn’t publish the document, try again?", - "No results found": "No results found", "Publish in {{ location }}": "Publish in {{ location }}", "view and edit access": "view and edit access", "view only access": "view only access", diff --git a/shared/types.ts b/shared/types.ts index d6bd53750..a0a29c008 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -117,3 +117,20 @@ export enum TeamPreference { } export type TeamPreferences = { [key in TeamPreference]?: boolean }; + +export enum NavigationNodeType { + Collection = "collection", + Document = "document", +} + +export type NavigationNode = { + id: string; + title: string; + url: string; + children: NavigationNode[]; + isDraft?: boolean; + collectionId?: string; + type?: NavigationNodeType; + parent?: NavigationNode | null; + depth?: number; +}; diff --git a/shared/utils/collections.ts b/shared/utils/collections.ts index 9b9e60487..84a9d6c34 100644 --- a/shared/utils/collections.ts +++ b/shared/utils/collections.ts @@ -1,4 +1,4 @@ -import { NavigationNode } from "~/types"; +import { NavigationNode } from "@shared/types"; import naturalSort from "./naturalSort"; type Sort = {