Move tree implementation out of collections store (#4763)

* refactor: attaching emoji in tree node is unnecessary

* refactor: pass depth and hasChildren as separate props

* refactor: move tree impl into a separate hook

* refactor: separate out as DocumentExplorer for reuse

* fix: separate search and node

* fix: review comments

* fix: tsc
This commit is contained in:
Apoorv Mishra
2023-01-27 11:33:51 +05:30
committed by GitHub
parent cc14c212b6
commit ad902af52c
31 changed files with 656 additions and 517 deletions

View File

@@ -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 = {

View File

@@ -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<string>();
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
null
);
const [initialScrollOffset, setInitialScrollOffset] = React.useState<number>(
0
);
const [nodes, setNodes] = React.useState<NavigationNode[]>([]);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
const inputSearchRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(
null
);
const listRef = React.useRef<List<NavigationNode[]>>(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<HTMLInputElement>) => {
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 && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
title = node.title;
} else {
const doc = documents.get(node.id);
const { strippedTitle, emoji } = parseTitle(node.title);
title = strippedTitle;
if (emoji) {
icon = <EmojiIcon emoji={emoji} />;
} else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />;
} else {
icon = <DocumentIcon />;
}
path = ancestors(node)
.map((a) => parseTitle(a.title).strippedTitle)
.join(" / ");
}
return searchTerm ? (
<DocumentExplorerSearchResult
selected={isSelected(index)}
active={activeNode === index}
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
icon={icon}
title={title}
path={path}
/>
) : (
<DocumentExplorerNode
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => 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<HTMLDivElement>) => {
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<HTMLDivElement>
>(({ style, ...rest }, ref) => (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
));
return (
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
<ListSearch
ref={inputSearchRef}
onChange={handleSearch}
placeholder={`${t("Search collections & documents")}`}
autoFocus
/>
<ListContainer>
{nodes.length ? (
<AutoSizer>
{({ width, height }: { width: number; height: number }) => (
<Flex role="listbox" column>
<List
ref={listRef}
key={nodes.length}
width={width}
height={height}
itemData={nodes}
itemCount={nodes.length}
itemSize={isMobile ? 48 : 32}
innerElementType={innerElementType}
initialScrollOffset={initialScrollOffset}
itemKey={(index, results) => results[index].id}
>
{ListItem}
</List>
</Flex>
)}
</AutoSizer>
) : (
<FlexContainer>
<Text type="secondary">{t("No results found")}.</Text>
</FlexContainer>
)}
</ListContainer>
</Container>
);
}
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);

View File

@@ -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 (
<Row
<Node
ref={ref}
selected={selected}
active={active}
@@ -74,45 +65,29 @@ function PublishLocation({
onPointerMove={onPointerMove}
role="option"
>
{!isSearchResult && (
<Spacer width={width}>
{hasChildren && (
<StyledDisclosure
expanded={expanded}
onClick={onDisclosureClick}
tabIndex={-1}
/>
)}
</Spacer>
)}
<Spacer width={width}>
{hasChildren && (
<StyledDisclosure
expanded={expanded}
onClick={onDisclosureClick}
tabIndex={-1}
/>
)}
</Spacer>
{icon}
<Title>{location.data.title || t("Untitled")}</Title>
{isSearchResult && !isCollection && (
<Path $selected={selected} size="xsmall">
{path(location)}
</Path>
)}
</Row>
<Title>{title || t("Untitled")}</Title>
</Node>
);
}
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);

View File

@@ -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 (
<SearchResult
ref={ref}
selected={selected}
active={active}
onClick={onClick}
style={style}
onPointerMove={onPointerMove}
role="option"
>
{icon}
<Flex>
<Title>{title || t("Untitled")}</Title>
<Path $selected={selected} size="xsmall">
{path}
</Path>
</Flex>
</SearchResult>
);
}
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);

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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<string>();
const [selectedLocation, setLocation] = React.useState<any>();
const [initialScrollOffset, setInitialScrollOffset] = React.useState<number>(
0
);
const { collections, documents } = useStores();
const { dialogs } = useStores();
const { showToast } = useToasts();
const theme = useTheme();
const [items, setItems] = React.useState<any>(
flattenTree(collections.tree.root).slice(1)
);
const [activeItem, setActiveItem] = React.useState<number>(0);
const [expandedItems, setExpandedItems] = React.useState<string[]>([]);
const inputSearchRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(
const { t } = useTranslation();
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
null
);
const { t } = useTranslation();
const { dialogs } = useStores();
const listRef = React.useRef<List<HTMLDivElement>>(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<HTMLInputElement>) => {
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 && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
} else {
const doc = documents.get(result.data.id);
const { emoji } = result.data;
if (emoji) {
icon = <EmojiIcon emoji={emoji} />;
} else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />;
} else {
icon = <DocumentIcon />;
}
}
return (
<PublishLocation
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => 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<HTMLDivElement>) => {
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<HTMLDivElement>
>(({ style, ...rest }, ref) => (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
));
return (
<FlexContainer column tabIndex={-1} onKeyDown={handleKeyDown}>
<Search
ref={inputSearchRef}
onChange={handleSearch}
placeholder={`${t("Search collections & documents")}`}
autoFocus
/>
{items.length ? (
<Results>
<AutoSizer>
{({ width, height }: { width: number; height: number }) => (
<Flex role="listbox" column>
<List
ref={listRef}
key={items.length}
width={width}
height={height}
itemData={items}
itemCount={items.length}
itemSize={isMobile ? 48 : 32}
innerElementType={innerElementType}
initialScrollOffset={initialScrollOffset}
itemKey={(index, results: any) => results[index].data.id}
>
{row}
</List>
</Flex>
)}
</AutoSizer>
</Results>
) : (
<NoResults>
<Text type="secondary">{t("No results found")}.</Text>
</NoResults>
)}
<FlexContainer column>
<DocumentExplorer onSubmit={publish} onSelect={selectPath} />
<Footer justify="space-between" align="center" gap={8}>
{selectedLocation ? (
<SelectedLocation type="secondary">
<StyledText type="secondary">
{selectedPath ? (
<Trans
defaults="Publish in <em>{{ location }}</em>"
values={{
location: selectedLocation.data.title,
location: selectedPath.title,
}}
components={{
em: <strong />,
}}
/>
</SelectedLocation>
) : (
<SelectedLocation type="tertiary">
{t("Select a location to publish")}
</SelectedLocation>
)}
<Button disabled={!selectedLocation} onClick={publish}>
) : (
t("Select a location to publish")
)}
</StyledText>
<Button disabled={!selectedPath} onClick={publish}>
{t("Publish")}
</Button>
</Footer>
@@ -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;

View File

@@ -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:

View File

@@ -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<Collection> {
});
}
@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", {

View File

@@ -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 & {

View File

@@ -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";

View File

@@ -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
);
};

View File

@@ -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";

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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",
"Couldnt create the document, try again?": "Couldnt create the document, try again?",
"Document permanently deleted": "Document permanently deleted",
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.",
"Select a location to publish": "Select a location to publish",
"Couldnt publish the document, try again?": "Couldnt publish the document, try again?",
"No results found": "No results found",
"Publish in <em>{{ location }}</em>": "Publish in <em>{{ location }}</em>",
"view and edit access": "view and edit access",
"view only access": "view only access",

View File

@@ -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;
};

View File

@@ -1,4 +1,4 @@
import { NavigationNode } from "~/types";
import { NavigationNode } from "@shared/types";
import naturalSort from "./naturalSort";
type Sort = {