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:
Tom Moor
2026-04-29 19:28:53 -04:00
committed by GitHub
parent d2328b1763
commit bac2b01abd
7 changed files with 469 additions and 232 deletions
+30 -19
View File
@@ -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>