import copy from "copy-to-clipboard"; import invariant from "invariant"; import { DownloadIcon, DuplicateIcon, StarredIcon, PrintIcon, UnstarredIcon, DocumentIcon, NewDocumentIcon, ShapesIcon, ImportIcon, PinIcon, SearchIcon, UnsubscribeIcon, SubscribeIcon, MoveIcon, TrashIcon, CrossIcon, ArchiveIcon, ShuffleIcon, HistoryIcon, GraphIcon, UnpublishIcon, PublishIcon, CommentIcon, GlobeIcon, CopyIcon, EyeIcon, } from "outline-icons"; import * as React from "react"; import { toast } from "sonner"; import { ExportContentType, TeamPreference } from "@shared/types"; import { getEventFiles } from "@shared/utils/files"; import DocumentDelete from "~/scenes/DocumentDelete"; import DocumentMove from "~/scenes/DocumentMove"; import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete"; import DocumentPublish from "~/scenes/DocumentPublish"; import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash"; import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog"; import DuplicateDialog from "~/components/DuplicateDialog"; import SharePopover from "~/components/Sharing/Document"; import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header"; import { createAction } from "~/actions"; import { DocumentSection, TrashSection } from "~/actions/sections"; import env from "~/env"; import { setPersistedState } from "~/hooks/usePersistedState"; import history from "~/utils/history"; import { documentInsightsPath, documentHistoryPath, homePath, newDocumentPath, searchPath, documentPath, urlify, trashPath, } from "~/utils/routeHelpers"; export const openDocument = createAction({ name: ({ t }) => t("Open document"), analyticsName: "Open document", section: DocumentSection, shortcut: ["o", "d"], keywords: "go to", icon: , children: ({ stores }) => { const paths = stores.collections.pathsToDocuments; return paths .filter((path) => path.type === "document") .map((path) => ({ // Note: using url which includes the slug rather than id here to bust // cache if the document is renamed id: path.url, name: path.title, icon: function _Icon() { return stores.documents.get(path.id)?.isStarred ? ( ) : null; }, section: DocumentSection, perform: () => history.push(path.url), })); }, }); export const createDocument = createAction({ name: ({ t }) => t("New document"), analyticsName: "New document", section: DocumentSection, icon: , keywords: "create", visible: ({ currentTeamId, activeCollectionId, stores }) => { if ( activeCollectionId && !stores.policies.abilities(activeCollectionId).createDocument ) { return false; } return ( !!currentTeamId && stores.policies.abilities(currentTeamId).createDocument ); }, perform: ({ activeCollectionId, inStarredSection }) => history.push(newDocumentPath(activeCollectionId), { starred: inStarredSection, }), }); export const createDocumentFromTemplate = createAction({ name: ({ t }) => t("New from template"), analyticsName: "New document", section: DocumentSection, icon: , keywords: "create", visible: ({ currentTeamId, activeDocumentId, stores }) => !!currentTeamId && !!activeDocumentId && !!stores.documents.get(activeDocumentId)?.template && stores.policies.abilities(currentTeamId).createDocument, perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) => history.push( newDocumentPath(activeCollectionId, { templateId: activeDocumentId }), { starred: inStarredSection, } ), }); export const createNestedDocument = createAction({ name: ({ t }) => t("New nested document"), analyticsName: "New document", section: DocumentSection, icon: , keywords: "create", visible: ({ currentTeamId, activeDocumentId, stores }) => !!currentTeamId && !!activeDocumentId && stores.policies.abilities(currentTeamId).createDocument && stores.policies.abilities(activeDocumentId).createChildDocument, perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) => history.push( newDocumentPath(activeCollectionId, { parentDocumentId: activeDocumentId, }), { starred: inStarredSection, } ), }); export const starDocument = createAction({ name: ({ t }) => t("Star"), analyticsName: "Star document", section: DocumentSection, icon: , keywords: "favorite bookmark", visible: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return false; } const document = stores.documents.get(activeDocumentId); return ( !document?.isStarred && stores.policies.abilities(activeDocumentId).star ); }, perform: async ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return; } const document = stores.documents.get(activeDocumentId); await document?.star(); setPersistedState(getHeaderExpandedKey("starred"), true); }, }); export const unstarDocument = createAction({ name: ({ t }) => t("Unstar"), analyticsName: "Unstar document", section: DocumentSection, icon: , keywords: "unfavorite unbookmark", visible: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return false; } const document = stores.documents.get(activeDocumentId); return ( !!document?.isStarred && stores.policies.abilities(activeDocumentId).unstar ); }, perform: async ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return; } const document = stores.documents.get(activeDocumentId); await document?.unstar(); }, }); export const publishDocument = createAction({ name: ({ t }) => t("Publish"), analyticsName: "Publish document", section: DocumentSection, icon: , 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: t("Publish document"), content: , }); } }, }); export const unpublishDocument = createAction({ name: ({ t }) => t("Unpublish"), analyticsName: "Unpublish document", section: DocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return false; } return stores.policies.abilities(activeDocumentId).unpublish; }, perform: async ({ activeDocumentId, stores, t }) => { if (!activeDocumentId) { return; } const document = stores.documents.get(activeDocumentId); if (!document) { return; } await document.unpublish(); toast.success( t("Unpublished {{ documentName }}", { documentName: document.noun, }) ); }, }); export const subscribeDocument = createAction({ name: ({ t }) => t("Subscribe"), analyticsName: "Subscribe to document", section: DocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return false; } const document = stores.documents.get(activeDocumentId); return ( !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: DocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return false; } const document = stores.documents.get(activeDocumentId); return ( !!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(currentUserId); toast.success(t("Unsubscribed from document notifications")); }, }); export const shareDocument = createAction({ name: ({ t }) => t("Share"), analyticsName: "Share document", section: DocumentSection, icon: , perform: async ({ activeDocumentId, stores, currentUserId, t }) => { if (!activeDocumentId || !currentUserId) { return; } const document = stores.documents.get(activeDocumentId); const share = stores.shares.getByDocumentId(activeDocumentId); const sharedParent = stores.shares.getByDocumentParents(activeDocumentId); if (!document) { return; } stores.dialogs.openModal({ title: t("Share this document"), content: ( ), }); }, }); export const downloadDocumentAsHTML = createAction({ name: ({ t }) => t("HTML"), analyticsName: "Download document as HTML", section: DocumentSection, keywords: "html export", icon: , 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(ExportContentType.Html); }, }); export const downloadDocumentAsPDF = createAction({ name: ({ t }) => t("PDF"), analyticsName: "Download document as PDF", section: DocumentSection, keywords: "export", icon: , iconInContextMenu: false, visible: ({ activeDocumentId, stores }) => !!activeDocumentId && stores.policies.abilities(activeDocumentId).download && env.PDF_EXPORT_ENABLED, perform: ({ activeDocumentId, t, stores }) => { if (!activeDocumentId) { return; } const id = toast.loading(`${t("Exporting")}…`); const document = stores.documents.get(activeDocumentId); return document ?.download(ExportContentType.Pdf) .finally(() => id && toast.dismiss(id)); }, }); export const downloadDocumentAsMarkdown = createAction({ name: ({ t }) => t("Markdown"), analyticsName: "Download document as Markdown", section: DocumentSection, keywords: "md markdown export", icon: , 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(ExportContentType.Markdown); }, }); export const downloadDocument = createAction({ name: ({ t, isContextMenu }) => isContextMenu ? t("Download") : t("Download document"), analyticsName: "Download document", section: DocumentSection, icon: , keywords: "export", children: [ downloadDocumentAsHTML, downloadDocumentAsPDF, downloadDocumentAsMarkdown, ], }); export const copyDocumentAsMarkdown = createAction({ name: ({ t }) => t("Copy as Markdown"), section: DocumentSection, keywords: "clipboard", visible: ({ activeDocumentId, stores }) => !!activeDocumentId && stores.policies.abilities(activeDocumentId).download, perform: ({ stores, activeDocumentId, t }) => { const document = activeDocumentId ? stores.documents.get(activeDocumentId) : undefined; if (document) { copy(document.toMarkdown()); toast.success(t("Markdown copied to clipboard")); } }, }); export const copyDocumentLink = createAction({ name: ({ t }) => t("Copy link"), section: DocumentSection, keywords: "clipboard", 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 = createAction({ name: ({ t }) => t("Copy"), analyticsName: "Copy document", section: DocumentSection, icon: , keywords: "clipboard", children: [copyDocumentLink, copyDocumentAsMarkdown], }); export const duplicateDocument = createAction({ name: ({ t, isContextMenu }) => isContextMenu ? t("Duplicate") : t("Duplicate document"), analyticsName: "Duplicate document", section: DocumentSection, icon: , 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: t("Copy document"), content: ( { stores.dialogs.closeAllModals(); history.push(documentPath(response[0])); }} /> ), }); }, }); /** * 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: ({ activeDocumentId = "", t, stores }) => { const selectedDocument = stores.documents.get(activeDocumentId); const collectionName = selectedDocument ? stores.documents.getCollectionForDocument(selectedDocument)?.name : t("collection"); return t("Pin to {{collectionName}}", { collectionName, }); }, analyticsName: "Pin document to collection", section: DocumentSection, icon: , iconInContextMenu: false, visible: ({ activeCollectionId, activeDocumentId, stores }) => { if (!activeDocumentId || !activeCollectionId) { return false; } const document = stores.documents.get(activeDocumentId); return ( !!stores.policies.abilities(activeDocumentId).pin && !document?.pinned ); }, perform: async ({ activeDocumentId, activeCollectionId, t, stores }) => { if (!activeDocumentId || !activeCollectionId) { return; } const document = stores.documents.get(activeDocumentId); await document?.pin(document.collectionId); const collection = stores.collections.get(activeCollectionId); if (!collection || !location.pathname.startsWith(collection?.url)) { toast.success(t("Pinned to collection")); } }, }); /** * 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: DocumentSection, icon: , 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 pinDocument = createAction({ name: ({ t }) => t("Pin"), analyticsName: "Pin document", section: DocumentSection, icon: , children: [pinDocumentToCollection, pinDocumentToHome], }); export const searchInDocument = createAction({ name: ({ t }) => t("Search in document"), analyticsName: "Search document", section: DocumentSection, icon: , visible: ({ stores, activeDocumentId }) => { if (!activeDocumentId) { return false; } const document = stores.documents.get(activeDocumentId); return !!document?.isActive; }, perform: ({ activeDocumentId }) => { history.push(searchPath(undefined, { documentId: activeDocumentId })); }, }); export const printDocument = createAction({ name: ({ t, isContextMenu }) => isContextMenu ? t("Print") : t("Print document"), analyticsName: "Print document", section: DocumentSection, icon: , visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print), perform: () => { queueMicrotask(window.print); }, }); export const importDocument = createAction({ name: ({ t }) => t("Import document"), analyticsName: "Import document", section: DocumentSection, icon: , keywords: "upload", visible: ({ activeCollectionId, activeDocumentId, stores }) => { if (activeDocumentId) { return !!stores.policies.abilities(activeDocumentId).createChildDocument; } if (activeCollectionId) { return !!stores.policies.abilities(activeCollectionId).update; } return false; }, perform: ({ activeCollectionId, activeDocumentId, stores }) => { const { documents } = stores; const input = document.createElement("input"); input.type = "file"; input.accept = documents.importFileTypes.join(", "); input.onchange = async (ev) => { const files = getEventFiles(ev); const file = files[0]; const document = await documents.import( file, activeDocumentId, activeCollectionId, { publish: true, } ); history.push(document.url); }; input.click(); }, }); export const createTemplate = createAction({ name: ({ t }) => t("Templatize"), analyticsName: "Templatize document", section: DocumentSection, icon: , keywords: "new create template", visible: ({ activeCollectionId, activeDocumentId, stores }) => { if (!activeDocumentId) { return false; } const document = stores.documents.get(activeDocumentId); return !!( !!activeCollectionId && stores.policies.abilities(activeCollectionId).update && !document?.isTemplate && !!document?.isActive ); }, perform: ({ activeDocumentId, stores, t, event }) => { if (!activeDocumentId) { return; } event?.preventDefault(); event?.stopPropagation(); stores.dialogs.openModal({ title: t("Create template"), content: , }); }, }); export const openRandomDocument = createAction({ id: "random", name: ({ t }) => t(`Open random document`), analyticsName: "Open random document", section: DocumentSection, icon: , perform: ({ stores, activeDocumentId }) => { const documentPaths = stores.collections.pathsToDocuments.filter( (path) => path.type === "document" && path.id !== activeDocumentId ); const documentPath = documentPaths[Math.round(Math.random() * documentPaths.length)]; if (documentPath) { history.push(documentPath.url); } }, }); export const searchDocumentsForQuery = (searchQuery: string) => createAction({ id: "search", name: ({ t }) => t(`Search documents for "{{searchQuery}}"`, { searchQuery }), analyticsName: "Search documents", section: DocumentSection, icon: , perform: () => history.push(searchPath(searchQuery)), visible: ({ location }) => location.pathname !== searchPath(), }); export const moveDocument = createAction({ name: ({ t }) => t("Move"), analyticsName: "Move document", section: DocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return false; } return !!stores.policies.abilities(activeDocumentId).move; }, perform: ({ activeDocumentId, stores, t }) => { if (activeDocumentId) { const document = stores.documents.get(activeDocumentId); if (!document) { return; } stores.dialogs.openModal({ title: t("Move {{ documentType }}", { documentType: document.noun, }), content: , }); } }, }); export const archiveDocument = createAction({ name: ({ t }) => t("Archive"), analyticsName: "Archive document", section: DocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return false; } return !!stores.policies.abilities(activeDocumentId).archive; }, perform: async ({ activeDocumentId, stores, t }) => { if (activeDocumentId) { const document = stores.documents.get(activeDocumentId); if (!document) { return; } await document.archive(); toast.success(t("Document archived")); } }, }); export const deleteDocument = createAction({ name: ({ t }) => `${t("Delete")}…`, analyticsName: "Delete document", section: DocumentSection, icon: , dangerous: true, visible: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return false; } return !!stores.policies.abilities(activeDocumentId).delete; }, perform: ({ activeDocumentId, stores, t }) => { if (activeDocumentId) { const document = stores.documents.get(activeDocumentId); if (!document) { return; } stores.dialogs.openModal({ title: t("Delete {{ documentName }}", { documentName: document.noun, }), content: ( ), }); } }, }); export const permanentlyDeleteDocument = createAction({ name: ({ t }) => t("Permanently delete"), analyticsName: "Permanently delete document", section: DocumentSection, icon: , 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: t("Permanently delete {{ documentName }}", { documentName: document.noun, }), content: ( ), }); } }, }); export const permanentlyDeleteDocumentsInTrash = createAction({ name: ({ t }) => t("Empty trash"), analyticsName: "Empty trash", section: TrashSection, icon: , dangerous: true, 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: ( ), }); }, }); export const openDocumentComments = createAction({ name: ({ t }) => t("Comments"), analyticsName: "Open comments", section: DocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { const can = stores.policies.abilities(activeDocumentId ?? ""); return ( !!activeDocumentId && can.comment && !!stores.auth.team?.getPreference(TeamPreference.Commenting) ); }, perform: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return; } stores.ui.toggleComments(activeDocumentId); }, }); export const openDocumentHistory = createAction({ name: ({ t }) => t("History"), analyticsName: "Open document history", section: DocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { const can = stores.policies.abilities(activeDocumentId ?? ""); return !!activeDocumentId && can.listRevisions; }, perform: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return; } const document = stores.documents.get(activeDocumentId); if (!document) { return; } history.push(documentHistoryPath(document)); }, }); export const openDocumentInsights = createAction({ name: ({ t }) => t("Insights"), analyticsName: "Open document insights", section: DocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { const can = stores.policies.abilities(activeDocumentId ?? ""); const document = activeDocumentId ? stores.documents.get(activeDocumentId) : undefined; return ( !!activeDocumentId && can.listViews && !document?.isTemplate && !document?.isDeleted ); }, perform: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return; } const document = stores.documents.get(activeDocumentId); if (!document) { return; } history.push(documentInsightsPath(document)); }, }); export const toggleViewerInsights = createAction({ name: ({ t, stores, activeDocumentId }) => { const document = activeDocumentId ? stores.documents.get(activeDocumentId) : undefined; return document?.insightsEnabled ? t("Disable viewer insights") : t("Enable viewer insights"); }, analyticsName: "Toggle viewer insights", section: DocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { const can = stores.policies.abilities(activeDocumentId ?? ""); return can.updateInsights; }, perform: async ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return; } const document = stores.documents.get(activeDocumentId); if (!document) { return; } await document.save({ insightsEnabled: !document.insightsEnabled, }); }, }); export const rootDocumentActions = [ openDocument, archiveDocument, createDocument, createTemplate, deleteDocument, importDocument, downloadDocument, copyDocumentLink, copyDocumentAsMarkdown, starDocument, unstarDocument, publishDocument, unpublishDocument, subscribeDocument, unsubscribeDocument, duplicateDocument, moveDocument, openRandomDocument, permanentlyDeleteDocument, permanentlyDeleteDocumentsInTrash, printDocument, pinDocumentToCollection, pinDocumentToHome, openDocumentComments, openDocumentHistory, openDocumentInsights, shareDocument, ];