mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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:
@@ -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);
|
||||
|
||||
@@ -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} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user