app/actions/definitions/documents.tsx
Rama main · réplica de lectura del repositorio original.
import copy from "copy-to-clipboard";
import type { TFunction } from "i18next";
import invariant from "invariant";
import { capitalize, uniqBy } from "es-toolkit/compat";
import {
DuplicateIcon,
StarredIcon,
PrintIcon,
UnstarredIcon,
NewDocumentIcon,
ShapesIcon,
ImportIcon,
PinIcon,
SearchIcon,
UnsubscribeIcon,
SubscribeIcon,
MoveIcon,
TrashIcon,
CrossIcon,
ArchiveIcon,
ShuffleIcon,
HistoryIcon,
GraphIcon,
HashtagIcon,
UnpublishIcon,
PublishIcon,
CommentIcon,
CopyIcon,
PadlockIcon,
GlobeIcon,
LogoutIcon,
CaseSensitiveIcon,
OrderedListIcon,
RestoreIcon,
EditIcon,
EmbedIcon,
OpenIcon,
SplitIcon,
ExportIcon,
CodeIcon,
PDFIcon,
} from "outline-icons";
import { toast } from "sonner";
import { showPublicLinkCopiedToast } from "~/components/Sharing/components/toasts";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import {
DocumentPreference,
ExportContentType,
HeadingPrefixStyle,
UserPreference,
} from "@shared/types";
import { isMobile } from "@shared/utils/browser";
import { Week } from "@shared/utils/time";
import type UserMembership from "~/models/UserMembership";
import Document from "~/models/Document";
import { client } from "~/utils/ApiClient";
import DocumentDelete from "~/scenes/DocumentDelete";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import { DialogTitle } from "~/components/DialogTitle";
import { DocumentArchiveDialog } from "~/components/DocumentArchiveDialog";
import DocumentCopy from "~/components/DocumentExplorer/DocumentCopy";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import { ImportDocumentDialog } from "~/components/ImportDocumentDialog";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
import {
ActionSeparator,
createAction,
createActionGroup,
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import {
dialogActionFactory,
everyActiveModel,
performBatch,
performBatchOnActiveModels,
} from "~/actions/definitions/common";
import {
ActiveDocumentSection,
DocumentSection,
SearchResultsSection,
TrashSection,
} from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
import {
documentHistoryPath,
homePath,
newDocumentPath,
newNestedDocumentPath,
newSiblingDocumentPath,
searchPath,
documentPath,
urlify,
desktopify,
trashPath,
documentEditPath,
} from "~/utils/routeHelpers";
import { getFocusedSplitPane, openRouteInSplit } from "~/utils/splitView";
import { recentDocuments } from "~/components/CommandBar/useRecentDocumentActions";
import { documentBreadcrumbText } from "~/components/DocumentBreadcrumb";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type {
Action,
ActionContext,
ActionGroup,
ActionSeparator as TActionSeparator,
} from "~/types";
import lazyWithRetry from "@shared/utils/lazyWithRetry";
import env from "~/env";
import { isMac, isWindows } from "@shared/utils/browser";
import isCloudHosted from "~/utils/isCloudHosted";
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
import { DocumentIcon } from "~/components/Icons/DocumentIcon";
/** Lazy insights dialog, exported so that triggers can preload it. */
export const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
);
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Document/SharePopover")
);
export const openDocument = createActionWithChildren({
name: ({ t }) => t("Open document"),
analyticsName: "Open document",
section: DocumentSection,
shortcut: ["o", "d"],
keywords: "go to",
icon: <DocumentIcon />,
children: ({ stores, activeDocumentId, t }) => {
const nodes = stores.collections.navigationNodes.reduce(
(acc, node) => [...acc, ...node.children],
[] as NavigationNode[]
);
const documents = stores.documents.orderedData;
// Documents already listed under "Recently viewed" are skipped so that they
// do not appear twice in the command bar.
const recentIds = new Set(
recentDocuments(stores.documents.recentlyViewed, activeDocumentId).map(
(document) => document.id
)
);
return uniqBy([...documents, ...nodes], "id")
.filter((item) => !recentIds.has(item.id))
.map((item) => {
const document = stores.documents.get(item.id);
const path = item instanceof Document ? item.path : item.url;
return createInternalLinkAction({
// Note: using path which includes the slug rather than id here to bust
// cache if the document is renamed
id: path,
name: item.title,
description: document
? documentBreadcrumbText(document, t)
: undefined,
icon: item.icon ? (
<Icon
value={item.icon}
initial={item.title}
color={item.color ?? undefined}
/>
) : (
<DocumentIcon empty={document?.isEmpty} />
),
section: DocumentSection,
to: path,
});
});
},
});
export const editDocument = createInternalLinkAction({
name: ({ t }) => t("Edit"),
analyticsName: "Edit document",
section: ActiveDocumentSection,
keywords: "edit",
icon: <EditIcon />,
visible: ({ activeDocumentId, stores }) => {
const { auth, policies } = stores;
const can = activeDocumentId
? policies.abilities(activeDocumentId)
: undefined;
return !!can?.update && !!auth.user?.separateEditMode;
},
to: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
return documentEditPath(document);
},
});
export const createDocument = createInternalLinkAction({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (
activeCollectionId &&
!stores.policies.abilities(activeCollectionId).createDocument
) {
return false;
}
return (
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
);
},
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const createDraftDocument = createInternalLinkAction({
name: ({ t }) => t("New draft"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create document",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
to: ({ sidebarContext }) => ({
pathname: newDocumentPath(),
state: { sidebarContext },
}),
});
/**
* Finds the index of a document among its siblings in the collection tree.
*
* @param stores - the root stores.
* @param document - the document to find the index of.
* @returns the index of the document among its siblings, or -1 if not found.
*/
function findDocumentSiblingIndex(
stores: ActionContext["stores"],
document: {
id: string;
collectionId?: string | null;
parentDocumentId?: string;
}
): number {
if (!document.collectionId) {
return -1;
}
const collection = stores.collections.get(document.collectionId);
if (!collection) {
return -1;
}
const siblings = document.parentDocumentId
? collection.getChildrenForDocument(document.parentDocumentId)
: collection.sortedDocuments;
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
}
/**
* Determines whether the user can create a sibling of the given document.
* A sibling shares the document's parent, so this mirrors the backend's
* create authorization: create permission on the parent document, or on the
* collection when the document is at the root.
*
* @param stores - the root stores.
* @param document - the document to create a sibling of.
* @returns true if the user can create a sibling.
*/
function canCreateSiblingDocument(
stores: ActionContext["stores"],
document: Document
): boolean {
if (document.isDeleted) {
return false;
}
return document.parentDocumentId
? stores.policies.abilities(document.parentDocumentId).createChildDocument
: !!document.collectionId &&
stores.policies.abilities(document.collectionId).createDocument;
}
export const createNestedDocument = createInternalLinkAction({
name: ({ t }) => t("Nested document"),
analyticsName: "New document",
section: ActiveDocumentSection,
keywords: "create nested",
visible: ({ currentTeamId, activeDocumentId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
stores.policies.abilities(currentTeamId).createDocument &&
stores.policies.abilities(activeDocumentId).createChildDocument,
to: ({ activeDocumentId, sidebarContext }) => {
const [pathname, search] =
newNestedDocumentPath(activeDocumentId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
const createDocumentBefore = createInternalLinkAction({
name: ({ t }) => t("Before"),
analyticsName: "New document before",
section: ActiveDocumentSection,
keywords: "create before",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document?.collectionId) {
return false;
}
const collection = stores.collections.get(document.collectionId);
if (collection?.sort.field === "title") {
return false;
}
return canCreateSiblingDocument(stores, document);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const index = findDocumentSiblingIndex(stores, document);
const [pathname, search] = newSiblingDocumentPath({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
index: Math.max(0, index),
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
const createDocumentAfter = createInternalLinkAction({
name: ({ t }) => t("After"),
analyticsName: "New document after",
section: ActiveDocumentSection,
keywords: "create after",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document?.collectionId) {
return false;
}
const collection = stores.collections.get(document.collectionId);
if (collection?.sort.field === "title") {
return false;
}
return canCreateSiblingDocument(stores, document);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const index = findDocumentSiblingIndex(stores, document);
const [pathname, search] = newSiblingDocumentPath({
collectionId: document.collectionId,
parentDocumentId: document.parentDocumentId,
index: index + 1,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
function isAlphabeticallySorted(
stores: ActionContext["stores"],
activeDocumentId: string
): boolean {
const document = stores.documents.get(activeDocumentId);
if (!document?.collectionId) {
return false;
}
const collection = stores.collections.get(document.collectionId);
return collection?.sort.field === "title";
}
export const createNewDocument = createActionWithChildren({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!activeDocumentId || !currentTeamId) {
return false;
}
if (!stores.policies.abilities(currentTeamId).createDocument) {
return false;
}
if (stores.documents.get(activeDocumentId)?.isDeleted) {
return false;
}
return !isAlphabeticallySorted(stores, activeDocumentId);
},
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
});
export const createNewDocumentInAlphabeticalCollection =
createInternalLinkAction({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveDocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) => {
if (!activeDocumentId || !currentTeamId) {
return false;
}
if (!stores.policies.abilities(currentTeamId).createDocument) {
return false;
}
if (!stores.policies.abilities(activeDocumentId).createChildDocument) {
return false;
}
return isAlphabeticallySorted(stores, activeDocumentId);
},
to: ({ activeDocumentId, sidebarContext }) => {
const [pathname, search] =
newNestedDocumentPath(activeDocumentId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
section: ActiveDocumentSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: (context) =>
everyActiveModel(
context,
Document,
(document) =>
!document.isStarred &&
!document.isDeleted &&
context.stores.policies.abilities(document.id).star
),
perform: async (context) => {
await performBatchOnActiveModels(
context,
Document,
(document) => document.star(),
(documents, succeeded, t) =>
documents.length > 1
? t("{{ count }} document starred", { count: succeeded })
: undefined
);
setPersistedState(getHeaderExpandedKey("starred"), true);
},
});
export const unstarDocument = createAction({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar document",
section: ActiveDocumentSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: (context) =>
everyActiveModel(
context,
Document,
(document) =>
document.isStarred &&
context.stores.policies.abilities(document.id).unstar
),
perform: (context) =>
performBatchOnActiveModels(
context,
Document,
(document) => document.unstar(),
(documents, succeeded, t) =>
documents.length > 1
? t("{{ count }} document unstarred", { count: succeeded })
: undefined
),
});
export const publishDocument = createAction({
name: ({ t }) => t("Publish"),
analyticsName: "Publish document",
section: ActiveDocumentSection,
icon: <PublishIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isDraft && stores.policies.abilities(activeDocumentId).publish
);
},
perform: async ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (document?.publishedAt) {
return;
}
if (document?.collectionId) {
await document.save(undefined, {
publish: true,
});
toast.success(
t("Published {{ documentName }}", {
documentName: document.noun,
})
);
} else if (document) {
stores.dialogs.openModal({
title: <DialogTitle title={t("Publish document")} model={document} />,
content: <DocumentPublish document={document} />,
});
}
},
});
export const unpublishDocument = createAction({
name: ({ t }) => t("Unpublish"),
analyticsName: "Unpublish document",
section: ActiveDocumentSection,
icon: <UnpublishIcon />,
visible: (context) =>
everyActiveModel(
context,
Document,
(document) => !!context.stores.policies.abilities(document.id).unpublish
),
perform: (context) =>
performBatchOnActiveModels(
context,
Document,
(document) => document.unpublish(),
(documents, succeeded, t) =>
documents.length === 1
? t("Unpublished {{ documentName }}", {
documentName: documents[0].noun,
})
: t("{{ count }} document unpublished", { count: succeeded })
),
});
export const subscribeDocument = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to document",
section: ActiveDocumentSection,
icon: <SubscribeIcon />,
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
if (!isMenu || !activeCollectionId) {
return undefined;
}
return stores.collections.get(activeCollectionId)?.isSubscribed
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isMenu, stores }) => {
if (!isMenu || !activeCollectionId) {
return false;
}
return !!stores.collections.get(activeCollectionId)?.isSubscribed;
},
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isActive &&
!document?.collection?.isSubscribed &&
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
);
},
perform: async ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
await document?.subscribe();
toast.success(t("Subscribed to document notifications"));
},
});
export const unsubscribeDocument = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from document",
section: ActiveDocumentSection,
icon: <UnsubscribeIcon />,
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
if (!isMenu || !activeCollectionId) {
return undefined;
}
return stores.collections.get(activeCollectionId)?.isSubscribed
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isMenu, stores }) => {
if (!isMenu || !activeCollectionId) {
return false;
}
return !!stores.collections.get(activeCollectionId)?.isSubscribed;
},
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isActive &&
(!!document?.collection?.isSubscribed ||
(!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe))
);
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
if (!activeDocumentId || !currentUserId) {
return;
}
const document = stores.documents.get(activeDocumentId);
await document?.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
});
export const shareDocument = createAction({
name: ({ t }) => `${t("Permissions")}…`,
analyticsName: "Share document",
section: ActiveDocumentSection,
icon: <PadlockIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
return false;
}
const can = stores.policies.abilities(activeDocumentId);
return can.manageUsers || can.share;
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
if (!activeDocumentId || !currentUserId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: <DialogTitle title={t("Share document")} model={document} />,
content: (
<SharePopover
document={document}
onRequestClose={stores.dialogs.closeAllModals}
visible
/>
),
});
},
});
/** Shows a file extension at the end of menu items only. */
const fileExtensionHint =
(extension: string) =>
({ isMenu }: ActionContext) =>
isMenu ? extension : undefined;
export const downloadDocumentAsMarkdown = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Markdown") : t("Download as Markdown")),
analyticsName: "Download document as Markdown",
section: ActiveDocumentSection,
badge: fileExtensionHint(".md"),
keywords: "md markdown export download",
icon: <MarkdownIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: async ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
await document?.download({
contentType: ExportContentType.Markdown,
});
},
});
export const downloadDocumentAsHTML = createAction({
name: ({ t, isMenu }) => (isMenu ? t("HTML") : t("Download as HTML")),
analyticsName: "Download document as HTML",
section: ActiveDocumentSection,
badge: fileExtensionHint(".html"),
keywords: "xml html export download",
icon: <CodeIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: async ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
await document?.download({
contentType: ExportContentType.Html,
});
},
});
export const downloadDocumentAsTextBundle = createAction({
name: ({ t, isMenu }) =>
isMenu ? t("TextBundle") : t("Download as TextBundle"),
analyticsName: "Download document as TextBundle",
section: ActiveDocumentSection,
badge: fileExtensionHint(".textpack"),
keywords: "textbundle textpack bear ulysses export download",
icon: <ArchiveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: async ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
await document?.download({
contentType: ExportContentType.TextBundle,
});
},
});
export const downloadDocumentAsPDF = createAction({
name: ({ t, isMenu }) => (isMenu ? t("PDF") : t("Download as PDF")),
analyticsName: "Download document as PDF",
section: ActiveDocumentSection,
badge: fileExtensionHint(".pdf"),
keywords: "pdf export download",
icon: <PDFIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!(
activeDocumentId &&
stores.policies.abilities(activeDocumentId).download &&
env.PDF_EXPORT_ENABLED
),
perform: async ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
await document?.download({
contentType: ExportContentType.Pdf,
});
},
});
export const copyDocumentAsMarkdown = createAction({
name: ({ t }) => t("Copy as Markdown"),
section: ActiveDocumentSection,
keywords: "clipboard",
icon: <MarkdownIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: async ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
const res = await client.post("/documents.export", {
id: document.id,
signedUrls: Week.seconds, // 7 days (AWS S3 max for presigned URLs)
});
copy(res.data);
toast.success(t("Markdown copied to clipboard"));
}
},
});
export const copyDocumentAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
section: ActiveDocumentSection,
keywords: "clipboard",
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: async ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(ProsemirrorHelper.toPlainText(document));
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyDocumentShareLink = createAction({
name: ({ t }) => t("Copy public link"),
section: ActiveDocumentSection,
keywords: "clipboard share",
icon: <GlobeIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId &&
!!stores.shares.getByDocumentId(activeDocumentId)?.isActive,
perform: ({ stores, activeDocumentId, t }) => {
if (!activeDocumentId) {
return;
}
const share = stores.shares.getByDocumentId(activeDocumentId);
if (share) {
copy(share.url);
showPublicLinkCopiedToast(share, t);
}
},
});
export const copyDocumentLink = createAction({
name: ({ t }) => t("Copy link"),
section: ActiveDocumentSection,
keywords: "clipboard",
icon: <CopyIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId }) => !!activeDocumentId,
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(urlify(documentPath(document)));
toast.success(t("Link copied to clipboard"));
}
},
});
export const copyDocument = createActionWithChildren({
name: ({ t }) => t("Copy"),
analyticsName: "Copy document",
section: ActiveDocumentSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
],
});
export const duplicateDocument = createAction({
name: ({ t, isMenu }) =>
isMenu ? `${t("Duplicate")}…` : t("Duplicate document"),
analyticsName: "Duplicate document",
section: ActiveDocumentSection,
icon: <DuplicateIcon />,
keywords: "copy",
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).duplicate,
perform: async ({ activeDocumentId, t, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
invariant(document, "Document must exist");
stores.dialogs.openModal({
title: <DialogTitle title={t("Duplicate document")} model={document} />,
content: (
<DocumentCopy
document={document}
onSubmit={(response) => {
stores.dialogs.closeAllModals();
history.push(documentPath(response[0]));
}}
/>
),
});
},
});
function pinToCollectionName({ getActiveModels, t, stores }: ActionContext) {
const documents = getActiveModels(Document);
if (documents.length === 1) {
const collectionName = stores.documents.getCollectionForDocument(
documents[0]
)?.name;
return t("Pin to {{collectionName}}", {
collectionName: collectionName ?? t("collection"),
});
}
return t("Pin");
}
/**
* Pin a document to a collection. Pinned documents will be displayed at the top
* of the collection for all collection members to see.
*/
export const pinDocumentToCollection = createAction({
name: pinToCollectionName,
analyticsName: "Pin document to collection",
section: ActiveDocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
visible: (context) =>
everyActiveModel(
context,
Document,
(document) =>
!!document.collectionId &&
!document.pinned &&
!!context.stores.policies.abilities(document.id).pin
),
perform: (context) =>
performBatchOnActiveModels(
context,
Document,
(document) => document.pin(document.collectionId),
(documents, succeeded, t) =>
documents.length === 1
? t("Pinned to collection")
: t("{{ count }} document pinned", { count: succeeded })
),
});
/**
* Pin a document to team home. Pinned documents will be displayed at the top
* of the home screen for all team members to see.
*/
export const pinDocumentToHome = createAction({
name: ({ t }) => t("Pin to home"),
analyticsName: "Pin document to home",
section: ActiveDocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, currentTeamId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!stores.policies.abilities(activeDocumentId).pinToHome &&
!document?.pinnedToHome
);
},
perform: async ({ activeDocumentId, location, t, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
await document?.pin();
if (location.pathname !== homePath()) {
toast.success(t("Pinned to home"));
}
},
});
export const unpinDocument = createAction({
name: ({ t }) => t("Unpin"),
analyticsName: "Unpin document",
section: ActiveDocumentSection,
icon: <PinIcon />,
visible: (context) =>
everyActiveModel(
context,
Document,
(document) =>
document.pinned &&
!!context.stores.policies.abilities(document.id).unpin
),
perform: (context) =>
performBatchOnActiveModels(
context,
Document,
(document) => document.unpin(document.collectionId ?? undefined),
(documents, succeeded, t) =>
documents.length === 1
? t("Unpinned")
: t("{{ count }} document unpinned", { count: succeeded })
),
});
const allPinnedToCollection = (context: ActionContext) =>
everyActiveModel(context, Document, (document) => document.pinned);
const nonePinnedToCollection = (context: ActionContext) =>
everyActiveModel(context, Document, (document) => !document.pinned);
/**
* Toggle whether a document is pinned to its collection, the current state is
* reflected in the item so the label does not change between the two.
*/
export const togglePinDocumentToCollection = createAction({
name: pinToCollectionName,
analyticsName: "Toggle pin document to collection",
section: ActiveDocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
selected: allPinnedToCollection,
visible: (context) =>
// A mixed selection has no single state to toggle to, the one-way Pin and
// Unpin actions cover that case instead.
(allPinnedToCollection(context) || nonePinnedToCollection(context)) &&
everyActiveModel(context, Document, (document) => {
const can = context.stores.policies.abilities(document.id);
return !!document.collectionId && (document.pinned ? can.unpin : can.pin);
}),
perform: (context) =>
allPinnedToCollection(context)
? unpinDocument.perform(context)
: pinDocumentToCollection.perform(context),
});
/**
* Toggle whether a document is pinned to team home, the current state is
* reflected in the item so the label does not change between the two.
*/
export const togglePinDocumentToHome = createAction({
name: ({ t }) => t("Pin to home"),
analyticsName: "Toggle pin document to home",
section: ActiveDocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
selected: ({ activeDocumentId, stores }) =>
!!activeDocumentId &&
!!stores.documents.get(activeDocumentId)?.pinnedToHome,
visible: ({ activeDocumentId, currentTeamId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
!!stores.policies.abilities(activeDocumentId).pinToHome,
perform: async (context) => {
const { activeDocumentId, location, t, stores } = context;
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document?.pinnedToHome) {
await pinDocumentToHome.perform(context);
return;
}
await document.unpin();
if (location.pathname !== homePath()) {
toast.success(t("Unpinned"));
}
},
});
export const pinDocument = createActionWithChildren({
name: ({ t }) => t("Pin"),
analyticsName: "Pin document",
section: ActiveDocumentSection,
icon: <PinIcon />,
children: [togglePinDocumentToCollection, togglePinDocumentToHome],
});
export const searchInDocument = createInternalLinkAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
shortcut: [`Meta+/`],
icon: <SearchIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return !!document?.isActive;
},
to: ({ activeDocumentId, sidebarContext }) => {
if (!activeDocumentId) {
return "";
}
const [pathname, search] = searchPath({
documentId: activeDocumentId,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const printDocument = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print document")),
analyticsName: "Print document",
section: ActiveDocumentSection,
shortcut: ["Meta+P"],
icon: <PrintIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
perform: () => {
setTimeout(window.print, 0);
},
});
export const exportDocument = createActionWithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Export") : t("Export document")),
analyticsName: "Export document",
section: ActiveDocumentSection,
icon: <ExportIcon />,
keywords: "download print pdf markdown html",
children: [
downloadDocumentAsMarkdown,
downloadDocumentAsHTML,
downloadDocumentAsTextBundle,
downloadDocumentAsPDF,
ActionSeparator,
printDocument,
],
});
export const openDocumentInDesktop = createAction({
name: ({ t }) => t("Open in desktop app"),
analyticsName: "Open in desktop",
section: ActiveDocumentSection,
icon: <OpenIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
isCloudHosted &&
(isMac || isWindows) &&
!!document &&
!document.isDeleted &&
!isMobile()
);
},
perform: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
window.location.href = desktopify(documentPath(document));
}
},
});
export const openDocumentInSplit = createAction({
name: ({ t }) => t("Open in split view"),
analyticsName: "Open document in split view",
section: ActiveDocumentSection,
icon: <SplitIcon />,
keywords: "split side pane",
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId || isMobile()) {
return false;
}
return !!stores.documents.get(activeDocumentId);
},
perform: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
openRouteInSplit(history, documentPath(document));
}
},
});
export const presentDocument = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Present") : t("Present document")),
analyticsName: "Present document",
section: ActiveDocumentSection,
icon: <EmbedIcon />,
shortcut: ["Control+Alt+KeyP"],
visible: ({ activeDocumentId }) => !!activeDocumentId && !isMobile(),
perform: ({ activeDocumentId, stores }) => {
if (stores.ui.presentationData) {
stores.ui.setPresentingDocument(null);
return;
}
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return;
}
stores.ui.setPresentingDocument(document);
},
});
/**
* Returns the document or collection that an import will be nested inside.
*
* @param context - the action context.
* @returns the parent model, if it is loaded.
*/
function getImportParent({
activeDocumentId,
activeCollectionId,
stores,
}: ActionContext) {
if (activeDocumentId) {
return stores.documents.get(activeDocumentId);
}
return activeCollectionId
? stores.collections.get(activeCollectionId)
: undefined;
}
export const importDocument = dialogActionFactory({
analyticsName: "Import document",
section: DocumentSection,
width: "640px",
icon: <ImportIcon />,
keywords: "upload",
name: (t) => `${t("Import documents")}…`,
title: (t, context) => (
<DialogTitle
title={t("Import documents")}
model={getImportParent(context)}
/>
),
content: (onSubmit, { activeDocumentId, activeCollectionId }) => (
<ImportDocumentDialog
documentId={activeDocumentId}
collectionId={activeCollectionId}
onSubmit={onSubmit}
/>
),
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (activeDocumentId) {
return !!stores.policies.abilities(activeDocumentId).createChildDocument;
}
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
}
return false;
},
});
export const createTemplateFromDocument = createAction({
name: ({ t }) => `${t("Templatize")}…`,
analyticsName: "Templatize document",
section: ActiveDocumentSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document?.isActive) {
return false;
}
return !!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createTemplate
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
event?.preventDefault();
event?.stopPropagation();
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: <DialogTitle title={t("Create template")} model={document} />,
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
});
},
});
export const openRandomDocument = createAction({
id: "random",
name: ({ t }) => t(`Open random document`),
analyticsName: "Open random document",
section: DocumentSection,
icon: <ShuffleIcon />,
perform: ({ stores, activeDocumentId }) => {
const nodes = stores.collections.navigationNodes
.reduce((acc, node) => [...acc, ...node.children], [] as NavigationNode[])
.filter((node) => node.id !== activeDocumentId);
const random = nodes[Math.round(Math.random() * nodes.length)];
if (random) {
history.push(random.url);
}
},
});
export const searchDocumentsForQueryActionFactory = (query: string) =>
createInternalLinkAction({
id: "search",
name: ({ t }) =>
t("Search documents for “{{ searchQuery }}”", { searchQuery: query }),
analyticsName: "Search documents",
section: SearchResultsSection,
priority: -1,
icon: <SearchIcon />,
to: searchPath({ query }),
visible: ({ location, isMCP }) =>
!isMCP && location.pathname !== searchPath(),
});
export const moveDocumentToCollection = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: (context) =>
everyActiveModel(
context,
Document,
(document) => !!context.stores.policies.abilities(document.id).move
),
perform: ({ getActiveModels, stores, t }) => {
const documents = getActiveModels(Document);
if (!documents.length) {
return;
}
stores.dialogs.openModal({
title:
documents.length === 1 ? (
<DialogTitle
title={t("Move {{ documentType }}", {
documentType: documents[0].noun,
})}
model={documents[0]}
/>
) : (
t("Move {{ count }} document", { count: documents.length })
),
content: <DocumentMove documents={documents} />,
});
},
});
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: moveDocumentToCollection.visible,
perform: moveDocumentToCollection.perform,
});
export const archiveDocument = createAction({
name: ({ t }) => `${t("Archive")}…`,
analyticsName: "Archive document",
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: (context) =>
everyActiveModel(
context,
Document,
(document) => !!context.stores.policies.abilities(document.id).archive
),
perform: async ({ getActiveModels, stores, t }) => {
const documents = getActiveModels(Document);
if (!documents.length) {
return;
}
stores.dialogs.openModal({
title:
documents.length === 1 ? (
<DialogTitle
title={t("Are you sure you want to archive this document?")}
model={documents[0]}
/>
) : (
t("Are you sure you want to archive {{ count }} documents?", {
count: documents.length,
})
),
content: (
<DocumentArchiveDialog
count={documents.length}
onSubmit={async (reason) => {
const succeeded = await performBatch(documents, (document) =>
document.archive({ reason })
);
if (succeeded) {
toast.success(
documents.length === 1
? t("Document archived")
: t("{{ count }} document archived", { count: succeeded })
);
}
}}
/>
),
});
},
});
export const restoreDocument = createAction({
name: ({ t }) => `${t("Restore")}`,
analyticsName: "Restore document",
section: ActiveDocumentSection,
icon: <RestoreIcon />,
visible: (context) =>
everyActiveModel(context, Document, (document) => {
const collection = document.collectionId
? context.stores.collections.get(document.collectionId)
: undefined;
const can = context.stores.policies.abilities(document.id);
return !!collection?.isActive && !!(can.restore || can.unarchive);
}),
perform: (context) =>
performBatchOnActiveModels(
context,
Document,
(document) => document.restore(),
(documents, succeeded, t) =>
documents.length === 1
? t("{{ documentName }} restored", {
documentName: capitalize(documents[0].noun),
})
: t("{{ count }} document restored", { count: succeeded })
),
});
export const restoreDocumentToCollection = createActionWithChildren({
name: ({ t }) => `${t("Restore")}…`,
analyticsName: "Restore document",
section: ActiveDocumentSection,
icon: <RestoreIcon />,
visible: ({ stores, activeDocumentId }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return false;
}
const can = stores.policies.abilities(document.id);
const collection = document.collectionId
? stores.collections.get(document.collectionId)
: undefined;
return !collection?.isActive && !!(can.restore || can.unarchive);
},
children: ({ t, activeDocumentId, stores }) => {
const { collections, documents, policies } = stores;
const document = activeDocumentId
? documents.get(activeDocumentId)
: undefined;
if (!document) {
return [];
}
const actions = collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return createAction({
name: collection.displayName,
section: ActiveDocumentSection,
icon: <CollectionIcon collection={collection} />,
visible: can.createDocument,
perform: async () => {
await document.restore({ collectionId: collection.id });
toast.success(
t("{{ documentName }} restored", {
documentName: capitalize(document.noun),
})
);
},
});
});
return [createActionGroup({ name: t("Choose a collection"), actions })];
},
});
export const deleteDocument = createAction({
name: ({ t }) => `${t("Delete")}…`,
analyticsName: "Delete document",
section: ActiveDocumentSection,
icon: <TrashIcon />,
dangerous: true,
visible: (context) =>
everyActiveModel(
context,
Document,
(document) => !!context.stores.policies.abilities(document.id).delete
),
perform: ({ getActiveModels, stores, t }) => {
const documents = getActiveModels(Document);
if (!documents.length) {
return;
}
// A single document uses the richer delete dialog (permanent delete, child
// handling); multiple documents use a simple confirmation to move to trash.
if (documents.length === 1) {
const document = documents[0];
stores.dialogs.openModal({
title: (
<DialogTitle
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
model={document}
/>
),
content: (
<DocumentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
return;
}
stores.dialogs.openModal({
title: t("Delete {{ count }} documents", { count: documents.length }),
content: (
<ConfirmationDialog
danger
submitText={t("Delete")}
savingText={`${t("Deleting")}…`}
onSubmit={async () => {
const succeeded = await performBatch(documents, (document) =>
document.delete()
);
if (succeeded) {
toast.success(
t("{{ count }} document moved to trash", { count: succeeded })
);
}
}}
>
{t("Deleting these documents will move them to the trash.")}
</ConfirmationDialog>
),
});
},
});
export const permanentlyDeleteDocument = createAction({
name: ({ t }) => t("Permanently delete"),
analyticsName: "Permanently delete document",
section: ActiveDocumentSection,
icon: <CrossIcon />,
dangerous: true,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).permanentDelete;
},
perform: ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: (
<DialogTitle
title={t("Permanently delete {{ documentName }}", {
documentName: document.noun,
})}
model={document}
/>
),
content: (
<DocumentPermanentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
}
},
});
export const permanentlyDeleteDocumentsInTrash = createAction({
name: ({ t }) => `${t("Empty trash")}…`,
analyticsName: "Empty trash",
section: TrashSection,
icon: <TrashIcon />,
visible: ({ stores }) =>
stores.documents.deleted().length > 0 && !!stores.auth.user?.isAdmin,
perform: ({ stores, t, location }) => {
stores.dialogs.openModal({
title: t("Permanently delete documents in trash"),
content: (
<DeleteDocumentsInTrash
onSubmit={stores.dialogs.closeAllModals}
shouldRedirect={location.pathname === trashPath()}
/>
),
});
},
});
export const openDocumentComments = createAction({
name: ({ t }) => t("Comments"),
analyticsName: "Open comments",
section: ActiveDocumentSection,
icon: <CommentIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
return (
!!activeDocumentId && can.comment && !!stores.auth.team?.commentingEnabled
);
},
perform: ({ activeDocumentId, sidebarContext, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return;
}
// Navigate to the document when triggered from outside its scene (e.g. a
// document list), as the comments sidebar is only rendered there.
const path = documentPath(document);
if (!history.location.pathname.startsWith(path)) {
history.push(path, { sidebarContext });
}
stores.ui.setRightSidebar("comments", getFocusedSplitPane());
},
});
export const openDocumentHistory = createInternalLinkAction({
name: ({ t }) => t("History"),
analyticsName: "Open document history",
section: ActiveDocumentSection,
icon: <HistoryIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
return !!activeDocumentId && can.listRevisions;
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const [pathname, search] = documentHistoryPath(document).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const openDocumentInsights = createAction({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: ActiveDocumentSection,
shortcut: [`Meta+Shift+I`],
icon: <GraphIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
return !!activeDocumentId && can.listViews && !document?.isDeleted;
},
perform: ({ activeDocumentId, stores, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return;
}
stores.dialogs.openModal({
title: <DialogTitle title={t("Insights")} model={document} />,
content: <Insights document={document} />,
});
},
});
export const toggleDocumentStats = createAction({
name: ({ t }) => t("Show editing stats"),
analyticsName: "Toggle document stats",
section: ActiveDocumentSection,
shortcut: [`Meta+Shift+G`],
icon: <HashtagIcon />,
selected: ({ stores }) =>
!!stores.auth.user?.getPreference(UserPreference.ShowDocumentStats),
visible: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
return !!activeDocumentId && !document?.isDeleted && !isMobile();
},
perform: async ({ stores }) => {
const { user } = stores.auth;
if (!user) {
return;
}
user.setPreference(
UserPreference.ShowDocumentStats,
!user.getPreference(UserPreference.ShowDocumentStats)
);
await user.save();
},
});
/** An example of the numbering each style produces, used to aid search. */
const headingPrefixExamples: Record<HeadingPrefixStyle, string> = {
[HeadingPrefixStyle.None]: "",
[HeadingPrefixStyle.Numeric]: "1.1.1",
[HeadingPrefixStyle.Alphanumeric]: "1.a.i",
[HeadingPrefixStyle.Outline]: "I.A.1",
};
const headingPrefixNames: Record<HeadingPrefixStyle, (t: TFunction) => string> =
{
[HeadingPrefixStyle.None]: (t) => t("None"),
[HeadingPrefixStyle.Numeric]: (t) => t("Multi-level decimal"),
[HeadingPrefixStyle.Alphanumeric]: (t) => t("Alphanumeric"),
[HeadingPrefixStyle.Outline]: (t) => t("Harvard"),
};
const changeHeadingPrefixFactory = (style: HeadingPrefixStyle) =>
createAction({
name: ({ t }) => headingPrefixNames[style](t),
badge: ({ isMenu }) =>
isMenu && headingPrefixExamples[style]
? headingPrefixExamples[style]
: undefined,
keywords: headingPrefixExamples[style],
analyticsName: "Change heading numbering",
section: ActiveDocumentSection,
selected: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
return (
(document?.getPreference(DocumentPreference.HeadingPrefix) ??
HeadingPrefixStyle.None) === style
);
},
perform: async ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return;
}
document.setPreference(DocumentPreference.HeadingPrefix, style);
await document.save();
},
});
export const changeHeadingPrefix = createActionWithChildren({
name: ({ t }) => t("Heading numbering"),
analyticsName: "Change heading numbering",
section: ActiveDocumentSection,
icon: <OrderedListIcon />,
visible: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
return (
!!document &&
!document.isDeleted &&
stores.policies.abilities(document.id).update
);
},
children: Object.values(HeadingPrefixStyle).map(changeHeadingPrefixFactory),
});
export const leaveDocument = createAction({
name: ({ t }) => t("Leave document"),
analyticsName: "Leave document",
section: ActiveDocumentSection,
icon: <LogoutIcon />,
visible: ({ currentUserId, activeDocumentId, stores }) => {
const membership = stores.userMemberships.orderedData.find(
(m) => m.documentId === activeDocumentId && m.userId === currentUserId
);
return !!membership;
},
perform: async ({ t, location, currentUserId, activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
try {
if (document && location.pathname.startsWith(document.path)) {
history.push(homePath());
}
await stores.userMemberships.delete({
documentId: activeDocumentId,
userId: currentUserId,
} as UserMembership);
toast.success(t("You have left the shared document"));
} catch (_err) {
toast.error(t("Could not leave document"));
}
},
});
export const applyTemplateActionFactory = ({
actions,
}: {
actions: (Action | ActionGroup | TActionSeparator)[];
}) =>
createActionWithChildren({
name: ({ t }) => t("Apply template"),
analyticsName: "Apply template",
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: ({ activeDocumentId, stores }) => {
const { policies } = stores;
const can = activeDocumentId
? policies.abilities(activeDocumentId)
: undefined;
return !!can?.update;
},
children: actions,
});
export const rootDocumentActions = [
openDocument,
archiveDocument,
createDocument,
createDraftDocument,
createNewDocument,
createNewDocumentInAlphabeticalCollection,
createNestedDocument,
createTemplateFromDocument,
deleteDocument,
importDocument,
downloadDocumentAsMarkdown,
downloadDocumentAsHTML,
downloadDocumentAsTextBundle,
downloadDocumentAsPDF,
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
starDocument,
unstarDocument,
publishDocument,
unpublishDocument,
subscribeDocument,
unsubscribeDocument,
searchInDocument,
duplicateDocument,
leaveDocument,
moveDocumentToCollection,
openRandomDocument,
permanentlyDeleteDocument,
permanentlyDeleteDocumentsInTrash,
presentDocument,
printDocument,
pinDocumentToCollection,
pinDocumentToHome,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
openDocumentInDesktop,
openDocumentInSplit,
shareDocument,
toggleDocumentStats,
changeHeadingPrefix,
];