mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
perf: Refactor sidebar expanded state (#12215)
* fix: centralize sidebar expansion state to eliminate O(N²) tree traversals Each DocumentLink previously traversed the full collection tree independently to determine whether to auto-expand (pathToDocument / descendants), which is O(N) per row and quadratic overall. With thousands of documents this makes the sidebar unusable. Replaces per-node expansion state with a single MobX-backed SidebarExpansionState per tree root. The ObservableSet ensures only the toggled node re-renders. Alt-click cascade, auto-expand on navigation, and drag-to-reparent expansion all go through the same centralized state instead of the per-node SidebarDisclosureContext relay. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: move SidebarExpansionContext alongside other sidebar contexts Rename hooks/useSidebarExpansion.ts to components/SidebarExpansionContext.ts to match the convention of SidebarContext.ts and SidebarDisclosureContext.ts. The context is now the default export with hooks as named exports. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: scope sidebar expansion to its own tree and restore alt-click cascade `useSidebarExpansionState` was unconditionally adding the active document id to every per-tree expansion set, which made `SharedWithMeLink` auto- expand whenever the user navigated anywhere in the matching sidebar context. `computeAncestorPath` now includes the target when found and returns empty when absent, so the hook only expands ids that actually belong to its tree. Also restores alt-click cascade for `StarredDocumentLink` and `SharedWithMeLink`: the parents still broadcast disclosure events but `DocumentLink` no longer listens, so nested children weren't expanded. `StarredDocumentLink` now subscribes via `useSidebarDisclosure` (mirroring `CollectionLinkChildren`), and `SharedWithMeLink` calls `expansion.expandAll`/`collapseAll` directly on alt-click. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: collapse expanded nodes when children are removed and deduplicate shared expansion provider Restores the effect that collapses a node in the expansion state when it no longer has children, preventing the reorder drop logic from treating leaf nodes as expanded containers. Also removes the redundant SidebarExpansionContext.Provider from SharedCollectionLink since the parent SharedSidebar already provides one. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useKBar } from "kbar";
|
||||
import { observer } from "mobx-react";
|
||||
import { SearchIcon } from "outline-icons";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
@@ -18,6 +18,9 @@ import { homePath, sharedModelPath } from "~/utils/routeHelpers";
|
||||
import { AvatarSize } from "../Avatar";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Sidebar from "./Sidebar";
|
||||
import SidebarExpansionContext, {
|
||||
useSidebarExpansionState,
|
||||
} from "./components/SidebarExpansionContext";
|
||||
import Section from "./components/Section";
|
||||
import { SharedCollectionLink } from "./components/SharedCollectionLink";
|
||||
import { SharedDocumentLink } from "./components/SharedDocumentLink";
|
||||
@@ -46,6 +49,12 @@ function SharedSidebar({ share }: Props) {
|
||||
query.toggle();
|
||||
}, [query]);
|
||||
|
||||
const rootChildren = useMemo(
|
||||
() => (rootNode ? [rootNode] : undefined),
|
||||
[rootNode]
|
||||
);
|
||||
const expansion = useSidebarExpansionState(rootChildren, ui.activeDocumentId);
|
||||
|
||||
useEffect(() => {
|
||||
ui.tocVisible = share.showTOC;
|
||||
}, []);
|
||||
@@ -87,24 +96,26 @@ function SharedSidebar({ share }: Props) {
|
||||
</SearchButton>
|
||||
</TopSection>
|
||||
<Section as="nav" aria-label={t("Documents")}>
|
||||
{share.collectionId ? (
|
||||
<SharedCollectionLink
|
||||
node={rootNode}
|
||||
shareId={shareId}
|
||||
hideRootNode={hideRootNode}
|
||||
/>
|
||||
) : (
|
||||
<SharedDocumentLink
|
||||
index={0}
|
||||
// If the root node has an icon we need some extra space for it
|
||||
depth={rootNode.icon ? 1 : 0}
|
||||
shareId={shareId}
|
||||
node={rootNode}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
activeDocumentId={ui.activeDocumentId}
|
||||
activeDocument={documents.active}
|
||||
/>
|
||||
)}
|
||||
<SidebarExpansionContext.Provider value={expansion}>
|
||||
{share.collectionId ? (
|
||||
<SharedCollectionLink
|
||||
node={rootNode}
|
||||
shareId={shareId}
|
||||
hideRootNode={hideRootNode}
|
||||
/>
|
||||
) : (
|
||||
<SharedDocumentLink
|
||||
index={0}
|
||||
// If the root node has an icon we need some extra space for it
|
||||
depth={rootNode.icon ? 1 : 0}
|
||||
shareId={shareId}
|
||||
node={rootNode}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
activeDocumentId={ui.activeDocumentId}
|
||||
activeDocument={documents.active}
|
||||
/>
|
||||
)}
|
||||
</SidebarExpansionContext.Provider>
|
||||
</Section>
|
||||
</ScrollContainer>
|
||||
</Sidebar>
|
||||
|
||||
@@ -13,10 +13,14 @@ import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import useCollectionDocuments from "../hooks/useCollectionDocuments";
|
||||
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
|
||||
import SidebarExpansionContext, {
|
||||
useSidebarExpansionState,
|
||||
} from "./SidebarExpansionContext";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import { useSidebarDisclosure } from "./SidebarDisclosureContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
// The number of child documents to initially render
|
||||
@@ -42,7 +46,8 @@ function CollectionLinkChildren({
|
||||
const pageSize = DEFAULT_PAGE_SIZE;
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const childDocuments = useCollectionDocuments(collection, documents.active);
|
||||
const activeDocument = documents.active;
|
||||
const childDocuments = useCollectionDocuments(collection, activeDocument);
|
||||
const [showing, setShowing] = useState(pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -57,44 +62,64 @@ function CollectionLinkChildren({
|
||||
}
|
||||
}, [childDocuments, showing, pageSize]);
|
||||
|
||||
const expansion = useSidebarExpansionState(
|
||||
childDocuments,
|
||||
activeDocument?.id
|
||||
);
|
||||
|
||||
// Handle collection-level alt-click cascade from DraggableCollectionLink
|
||||
const handleCascadeExpand = useCallback(() => {
|
||||
if (childDocuments) {
|
||||
expansion.expandAll(childDocuments);
|
||||
}
|
||||
}, [expansion, childDocuments]);
|
||||
|
||||
const handleCascadeCollapse = useCallback(() => {
|
||||
expansion.collapseAll();
|
||||
}, [expansion]);
|
||||
|
||||
useSidebarDisclosure(handleCascadeExpand, handleCascadeCollapse);
|
||||
|
||||
return (
|
||||
<Folder expanded={expanded}>
|
||||
<DynamicDropCursor collection={collection} />
|
||||
<DocumentsLoader collection={collection} enabled={expanded}>
|
||||
{children}
|
||||
{!childDocuments && (
|
||||
<ResizingHeightContainer hideOverflow>
|
||||
<Loading />
|
||||
</ResizingHeightContainer>
|
||||
)}
|
||||
{childDocuments?.slice(0, showing).map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
{childDocuments?.length === 0 && !children && (
|
||||
<SidebarLink
|
||||
label={
|
||||
<Text type="tertiary" size="small" italic>
|
||||
{t("Empty")}
|
||||
</Text>
|
||||
}
|
||||
onClick={() => history.push(collection.url)}
|
||||
depth={2}
|
||||
/>
|
||||
)}
|
||||
{childDocuments && (
|
||||
<Waypoint key={showing} onEnter={showMore} fireOnRapidScroll />
|
||||
)}
|
||||
</DocumentsLoader>
|
||||
</Folder>
|
||||
<SidebarExpansionContext.Provider value={expansion}>
|
||||
<Folder expanded={expanded}>
|
||||
<DynamicDropCursor collection={collection} />
|
||||
<DocumentsLoader collection={collection} enabled={expanded}>
|
||||
{children}
|
||||
{!childDocuments && (
|
||||
<ResizingHeightContainer hideOverflow>
|
||||
<Loading />
|
||||
</ResizingHeightContainer>
|
||||
)}
|
||||
{childDocuments?.slice(0, showing).map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
{childDocuments?.length === 0 && !children && (
|
||||
<SidebarLink
|
||||
label={
|
||||
<Text type="tertiary" size="small" italic>
|
||||
{t("Empty")}
|
||||
</Text>
|
||||
}
|
||||
onClick={() => history.push(collection.url)}
|
||||
depth={2}
|
||||
/>
|
||||
)}
|
||||
{childDocuments && (
|
||||
<Waypoint key={showing} onEnter={showMore} fireOnRapidScroll />
|
||||
)}
|
||||
</DocumentsLoader>
|
||||
</Folder>
|
||||
</SidebarExpansionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,15 +25,12 @@ import {
|
||||
useDropToReorderDocument,
|
||||
useDropToReparentDocument,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useSidebarExpansion } from "./SidebarExpansionContext";
|
||||
import DocumentRow from "./DocumentRow";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import type { SidebarContextType } from "./SidebarContext";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosure,
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
@@ -58,10 +55,11 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
index,
|
||||
parentId,
|
||||
}: Props) {
|
||||
const { documents, policies } = useStores();
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const canUpdate = usePolicy(node.id).update;
|
||||
const can = usePolicy(node.id);
|
||||
const canUpdate = can.update;
|
||||
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
||||
const hasChildDocuments =
|
||||
!!node.children.length || activeDocument?.parentDocumentId === node.id;
|
||||
@@ -71,6 +69,14 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
const sidebarContext = useSidebarContext();
|
||||
const user = useCurrentUser();
|
||||
const expansion = useSidebarExpansion();
|
||||
const expanded = expansion.isExpanded(node.id);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (expanded && !hasChildDocuments) {
|
||||
expansion.collapse(node.id);
|
||||
}
|
||||
}, [expansion, expanded, hasChildDocuments, node.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -87,59 +93,33 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
isActiveDocument,
|
||||
]);
|
||||
|
||||
const showChildren = React.useMemo(() => {
|
||||
if (!hasChildDocuments || !activeDocument) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pathToDocument =
|
||||
collection?.pathToDocument(activeDocument.id) ??
|
||||
membership?.pathToDocument(activeDocument.id);
|
||||
|
||||
return !!(
|
||||
pathToDocument?.some((entry) => entry.id === node.id) || isActiveDocument
|
||||
);
|
||||
}, [
|
||||
hasChildDocuments,
|
||||
activeDocument,
|
||||
isActiveDocument,
|
||||
node,
|
||||
collection,
|
||||
membership,
|
||||
]);
|
||||
|
||||
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
|
||||
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
useSidebarDisclosure(setExpanded, setCollapsed);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showChildren) {
|
||||
setExpanded();
|
||||
}
|
||||
}, [setExpanded, showChildren]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (expanded && !hasChildDocuments) {
|
||||
setCollapsed();
|
||||
}
|
||||
}, [setCollapsed, expanded, hasChildDocuments]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev?: React.MouseEvent<HTMLElement>) => {
|
||||
const willExpand = !expanded;
|
||||
if (willExpand) {
|
||||
setExpanded();
|
||||
if (expanded) {
|
||||
if (ev?.altKey) {
|
||||
expansion.collapseDescendants(node);
|
||||
} else {
|
||||
expansion.collapse(node.id);
|
||||
}
|
||||
} else {
|
||||
setCollapsed();
|
||||
if (ev?.altKey) {
|
||||
expansion.expandDescendants(node);
|
||||
} else {
|
||||
expansion.expand(node.id);
|
||||
}
|
||||
}
|
||||
onDisclosureClick(willExpand, !!ev?.altKey);
|
||||
},
|
||||
[setCollapsed, setExpanded, expanded, onDisclosureClick]
|
||||
[expansion, expanded, node]
|
||||
);
|
||||
|
||||
const handleExpand = React.useCallback(() => {
|
||||
expansion.expand(node.id);
|
||||
}, [expansion, node.id]);
|
||||
|
||||
const handleCollapse = React.useCallback(() => {
|
||||
expansion.collapse(node.id);
|
||||
}, [expansion, node.id]);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void prefetchDocument?.(node.id);
|
||||
}, [prefetchDocument, node]);
|
||||
@@ -191,7 +171,6 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const isMoving = documents.movingDocumentId === node.id;
|
||||
const can = policies.abilities(node.id);
|
||||
const icon = document?.icon || node.icon || node.emoji;
|
||||
const color = document?.color || node.color;
|
||||
const initial = document?.initial || node.title.charAt(0).toUpperCase();
|
||||
@@ -211,7 +190,7 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] =
|
||||
useDropToReparentDocument(node, setExpanded, parentRef);
|
||||
useDropToReparentDocument(node, handleExpand, parentRef);
|
||||
|
||||
const [{ isOverReorder: isOverReorderAbove }, dropToReorderAbove] =
|
||||
useDropToReorderDocument(node, collection, (item) => {
|
||||
@@ -231,7 +210,7 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
if (expanded) {
|
||||
if (expansion.isExpanded(node.id)) {
|
||||
return {
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
@@ -266,11 +245,10 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
false
|
||||
)
|
||||
: node.children,
|
||||
[draftNavNode, collection, node]
|
||||
[draftNavNode, collection, node.children]
|
||||
);
|
||||
|
||||
const doc = documents.get(node.id);
|
||||
const title = doc?.title || node.title || t("Untitled");
|
||||
const title = document?.title || node.title || t("Untitled");
|
||||
const hasChildren = nodeChildren.length > 0;
|
||||
|
||||
const handleNewDoc = React.useCallback(
|
||||
@@ -280,7 +258,7 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
collectionId: collection?.id,
|
||||
parentDocumentId: node.id,
|
||||
fullWidth:
|
||||
doc?.fullWidth ??
|
||||
document?.fullWidth ??
|
||||
user.getPreference(UserPreference.FullWidthDocuments),
|
||||
title: input,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
@@ -300,8 +278,8 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
membership,
|
||||
sidebarContext,
|
||||
user,
|
||||
node,
|
||||
doc,
|
||||
node.id,
|
||||
document,
|
||||
history,
|
||||
]
|
||||
);
|
||||
@@ -353,8 +331,8 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
expanded={expanded && !isDragging}
|
||||
hasChildren={hasChildren}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onExpand={setExpanded}
|
||||
onCollapse={setCollapsed}
|
||||
onExpand={handleExpand}
|
||||
onCollapse={handleCollapse}
|
||||
dragRef={drag}
|
||||
isDragging={isDragging}
|
||||
isMoving={isMoving}
|
||||
@@ -371,24 +349,22 @@ const DocumentLink = observer(function DocumentLinkInner({
|
||||
isActiveOverride={isActiveCheck}
|
||||
onClickIntent={handlePrefetch}
|
||||
>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={childIndex}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={childIndex}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</DocumentRow>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import includes from "lodash/includes";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -8,11 +7,7 @@ import type Collection from "~/models/Collection";
|
||||
import type Document from "~/models/Document";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { sharedModelPath } from "~/utils/routeHelpers";
|
||||
import { descendants } from "@shared/utils/tree";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosure,
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import { useSidebarExpansion } from "./SidebarExpansionContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
@@ -43,59 +38,46 @@ function DocumentLink(
|
||||
) {
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const expansion = useSidebarExpansion();
|
||||
|
||||
const isActiveDocument = activeDocumentId === node.id;
|
||||
|
||||
const hasChildDocuments =
|
||||
!!node.children.length || activeDocument?.parentDocumentId === node.id;
|
||||
const document = documents.get(node.id);
|
||||
const showChildren = React.useMemo(
|
||||
() =>
|
||||
!!(
|
||||
hasChildDocuments &&
|
||||
((activeDocumentId &&
|
||||
includes(
|
||||
descendants(node).map((n) => n.id),
|
||||
activeDocumentId
|
||||
)) ||
|
||||
isActiveDocument ||
|
||||
depth <= 1)
|
||||
),
|
||||
[hasChildDocuments, activeDocumentId, isActiveDocument, depth, node]
|
||||
);
|
||||
|
||||
const [expanded, setExpanded] = React.useState(showChildren);
|
||||
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
const handleExpand = React.useCallback(() => setExpanded(true), []);
|
||||
const handleCollapse = React.useCallback(() => setExpanded(false), []);
|
||||
|
||||
useSidebarDisclosure(handleExpand, handleCollapse);
|
||||
|
||||
// Auto-expand top-level nodes (depth <= 1) on initial render
|
||||
React.useEffect(() => {
|
||||
if (showChildren) {
|
||||
setExpanded(showChildren);
|
||||
if (hasChildDocuments && depth <= 1 && !expansion.isExpanded(node.id)) {
|
||||
expansion.expand(node.id);
|
||||
}
|
||||
}, [showChildren]);
|
||||
}, [expansion, node.id, hasChildDocuments, depth]);
|
||||
|
||||
const expanded = expansion.isExpanded(node.id);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const willExpand = !expanded;
|
||||
setExpanded(willExpand);
|
||||
const altKey = "altKey" in ev && (ev as React.MouseEvent).altKey;
|
||||
onDisclosureClick(willExpand, !!altKey);
|
||||
if (expanded) {
|
||||
const altKey = "altKey" in ev && (ev as React.MouseEvent).altKey;
|
||||
if (altKey) {
|
||||
expansion.collapseDescendants(node);
|
||||
} else {
|
||||
expansion.collapse(node.id);
|
||||
}
|
||||
} else {
|
||||
const altKey = "altKey" in ev && (ev as React.MouseEvent).altKey;
|
||||
if (altKey) {
|
||||
expansion.expandDescendants(node);
|
||||
} else {
|
||||
expansion.expand(node.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
[expanded, onDisclosureClick]
|
||||
[expanded, expansion, node]
|
||||
);
|
||||
|
||||
// since we don't have access to the collection sort here, we just put any
|
||||
// drafts at the front of the list. this is slightly inconsistent with the
|
||||
// logged-in behavior, but it's probably better to emphasize the draft state
|
||||
// of the document in a shared context
|
||||
const nodeChildren = React.useMemo(() => {
|
||||
if (
|
||||
activeDocument?.isDraft &&
|
||||
@@ -148,24 +130,22 @@ function DocumentLink(
|
||||
ref={ref}
|
||||
isActive={() => !!isActiveDocument}
|
||||
/>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
{expanded &&
|
||||
nodeChildren.map((childNode, index) => (
|
||||
<SharedDocumentLink
|
||||
shareId={shareId}
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
activeDocumentId={activeDocumentId}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</SidebarDisclosureContext.Provider>
|
||||
{expanded &&
|
||||
nodeChildren.map((childNode, index) => (
|
||||
<SharedDocumentLink
|
||||
shareId={shareId}
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
activeDocumentId={activeDocumentId}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
useDropToReorderUserMembership,
|
||||
useDropToReparentDocument,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import SidebarExpansionContext, {
|
||||
useSidebarExpansionState,
|
||||
} from "./SidebarExpansionContext";
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DocumentRow from "./DocumentRow";
|
||||
@@ -41,6 +44,11 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
const sidebarContext = useSidebarContext();
|
||||
const document = documentId ? documents.get(documentId) : undefined;
|
||||
|
||||
const membershipDocuments = membership.documents;
|
||||
const expansion = useSidebarExpansionState(
|
||||
membershipDocuments,
|
||||
ui.activeDocumentId
|
||||
);
|
||||
const isActiveDocumentInPath = ui.activeDocumentId
|
||||
? membership.pathToDocument(ui.activeDocumentId).length > 0
|
||||
: false;
|
||||
@@ -85,12 +93,25 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
const willExpand = !expanded;
|
||||
if (willExpand) {
|
||||
setExpanded();
|
||||
if (ev?.altKey && membershipDocuments) {
|
||||
expansion.expandAll(membershipDocuments);
|
||||
}
|
||||
} else {
|
||||
setCollapsed();
|
||||
if (ev?.altKey) {
|
||||
expansion.collapseAll();
|
||||
}
|
||||
}
|
||||
onDisclosureClick(willExpand, !!ev?.altKey);
|
||||
},
|
||||
[expanded, setExpanded, setCollapsed, onDisclosureClick]
|
||||
[
|
||||
expanded,
|
||||
setExpanded,
|
||||
setCollapsed,
|
||||
onDisclosureClick,
|
||||
expansion,
|
||||
membershipDocuments,
|
||||
]
|
||||
);
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -135,7 +156,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
const childDocuments = membership.documents ?? [];
|
||||
const childDocuments = membershipDocuments ?? [];
|
||||
const hasChildren = childDocuments.length > 0;
|
||||
|
||||
const unreadBadge =
|
||||
@@ -177,20 +198,22 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
isActiveOverride={isActive}
|
||||
>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((childNode, index) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
activeDocument={documents.active}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
<SidebarExpansionContext.Provider value={expansion}>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((childNode, index) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
activeDocument={documents.active}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</SidebarExpansionContext.Provider>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
{reorderProps.isDragging && (
|
||||
<DropCursor
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { action, observable } from "mobx";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
|
||||
/**
|
||||
* Computes the set of node IDs along the path from any node in `roots` down
|
||||
* to a node with `targetId`, inclusive of both endpoints. Returns an empty
|
||||
* array when no path exists.
|
||||
*
|
||||
* @param roots the top-level navigation nodes to search through.
|
||||
* @param targetId the id of the target document.
|
||||
* @returns array of ancestor IDs (inclusive of the target).
|
||||
*/
|
||||
function computeAncestorPath(
|
||||
roots: NavigationNode[],
|
||||
targetId: string
|
||||
): string[] {
|
||||
const stack: string[] = [];
|
||||
let found = false;
|
||||
const search = (nodes: NavigationNode[]): boolean => {
|
||||
for (const node of nodes) {
|
||||
stack.push(node.id);
|
||||
if (node.id === targetId) {
|
||||
found = true;
|
||||
return true;
|
||||
}
|
||||
if (node.children.length && search(node.children)) {
|
||||
return true;
|
||||
}
|
||||
stack.pop();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
search(roots);
|
||||
return found ? stack : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the set of expanded node IDs for a sidebar document tree.
|
||||
*
|
||||
* Uses a MobX ObservableSet so that individual `observer`-wrapped
|
||||
* DocumentLinks only re-render when their own node's membership in the set
|
||||
* changes, rather than on every expansion toggle anywhere in the tree.
|
||||
*/
|
||||
export class SidebarExpansionState {
|
||||
@observable
|
||||
expandedIds = new Set<string>();
|
||||
|
||||
/**
|
||||
* Whether a given node is currently expanded.
|
||||
*
|
||||
* @param nodeId the id of the node to check.
|
||||
* @returns true if the node is expanded.
|
||||
*/
|
||||
isExpanded(nodeId: string): boolean {
|
||||
return this.expandedIds.has(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a single node.
|
||||
*
|
||||
* @param nodeId the id of the node to expand.
|
||||
*/
|
||||
@action
|
||||
expand(nodeId: string): void {
|
||||
this.expandedIds.add(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a single node.
|
||||
*
|
||||
* @param nodeId the id of the node to collapse.
|
||||
*/
|
||||
@action
|
||||
collapse(nodeId: string): void {
|
||||
this.expandedIds.delete(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a node and all of its descendants recursively.
|
||||
*
|
||||
* @param node the root NavigationNode to expand.
|
||||
*/
|
||||
@action
|
||||
expandDescendants(node: NavigationNode): void {
|
||||
const walk = (n: NavigationNode) => {
|
||||
this.expandedIds.add(n.id);
|
||||
for (const child of n.children) {
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a node and all of its descendants recursively.
|
||||
*
|
||||
* @param node the root NavigationNode to collapse.
|
||||
*/
|
||||
@action
|
||||
collapseDescendants(node: NavigationNode): void {
|
||||
const walk = (n: NavigationNode) => {
|
||||
this.expandedIds.delete(n.id);
|
||||
for (const child of n.children) {
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand all nodes along a path (e.g. ancestors of the active document).
|
||||
*
|
||||
* @param ids the node IDs to expand.
|
||||
*/
|
||||
@action
|
||||
expandPath(ids: Iterable<string>): void {
|
||||
for (const id of ids) {
|
||||
this.expandedIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand every node in the given roots, recursively.
|
||||
*
|
||||
* @param roots the top-level navigation nodes.
|
||||
*/
|
||||
@action
|
||||
expandAll(roots: NavigationNode[]): void {
|
||||
const walk = (nodes: NavigationNode[]) => {
|
||||
for (const node of nodes) {
|
||||
this.expandedIds.add(node.id);
|
||||
walk(node.children);
|
||||
}
|
||||
};
|
||||
walk(roots);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse every node by clearing the set.
|
||||
*/
|
||||
@action
|
||||
collapseAll(): void {
|
||||
this.expandedIds.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for providing a SidebarExpansionState to descendant sidebar
|
||||
* components. Each document tree root (collection, starred doc, shared
|
||||
* membership) creates its own instance so expansion state is scoped.
|
||||
*/
|
||||
const SidebarExpansionContext = createContext<SidebarExpansionState | null>(
|
||||
null
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook to consume the nearest SidebarExpansionState from context.
|
||||
*
|
||||
* @returns the expansion state instance.
|
||||
*/
|
||||
export function useSidebarExpansion(): SidebarExpansionState {
|
||||
const ctx = useContext(SidebarExpansionContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useSidebarExpansion must be used within a SidebarExpansionContext.Provider"
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that creates a SidebarExpansionState and auto-expands the path
|
||||
* to the active document whenever it changes. Returns the state instance
|
||||
* to be provided via SidebarExpansionContext.Provider.
|
||||
*
|
||||
* @param roots the top-level navigation nodes (e.g. collection documents).
|
||||
* @param activeDocumentId the currently active document ID.
|
||||
* @returns the expansion state instance.
|
||||
*/
|
||||
export function useSidebarExpansionState(
|
||||
roots: NavigationNode[] | undefined,
|
||||
activeDocumentId: string | undefined
|
||||
): SidebarExpansionState {
|
||||
const [state] = useState(() => new SidebarExpansionState());
|
||||
|
||||
useEffect(() => {
|
||||
if (!roots || !activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const path = computeAncestorPath(roots, activeDocumentId);
|
||||
if (path.length > 0) {
|
||||
state.expandPath(path);
|
||||
}
|
||||
}, [state, roots, activeDocumentId]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export default SidebarExpansionContext;
|
||||
@@ -28,6 +28,9 @@ import {
|
||||
useDropToReorderStar,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import SidebarExpansionContext, {
|
||||
useSidebarExpansionState,
|
||||
} from "./SidebarExpansionContext";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import CollectionRow from "./CollectionRow";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
@@ -38,6 +41,7 @@ import Relative from "./Relative";
|
||||
import type { SidebarContextType } from "./SidebarContext";
|
||||
import SidebarContext, { starredSidebarContext } from "./SidebarContext";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosure,
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
|
||||
@@ -101,6 +105,22 @@ const StarredDocumentLink = observer(function StarredDocumentLink({
|
||||
: [];
|
||||
const hasChildDocuments = childDocuments.length > 0;
|
||||
const displayChildDocuments = expanded && !isDragging;
|
||||
const expansion = useSidebarExpansionState(
|
||||
childDocuments,
|
||||
documents.active?.id
|
||||
);
|
||||
|
||||
const handleCascadeExpand = React.useCallback(() => {
|
||||
if (childDocuments.length) {
|
||||
expansion.expandAll(childDocuments);
|
||||
}
|
||||
}, [expansion, childDocuments]);
|
||||
|
||||
const handleCascadeCollapse = React.useCallback(() => {
|
||||
expansion.collapseAll();
|
||||
}, [expansion]);
|
||||
|
||||
useSidebarDisclosure(handleCascadeExpand, handleCascadeCollapse);
|
||||
|
||||
const handleRename = React.useCallback(() => {
|
||||
editableTitleRef.current?.setIsEditing(true);
|
||||
@@ -199,24 +219,26 @@ const StarredDocumentLink = observer(function StarredDocumentLink({
|
||||
onClickIntent={handlePrefetch}
|
||||
>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={documentCollection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
parentId={document.id}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
{cursor}
|
||||
</Relative>
|
||||
<SidebarExpansionContext.Provider value={expansion}>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={documentCollection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
parentId={document.id}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarExpansionContext.Provider>
|
||||
</SidebarContext.Provider>
|
||||
</DocumentRow>
|
||||
</Draggable>
|
||||
|
||||
Reference in New Issue
Block a user