Add missing controls to starred documents (#12100)

* Add missing controls to starred documents

* refactor

* refactor

* fix: Enter does not submit

* fix: Reordering child docs in starred section

* refactor: Rename editTitle to labelText, remove non-null assertion

* Refactor draggable for consistency

* refactor

* Remove star icon

* fix: Allow drag and drop importing into starred

* tsc
This commit is contained in:
Tom Moor
2026-04-18 11:04:05 -04:00
committed by GitHub
parent 60562f4f6a
commit 267835ce6f
12 changed files with 1117 additions and 609 deletions
+1 -1
View File
@@ -110,7 +110,7 @@ function DocumentCard(props: Props) {
dir={document.dir}
$isDragging={isDragging}
to={{
pathname: document.url,
pathname: document.path,
state: {
title: document.titleWithDefault,
},
@@ -1,36 +1,22 @@
import type { Location } from "history";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { useHistory } from "react-router-dom";
import { UserPreference } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation, DocumentValidation } from "@shared/validations";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import type { RefHandle } from "~/components/EditableTitle";
import EditableTitle from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import CollectionMenu from "~/menus/CollectionMenu";
import useBoolean from "~/hooks/useBoolean";
import { documentEditPath } from "~/utils/routeHelpers";
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
import DropToImport from "./DropToImport";
import Relative from "./Relative";
import type { SidebarContextType } from "./SidebarContext";
import { useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
import { ActionContextProvider } from "~/hooks/useActionContext";
import CollectionLinkChildren from "./CollectionLinkChildren";
import CollectionRow from "./CollectionRow";
import { useSidebarContext } from "./SidebarContext";
type Props = {
collection: Collection;
@@ -51,20 +37,16 @@ const CollectionLink: React.FC<Props> = ({
onClick,
}: Props) => {
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [isEditing, setIsEditing] = React.useState(false);
const { documents } = useStores();
const history = useHistory();
const can = usePolicy(collection);
const { t } = useTranslation();
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
const editableTitleRef = React.useRef<RefHandle>(null);
const handleTitleChange = React.useCallback(
async (name: string) => {
await collection.save({
name,
});
await collection.save({ name });
},
[collection]
);
@@ -88,37 +70,26 @@ const CollectionLink: React.FC<Props> = ({
const handleRename = React.useCallback(() => {
editableTitleRef.current?.setIsEditing(true);
}, [editableTitleRef]);
const newChildTitleRef = React.useRef<RefHandle>(null);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
}, []);
const handleNewDoc = React.useCallback(
async (input) => {
try {
newChildTitleRef.current?.setIsEditing(false);
const newDocument = await documents.create(
{
collectionId: collection.id,
title: input,
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument);
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
} catch (_err) {
newChildTitleRef.current?.setIsEditing(true);
}
async (input: string) => {
const newDocument = await documents.create(
{
collectionId: collection.id,
title: input,
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument);
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
},
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
[user, sidebarContext, history, collection, documents]
);
const contextMenuAction = useCollectionMenuAction({
@@ -126,98 +97,44 @@ const CollectionLink: React.FC<Props> = ({
onRename: handleRename,
});
const menu = !isDraggingAnyCollection ? (
<CollectionMenu
collection={collection}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
) : undefined;
return (
<ActionContextProvider value={{ activeModels: [collection] }}>
<Relative ref={mergeRefs([parentRef, dropRef])}>
<DropToImport collectionId={collection.id}>
<SidebarLink
onClick={onClick}
to={{
pathname: collection.path,
state: { sidebarContext },
}}
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
$showActions={menuOpen}
isActiveDrop={isOver && canDrop}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={
<EditableTitle
title={collection.name}
onSubmit={handleTitleChange}
onEditing={setIsEditing}
canUpdate={can.update}
maxLength={CollectionValidation.maxNameLength}
ref={editableTitleRef}
/>
}
ellipsis={!isEditing}
exact={false}
depth={depth ? depth : 0}
menu={
!isEditing &&
!isDraggingAnyCollection && (
<Fade>
{can.createDocument && (
<Tooltip content={t("New doc")} delay={500}>
<NudeButton
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
handleExpand();
}}
>
<PlusIcon />
</NudeButton>
</Tooltip>
)}
<CollectionMenu
collection={collection}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
)
}
/>
</DropToImport>
</Relative>
<CollectionRow
collection={collection}
depth={depth}
to={{ pathname: collection.path, state: { sidebarContext } }}
onClick={onClick}
onClickIntent={handlePrefetch}
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onExpand={handleExpand}
canEdit={can.update}
labelText={collection.name}
onTitleChange={handleTitleChange}
editableTitleRef={editableTitleRef}
contextAction={contextMenuAction}
menu={menu}
menuOpen={menuOpen}
canCreateChild={!isDraggingAnyCollection && can.createDocument}
onCreateChild={handleNewDoc}
parentRef={parentRef}
dropRef={dropRef}
isActiveDropTarget={isOver && canDrop}
>
<CollectionLinkChildren
collection={collection}
expanded={!!expanded}
prefetchDocument={documents.prefetchDocument}
>
{isAddingNewChild ? (
<SidebarLink
depth={2}
isActive={() => true}
ellipsis={false}
label={
<EditableTitle
title=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
ref={newChildTitleRef}
/>
}
/>
) : undefined}
</CollectionLinkChildren>
</ActionContextProvider>
/>
</CollectionRow>
);
};
@@ -0,0 +1,260 @@
import type { Location, LocationDescriptor } from "history";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import type { ConnectDropTarget } from "react-dnd";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import type { match } from "react-router";
import { CollectionValidation, DocumentValidation } from "@shared/validations";
import type Collection from "~/models/Collection";
import EditableTitle, { type RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import { ActionContextProvider } from "~/hooks/useActionContext";
import DropToImport from "./DropToImport";
import Relative from "./Relative";
import SidebarLink from "./SidebarLink";
import type { SidebarContextType } from "./SidebarContext";
import { useSidebarContext } from "./SidebarContext";
import type { ActionWithChildren } from "~/types";
export type CollectionRowProps = {
/** Collection model for the row. */
collection: Collection;
/** Indentation depth of the row. */
depth?: number;
/** Navigation target for the row. */
to: LocationDescriptor;
/** Click handler for the row. */
onClick?: () => void;
/** Called on click intent for prefetching. */
onClickIntent?: () => void;
/** Optional override for the active-match function. */
isActiveOverride?: (
match: match | null,
location: Location<{ sidebarContext?: SidebarContextType }>
) => boolean;
/** Icon displayed to the left of the label. Defaults to CollectionIcon. */
icon?: React.ReactNode;
/** Whether the row is expanded. Pass undefined to hide the disclosure. */
expanded?: boolean;
/** Called when the disclosure caret toggles expansion. */
onDisclosureClick: (ev?: React.MouseEvent<HTMLElement>) => void;
/** Imperative expand, used by the "+" button to auto-expand. */
onExpand?: () => void;
/** When true, the name renders as an EditableTitle. */
canEdit?: boolean;
/** Title displayed and edited when canEdit is true. */
labelText?: string;
/** Submit handler for the edited title. */
onTitleChange?: (value: string) => Promise<void>;
/** Forwarded ref to the EditableTitle so the container can trigger rename. */
editableTitleRef?: React.Ref<RefHandle>;
/** Notifies the container when the inline title's editing state changes. */
onEditingChange?: (editing: boolean) => void;
/** Context menu action for the row. */
contextAction?: ActionWithChildren;
/** Menu content rendered by the container; wrapped in Fade. */
menu?: React.ReactNode;
/** Whether the menu's action slot is visible (e.g. while the menu is open). */
menuOpen?: boolean;
/** When true, the "+" new-child button is rendered in the menu slot. */
canCreateChild?: boolean;
/** Submit handler for the inline new-child title input. */
onCreateChild?: (title: string) => Promise<void>;
/** Depth of the inline new-child SidebarLink. Defaults to 2. */
newChildDepth?: number;
/** Ref forwarded to the outer Relative; for drag hover timers. */
parentRef?: React.Ref<HTMLDivElement>;
/** Drop target connector for "change collection" / reorder. */
dropRef?: ConnectDropTarget;
/** Whether the row is an active drop target (visual highlight). */
isActiveDropTarget?: boolean;
/** Content rendered after the row (e.g. CollectionLinkChildren). */
children?: React.ReactNode;
};
function CollectionRow({
collection,
depth = 0,
to,
onClick,
onClickIntent,
isActiveOverride,
icon,
expanded,
onDisclosureClick,
onExpand,
canEdit,
labelText,
onTitleChange,
editableTitleRef,
onEditingChange,
contextAction,
menu,
menuOpen,
canCreateChild,
onCreateChild,
newChildDepth = 2,
parentRef,
dropRef,
isActiveDropTarget,
children,
}: CollectionRowProps) {
const { t } = useTranslation();
const sidebarContext = useSidebarContext();
const [isEditing, setIsEditingState] = React.useState(false);
const setIsEditing = React.useCallback(
(editing: boolean) => {
setIsEditingState(editing);
onEditingChange?.(editing);
},
[onEditingChange]
);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
const newChildTitleRef = React.useRef<RefHandle>(null);
const handleAddChild = React.useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
setIsAddingNewChild();
onExpand?.();
},
[setIsAddingNewChild, onExpand]
);
const handleNewChildSubmit = React.useCallback(
async (value: string) => {
if (!onCreateChild) {
return;
}
try {
newChildTitleRef.current?.setIsEditing(false);
await onCreateChild(value);
closeAddingNewChild();
} catch (_err) {
newChildTitleRef.current?.setIsEditing(true);
}
},
[onCreateChild, closeAddingNewChild]
);
const defaultIsActive = React.useCallback(
(
_m: match | null,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!_m && location.state?.sidebarContext === sidebarContext,
[sidebarContext]
);
const labelElement = canEdit ? (
<EditableTitle
title={labelText ?? collection.name}
onSubmit={onTitleChange ?? (async () => undefined)}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canEdit}
maxLength={CollectionValidation.maxNameLength}
ref={editableTitleRef}
/>
) : (
collection.name
);
const iconElement = icon ?? (
<CollectionIcon collection={collection} expanded={expanded} />
);
const hasMenuContent = Boolean(menu) || canCreateChild;
const menuVisible = hasMenuContent && !isEditing;
const menuElement = menuVisible ? (
<Fade>
{canCreateChild && (
<Tooltip content={t("New doc")} delay={500}>
<NudeButton
aria-label={t("New nested document")}
onClick={handleAddChild}
>
<PlusIcon />
</NudeButton>
</Tooltip>
)}
{menu}
</Fade>
) : undefined;
const mergedRef = React.useMemo(
() =>
mergeRefs<HTMLDivElement>(
[parentRef, dropRef].filter(Boolean) as React.Ref<HTMLDivElement>[]
),
[parentRef, dropRef]
);
const sidebarLinkElement = (
<SidebarLink
// @ts-expect-error react-router type is wrong, string component is fine.
component={isEditing ? "div" : undefined}
depth={depth}
to={to}
onClick={onClick}
onClickIntent={onClickIntent}
contextAction={contextAction}
expanded={expanded}
onDisclosureClick={onDisclosureClick}
icon={iconElement}
isActive={isActiveOverride ?? defaultIsActive}
isActiveDrop={isActiveDropTarget}
label={labelElement}
ellipsis={!isEditing}
exact={false}
$showActions={menuOpen}
menu={menuElement}
/>
);
return (
<ActionContextProvider value={{ activeModels: [collection] }}>
<Relative ref={mergedRef}>
<DropToImport collectionId={collection.id}>
{sidebarLinkElement}
</DropToImport>
</Relative>
{isAddingNewChild && onCreateChild && (
<SidebarLink
isActive={() => true}
depth={newChildDepth}
ellipsis={false}
label={
<EditableTitle
title=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewChildSubmit}
maxLength={DocumentValidation.maxTitleLength}
ref={newChildTitleRef}
/>
}
/>
)}
{children}
</ActionContextProvider>
);
}
export default observer(CollectionRow);
@@ -1,25 +1,21 @@
import type { Location } from "history";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { UserPreference } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import type GroupMembership from "~/models/GroupMembership";
import type UserMembership from "~/models/UserMembership";
import type { RefHandle } from "~/components/EditableTitle";
import EditableTitle from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import DocumentMenu from "~/menus/DocumentMenu";
@@ -29,17 +25,11 @@ import {
useDropToReorderDocument,
useDropToReparentDocument,
} from "../hooks/useDragAndDrop";
import DocumentRow from "./DocumentRow";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import Folder from "./Folder";
import Relative from "./Relative";
import type { SidebarContextType } from "./SidebarContext";
import { useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import type UserMembership from "~/models/UserMembership";
import type GroupMembership from "~/models/GroupMembership";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import SidebarDisclosureContext, {
useSidebarDisclosure,
useSidebarDisclosureState,
@@ -57,20 +47,17 @@ type Props = {
parentId?: string;
};
function InnerDocumentLink(
{
node,
collection,
membership,
activeDocument,
prefetchDocument,
isDraft,
depth,
index,
parentId,
}: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const DocumentLink = observer(function DocumentLinkInner({
node,
collection,
membership,
activeDocument,
prefetchDocument,
isDraft,
depth,
index,
parentId,
}: Props) {
const { documents, policies } = useStores();
const { t } = useTranslation();
const history = useHistory();
@@ -123,11 +110,9 @@ function InnerDocumentLink(
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
// Context-based recursive expand/collapse for descendant DocumentLinks
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
// Subscribe to recursive expand/collapse events from an ancestor
useSidebarDisclosure(setExpanded, setCollapsed);
React.useEffect(() => {
@@ -136,7 +121,6 @@ function InnerDocumentLink(
}
}, [setExpanded, showChildren]);
// when the last child document is removed auto-close the local folder state
React.useEffect(() => {
if (expanded && !hasChildDocuments) {
setCollapsed();
@@ -144,14 +128,14 @@ function InnerDocumentLink(
}, [setCollapsed, expanded, hasChildDocuments]);
const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<HTMLElement>) => {
(ev?: React.MouseEvent<HTMLElement>) => {
const willExpand = !expanded;
if (willExpand) {
setExpanded();
} else {
setCollapsed();
}
onDisclosureClick(willExpand, ev.altKey);
onDisclosureClick(willExpand, !!ev?.altKey);
},
[setCollapsed, setExpanded, expanded, onDisclosureClick]
);
@@ -172,6 +156,7 @@ function InnerDocumentLink(
},
[documents, document]
);
const handleRename = React.useCallback(() => {
editableTitleRef.current?.setIsEditing(true);
}, []);
@@ -214,10 +199,9 @@ function InnerDocumentLink(
const iconElement = React.useMemo(
() =>
icon ? <Icon value={icon} color={color} initial={initial} /> : undefined,
[icon, color]
[icon, color, initial]
);
// Draggable
const [{ isDragging }, drag] = useDragDocument(
node,
depth,
@@ -225,12 +209,10 @@ function InnerDocumentLink(
isEditing
);
// Drop to re-parent
const parentRef = React.useRef<HTMLDivElement>(null);
const [{ isOverReparent, canDropToReparent }, dropToReparent] =
useDropToReparentDocument(node, setExpanded, parentRef);
// Drop to reorder
const [{ isOverReorder: isOverReorderAbove }, dropToReorderAbove] =
useDropToReorderDocument(node, collection, (item) => {
if (!collection) {
@@ -271,9 +253,6 @@ function InnerDocumentLink(
activeDocument?.parentDocumentId === node.id
);
// Only subscribe to asNavigationNode when this node is the parent of an
// active draft. This avoids every DocumentLink observer re-rendering on
// every title keystroke.
const draftNavNode = insertDraftChild
? activeDocument?.asNavigationNode
: undefined;
@@ -292,66 +271,38 @@ function InnerDocumentLink(
const doc = documents.get(node.id);
const title = doc?.title || node.title || t("Untitled");
const isExpanded = expanded && !isDragging;
const hasChildren = nodeChildren.length > 0;
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent) => {
if (!hasChildren) {
return;
}
if (ev.key === "ArrowRight" && !expanded) {
setExpanded();
}
if (ev.key === "ArrowLeft" && expanded) {
setCollapsed();
}
},
[setExpanded, setCollapsed, hasChildren, expanded]
);
const newChildTitleRef = React.useRef<RefHandle>(null);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
const handleNewDoc = React.useCallback(
async (input) => {
try {
newChildTitleRef.current?.setIsEditing(false);
const newDocument = await documents.create(
{
collectionId: collection?.id,
parentDocumentId: node.id,
fullWidth:
doc?.fullWidth ??
user.getPreference(UserPreference.FullWidthDocuments),
title: input,
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument, node.id);
membership?.addDocument(newDocument, node.id);
closeAddingNewChild();
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
} catch (_err) {
newChildTitleRef.current?.setIsEditing(true);
}
async (input: string) => {
const newDocument = await documents.create(
{
collectionId: collection?.id,
parentDocumentId: node.id,
fullWidth:
doc?.fullWidth ??
user.getPreference(UserPreference.FullWidthDocuments),
title: input,
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument, node.id);
membership?.addDocument(newDocument, node.id);
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
},
[
documents,
collection,
membership,
sidebarContext,
user,
node,
doc,
history,
closeAddingNewChild,
]
);
@@ -360,132 +311,66 @@ function InnerDocumentLink(
onRename: handleRename,
});
const labelElement = React.useMemo(
() => (
<EditableTitle
title={title}
onSubmit={handleTitleChange}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
ref={editableTitleRef}
const showMenuActions = !isDraggingAnyDocument;
const menu =
showMenuActions && document ? (
<DocumentMenu
document={document}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
),
[title, handleTitleChange, isEditing, setIsEditing, canUpdate]
);
) : undefined;
const menuElement = React.useMemo(
() =>
document && !isMoving && !isEditing && !isDraggingAnyDocument ? (
<Fade>
{can.createChildDocument && (
<Tooltip content={t("New doc")}>
<NudeButton
aria-label={t("New nested document")}
onClick={(ev) => {
ev.preventDefault();
setIsAddingNewChild();
setExpanded();
}}
>
<PlusIcon />
</NudeButton>
</Tooltip>
)}
<DocumentMenu
document={document}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined,
[
document,
isMoving,
isEditing,
isDraggingAnyDocument,
can.createChildDocument,
t,
setIsAddingNewChild,
setExpanded,
handleRename,
handleMenuOpen,
handleMenuClose,
]
);
const cursorBefore =
isDraggingAnyDocument && collection?.isManualSort && index === 0 ? (
<DropCursor
isActiveDrop={isOverReorderAbove}
innerRef={dropToReorderAbove}
position="top"
/>
) : undefined;
const cursorAfter =
isDraggingAnyDocument && collection?.isManualSort ? (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
) : undefined;
return (
<ActionContextProvider
value={{
activeModels: document ? [document] : [],
}}
<DocumentRow
documentId={node.id}
document={document}
to={toPath}
depth={depth}
isDraft={isDraft}
scrollIntoViewIfNeeded={sidebarContext === "collections"}
icon={iconElement}
canEdit={canUpdate}
labelText={title}
onTitleChange={handleTitleChange}
editableTitleRef={editableTitleRef}
onEditingChange={setIsEditing}
expanded={expanded && !isDragging}
hasChildren={hasChildren}
onDisclosureClick={handleDisclosureClick}
onExpand={setExpanded}
onCollapse={setCollapsed}
dragRef={drag}
isDragging={isDragging}
isMoving={isMoving}
parentRef={parentRef}
dropToReparentRef={dropToReparent}
isActiveDropTarget={isOverReparent && canDropToReparent}
cursorBefore={cursorBefore}
cursorAfter={cursorAfter}
menu={menu}
menuOpen={menuOpen}
canCreateChild={showMenuActions && can.createChildDocument}
onCreateChild={handleNewDoc}
contextAction={contextMenuAction}
isActiveOverride={isActiveCheck}
onClickIntent={handlePrefetch}
>
<Relative ref={parentRef}>
{isDraggingAnyDocument && collection?.isManualSort && index === 0 && (
<DropCursor
isActiveDrop={isOverReorderAbove}
innerRef={dropToReorderAbove}
position="top"
/>
)}
<Draggable
key={node.id}
ref={drag}
$isDragging={isDragging}
$isMoving={isMoving}
onKeyDown={handleKeyDown}
>
<div ref={dropToReparent}>
<DropToImport documentId={node.id}>
<SidebarLink
// @ts-expect-error react-router type is wrong, string component is fine.
component={isEditing ? "div" : undefined}
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
to={toPath}
icon={iconElement}
label={labelElement}
ellipsis={!isEditing}
isActive={isActiveCheck}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
$showActions={menuOpen}
scrollIntoViewIfNeeded={sidebarContext === "collections"}
isDraft={isDraft}
ref={ref}
menu={menuElement}
/>
</DropToImport>
</div>
</Draggable>
{isDraggingAnyDocument && collection?.isManualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Relative>
{isAddingNewChild && (
<SidebarLink
isActive={() => true}
depth={depth + 1}
ellipsis={false}
label={
<EditableTitle
title=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewDoc}
maxLength={DocumentValidation.maxTitleLength}
ref={newChildTitleRef}
/>
}
/>
)}
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<Folder expanded={expanded && !isDragging}>
{nodeChildren.map((childNode, childIndex) => (
@@ -504,16 +389,8 @@ function InnerDocumentLink(
))}
</Folder>
</SidebarDisclosureContext.Provider>
</ActionContextProvider>
</DocumentRow>
);
}
const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>`
transition: opacity 250ms ease;
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.1 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "inherit")};
`;
const DocumentLink = observer(React.forwardRef(InnerDocumentLink));
});
export default DocumentLink;
@@ -0,0 +1,336 @@
import type { Location, LocationDescriptor } from "history";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import type { ConnectDragSource } from "react-dnd";
import { useTranslation } from "react-i18next";
import type { match } from "react-router";
import styled from "styled-components";
import { DocumentValidation } from "@shared/validations";
import type Document from "~/models/Document";
import EditableTitle, { type RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import { ActionContextProvider } from "~/hooks/useActionContext";
import DropToImport from "./DropToImport";
import Relative from "./Relative";
import SidebarLink from "./SidebarLink";
import type { SidebarContextType } from "./SidebarContext";
import { useSidebarContext } from "./SidebarContext";
import type { ActionWithChildren } from "~/types";
export type DocumentRowProps = {
/** Document identifier for policy, prefetch and import. */
documentId: string;
/** Loaded document; used for editing title and active matching. */
document?: Document;
/** Navigation target for the row. */
to: LocationDescriptor;
/** Indentation depth of the row. */
depth: number;
/** Applies draft styling around the row. */
isDraft?: boolean;
/** Scroll this row into view when it becomes the active route. */
scrollIntoViewIfNeeded?: boolean;
/** Icon displayed to the left of the label. */
icon?: React.ReactNode;
/** Displays a small unread badge to the right of the label. */
unreadBadge?: boolean;
/** Whether inline title updates are allowed. */
canEdit?: boolean;
/** Static label content; when provided, it is rendered in preference to `labelText`. */
label?: React.ReactNode;
/** Label as a text string, for editing. */
labelText?: string;
/** Submit handler when title updates are allowed. */
onTitleChange?: (value: string) => Promise<void>;
/** Forwarded ref to the `EditableTitle` instance when it is rendered. */
editableTitleRef?: React.Ref<RefHandle>;
/** Notifies the container when the rendered inline title enters or exits editing mode. */
onEditingChange?: (editing: boolean) => void;
/** Whether the row is expanded. */
expanded: boolean;
/** Whether the row has any descendants (controls whether the disclosure renders). */
hasChildren: boolean;
/** Called when the disclosure caret or Alt+click toggles expansion. */
onDisclosureClick: (ev?: React.MouseEvent<HTMLElement>) => void;
/** Imperative expand, used by the "+" button and ArrowRight keydown. */
onExpand?: () => void;
/** Imperative collapse, used by ArrowLeft keydown. */
onCollapse?: () => void;
/** Drag source ref from the container's drag hook. */
dragRef?: ConnectDragSource;
/** Whether the row is being dragged. */
isDragging?: boolean;
/** Whether the row's document is being moved. */
isMoving?: boolean;
/** Ref to the outer Relative element; some drop hooks need to read it. */
parentRef?: React.Ref<HTMLDivElement>;
/** Ref for the row's reparent drop target. */
dropToReparentRef?: React.Ref<HTMLDivElement>;
/** Whether the row is an active drop target (visual highlight). */
isActiveDropTarget?: boolean;
/** Cursor element rendered above the row. */
cursorBefore?: React.ReactNode;
/** Cursor element rendered below the row. */
cursorAfter?: React.ReactNode;
/** Menu content rendered by the container. */
menu?: React.ReactNode;
/** Whether the menu's action slot is visible (e.g. while the menu is open). */
menuOpen?: boolean;
/** When true, the "+" new-child button is rendered in the menu slot. */
canCreateChild?: boolean;
/** Submit handler for the inline new-child title input. */
onCreateChild?: (title: string) => Promise<void>;
/** Depth of the inline new-child SidebarLink. Defaults to `depth + 1`. */
newChildDepth?: number;
/** Context menu action for the row. */
contextAction?: ActionWithChildren;
/** Optional override for the active-match function. */
isActiveOverride?: (
match: match | null,
location: Location<{ sidebarContext?: SidebarContextType }>
) => boolean;
/** Content rendered after the row (e.g. a Folder of nested child rows). */
children?: React.ReactNode;
/** Called on click intent for prefetching. */
onClickIntent?: () => void;
};
function DocumentRow({
documentId,
document,
to,
depth,
isDraft,
scrollIntoViewIfNeeded,
icon,
unreadBadge,
label,
canEdit,
labelText,
onTitleChange,
editableTitleRef,
onEditingChange,
expanded,
hasChildren,
onDisclosureClick,
onExpand,
onCollapse,
dragRef,
isDragging,
isMoving,
parentRef,
dropToReparentRef,
isActiveDropTarget,
cursorBefore,
cursorAfter,
menu,
menuOpen,
canCreateChild,
onCreateChild,
newChildDepth,
contextAction,
isActiveOverride,
children,
onClickIntent,
}: DocumentRowProps) {
const { t } = useTranslation();
const sidebarContext = useSidebarContext();
const [isEditing, setIsEditingState] = React.useState(false);
const setIsEditing = React.useCallback(
(editing: boolean) => {
setIsEditingState(editing);
onEditingChange?.(editing);
},
[onEditingChange]
);
const [isAddingNewChild, setIsAddingNewChild, closeAddingNewChild] =
useBoolean();
const newChildTitleRef = React.useRef<RefHandle>(null);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent) => {
if (!hasChildren) {
return;
}
if (ev.key === "ArrowRight" && !expanded) {
onExpand?.();
}
if (ev.key === "ArrowLeft" && expanded) {
onCollapse?.();
}
},
[hasChildren, expanded, onExpand, onCollapse]
);
const handleAddChild = React.useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
setIsAddingNewChild();
onExpand?.();
},
[setIsAddingNewChild, onExpand]
);
const handleNewChildSubmit = React.useCallback(
async (value: string) => {
if (!onCreateChild) {
return;
}
try {
newChildTitleRef.current?.setIsEditing(false);
await onCreateChild(value);
closeAddingNewChild();
} catch (_err) {
newChildTitleRef.current?.setIsEditing(true);
}
},
[onCreateChild, closeAddingNewChild]
);
const labelElement =
label ??
(labelText !== undefined ? (
<EditableTitle
title={labelText}
onSubmit={onTitleChange ?? (async () => undefined)}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={!!canEdit}
maxLength={DocumentValidation.maxTitleLength}
ref={editableTitleRef}
/>
) : null);
const hasMenuContent = Boolean(menu) || canCreateChild;
const menuVisible = hasMenuContent && !isEditing && !isDragging && !isMoving;
const menuElement = menuVisible ? (
<Fade>
{canCreateChild && (
<Tooltip content={t("New doc")}>
<NudeButton
aria-label={t("New nested document")}
onClick={handleAddChild}
>
<PlusIcon />
</NudeButton>
</Tooltip>
)}
{menu}
</Fade>
) : undefined;
const defaultIsActive = React.useCallback(
(
m: match | null,
location: Location<{ sidebarContext?: SidebarContextType }>
) => {
if (sidebarContext !== location.state?.sidebarContext) {
return false;
}
return (document && location.pathname.endsWith(document.urlId)) || !!m;
},
[sidebarContext, document]
);
const sidebarLinkElement = (
<SidebarLink
// @ts-expect-error react-router type is wrong, string component is fine.
component={isEditing ? "div" : undefined}
depth={depth}
to={to}
expanded={hasChildren && !isDragging ? expanded : undefined}
onDisclosureClick={onDisclosureClick}
onClickIntent={onClickIntent}
contextAction={contextAction}
icon={icon}
isActive={isActiveOverride ?? defaultIsActive}
isActiveDrop={isActiveDropTarget}
label={labelElement}
ellipsis={!isEditing}
exact={false}
scrollIntoViewIfNeeded={scrollIntoViewIfNeeded}
isDraft={isDraft}
unreadBadge={unreadBadge}
$showActions={menuOpen}
menu={menuElement}
/>
);
const withImport = documentId ? (
<DropToImport documentId={documentId}>{sidebarLinkElement}</DropToImport>
) : (
sidebarLinkElement
);
return (
<ActionContextProvider
value={{
activeModels: document ? [document] : [],
}}
>
<Relative ref={parentRef}>
{cursorBefore}
<Draggable
key={documentId}
ref={dragRef}
$isDragging={isDragging}
$isMoving={isMoving}
onKeyDown={handleKeyDown}
>
{dropToReparentRef ? (
<div ref={dropToReparentRef}>{withImport}</div>
) : (
withImport
)}
</Draggable>
{cursorAfter}
</Relative>
{isAddingNewChild && onCreateChild && (
<SidebarLink
isActive={() => true}
depth={newChildDepth ?? depth + 1}
ellipsis={false}
label={
<EditableTitle
title=""
canUpdate
isEditing
placeholder={`${t("New doc")}`}
onCancel={closeAddingNewChild}
onSubmit={handleNewChildSubmit}
maxLength={DocumentValidation.maxTitleLength}
ref={newChildTitleRef}
/>
}
/>
)}
{children}
</ActionContextProvider>
);
}
const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>`
transition: opacity 250ms ease;
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.1 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "inherit")};
`;
export default observer(DocumentRow);
@@ -2,12 +2,10 @@ import fractionalIndex from "fractional-index";
import type { Location } from "history";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { IconType, NotificationEventType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import type GroupMembership from "~/models/GroupMembership";
import UserMembership from "~/models/UserMembership";
import Fade from "~/components/Fade";
import useBoolean from "~/hooks/useBoolean";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
@@ -19,15 +17,14 @@ import {
} from "../hooks/useDragAndDrop";
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
import DocumentLink from "./DocumentLink";
import DocumentRow from "./DocumentRow";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import Relative from "./Relative";
import SidebarDisclosureContext, {
useSidebarDisclosure,
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import { useSidebarContext, type SidebarContextType } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
type Props = {
membership: UserMembership | GroupMembership;
@@ -55,7 +52,6 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
const { event: disclosureEvent, onDisclosureClick } =
useSidebarDisclosureState();
// Subscribe to recursive expand/collapse events from an ancestor (e.g. GroupLink)
useSidebarDisclosure(setExpanded, setCollapsed);
React.useEffect(() => {
@@ -83,16 +79,16 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
}, [fetchChildDocuments, isActiveDocument, membership.documentId]);
const handleDisclosureClick = React.useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
ev.stopPropagation();
(ev?: React.MouseEvent<HTMLElement>) => {
ev?.preventDefault();
ev?.stopPropagation();
const willExpand = !expanded;
if (willExpand) {
setExpanded();
} else {
setCollapsed();
}
onDisclosureClick(willExpand, ev.altKey);
onDisclosureClick(willExpand, !!ev?.altKey);
},
[expanded, setExpanded, setCollapsed, onDisclosureClick]
);
@@ -118,107 +114,92 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
const [reorderProps, dropToReorderRef] =
useDropToReorderUserMembership(getIndex);
const isActive = React.useCallback(
(match, location: Location<{ sidebarContext?: SidebarContextType }>) =>
!!match && location.state?.sidebarContext === sidebarContext,
[sidebarContext]
);
const displayChildDocuments = expanded && !isDragging;
if (document) {
const { icon: docIcon } = document;
const label =
determineIconType(docIcon) === IconType.Emoji
? document.title.replace(docIcon!, "")
: document.titleWithDefault;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const childDocuments = membership.documents ?? [];
return (
<>
<Relative ref={parentRef}>
<Draggable
key={membership.id}
ref={draggableRef}
$isDragging={isDragging}
>
<div ref={dropToReparent}>
<SidebarLink
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
to={{
pathname: document.path,
state: { sidebarContext },
}}
expanded={
childDocuments.length > 0 && !isDragging
? expanded
: undefined
}
onDisclosureClick={handleDisclosureClick}
icon={icon}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) =>
!!match && location.state?.sidebarContext === sidebarContext
}
label={label}
exact={false}
unreadBadge={
document.unreadNotifications.filter(
(notification) =>
notification.event ===
NotificationEventType.AddUserToDocument
).length > 0
}
$showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
</div>
</Draggable>
</Relative>
<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>
</SidebarDisclosureContext.Provider>
{reorderProps.isDragging && (
<DropCursor
isActiveDrop={reorderProps.isOverCursor}
innerRef={dropToReorderRef}
/>
)}
</>
);
if (!document) {
return null;
}
return null;
const { icon: docIcon } = document;
const label =
determineIconType(docIcon) === IconType.Emoji
? document.title.replace(docIcon!, "")
: document.titleWithDefault;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const childDocuments = membership.documents ?? [];
const hasChildren = childDocuments.length > 0;
const unreadBadge =
document.unreadNotifications.filter(
(notification) =>
notification.event === NotificationEventType.AddUserToDocument
).length > 0;
const menu = !isDragging ? (
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
) : undefined;
return (
<DocumentRow
documentId={documentId ?? ""}
document={document}
to={{ pathname: document.path, state: { sidebarContext } }}
depth={depth}
icon={icon}
canEdit={false}
label={label}
unreadBadge={unreadBadge}
expanded={expanded && !isDragging}
hasChildren={hasChildren}
onDisclosureClick={handleDisclosureClick}
onExpand={setExpanded}
onCollapse={setCollapsed}
dragRef={draggableRef}
isDragging={isDragging}
parentRef={parentRef}
dropToReparentRef={dropToReparent}
isActiveDropTarget={isOverReparent && canDropToReparent}
menu={menu}
menuOpen={menuOpen}
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>
</SidebarDisclosureContext.Provider>
{reorderProps.isDragging && (
<DropCursor
isActiveDrop={reorderProps.isOverCursor}
innerRef={dropToReorderRef}
/>
)}
</DocumentRow>
);
}
const Draggable = styled.div<{ $isDragging?: boolean }>`
position: relative;
transition: opacity 250ms ease;
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
`;
export default observer(SharedWithMeLink);
+262 -116
View File
@@ -1,36 +1,45 @@
import fractionalIndex from "fractional-index";
import type { Location } from "history";
import { observer } from "mobx-react";
import { StarredIcon } from "outline-icons";
import * as React from "react";
import { useEffect, useState } from "react";
import styled, { useTheme } from "styled-components";
import { useHistory } from "react-router-dom";
import styled from "styled-components";
import { UserPreference } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import type Star from "~/models/Star";
import Fade from "~/components/Fade";
import type { RefHandle } from "~/components/EditableTitle";
import useBoolean from "~/hooks/useBoolean";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import CollectionMenu from "~/menus/CollectionMenu";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentEditPath } from "~/utils/routeHelpers";
import {
useDragStar,
useDropToChangeCollection,
useDropToCreateStar,
useDropToReorderStar,
} from "../hooks/useDragAndDrop";
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
import CollectionLink from "./CollectionLink";
import CollectionLinkChildren from "./CollectionLinkChildren";
import CollectionRow from "./CollectionRow";
import DocumentLink from "./DocumentLink";
import SidebarDisclosureContext, {
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
import DocumentRow from "./DocumentRow";
import DropCursor from "./DropCursor";
import Folder from "./Folder";
import Relative from "./Relative";
import type { SidebarContextType } from "./SidebarContext";
import SidebarContext, { starredSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { type ConnectDragSource } from "react-dnd";
import SidebarDisclosureContext, {
useSidebarDisclosureState,
} from "./SidebarDisclosureContext";
type Props = {
star: Star;
@@ -38,152 +47,291 @@ type Props = {
type StarredDocumentLinkProps = {
star: Star;
documentId: string;
document: Document;
expanded: boolean;
sidebarContext: SidebarContextType;
isDragging: boolean;
handleDisclosureClick: React.MouseEventHandler<HTMLElement>;
handleDisclosureClick: (ev?: React.MouseEvent<HTMLElement>) => void;
handlePrefetch: () => void;
onExpand: () => void;
onCollapse: () => void;
icon: React.ReactNode;
label: React.ReactNode;
menuOpen: boolean;
handleMenuOpen: () => void;
handleMenuClose: () => void;
draggableRef: ConnectDragSource;
cursor: React.ReactNode;
};
type StarredCollectionLinkProps = {
star: Star;
collection: any;
collection: Collection;
expanded: boolean;
sidebarContext: SidebarContextType;
isDragging: boolean;
handleDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
draggableRef: ConnectDragSource;
handleDisclosureClick: (ev?: React.MouseEvent<HTMLElement>) => void;
cursor: React.ReactNode;
displayChildDocuments: boolean;
reorderStarProps: any;
isDraggingAnyStar: boolean;
};
const StarredDocumentLink = observer(function StarredDocumentLink({
star,
documentId,
document,
expanded,
sidebarContext,
isDragging,
handleDisclosureClick,
handlePrefetch,
onExpand,
onCollapse,
icon,
label,
menuOpen,
handleMenuOpen,
handleMenuClose,
draggableRef,
cursor,
}: StarredDocumentLinkProps) {
const history = useHistory();
const user = useCurrentUser();
const { collections, documents } = useStores();
const can = usePolicy(document);
const editableTitleRef = React.useRef<RefHandle>(null);
const [{ isDragging }, draggableRef] = useDragStar(star);
const document = documents.get(documentId);
const documentCollection = document?.collectionId
const documentCollection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const childDocuments = documentCollection
? documentCollection.getChildrenForDocument(documentId)
? documentCollection.getChildrenForDocument(document.id)
: [];
const hasChildDocuments = childDocuments.length > 0;
const displayChildDocuments = expanded && !isDragging;
const contextMenuAction = useDocumentMenuAction({ documentId });
if (!document) {
return null;
}
const handleRename = React.useCallback(() => {
editableTitleRef.current?.setIsEditing(true);
}, []);
const handleTitleChange = React.useCallback(
async (value: string) => {
if (!document) {
return;
}
await documents.update({
id: document.id,
title: value,
});
},
[documents, document]
);
const handleNewDoc = React.useCallback(
async (input: string) => {
if (!document) {
return;
}
const newDocument = await documents.create(
{
collectionId: documentCollection?.id,
parentDocumentId: document.id,
fullWidth:
document.fullWidth ??
user.getPreference(UserPreference.FullWidthDocuments),
title: input,
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
documentCollection?.addDocument(newDocument, document.id);
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
},
[documents, document, documentCollection, sidebarContext, user, history]
);
const contextMenuAction = useDocumentMenuAction({
documentId: document.id,
onRename: handleRename,
});
const isActive = React.useCallback(
(match, location: Location<{ sidebarContext?: SidebarContextType }>) => {
if (location.state?.sidebarContext !== sidebarContext) {
return false;
}
return (
!!match || (!!document && location.pathname.endsWith(document.urlId))
);
},
[sidebarContext, document]
);
const menu = (
<DocumentMenu
document={document}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
);
return (
<ActionContextProvider
value={{
activeModels: [document],
}}
>
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
<SidebarLink
depth={0}
to={{
pathname: document.url,
state: { sidebarContext },
}}
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
contextAction={contextMenuAction}
icon={icon}
isActive={(
match,
location: Location<{ sidebarContext?: SidebarContextType }>
) => !!match && location.state?.sidebarContext === sidebarContext}
label={label}
exact={false}
$showActions={menuOpen}
menu={
document && !isDragging ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
<Draggable ref={draggableRef} $isDragging={isDragging}>
<DocumentRow
documentId={document.id}
document={document}
to={{ pathname: document.path, state: { sidebarContext } }}
depth={0}
icon={icon}
canEdit={can.update}
labelText={document.titleWithDefault}
onTitleChange={handleTitleChange}
editableTitleRef={editableTitleRef}
expanded={expanded}
hasChildren={hasChildDocuments}
onDisclosureClick={handleDisclosureClick}
onExpand={onExpand}
onCollapse={onCollapse}
isDragging={isDragging}
menu={menu}
menuOpen={menuOpen}
canCreateChild={can.createChildDocument}
onCreateChild={handleNewDoc}
newChildDepth={2}
contextAction={contextMenuAction}
isActiveOverride={isActive}
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}
/>
</Fade>
) : undefined
}
/>
</Draggable>
<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}
/>
))}
</Folder>
{cursor}
</Relative>
</SidebarContext.Provider>
</ActionContextProvider>
))}
</Folder>
{cursor}
</Relative>
</SidebarContext.Provider>
</DocumentRow>
</Draggable>
);
});
const StarredCollectionLink = observer(function StarredCollectionLink({
star,
collection,
expanded,
sidebarContext,
isDragging,
handleDisclosureClick,
draggableRef,
cursor,
displayChildDocuments,
reorderStarProps,
isDraggingAnyStar,
}: StarredCollectionLinkProps) {
const { documents } = useStores();
const history = useHistory();
const user = useCurrentUser();
const can = usePolicy(collection.id);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const editableTitleRef = React.useRef<RefHandle>(null);
const [{ isDragging }, draggableRef] = useDragStar(star);
const displayChildDocuments = expanded && !isDragging;
const handleTitleChange = React.useCallback(
async (name: string) => {
await collection.save({ name });
},
[collection]
);
const handleExpand = React.useCallback(() => {
if (!displayChildDocuments) {
handleDisclosureClick();
}
}, [displayChildDocuments, handleDisclosureClick]);
const parentRef = React.useRef<HTMLDivElement>(null);
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
collection,
handleExpand,
parentRef
);
const handleRename = React.useCallback(() => {
editableTitleRef.current?.setIsEditing(true);
}, []);
const handlePrefetch = React.useCallback(() => {
void collection.fetchDocuments();
}, [collection]);
const handleNewDoc = React.useCallback(
async (input: string) => {
const newDocument = await documents.create(
{
collectionId: collection.id,
title: input,
fullWidth: user.getPreference(UserPreference.FullWidthDocuments),
data: ProsemirrorHelper.getEmptyDocument(),
},
{ publish: true }
);
collection?.addDocument(newDocument);
history.push({
pathname: documentEditPath(newDocument),
state: { sidebarContext },
});
},
[user, sidebarContext, history, collection, documents]
);
const contextMenuAction = useCollectionMenuAction({
collectionId: collection.id,
onRename: handleRename,
});
const menu = !isDraggingAnyStar ? (
<CollectionMenu
collection={collection}
onRename={handleRename}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
) : undefined;
return (
<SidebarContext.Provider value={sidebarContext}>
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
<CollectionLink
<Draggable ref={draggableRef} $isDragging={isDragging}>
<CollectionRow
collection={collection}
to={{ pathname: collection.path, state: { sidebarContext } }}
expanded={isDragging ? undefined : displayChildDocuments}
activeDocument={documents.active}
onDisclosureClick={handleDisclosureClick}
isDraggingAnyCollection={reorderStarProps.isDragging}
/>
onExpand={handleExpand}
onClickIntent={handlePrefetch}
canEdit={can.update}
labelText={collection.name}
onTitleChange={handleTitleChange}
editableTitleRef={editableTitleRef}
contextAction={contextMenuAction}
menu={menu}
menuOpen={menuOpen}
canCreateChild={!isDraggingAnyStar && can.createDocument}
onCreateChild={handleNewDoc}
parentRef={parentRef}
dropRef={dropRef}
isActiveDropTarget={isOver && canDrop}
>
<CollectionLinkChildren
collection={collection}
expanded={displayChildDocuments}
prefetchDocument={documents.prefetchDocument}
/>
</CollectionRow>
</Draggable>
<Relative>{cursor}</Relative>
</SidebarContext.Provider>
@@ -191,11 +339,11 @@ const StarredCollectionLink = observer(function StarredCollectionLink({
});
function StarredLink({ star }: Props) {
const theme = useTheme();
const { ui, collections, documents } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const { documentId, collectionId } = star;
const collection = collectionId ? collections.get(collectionId) : undefined;
const document = documentId ? documents.get(documentId) : undefined;
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = starredSidebarContext(
star.documentId ?? star.collectionId ?? ""
@@ -250,6 +398,14 @@ function StarredLink({ star }: Props) {
[onDisclosureClick]
);
const handleExpand = React.useCallback(() => {
setExpanded(true);
}, []);
const handleCollapse = React.useCallback(() => {
setExpanded(false);
}, []);
const handlePrefetch = React.useCallback(() => {
if (documentId) {
void documents.prefetchDocument(documentId);
@@ -265,16 +421,10 @@ function StarredLink({ star }: Props) {
const next = star?.next();
return fractionalIndex(star?.index || null, next?.index || null);
};
const { label, icon } = useSidebarLabelAndIcon(
star,
<StarredIcon color={theme.yellow} />
);
const [{ isDragging }, draggableRef] = useDragStar(star);
const { icon } = useSidebarLabelAndIcon(star);
const [reorderStarProps, dropToReorderRef] = useDropToReorderStar(getIndex);
const [createStarProps, dropToStarRef] = useDropToCreateStar(getIndex);
const displayChildDocuments = expanded && !isDragging;
const cursor = (
<>
{reorderStarProps.isDragging && (
@@ -292,23 +442,22 @@ function StarredLink({ star }: Props) {
</>
);
if (documentId) {
if (document) {
return (
<SidebarDisclosureContext.Provider value={disclosureEvent}>
<StarredDocumentLink
star={star}
documentId={documentId}
document={document}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
handlePrefetch={handlePrefetch}
onExpand={handleExpand}
onCollapse={handleCollapse}
icon={icon}
label={label}
menuOpen={menuOpen}
handleMenuOpen={handleMenuOpen}
handleMenuClose={handleMenuClose}
draggableRef={draggableRef}
cursor={cursor}
/>
</SidebarDisclosureContext.Provider>
@@ -323,12 +472,9 @@ function StarredLink({ star }: Props) {
collection={collection}
expanded={expanded}
sidebarContext={sidebarContext}
isDragging={isDragging}
handleDisclosureClick={handleDisclosureClick}
draggableRef={draggableRef}
cursor={cursor}
displayChildDocuments={displayChildDocuments}
reorderStarProps={reorderStarProps}
isDraggingAnyStar={reorderStarProps.isDragging}
/>
</SidebarDisclosureContext.Provider>
);
@@ -1,12 +1,10 @@
import fractionalIndex from "fractional-index";
import { StarredIcon } from "outline-icons";
import * as React from "react";
import type { ConnectDragSource } from "react-dnd";
import { useDrag, useDrop } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import type Collection from "~/models/Collection";
@@ -68,11 +66,8 @@ export function useDragStar(
star: Star
): [{ isDragging: boolean }, ConnectDragSource] {
const id = star.id;
const theme = useTheme();
const { label: title, icon } = useSidebarLabelAndIcon(
star,
<StarredIcon color={theme.yellow} />
);
const { label: title, icon } = useSidebarLabelAndIcon(star);
const [{ isDragging }, draggableRef, preview] = useDrag({
type: "star",
item: () => ({ id, title, icon }),
@@ -495,21 +490,12 @@ export function useDragMembership(
const id = membership.id;
const { label: title, icon } = useSidebarLabelAndIcon(membership);
const [{ isDragging }, draggableRef, preview] = useDrag<
DragObject,
Promise<void>,
{ isDragging: boolean }
>({
const [{ isDragging }, draggableRef, preview] = useDrag({
type:
membership instanceof UserMembership
? "userMembership"
: "groupMembership",
item: () =>
({
id,
title,
icon,
}) as DragObject,
item: () => ({ id, title, icon }),
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
@@ -1,4 +1,4 @@
import { DocumentIcon } from "outline-icons";
import { DocumentIcon, QuestionMarkIcon } from "outline-icons";
import * as React from "react";
import Icon from "@shared/components/Icon";
import CollectionIcon from "~/components/Icons/CollectionIcon";
@@ -9,12 +9,12 @@ interface SidebarItem {
collectionId?: string;
}
export function useSidebarLabelAndIcon(
{ documentId, collectionId }: SidebarItem,
defaultIcon?: React.ReactNode
) {
export function useSidebarLabelAndIcon({
documentId,
collectionId,
}: SidebarItem) {
const { collections, documents } = useStores();
const icon = defaultIcon ?? <DocumentIcon />;
const icon = <QuestionMarkIcon />;
if (documentId) {
const document = documents.get(documentId);
@@ -28,7 +28,7 @@ export function useSidebarLabelAndIcon(
color={document.color ?? undefined}
/>
) : (
icon
<DocumentIcon outline={document.isDraft} />
),
};
}
+7 -2
View File
@@ -3,6 +3,7 @@ import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { useSidebarContext } from "~/components/Sidebar/components/SidebarContext";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
@@ -16,6 +17,7 @@ export default function useImportDocument(
isImporting: boolean;
} {
const { documents } = useStores();
const sidebarContext = useSidebarContext();
const [isImporting, setImporting] = useState(false);
const { t } = useTranslation();
const history = useHistory();
@@ -53,7 +55,10 @@ export default function useImportDocument(
});
if (redirect) {
history.push(documentPath(doc));
history.push({
pathname: documentPath(doc),
state: { sidebarContext },
});
}
} catch (err) {
toast.error(err.message);
@@ -68,7 +73,7 @@ export default function useImportDocument(
importingLock = false;
}
},
[t, documents, history, collectionId, documentId]
[t, documents, history, collectionId, sidebarContext, documentId]
);
return {
+1 -1
View File
@@ -281,7 +281,7 @@ class DocumentScene extends React.Component<Props> {
if (location.pathname.endsWith("history")) {
this.props.history.push({
pathname: document.url,
pathname: document.path,
state: { sidebarContext: this.props.location.state?.sidebarContext },
});
} else {
+1 -1
View File
@@ -500,9 +500,9 @@
"Expand sidebar": "Expand sidebar",
"Collapse sidebar": "Collapse sidebar",
"Archived collections": "Archived collections",
"Empty": "Empty",
"New doc": "New doc",
"New nested document": "New nested document",
"Empty": "Empty",
"No collections": "No collections",
"Collapse": "Collapse",
"Expand": "Expand",