mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
129 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e07aa877f | |||
| 19d5ef5694 | |||
| b37074304a | |||
| 35c7cc2086 | |||
| 82f9600d9e | |||
| 686f9aeb5c | |||
| a41e17f875 | |||
| db114fd966 | |||
| ce987d23ed | |||
| 5e61fcd336 | |||
| 4f84daf558 | |||
| f80842ca20 | |||
| 53758b69fb | |||
| cd86877cb0 | |||
| a2d5598b96 | |||
| ffae5d2f20 | |||
| 53272c8c3d | |||
| 65ff9bde3e | |||
| 21adfdd1bf | |||
| 91c2f60827 | |||
| a253d2921a | |||
| b83d218fbe | |||
| dce96955a1 | |||
| ba7c446f59 | |||
| 7b9ec4c43a | |||
| faaf0a6733 | |||
| c58aafeb32 | |||
| 3f73c9d2bf | |||
| b6e43e1990 | |||
| 0a2c066253 | |||
| 840db4692e | |||
| fa961d7464 | |||
| 3e75b24f7a | |||
| ce91071995 | |||
| 9b807f7a9e | |||
| 17493ca0cf | |||
| 1d4b05c9f6 | |||
| 8a5e42071f | |||
| 6b53755f5a | |||
| 709e4f44fd | |||
| c37646b5ad | |||
| 36ca667c50 | |||
| 009e66a466 | |||
| 7adda26c6d | |||
| 62860c593b | |||
| bdc2357984 | |||
| 4fc1ed0d7e | |||
| 5d068361cc | |||
| 176cfff7f8 | |||
| 2fd18f7fdb | |||
| 34f951c511 | |||
| f0c26cf8c8 | |||
| d77ddbd7de | |||
| 4e1038837b | |||
| c54fcc3536 | |||
| c4fa63df3d | |||
| 2b42ce0c0f | |||
| 3208156591 | |||
| e8577ef2a8 | |||
| ca66dec22b | |||
| 41ccad7cce | |||
| bd52b364dd | |||
| 5c56714bc8 | |||
| 895a88f934 | |||
| f32db08ef3 | |||
| 05a513b10c | |||
| bf3c6333b0 | |||
| 544554f106 | |||
| 37c90e1592 | |||
| 815abc8423 | |||
| b9ed7ddf58 | |||
| bc0b73e7a7 | |||
| 1218bc1f3c | |||
| ae3b05fdba | |||
| 549c8d9ed8 | |||
| 6bb798220b | |||
| e032bb5ab8 | |||
| 23b3b8aa54 | |||
| 738d943bd4 | |||
| ae5c737ed2 | |||
| 5116147ace | |||
| e6ba84e434 | |||
| 3b546a7935 | |||
| 9373da0da6 | |||
| 494ef2a6cd | |||
| c60703cc5a | |||
| f5b6d10a73 | |||
| 3b17926023 | |||
| 0c080038d7 | |||
| ae0bd5f59d | |||
| 7c9a2bbcf6 | |||
| b55a8ab54f | |||
| 1bc41b4d62 | |||
| 43b9eb0ad7 | |||
| 3f87912656 | |||
| c960804bb8 | |||
| 26fa70cbbd | |||
| ba749cac71 | |||
| df08a0063c | |||
| 6591bbebc9 | |||
| cb56941a17 | |||
| 209e5e20d5 | |||
| 2d0612a9d0 | |||
| fca4467bda | |||
| b77af9bda3 | |||
| f984ee0fcc | |||
| f3fe73057a | |||
| 4a009ed35b | |||
| cd419190ef | |||
| 7c309c7986 | |||
| 4a2707c74c | |||
| a6b9672779 | |||
| 3bce4853c3 | |||
| 6859b0cf62 | |||
| d10668de54 | |||
| f8535ff047 | |||
| e2355d63a2 | |||
| ed22891a69 | |||
| 363c416873 | |||
| 967594686e | |||
| ce85b8f94d | |||
| 81b7ac5776 | |||
| fe5d8b7158 | |||
| 7013a87c6e | |||
| 4ef7e95863 | |||
| f81a836549 | |||
| 97674471db | |||
| 5a3e97d6c5 | |||
| 273d6550ca |
@@ -20,6 +20,11 @@ COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
|
||||
COPY --from=base $APP_PATH/node_modules ./node_modules
|
||||
COPY --from=base $APP_PATH/package.json ./package.json
|
||||
|
||||
# Install wget to healthcheck the server
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a non-root user compatible with Debian and BusyBox based images
|
||||
RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
@@ -36,5 +41,7 @@ VOLUME /var/lib/outline/data
|
||||
|
||||
USER nodejs
|
||||
|
||||
HEALTHCHECK --interval=1m CMD wget -qO- "http://localhost:${PORT:-3000}/_health" | grep -q "OK" || exit 1
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
@@ -6,10 +6,6 @@ WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
COPY ./patches ./patches
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
@@ -23,4 +19,3 @@ RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 &
|
||||
yarn cache clean
|
||||
|
||||
ENV PORT=3000
|
||||
HEALTHCHECK CMD wget -qO- http://localhost:${PORT}/_health | grep -q "OK" || exit 1
|
||||
|
||||
@@ -3,8 +3,8 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.71.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Licensed Work: Outline 0.80.2
|
||||
The Licensed Work is (c) 2024 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
Service.
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2027-08-18
|
||||
Change Date: 2028-09-26
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
"description": "Open source wiki and knowledge base for growing teams",
|
||||
"website": "https://www.getoutline.com/",
|
||||
"repository": "https://github.com/outline/outline",
|
||||
"keywords": ["wiki", "team", "node", "markdown", "slack"],
|
||||
"keywords": [
|
||||
"wiki",
|
||||
"team",
|
||||
"node",
|
||||
"markdown",
|
||||
"slack"
|
||||
],
|
||||
"success_url": "/",
|
||||
"formation": {
|
||||
"web": {
|
||||
@@ -212,4 +218,4 @@
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import SharePopover from "~/components/Sharing/Collection/SharePopover";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import { createAction } from "~/actions";
|
||||
import { CollectionSection } from "~/actions/sections";
|
||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
|
||||
@@ -70,7 +70,7 @@ export const editCollection = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Edit")}…` : t("Edit collection"),
|
||||
analyticsName: "Edit collection",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
@@ -96,7 +96,7 @@ export const editCollectionPermissions = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
analyticsName: "Collection permissions",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
@@ -127,7 +127,7 @@ export const editCollectionPermissions = createAction({
|
||||
export const searchInCollection = createAction({
|
||||
name: ({ t }) => t("Search in collection"),
|
||||
analyticsName: "Search collection",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
@@ -140,7 +140,7 @@ export const searchInCollection = createAction({
|
||||
export const starCollection = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star collection",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ activeCollectionId }) => {
|
||||
@@ -167,7 +167,7 @@ export const starCollection = createAction({
|
||||
export const unstarCollection = createAction({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar collection",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ activeCollectionId }) => {
|
||||
@@ -193,7 +193,7 @@ export const unstarCollection = createAction({
|
||||
export const deleteCollection = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete collection",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ activeCollectionId }) => {
|
||||
@@ -227,7 +227,7 @@ export const deleteCollection = createAction({
|
||||
export const createTemplate = createAction({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId }) =>
|
||||
|
||||
@@ -24,25 +24,37 @@ import {
|
||||
UnpublishIcon,
|
||||
PublishIcon,
|
||||
CommentIcon,
|
||||
GlobeIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
PadlockIcon,
|
||||
GlobeIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ExportContentType, TeamPreference } from "@shared/types";
|
||||
import {
|
||||
ExportContentType,
|
||||
TeamPreference,
|
||||
NavigationNode,
|
||||
} 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 ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DuplicateDialog from "~/components/DuplicateDialog";
|
||||
import Icon from "~/components/Icon";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection, TrashSection } from "~/actions/sections";
|
||||
import {
|
||||
ActiveDocumentSection,
|
||||
DocumentSection,
|
||||
TrashSection,
|
||||
} from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
@@ -66,23 +78,24 @@ export const openDocument = createAction({
|
||||
keywords: "go to",
|
||||
icon: <DocumentIcon />,
|
||||
children: ({ stores }) => {
|
||||
const paths = stores.collections.pathsToDocuments;
|
||||
const nodes = stores.collections.navigationNodes.reduce(
|
||||
(acc, node) => [...acc, ...node.children],
|
||||
[] as NavigationNode[]
|
||||
);
|
||||
|
||||
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 ? (
|
||||
<StarredIcon />
|
||||
) : null;
|
||||
},
|
||||
section: DocumentSection,
|
||||
perform: () => history.push(path.url),
|
||||
}));
|
||||
return nodes.map((item) => ({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the document is renamed
|
||||
id: item.url,
|
||||
name: item.title,
|
||||
icon: item.icon ? (
|
||||
<Icon value={item.icon} color={item.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
section: DocumentSection,
|
||||
perform: () => history.push(item.url),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -133,7 +146,7 @@ export const createDocumentFromTemplate = createAction({
|
||||
export const createNestedDocument = createAction({
|
||||
name: ({ t }) => t("New nested document"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) =>
|
||||
@@ -150,7 +163,7 @@ export const createNestedDocument = createAction({
|
||||
export const starDocument = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -176,7 +189,7 @@ export const starDocument = createAction({
|
||||
export const unstarDocument = createAction({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -202,7 +215,7 @@ export const unstarDocument = createAction({
|
||||
export const publishDocument = createAction({
|
||||
name: ({ t }) => t("Publish"),
|
||||
analyticsName: "Publish document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PublishIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -244,7 +257,7 @@ export const publishDocument = createAction({
|
||||
export const unpublishDocument = createAction({
|
||||
name: ({ t }) => t("Unpublish"),
|
||||
analyticsName: "Unpublish document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <UnpublishIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -275,7 +288,7 @@ export const unpublishDocument = createAction({
|
||||
export const subscribeDocument = createAction({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SubscribeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -303,7 +316,7 @@ export const subscribeDocument = createAction({
|
||||
export const unsubscribeDocument = createAction({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -331,10 +344,14 @@ export const unsubscribeDocument = createAction({
|
||||
});
|
||||
|
||||
export const shareDocument = createAction({
|
||||
name: ({ t }) => t("Share"),
|
||||
name: ({ t }) => `${t("Permissions")}…`,
|
||||
analyticsName: "Share document",
|
||||
section: DocumentSection,
|
||||
icon: <GlobeIcon />,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId!);
|
||||
return can.manageUsers || can.share;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
if (!activeDocumentId || !currentUserId) {
|
||||
return;
|
||||
@@ -366,7 +383,7 @@ export const shareDocument = createAction({
|
||||
export const downloadDocumentAsHTML = createAction({
|
||||
name: ({ t }) => t("HTML"),
|
||||
analyticsName: "Download document as HTML",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "html export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
@@ -385,7 +402,7 @@ export const downloadDocumentAsHTML = createAction({
|
||||
export const downloadDocumentAsPDF = createAction({
|
||||
name: ({ t }) => t("PDF"),
|
||||
analyticsName: "Download document as PDF",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
@@ -409,7 +426,7 @@ export const downloadDocumentAsPDF = createAction({
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "md markdown export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
@@ -429,9 +446,11 @@ export const downloadDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Download") : t("Download document"),
|
||||
analyticsName: "Download document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
children: [
|
||||
downloadDocumentAsHTML,
|
||||
downloadDocumentAsPDF,
|
||||
@@ -441,8 +460,10 @@ export const downloadDocument = createAction({
|
||||
|
||||
export const copyDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Copy as Markdown"),
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
icon: <MarkdownIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
@@ -456,10 +477,33 @@ export const copyDocumentAsMarkdown = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
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)?.published,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const share = stores.shares.getByDocumentId(activeDocumentId);
|
||||
if (share) {
|
||||
copy(share.url);
|
||||
toast.success(t("Link copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentLink = createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
icon: <CopyIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId }) => !!activeDocumentId,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -475,17 +519,17 @@ export const copyDocumentLink = createAction({
|
||||
export const copyDocument = createAction({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CopyIcon />,
|
||||
keywords: "clipboard",
|
||||
children: [copyDocumentLink, copyDocumentAsMarkdown],
|
||||
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Duplicate") : t("Duplicate document"),
|
||||
analyticsName: "Duplicate document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DuplicateIcon />,
|
||||
keywords: "copy",
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
@@ -529,7 +573,7 @@ export const pinDocumentToCollection = createAction({
|
||||
});
|
||||
},
|
||||
analyticsName: "Pin document to collection",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PinIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
@@ -565,7 +609,7 @@ export const pinDocumentToCollection = createAction({
|
||||
export const pinDocumentToHome = createAction({
|
||||
name: ({ t }) => t("Pin to home"),
|
||||
analyticsName: "Pin document to home",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PinIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, currentTeamId, stores }) => {
|
||||
@@ -597,7 +641,7 @@ export const pinDocumentToHome = createAction({
|
||||
export const pinDocument = createAction({
|
||||
name: ({ t }) => t("Pin"),
|
||||
analyticsName: "Pin document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PinIcon />,
|
||||
children: [pinDocumentToCollection, pinDocumentToHome],
|
||||
});
|
||||
@@ -605,7 +649,7 @@ export const pinDocument = createAction({
|
||||
export const searchInDocument = createAction({
|
||||
name: ({ t }) => t("Search in document"),
|
||||
analyticsName: "Search document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -623,7 +667,7 @@ export const printDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Print") : t("Print document"),
|
||||
analyticsName: "Print document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
|
||||
perform: () => {
|
||||
@@ -682,7 +726,7 @@ export const importDocument = createAction({
|
||||
export const createTemplateFromDocument = createAction({
|
||||
name: ({ t }) => t("Templatize"),
|
||||
analyticsName: "Templatize document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
@@ -717,14 +761,14 @@ export const openRandomDocument = createAction({
|
||||
section: DocumentSection,
|
||||
icon: <ShuffleIcon />,
|
||||
perform: ({ stores, activeDocumentId }) => {
|
||||
const documentPaths = stores.collections.pathsToDocuments.filter(
|
||||
(path) => path.type === "document" && path.id !== activeDocumentId
|
||||
);
|
||||
const randomPath =
|
||||
documentPaths[Math.round(Math.random() * documentPaths.length)];
|
||||
const nodes = stores.collections.navigationNodes
|
||||
.reduce((acc, node) => [...acc, ...node.children], [] as NavigationNode[])
|
||||
.filter((node) => node.id !== activeDocumentId);
|
||||
|
||||
if (randomPath) {
|
||||
history.push(randomPath.url);
|
||||
const random = nodes[Math.round(Math.random() * nodes.length)];
|
||||
|
||||
if (random) {
|
||||
history.push(random.url);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -782,7 +826,7 @@ export const moveDocumentToCollection = createAction({
|
||||
: t("Move");
|
||||
},
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -811,7 +855,7 @@ export const moveDocumentToCollection = createAction({
|
||||
export const moveDocument = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -830,7 +874,7 @@ export const moveDocument = createAction({
|
||||
export const moveTemplate = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -847,9 +891,9 @@ export const moveTemplate = createAction({
|
||||
});
|
||||
|
||||
export const archiveDocument = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
@@ -858,14 +902,30 @@ export const archiveDocument = createAction({
|
||||
return !!stores.policies.abilities(activeDocumentId).archive;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
const { dialogs, documents } = stores;
|
||||
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
const document = documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.archive();
|
||||
toast.success(t("Document archived"));
|
||||
dialogs.openModal({
|
||||
title: t("Are you sure you want to archive this document?"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await document.archive();
|
||||
toast.success(t("Document archived"));
|
||||
}}
|
||||
savingText={`${t("Archiving")}…`}
|
||||
>
|
||||
{t(
|
||||
"Archiving this document will remove it from the collection and search results."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -873,7 +933,7 @@ export const archiveDocument = createAction({
|
||||
export const deleteDocument = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -907,7 +967,7 @@ export const deleteDocument = createAction({
|
||||
export const permanentlyDeleteDocument = createAction({
|
||||
name: ({ t }) => t("Permanently delete"),
|
||||
analyticsName: "Permanently delete document",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CrossIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
@@ -962,7 +1022,7 @@ export const permanentlyDeleteDocumentsInTrash = createAction({
|
||||
export const openDocumentComments = createAction({
|
||||
name: ({ t }) => t("Comments"),
|
||||
analyticsName: "Open comments",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CommentIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
@@ -984,7 +1044,7 @@ export const openDocumentComments = createAction({
|
||||
export const openDocumentHistory = createAction({
|
||||
name: ({ t }) => t("History"),
|
||||
analyticsName: "Open document history",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <HistoryIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
@@ -1005,7 +1065,7 @@ export const openDocumentHistory = createAction({
|
||||
export const openDocumentInsights = createAction({
|
||||
name: ({ t }) => t("Insights"),
|
||||
analyticsName: "Open document insights",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <GraphIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
@@ -1042,7 +1102,7 @@ export const toggleViewerInsights = createAction({
|
||||
: t("Enable viewer insights");
|
||||
},
|
||||
analyticsName: "Toggle viewer insights",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <EyeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
@@ -1072,6 +1132,7 @@ export const rootDocumentActions = [
|
||||
importDocument,
|
||||
downloadDocument,
|
||||
copyDocumentLink,
|
||||
copyDocumentShareLink,
|
||||
copyDocumentAsMarkdown,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
|
||||
@@ -98,6 +98,11 @@ export function actionToKBar(
|
||||
)
|
||||
: [];
|
||||
|
||||
const sectionPriority =
|
||||
typeof action.section !== "string" && "priority" in action.section
|
||||
? (action.section.priority as number) ?? 0
|
||||
: 0;
|
||||
|
||||
return [
|
||||
{
|
||||
id: action.id,
|
||||
@@ -108,6 +113,7 @@ export function actionToKBar(
|
||||
keywords: action.keywords ?? "",
|
||||
shortcut: action.shortcut || [],
|
||||
icon: resolvedIcon,
|
||||
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)),
|
||||
perform: action.perform
|
||||
? () => performAction(action, context)
|
||||
: undefined,
|
||||
|
||||
@@ -2,10 +2,28 @@ import { ActionContext } from "~/types";
|
||||
|
||||
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
|
||||
|
||||
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
|
||||
const activeCollection = stores.collections.active;
|
||||
return `${t("Collection")} · ${activeCollection?.name}`;
|
||||
};
|
||||
|
||||
ActiveCollectionSection.priority = 0.8;
|
||||
|
||||
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
|
||||
const activeDocument = stores.documents.active;
|
||||
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
|
||||
};
|
||||
|
||||
ActiveDocumentSection.priority = 0.9;
|
||||
|
||||
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
|
||||
|
||||
RecentSection.priority = 1;
|
||||
|
||||
export const RevisionSection = ({ t }: ActionContext) => t("Revision");
|
||||
|
||||
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
|
||||
@@ -21,4 +39,6 @@ export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
t("Recent searches");
|
||||
|
||||
RecentSearchesSection.priority = -0.1;
|
||||
|
||||
export const TrashSection = ({ t }: ActionContext) => t("Trash");
|
||||
|
||||
@@ -106,6 +106,24 @@ const Analytics: React.FC = ({ children }: Props) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Umami
|
||||
React.useEffect(() => {
|
||||
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
|
||||
if (integration.service !== IntegrationService.Umami) {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.defer = true;
|
||||
script.src = `${integration.settings?.instanceUrl}${integration.settings?.scriptName}`;
|
||||
script.setAttribute(
|
||||
"data-website-id",
|
||||
integration.settings?.measurementId
|
||||
);
|
||||
document.getElementsByTagName("head")[0]?.appendChild(script);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ function ArrowKeyNavigation(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
if (ev.key === "Escape" || ev.key === "Backspace") {
|
||||
ev.preventDefault();
|
||||
onEscape(ev);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer, useLocalStore } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||
import DocumentContext from "~/components/DocumentContext";
|
||||
import type { DocumentContextValue } from "~/components/DocumentContext";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import SidebarRight from "~/components/Sidebar/Right";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -25,6 +22,7 @@ import {
|
||||
matchDocumentSlug as slug,
|
||||
matchDocumentInsights,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
import { PortalContext } from "./Portal";
|
||||
|
||||
@@ -50,12 +48,6 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const can = usePolicy(ui.activeDocumentId);
|
||||
const canCollection = usePolicy(ui.activeCollectionId);
|
||||
const team = useCurrentTeam();
|
||||
const documentContext = useLocalStore<DocumentContextValue>(() => ({
|
||||
editor: null,
|
||||
setEditor: (editor: TEditor) => {
|
||||
documentContext.editor = editor;
|
||||
},
|
||||
}));
|
||||
|
||||
const goToSearch = (ev: KeyboardEvent) => {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
@@ -125,7 +117,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentContext.Provider value={documentContext}>
|
||||
<DocumentContextProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout
|
||||
title={team.name}
|
||||
@@ -142,7 +134,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
</React.Suspense>
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</DocumentContext.Provider>
|
||||
</DocumentContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
|
||||
@@ -156,18 +155,16 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
/>
|
||||
)}
|
||||
|
||||
{team.sharing &&
|
||||
(!collection ||
|
||||
FeatureFlags.isEnabled(Feature.newCollectionSharing)) && (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
{...register("sharing")}
|
||||
/>
|
||||
)}
|
||||
{team.sharing && (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
{...register("sharing")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { runInAction } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import Collection from "~/models/Collection";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { CollectionForm, FormData } from "./CollectionForm";
|
||||
@@ -17,8 +17,11 @@ export const CollectionNew = observer(function CollectionNew_({
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
const collection = new Collection(data, collections);
|
||||
await collection.save();
|
||||
const collection = await collections.save(data);
|
||||
// Avoid flash of loading state for the new collection, we know it's empty.
|
||||
runInAction(() => {
|
||||
collection.documents = [];
|
||||
});
|
||||
onSubmit?.();
|
||||
history.push(collection.path);
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,20 +6,27 @@ import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import CommandBarResults from "~/components/CommandBarResults";
|
||||
import SearchActions from "~/components/SearchActions";
|
||||
import rootActions from "~/actions/root";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useSettingsActions from "~/hooks/useSettingsActions";
|
||||
import useTemplateActions from "~/hooks/useTemplateActions";
|
||||
import CommandBarResults from "./CommandBarResults";
|
||||
import useRecentDocumentActions from "./useRecentDocumentActions";
|
||||
import useSettingsAction from "./useSettingsAction";
|
||||
import useTemplatesAction from "./useTemplatesAction";
|
||||
|
||||
function CommandBar() {
|
||||
const { t } = useTranslation();
|
||||
const settingsActions = useSettingsActions();
|
||||
const templateActions = useTemplateActions();
|
||||
const recentDocumentActions = useRecentDocumentActions();
|
||||
const settingsAction = useSettingsAction();
|
||||
const templatesAction = useTemplatesAction();
|
||||
const commandBarActions = React.useMemo(
|
||||
() => [...rootActions, templateActions, settingsActions],
|
||||
[settingsActions, templateActions]
|
||||
() => [
|
||||
...recentDocumentActions,
|
||||
...rootActions,
|
||||
templatesAction,
|
||||
settingsAction,
|
||||
],
|
||||
[recentDocumentActions, settingsAction, templatesAction]
|
||||
);
|
||||
|
||||
useCommandBarActions(commandBarActions);
|
||||
@@ -30,7 +37,9 @@ function CommandBar() {
|
||||
<Positioner>
|
||||
<Animator>
|
||||
<SearchActions />
|
||||
<SearchInput defaultPlaceholder={t("Type a command or search")} />
|
||||
<SearchInput
|
||||
defaultPlaceholder={`${t("Type a command or search")}…`}
|
||||
/>
|
||||
<CommandBarResults />
|
||||
</Animator>
|
||||
</Positioner>
|
||||
@@ -60,12 +69,15 @@ const Positioner = styled(KBarPositioner)`
|
||||
`;
|
||||
|
||||
const SearchInput = styled(KBarSearch)`
|
||||
padding: 16px 20px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding: 16px 12px;
|
||||
margin: 0 8px;
|
||||
width: calc(100% - 16px);
|
||||
outline: none;
|
||||
border: none;
|
||||
background: ${s("menuBackground")};
|
||||
color: ${s("text")};
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
@@ -5,7 +5,7 @@ import styled, { css, useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import Key from "~/components/Key";
|
||||
import Text from "./Text";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
type Props = {
|
||||
action: ActionImpl;
|
||||
+8
-7
@@ -1,8 +1,8 @@
|
||||
import { useMatches, KBarResults } from "kbar";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import CommandBarItem from "~/components/CommandBarItem";
|
||||
import Text from "~/components/Text";
|
||||
import CommandBarItem from "./CommandBarItem";
|
||||
|
||||
export default function CommandBarResults() {
|
||||
const { results, rootActionId } = useMatches();
|
||||
@@ -14,7 +14,9 @@ export default function CommandBarResults() {
|
||||
maxHeight={400}
|
||||
onRender={({ item, active }) =>
|
||||
typeof item === "string" ? (
|
||||
<Header>{item}</Header>
|
||||
<Header type="tertiary" size="xsmall" ellipsis>
|
||||
{item}
|
||||
</Header>
|
||||
) : (
|
||||
<CommandBarItem
|
||||
action={item}
|
||||
@@ -35,11 +37,10 @@ const Container = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = styled.h3`
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
const Header = styled(Text).attrs({ as: "h3" })`
|
||||
letter-spacing: 0.03em;
|
||||
margin: 0;
|
||||
padding: 16px 0 4px 20px;
|
||||
color: ${s("textTertiary")};
|
||||
height: 36px;
|
||||
cursor: default;
|
||||
`;
|
||||
@@ -0,0 +1,3 @@
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
export default CommandBar;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import { RecentSection } from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
const useRecentDocumentActions = (count = 6) => {
|
||||
const { documents, ui } = useStores();
|
||||
|
||||
return React.useMemo(
|
||||
() =>
|
||||
documents.recentlyViewed
|
||||
.filter((document) => document.id !== ui.activeDocumentId)
|
||||
.slice(0, count)
|
||||
.map((item) =>
|
||||
createAction({
|
||||
name: item.titleWithDefault,
|
||||
analyticsName: "Recently viewed document",
|
||||
section: RecentSection,
|
||||
icon: item.icon ? (
|
||||
<Icon value={item.icon} color={item.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
perform: () => history.push(documentPath(item)),
|
||||
})
|
||||
),
|
||||
[count, ui.activeDocumentId, documents.recentlyViewed]
|
||||
);
|
||||
};
|
||||
|
||||
export default useRecentDocumentActions;
|
||||
@@ -2,10 +2,10 @@ import { SettingsIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { createAction } from "~/actions";
|
||||
import { NavigationSection } from "~/actions/sections";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import history from "~/utils/history";
|
||||
import useSettingsConfig from "./useSettingsConfig";
|
||||
|
||||
const useSettingsActions = () => {
|
||||
const useSettingsAction = () => {
|
||||
const config = useSettingsConfig();
|
||||
const actions = React.useMemo(
|
||||
() =>
|
||||
@@ -38,4 +38,4 @@ const useSettingsActions = () => {
|
||||
return navigateToSettings;
|
||||
};
|
||||
|
||||
export default useSettingsActions;
|
||||
export default useSettingsAction;
|
||||
@@ -3,11 +3,11 @@ import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
import useStores from "./useStores";
|
||||
|
||||
const useTemplatesActions = () => {
|
||||
const useTemplatesAction = () => {
|
||||
const { documents } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -60,4 +60,4 @@ const useTemplatesActions = () => {
|
||||
return newFromTemplate;
|
||||
};
|
||||
|
||||
export default useTemplatesActions;
|
||||
export default useTemplatesAction;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { CollectionPermission, NavigationNode } from "@shared/types";
|
||||
import type Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/** The navigation node to move, must represent a document. */
|
||||
item: NavigationNode;
|
||||
/** The collection to move the document to. */
|
||||
collection: Collection;
|
||||
/** The parent document to move the document under. */
|
||||
parentDocumentId?: string | null;
|
||||
/** The index to move the document to. */
|
||||
index?: number | null;
|
||||
};
|
||||
|
||||
function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
|
||||
const { documents, dialogs, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const prevCollection = collections.get(item.collectionId!);
|
||||
const accessMapping = {
|
||||
[CollectionPermission.ReadWrite]: t("view and edit access"),
|
||||
[CollectionPermission.Read]: t("view only access"),
|
||||
null: t("no access"),
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
...rest,
|
||||
});
|
||||
dialogs.closeAllModals();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Move document")}
|
||||
savingText={`${t("Moving")}…`}
|
||||
>
|
||||
<Trans
|
||||
defaults="Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>."
|
||||
values={{
|
||||
title: item.title,
|
||||
prevCollectionName: prevCollection?.name,
|
||||
newCollectionName: collection.name,
|
||||
prevPermission: accessMapping[prevCollection?.permission || "null"],
|
||||
newPermission: accessMapping[collection.permission || "null"],
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ConfirmMoveDialog);
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { DisconnectedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -11,7 +11,6 @@ import useStores from "~/hooks/useStores";
|
||||
|
||||
function ConnectionStatus() {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const codeToMessage = {
|
||||
@@ -61,7 +60,7 @@ function ConnectionStatus() {
|
||||
>
|
||||
<Button>
|
||||
<Fade>
|
||||
<DisconnectedIcon color={theme.sidebarText} />
|
||||
<DisconnectedIcon />
|
||||
</Fade>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -72,7 +71,7 @@ const Button = styled(NudeButton)`
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
margin: 24px;
|
||||
margin: 20px;
|
||||
transform: translateX(-32px);
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
@@ -6,6 +6,7 @@ import { mergeRefs } from "react-merge-refs";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Text from "../Text";
|
||||
import MenuIconWrapper from "./MenuIconWrapper";
|
||||
|
||||
@@ -74,9 +75,9 @@ const MenuItem = (
|
||||
])}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<MenuIconWrapper aria-hidden>
|
||||
<SelectedWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon /> : <Spacer />}
|
||||
</MenuIconWrapper>
|
||||
</SelectedWrapper>
|
||||
)}
|
||||
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
|
||||
<Title>{children}</Title>
|
||||
@@ -196,4 +197,13 @@ export const MenuAnchor = styled.a`
|
||||
${MenuAnchorCSS}
|
||||
`;
|
||||
|
||||
const SelectedWrapper = styled.span`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
margin-left: -8px;
|
||||
flex-shrink: 0;
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
|
||||
|
||||
@@ -51,6 +51,8 @@ type Props = MenuStateReturn & {
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
/** The maximum width of the context menu. */
|
||||
maxWidth?: number;
|
||||
/** The minimum height of the context menu. */
|
||||
minHeight?: number;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -135,6 +137,7 @@ type InnerContextMenuProps = MenuStateReturn & {
|
||||
menuProps: { style?: React.CSSProperties; placement: string };
|
||||
children: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -220,6 +223,7 @@ const InnerContextMenu = (props: InnerContextMenuProps) => {
|
||||
<Background
|
||||
dir="auto"
|
||||
maxWidth={props.maxWidth}
|
||||
minHeight={props.minHeight}
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
ref={backgroundRef}
|
||||
@@ -257,6 +261,23 @@ export const Position = styled.div`
|
||||
transition-delay: 250ms;
|
||||
transition-property: outline-width;
|
||||
transition-duration: 0;
|
||||
outline: none;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
bottom: 1px;
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
|
||||
outline-color: ${s("accent")};
|
||||
outline-width: initial;
|
||||
outline-offset: -1px;
|
||||
outline-style: solid;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -277,6 +298,7 @@ type BackgroundProps = {
|
||||
topAnchor?: boolean;
|
||||
rightAnchor?: boolean;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
@@ -288,7 +310,7 @@ export const Background = styled(Scrollable)<BackgroundProps>`
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
min-width: 180px;
|
||||
min-height: 44px;
|
||||
min-height: ${(props) => props.minHeight || 44}px;
|
||||
max-height: 75vh;
|
||||
font-weight: normal;
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const DefaultCollectionInputSelect = ({
|
||||
|
||||
const options = React.useMemo(
|
||||
() =>
|
||||
collections.publicCollections.reduce(
|
||||
collections.nonPrivate.reduce(
|
||||
(acc, collection) => [
|
||||
...acc,
|
||||
{
|
||||
@@ -78,7 +78,7 @@ const DefaultCollectionInputSelect = ({
|
||||
},
|
||||
]
|
||||
),
|
||||
[collections.publicCollections, t]
|
||||
[collections.nonPrivate, t]
|
||||
);
|
||||
|
||||
if (fetching) {
|
||||
|
||||
@@ -71,7 +71,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
||||
const can = usePolicy(collection);
|
||||
|
||||
React.useEffect(() => {
|
||||
void document.loadRelations();
|
||||
void document.loadRelations({ withoutPolicies: true });
|
||||
}, [document]);
|
||||
|
||||
let collectionNode: MenuInternalLink | undefined;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Editor } from "~/editor";
|
||||
import useIdle from "~/hooks/useIdle";
|
||||
|
||||
export type DocumentContextValue = {
|
||||
/** The current editor instance for this document. */
|
||||
editor: Editor | null;
|
||||
/** Set the current editor instance for this document. */
|
||||
setEditor: (editor: Editor) => void;
|
||||
};
|
||||
|
||||
const DocumentContext = React.createContext<DocumentContextValue>({
|
||||
editor: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
setEditor() {},
|
||||
});
|
||||
|
||||
export const useDocumentContext = () => React.useContext(DocumentContext);
|
||||
|
||||
const activityEvents = [
|
||||
"click",
|
||||
"mousemove",
|
||||
"DOMMouseScroll",
|
||||
"mousewheel",
|
||||
"mousedown",
|
||||
"touchstart",
|
||||
"touchmove",
|
||||
"focus",
|
||||
];
|
||||
|
||||
export const useEditingFocus = () => {
|
||||
const { editor } = useDocumentContext();
|
||||
const isIdle = useIdle(3000, activityEvents);
|
||||
return isIdle && !!editor?.view.hasFocus();
|
||||
};
|
||||
|
||||
export default DocumentContext;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { action, computed, observable } from "mobx";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import Document from "~/models/Document";
|
||||
import { Editor } from "~/editor";
|
||||
|
||||
class DocumentContext {
|
||||
/** The current document */
|
||||
document?: Document;
|
||||
|
||||
/** The editor instance for this document */
|
||||
editor?: Editor;
|
||||
|
||||
@observable
|
||||
headings: Heading[] = [];
|
||||
|
||||
@computed
|
||||
get hasHeadings() {
|
||||
return this.headings.length > 0;
|
||||
}
|
||||
|
||||
@action
|
||||
setDocument = (document: Document) => {
|
||||
this.document = document;
|
||||
this.updateState();
|
||||
};
|
||||
|
||||
@action
|
||||
setEditor = (editor: Editor) => {
|
||||
this.editor = editor;
|
||||
this.updateState();
|
||||
};
|
||||
|
||||
@action
|
||||
updateState = () => {
|
||||
this.updateHeadings();
|
||||
this.updateTasks();
|
||||
};
|
||||
|
||||
private updateHeadings() {
|
||||
const currHeadings = this.editor?.getHeadings() ?? [];
|
||||
const hasChanged =
|
||||
currHeadings.map((h) => h.level + h.title).join("") !==
|
||||
this.headings.map((h) => h.level + h.title).join("");
|
||||
|
||||
if (hasChanged) {
|
||||
this.headings = currHeadings;
|
||||
}
|
||||
}
|
||||
|
||||
private updateTasks() {
|
||||
const tasks = this.editor?.getTasks() ?? [];
|
||||
const total = tasks.length ?? 0;
|
||||
const completed = tasks.filter((t) => t.completed).length ?? 0;
|
||||
this.document?.updateTasks(total, completed);
|
||||
}
|
||||
}
|
||||
|
||||
const Context = React.createContext<DocumentContext | null>(null);
|
||||
|
||||
export const useDocumentContext = () => {
|
||||
const ctx = React.useContext(Context);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useDocumentContext must be used within DocumentContextProvider"
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const DocumentContextProvider = ({
|
||||
children,
|
||||
}: PropsWithChildren<unknown>) => {
|
||||
const context = React.useMemo(() => new DocumentContext(), []);
|
||||
return <Context.Provider value={context}>{children}</Context.Provider>;
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
|
||||
|
||||
@@ -140,7 +140,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getDocumentChildren(document.id).length
|
||||
? collection.getChildrenForDocument(document.id).length
|
||||
: 0;
|
||||
const canShowProgressBar = isTasks && !isTemplate;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { mergeRefs } from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
@@ -43,21 +42,14 @@ export type Props = Optional<
|
||||
> & {
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
onHeadingsChange?: (headings: Heading[]) => void;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => void;
|
||||
editorStyle?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const {
|
||||
id,
|
||||
shareId,
|
||||
onChange,
|
||||
onHeadingsChange,
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
} = props;
|
||||
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
|
||||
props;
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
const { comments, documents } = useStores();
|
||||
@@ -65,7 +57,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
|
||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
const handleSearchLink = React.useCallback(
|
||||
@@ -212,21 +203,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
[]
|
||||
);
|
||||
|
||||
// Calculate if headings have changed and trigger callback if so
|
||||
const updateHeadings = React.useCallback(() => {
|
||||
if (onHeadingsChange) {
|
||||
const headings = localRef?.current?.getHeadings();
|
||||
if (
|
||||
headings &&
|
||||
headings.map((h) => h.level + h.title).join("") !==
|
||||
previousHeadings.current?.map((h) => h.level + h.title).join("")
|
||||
) {
|
||||
previousHeadings.current = headings;
|
||||
onHeadingsChange(headings);
|
||||
}
|
||||
}
|
||||
}, [localRef, onHeadingsChange]);
|
||||
|
||||
const updateComments = React.useCallback(() => {
|
||||
if (onCreateCommentMark && onDeleteCommentMark && localRef.current) {
|
||||
const commentMarks = localRef.current.getComments();
|
||||
@@ -261,20 +237,18 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const handleChange = React.useCallback(
|
||||
(event) => {
|
||||
onChange?.(event);
|
||||
updateHeadings();
|
||||
updateComments();
|
||||
},
|
||||
[onChange, updateComments, updateHeadings]
|
||||
[onChange, updateComments]
|
||||
);
|
||||
|
||||
const handleRefChanged = React.useCallback(
|
||||
(node: SharedEditor | null) => {
|
||||
if (node) {
|
||||
updateHeadings();
|
||||
updateComments();
|
||||
}
|
||||
},
|
||||
[updateComments, updateHeadings]
|
||||
[updateComments]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import deburr from "lodash/deburr";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { FetchPageParams } from "~/stores/base/Store";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import Text from "~/components/Text";
|
||||
import Input, { NativeInput, Outline } from "./Input";
|
||||
import PaginatedList, { PaginatedItem } from "./PaginatedList";
|
||||
|
||||
type TFilterOption = {
|
||||
interface TFilterOption extends PaginatedItem {
|
||||
key: string;
|
||||
label: string;
|
||||
note?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
options: TFilterOption[];
|
||||
@@ -21,6 +26,9 @@ type Props = {
|
||||
selectedPrefix?: string;
|
||||
className?: string;
|
||||
onSelect: (key: string | null | undefined) => void;
|
||||
showFilter?: boolean;
|
||||
fetchQuery?: (options: FetchPageParams) => Promise<PaginatedItem[]>;
|
||||
fetchQueryOptions?: Record<string, string>;
|
||||
};
|
||||
|
||||
const FilterOptions = ({
|
||||
@@ -30,13 +38,20 @@ const FilterOptions = ({
|
||||
selectedPrefix = "",
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
fetchQuery,
|
||||
fetchQueryOptions,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const selectedItems = options.filter((option) =>
|
||||
selectedKeys.includes(option.key)
|
||||
);
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const selectedLabel = selectedItems.length
|
||||
? selectedItems
|
||||
@@ -44,6 +59,109 @@ const FilterOptions = ({
|
||||
.join(", ")
|
||||
: "";
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(option: TFilterOption) => (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
menu.hide();
|
||||
}}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
{...menu}
|
||||
>
|
||||
{option.icon && <Icon>{option.icon}</Icon>}
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
<Note>{option.note}</Note>
|
||||
</LabelWithNote>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</MenuItem>
|
||||
),
|
||||
[menu, onSelect, selectedKeys]
|
||||
);
|
||||
|
||||
const handleFilter = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(ev.target.value);
|
||||
};
|
||||
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
const normalizedQuery = deburr(query.toLowerCase());
|
||||
|
||||
return query
|
||||
? options
|
||||
.filter((option) =>
|
||||
deburr(option.label).toLowerCase().includes(normalizedQuery)
|
||||
)
|
||||
// sort options starting with query first
|
||||
.sort((a, b) => {
|
||||
const aStartsWith = deburr(a.label)
|
||||
.toLowerCase()
|
||||
.startsWith(normalizedQuery);
|
||||
const bStartsWith = deburr(b.label)
|
||||
.toLowerCase()
|
||||
.startsWith(normalizedQuery);
|
||||
|
||||
if (aStartsWith && !bStartsWith) {
|
||||
return -1;
|
||||
}
|
||||
if (!aStartsWith && bStartsWith) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
: options;
|
||||
}, [options, query]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent) => {
|
||||
if (ev.nativeEvent.isComposing || ev.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (ev.key) {
|
||||
case "Escape":
|
||||
menu.hide();
|
||||
break;
|
||||
case "Enter":
|
||||
if (filteredOptions.length === 1) {
|
||||
ev.preventDefault();
|
||||
onSelect(filteredOptions[0].key);
|
||||
menu.hide();
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
ev.preventDefault();
|
||||
(listRef.current?.firstElementChild as HTMLElement)?.focus();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[filteredOptions, menu, onSelect]
|
||||
);
|
||||
|
||||
const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => {
|
||||
searchInputRef.current?.focus();
|
||||
|
||||
if (ev.key === "Backspace") {
|
||||
setQuery((prev) => prev.slice(0, -1));
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (menu.visible) {
|
||||
searchInputRef.current?.focus();
|
||||
} else {
|
||||
setQuery("");
|
||||
}
|
||||
}, [menu.visible]);
|
||||
|
||||
const showFilterInput = showFilter || options.length > 10;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MenuButton {...menu}>
|
||||
@@ -53,33 +171,73 @@ const FilterOptions = ({
|
||||
</StyledButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={defaultLabel} {...menu}>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
menu.hide();
|
||||
}}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
{...menu}
|
||||
>
|
||||
{option.icon && <Icon>{option.icon}</Icon>}
|
||||
{option.note ? (
|
||||
<LabelWithNote>
|
||||
{option.label}
|
||||
<Note>{option.note}</Note>
|
||||
</LabelWithNote>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
|
||||
<PaginatedList
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
fetch={fetchQuery}
|
||||
renderItem={renderItem}
|
||||
onEscape={handleEscapeFromList}
|
||||
heading={showFilterInput ? <Spacer /> : undefined}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
{showFilterInput && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Empty = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spacer />
|
||||
<Text size="small" type="tertiary" style={{ marginLeft: 6 }}>
|
||||
{t("No results")}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Spacer = styled.div`
|
||||
height: 30px;
|
||||
`;
|
||||
|
||||
const SearchInput = styled(Input)`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
${Outline} {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
background: ${s("menuBackground")};
|
||||
}
|
||||
|
||||
${NativeInput} {
|
||||
font-size: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Note = styled(Text)`
|
||||
display: block;
|
||||
margin: 2px 0;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { IconType } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
@@ -118,12 +117,7 @@ export const IconTitleWrapper = styled(Flex)<{ dir?: string }>`
|
||||
z-index: 1;
|
||||
|
||||
${(props: { dir?: string }) =>
|
||||
props.dir === "rtl" ? "right: -40px" : "left: -40px"};
|
||||
|
||||
${breakpoint("desktop")`
|
||||
${(props: { dir?: string }) =>
|
||||
props.dir === "rtl" ? "right: -44px" : "left: -44px"};
|
||||
`}
|
||||
props.dir === "rtl" ? "right: -44px" : "left: -44px"};
|
||||
`;
|
||||
|
||||
export default Icon;
|
||||
|
||||
@@ -80,8 +80,8 @@ const BuiltinColors = ({
|
||||
{colorPalette.map((color) => (
|
||||
<ColorButton
|
||||
key={color}
|
||||
color={color}
|
||||
active={color === activeColor}
|
||||
$color={color}
|
||||
$active={color === activeColor}
|
||||
onClick={() => onClick(color)}
|
||||
>
|
||||
<Selected />
|
||||
@@ -156,22 +156,22 @@ const Selected = styled.span`
|
||||
transform: translateY(-25%) rotate(-45deg);
|
||||
`;
|
||||
|
||||
const ColorButton = styled(NudeButton)<{ color: string; active: boolean }>`
|
||||
const ColorButton = styled(NudeButton)<{ $color: string; $active: boolean }>`
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: ${({ color }) => color};
|
||||
background-color: ${({ $color }) => $color};
|
||||
|
||||
&: ${hover} {
|
||||
outline: 2px solid ${s("menuBackground")} !important;
|
||||
box-shadow: ${({ color }) => `0px 0px 3px 3px ${color}`};
|
||||
box-shadow: ${({ $color }) => `0px 0px 3px 3px ${$color}`};
|
||||
}
|
||||
|
||||
& ${Selected} {
|
||||
display: ${({ active }) => (active ? "block" : "none")};
|
||||
display: ${({ $active }) => ($active ? "block" : "none")};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "@getoutline/react-roving-tabindex";
|
||||
import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
@@ -13,9 +13,9 @@ import withStores from "~/components/withStores";
|
||||
import { dateToHeading } from "~/utils/date";
|
||||
|
||||
export interface PaginatedItem {
|
||||
id: string;
|
||||
createdAt?: string;
|
||||
id?: string;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
type Props<T> = WithTranslation &
|
||||
@@ -36,6 +36,7 @@ type Props<T> = WithTranslation &
|
||||
}) => React.ReactNode;
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
listRef?: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -196,6 +197,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
onEscape={onEscape}
|
||||
className={this.props.className}
|
||||
items={this.itemsToRender}
|
||||
ref={this.props.listRef}
|
||||
>
|
||||
{() => {
|
||||
let previousHeading = "";
|
||||
@@ -211,7 +213,11 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
// Our models have standard date fields, updatedAt > createdAt.
|
||||
// Get what a heading would look like for this item
|
||||
const currentDate =
|
||||
item.updatedAt || item.createdAt || previousHeading;
|
||||
"updatedAt" in item && item.updatedAt
|
||||
? item.updatedAt
|
||||
: "createdAt" in item && item.createdAt
|
||||
? item.createdAt
|
||||
: previousHeading;
|
||||
const currentHeading = dateToHeading(
|
||||
currentDate,
|
||||
this.props.t,
|
||||
@@ -227,7 +233,9 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
) {
|
||||
previousHeading = currentHeading;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
<React.Fragment
|
||||
key={"id" in item && item.id ? item.id : index}
|
||||
>
|
||||
{renderHeading(currentHeading)}
|
||||
{children}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { m, TargetAndTransition } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
|
||||
type Props = {
|
||||
@@ -18,35 +19,37 @@ type Props = {
|
||||
/**
|
||||
* Automatically animates the height of a container based on it's contents.
|
||||
*/
|
||||
export function ResizingHeightContainer(props: Props) {
|
||||
const {
|
||||
hideOverflow,
|
||||
children,
|
||||
config = {
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
ease: "easeInOut",
|
||||
export const ResizingHeightContainer = React.forwardRef<HTMLDivElement, Props>(
|
||||
function ResizingHeightContainer_(props, forwardedRef) {
|
||||
const {
|
||||
hideOverflow,
|
||||
children,
|
||||
config = {
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
},
|
||||
style,
|
||||
} = props;
|
||||
style,
|
||||
} = props;
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const { height } = useComponentSize(ref);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const { height } = useComponentSize(ref);
|
||||
|
||||
return (
|
||||
<m.div
|
||||
animate={{
|
||||
...config,
|
||||
height: Math.round(height),
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
overflow: hideOverflow ? "hidden" : "inherit",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div ref={ref}>{children}</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<m.div
|
||||
animate={{
|
||||
...config,
|
||||
height: Math.round(height),
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
overflow: hideOverflow ? "hidden" : "inherit",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div ref={mergeRefs([ref, forwardedRef])}>{children}</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function SearchActions() {
|
||||
const { searches } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!searches.isLoaded) {
|
||||
if (!searches.isLoaded && !searches.isFetching) {
|
||||
void searches.fetchPage({
|
||||
source: "app",
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import Empty from "~/components/Empty";
|
||||
import { Outline } from "~/components/Input";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Placeholder from "~/components/List/Placeholder";
|
||||
import PaginatedList, { PaginatedItem } from "~/components/PaginatedList";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Popover from "~/components/Popover";
|
||||
import { id as bodyContentId } from "~/components/SkipNavContent";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
@@ -36,11 +36,11 @@ function SearchPopover({ shareId }: Props) {
|
||||
const { show, hide } = popover;
|
||||
|
||||
const [searchResults, setSearchResults] = React.useState<
|
||||
PaginatedItem[] | undefined
|
||||
SearchResult[] | undefined
|
||||
>();
|
||||
const [cachedQuery, setCachedQuery] = React.useState(query);
|
||||
const [cachedSearchResults, setCachedSearchResults] = React.useState<
|
||||
PaginatedItem[] | undefined
|
||||
SearchResult[] | undefined
|
||||
>(searchResults);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -54,7 +54,7 @@ function SearchPopover({ shareId }: Props) {
|
||||
const performSearch = React.useCallback(
|
||||
async ({ query, ...options }) => {
|
||||
if (query?.length > 0) {
|
||||
const response: PaginatedItem[] = await documents.search(query, {
|
||||
const response = await documents.search(query, {
|
||||
shareId,
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -137,6 +137,7 @@ export const AccessControlList = observer(
|
||||
}
|
||||
/>
|
||||
{groupMembershipsInCollection
|
||||
.filter((membership) => membership.group)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.group.id) ? "_" : "") +
|
||||
@@ -189,6 +190,7 @@ export const AccessControlList = observer(
|
||||
/>
|
||||
))}
|
||||
{membershipsInCollection
|
||||
.filter((membership) => membership.user)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.user.id) ? "_" : "") +
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionPermission, IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import type Collection from "~/models/Collection";
|
||||
@@ -67,7 +68,7 @@ export const AccessControlList = observer(
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const { maxHeight, calcMaxHeight } = useMaxHeight({
|
||||
elementRef: containerRef,
|
||||
maxViewportPercentage: 70,
|
||||
maxViewportPercentage: 65,
|
||||
margin: 24,
|
||||
});
|
||||
|
||||
@@ -201,7 +202,7 @@ export const AccessControlList = observer(
|
||||
</>
|
||||
)}
|
||||
{team.sharing && can.share && !collectionSharingDisabled && visible && (
|
||||
<>
|
||||
<Sticky>
|
||||
{document.members.length ? <Separator /> : null}
|
||||
<PublicAccess
|
||||
document={document}
|
||||
@@ -209,7 +210,7 @@ export const AccessControlList = observer(
|
||||
sharedParent={sharedParent}
|
||||
onRequestClose={onRequestClose}
|
||||
/>
|
||||
</>
|
||||
</Sticky>
|
||||
)}
|
||||
</ScrollableContainer>
|
||||
);
|
||||
@@ -274,6 +275,12 @@ function useUsersInCollection(collection?: Collection) {
|
||||
: false;
|
||||
}
|
||||
|
||||
const Sticky = styled.div`
|
||||
background: ${s("menuBackground")};
|
||||
position: sticky;
|
||||
bottom: -12px;
|
||||
`;
|
||||
|
||||
const ScrollableContainer = styled(Scrollable)`
|
||||
padding: 12px 24px;
|
||||
margin: -12px -24px;
|
||||
|
||||
@@ -203,7 +203,7 @@ const StyledInfoIcon = styled(InfoIcon)`
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
`;
|
||||
|
||||
const DomainPrefix = styled.span`
|
||||
|
||||
@@ -98,7 +98,7 @@ export const Suggestions = observer(
|
||||
: collection
|
||||
? users.notInCollection(collection.id, query)
|
||||
: users.orderedData
|
||||
).filter((u) => !u.isSuspended);
|
||||
).filter((u) => !u.isSuspended && u.id !== user.id);
|
||||
|
||||
if (isEmail(query)) {
|
||||
filtered.push(getSuggestionForEmail(query));
|
||||
|
||||
@@ -15,7 +15,7 @@ export const Wrapper = styled.div`
|
||||
|
||||
export const Separator = styled.div`
|
||||
border-top: 1px dashed ${s("divider")};
|
||||
margin: 12px 0;
|
||||
margin: 8px 0;
|
||||
`;
|
||||
|
||||
export const HeaderInput = styled(Flex)`
|
||||
|
||||
@@ -94,37 +94,39 @@ function AppSidebar() {
|
||||
</SidebarButton>
|
||||
)}
|
||||
</OrganizationMenu>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={homePath()}
|
||||
icon={<HomeIcon />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={searchPath()}
|
||||
icon={<SearchIcon />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
{can.createDocument && (
|
||||
<SidebarLink
|
||||
to={draftsPath()}
|
||||
icon={<DraftsIcon />}
|
||||
label={
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts > 25
|
||||
? "25+"
|
||||
: documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Scrollable flex shadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={homePath()}
|
||||
icon={<HomeIcon />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={searchPath()}
|
||||
icon={<SearchIcon />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
{can.createDocument && (
|
||||
<SidebarLink
|
||||
to={draftsPath()}
|
||||
icon={<DraftsIcon />}
|
||||
label={
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Section>
|
||||
<Starred />
|
||||
</Section>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import AccountMenu from "~/menus/AccountMenu";
|
||||
@@ -39,6 +40,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const previousLocation = usePrevious(location);
|
||||
const { isMenuOpen } = useMenuContext();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const isMobile = useMobile();
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
@@ -189,6 +191,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
$isMobile={isMobile}
|
||||
className={className}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
@@ -256,6 +259,7 @@ type ContainerProps = {
|
||||
$isHovering: boolean;
|
||||
$collapsed: boolean;
|
||||
$hidden: boolean;
|
||||
$isMobile: boolean;
|
||||
};
|
||||
|
||||
const hoverStyles = (props: ContainerProps) => `
|
||||
@@ -298,8 +302,19 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
|
||||
& > div {
|
||||
transition: opacity 150ms ease-in-out;
|
||||
opacity: ${(props) =>
|
||||
props.$hidden || (props.$collapsed && !props.$isHovering) ? "0" : "1"};
|
||||
opacity: ${(props) => {
|
||||
if (props.$hidden) {
|
||||
return "0";
|
||||
}
|
||||
if (props.$isHovering) {
|
||||
return "1";
|
||||
}
|
||||
if (props.$isMobile) {
|
||||
return props.$mobileSidebarVisible ? "1" : "0";
|
||||
} else {
|
||||
return props.$collapsed ? "0" : "1";
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useHistory } from "react-router-dom";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentReparent from "~/scenes/DocumentReparent";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -78,20 +78,12 @@ const CollectionLink: React.FC<Props> = ({
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission === null &&
|
||||
prevCollection.permission !== collection.permission &&
|
||||
!document?.isDraft
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Move document"),
|
||||
content: (
|
||||
<DocumentReparent
|
||||
item={item}
|
||||
collection={collection}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
onCancel={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
title: t("Change permissions?"),
|
||||
content: <ConfirmMoveDialog item={item} collection={collection} />,
|
||||
});
|
||||
} else {
|
||||
await documents.move({ documentId: id, collectionId: collection.id });
|
||||
|
||||
@@ -6,21 +6,26 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import DocumentsLoader from "~/components/DocumentsLoader";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Text from "~/components/Text";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import useCollectionDocuments from "../hooks/useCollectionDocuments";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import EmptyCollectionPlaceholder from "./EmptyCollectionPlaceholder";
|
||||
import Folder from "./Folder";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
import useCollectionDocuments from "./useCollectionDocuments";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
/** The collection to render the children of. */
|
||||
collection: Collection;
|
||||
/** Whether the children are shown in an expanded state. */
|
||||
expanded: boolean;
|
||||
/** Function to prefetch a document by ID. */
|
||||
prefetchDocument?: (documentId: string) => Promise<Document | void>;
|
||||
};
|
||||
|
||||
@@ -31,9 +36,8 @@ function CollectionLinkChildren({
|
||||
}: Props) {
|
||||
const can = usePolicy(collection);
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const { documents } = useStores();
|
||||
const { documents, dialogs, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const childDocuments = useCollectionDocuments(collection, documents.active);
|
||||
|
||||
// Drop to reorder document
|
||||
@@ -52,11 +56,26 @@ function CollectionLinkChildren({
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
if (
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: (
|
||||
<ConfirmMoveDialog item={item} collection={collection} index={0} />
|
||||
),
|
||||
});
|
||||
} else {
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
@@ -91,7 +110,17 @@ function CollectionLinkChildren({
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
{childDocuments?.length === 0 && <EmptyCollectionPlaceholder />}
|
||||
{childDocuments?.length === 0 && (
|
||||
<SidebarLink
|
||||
label={
|
||||
<Text type="tertiary" size="small" italic>
|
||||
{t("Empty")}
|
||||
</Text>
|
||||
}
|
||||
onClick={() => history.push(collection.url)}
|
||||
depth={2}
|
||||
/>
|
||||
)}
|
||||
</DocumentsLoader>
|
||||
</Folder>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
@@ -20,14 +19,18 @@ import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { newNestedDocumentPath } from "~/utils/routeHelpers";
|
||||
import {
|
||||
useDragDocument,
|
||||
useDropToReorderDocument,
|
||||
useDropToReparentDocument,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import DropCursor from "./DropCursor";
|
||||
import DropToImport from "./DropToImport";
|
||||
import EditableTitle, { RefHandle } from "./EditableTitle";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import { useDragDocument, useDropToReorderDocument } from "./useDragAndDrop";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
@@ -80,11 +83,6 @@ function InnerDocumentLink(
|
||||
isActiveDocument,
|
||||
]);
|
||||
|
||||
const pathToNode = React.useMemo(
|
||||
() => collection?.pathToDocument(node.id).map((entry) => entry.id),
|
||||
[collection, node]
|
||||
);
|
||||
|
||||
const showChildren = React.useMemo(
|
||||
() =>
|
||||
!!(
|
||||
@@ -100,27 +98,27 @@ function InnerDocumentLink(
|
||||
[hasChildDocuments, activeDocument, isActiveDocument, node, collection]
|
||||
);
|
||||
|
||||
const [expanded, setExpanded] = React.useState(showChildren);
|
||||
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showChildren) {
|
||||
setExpanded(showChildren);
|
||||
setExpanded();
|
||||
}
|
||||
}, [showChildren]);
|
||||
}, [setExpanded, showChildren]);
|
||||
|
||||
// when the last child document is removed auto-close the local folder state
|
||||
React.useEffect(() => {
|
||||
if (expanded && !hasChildDocuments) {
|
||||
setExpanded(false);
|
||||
setCollapsed();
|
||||
}
|
||||
}, [expanded, hasChildDocuments]);
|
||||
}, [setCollapsed, expanded, hasChildDocuments]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded(!expanded);
|
||||
expanded ? setCollapsed() : setExpanded();
|
||||
},
|
||||
[expanded]
|
||||
[setCollapsed, setExpanded, expanded]
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
@@ -148,72 +146,10 @@ function InnerDocumentLink(
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
|
||||
|
||||
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
const resetHoverExpanding = React.useCallback(() => {
|
||||
if (hoverExpanding.current) {
|
||||
clearTimeout(hoverExpanding.current);
|
||||
hoverExpanding.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Drop to re-parent
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject, monitor) => {
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
setExpanded(true);
|
||||
},
|
||||
canDrop: (item, monitor) =>
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem<DragObject>().id) &&
|
||||
item.id !== node.id &&
|
||||
policies.abilities(node.id).update &&
|
||||
policies.abilities(item.id).move,
|
||||
hover: (_item, monitor) => {
|
||||
// Enables expansion of document children when hovering over the document
|
||||
// for more than half a second.
|
||||
if (
|
||||
hasChildDocuments &&
|
||||
monitor.canDrop() &&
|
||||
monitor.isOver({
|
||||
shallow: true,
|
||||
})
|
||||
) {
|
||||
if (!hoverExpanding.current) {
|
||||
hoverExpanding.current = setTimeout(() => {
|
||||
hoverExpanding.current = undefined;
|
||||
|
||||
if (
|
||||
monitor.isOver({
|
||||
shallow: true,
|
||||
})
|
||||
) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReparent: monitor.isOver({
|
||||
shallow: true,
|
||||
}),
|
||||
canDropToReparent: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] =
|
||||
useDropToReparentDocument(node, setExpanded, parentRef);
|
||||
|
||||
// Drop to reorder
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] =
|
||||
@@ -271,18 +207,18 @@ function InnerDocumentLink(
|
||||
return;
|
||||
}
|
||||
if (ev.key === "ArrowRight" && !expanded) {
|
||||
setExpanded(true);
|
||||
setExpanded();
|
||||
}
|
||||
if (ev.key === "ArrowLeft" && expanded) {
|
||||
setExpanded(false);
|
||||
setCollapsed();
|
||||
}
|
||||
},
|
||||
[hasChildren, expanded]
|
||||
[setExpanded, setCollapsed, hasChildren, expanded]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Relative onDragLeave={resetHoverExpanding}>
|
||||
<Relative ref={parentRef}>
|
||||
<Draggable
|
||||
key={node.id}
|
||||
ref={drag}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
const EmptyCollectionPlaceholder = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Empty type="tertiary" size="small">
|
||||
{t("Empty")}
|
||||
</Empty>
|
||||
);
|
||||
};
|
||||
|
||||
const Empty = styled(Text)`
|
||||
margin-left: 46px;
|
||||
margin-bottom: 0;
|
||||
line-height: 34px;
|
||||
font-style: italic;
|
||||
`;
|
||||
|
||||
export default EmptyCollectionPlaceholder;
|
||||
@@ -44,12 +44,12 @@ function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
|
||||
return (
|
||||
<Navigation gap={4} {...props}>
|
||||
<Tooltip content={t("Go back")} delay={500}>
|
||||
<NudeButton onClick={() => Desktop.bridge.goBack()}>
|
||||
<NudeButton onClick={() => Desktop.bridge?.goBack()}>
|
||||
<Back $active={back} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Go forward")} delay={500}>
|
||||
<NudeButton onClick={() => Desktop.bridge.goForward()}>
|
||||
<NudeButton onClick={() => Desktop.bridge?.goForward()}>
|
||||
<Forward $active={forward} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
match,
|
||||
} from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import history from "~/utils/history";
|
||||
|
||||
const resolveToLocation = (
|
||||
|
||||
@@ -11,6 +11,7 @@ import Flex from "~/components/Flex";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePaginatedRequest from "~/hooks/usePaginatedRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useDropToReorderUserMembership } from "../hooks/useDragAndDrop";
|
||||
import DropCursor from "./DropCursor";
|
||||
import GroupLink from "./GroupLink";
|
||||
import Header from "./Header";
|
||||
@@ -19,7 +20,6 @@ import Relative from "./Relative";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import { useDropToReorderUserMembership } from "./useDragAndDrop";
|
||||
|
||||
function SharedWithMe() {
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
|
||||
@@ -11,18 +11,19 @@ import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import {
|
||||
useDragMembership,
|
||||
useDropToReorderUserMembership,
|
||||
useDropToReparentDocument,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import { useSidebarContext, type SidebarContextType } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import {
|
||||
useDragMembership,
|
||||
useDropToReorderUserMembership,
|
||||
} from "./useDragAndDrop";
|
||||
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
|
||||
type Props = {
|
||||
membership: UserMembership | GroupMembership;
|
||||
@@ -37,8 +38,9 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
const isActiveDocument = documentId === ui.activeDocumentId;
|
||||
const locationSidebarContext = useLocationState();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const document = documentId ? documents.get(documentId) : undefined;
|
||||
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
const [expanded, setExpanded, setCollapsed] = useBoolean(
|
||||
membership.documentId === ui.activeDocumentId &&
|
||||
locationSidebarContext === sidebarContext
|
||||
);
|
||||
@@ -48,13 +50,14 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
membership.documentId === ui.activeDocumentId &&
|
||||
locationSidebarContext === sidebarContext
|
||||
) {
|
||||
setExpanded(true);
|
||||
setExpanded();
|
||||
}
|
||||
}, [
|
||||
membership.documentId,
|
||||
ui.activeDocumentId,
|
||||
sidebarContext,
|
||||
locationSidebarContext,
|
||||
setExpanded,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -73,11 +76,20 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((prevExpanded) => !prevExpanded);
|
||||
if (expanded) {
|
||||
setCollapsed();
|
||||
} else {
|
||||
setExpanded();
|
||||
}
|
||||
},
|
||||
[]
|
||||
[expanded, setExpanded, setCollapsed]
|
||||
);
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const node = React.useMemo(() => document?.asNavigationNode, [document]);
|
||||
const [{ isOverReparent, canDropToReparent }, dropToReparent] =
|
||||
useDropToReparentDocument(node, setExpanded, parentRef);
|
||||
|
||||
const { icon } = useSidebarLabelAndIcon(membership);
|
||||
const [{ isDragging }, draggableRef] = useDragMembership(membership);
|
||||
|
||||
@@ -93,12 +105,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
|
||||
const displayChildDocuments = expanded && !isDragging;
|
||||
|
||||
if (documentId) {
|
||||
const document = documents.get(documentId);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (document) {
|
||||
const { icon: docIcon } = document;
|
||||
const label =
|
||||
determineIconType(docIcon) === IconType.Emoji
|
||||
@@ -114,67 +121,75 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Draggable
|
||||
key={membership.id}
|
||||
ref={draggableRef}
|
||||
$isDragging={isDragging}
|
||||
>
|
||||
<SidebarLink
|
||||
depth={depth}
|
||||
to={{
|
||||
pathname: document.path,
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
expanded={hasChildDocuments && !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
|
||||
}
|
||||
/>
|
||||
</Draggable>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
<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={
|
||||
hasChildDocuments && !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
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
{reorderProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderProps.isOverCursor}
|
||||
innerRef={dropToReorderRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Draggable>
|
||||
</Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
{reorderProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderProps.isOverCursor}
|
||||
innerRef={dropToReorderRef}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import DelayedMount from "~/components/DelayedMount";
|
||||
import Flex from "~/components/Flex";
|
||||
import usePaginatedRequest from "~/hooks/usePaginatedRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
useDropToCreateStar,
|
||||
useDropToReorderStar,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
@@ -14,7 +18,6 @@ import Relative from "./Relative";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import StarredLink from "./StarredLink";
|
||||
import { useDropToCreateStar, useDropToReorderStar } from "./useDragAndDrop";
|
||||
|
||||
const STARRED_PAGINATION_LIMIT = 10;
|
||||
|
||||
|
||||
@@ -10,7 +10,13 @@ import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import {
|
||||
useDragStar,
|
||||
useDropToCreateStar,
|
||||
useDropToReorderStar,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
@@ -22,12 +28,6 @@ import SidebarContext, {
|
||||
useSidebarContext,
|
||||
} from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import {
|
||||
useDragStar,
|
||||
useDropToCreateStar,
|
||||
useDropToReorderStar,
|
||||
} from "./useDragAndDrop";
|
||||
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
|
||||
type Props = {
|
||||
star: Star;
|
||||
@@ -116,7 +116,7 @@ function StarredLink({ star }: Props) {
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const childDocuments = collection
|
||||
? collection.getDocumentChildren(documentId)
|
||||
? collection.getChildrenForDocument(documentId)
|
||||
: [];
|
||||
const hasChildDocuments = childDocuments.length > 0;
|
||||
|
||||
|
||||
+148
-5
@@ -12,10 +12,11 @@ import Document from "~/models/Document";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import Star from "~/models/Star";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import Icon from "~/components/Icon";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
import { DragObject } from "../components/SidebarLink";
|
||||
import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
|
||||
/**
|
||||
@@ -160,6 +161,120 @@ export function useDragDocument(
|
||||
return [{ isDragging }, draggableRef] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dropping documents to reparent
|
||||
*
|
||||
* @param node The NavigationNode model to drop.
|
||||
* @param setExpanded A function to expand the parent node.
|
||||
* @param parentRef A ref to the parent element that will be used to detect when the user is no longer hovering..
|
||||
*/
|
||||
export function useDropToReparentDocument(
|
||||
node: NavigationNode | undefined,
|
||||
setExpanded: () => void,
|
||||
parentRef: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
const hasChildDocuments = !!node?.children.length;
|
||||
const document = node ? documents.get(node.id) : undefined;
|
||||
const pathToNode = React.useMemo(
|
||||
() => document?.pathTo.map((item) => item.id),
|
||||
[document]
|
||||
);
|
||||
|
||||
const hoverExpanding = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// We set a timeout when the user first starts hovering over the document link,
|
||||
// to trigger expansion of children. Clear this timeout when they stop hovering.
|
||||
React.useEffect(() => {
|
||||
const resetHoverExpanding = () => {
|
||||
if (hoverExpanding.current) {
|
||||
clearTimeout(hoverExpanding.current);
|
||||
hoverExpanding.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const element = parentRef.current;
|
||||
element?.addEventListener("dragleave", resetHoverExpanding);
|
||||
|
||||
return () => {
|
||||
element?.removeEventListener("dragleave", resetHoverExpanding);
|
||||
};
|
||||
}, [parentRef]);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOverReparent: boolean; canDropToReparent: boolean }
|
||||
>({
|
||||
accept: "document",
|
||||
drop: async (item, monitor) => {
|
||||
if (monitor.didDrop() || !node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = documents.get(node.id)?.collection;
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
if (
|
||||
collection &&
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: (
|
||||
<ConfirmMoveDialog
|
||||
item={item}
|
||||
collection={collection}
|
||||
parentDocumentId={node.id}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
}
|
||||
|
||||
setExpanded();
|
||||
},
|
||||
canDrop: (item, monitor) =>
|
||||
!!node &&
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem().id) &&
|
||||
item.id !== node.id &&
|
||||
policies.abilities(node.id).update &&
|
||||
policies.abilities(item.id).move,
|
||||
hover: (_item, monitor) => {
|
||||
// Enables expansion of document children when hovering over the document
|
||||
// for more than half a second.
|
||||
if (
|
||||
hasChildDocuments &&
|
||||
monitor.canDrop() &&
|
||||
monitor.isOver({
|
||||
shallow: true,
|
||||
})
|
||||
) {
|
||||
if (!hoverExpanding.current) {
|
||||
hoverExpanding.current = setTimeout(() => {
|
||||
hoverExpanding.current = undefined;
|
||||
|
||||
if (monitor.isOver({ shallow: true })) {
|
||||
setExpanded();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReparent: monitor.isOver({ shallow: true }),
|
||||
canDropToReparent: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dropping documents to reorder
|
||||
*
|
||||
@@ -180,7 +295,7 @@ export function useDropToReorderDocument(
|
||||
}
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { documents, policies } = useStores();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
@@ -189,11 +304,19 @@ export function useDropToReorderDocument(
|
||||
>({
|
||||
accept: "document",
|
||||
canDrop: (item: DragObject) => {
|
||||
if (item.id === node.id) {
|
||||
if (item.id === node.id || !policies.abilities(item.id)?.move) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return policies.abilities(item.id)?.move;
|
||||
const params = getMoveParams(item);
|
||||
if (params?.collectionId) {
|
||||
return policies.abilities(params.collectionId)?.updateDocument;
|
||||
}
|
||||
if (params?.parentDocumentId) {
|
||||
return policies.abilities(params.parentDocumentId)?.update;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
drop: async (item) => {
|
||||
if (!collection?.isManualSort && item.collectionId === collection?.id) {
|
||||
@@ -206,8 +329,28 @@ export function useDropToReorderDocument(
|
||||
}
|
||||
|
||||
const params = getMoveParams(item);
|
||||
|
||||
if (params) {
|
||||
void documents.move(params);
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
|
||||
if (
|
||||
collection &&
|
||||
prevCollection &&
|
||||
prevCollection.permission !== collection.permission
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Change permissions?"),
|
||||
content: (
|
||||
<ConfirmMoveDialog
|
||||
item={item}
|
||||
collection={collection}
|
||||
{...params}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
void documents.move(params);
|
||||
}
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import type { Props } from "./Table";
|
||||
|
||||
@@ -12,8 +12,12 @@ type Props = {
|
||||
selectable?: boolean;
|
||||
/** The font weight of the text */
|
||||
weight?: "xbold" | "bold" | "normal";
|
||||
/** Whether the text should be italic */
|
||||
italic?: boolean;
|
||||
/** Whether the text should be truncated with an ellipsis */
|
||||
ellipsis?: boolean;
|
||||
/** Whether the text should be monospaced */
|
||||
monospace?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,6 +60,10 @@ const Text = styled.span<Props>`
|
||||
: "inherit"};
|
||||
`}
|
||||
|
||||
font-style: ${(props) => (props.italic ? "italic" : "normal")};
|
||||
font-family: ${(props) =>
|
||||
props.monospace ? props.theme.fontFamilyMono : "inherit"};
|
||||
|
||||
white-space: normal;
|
||||
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
initialValue = this.href;
|
||||
initialSelectionLength = this.props.to - this.props.from;
|
||||
resultsRef = React.createRef<HTMLDivElement>();
|
||||
inputRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
state: State = {
|
||||
selectedIndex: -1,
|
||||
@@ -91,7 +92,13 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
return this.state.value.trim() || this.selectedText;
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
window.addEventListener("keydown", this.handleGlobalKeyDown);
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
window.removeEventListener("keydown", this.handleGlobalKeyDown);
|
||||
|
||||
// If we discarded the changes then nothing to do
|
||||
if (this.discardInputValue) {
|
||||
return;
|
||||
@@ -111,6 +118,12 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
this.save(href, href);
|
||||
};
|
||||
|
||||
handleGlobalKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === "k" && event.metaKey) {
|
||||
this.inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
|
||||
save = (href: string, title?: string): void => {
|
||||
href = href.trim();
|
||||
|
||||
@@ -321,6 +334,7 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Input
|
||||
ref={this.inputRef}
|
||||
value={value}
|
||||
placeholder={
|
||||
showCreateLink
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
|
||||
@@ -22,7 +22,7 @@ function LinkSearchResult({
|
||||
const ref = React.useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (selected && node) {
|
||||
void scrollIntoView(node, {
|
||||
scrollIntoView(node, {
|
||||
scrollMode: "if-needed",
|
||||
block: "center",
|
||||
boundary: (parent) =>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { v4 } from "uuid";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
@@ -11,6 +12,7 @@ import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import MentionMenuItem from "./MentionMenuItem";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
@@ -45,7 +47,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
React.useCallback(
|
||||
() =>
|
||||
documentId
|
||||
? users.fetchDocumentUsers({ id: documentId, query: search })
|
||||
? users.fetchPage({ id: documentId, query: search })
|
||||
: Promise.resolve([]),
|
||||
[users, documentId, search]
|
||||
)
|
||||
@@ -78,6 +80,33 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
}
|
||||
}, [auth.currentUserId, loading, data]);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
async (item: MentionItem) => {
|
||||
// Check if the mentioned user has access to the document
|
||||
const res = await client.post("/documents.users", {
|
||||
id: documentId,
|
||||
userId: item.attrs.modelId,
|
||||
});
|
||||
|
||||
if (!res.data.length) {
|
||||
const user = users.get(item.attrs.modelId);
|
||||
toast.message(
|
||||
t(
|
||||
"{{ userName }} won't by notified as they do not have access to this document",
|
||||
{
|
||||
userName: item.attrs.label,
|
||||
}
|
||||
),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
duration: 10000,
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[t, users, documentId]
|
||||
);
|
||||
|
||||
// Prevent showing the menu until we have data otherwise it will be positioned
|
||||
// incorrectly due to the height being unknown.
|
||||
if (!loaded) {
|
||||
@@ -91,6 +120,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
filterable={false}
|
||||
trigger="@"
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<MentionMenuItem
|
||||
onClick={options.onClick}
|
||||
|
||||
@@ -60,7 +60,10 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
uploadFile?: (file: File) => Promise<string>;
|
||||
onFileUploadStart?: () => void;
|
||||
onFileUploadStop?: () => void;
|
||||
/** Callback when the menu is closed */
|
||||
onClose: (insertNewLine?: boolean) => void;
|
||||
/** Optional callback when a suggestion is selected */
|
||||
onSelect?: (item: MenuItem) => void;
|
||||
embeds?: EmbedDescriptor[];
|
||||
renderMenuItem: (
|
||||
item: T,
|
||||
@@ -244,6 +247,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
|
||||
const handleClickItem = React.useCallback(
|
||||
(item) => {
|
||||
props.onSelect?.(item);
|
||||
|
||||
switch (item.name) {
|
||||
case "image":
|
||||
return triggerFilePick(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import { usePortalContext } from "~/components/Portal";
|
||||
@@ -31,7 +31,7 @@ function SuggestionsMenuItem({
|
||||
const ref = React.useCallback(
|
||||
(node) => {
|
||||
if (selected && node) {
|
||||
void scrollIntoView(node, {
|
||||
scrollIntoView(node, {
|
||||
scrollMode: "if-needed",
|
||||
block: "nearest",
|
||||
boundary: (parent) =>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Node } from "prosemirror-model";
|
||||
import { Command, Plugin, PluginKey } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import FindAndReplace from "../components/FindAndReplace";
|
||||
|
||||
@@ -184,7 +184,7 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
`.${this.options.resultCurrentClassName}`
|
||||
);
|
||||
if (element) {
|
||||
void scrollIntoView(element, {
|
||||
scrollIntoView(element, {
|
||||
scrollMode: "if-needed",
|
||||
block: "center",
|
||||
});
|
||||
|
||||
@@ -766,6 +766,9 @@ export class Editor extends React.PureComponent<
|
||||
};
|
||||
|
||||
private handleOpenLinkToolbar = () => {
|
||||
if (this.state.selectionToolbarOpen) {
|
||||
return;
|
||||
}
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
linkToolbarOpen: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
Heading4Icon,
|
||||
HorizontalRuleIcon,
|
||||
OrderedListIcon,
|
||||
PageBreakIcon,
|
||||
@@ -63,6 +64,14 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
|
||||
shortcut: "^ ⇧ 3",
|
||||
attrs: { level: 3 },
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
title: dictionary.h4,
|
||||
keywords: "h4 heading4",
|
||||
icon: <Heading4Icon />,
|
||||
shortcut: "^ ⇧ 4",
|
||||
attrs: { level: 4 },
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
tooltip: dictionary.mark,
|
||||
icon: highlight ? (
|
||||
<CircleIcon color={highlight.mark.attrs.color} />
|
||||
<CircleIcon color={highlight.mark.attrs.color || Highlight.colors[0]} />
|
||||
) : (
|
||||
<HighlightIcon />
|
||||
),
|
||||
|
||||
@@ -3,7 +3,7 @@ import Desktop from "~/utils/Desktop";
|
||||
|
||||
export const useDesktopTitlebar = () => {
|
||||
React.useEffect(() => {
|
||||
if (!Desktop.isElectron()) {
|
||||
if (!Desktop.bridge) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const useDesktopTitlebar = () => {
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
await Desktop.bridge.onTitlebarDoubleClick();
|
||||
await Desktop.bridge?.onTitlebarDoubleClick();
|
||||
};
|
||||
|
||||
window.addEventListener("dblclick", handleDoubleClick);
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function useDictionary() {
|
||||
h1: t("Big heading"),
|
||||
h2: t("Medium heading"),
|
||||
h3: t("Small heading"),
|
||||
h4: t("Extra small heading"),
|
||||
heading: t("Heading"),
|
||||
hr: t("Divider"),
|
||||
image: t("Image"),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import useIdle from "./useIdle";
|
||||
|
||||
const activityEvents = [
|
||||
"click",
|
||||
"mousemove",
|
||||
"DOMMouseScroll",
|
||||
"mousewheel",
|
||||
"mousedown",
|
||||
"touchstart",
|
||||
"touchmove",
|
||||
"focus",
|
||||
];
|
||||
|
||||
export default function useEditingFocus() {
|
||||
const { editor } = useDocumentContext();
|
||||
const isIdle = useIdle(3000, activityEvents);
|
||||
return isIdle && !!editor?.view.hasFocus();
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { getCookie, removeCookie, setCookie } from "tiny-cookie";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import Logger from "~/utils/Logger";
|
||||
import history from "~/utils/history";
|
||||
import { isValidPostLoginRedirect } from "~/utils/urls";
|
||||
import { isAllowedLoginRedirect } from "~/utils/urls";
|
||||
|
||||
/**
|
||||
* Hook to set locally and return the document or collection that the user last visited. This is
|
||||
@@ -20,7 +20,9 @@ export function useLastVisitedPath(): [string, (path: string) => void] {
|
||||
|
||||
const setPathAsLastVisitedPath = React.useCallback(
|
||||
(path: string) => {
|
||||
path !== lastVisitedPath && setLastVisitedPath(path);
|
||||
if (isAllowedLoginRedirect(path) && path !== lastVisitedPath) {
|
||||
setLastVisitedPath(path);
|
||||
}
|
||||
},
|
||||
[lastVisitedPath, setLastVisitedPath]
|
||||
);
|
||||
@@ -34,8 +36,16 @@ export function useLastVisitedPath(): [string, (path: string) => void] {
|
||||
* @param path The path to set as the post login path.
|
||||
*/
|
||||
export function setPostLoginPath(path: string) {
|
||||
if (isValidPostLoginRedirect(path)) {
|
||||
setCookie("postLoginRedirectPath", path, { expires: 1 });
|
||||
const key = "postLoginRedirectPath";
|
||||
|
||||
if (isAllowedLoginRedirect(path)) {
|
||||
setCookie(key, path, { expires: 1 });
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(key, path);
|
||||
} catch (e) {
|
||||
// If the session storage is full or inaccessible, we can't do anything about it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +59,12 @@ export function usePostLoginPath() {
|
||||
const key = "postLoginRedirectPath";
|
||||
|
||||
const getter = React.useCallback(() => {
|
||||
const path = getCookie(key);
|
||||
let path;
|
||||
try {
|
||||
path = sessionStorage.getItem(key) || getCookie(key);
|
||||
} catch (e) {
|
||||
// Expected error if the session storage is full or inaccessible.
|
||||
}
|
||||
|
||||
if (path) {
|
||||
Logger.info("lifecycle", "Spending post login path", { path });
|
||||
@@ -57,11 +72,16 @@ export function usePostLoginPath() {
|
||||
// Remove the cookie once the app has been navigated to the post login path. We dont
|
||||
// do this immediately as React StrictMode will render multiple times.
|
||||
const cleanup = history.listen(() => {
|
||||
try {
|
||||
sessionStorage.removeItem(key);
|
||||
} catch (e) {
|
||||
// Expected error if the session storage is full or inaccessible.
|
||||
}
|
||||
removeCookie(key);
|
||||
cleanup?.();
|
||||
});
|
||||
|
||||
if (isValidPostLoginRedirect(path)) {
|
||||
if (isAllowedLoginRedirect(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ImportIcon,
|
||||
ExportIcon,
|
||||
AlphabeticalSortIcon,
|
||||
AlphabeticalReverseSortIcon,
|
||||
ManualSortIcon,
|
||||
InputIcon,
|
||||
} from "outline-icons";
|
||||
@@ -127,12 +128,12 @@ function CollectionMenu({
|
||||
);
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(field: string) => {
|
||||
(field: string, direction = "asc") => {
|
||||
menu.hide();
|
||||
return collection.save({
|
||||
sort: {
|
||||
field,
|
||||
direction: "asc",
|
||||
direction,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -144,7 +145,8 @@ function CollectionMenu({
|
||||
activeCollectionId: collection.id,
|
||||
});
|
||||
|
||||
const alphabeticalSort = collection.sort.field === "title";
|
||||
const sortAlphabetical = collection.sort.field === "title";
|
||||
const sortDir = collection.sort.direction;
|
||||
const can = usePolicy(collection);
|
||||
const canUserInTeam = usePolicy(team);
|
||||
const items: MenuItem[] = React.useMemo(
|
||||
@@ -185,19 +187,33 @@ function CollectionMenu({
|
||||
type: "submenu",
|
||||
title: t("Sort in sidebar"),
|
||||
visible: can.update,
|
||||
icon: alphabeticalSort ? <AlphabeticalSortIcon /> : <ManualSortIcon />,
|
||||
icon: sortAlphabetical ? (
|
||||
sortDir === "asc" ? (
|
||||
<AlphabeticalSortIcon />
|
||||
) : (
|
||||
<AlphabeticalReverseSortIcon />
|
||||
)
|
||||
) : (
|
||||
<ManualSortIcon />
|
||||
),
|
||||
items: [
|
||||
{
|
||||
type: "button",
|
||||
title: t("Alphabetical sort"),
|
||||
onClick: () => handleChangeSort("title"),
|
||||
selected: alphabeticalSort,
|
||||
title: t("A-Z sort"),
|
||||
onClick: () => handleChangeSort("title", "asc"),
|
||||
selected: sortAlphabetical && sortDir === "asc",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Z-A sort"),
|
||||
onClick: () => handleChangeSort("title", "desc"),
|
||||
selected: sortAlphabetical && sortDir === "desc",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Manual sort"),
|
||||
onClick: () => handleChangeSort("index"),
|
||||
selected: !alphabeticalSort,
|
||||
selected: !sortAlphabetical,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -216,6 +232,7 @@ function CollectionMenu({
|
||||
],
|
||||
[
|
||||
t,
|
||||
onRename,
|
||||
collection,
|
||||
can.createDocument,
|
||||
can.update,
|
||||
@@ -223,7 +240,7 @@ function CollectionMenu({
|
||||
handleNewDocument,
|
||||
handleImportDocument,
|
||||
context,
|
||||
alphabeticalSort,
|
||||
sortAlphabetical,
|
||||
canUserInTeam.createExport,
|
||||
handleExport,
|
||||
handleChangeSort,
|
||||
|
||||
+268
-195
@@ -1,4 +1,6 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import isUndefined from "lodash/isUndefined";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -48,6 +50,7 @@ import {
|
||||
moveTemplate,
|
||||
} from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -55,68 +58,108 @@ import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { MenuContext, useMenuContext } from "./MenuContext";
|
||||
|
||||
type Props = {
|
||||
/** Document for which the menu is to be shown */
|
||||
document: Document;
|
||||
className?: string;
|
||||
isRevision?: boolean;
|
||||
/** Pass true if the document is currently being displayed */
|
||||
showDisplayOptions?: boolean;
|
||||
/** Whether to display menu as a modal */
|
||||
modal?: boolean;
|
||||
/** Whether to include the option of toggling embeds as menu item */
|
||||
showToggleEmbeds?: boolean;
|
||||
showPin?: boolean;
|
||||
/** Label for menu button */
|
||||
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
|
||||
/** Invoked when the "Find and replace" menu item is clicked */
|
||||
onFindAndReplace?: () => void;
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
onRename?: () => void;
|
||||
/** Invoked when menu is opened */
|
||||
onOpen?: () => void;
|
||||
/** Invoked when menu is closed */
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
function DocumentMenu({
|
||||
document,
|
||||
className,
|
||||
modal = true,
|
||||
showToggleEmbeds,
|
||||
showDisplayOptions,
|
||||
label,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const user = useCurrentUser();
|
||||
const { policies, collections, documents, subscriptions } = useStores();
|
||||
const menu = useMenuState({
|
||||
modal,
|
||||
unstable_preventOverflow: true,
|
||||
unstable_fixed: true,
|
||||
unstable_flip: true,
|
||||
});
|
||||
const history = useHistory();
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId: document.collectionId ?? undefined,
|
||||
});
|
||||
type MenuTriggerProps = {
|
||||
label?: (props: MenuButtonHTMLProps) => React.ReactNode;
|
||||
onTrigger: () => void;
|
||||
};
|
||||
|
||||
const MenuTrigger: React.FC<MenuTriggerProps> = ({ label, onTrigger }) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const file = React.useRef<HTMLInputElement>(null);
|
||||
const { data, loading, request } = useRequest(() =>
|
||||
|
||||
const { subscriptions } = useStores();
|
||||
const { model: document, menuState } = useMenuContext<Document>();
|
||||
|
||||
const { data, loading, error, request } = useRequest(() =>
|
||||
subscriptions.fetchPage({
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
})
|
||||
);
|
||||
|
||||
const handleOpen = React.useCallback(async () => {
|
||||
if (!data && !loading) {
|
||||
await request();
|
||||
const handlePointerEnter = React.useCallback(() => {
|
||||
if (isUndefined(data ?? error) && !loading) {
|
||||
void request();
|
||||
void document.loadRelations();
|
||||
}
|
||||
}, [data, error, loading, request, document]);
|
||||
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
}, [data, loading, onOpen, request]);
|
||||
return label ? (
|
||||
<MenuButton
|
||||
{...menuState}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onClick={onTrigger}
|
||||
>
|
||||
{label}
|
||||
</MenuButton>
|
||||
) : (
|
||||
<OverflowMenuButton
|
||||
aria-label={t("Show document menu")}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onClick={onTrigger}
|
||||
{...menuState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type MenuContentProps = {
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onFindAndReplace?: () => void;
|
||||
onRename?: () => void;
|
||||
showDisplayOptions?: boolean;
|
||||
showToggleEmbeds?: boolean;
|
||||
};
|
||||
|
||||
const MenuContent: React.FC<MenuContentProps> = ({
|
||||
onOpen,
|
||||
onClose,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
showDisplayOptions,
|
||||
showToggleEmbeds,
|
||||
}) => {
|
||||
const user = useCurrentUser();
|
||||
const { model: document, menuState } = useMenuContext<Document>();
|
||||
const can = usePolicy(document);
|
||||
const { t } = useTranslation();
|
||||
const { policies, collections } = useStores();
|
||||
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId: document.collectionId ?? undefined,
|
||||
});
|
||||
|
||||
const isMobile = useMobile();
|
||||
|
||||
const handleRestore = React.useCallback(
|
||||
async (
|
||||
@@ -135,10 +178,6 @@ function DocumentMenu({
|
||||
[t, document]
|
||||
);
|
||||
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const can = usePolicy(document);
|
||||
const restoreItems = React.useMemo(
|
||||
() => [
|
||||
...collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
|
||||
@@ -161,6 +200,182 @@ function DocumentMenu({
|
||||
],
|
||||
[collections.orderedData, handleRestore, policies]
|
||||
);
|
||||
|
||||
return !isEmpty(can) ? (
|
||||
<ContextMenu
|
||||
{...menuState}
|
||||
aria-label={t("Document options")}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Template
|
||||
{...menuState}
|
||||
items={[
|
||||
{
|
||||
type: "button",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
((document.isWorkspaceTemplate || !!collection) && can.restore) ||
|
||||
!!can.unarchive,
|
||||
onClick: (ev) => handleRestore(ev),
|
||||
icon: <RestoreIcon />,
|
||||
},
|
||||
{
|
||||
type: "submenu",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
!document.isWorkspaceTemplate &&
|
||||
!collection &&
|
||||
!!can.restore &&
|
||||
restoreItems.length !== 0,
|
||||
style: {
|
||||
left: -170,
|
||||
position: "relative",
|
||||
top: -40,
|
||||
},
|
||||
icon: <RestoreIcon />,
|
||||
hover: true,
|
||||
items: [
|
||||
{
|
||||
type: "heading",
|
||||
title: t("Choose a collection"),
|
||||
},
|
||||
...restoreItems,
|
||||
],
|
||||
},
|
||||
actionToMenuItem(starDocument, context),
|
||||
actionToMenuItem(unstarDocument, context),
|
||||
actionToMenuItem(subscribeDocument, context),
|
||||
actionToMenuItem(unsubscribeDocument, context),
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Find and replace")}…`,
|
||||
visible: !!onFindAndReplace && isMobile,
|
||||
onClick: () => onFindAndReplace?.(),
|
||||
icon: <SearchIcon />,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
title: t("Edit"),
|
||||
to: documentEditPath(document),
|
||||
visible:
|
||||
!!can.update && user.separateEditMode && !document.template,
|
||||
icon: <EditIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Rename")}…`,
|
||||
visible: !!can.update && !user.separateEditMode && !!onRename,
|
||||
onClick: () => onRename?.(),
|
||||
icon: <InputIcon />,
|
||||
},
|
||||
actionToMenuItem(shareDocument, context),
|
||||
actionToMenuItem(createNestedDocument, context),
|
||||
actionToMenuItem(importDocument, context),
|
||||
actionToMenuItem(createTemplateFromDocument, context),
|
||||
actionToMenuItem(duplicateDocument, context),
|
||||
actionToMenuItem(publishDocument, context),
|
||||
actionToMenuItem(unpublishDocument, context),
|
||||
actionToMenuItem(archiveDocument, context),
|
||||
actionToMenuItem(moveDocument, context),
|
||||
actionToMenuItem(moveTemplate, context),
|
||||
actionToMenuItem(pinDocument, context),
|
||||
actionToMenuItem(createDocumentFromTemplate, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(openDocumentComments, context),
|
||||
actionToMenuItem(openDocumentHistory, context),
|
||||
actionToMenuItem(openDocumentInsights, context),
|
||||
actionToMenuItem(downloadDocument, context),
|
||||
actionToMenuItem(copyDocument, context),
|
||||
actionToMenuItem(printDocument, context),
|
||||
actionToMenuItem(searchInDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(deleteDocument, context),
|
||||
actionToMenuItem(permanentlyDeleteDocument, context),
|
||||
]}
|
||||
/>
|
||||
{(showDisplayOptions || showToggleEmbeds) && can.update && (
|
||||
<>
|
||||
<Separator />
|
||||
<DisplayOptions>
|
||||
{showToggleEmbeds && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Enable embeds")}
|
||||
labelPosition="left"
|
||||
checked={!document.embedsDisabled}
|
||||
onChange={
|
||||
document.embedsDisabled
|
||||
? document.enableEmbeds
|
||||
: document.disableEmbeds
|
||||
}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
{showDisplayOptions && !isMobile && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Full width")}
|
||||
labelPosition="left"
|
||||
checked={document.fullWidth}
|
||||
onChange={(ev) => {
|
||||
const fullWidth = ev.currentTarget.checked;
|
||||
user.setPreference(
|
||||
UserPreference.FullWidthDocuments,
|
||||
fullWidth
|
||||
);
|
||||
void user.save();
|
||||
document.fullWidth = fullWidth;
|
||||
void document.save();
|
||||
}}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
</DisplayOptions>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
) : null;
|
||||
};
|
||||
|
||||
function DocumentMenu({
|
||||
document,
|
||||
modal = true,
|
||||
showToggleEmbeds,
|
||||
showDisplayOptions,
|
||||
label,
|
||||
onRename,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const { collections, documents } = useStores();
|
||||
const menuState = useMenuState({
|
||||
modal,
|
||||
unstable_preventOverflow: true,
|
||||
unstable_fixed: true,
|
||||
unstable_flip: true,
|
||||
});
|
||||
const history = useHistory();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [isMenuVisible, showMenu] = useBoolean(false);
|
||||
const file = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
const stopPropagation = React.useCallback((ev: React.SyntheticEvent) => {
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
@@ -213,160 +428,18 @@ function DocumentMenu({
|
||||
/>
|
||||
</label>
|
||||
</VisuallyHidden>
|
||||
{label ? (
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
) : (
|
||||
<OverflowMenuButton
|
||||
className={className}
|
||||
aria-label={t("Show menu")}
|
||||
{...menu}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Document options")}
|
||||
onOpen={handleOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
type: "button",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
((document.isWorkspaceTemplate || !!collection) &&
|
||||
can.restore) ||
|
||||
!!can.unarchive,
|
||||
onClick: (ev) => handleRestore(ev),
|
||||
icon: <RestoreIcon />,
|
||||
},
|
||||
{
|
||||
type: "submenu",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
!document.isWorkspaceTemplate &&
|
||||
!collection &&
|
||||
!!can.restore &&
|
||||
restoreItems.length !== 0,
|
||||
style: {
|
||||
left: -170,
|
||||
position: "relative",
|
||||
top: -40,
|
||||
},
|
||||
icon: <RestoreIcon />,
|
||||
hover: true,
|
||||
items: [
|
||||
{
|
||||
type: "heading",
|
||||
title: t("Choose a collection"),
|
||||
},
|
||||
...restoreItems,
|
||||
],
|
||||
},
|
||||
actionToMenuItem(starDocument, context),
|
||||
actionToMenuItem(unstarDocument, context),
|
||||
actionToMenuItem(subscribeDocument, context),
|
||||
actionToMenuItem(unsubscribeDocument, context),
|
||||
...(isMobile ? [actionToMenuItem(shareDocument, context)] : []),
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Find and replace")}…`,
|
||||
visible: !!onFindAndReplace && isMobile,
|
||||
onClick: () => onFindAndReplace?.(),
|
||||
icon: <SearchIcon />,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "route",
|
||||
title: t("Edit"),
|
||||
to: documentEditPath(document),
|
||||
visible:
|
||||
!!can.update && user.separateEditMode && !document.template,
|
||||
icon: <EditIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Rename")}…`,
|
||||
visible: !!can.update && !user.separateEditMode && !!onRename,
|
||||
onClick: () => onRename?.(),
|
||||
icon: <InputIcon />,
|
||||
},
|
||||
actionToMenuItem(createNestedDocument, context),
|
||||
actionToMenuItem(importDocument, context),
|
||||
actionToMenuItem(createTemplateFromDocument, context),
|
||||
actionToMenuItem(duplicateDocument, context),
|
||||
actionToMenuItem(publishDocument, context),
|
||||
actionToMenuItem(unpublishDocument, context),
|
||||
actionToMenuItem(archiveDocument, context),
|
||||
actionToMenuItem(moveDocument, context),
|
||||
actionToMenuItem(moveTemplate, context),
|
||||
actionToMenuItem(pinDocument, context),
|
||||
actionToMenuItem(createDocumentFromTemplate, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(openDocumentComments, context),
|
||||
actionToMenuItem(openDocumentHistory, context),
|
||||
actionToMenuItem(openDocumentInsights, context),
|
||||
actionToMenuItem(downloadDocument, context),
|
||||
actionToMenuItem(copyDocument, context),
|
||||
actionToMenuItem(printDocument, context),
|
||||
actionToMenuItem(searchInDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(deleteDocument, context),
|
||||
actionToMenuItem(permanentlyDeleteDocument, context),
|
||||
]}
|
||||
/>
|
||||
{(showDisplayOptions || showToggleEmbeds) && can.update && (
|
||||
<>
|
||||
<Separator />
|
||||
<DisplayOptions>
|
||||
{showToggleEmbeds && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Enable embeds")}
|
||||
labelPosition="left"
|
||||
checked={!document.embedsDisabled}
|
||||
onChange={
|
||||
document.embedsDisabled
|
||||
? document.enableEmbeds
|
||||
: document.disableEmbeds
|
||||
}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
{showDisplayOptions && !isMobile && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Full width")}
|
||||
labelPosition="left"
|
||||
checked={document.fullWidth}
|
||||
onChange={(ev) => {
|
||||
const fullWidth = ev.currentTarget.checked;
|
||||
user.setPreference(
|
||||
UserPreference.FullWidthDocuments,
|
||||
fullWidth
|
||||
);
|
||||
void user.save();
|
||||
document.fullWidth = fullWidth;
|
||||
void document.save();
|
||||
}}
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
</DisplayOptions>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
<MenuContext.Provider value={{ model: document, menuState }}>
|
||||
<MenuTrigger label={label} onTrigger={showMenu} />
|
||||
{isMenuVisible ? (
|
||||
<MenuContent
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
onRename={onRename}
|
||||
showDisplayOptions={showDisplayOptions}
|
||||
showToggleEmbeds={showToggleEmbeds}
|
||||
/>
|
||||
) : null}
|
||||
</MenuContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { MenuStateReturn } from "reakit";
|
||||
import Model from "~/models/base/Model";
|
||||
|
||||
export type MenuContext<T extends Model> = {
|
||||
/** Model for which the menu is to be designed. */
|
||||
model: T;
|
||||
/** Menu state */
|
||||
menuState: MenuStateReturn;
|
||||
};
|
||||
|
||||
export const MenuContext = React.createContext<MenuContext<Model>>(
|
||||
{} as MenuContext<Model>
|
||||
);
|
||||
|
||||
export const useMenuContext = <T extends Model>() =>
|
||||
React.useContext<MenuContext<T>>(
|
||||
MenuContext as unknown as React.Context<MenuContext<T>>
|
||||
);
|
||||
@@ -6,17 +6,11 @@ import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
type Props = {
|
||||
headings: {
|
||||
title: string;
|
||||
level: number;
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
function TableOfContentsMenu({ headings }: Props) {
|
||||
function TableOfContentsMenu() {
|
||||
const { headings } = useDocumentContext();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
unstable_preventOverflow: true,
|
||||
|
||||
+19
-19
@@ -1,44 +1,44 @@
|
||||
import { isPast } from "date-fns";
|
||||
import { computed, observable } from "mobx";
|
||||
import Model from "./base/Model";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
class ApiKey extends Model {
|
||||
class ApiKey extends ParanoidModel {
|
||||
static modelName = "ApiKey";
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The user chosen name of the API key.
|
||||
*/
|
||||
/** The user chosen name of the API key. */
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* An optional datetime that the API key expires.
|
||||
*/
|
||||
/** An optional datetime that the API key expires. */
|
||||
@Field
|
||||
@observable
|
||||
expiresAt?: string;
|
||||
|
||||
/**
|
||||
* An optional datetime that the API key was last used at.
|
||||
*/
|
||||
/** An optional datetime that the API key was last used at. */
|
||||
@observable
|
||||
lastActiveAt?: string;
|
||||
|
||||
secret: string;
|
||||
/** The plain text value of the API key, only available on creation. */
|
||||
value: string;
|
||||
|
||||
/**
|
||||
* Whether the API key has an expiry in the past.
|
||||
*/
|
||||
/** A preview of the last 4 characters of the API key. */
|
||||
last4: string;
|
||||
|
||||
/** Whether the API key has an expiry in the past. */
|
||||
@computed
|
||||
get isExpired() {
|
||||
return this.expiresAt ? isPast(new Date(this.expiresAt)) : false;
|
||||
}
|
||||
|
||||
@computed
|
||||
get obfuscatedValue() {
|
||||
if (this.createdAt < new Date("2022-12-03").toISOString()) {
|
||||
return `...${this.last4}`;
|
||||
}
|
||||
return `ol...${this.last4}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiKey;
|
||||
|
||||
+37
-59
@@ -1,9 +1,10 @@
|
||||
import invariant from "invariant";
|
||||
import { action, computed, observable, reaction, runInAction } from "mobx";
|
||||
import { action, computed, observable, runInAction } from "mobx";
|
||||
import {
|
||||
CollectionPermission,
|
||||
FileOperationFormat,
|
||||
NavigationNode,
|
||||
type NavigationNode,
|
||||
NavigationNodeType,
|
||||
type ProsemirrorData,
|
||||
} from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
@@ -21,43 +22,27 @@ export default class Collection extends ParanoidModel {
|
||||
|
||||
store: CollectionsStore;
|
||||
|
||||
@observable
|
||||
isSaving: boolean;
|
||||
|
||||
isFetching = false;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The name of the collection.
|
||||
*/
|
||||
/** The name of the collection. */
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
/** Collection description in Prosemirror format. */
|
||||
@Field
|
||||
@observable.shallow
|
||||
data: ProsemirrorData;
|
||||
|
||||
/**
|
||||
* An icon (or) emoji to use as the collection icon.
|
||||
*/
|
||||
/** An icon (or) emoji to use as the collection icon. */
|
||||
@Field
|
||||
@observable
|
||||
icon: string;
|
||||
|
||||
/**
|
||||
* The color to use for the collection icon and other highlights.
|
||||
*/
|
||||
/** The color to use for the collection icon and other highlights. */
|
||||
@Field
|
||||
@observable
|
||||
color?: string | null;
|
||||
|
||||
/**
|
||||
* The default permission for workspace users.
|
||||
*/
|
||||
/** The default permission for workspace users. */
|
||||
@Field
|
||||
@observable
|
||||
permission?: CollectionPermission;
|
||||
@@ -70,16 +55,12 @@ export default class Collection extends ParanoidModel {
|
||||
@observable
|
||||
sharing: boolean;
|
||||
|
||||
/**
|
||||
* The sort index for the collection.
|
||||
*/
|
||||
/** The sort index for the collection. */
|
||||
@Field
|
||||
@observable
|
||||
index: string;
|
||||
|
||||
/**
|
||||
* The sort field and direction for documents in the collection.
|
||||
*/
|
||||
/** The sort field and direction for documents in the collection. */
|
||||
@Field
|
||||
@observable
|
||||
sort: {
|
||||
@@ -87,33 +68,19 @@ export default class Collection extends ParanoidModel {
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
/** The child documents of the collection. */
|
||||
@observable
|
||||
documents?: NavigationNode[];
|
||||
|
||||
/**
|
||||
* @deprecated Use path instead.
|
||||
*/
|
||||
/** @deprecated Use path instead. */
|
||||
@observable
|
||||
url: string;
|
||||
|
||||
/** The ID that appears in the collection slug. */
|
||||
@observable
|
||||
urlId: string;
|
||||
|
||||
constructor(fields: Partial<Collection>, store: CollectionsStore) {
|
||||
super(fields, store);
|
||||
|
||||
const resetDocumentPolicies = () => {
|
||||
this.store.rootStore.documents
|
||||
.inCollection(this.id)
|
||||
.forEach((document) => {
|
||||
this.store.rootStore.policies.remove(document.id);
|
||||
});
|
||||
};
|
||||
|
||||
reaction(() => this.permission, resetDocumentPolicies);
|
||||
reaction(() => this.sharing, resetDocumentPolicies);
|
||||
}
|
||||
|
||||
/** Returns whether the collection is empty, or undefined if not loaded. */
|
||||
@computed
|
||||
get isEmpty(): boolean | undefined {
|
||||
if (!this.documents) {
|
||||
@@ -137,11 +104,7 @@ export default class Collection extends ParanoidModel {
|
||||
return !this.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether this collection has a description.
|
||||
*
|
||||
* @returns boolean
|
||||
*/
|
||||
/** Returns whether the collection description is not empty. */
|
||||
@computed
|
||||
get hasDescription(): boolean {
|
||||
return this.data ? !ProsemirrorHelper.isEmptyData(this.data) : false;
|
||||
@@ -167,11 +130,7 @@ export default class Collection extends ParanoidModel {
|
||||
return sortNavigationNodes(this.documents, this.sort);
|
||||
}
|
||||
|
||||
/**
|
||||
* The initial letter of the collection name.
|
||||
*
|
||||
* @returns string
|
||||
*/
|
||||
/** The initial letter of the collection name as a string. */
|
||||
@computed
|
||||
get initial() {
|
||||
return (this.name ? this.name[0] : "?").toUpperCase();
|
||||
@@ -277,7 +236,7 @@ export default class Collection extends ParanoidModel {
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
getDocumentChildren(documentId: string) {
|
||||
getChildrenForDocument(documentId: string) {
|
||||
let result: NavigationNode[] = [];
|
||||
|
||||
const travelNodes = (nodes: NavigationNode[]) => {
|
||||
@@ -298,6 +257,19 @@ export default class Collection extends ParanoidModel {
|
||||
return result;
|
||||
}
|
||||
|
||||
@computed
|
||||
get asNavigationNode(): NavigationNode {
|
||||
return {
|
||||
type: NavigationNodeType.Collection,
|
||||
id: this.id,
|
||||
title: this.name,
|
||||
color: this.color ?? undefined,
|
||||
icon: this.icon ?? undefined,
|
||||
children: this.documents ?? [],
|
||||
url: this.url,
|
||||
};
|
||||
}
|
||||
|
||||
pathToDocument(documentId: string) {
|
||||
let path: NavigationNode[] | undefined = [];
|
||||
const document = this.store.rootStore.documents.get(documentId);
|
||||
@@ -356,7 +328,11 @@ export default class Collection extends ParanoidModel {
|
||||
model: Collection,
|
||||
previousAttributes: Partial<Collection>
|
||||
) {
|
||||
if (previousAttributes && model.sharing !== previousAttributes?.sharing) {
|
||||
if (
|
||||
previousAttributes &&
|
||||
(model.sharing !== previousAttributes?.sharing ||
|
||||
model.permission !== previousAttributes?.permission)
|
||||
) {
|
||||
const { documents, policies } = model.store.rootStore;
|
||||
|
||||
documents.inCollection(model.id).forEach((document) => {
|
||||
@@ -364,4 +340,6 @@ export default class Collection extends ParanoidModel {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private isFetching = false;
|
||||
}
|
||||
|
||||
+22
-1
@@ -14,6 +14,7 @@ import type {
|
||||
import {
|
||||
ExportContentType,
|
||||
FileOperationFormat,
|
||||
NavigationNodeType,
|
||||
NotificationEventType,
|
||||
} from "@shared/types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
@@ -308,6 +309,24 @@ export default class Document extends ParanoidModel {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the document is currently publicly shared, taking into account
|
||||
* the document's and team's sharing settings.
|
||||
*
|
||||
* @returns True if the document is publicly shared, false otherwise.
|
||||
*/
|
||||
get isPubliclyShared(): boolean {
|
||||
const { shares, auth } = this.store.rootStore;
|
||||
const share = shares.getByDocumentId(this.id);
|
||||
const sharedParent = shares.getByDocumentParents(this.id);
|
||||
|
||||
return !!(
|
||||
auth.team?.sharing !== false &&
|
||||
this.collection?.sharing !== false &&
|
||||
(share?.published || (sharedParent?.published && !this.isDraft))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns users that have been individually given access to the document.
|
||||
*
|
||||
@@ -593,13 +612,15 @@ export default class Document extends ParanoidModel {
|
||||
@computed
|
||||
get childDocuments() {
|
||||
return this.store.orderedData.filter(
|
||||
(doc) => doc.parentDocumentId === this.id
|
||||
(doc) =>
|
||||
doc.parentDocumentId === this.id && this.isActive === doc.isActive
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get asNavigationNode(): NavigationNode {
|
||||
return {
|
||||
type: NavigationNodeType.Document,
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
color: this.color ?? undefined,
|
||||
|
||||
@@ -25,8 +25,6 @@ class Integration<T = unknown> extends Model {
|
||||
@Relation(() => User, { onDelete: "cascade" })
|
||||
user: User;
|
||||
|
||||
teamId: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
events: string[];
|
||||
|
||||
+11
-1
@@ -1,5 +1,7 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { computed, observable } from "mobx";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { AfterChange } from "./decorators/Lifecycle";
|
||||
|
||||
class Policy extends Model {
|
||||
@@ -9,6 +11,7 @@ class Policy extends Model {
|
||||
* An object containing keys representing abilities and values that are either
|
||||
* a boolean or an array of membership IDs that have provided access to the ability.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
abilities: Record<string, boolean | string[]>;
|
||||
|
||||
@@ -30,9 +33,16 @@ class Policy extends Model {
|
||||
}
|
||||
|
||||
@AfterChange
|
||||
public static removeChildPolicies(model: Policy) {
|
||||
public static removeChildPolicies(
|
||||
model: Policy,
|
||||
previousAttributes: Partial<Policy>
|
||||
) {
|
||||
const { documents, collections, policies } = model.store.rootStore;
|
||||
|
||||
if (isEqual(model.abilities, previousAttributes.abilities)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = collections.get(model.id);
|
||||
if (collection) {
|
||||
documents.inCollection(collection.id).forEach((i) => {
|
||||
|
||||
@@ -39,7 +39,9 @@ export default abstract class Model {
|
||||
*
|
||||
* @returns A promise that resolves when loading is complete.
|
||||
*/
|
||||
async loadRelations(): Promise<any> {
|
||||
async loadRelations(
|
||||
options: { withoutPolicies?: boolean } = {}
|
||||
): Promise<any> {
|
||||
const relations = getRelationsForModelClass(
|
||||
this.constructor as typeof Model
|
||||
);
|
||||
@@ -66,7 +68,7 @@ export default abstract class Model {
|
||||
}
|
||||
|
||||
const policy = this.store.rootStore.policies.get(this.id);
|
||||
if (!policy) {
|
||||
if (!policy && !options.withoutPolicies) {
|
||||
promises.push(this.store.fetch(this.id, { force: true }));
|
||||
}
|
||||
|
||||
@@ -139,6 +141,10 @@ export default abstract class Model {
|
||||
|
||||
for (const key in data) {
|
||||
try {
|
||||
// Some models are serialized with the initialized flag, this should be ignored.
|
||||
if (key === "initialized") {
|
||||
continue;
|
||||
}
|
||||
this[key] = data[key];
|
||||
} catch (error) {
|
||||
Logger.warn(`Error setting ${key} on model`, error);
|
||||
@@ -148,7 +154,7 @@ export default abstract class Model {
|
||||
this.isNew = false;
|
||||
this.persistedAttributes = this.toAPI();
|
||||
|
||||
if (!this.initialized) {
|
||||
if (this.initialized) {
|
||||
LifecycleManager.executeHooks(
|
||||
this.constructor,
|
||||
"afterChange",
|
||||
|
||||
@@ -71,7 +71,11 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
name,
|
||||
expiresAt: expiresAt?.toISOString(),
|
||||
});
|
||||
toast.success(t("API key created"));
|
||||
toast.success(
|
||||
t(
|
||||
"API key created. Please copy the value now as it will not be shown again."
|
||||
)
|
||||
);
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
|
||||
@@ -1,81 +1,47 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { NewDocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import { editCollectionPermissions } from "~/actions/definitions/collections";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
/** The collection to display the empty state for. */
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
function EmptyCollection({ collection }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(collection);
|
||||
const context = useActionContext();
|
||||
const collectionName = collection ? collection.name : "";
|
||||
|
||||
return (
|
||||
<Centered column>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="<em>{{ collectionName }}</em> doesn’t contain any
|
||||
<Fade>
|
||||
<Centered column>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="<em>{{ collectionName }}</em> doesn’t contain any
|
||||
documents yet."
|
||||
values={{
|
||||
collectionName,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
{can.createDocument && (
|
||||
<Trans>Get started by creating a new one!</Trans>
|
||||
)}
|
||||
</Text>
|
||||
{can.createDocument && (
|
||||
<Empty>
|
||||
<Link to={newDocumentPath(collection.id)}>
|
||||
<Button icon={<NewDocumentIcon />} neutral>
|
||||
{t("Create a document")}
|
||||
</Button>
|
||||
</Link>
|
||||
{FeatureFlags.isEnabled(Feature.newCollectionSharing) ? null : (
|
||||
<Button
|
||||
action={editCollectionPermissions}
|
||||
context={context}
|
||||
hideOnActionDisabled
|
||||
neutral
|
||||
>
|
||||
{t("Manage permissions")}…
|
||||
</Button>
|
||||
)}
|
||||
</Empty>
|
||||
)}
|
||||
</Centered>
|
||||
values={{
|
||||
collectionName,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</Centered>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
|
||||
const Centered = styled(Flex)`
|
||||
text-align: center;
|
||||
margin: 40vh auto 0;
|
||||
max-width: 380px;
|
||||
transform: translateY(-50%);
|
||||
`;
|
||||
|
||||
const Empty = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 10px 0;
|
||||
gap: 8px;
|
||||
margin: 0 auto;
|
||||
max-width: 380px;
|
||||
height: 50vh;
|
||||
`;
|
||||
|
||||
export default observer(EmptyCollection);
|
||||
|
||||
@@ -8,11 +8,9 @@ import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { editCollectionPermissions } from "~/actions/definitions/collections";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -71,11 +69,6 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
|
||||
return (
|
||||
<NudeButton
|
||||
context={context}
|
||||
action={
|
||||
FeatureFlags.isEnabled(Feature.newCollectionSharing)
|
||||
? undefined
|
||||
: editCollectionPermissions
|
||||
}
|
||||
tooltip={{
|
||||
content:
|
||||
usersCount > 0
|
||||
|
||||
+117
-139
@@ -17,7 +17,6 @@ import { colorPalette } from "@shared/utils/collections";
|
||||
import Collection from "~/models/Collection";
|
||||
import Search from "~/scenes/Search";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Badge from "~/components/Badge";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import CollectionDescription from "~/components/CollectionDescription";
|
||||
import Heading from "~/components/Heading";
|
||||
@@ -31,14 +30,12 @@ import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Scene from "~/components/Scene";
|
||||
import Tab from "~/components/Tab";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { editCollection } from "~/actions/definitions/collections";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
|
||||
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
|
||||
import Actions from "./components/Actions";
|
||||
import DropToImport from "./components/DropToImport";
|
||||
@@ -154,8 +151,7 @@ function CollectionScene() {
|
||||
<>
|
||||
<MembershipPreview collection={collection} />
|
||||
<Action>
|
||||
{FeatureFlags.isEnabled(Feature.newCollectionSharing) &&
|
||||
can.update && <ShareButton collection={collection} />}
|
||||
{can.update && <ShareButton collection={collection} />}
|
||||
</Action>
|
||||
<Actions collection={collection} />
|
||||
</>
|
||||
@@ -167,142 +163,124 @@ function CollectionScene() {
|
||||
collectionId={collection.id}
|
||||
>
|
||||
<CenteredContent withStickyHeader>
|
||||
{collection.isEmpty ? (
|
||||
<Empty collection={collection} />
|
||||
) : (
|
||||
<>
|
||||
<CollectionHeading>
|
||||
<IconTitleWrapper>
|
||||
{can.update ? (
|
||||
<React.Suspense fallback={fallbackIcon}>
|
||||
<IconPicker
|
||||
icon={collection.icon ?? "collection"}
|
||||
color={collection.color ?? colorPalette[0]}
|
||||
initial={collection.name[0]}
|
||||
size={40}
|
||||
popoverPosition="bottom-start"
|
||||
onChange={handleIconChange}
|
||||
borderOnHover
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
fallbackIcon
|
||||
)}
|
||||
</IconTitleWrapper>
|
||||
{collection.name}
|
||||
{collection.isPrivate &&
|
||||
!FeatureFlags.isEnabled(Feature.newCollectionSharing) && (
|
||||
<Tooltip
|
||||
content={t(
|
||||
"This collection is only visible to those given access"
|
||||
)}
|
||||
placement="bottom"
|
||||
>
|
||||
<Badge>{t("Private")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CollectionHeading>
|
||||
<CollectionHeading>
|
||||
<IconTitleWrapper>
|
||||
{can.update ? (
|
||||
<React.Suspense fallback={fallbackIcon}>
|
||||
<IconPicker
|
||||
icon={collection.icon ?? "collection"}
|
||||
color={collection.color ?? colorPalette[0]}
|
||||
initial={collection.name[0]}
|
||||
size={40}
|
||||
popoverPosition="bottom-start"
|
||||
onChange={handleIconChange}
|
||||
borderOnHover
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
fallbackIcon
|
||||
)}
|
||||
</IconTitleWrapper>
|
||||
{collection.name}
|
||||
</CollectionHeading>
|
||||
|
||||
<PinnedDocuments
|
||||
pins={pins}
|
||||
canUpdate={can.update}
|
||||
placeholderCount={count}
|
||||
/>
|
||||
<CollectionDescription collection={collection} />
|
||||
<PinnedDocuments
|
||||
pins={pins}
|
||||
canUpdate={can.update}
|
||||
placeholderCount={count}
|
||||
/>
|
||||
<CollectionDescription collection={collection} />
|
||||
|
||||
<Documents>
|
||||
<Tabs>
|
||||
<Tab to={collectionPath(collection.path)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab
|
||||
to={collectionPath(collection.path, "alphabetical")}
|
||||
exact
|
||||
>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "recent")}>
|
||||
<Redirect
|
||||
to={collectionPath(collection.path, "published")}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
showPublished
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: collection.sort.direction,
|
||||
}}
|
||||
showParentDocuments
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Documents>
|
||||
</>
|
||||
)}
|
||||
<Documents>
|
||||
<Tabs>
|
||||
<Tab to={collectionPath(collection.path)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
{collection.isEmpty ? (
|
||||
<Empty collection={collection} />
|
||||
) : (
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "recent")}>
|
||||
<Redirect to={collectionPath(collection.path, "published")} />
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
showPublished
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionPath(collection.path)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: collection.sort.direction,
|
||||
}}
|
||||
showParentDocuments
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
</Documents>
|
||||
</CenteredContent>
|
||||
</DropToImport>
|
||||
</Scene>
|
||||
|
||||
@@ -12,6 +12,10 @@ import DocumentModel from "~/models/Document";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import ErrorOffline from "~/scenes/ErrorOffline";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
import {
|
||||
DocumentContextProvider,
|
||||
useDocumentContext,
|
||||
} from "~/components/DocumentContext";
|
||||
import Layout from "~/components/Layout";
|
||||
import Sidebar from "~/components/Sidebar/Shared";
|
||||
import { TeamContext } from "~/components/TeamContext";
|
||||
@@ -104,7 +108,6 @@ function SharedDocumentScene(props: Props) {
|
||||
? (searchParams.get("theme") as Theme)
|
||||
: undefined;
|
||||
const theme = useBuildTheme(response?.team?.customTheme, themeOverride);
|
||||
const tocPosition = response?.team?.tocPosition ?? TOCPosition.Left;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!user) {
|
||||
@@ -183,32 +186,53 @@ function SharedDocumentScene(props: Props) {
|
||||
</Helmet>
|
||||
<TeamContext.Provider value={response.team}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Layout
|
||||
title={response.document?.title}
|
||||
sidebar={
|
||||
response.sharedTree?.children.length ? (
|
||||
<Sidebar rootNode={response.sharedTree} shareId={shareId!} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{response.document && (
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
shareId={shareId}
|
||||
tocPosition={tocPosition}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
<ClickablePadding minHeight="20vh" />
|
||||
<DocumentContextProvider>
|
||||
<Layout
|
||||
title={response.document?.title}
|
||||
sidebar={
|
||||
response.sharedTree?.children.length ? (
|
||||
<Sidebar rootNode={response.sharedTree} shareId={shareId!} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<SharedDocument shareId={shareId} response={response} />
|
||||
</Layout>
|
||||
<ClickablePadding minHeight="20vh" />
|
||||
</DocumentContextProvider>
|
||||
</ThemeProvider>
|
||||
</TeamContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SharedDocument = ({
|
||||
shareId,
|
||||
response,
|
||||
}: {
|
||||
shareId?: string;
|
||||
response: Response;
|
||||
}) => {
|
||||
const { setDocument } = useDocumentContext();
|
||||
|
||||
if (!response.document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tocPosition = response.team?.tocPosition ?? TOCPosition.Left;
|
||||
setDocument(response.document);
|
||||
|
||||
return (
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
shareId={shareId}
|
||||
tocPosition={tocPosition}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Content = styled(Text)`
|
||||
color: ${s("textSecondary")};
|
||||
text-align: center;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
@@ -64,9 +64,11 @@ function CommentThread({
|
||||
recessed,
|
||||
focused,
|
||||
}: Props) {
|
||||
const [focusedOnMount] = React.useState(focused);
|
||||
const { editor } = useDocumentContext();
|
||||
const { comments } = useStores();
|
||||
const topRef = React.useRef<HTMLDivElement>(null);
|
||||
const replyRef = React.useRef<HTMLDivElement>(null);
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
@@ -102,7 +104,8 @@ function CommentThread({
|
||||
|
||||
const handleClickThread = () => {
|
||||
history.replace({
|
||||
search: location.search,
|
||||
// Clear any commentId from the URL when explicitly focusing a thread
|
||||
search: "",
|
||||
pathname: location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId: thread.id },
|
||||
});
|
||||
@@ -116,27 +119,35 @@ function CommentThread({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focused) {
|
||||
// If the thread is already visible, scroll it into view immediately,
|
||||
// otherwise wait for the sidebar to appear.
|
||||
const isThreadVisible =
|
||||
(topRef.current?.getBoundingClientRect().left ?? 0) < window.innerWidth;
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
if (focusedOnMount) {
|
||||
setTimeout(() => {
|
||||
if (!topRef.current) {
|
||||
return;
|
||||
}
|
||||
return scrollIntoView(topRef.current, {
|
||||
scrollIntoView(topRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
behavior: "auto",
|
||||
block: "nearest",
|
||||
boundary: (parent) =>
|
||||
// Prevents body and other parent elements from being scrolled
|
||||
parent.id !== "comments",
|
||||
});
|
||||
},
|
||||
isThreadVisible ? 0 : sidebarAppearDuration
|
||||
);
|
||||
}, sidebarAppearDuration);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
if (!replyRef.current) {
|
||||
return;
|
||||
}
|
||||
scrollIntoView(replyRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
boundary: (parent) =>
|
||||
// Prevents body and other parent elements from being scrolled
|
||||
parent.id !== "comments",
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const getCommentMarkElement = () =>
|
||||
window.document?.getElementById(`comment-${thread.id}`);
|
||||
@@ -152,7 +163,7 @@ function CommentThread({
|
||||
isMarkVisible ? 0 : sidebarAppearDuration
|
||||
);
|
||||
}
|
||||
}, [focused, thread.id]);
|
||||
}, [focused, focusedOnMount, thread.id]);
|
||||
|
||||
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
|
||||
`draft-${document.id}-${thread.id}`,
|
||||
@@ -202,7 +213,7 @@ function CommentThread({
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
<ResizingHeightContainer hideOverflow={false}>
|
||||
<ResizingHeightContainer hideOverflow={false} ref={replyRef}>
|
||||
{(focused || draft || commentsInThread.length === 0) && can.comment && (
|
||||
<Fade timing={100}>
|
||||
<CommentForm
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -5,25 +6,18 @@ import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import useWindowScrollPosition from "~/hooks/useWindowScrollPosition";
|
||||
import { decodeURIComponentSafe } from "~/utils/urls";
|
||||
|
||||
const HEADING_OFFSET = 20;
|
||||
|
||||
type Props = {
|
||||
/** The headings to render in the contents. */
|
||||
headings: {
|
||||
title: string;
|
||||
level: number;
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export default function Contents({ headings }: Props) {
|
||||
function Contents() {
|
||||
const [activeSlug, setActiveSlug] = React.useState<string>();
|
||||
const scrollPosition = useWindowScrollPosition({
|
||||
throttle: 100,
|
||||
});
|
||||
const { headings } = useDocumentContext();
|
||||
|
||||
React.useEffect(() => {
|
||||
let activeId = headings.length > 0 ? headings[0].id : undefined;
|
||||
@@ -139,3 +133,5 @@ const List = styled.ol`
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
`;
|
||||
|
||||
export default observer(Contents);
|
||||
|
||||
@@ -9,6 +9,7 @@ import Revision from "~/models/Revision";
|
||||
import Error402 from "~/scenes/Error402";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import ErrorOffline from "~/scenes/ErrorOffline";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -53,10 +54,10 @@ type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
|
||||
};
|
||||
|
||||
function DataLoader({ match, children }: Props) {
|
||||
const { ui, views, shares, comments, documents, revisions, subscriptions } =
|
||||
useStores();
|
||||
const { ui, views, shares, comments, documents, revisions } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { setDocument } = useDocumentContext();
|
||||
const [error, setError] = React.useState<Error | null>(null);
|
||||
const { revisionId, shareId, documentSlug } = match.params;
|
||||
|
||||
@@ -65,6 +66,10 @@ function DataLoader({ match, children }: Props) {
|
||||
documents.getByUrl(match.params.documentSlug) ??
|
||||
documents.get(match.params.documentSlug);
|
||||
|
||||
if (document) {
|
||||
setDocument(document);
|
||||
}
|
||||
|
||||
const revision = revisionId
|
||||
? revisions.get(
|
||||
revisionId === "latest"
|
||||
@@ -121,22 +126,6 @@ function DataLoader({ match, children }: Props) {
|
||||
void fetchRevision();
|
||||
}, [document, revisionId, revisions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchSubscription() {
|
||||
if (document?.id && !document?.isDeleted && !revisionId) {
|
||||
try {
|
||||
await subscriptions.fetchPage({
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error("Failed to fetch subscriptions", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
void fetchSubscription();
|
||||
}, [document?.id, document?.isDeleted, subscriptions, revisionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchViews() {
|
||||
if (document?.id && !document?.isDeleted && !revisionId) {
|
||||
@@ -158,12 +147,17 @@ function DataLoader({ match, children }: Props) {
|
||||
throw new Error("Document not loaded yet");
|
||||
}
|
||||
|
||||
const newDocument = await documents.create({
|
||||
collectionId: nested ? undefined : document.collectionId,
|
||||
parentDocumentId: nested ? document.id : document.parentDocumentId,
|
||||
title,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
});
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: nested ? undefined : document.collectionId,
|
||||
parentDocumentId: nested ? document.id : document.parentDocumentId,
|
||||
title,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
},
|
||||
{
|
||||
publish: document.isDraft ? undefined : true,
|
||||
}
|
||||
);
|
||||
|
||||
return newDocument.url;
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
TOCPosition,
|
||||
TeamPreference,
|
||||
} from "@shared/types";
|
||||
import { ProsemirrorHelper, Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
@@ -116,9 +116,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
@observable
|
||||
title: string = this.props.document.title;
|
||||
|
||||
@observable
|
||||
headings: Heading[] = [];
|
||||
|
||||
componentDidMount() {
|
||||
this.updateIsDirty();
|
||||
}
|
||||
@@ -376,20 +373,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.isUploading = false;
|
||||
};
|
||||
|
||||
handleChange = () => {
|
||||
const { document } = this.props;
|
||||
|
||||
// Keep derived task list in sync
|
||||
const tasks = this.editor.current?.getTasks();
|
||||
const total = tasks?.length ?? 0;
|
||||
const completed = tasks?.filter((t) => t.completed).length ?? 0;
|
||||
document.updateTasks(total, completed);
|
||||
};
|
||||
|
||||
onHeadingsChange = (headings: Heading[]) => {
|
||||
this.headings = headings;
|
||||
};
|
||||
|
||||
handleChangeTitle = action((value: string) => {
|
||||
this.title = value;
|
||||
this.props.document.title = value;
|
||||
@@ -426,7 +409,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
const embedsDisabled =
|
||||
(team && team.documentEmbeds === false) || document.embedsDisabled;
|
||||
|
||||
const hasHeadings = this.headings.length > 0;
|
||||
const showContents =
|
||||
ui.tocVisible === true || (isShare && ui.tocVisible !== false);
|
||||
const tocPos =
|
||||
@@ -493,7 +475,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
)}
|
||||
<Header
|
||||
document={document}
|
||||
documentHasHeadings={hasHeadings}
|
||||
revision={revision}
|
||||
shareId={shareId}
|
||||
isDraft={document.isDraft}
|
||||
@@ -507,7 +488,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
sharedTree={this.props.sharedTree}
|
||||
onSelectTemplate={this.replaceDocument}
|
||||
onSave={this.onSave}
|
||||
headings={this.headings}
|
||||
/>
|
||||
<Main fullWidth={document.fullWidth} tocPosition={tocPos}>
|
||||
<React.Suspense
|
||||
@@ -536,7 +516,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
docFullWidth={document.fullWidth}
|
||||
position={tocPos}
|
||||
>
|
||||
<Contents headings={this.headings} />
|
||||
<Contents />
|
||||
</ContentsContainer>
|
||||
)}
|
||||
<MeasuredContainer
|
||||
@@ -567,8 +547,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onChangeTitle={this.handleChangeTitle}
|
||||
onChangeIcon={this.handleChangeIcon}
|
||||
onChange={this.handleChange}
|
||||
onHeadingsChange={this.onHeadingsChange}
|
||||
onSave={this.onSave}
|
||||
onPublish={this.onPublish}
|
||||
onCancel={this.goBack}
|
||||
@@ -632,7 +610,7 @@ const Main = styled.div<MainProps>`
|
||||
? tocPosition === TOCPosition.Left
|
||||
? `${EditorStyleHelper.tocWidth}px minmax(0, 1fr)`
|
||||
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
|
||||
: `1fr minmax(0, ${`calc(46em + 76px)`}) 1fr`};
|
||||
: `1fr minmax(0, ${`calc(46em + 88px)`}) 1fr`};
|
||||
`};
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
@@ -641,7 +619,7 @@ const Main = styled.div<MainProps>`
|
||||
? tocPosition === TOCPosition.Left
|
||||
? `${EditorStyleHelper.tocWidth}px minmax(0, 1fr)`
|
||||
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
|
||||
: `1fr minmax(0, ${`calc(52em + 76px)`}) 1fr`};
|
||||
: `1fr minmax(0, ${`calc(52em + 88px)`}) 1fr`};
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -670,7 +648,7 @@ type EditorContainerProps = {
|
||||
|
||||
const EditorContainer = styled.div<EditorContainerProps>`
|
||||
// Adds space to the gutter to make room for icon & heading annotations
|
||||
padding: 0 40px;
|
||||
padding: 0 44px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
grid-row: 1;
|
||||
|
||||
@@ -175,7 +175,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
[comments]
|
||||
);
|
||||
|
||||
const { setEditor } = useDocumentContext();
|
||||
const { setEditor, updateState: updateDocState } = useDocumentContext();
|
||||
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
|
||||
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
|
||||
|
||||
@@ -241,6 +241,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
? handleRemoveComment
|
||||
: undefined
|
||||
}
|
||||
onChange={updateDocState}
|
||||
extensions={extensions}
|
||||
editorStyle={editorStyle}
|
||||
{...rest}
|
||||
|
||||
@@ -20,10 +20,7 @@ import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import Collaborators from "~/components/Collaborators";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import {
|
||||
useDocumentContext,
|
||||
useEditingFocus,
|
||||
} from "~/components/DocumentContext";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Flex from "~/components/Flex";
|
||||
import Header from "~/components/Header";
|
||||
import Icon from "~/components/Icon";
|
||||
@@ -35,6 +32,7 @@ import { restoreRevision } from "~/actions/definitions/revisions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useEditingFocus from "~/hooks/useEditingFocus";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -51,7 +49,6 @@ import ShareButton from "./ShareButton";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
documentHasHeadings: boolean;
|
||||
revision: Revision | undefined;
|
||||
sharedTree: NavigationNode | undefined;
|
||||
shareId: string | null | undefined;
|
||||
@@ -67,16 +64,10 @@ type Props = {
|
||||
publish?: boolean;
|
||||
autosave?: boolean;
|
||||
}) => void;
|
||||
headings: {
|
||||
title: string;
|
||||
level: number;
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
function DocumentHeader({
|
||||
document,
|
||||
documentHasHeadings,
|
||||
revision,
|
||||
shareId,
|
||||
isEditing,
|
||||
@@ -88,7 +79,6 @@ function DocumentHeader({
|
||||
sharedTree,
|
||||
onSelectTemplate,
|
||||
onSave,
|
||||
headings,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
@@ -100,6 +90,7 @@ function DocumentHeader({
|
||||
const isRevision = !!revision;
|
||||
const isEditingFocus = useEditingFocus();
|
||||
const { editor } = useDocumentContext();
|
||||
const { hasHeadings } = useDocumentContext();
|
||||
|
||||
// We cache this value for as long as the component is mounted so that if you
|
||||
// apply a template there is still the option to replace it until the user
|
||||
@@ -129,7 +120,7 @@ function DocumentHeader({
|
||||
content={
|
||||
showContents
|
||||
? t("Hide contents")
|
||||
: documentHasHeadings
|
||||
: hasHeadings
|
||||
? t("Show contents")
|
||||
: `${t("Show contents")} (${t("available when headings are added")})`
|
||||
}
|
||||
@@ -210,14 +201,14 @@ function DocumentHeader({
|
||||
hasSidebar={sharedTree && sharedTree.children?.length > 0}
|
||||
left={
|
||||
isMobile ? (
|
||||
<TableOfContentsMenu headings={headings} />
|
||||
<TableOfContentsMenu />
|
||||
) : (
|
||||
<PublicBreadcrumb
|
||||
documentId={document.id}
|
||||
shareId={shareId}
|
||||
sharedTree={sharedTree}
|
||||
>
|
||||
{documentHasHeadings ? toc : null}
|
||||
{hasHeadings ? toc : null}
|
||||
</PublicBreadcrumb>
|
||||
)
|
||||
}
|
||||
@@ -238,7 +229,7 @@ function DocumentHeader({
|
||||
hasSidebar
|
||||
left={
|
||||
isMobile ? (
|
||||
<TableOfContentsMenu headings={headings} />
|
||||
<TableOfContentsMenu />
|
||||
) : (
|
||||
<DocumentBreadcrumb document={document}>
|
||||
{toc} <Star document={document} color={theme.textSecondary} />
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { useEditingFocus } from "~/components/DocumentContext";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useEditingFocus from "~/hooks/useEditingFocus";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
function KeyboardShortcutsButton() {
|
||||
|
||||
@@ -34,15 +34,23 @@ export default function Notices({ document, readOnly }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the permanently deleted date is in the past, show the current date
|
||||
// to avoid showing a negative number of days. The cleanup task will
|
||||
// permanently delete the document at the next run.
|
||||
const permanentlyDeletedAt =
|
||||
new Date(document.permanentlyDeletedAt) < new Date()
|
||||
? new Date().toISOString()
|
||||
: document.permanentlyDeletedAt;
|
||||
|
||||
return document.template ? (
|
||||
<Trans>
|
||||
This template will be permanently deleted in{" "}
|
||||
<Days dateTime={document.permanentlyDeletedAt} /> unless restored.
|
||||
<Days dateTime={permanentlyDeletedAt} /> unless restored.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
This document will be permanently deleted in{" "}
|
||||
<Days dateTime={document.permanentlyDeletedAt} /> unless restored.
|
||||
<Days dateTime={permanentlyDeletedAt} /> unless restored.
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ function References({ document }: Props) {
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const children = collection
|
||||
? collection.getDocumentChildren(document.id)
|
||||
? collection.getChildrenForDocument(document.id)
|
||||
: [];
|
||||
const showBacklinks = !!backlinks.length;
|
||||
const showChildDocuments = !!children.length;
|
||||
|
||||
@@ -7,7 +7,6 @@ import Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import Popover from "~/components/Popover";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -19,14 +18,9 @@ type Props = {
|
||||
function ShareButton({ document }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { shares } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const sharedParent = shares.getByDocumentParents(document.id);
|
||||
const domain = share?.domain || sharedParent?.domain;
|
||||
const isPubliclyShared =
|
||||
team.sharing !== false &&
|
||||
document.collection?.sharing !== false &&
|
||||
(share?.published || (sharedParent?.published && !document.isDraft));
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 0,
|
||||
@@ -39,7 +33,7 @@ function ShareButton({ document }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const icon = isPubliclyShared ? <GlobeIcon /> : undefined;
|
||||
const icon = document.isPubliclyShared ? <GlobeIcon /> : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user