mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
240 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfc5857e01 | |||
| 954a909749 | |||
| 577de24290 | |||
| 8de59f0a2f | |||
| d8fbe35455 | |||
| 514a724d9d | |||
| d66f41c854 | |||
| b2d6c40ea8 | |||
| c98d6aa33a | |||
| 554c2a5cdb | |||
| ee426de942 | |||
| 746e65e658 | |||
| 8a3a3453e7 | |||
| c7d339ded5 | |||
| ed25554607 | |||
| 29329daf15 | |||
| 3f6390ff18 | |||
| 54b43c6e6f | |||
| 8c9c83eb5a | |||
| 63171e5da2 | |||
| bfd84681d7 | |||
| 7d6a47ce86 | |||
| 68f715b607 | |||
| ea2e7a4d0f | |||
| 26948af1b8 | |||
| 816a6715c5 | |||
| 4579594c63 | |||
| 88f7705fd4 | |||
| 8393847910 | |||
| b9adfa175d | |||
| 7fff8161ff | |||
| 0ef9f1aea1 | |||
| fe63c5d706 | |||
| 7749f0ab9f | |||
| 763b911dfd | |||
| 99e541ede8 | |||
| 06f48ec79a | |||
| 5566d995bd | |||
| 921e89d7b7 | |||
| 32602f89dd | |||
| 2cce95488c | |||
| 0663d191fc | |||
| 84eb1b801d | |||
| 5102cfe8eb | |||
| 1d0617dbd6 | |||
| eedfd549b3 | |||
| 28cb5aa379 | |||
| fd5391cbb6 | |||
| 6e685ee8d9 | |||
| b595a0d427 | |||
| 1c86119065 | |||
| c629006642 | |||
| 326f733d4c | |||
| d4d683c046 | |||
| 8204ac343f | |||
| cae8de7c7a | |||
| 8efa601967 | |||
| 86c3ea8e9d | |||
| c222782534 | |||
| 19ea7ee52b | |||
| d1de84a07e | |||
| d73b4c55bf | |||
| 9843c4c995 | |||
| 685397b057 | |||
| 13d37d4207 | |||
| 7bedfab301 | |||
| db5850ac0d | |||
| a4c40ce25e | |||
| f5457e79cd | |||
| 73eeeefb25 | |||
| 54f82cac96 | |||
| bb43c24efe | |||
| acf3d7cd08 | |||
| 5245f93642 | |||
| cfce55250e | |||
| 6421995b29 | |||
| 8cfd8e25db | |||
| 1282e9653e | |||
| f1edaecf49 | |||
| f7d737ca45 | |||
| 41c2c760d4 | |||
| f692d1bc3a | |||
| 5197d6e18c | |||
| b901ea7b30 | |||
| 3820499856 | |||
| 0cffde63ab | |||
| 449ba6488e | |||
| 62f3e6921f | |||
| bc259316f7 | |||
| 98e03cc227 | |||
| 633e547d3e | |||
| d5de69fd4b | |||
| feec01f160 | |||
| aa5813032e | |||
| a6ba189180 | |||
| 4c65bbc57c | |||
| c76b4f46aa | |||
| ca17b41c53 | |||
| 9747c6ba5d | |||
| 55ffd6d098 | |||
| 9b26ccda19 | |||
| 56b38b9dbd | |||
| 0a3a684493 | |||
| 24548dc7ee | |||
| 28cc83ad05 | |||
| c57b845093 | |||
| 62ee075a6f | |||
| 356b0916fd | |||
| 03160c44d4 | |||
| bf65d80fc8 | |||
| 3d0df9c612 | |||
| 9de95ff658 | |||
| 55bdd6fbc0 | |||
| fec91fb210 | |||
| abb1d3a923 | |||
| f5de2834d6 | |||
| 68377c3c46 | |||
| 9661e18cbd | |||
| 08d210e483 | |||
| 5a0ce58fa0 | |||
| 08eeac2049 | |||
| 08a49378ea | |||
| a4a068a3ba | |||
| 27cbf5a56a | |||
| f3daf45ccc | |||
| c1c20f1ff9 | |||
| e4d60382fd | |||
| 763dd28829 | |||
| 93f7fa8c89 | |||
| 24e50d9290 | |||
| c1b19ef86c | |||
| 5780959e93 | |||
| 74192040a2 | |||
| 7b3eba0f2f | |||
| 9b03b529f8 | |||
| aa579412d0 | |||
| 774402560e | |||
| 0a875d4738 | |||
| de04d1c0c5 | |||
| d87e1f6264 | |||
| 0e249951ab | |||
| 398be02091 | |||
| 83f0d34430 | |||
| 21723d3ca2 | |||
| bc4f0b926d | |||
| 4d67d47795 | |||
| d372ccf5b6 | |||
| d78eeaba84 | |||
| fc333abb86 | |||
| 73ef9f9a05 | |||
| 670ddda3a4 | |||
| 6e74ccf61f | |||
| f3f7189c93 | |||
| 50e680aaaf | |||
| 373ffba384 | |||
| b0182dfc76 | |||
| 2084c4ff8e | |||
| 29fce45a6e | |||
| e524699a8c | |||
| 5e74554f4b | |||
| 2a3909f65a | |||
| b91c06d26a | |||
| f8433bc65e | |||
| 7bdae0cbda | |||
| 3692d9c930 | |||
| 2e1a827157 | |||
| fe33871dfe | |||
| f22bd1d7c8 | |||
| 48ff0ad84b | |||
| 4f626c08c2 | |||
| 57e9abd77f | |||
| 0d7ce76c21 | |||
| c8d307c2d4 | |||
| 10c51ef08d | |||
| bb988b551d | |||
| 0e75edf7e3 | |||
| 3523ee4c35 | |||
| c0fba3913c | |||
| 597106cb48 | |||
| 02c29e06fb | |||
| 7226109989 | |||
| 85957c10b8 | |||
| 7250bd3bcb | |||
| 2ee7e0f832 | |||
| c5278a71de | |||
| e41519575f | |||
| 201ccf39a0 | |||
| ac3285a29a | |||
| fdaeb6602d | |||
| da4cd4ebcd | |||
| b6fc8fb4b1 | |||
| 4e6572d686 | |||
| 9e378899ff | |||
| 31dafc4258 | |||
| 6614b23eae | |||
| 9e54fd1bfb | |||
| f0add849f9 | |||
| b55915c257 | |||
| bdac4360b4 | |||
| 72bfbf2060 | |||
| db02b0ae6b | |||
| bb40e4079a | |||
| 198a96c78f | |||
| 1dd835bb87 | |||
| 25c504ceaf | |||
| 9680e57849 | |||
| 0f8ac54bcb | |||
| 936a8b2510 | |||
| b7b5e3edb9 | |||
| 1cea59abe2 | |||
| 8f0211057c | |||
| 2bfef05137 | |||
| d2a99b6872 | |||
| 6c9f265918 | |||
| 7a8d40b9e7 | |||
| 3ddffdda17 | |||
| 91396148ae | |||
| 1c2ea2aa92 | |||
| ba5eb60825 | |||
| a0e363799c | |||
| 3d457890cd | |||
| e857d00e3d | |||
| 98d8435b15 | |||
| b80463665b | |||
| b4ce4a2922 | |||
| 9bee54b07e | |||
| d3c8224839 | |||
| 0a1c614c55 | |||
| db4dad5e37 | |||
| 35ff70bf14 | |||
| 8b5fdba6f4 | |||
| e0a3ad92e0 | |||
| 10f4889737 | |||
| 7f66393e63 | |||
| 033b05f679 | |||
| 8356d44cae | |||
| 030c0fd40e | |||
| 1a02b0d9d7 | |||
| be5f092117 | |||
| 0ba423feb4 |
@@ -4,12 +4,6 @@ defaults: &defaults
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: cimg/node:20.10
|
||||
- image: cimg/redis:5.0
|
||||
- image: cimg/postgres:14.2
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: circle_test
|
||||
resource_class: large
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
@@ -78,6 +72,14 @@ jobs:
|
||||
test-server:
|
||||
<<: *defaults
|
||||
parallelism: 3
|
||||
docker:
|
||||
- image: cimg/node:20.10
|
||||
- image: cimg/redis:5.0
|
||||
- image: cimg/postgres:14.2
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: circle_test
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
@@ -108,8 +110,7 @@ jobs:
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
- checkout
|
||||
- setup_remote_docker:
|
||||
version: 20.10.6
|
||||
- setup_remote_docker
|
||||
- run:
|
||||
name: Install Docker buildx
|
||||
command: |
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.80.2
|
||||
Licensed Work: Outline 0.81.0
|
||||
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
|
||||
@@ -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: 2028-09-26
|
||||
Change Date: 2028-11-11
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
RestoreIcon,
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
StarredIcon,
|
||||
@@ -10,11 +12,13 @@ import {
|
||||
UnstarredIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import Collection from "~/models/Collection";
|
||||
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
|
||||
import { CollectionNew } from "~/components/Collection/CollectionNew";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import SharePopover from "~/components/Sharing/Collection/SharePopover";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
@@ -129,9 +133,20 @@ export const searchInCollection = createAction({
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).readDocument,
|
||||
visible: ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
if (!collection?.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stores.policies.abilities(activeCollectionId).readDocument;
|
||||
},
|
||||
|
||||
perform: ({ activeCollectionId }) => {
|
||||
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
|
||||
},
|
||||
@@ -190,6 +205,72 @@ export const unstarCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveCollection = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive collection",
|
||||
section: CollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeCollectionId).archive;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
const { dialogs, collections } = stores;
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Archive collection"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await collection.archive();
|
||||
toast.success(t("Collection archived"));
|
||||
}}
|
||||
submitText={t("Archive")}
|
||||
savingText={`${t("Archiving")}…`}
|
||||
>
|
||||
{t(
|
||||
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreCollection = createAction({
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore collection",
|
||||
section: CollectionSection,
|
||||
icon: <RestoreIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeCollectionId).restore;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
await collection.restore();
|
||||
toast.success(t("Collection restored"));
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCollection = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete collection",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { DoneIcon, TrashIcon } from "outline-icons";
|
||||
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
|
||||
import history from "~/utils/history";
|
||||
import { createAction } from "..";
|
||||
import { DocumentSection } from "../sections";
|
||||
@@ -88,3 +89,27 @@ export const unresolveCommentFactory = ({
|
||||
onUnresolve();
|
||||
},
|
||||
});
|
||||
|
||||
export const viewCommentReactionsFactory = ({
|
||||
comment,
|
||||
}: {
|
||||
comment: Comment;
|
||||
}) =>
|
||||
createAction({
|
||||
name: ({ t }) => `${t("View reactions")}`,
|
||||
analyticsName: "View comment reactions",
|
||||
section: DocumentSection,
|
||||
icon: <SmileyIcon />,
|
||||
visible: () =>
|
||||
stores.policies.abilities(comment.id).read &&
|
||||
comment.reactions.length > 0,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Reactions"),
|
||||
content: <ViewReactionsDialog model={comment} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
EyeIcon,
|
||||
PadlockIcon,
|
||||
GlobeIcon,
|
||||
LogoutIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -37,13 +38,14 @@ import {
|
||||
NavigationNode,
|
||||
} from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
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 DocumentCopy from "~/components/DocumentCopy";
|
||||
import Icon from "~/components/Icon";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
@@ -129,11 +131,30 @@ export const createDocumentFromTemplate = createAction({
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) =>
|
||||
!!currentTeamId &&
|
||||
!!activeDocumentId &&
|
||||
!!stores.documents.get(activeDocumentId)?.template &&
|
||||
stores.policies.abilities(currentTeamId).createDocument,
|
||||
visible: ({
|
||||
currentTeamId,
|
||||
activeCollectionId,
|
||||
activeDocumentId,
|
||||
stores,
|
||||
}) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
!currentTeamId ||
|
||||
!document?.isTemplate ||
|
||||
!!document?.isDraft ||
|
||||
!!document?.isDeleted
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeCollectionId) {
|
||||
return stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
|
||||
history.push(
|
||||
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
|
||||
@@ -358,8 +379,6 @@ export const shareDocument = createAction({
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
const share = stores.shares.getByDocumentId(activeDocumentId);
|
||||
const sharedParent = stores.shares.getByDocumentParents(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
@@ -370,8 +389,6 @@ export const shareDocument = createAction({
|
||||
content: (
|
||||
<SharePopover
|
||||
document={document}
|
||||
share={share}
|
||||
sharedParent={sharedParent}
|
||||
onRequestClose={stores.dialogs.closeAllModals}
|
||||
visible
|
||||
/>
|
||||
@@ -545,7 +562,7 @@ export const duplicateDocument = createAction({
|
||||
stores.dialogs.openModal({
|
||||
title: t("Copy document"),
|
||||
content: (
|
||||
<DuplicateDialog
|
||||
<DocumentCopy
|
||||
document={document}
|
||||
onSubmit={(response) => {
|
||||
stores.dialogs.closeAllModals();
|
||||
@@ -1037,7 +1054,7 @@ export const openDocumentComments = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.toggleComments(activeDocumentId);
|
||||
stores.ui.toggleComments();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1123,6 +1140,42 @@ export const toggleViewerInsights = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const leaveDocument = createAction({
|
||||
name: ({ t }) => t("Leave document"),
|
||||
analyticsName: "Leave document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <LogoutIcon />,
|
||||
visible: ({ currentUserId, activeDocumentId, stores }) => {
|
||||
const membership = stores.userMemberships.orderedData.find(
|
||||
(m) => m.documentId === activeDocumentId && m.userId === currentUserId
|
||||
);
|
||||
|
||||
return !!membership;
|
||||
},
|
||||
perform: async ({ t, location, currentUserId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
try {
|
||||
if (document && location.pathname.startsWith(document.path)) {
|
||||
history.push(homePath());
|
||||
}
|
||||
|
||||
await stores.userMemberships.delete({
|
||||
documentId: activeDocumentId,
|
||||
userId: currentUserId,
|
||||
} as UserMembership);
|
||||
|
||||
toast.success(t("You have left the shared document"));
|
||||
} catch (err) {
|
||||
toast.error(t("Could not leave document"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
@@ -1141,6 +1194,7 @@ export const rootDocumentActions = [
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
duplicateDocument,
|
||||
leaveDocument,
|
||||
moveTemplateToWorkspace,
|
||||
moveDocumentToCollection,
|
||||
openRandomDocument,
|
||||
|
||||
@@ -91,6 +91,15 @@ export const navigateToSettings = createAction({
|
||||
perform: () => history.push(settingsPath()),
|
||||
});
|
||||
|
||||
export const navigateToWorkspaceSettings = createAction({
|
||||
name: ({ t }) => t("Settings"),
|
||||
analyticsName: "Navigate to workspace settings",
|
||||
section: NavigationSection,
|
||||
icon: <SettingsIcon />,
|
||||
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
|
||||
perform: () => history.push(settingsPath("details")),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createAction({
|
||||
name: ({ t }) => t("Profile"),
|
||||
analyticsName: "Navigate to profile settings",
|
||||
|
||||
@@ -31,7 +31,6 @@ const Actions = styled(Flex)`
|
||||
left: 0;
|
||||
border-radius: 3px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
padding: 12px;
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Redirect } from "react-router-dom";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
import { logoutPath } from "~/utils/routeHelpers";
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
|
||||
type Props = {
|
||||
@@ -32,7 +33,7 @@ const Authenticated = ({ children }: Props) => {
|
||||
}
|
||||
|
||||
void auth.logout(true);
|
||||
return <Redirect to="/" />;
|
||||
return <Redirect to={logoutPath()} />;
|
||||
};
|
||||
|
||||
export default observer(Authenticated);
|
||||
|
||||
@@ -94,7 +94,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
!showHistory &&
|
||||
can.comment &&
|
||||
ui.activeDocumentId &&
|
||||
ui.commentsExpanded.includes(ui.activeDocumentId) &&
|
||||
ui.commentsExpanded &&
|
||||
team.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { DisclosureIcon } from "outline-icons";
|
||||
import { darken, lighten, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
@@ -189,10 +189,14 @@ const Button = <T extends React.ElementType = "button">(
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && ic}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon />}
|
||||
{disclosure && <StyledDisclosureIcon />}
|
||||
</Inner>
|
||||
</RealButton>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledDisclosureIcon = styled(DisclosureIcon)`
|
||||
opacity: 0.8;
|
||||
`;
|
||||
|
||||
export default React.forwardRef(Button);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { archivePath, collectionPath } from "~/utils/routeHelpers";
|
||||
import Breadcrumb from "./Breadcrumb";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const collectionNode: MenuInternalLink = {
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.path),
|
||||
};
|
||||
|
||||
const category: MenuInternalLink | undefined = collection.isArchived
|
||||
? {
|
||||
type: "route",
|
||||
icon: <ArchiveIcon />,
|
||||
title: t("Archive"),
|
||||
to: archivePath(),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const output = [];
|
||||
if (category) {
|
||||
output.push(category);
|
||||
}
|
||||
|
||||
output.push(collectionNode);
|
||||
|
||||
return output;
|
||||
}, [collection, t]);
|
||||
|
||||
return <Breadcrumb items={items} highlightFirstItem />;
|
||||
};
|
||||
@@ -201,7 +201,6 @@ const Input = styled.div`
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: ${s("backgroundTransition")};
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
@@ -226,7 +225,7 @@ const Input = styled.div`
|
||||
}
|
||||
|
||||
&[data-editing="true"] {
|
||||
background: ${s("secondaryBackground")};
|
||||
background: ${s("backgroundSecondary")};
|
||||
}
|
||||
|
||||
.block-menu-trigger,
|
||||
|
||||
@@ -77,7 +77,10 @@ const SearchInput = styled(KBarSearch)`
|
||||
border: none;
|
||||
background: ${s("menuBackground")};
|
||||
color: ${s("text")};
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
|
||||
@@ -7,6 +7,10 @@ import CommandBarItem from "./CommandBarItem";
|
||||
export default function CommandBarResults() {
|
||||
const { results, rootActionId } = useMatches();
|
||||
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<KBarResults
|
||||
|
||||
@@ -2,7 +2,11 @@ import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import {
|
||||
ActiveCollectionSection,
|
||||
DocumentSection,
|
||||
TeamSection,
|
||||
} from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
@@ -11,26 +15,42 @@ const useTemplatesAction = () => {
|
||||
const { documents } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
void documents.fetchTemplates();
|
||||
void documents.fetchAllTemplates();
|
||||
}, [documents]);
|
||||
|
||||
const actions = React.useMemo(
|
||||
() =>
|
||||
documents.templatesAlphabetical.map((item) =>
|
||||
documents.templatesAlphabetical.map((template) =>
|
||||
createAction({
|
||||
name: item.titleWithDefault,
|
||||
name: template.titleWithDefault,
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: item.icon ? (
|
||||
<Icon value={item.icon} color={item.color ?? undefined} />
|
||||
section: template.isWorkspaceTemplate
|
||||
? TeamSection
|
||||
: ActiveCollectionSection,
|
||||
icon: template.icon ? (
|
||||
<Icon value={template.icon} color={template.color ?? undefined} />
|
||||
) : (
|
||||
<NewDocumentIcon />
|
||||
),
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
||||
if (activeCollectionId) {
|
||||
return (
|
||||
stores.policies.abilities(activeCollectionId).createDocument &&
|
||||
(template.collectionId === activeCollectionId ||
|
||||
template.isWorkspaceTemplate)
|
||||
);
|
||||
}
|
||||
return (
|
||||
!!currentTeamId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument &&
|
||||
template.isWorkspaceTemplate
|
||||
);
|
||||
},
|
||||
perform: ({ activeCollectionId, sidebarContext }) =>
|
||||
history.push(
|
||||
newDocumentPath(item.collectionId ?? activeCollectionId, {
|
||||
templateId: item.id,
|
||||
newDocumentPath(template.collectionId ?? activeCollectionId, {
|
||||
templateId: template.id,
|
||||
}),
|
||||
{
|
||||
sidebarContext,
|
||||
@@ -49,9 +69,15 @@ const useTemplatesAction = () => {
|
||||
placeholder: ({ t }) => t("Choose a template"),
|
||||
section: DocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument,
|
||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
||||
if (activeCollectionId) {
|
||||
return stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
return (
|
||||
!!currentTeamId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
children: () => actions,
|
||||
}),
|
||||
[actions]
|
||||
|
||||
@@ -21,11 +21,13 @@ 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 accessMapping: Record<Partial<CollectionPermission> | "null", string> =
|
||||
{
|
||||
[CollectionPermission.Admin]: t("manage access"),
|
||||
[CollectionPermission.ReadWrite]: t("view and edit access"),
|
||||
[CollectionPermission.Read]: t("view only access"),
|
||||
null: t("no access"),
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await documents.move({
|
||||
|
||||
@@ -35,7 +35,7 @@ function ConnectionStatus() {
|
||||
};
|
||||
|
||||
const message = ui.multiplayerErrorCode
|
||||
? codeToMessage[ui.multiplayerErrorCode]
|
||||
? codeToMessage[ui.multiplayerErrorCode as keyof typeof codeToMessage]
|
||||
: undefined;
|
||||
|
||||
return ui.multiplayerStatus === "connecting" ||
|
||||
|
||||
@@ -182,7 +182,6 @@ function placeCaret(element: HTMLElement, atStart: boolean) {
|
||||
|
||||
const Content = styled.span`
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
color: ${s("text")};
|
||||
-webkit-text-fill-color: ${s("text")};
|
||||
outline: none;
|
||||
|
||||
@@ -262,22 +262,6 @@ export const Position = styled.div`
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -39,6 +39,7 @@ function DocumentCard(props: Props) {
|
||||
const { collections } = useStores();
|
||||
const theme = useTheme();
|
||||
const { document, pin, canUpdatePin, isDraggable } = props;
|
||||
const pinnedToHome = React.useRef(!pin?.collectionId).current;
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
@@ -122,13 +123,13 @@ function DocumentCard(props: Props) {
|
||||
<Squircle
|
||||
color={
|
||||
collection?.color ??
|
||||
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
|
||||
(pinnedToHome ? theme.slateLight : theme.slateDark)
|
||||
}
|
||||
>
|
||||
{collection?.icon &&
|
||||
collection?.icon !== "letter" &&
|
||||
collection?.icon !== "collection" &&
|
||||
!pin?.collectionId ? (
|
||||
pinnedToHome ? (
|
||||
<CollectionIcon collection={collection} color="white" />
|
||||
) : (
|
||||
<DocumentIcon color="white" />
|
||||
|
||||
@@ -11,6 +11,9 @@ class DocumentContext {
|
||||
/** The editor instance for this document */
|
||||
editor?: Editor;
|
||||
|
||||
@observable
|
||||
isEditorInitialized: boolean = false;
|
||||
|
||||
@observable
|
||||
headings: Heading[] = [];
|
||||
|
||||
@@ -31,6 +34,11 @@ class DocumentContext {
|
||||
this.updateState();
|
||||
};
|
||||
|
||||
@action
|
||||
setEditorInitialized = (initialized: boolean) => {
|
||||
this.isEditorInitialized = initialized;
|
||||
};
|
||||
|
||||
@action
|
||||
updateState = () => {
|
||||
this.updateHeadings();
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import flatten from "lodash/flatten";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
|
||||
import Button from "~/components/Button";
|
||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { flattenTree } from "~/utils/tree";
|
||||
import Switch from "./Switch";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
/** The original document to duplicate */
|
||||
document: Document;
|
||||
onSubmit: (documents: Document[]) => void;
|
||||
};
|
||||
|
||||
function DocumentCopy({ document, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { policies } = useStores();
|
||||
const collectionTrees = useCollectionTrees();
|
||||
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
|
||||
const [recursive, setRecursive] = React.useState<boolean>(true);
|
||||
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
: true
|
||||
);
|
||||
|
||||
if (document.isTemplate) {
|
||||
return nodes
|
||||
.filter((node) => node.type === "collection")
|
||||
.map((node) => ({ ...node, children: [] }));
|
||||
}
|
||||
return nodes;
|
||||
}, [policies, collectionTrees, document.isTemplate]);
|
||||
|
||||
const handlePublishChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPublish(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRecursiveChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRecursive(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const copy = async () => {
|
||||
if (!selectedPath) {
|
||||
toast.message(t("Select a location to copy"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await document.duplicate({
|
||||
publish,
|
||||
recursive,
|
||||
title: document.title,
|
||||
collectionId: selectedPath.collectionId,
|
||||
...(selectedPath.type === "document"
|
||||
? { parentDocumentId: selectedPath.id }
|
||||
: {}),
|
||||
});
|
||||
|
||||
toast.success(t("Document copied"));
|
||||
onSubmit(result);
|
||||
} catch (err) {
|
||||
toast.error(t("Couldn’t copy the document, try again?"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FlexContainer column>
|
||||
<DocumentExplorer
|
||||
items={items}
|
||||
onSubmit={copy}
|
||||
onSelect={selectPath}
|
||||
defaultValue={document.parentDocumentId || document.collectionId || ""}
|
||||
/>
|
||||
<OptionsContainer>
|
||||
{!document.isTemplate && (
|
||||
<>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={handlePublishChange}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={handleRecursiveChange}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</OptionsContainer>
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<StyledText type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Copy to <em>{{ location }}</em>"
|
||||
values={{ location: selectedPath.title }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
) : (
|
||||
t("Select a location to copy")
|
||||
)}
|
||||
</StyledText>
|
||||
<Button disabled={!selectedPath} onClick={copy}>
|
||||
{t("Copy")}
|
||||
</Button>
|
||||
</Footer>
|
||||
</FlexContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const OptionsContainer = styled.div`
|
||||
margin: 16px 0 8px 0;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
`;
|
||||
|
||||
export default observer(DocumentCopy);
|
||||
@@ -31,15 +31,15 @@ import { ancestors, descendants } from "~/utils/tree";
|
||||
type Props = {
|
||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||
onSubmit: () => void;
|
||||
|
||||
/** A side-effect of item selection */
|
||||
onSelect: (item: NavigationNode | null) => void;
|
||||
|
||||
/** Items to be shown in explorer */
|
||||
items: NavigationNode[];
|
||||
/** Automatically expand to and select item with the given id */
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const isMobile = useMobile();
|
||||
const { collections, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -47,12 +47,25 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState<string>();
|
||||
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
||||
null
|
||||
() => {
|
||||
const node =
|
||||
defaultValue && items.find((item) => item.id === defaultValue);
|
||||
return node || null;
|
||||
}
|
||||
);
|
||||
const [initialScrollOffset, setInitialScrollOffset] =
|
||||
React.useState<number>(0);
|
||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
|
||||
if (defaultValue) {
|
||||
const node = items.find((item) => item.id === defaultValue);
|
||||
if (node) {
|
||||
return ancestors(node).map((node) => node.id);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const [itemRefs, setItemRefs] = React.useState<
|
||||
React.RefObject<HTMLSpanElement>[]
|
||||
>([]);
|
||||
@@ -94,6 +107,15 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
|
||||
onSelect(selectedNode);
|
||||
}, [selectedNode, onSelect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (defaultValue && selectedNode && listRef) {
|
||||
const index = nodes.findIndex((node) => node.id === selectedNode.id);
|
||||
if (index > 0) {
|
||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
function getNodes() {
|
||||
function includeDescendants(item: NavigationNode): NavigationNode[] {
|
||||
return expandedNodes.includes(item.id)
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Document from "~/models/Document";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input from "./Input";
|
||||
import Switch from "./Switch";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
/** The original document to duplicate */
|
||||
document: Document;
|
||||
onSubmit: (documents: Document[]) => void;
|
||||
};
|
||||
|
||||
function DuplicateDialog({ document, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const defaultTitle = t(`Copy of {{ documentName }}`, {
|
||||
documentName: document.title,
|
||||
});
|
||||
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
|
||||
const [recursive, setRecursive] = React.useState<boolean>(true);
|
||||
const [title, setTitle] = React.useState<string>(defaultTitle);
|
||||
|
||||
const handlePublishChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPublish(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRecursiveChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRecursive(ev.target.checked);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTitle(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const result = await document.duplicate({
|
||||
publish,
|
||||
recursive,
|
||||
title,
|
||||
});
|
||||
onSubmit(result);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Duplicate")}>
|
||||
<Input
|
||||
autoFocus
|
||||
autoSelect
|
||||
name="title"
|
||||
label={t("Title")}
|
||||
onChange={handleTitleChange}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
defaultValue={defaultTitle}
|
||||
/>
|
||||
{!document.isTemplate && (
|
||||
<>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={handlePublishChange}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={handleRecursiveChange}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DuplicateDialog);
|
||||
@@ -98,7 +98,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
}
|
||||
|
||||
// default search for anything that doesn't look like a URL
|
||||
const results = await documents.searchTitles(term);
|
||||
const results = await documents.searchTitles({ query: term });
|
||||
|
||||
return sortBy(
|
||||
results.map(({ document }) => ({
|
||||
@@ -255,6 +255,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
<ErrorBoundary component="div" reloadOnChunkMissing>
|
||||
<>
|
||||
<LazyLoadedEditor
|
||||
key={props.extensions?.length || 0}
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
uploadFile={handleUploadFile}
|
||||
embeds={embeds}
|
||||
@@ -267,14 +268,15 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{props.editorStyle?.paddingBottom && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
onClick={focusAtEnd}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
/>
|
||||
)}
|
||||
{props.editorStyle?.paddingBottom &&
|
||||
(!props.readOnly || props.shareId) && (
|
||||
<ClickablePadding
|
||||
onClick={props.readOnly ? undefined : focusAtEnd}
|
||||
onDrop={props.readOnly ? undefined : handleDrop}
|
||||
onDragOver={props.readOnly ? undefined : handleDragOver}
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** Width of the containing element. */
|
||||
width?: number | string;
|
||||
/** Height of the containing element. */
|
||||
height?: number | string;
|
||||
/** Controls the rendered emoji size. */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const Emoji = styled.span<Props>`
|
||||
font-family: ${s("fontFamilyEmoji")};
|
||||
width: ${({ width }) =>
|
||||
typeof width === "string" ? width : width ? `${width}px` : "auto"};
|
||||
height: ${({ height }) =>
|
||||
typeof height === "string" ? height : height ? `${height}px` : "auto"};
|
||||
font-size: ${({ size }) => size && `${size}px`};
|
||||
`;
|
||||
@@ -138,7 +138,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const Pre = styled.pre`
|
||||
background: ${s("secondaryBackground")};
|
||||
background: ${s("backgroundSecondary")};
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -27,7 +27,7 @@ import { documentHistoryPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
event: Event;
|
||||
event: Event<Document>;
|
||||
latest?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ const SearchInput = styled(Input)`
|
||||
${Outline} {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
border-bottom: 1px solid rgb(34 40 52);
|
||||
background: ${s("menuBackground")};
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ const Image = styled(Flex)`
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${s("secondaryBackground")};
|
||||
background: ${s("backgroundSecondary")};
|
||||
border-radius: 32px;
|
||||
`;
|
||||
|
||||
|
||||
@@ -94,7 +94,6 @@ const Scene = styled.div`
|
||||
align-items: flex-start;
|
||||
width: 350px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
opacity: 0;
|
||||
|
||||
@@ -16,6 +16,8 @@ import useStores from "~/hooks/useStores";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
export const HEADER_HEIGHT = 64;
|
||||
|
||||
type Props = {
|
||||
left?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
@@ -128,9 +130,8 @@ const Wrapper = styled(Flex)<WrapperProps>`
|
||||
`};
|
||||
|
||||
padding: 12px;
|
||||
transition: all 100ms ease-out;
|
||||
transform: translate3d(0, 0, 0);
|
||||
min-height: 64px;
|
||||
min-height: ${HEADER_HEIGHT}px;
|
||||
justify-content: flex-start;
|
||||
${draggableOnDesktop()}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
|
||||
color?: string;
|
||||
}>`
|
||||
background-color: ${(props) =>
|
||||
props.color ?? props.theme.secondaryBackground};
|
||||
props.color ?? props.theme.backgroundSecondary};
|
||||
color: ${(props) =>
|
||||
props.color ? getTextColor(props.color) : props.theme.text};
|
||||
width: fit-content;
|
||||
|
||||
@@ -125,6 +125,7 @@ function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
|
||||
avatarUrl={data.avatarUrl}
|
||||
color={data.color}
|
||||
lastActive={data.lastActive}
|
||||
email={data.email}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
|
||||
|
||||
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
{ avatarUrl, name, lastActive, color }: Props,
|
||||
{ avatarUrl, name, lastActive, color, email }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
@@ -25,6 +25,7 @@ const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
/>
|
||||
<Flex column gap={2} justify="center">
|
||||
<Title>{name}</Title>
|
||||
{email && <Info>{email}</Info>}
|
||||
<Info>{lastActive}</Info>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
export const Emoji = styled.span`
|
||||
font-family: ${s("fontFamilyEmoji")};
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
@@ -18,11 +18,7 @@ import {
|
||||
import GridTemplate, { DataNode } from "./GridTemplate";
|
||||
import SkinTonePicker from "./SkinTonePicker";
|
||||
|
||||
/**
|
||||
* This is needed as a constant for react-window.
|
||||
* Calculated from the heights of TabPanel and InputSearch.
|
||||
*/
|
||||
const GRID_HEIGHT = 362;
|
||||
const GRID_HEIGHT = 410;
|
||||
|
||||
const useEmojiState = () => {
|
||||
const [emojiSkinTone, setEmojiSkinTone] = usePersistedState<EmojiSkinTone>(
|
||||
@@ -80,6 +76,7 @@ type Props = {
|
||||
panelWidth: number;
|
||||
query: string;
|
||||
panelActive: boolean;
|
||||
height?: number;
|
||||
onEmojiChange: (emoji: string) => void;
|
||||
onQueryChange: (query: string) => void;
|
||||
};
|
||||
@@ -90,6 +87,7 @@ const EmojiPanel = ({
|
||||
panelActive,
|
||||
onEmojiChange,
|
||||
onQueryChange,
|
||||
height = GRID_HEIGHT,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -159,7 +157,7 @@ const EmojiPanel = ({
|
||||
<GridTemplate
|
||||
ref={scrollableRef}
|
||||
width={panelWidth}
|
||||
height={GRID_HEIGHT}
|
||||
height={height - 48}
|
||||
data={templateData}
|
||||
onIconSelect={handleEmojiSelection}
|
||||
/>
|
||||
|
||||
@@ -4,9 +4,9 @@ import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { IconType } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Text from "~/components/Text";
|
||||
import { TRANSLATED_CATEGORIES } from "../utils";
|
||||
import { Emoji } from "./Emoji";
|
||||
import Grid from "./Grid";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
@@ -71,7 +71,7 @@ const GridTemplate = (
|
||||
<IconButton
|
||||
key={item.name}
|
||||
onClick={() => onIconSelect({ id: item.name, value: item.name })}
|
||||
delay={item.delay}
|
||||
style={{ "--delay": `${item.delay}ms` } as React.CSSProperties}
|
||||
>
|
||||
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
|
||||
{item.initial}
|
||||
@@ -85,7 +85,9 @@ const GridTemplate = (
|
||||
key={item.id}
|
||||
onClick={() => onIconSelect({ id: item.id, value: item.value })}
|
||||
>
|
||||
<Emoji>{item.value}</Emoji>
|
||||
<Emoji width={24} height={24}>
|
||||
{item.value}
|
||||
</Emoji>
|
||||
</IconButton>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ export const IconButton = styled(NudeButton)<{ delay?: number }>`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
--delay: ${({ delay }) => delay && `${delay}ms`};
|
||||
|
||||
&: ${hover} {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
|
||||
@@ -5,10 +5,10 @@ import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { EmojiSkinTone } from "@shared/types";
|
||||
import { getEmojiVariants } from "@shared/utils/emoji";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { hover } from "~/styles";
|
||||
import { Emoji } from "./Emoji";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
const SkinTonePicker = ({
|
||||
@@ -26,7 +26,7 @@ const SkinTonePicker = ({
|
||||
);
|
||||
|
||||
const menu = useMenuState({
|
||||
placement: "bottom",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const handleSkinClick = React.useCallback(
|
||||
@@ -43,7 +43,9 @@ const SkinTonePicker = ({
|
||||
<MenuItem {...menu} key={emoji.value}>
|
||||
{(menuprops) => (
|
||||
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
|
||||
<Emoji>{emoji.value}</Emoji>
|
||||
<Emoji width={24} height={24}>
|
||||
{emoji.value}
|
||||
</Emoji>
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
|
||||
@@ -82,6 +82,7 @@ const IconPicker = ({
|
||||
modal: true,
|
||||
unstable_offset: [0, 0],
|
||||
});
|
||||
const { hide, show, visible } = popover;
|
||||
const tab = useTabState({ selectedId: defaultTab });
|
||||
const previouslyVisible = usePrevious(popover.visible);
|
||||
|
||||
@@ -96,12 +97,12 @@ const IconPicker = ({
|
||||
|
||||
const handleIconChange = React.useCallback(
|
||||
(ic: string) => {
|
||||
popover.hide();
|
||||
hide();
|
||||
const icType = determineIconType(ic);
|
||||
const finalColor = icType === IconType.SVG ? chosenColor : null;
|
||||
onChange(ic, finalColor);
|
||||
},
|
||||
[popover, onChange, chosenColor]
|
||||
[hide, onChange, chosenColor]
|
||||
);
|
||||
|
||||
const handleIconColorChange = React.useCallback(
|
||||
@@ -118,32 +119,32 @@ const IconPicker = ({
|
||||
);
|
||||
|
||||
const handleIconRemove = React.useCallback(() => {
|
||||
popover.hide();
|
||||
hide();
|
||||
onChange(null, null);
|
||||
}, [popover, onChange]);
|
||||
}, [hide, onChange]);
|
||||
|
||||
const handlePopoverButtonClick = React.useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (popover.visible) {
|
||||
popover.hide();
|
||||
if (visible) {
|
||||
hide();
|
||||
} else {
|
||||
popover.show();
|
||||
show();
|
||||
}
|
||||
},
|
||||
[popover]
|
||||
[hide, show, visible]
|
||||
);
|
||||
|
||||
// Popover open effect
|
||||
React.useEffect(() => {
|
||||
if (popover.visible && !previouslyVisible) {
|
||||
if (visible && !previouslyVisible) {
|
||||
onOpen?.();
|
||||
} else if (!popover.visible && previouslyVisible) {
|
||||
} else if (!visible && previouslyVisible) {
|
||||
onClose?.();
|
||||
setQuery("");
|
||||
resetDefaultTab();
|
||||
}
|
||||
}, [popover.visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
|
||||
}, [visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
|
||||
|
||||
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
|
||||
// prevent event bubbling.
|
||||
@@ -198,7 +199,7 @@ const IconPicker = ({
|
||||
{...tab}
|
||||
id={TAB_NAMES["Icon"]}
|
||||
aria-label={t("Icons")}
|
||||
active={tab.selectedId === TAB_NAMES["Icon"]}
|
||||
$active={tab.selectedId === TAB_NAMES["Icon"]}
|
||||
>
|
||||
{t("Icons")}
|
||||
</StyledTab>
|
||||
@@ -206,7 +207,7 @@ const IconPicker = ({
|
||||
{...tab}
|
||||
id={TAB_NAMES["Emoji"]}
|
||||
aria-label={t("Emojis")}
|
||||
active={tab.selectedId === TAB_NAMES["Emoji"]}
|
||||
$active={tab.selectedId === TAB_NAMES["Emoji"]}
|
||||
>
|
||||
{t("Emojis")}
|
||||
</StyledTab>
|
||||
@@ -273,7 +274,7 @@ const TabActionsWrapper = styled(Flex)`
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
const StyledTab = styled(Tab)<{ active: boolean }>`
|
||||
const StyledTab = styled(Tab)<{ $active: boolean }>`
|
||||
position: relative;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
@@ -282,15 +283,15 @@ const StyledTab = styled(Tab)<{ active: boolean }>`
|
||||
border: 0;
|
||||
padding: 8px 12px;
|
||||
user-select: none;
|
||||
color: ${({ active }) => (active ? s("textSecondary") : s("textTertiary"))};
|
||||
color: ${({ $active }) => ($active ? s("textSecondary") : s("textTertiary"))};
|
||||
transition: color 100ms ease-in-out;
|
||||
|
||||
&: ${hover} {
|
||||
color: ${s("textSecondary")};
|
||||
}
|
||||
|
||||
${({ active }) =>
|
||||
active &&
|
||||
${({ $active }) =>
|
||||
$active &&
|
||||
css`
|
||||
&:after {
|
||||
content: "";
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Button, { Props as ButtonProps, Inner } from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -33,7 +33,7 @@ export type Option = {
|
||||
divider?: boolean;
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
export type Props = Omit<ButtonProps<any>, "onChange"> & {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string | null;
|
||||
@@ -313,7 +313,7 @@ const StyledButton = styled(Button)<{ $nude?: boolean }>`
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
cursor: var(--pointer);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${s("buttonNeutralBackground")};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { $Diff } from "utility-types";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import InputSelect, { Props, Option, InputSelectRef } from "./InputSelect";
|
||||
@@ -19,7 +21,7 @@ function InputSelectPermission(
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
<Select
|
||||
ref={ref}
|
||||
label={t("Permission")}
|
||||
options={[
|
||||
@@ -45,4 +47,8 @@ function InputSelectPermission(
|
||||
);
|
||||
}
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default React.forwardRef(InputSelectPermission);
|
||||
|
||||
@@ -47,14 +47,16 @@ export default function LanguagePrompt() {
|
||||
<br />
|
||||
<Link
|
||||
onClick={async () => {
|
||||
ui.setLanguagePromptDismissed();
|
||||
ui.set({ languagePromptDismissed: true });
|
||||
await user.save({ language });
|
||||
}}
|
||||
>
|
||||
{t("Change Language")}
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
|
||||
<Link onClick={() => ui.set({ languagePromptDismissed: true })}>
|
||||
{t("Dismiss")}
|
||||
</Link>
|
||||
</span>
|
||||
</Flex>
|
||||
</Wrapper>
|
||||
|
||||
@@ -76,7 +76,6 @@ const Layout = React.forwardRef(function Layout_(
|
||||
|
||||
const Container = styled(Flex)`
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
|
||||
@@ -192,7 +192,7 @@ const Wrapper = styled.a<{
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
background: ${(props) =>
|
||||
props.$hover ? props.theme.secondaryBackground : "inherit"};
|
||||
props.$hover ? props.theme.backgroundSecondary : "inherit"};
|
||||
}
|
||||
|
||||
cursor: ${(props) =>
|
||||
|
||||
@@ -39,12 +39,15 @@ const LocaleTime: React.FC<Props> = ({
|
||||
relative,
|
||||
tooltipDelay,
|
||||
}: Props) => {
|
||||
const userLocale: string = useUserLocale() || "";
|
||||
const dateFormatLong = {
|
||||
const userLocale = useUserLocale();
|
||||
const dateFormatLong: Record<string, string> = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
|
||||
};
|
||||
const formatLocaleLong = dateFormatLong[userLocale] ?? "MMMM do, yyyy h:mm a";
|
||||
const formatLocaleLong =
|
||||
(userLocale ? dateFormatLong[userLocale] : undefined) ??
|
||||
"MMMM do, yyyy h:mm a";
|
||||
// @ts-expect-error fallback to formatLocaleLong
|
||||
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const callback = React.useRef<() => void>();
|
||||
|
||||
@@ -174,7 +174,6 @@ const Fullscreen = styled.div<FullscreenProps>`
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
outline: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
@@ -265,7 +264,6 @@ const Small = styled.div`
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
background: ${s("modalBackground")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
box-shadow: ${s("modalShadow")};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
|
||||
@@ -24,13 +24,15 @@ import NotificationListItem from "./NotificationListItem";
|
||||
type Props = {
|
||||
/** Callback when the notification panel wants to close. */
|
||||
onRequestClose: () => void;
|
||||
/** Whether the panel is open or not. */
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A panel containing a list of notifications and controls to manage them.
|
||||
*/
|
||||
function Notifications(
|
||||
{ onRequestClose }: Props,
|
||||
{ onRequestClose, isOpen }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const context = useActionContext();
|
||||
@@ -72,7 +74,7 @@ function Notifications(
|
||||
<PaginatedList
|
||||
fetch={notifications.fetchPage}
|
||||
options={{ archived: false }}
|
||||
items={notifications.orderedData}
|
||||
items={isOpen ? notifications.orderedData : undefined}
|
||||
renderItem={(item: Notification) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
|
||||
@@ -40,7 +40,11 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
shrink
|
||||
flex
|
||||
>
|
||||
<Notifications onRequestClose={popover.hide} ref={scrollableRef} />
|
||||
<Notifications
|
||||
onRequestClose={popover.hide}
|
||||
isOpen={popover.visible}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
</StyledPopover>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,9 +6,11 @@ import PaginatedList from "~/components/PaginatedList";
|
||||
import EventListItem from "./EventListItem";
|
||||
|
||||
type Props = {
|
||||
events: Event[];
|
||||
events: Event<Document>[];
|
||||
document: Document;
|
||||
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
|
||||
fetch: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<Event<Document>[]>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
@@ -30,7 +32,7 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item: Event, index) => (
|
||||
renderItem={(item: Event<Document>, index) => (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
|
||||
@@ -19,7 +19,8 @@ export interface PaginatedItem {
|
||||
}
|
||||
|
||||
type Props<T> = WithTranslation &
|
||||
RootStore & {
|
||||
RootStore &
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
fetch?: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<T[] | undefined> | undefined;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { ReactionSummary } from "@shared/types";
|
||||
import { getEmojiId } from "@shared/utils/emoji";
|
||||
import User from "~/models/User";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = {
|
||||
/** Thin reaction data - contains the emoji & active user ids for this reaction. */
|
||||
reaction: ReactionSummary;
|
||||
/** Users who reacted using this emoji. */
|
||||
reactedUsers: User[];
|
||||
/** Whether the emoji button should be disabled (prevents add/remove events). */
|
||||
disabled: boolean;
|
||||
/** Callback when the user intends to add the reaction. */
|
||||
onAddReaction: (emoji: string) => Promise<void>;
|
||||
/** Callback when the user intends to remove the reaction. */
|
||||
onRemoveReaction: (emoji: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const useTooltipContent = ({
|
||||
reactedUsers,
|
||||
currUser,
|
||||
emoji,
|
||||
active,
|
||||
}: {
|
||||
reactedUsers: User[];
|
||||
currUser: User;
|
||||
emoji: string;
|
||||
active: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!reactedUsers.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transformedEmoji = `:${getEmojiId(emoji)}:`;
|
||||
|
||||
switch (reactedUsers.length) {
|
||||
case 1: {
|
||||
return t("{{ username }} reacted with {{ emoji }}", {
|
||||
username: active ? t("You") : reactedUsers[0].name,
|
||||
emoji: transformedEmoji,
|
||||
});
|
||||
}
|
||||
|
||||
case 2: {
|
||||
const firstUsername = active ? t("You") : reactedUsers[0].name;
|
||||
const secondUsername = active
|
||||
? reactedUsers.find((user) => user.id !== currUser.id)?.name
|
||||
: reactedUsers[1].name;
|
||||
|
||||
return t(
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
|
||||
{
|
||||
firstUsername,
|
||||
secondUsername,
|
||||
emoji: transformedEmoji,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
default: {
|
||||
const firstUsername = active ? t("You") : reactedUsers[0].name;
|
||||
const count = reactedUsers.length - 1;
|
||||
|
||||
return t(
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
|
||||
{
|
||||
firstUsername,
|
||||
count,
|
||||
emoji: transformedEmoji,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const Reaction: React.FC<Props> = ({
|
||||
reaction,
|
||||
reactedUsers,
|
||||
disabled,
|
||||
onAddReaction,
|
||||
onRemoveReaction,
|
||||
}) => {
|
||||
const user = useCurrentUser();
|
||||
|
||||
const active = reaction.userIds.includes(user.id);
|
||||
|
||||
const tooltipContent = useTooltipContent({
|
||||
reactedUsers,
|
||||
currUser: user,
|
||||
emoji: reaction.emoji,
|
||||
active,
|
||||
});
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.SyntheticEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
active
|
||||
? void onRemoveReaction(reaction.emoji)
|
||||
: void onAddReaction(reaction.emoji);
|
||||
},
|
||||
[reaction, active, onAddReaction, onRemoveReaction]
|
||||
);
|
||||
|
||||
const DisplayedEmoji = React.useMemo(
|
||||
() => (
|
||||
<EmojiButton disabled={disabled} $active={active} onClick={handleClick}>
|
||||
<Flex gap={6} justify="center" align="center">
|
||||
<Emoji size={15}>{reaction.emoji}</Emoji>
|
||||
<Count weight="xbold">{reaction.userIds.length}</Count>
|
||||
</Flex>
|
||||
</EmojiButton>
|
||||
),
|
||||
[reaction.emoji, reaction.userIds, disabled, active, handleClick]
|
||||
);
|
||||
|
||||
return tooltipContent ? (
|
||||
<Tooltip content={tooltipContent} delay={250} placement="bottom">
|
||||
{DisplayedEmoji}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{DisplayedEmoji}</>
|
||||
);
|
||||
};
|
||||
|
||||
const EmojiButton = styled(NudeButton)<{
|
||||
$active: boolean;
|
||||
disabled: boolean;
|
||||
}>`
|
||||
width: auto;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
background: ${s("backgroundTertiary")};
|
||||
pointer-events: ${({ disabled }) => disabled && "none"};
|
||||
|
||||
&: ${hover} {
|
||||
background: ${s("backgroundQuaternary")};
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$active &&
|
||||
css`
|
||||
background: ${transparentize(0.7, props.theme.accent)};
|
||||
|
||||
&: ${hover} {
|
||||
background: ${transparentize(0.5, props.theme.accent)};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Count = styled(Text)`
|
||||
font-size: 11px;
|
||||
color: ${s("buttonNeutralText")};
|
||||
padding-right: 1px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
`;
|
||||
|
||||
export default observer(Reaction);
|
||||
@@ -0,0 +1,87 @@
|
||||
import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import Comment from "~/models/Comment";
|
||||
import useHover from "~/hooks/useHover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Flex from "../Flex";
|
||||
import { ResizingHeightContainer } from "../ResizingHeightContainer";
|
||||
import Reaction from "./Reaction";
|
||||
|
||||
type Props = {
|
||||
/** Model for which to show the reactions. */
|
||||
model: Comment;
|
||||
/** Callback when the user intends to add a reaction. */
|
||||
onAddReaction: (emoji: string) => Promise<void>;
|
||||
/** Callback when the user intends to remove a reaction. */
|
||||
onRemoveReaction: (emoji: string) => Promise<void>;
|
||||
/** classname generated by styled-components. */
|
||||
className?: string;
|
||||
/** Picker to render as the last element */
|
||||
picker?: React.ReactElement;
|
||||
};
|
||||
|
||||
const ReactionList: React.FC<Props> = ({
|
||||
model,
|
||||
onAddReaction,
|
||||
onRemoveReaction,
|
||||
className,
|
||||
picker,
|
||||
}) => {
|
||||
const { users } = useStores();
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const hovered = useHover({
|
||||
ref: listRef,
|
||||
duration: 250,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadReactedUsersData = async () => {
|
||||
try {
|
||||
await model.loadReactedUsersData();
|
||||
} catch (err) {
|
||||
Logger.warn("Could not prefetch reaction data");
|
||||
}
|
||||
};
|
||||
|
||||
if (hovered) {
|
||||
void loadReactedUsersData();
|
||||
}
|
||||
}, [hovered, model]);
|
||||
|
||||
const hasReactions = !!model.reactions.length;
|
||||
const style = React.useMemo(() => {
|
||||
if (hasReactions) {
|
||||
return { minHeight: 28 };
|
||||
}
|
||||
return undefined;
|
||||
}, [hasReactions]);
|
||||
|
||||
return (
|
||||
<ResizingHeightContainer style={style}>
|
||||
<Flex ref={listRef} className={className} align="center" gap={6} wrap>
|
||||
{model.reactions.map((reaction) => {
|
||||
const reactedUsers = compact(
|
||||
reaction.userIds.map((id) => users.get(id))
|
||||
);
|
||||
|
||||
return (
|
||||
<Reaction
|
||||
key={reaction.emoji}
|
||||
reaction={reaction}
|
||||
reactedUsers={reactedUsers}
|
||||
disabled={model.isResolved}
|
||||
onAddReaction={onAddReaction}
|
||||
onRemoveReaction={onRemoveReaction}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{picker}
|
||||
</Flex>
|
||||
</ResizingHeightContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(ReactionList);
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ReactionIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoverDisclosure, usePopoverState } from "reakit";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Popover from "~/components/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
const EmojiPanel = React.lazy(
|
||||
() => import("~/components/IconPicker/components/EmojiPanel")
|
||||
);
|
||||
|
||||
type Props = {
|
||||
/** Callback when an emoji is selected by the user. */
|
||||
onSelect: (emoji: string) => Promise<void>;
|
||||
/** Callback when the picker is opened. */
|
||||
onOpen?: () => void;
|
||||
/** Callback when the picker is closed. */
|
||||
onClose?: () => void;
|
||||
/** Optional classname. */
|
||||
className?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const ReactionPicker: React.FC<Props> = ({
|
||||
onSelect,
|
||||
onOpen,
|
||||
onClose,
|
||||
className,
|
||||
size,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const popover = usePopoverState({
|
||||
modal: true,
|
||||
unstable_offset: [0, 0],
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const [query, setQuery] = React.useState("");
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const popoverWidth = isMobile ? windowWidth : 300;
|
||||
// In mobile, popover is absolutely positioned to leave 8px on both sides.
|
||||
const panelWidth = isMobile ? windowWidth - 16 : popoverWidth;
|
||||
const { toggle, hide } = popover;
|
||||
const handlePopoverButtonClick = React.useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
toggle();
|
||||
},
|
||||
[toggle]
|
||||
);
|
||||
|
||||
const handleEmojiSelect = React.useCallback(
|
||||
(emoji: string) => {
|
||||
hide();
|
||||
void onSelect(emoji);
|
||||
},
|
||||
[hide, onSelect]
|
||||
);
|
||||
|
||||
// Popover open effect
|
||||
React.useEffect(() => {
|
||||
if (popover.visible) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
}, [popover.visible, onOpen, onClose]);
|
||||
|
||||
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
|
||||
// prevent event bubbling.
|
||||
useOnClickOutside(
|
||||
contentRef,
|
||||
(event) => {
|
||||
if (
|
||||
popover.visible &&
|
||||
!popover.unstable_disclosureRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
popover.hide();
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<Tooltip
|
||||
content={t("Add reaction")}
|
||||
placement="top"
|
||||
delay={500}
|
||||
hideOnClick
|
||||
>
|
||||
<NudeButton
|
||||
{...props}
|
||||
aria-label={t("Reaction picker")}
|
||||
className={className}
|
||||
onClick={handlePopoverButtonClick}
|
||||
size={size}
|
||||
>
|
||||
<ReactionIcon size={22} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover
|
||||
{...popover}
|
||||
ref={contentRef}
|
||||
width={popoverWidth}
|
||||
shrink
|
||||
aria-label={t("Reaction picker")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
hideOnClickOutside={false}
|
||||
>
|
||||
{popover.visible && (
|
||||
<React.Suspense fallback={<Placeholder />}>
|
||||
<EventBoundary>
|
||||
<EmojiPanel
|
||||
height={300}
|
||||
panelWidth={panelWidth}
|
||||
query={query}
|
||||
onEmojiChange={handleEmojiSelect}
|
||||
onQueryChange={setQuery}
|
||||
panelActive
|
||||
/>
|
||||
</EventBoundary>
|
||||
</React.Suspense>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Placeholder = React.memo(
|
||||
() => (
|
||||
<Flex column gap={6} style={{ height: "300px", padding: "6px 12px" }}>
|
||||
<Flex gap={8}>
|
||||
<PlaceholderText height={32} minWidth={90} />
|
||||
<PlaceholderText height={32} width={32} />
|
||||
</Flex>
|
||||
<PlaceholderText height={24} width={120} />
|
||||
</Flex>
|
||||
),
|
||||
() => true
|
||||
);
|
||||
Placeholder.displayName = "ReactionPickerPlaceholder";
|
||||
|
||||
export default ReactionPicker;
|
||||
@@ -0,0 +1,146 @@
|
||||
import compact from "lodash/compact";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tab, TabPanel, useTabState } from "reakit";
|
||||
import { toast } from "sonner";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Comment from "~/models/Comment";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = {
|
||||
/** Model for which to show the reactions. */
|
||||
model: Comment;
|
||||
};
|
||||
|
||||
const ViewReactionsDialog: React.FC<Props> = ({ model }) => {
|
||||
const { t } = useTranslation();
|
||||
const { users } = useStores();
|
||||
const tab = useTabState();
|
||||
const { reactedUsersLoaded } = model;
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadReactedUsersData = async () => {
|
||||
try {
|
||||
await model.loadReactedUsersData();
|
||||
} catch (err) {
|
||||
toast.error(t("Could not load reactions"));
|
||||
}
|
||||
};
|
||||
|
||||
void loadReactedUsersData();
|
||||
}, [t, model]);
|
||||
|
||||
if (!reactedUsersLoaded) {
|
||||
return <PlaceHolder />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabActionsWrapper>
|
||||
{model.reactions.map((reaction) => (
|
||||
<StyledTab
|
||||
{...tab}
|
||||
key={reaction.emoji}
|
||||
id={reaction.emoji}
|
||||
aria-label={t("Reaction")}
|
||||
$active={tab.selectedId === reaction.emoji}
|
||||
>
|
||||
<Emoji size={16}>{reaction.emoji}</Emoji>
|
||||
</StyledTab>
|
||||
))}
|
||||
</TabActionsWrapper>
|
||||
{model.reactions.map((reaction) => {
|
||||
const reactedUsers = compact(
|
||||
reaction.userIds.map((id) => users.get(id))
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledTabPanel {...tab} key={reaction.emoji}>
|
||||
{reactedUsers.map((user) => (
|
||||
<UserInfo key={user.name} align="center" gap={8}>
|
||||
<Avatar model={user} size={AvatarSize.Medium} />
|
||||
<Text size="medium">{user.name}</Text>
|
||||
</UserInfo>
|
||||
))}
|
||||
</StyledTabPanel>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const PlaceHolder = React.memo(
|
||||
() => (
|
||||
<>
|
||||
<TabActionsWrapper gap={8} style={{ paddingBottom: "10px" }}>
|
||||
<PlaceholderText width={40} height={32} />
|
||||
<PlaceholderText width={40} height={32} />
|
||||
</TabActionsWrapper>
|
||||
<UserInfo align="center" gap={12}>
|
||||
<PlaceholderText width={AvatarSize.Medium} height={AvatarSize.Medium} />
|
||||
<PlaceholderText height={34} />
|
||||
</UserInfo>
|
||||
<UserInfo align="center" gap={12}>
|
||||
<PlaceholderText width={AvatarSize.Medium} height={AvatarSize.Medium} />
|
||||
<PlaceholderText height={34} />
|
||||
</UserInfo>
|
||||
</>
|
||||
),
|
||||
() => true
|
||||
);
|
||||
PlaceHolder.displayName = "ViewReactionsPlaceholder";
|
||||
|
||||
const TabActionsWrapper = styled(Flex)`
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
const StyledTab = styled(Tab)<{ $active: boolean }>`
|
||||
position: relative;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
cursor: var(--pointer);
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 4px 4px 0 0;
|
||||
padding: 8px 12px 10px;
|
||||
user-select: none;
|
||||
transition: background-color 100ms ease;
|
||||
|
||||
&: ${hover} {
|
||||
background-color: ${s("listItemHoverBackground")};
|
||||
}
|
||||
|
||||
${({ $active }) =>
|
||||
$active &&
|
||||
css`
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: ${s("textSecondary")};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledTabPanel = styled(TabPanel)`
|
||||
height: 300px;
|
||||
padding: 5px 0;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const UserInfo = styled(Flex)`
|
||||
padding: 10px 8px;
|
||||
`;
|
||||
|
||||
export default observer(ViewReactionsDialog);
|
||||
@@ -19,9 +19,10 @@ import SearchListItem from "./SearchListItem";
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLInputElement> {
|
||||
shareId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function SearchPopover({ shareId }: Props) {
|
||||
function SearchPopover({ shareId, className }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const focusRef = React.useRef<HTMLElement | null>(null);
|
||||
@@ -54,7 +55,8 @@ function SearchPopover({ shareId }: Props) {
|
||||
const performSearch = React.useCallback(
|
||||
async ({ query, ...options }) => {
|
||||
if (query?.length > 0) {
|
||||
const response = await documents.search(query, {
|
||||
const response = await documents.search({
|
||||
query,
|
||||
shareId,
|
||||
...options,
|
||||
});
|
||||
@@ -186,6 +188,7 @@ function SearchPopover({ shareId }: Props) {
|
||||
onChange={handleSearchInputChange}
|
||||
onFocus={handleSearchInputFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={className}
|
||||
/>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import invariant from "invariant";
|
||||
import debounce from "lodash/debounce";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon, GlobeIcon, InfoIcon } from "outline-icons";
|
||||
import { CopyIcon, GlobeIcon, InfoIcon, QuestionMarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { s } from "@shared/styles";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
@@ -17,7 +17,6 @@ import Input, { NativeInput } from "~/components/Input";
|
||||
import Switch from "~/components/Switch";
|
||||
import env from "~/env";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { AvatarSize } from "../../Avatar";
|
||||
import CopyToClipboard from "../../CopyToClipboard";
|
||||
import NudeButton from "../../NudeButton";
|
||||
@@ -39,7 +38,6 @@ type Props = {
|
||||
};
|
||||
|
||||
function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
const { shares } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [validationError, setValidationError] = React.useState("");
|
||||
@@ -53,20 +51,30 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
setUrlId(share?.urlId);
|
||||
}, [share?.urlId]);
|
||||
|
||||
const handleIndexingChanged = React.useCallback(
|
||||
async (event) => {
|
||||
try {
|
||||
await share?.save({
|
||||
allowIndexing: event.currentTarget.checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[share]
|
||||
);
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (event) => {
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
invariant(share, "Share must exist");
|
||||
|
||||
try {
|
||||
await share.save({
|
||||
await share?.save({
|
||||
published: event.currentTarget.checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[document.id, shares]
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleUrlChange = React.useMemo(
|
||||
@@ -159,6 +167,32 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
/>
|
||||
|
||||
<ResizingHeightContainer>
|
||||
{share?.published && (
|
||||
<ListItem
|
||||
title={
|
||||
<Text type="tertiary" as={Flex}>
|
||||
{t("Search engine indexing")}
|
||||
<Tooltip
|
||||
content={t(
|
||||
"Disable this setting to discourage search engines from indexing the page"
|
||||
)}
|
||||
>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</Tooltip>
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Switch
|
||||
aria-label={t("Search engine indexing")}
|
||||
checked={share?.allowIndexing ?? false}
|
||||
onChange={handleIndexingChanged}
|
||||
width={26}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sharedParent?.published ? (
|
||||
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
|
||||
{copyButton}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { toast } from "sonner";
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Group from "~/models/Group";
|
||||
import Share from "~/models/Share";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -32,26 +31,19 @@ import { AccessControlList } from "./AccessControlList";
|
||||
type Props = {
|
||||
/** The document to share. */
|
||||
document: Document;
|
||||
/** The existing share model, if any. */
|
||||
share: Share | null | undefined;
|
||||
/** The existing share parent model, if any. */
|
||||
sharedParent: Share | null | undefined;
|
||||
/** Callback fired when the popover requests to be closed. */
|
||||
onRequestClose: () => void;
|
||||
/** Whether the popover is visible. */
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
function SharePopover({
|
||||
document,
|
||||
share,
|
||||
sharedParent,
|
||||
onRequestClose,
|
||||
visible,
|
||||
}: Props) {
|
||||
function SharePopover({ document, onRequestClose, visible }: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(document);
|
||||
const { shares } = useStores();
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const sharedParent = shares.getByDocumentParents(document.id);
|
||||
const [hasRendered, setHasRendered] = React.useState(visible);
|
||||
const { users, userMemberships, groups, groupMemberships } = useStores();
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
@@ -94,38 +94,40 @@ 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 && (
|
||||
<Overflow>
|
||||
<Section>
|
||||
<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>
|
||||
}
|
||||
to={homePath()}
|
||||
icon={<HomeIcon />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<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>
|
||||
</Overflow>
|
||||
<Scrollable flex shadow>
|
||||
<Section>
|
||||
<Starred />
|
||||
@@ -133,16 +135,16 @@ function AppSidebar() {
|
||||
<Section>
|
||||
<SharedWithMe />
|
||||
</Section>
|
||||
<Section auto>
|
||||
<Section>
|
||||
<Collections />
|
||||
</Section>
|
||||
{can.createDocument && (
|
||||
<Section auto>
|
||||
<ArchiveLink />
|
||||
</Section>
|
||||
)}
|
||||
<Section>
|
||||
{can.createDocument && (
|
||||
<>
|
||||
<ArchiveLink />
|
||||
<TrashLink />
|
||||
</>
|
||||
)}
|
||||
{can.createDocument && <TrashLink />}
|
||||
<SidebarAction action={inviteUser} />
|
||||
</Section>
|
||||
</Scrollable>
|
||||
@@ -152,6 +154,11 @@ function AppSidebar() {
|
||||
);
|
||||
}
|
||||
|
||||
const Overflow = styled.div`
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Drafts = styled(Text)`
|
||||
margin: 0 4px;
|
||||
`;
|
||||
|
||||
@@ -32,13 +32,13 @@ function Right({ children, border, className }: Props) {
|
||||
Math.min(window.innerWidth - event.pageX, maxWidth),
|
||||
minWidth
|
||||
);
|
||||
ui.setRightSidebarWidth(width);
|
||||
ui.set({ sidebarRightWidth: width });
|
||||
},
|
||||
[minWidth, maxWidth, ui]
|
||||
);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setRightSidebarWidth(theme.sidebarRightWidth);
|
||||
ui.set({ sidebarRightWidth: theme.sidebarRightWidth });
|
||||
}, [ui, theme.sidebarRightWidth]);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import SearchPopover from "~/components/SearchPopover";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import history from "~/utils/history";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
@@ -15,6 +20,7 @@ import Sidebar from "./Sidebar";
|
||||
import Section from "./components/Section";
|
||||
import DocumentLink from "./components/SharedDocumentLink";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
|
||||
type Props = {
|
||||
rootNode: NavigationNode;
|
||||
@@ -27,9 +33,11 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
const { ui, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const teamAvailable = !!team?.name;
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
{team?.name && (
|
||||
<StyledSidebar $hoverTransition={!teamAvailable}>
|
||||
{teamAvailable && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
|
||||
@@ -38,11 +46,20 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
user ? homePath() : sharedDocumentPath(shareId, rootNode.url)
|
||||
)
|
||||
}
|
||||
/>
|
||||
>
|
||||
<ToggleSidebar />
|
||||
</SidebarButton>
|
||||
)}
|
||||
<ScrollContainer topShadow flex>
|
||||
<TopSection>
|
||||
<SearchPopover shareId={shareId} />
|
||||
<SearchWrapper>
|
||||
<StyledSearchPopover shareId={shareId} />
|
||||
</SearchWrapper>
|
||||
{!teamAvailable && (
|
||||
<ToggleWrapper>
|
||||
<ToggleSidebar />
|
||||
</ToggleWrapper>
|
||||
)}
|
||||
</TopSection>
|
||||
<Section>
|
||||
<DocumentLink
|
||||
@@ -55,23 +72,74 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
/>
|
||||
</Section>
|
||||
</ScrollContainer>
|
||||
</Sidebar>
|
||||
</StyledSidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const ToggleSidebar = () => {
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
delay={500}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const ScrollContainer = styled(Scrollable)`
|
||||
padding-bottom: 16px;
|
||||
`;
|
||||
|
||||
const TopSection = styled(Section)`
|
||||
// this weird looking && increases the specificity of the style rule
|
||||
&&:first-child {
|
||||
margin-top: 16px;
|
||||
}
|
||||
const TopSection = styled(Flex)`
|
||||
padding: 8px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
&& {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
const SearchWrapper = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledSearchPopover = styled(SearchPopover)`
|
||||
width: 100%;
|
||||
transition: width 100ms ease-out;
|
||||
margin: 8px 0;
|
||||
`;
|
||||
|
||||
const ToggleWrapper = styled.div`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
transition: opacity 100ms ease-out, transform 100ms ease-out;
|
||||
`;
|
||||
|
||||
const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
|
||||
${({ $hoverTransition }) =>
|
||||
$hoverTransition &&
|
||||
`
|
||||
&: ${hover} {
|
||||
${StyledSearchPopover} {
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
${ToggleWrapper} {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(SharedSidebar);
|
||||
|
||||
@@ -46,7 +46,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
const setWidth = ui.setSidebarWidth;
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isHovering, setHovering] = React.useState(false);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
@@ -62,13 +61,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setWidth(theme.sidebarCollapsedWidth);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
ui.set({
|
||||
sidebarWidth: isSmallerThanCollapsePoint
|
||||
? theme.sidebarCollapsedWidth
|
||||
: width,
|
||||
});
|
||||
},
|
||||
[theme, offset, minWidth, maxWidth, setWidth]
|
||||
[ui, theme, offset, minWidth, maxWidth]
|
||||
);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
@@ -86,17 +85,25 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
setCollapsing(true);
|
||||
ui.collapseSidebar();
|
||||
} else {
|
||||
setWidth(minWidth);
|
||||
ui.set({ sidebarWidth: minWidth });
|
||||
setAnimating(true);
|
||||
}
|
||||
} else {
|
||||
setWidth(width);
|
||||
ui.set({ sidebarWidth: width });
|
||||
}
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width]);
|
||||
|
||||
const handleBlur = React.useCallback(() => {
|
||||
setHovering(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = React.useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
if (!document.hasFocus()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
setAnimating(false);
|
||||
@@ -104,9 +111,9 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
[width]
|
||||
);
|
||||
|
||||
const handlePointerMove = React.useCallback(() => {
|
||||
const handlePointerActivity = React.useCallback(() => {
|
||||
if (ui.sidebarIsClosed) {
|
||||
setHovering(true);
|
||||
setHovering(document.hasFocus());
|
||||
setPointerMoved(true);
|
||||
}
|
||||
}, [ui.sidebarIsClosed]);
|
||||
@@ -115,7 +122,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
(ev) => {
|
||||
if (hasPointerMoved) {
|
||||
setHovering(
|
||||
ev.pageX < width && ev.pageY < window.innerHeight && ev.pageY > 0
|
||||
document.hasFocus() &&
|
||||
ev.pageX < width &&
|
||||
ev.pageY < window.innerHeight &&
|
||||
ev.pageY > 0
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -138,11 +148,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
React.useEffect(() => {
|
||||
if (isCollapsing) {
|
||||
setTimeout(() => {
|
||||
setWidth(minWidth);
|
||||
ui.set({ sidebarWidth: minWidth });
|
||||
setCollapsing(false);
|
||||
}, ANIMATION_MS);
|
||||
}
|
||||
}, [setWidth, minWidth, isCollapsing]);
|
||||
}, [ui, minWidth, isCollapsing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
@@ -153,14 +163,17 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
document.body.style.cursor = "initial";
|
||||
}
|
||||
|
||||
window.addEventListener("blur", handleBlur);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", handleStopDrag);
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
}, [isResizing, handleDrag, handleBlur, handleStopDrag]);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
ui.set({ sidebarWidth: theme.sidebarWidth });
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -193,7 +206,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
$collapsed={collapsed}
|
||||
$isMobile={isMobile}
|
||||
className={className}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerActivity}
|
||||
onPointerMove={handlePointerActivity}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
column
|
||||
>
|
||||
@@ -217,15 +231,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
{(rest: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...rest}
|
||||
position="bottom"
|
||||
image={<NotificationIcon />}
|
||||
/>
|
||||
)}
|
||||
</NotificationsPopover>
|
||||
<Notifications />
|
||||
</SidebarButton>
|
||||
)}
|
||||
</AccountMenu>
|
||||
@@ -240,6 +246,14 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
);
|
||||
});
|
||||
|
||||
const Notifications = () => (
|
||||
<NotificationsPopover>
|
||||
{(rest: SidebarButtonProps) => (
|
||||
<SidebarButton {...rest} position="bottom" image={<NotificationIcon />} />
|
||||
)}
|
||||
</NotificationsPopover>
|
||||
);
|
||||
|
||||
const Backdrop = styled.a`
|
||||
animation: ${fadeIn} 250ms ease-in-out;
|
||||
position: fixed;
|
||||
@@ -284,9 +298,8 @@ const Container = styled(Flex)<ContainerProps>`
|
||||
width: 100%;
|
||||
background: ${s("sidebarBackground")};
|
||||
transition: box-shadow 150ms ease-in-out, transform 150ms ease-out,
|
||||
${s("backgroundTransition")}
|
||||
${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
${(props: ContainerProps) =>
|
||||
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
|
||||
transform: translateX(
|
||||
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
|
||||
);
|
||||
|
||||
@@ -1,41 +1,101 @@
|
||||
import isUndefined from "lodash/isUndefined";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Collection from "~/models/Collection";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { archivePath } from "~/utils/routeHelpers";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import { useDropToArchive } from "../hooks/useDragAndDrop";
|
||||
import { ArchivedCollectionLink } from "./ArchivedCollectionLink";
|
||||
import { StyledError } from "./Collections";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
function ArchiveLink() {
|
||||
const { policies, documents } = useStores();
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [{ isDocumentDropping }, dropToArchiveDocument] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject) => {
|
||||
const document = documents.get(item.id);
|
||||
await document?.archive();
|
||||
toast.success(t("Document archived"));
|
||||
},
|
||||
canDrop: (item) => policies.abilities(item.id).archive,
|
||||
collect: (monitor) => ({
|
||||
isDocumentDropping: monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
const [disclosure, setDisclosure] = React.useState<boolean>(false);
|
||||
const [expanded, setExpanded] = React.useState<boolean | undefined>();
|
||||
|
||||
const { request, data, loading, error } = useRequest(
|
||||
collections.fetchArchived,
|
||||
true
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isUndefined(data) && !loading && isUndefined(error)) {
|
||||
setDisclosure(data.length > 0);
|
||||
}
|
||||
}, [data, loading, error]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setDisclosure(collections.archived.length > 0);
|
||||
}, [collections.archived]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (disclosure && isUndefined(expanded)) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [disclosure]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (expanded) {
|
||||
void request();
|
||||
}
|
||||
}, [expanded, request]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, []);
|
||||
|
||||
const [{ isOverArchiveSection, isDragging }, dropToArchiveRef] =
|
||||
useDropToArchive();
|
||||
|
||||
return (
|
||||
<div ref={dropToArchiveDocument}>
|
||||
<SidebarLink
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon open={isDocumentDropping} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
active={documents.active?.isArchived && !documents.active?.isDeleted}
|
||||
isActiveDrop={isDocumentDropping}
|
||||
/>
|
||||
</div>
|
||||
<Flex column>
|
||||
<div ref={dropToArchiveRef}>
|
||||
<SidebarLink
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
isActiveDrop={isOverArchiveSection && isDragging}
|
||||
depth={0}
|
||||
expanded={disclosure ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</div>
|
||||
{expanded === true ? (
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
aria-label={t("Archived collections")}
|
||||
items={collections.archived}
|
||||
loading={<PlaceholderCollections />}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection) => (
|
||||
<ArchivedCollectionLink
|
||||
key={item.id}
|
||||
depth={1}
|
||||
collection={item}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Relative>
|
||||
) : null}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
import Collection from "~/models/Collection";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import Relative from "./Relative";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
export function ArchivedCollectionLink({ collection, depth }: Props) {
|
||||
const { documents } = useStores();
|
||||
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollectionLink
|
||||
depth={depth ? depth : 0}
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
activeDocument={documents.active}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
/>
|
||||
</Relative>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -30,6 +29,8 @@ type Props = {
|
||||
onDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
activeDocument: Document | undefined;
|
||||
isDraggingAnyCollection?: boolean;
|
||||
depth?: number;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const CollectionLink: React.FC<Props> = ({
|
||||
@@ -37,13 +38,14 @@ const CollectionLink: React.FC<Props> = ({
|
||||
expanded,
|
||||
onDisclosureClick,
|
||||
isDraggingAnyCollection,
|
||||
depth,
|
||||
onClick,
|
||||
}: Props) => {
|
||||
const { dialogs, documents, collections } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const can = usePolicy(collection);
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
|
||||
@@ -52,9 +54,8 @@ const CollectionLink: React.FC<Props> = ({
|
||||
await collection.save({
|
||||
name,
|
||||
});
|
||||
history.replace(collection.path, history.location.state);
|
||||
},
|
||||
[collection, history]
|
||||
[collection]
|
||||
);
|
||||
|
||||
// Drop to re-parent document
|
||||
@@ -111,10 +112,15 @@ const CollectionLink: React.FC<Props> = ({
|
||||
sidebarContext,
|
||||
});
|
||||
|
||||
const handleRename = React.useCallback(() => {
|
||||
editableTitleRef.current?.setIsEditing(true);
|
||||
}, [editableTitleRef]);
|
||||
|
||||
return (
|
||||
<Relative ref={drop}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
onClick={onClick}
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
@@ -140,7 +146,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={0}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
@@ -155,7 +161,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
</NudeButton>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={() => editableTitleRef.current?.setIsEditing(true)}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
@@ -34,11 +35,13 @@ function CollectionLinkChildren({
|
||||
expanded,
|
||||
prefetchDocument,
|
||||
}: Props) {
|
||||
const pageSize = 250;
|
||||
const can = usePolicy(collection);
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const { documents, dialogs, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const childDocuments = useCollectionDocuments(collection, documents.active);
|
||||
const [showing, setShowing] = React.useState(pageSize);
|
||||
|
||||
// Drop to reorder document
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
|
||||
@@ -83,6 +86,18 @@ function CollectionLinkChildren({
|
||||
}),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!expanded) {
|
||||
setShowing(pageSize);
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
const showMore = React.useCallback(() => {
|
||||
if (childDocuments && childDocuments.length > showing) {
|
||||
setShowing((value) => value + pageSize);
|
||||
}
|
||||
}, [childDocuments, showing]);
|
||||
|
||||
return (
|
||||
<Folder expanded={expanded}>
|
||||
{isDraggingAnyDocument && can.createDocument && manualSort && (
|
||||
@@ -98,7 +113,7 @@ function CollectionLinkChildren({
|
||||
<Loading />
|
||||
</ResizingHeightContainer>
|
||||
)}
|
||||
{childDocuments?.map((node, index) => (
|
||||
{childDocuments?.slice(0, showing).map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
@@ -121,6 +136,7 @@ function CollectionLinkChildren({
|
||||
depth={2}
|
||||
/>
|
||||
)}
|
||||
<Waypoint key={showing} onEnter={showMore} fireOnRapidScroll />
|
||||
</DocumentsLoader>
|
||||
</Folder>
|
||||
);
|
||||
|
||||
@@ -55,7 +55,7 @@ function Collections() {
|
||||
<PaginatedList
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.orderedData}
|
||||
items={collections.allActive}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
@@ -84,7 +84,7 @@ function Collections() {
|
||||
);
|
||||
}
|
||||
|
||||
const StyledError = styled(Error)`
|
||||
export const StyledError = styled(Error)`
|
||||
font-size: 15px;
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
@@ -41,16 +41,6 @@ function EditableTitle(
|
||||
setIsEditing(true);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsEditing(false);
|
||||
setValue(originalValue);
|
||||
}
|
||||
},
|
||||
[originalValue]
|
||||
);
|
||||
|
||||
const stopPropagation = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -63,6 +53,7 @@ function EditableTitle(
|
||||
const handleSave = React.useCallback(
|
||||
async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setIsEditing(false);
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
@@ -85,6 +76,22 @@ function EditableTitle(
|
||||
[originalValue, value, onSubmit]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
async (ev) => {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
if (ev.key === "Escape") {
|
||||
setIsEditing(false);
|
||||
setValue(originalValue);
|
||||
}
|
||||
if (ev.key === "Enter") {
|
||||
await handleSave(ev);
|
||||
}
|
||||
},
|
||||
[handleSave, originalValue]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
onEditing?.(isEditing);
|
||||
}, [onEditing, isEditing]);
|
||||
|
||||
@@ -112,8 +112,9 @@ const NavLink = ({
|
||||
!rest.target &&
|
||||
!event.altKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey,
|
||||
[rest.target]
|
||||
!event.ctrlKey &&
|
||||
!isActive,
|
||||
[rest.target, isActive]
|
||||
);
|
||||
|
||||
const navigateTo = React.useCallback(() => {
|
||||
@@ -153,14 +154,13 @@ const NavLink = ({
|
||||
<Link
|
||||
key={isActive ? "active" : "inactive"}
|
||||
ref={linkRef}
|
||||
// onMouseDown={handleClick}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(event) => {
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
navigateTo();
|
||||
event.currentTarget?.blur();
|
||||
}
|
||||
}}
|
||||
onClick={handleClick}
|
||||
aria-current={(isActive && ariaCurrent) || undefined}
|
||||
className={className}
|
||||
style={style}
|
||||
|
||||
@@ -59,6 +59,7 @@ const StyledMoreIcon = styled(MoreIcon)`
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<{ $position: "top" | "bottom" }>`
|
||||
overflow: hidden;
|
||||
padding-top: ${(props) =>
|
||||
props.$position === "top" && Desktop.hasInsetTitlebar() ? 36 : 0}px;
|
||||
${draggableOnDesktop()}
|
||||
@@ -104,7 +105,6 @@ const Button = styled(Flex)<{
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
color: ${s("sidebarText")};
|
||||
transition: background 100ms ease-in-out;
|
||||
background: ${s("sidebarActiveBackground")};
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,6 @@ function SidebarLink(
|
||||
|
||||
const activeStyle = React.useMemo(
|
||||
() => ({
|
||||
fontWeight: 600,
|
||||
color: theme.text,
|
||||
background: theme.sidebarActiveBackground,
|
||||
...style,
|
||||
@@ -202,10 +201,10 @@ const Link = styled(NavLink)<{
|
||||
display: flex;
|
||||
position: relative;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 475;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
min-height: 32px;
|
||||
transition: background 50ms, color 50ms;
|
||||
user-select: none;
|
||||
background: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
|
||||
|
||||
@@ -48,13 +48,20 @@ function StarredLink({ star }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
star.documentId === ui.activeDocumentId &&
|
||||
sidebarContext === locationSidebarContext
|
||||
) {
|
||||
setExpanded(true);
|
||||
} else if (
|
||||
star.collectionId === ui.activeCollectionId &&
|
||||
sidebarContext === locationSidebarContext
|
||||
) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [
|
||||
star.documentId,
|
||||
star.collectionId,
|
||||
ui.activeDocumentId,
|
||||
ui.activeCollectionId,
|
||||
sidebarContext,
|
||||
locationSidebarContext,
|
||||
|
||||
@@ -2,41 +2,29 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Badge from "~/components/Badge";
|
||||
import { version } from "../../../../package.json";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { version as currentVersion } from "../../../../package.json";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
export default function Version() {
|
||||
const [releasesBehind, setReleasesBehind] = React.useState(-1);
|
||||
const [versionsBehind, setVersionsBehind] = React.useState(-1);
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function loadReleases() {
|
||||
const res = await fetch(
|
||||
"https://api.github.com/repos/outline/outline/releases"
|
||||
);
|
||||
const releases = await res.json();
|
||||
|
||||
if (Array.isArray(releases)) {
|
||||
const everyNewRelease = releases
|
||||
.map((release) => release.tag_name)
|
||||
.findIndex((tagName) => tagName === `v${version}`);
|
||||
|
||||
const onlyFullNewRelease = releases
|
||||
.filter((release) => !release.prerelease)
|
||||
.map((release) => release.tag_name)
|
||||
.findIndex((tagName) => tagName === `v${version}`);
|
||||
|
||||
const computedReleasesBehind = version.includes("pre")
|
||||
? everyNewRelease
|
||||
: onlyFullNewRelease;
|
||||
|
||||
if (computedReleasesBehind >= 0) {
|
||||
setReleasesBehind(computedReleasesBehind);
|
||||
async function loadVersionInfo() {
|
||||
try {
|
||||
// Fetch version info from the server-side proxy
|
||||
const res = await client.post("/installation.info");
|
||||
if (res.data && res.data.versionsBehind >= 0) {
|
||||
setVersionsBehind(res.data.versionsBehind);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Failed to load version info", error);
|
||||
}
|
||||
}
|
||||
|
||||
void loadReleases();
|
||||
void loadVersionInfo();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -45,16 +33,16 @@ export default function Version() {
|
||||
href="https://github.com/outline/outline/releases"
|
||||
label={
|
||||
<>
|
||||
v{version}
|
||||
{releasesBehind >= 0 && (
|
||||
v{currentVersion}
|
||||
{versionsBehind >= 0 && (
|
||||
<>
|
||||
<br />
|
||||
<LilBadge>
|
||||
{releasesBehind === 0
|
||||
{versionsBehind === 0
|
||||
? t("Up to date")
|
||||
: t(`{{ releasesBehind }} versions behind`, {
|
||||
releasesBehind,
|
||||
count: releasesBehind,
|
||||
releasesBehind: versionsBehind,
|
||||
count: versionsBehind,
|
||||
})}
|
||||
</LilBadge>
|
||||
</>
|
||||
|
||||
@@ -149,6 +149,7 @@ export function useDragDocument(
|
||||
icon: icon ? <Icon value={icon} color={color} /> : undefined,
|
||||
collectionId: document?.collectionId || "",
|
||||
} as DragObject),
|
||||
canDrag: () => !!document?.isActive,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
@@ -245,6 +246,7 @@ export function useDropToReparentDocument(
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem().id) &&
|
||||
item.id !== node.id &&
|
||||
!!document?.isActive &&
|
||||
policies.abilities(node.id).update &&
|
||||
policies.abilities(item.id).move,
|
||||
hover: (_item, monitor) => {
|
||||
@@ -297,6 +299,8 @@ export function useDropToReorderDocument(
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
|
||||
const document = documents.get(node.id);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
@@ -304,7 +308,11 @@ export function useDropToReorderDocument(
|
||||
>({
|
||||
accept: "document",
|
||||
canDrop: (item: DragObject) => {
|
||||
if (item.id === node.id || !policies.abilities(item.id)?.move) {
|
||||
if (
|
||||
item.id === node.id ||
|
||||
!policies.abilities(item.id)?.move ||
|
||||
!document?.isActive
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -427,3 +435,44 @@ export function useDropToReorderUserMembership(getIndex?: () => string) {
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dropping documents and collections onto archive section
|
||||
*/
|
||||
export function useDropToArchive() {
|
||||
const accept = ["document", "collection"];
|
||||
const { documents, collections, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOverArchiveSection: boolean; isDragging: boolean }
|
||||
>({
|
||||
accept,
|
||||
drop: async (item, monitor) => {
|
||||
const type = monitor.getItemType();
|
||||
let model;
|
||||
|
||||
if (type === "collection") {
|
||||
model = collections.get(item.id);
|
||||
} else {
|
||||
model = documents.get(item.id);
|
||||
}
|
||||
|
||||
if (model) {
|
||||
await model.archive();
|
||||
toast.success(
|
||||
type === "collection"
|
||||
? t("Collection archived")
|
||||
: t("Document archived")
|
||||
);
|
||||
}
|
||||
},
|
||||
canDrop: (item) => policies.abilities(item.id).archive,
|
||||
collect: (monitor) => ({
|
||||
isOverArchiveSection: !!monitor.isOver(),
|
||||
isDragging: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ const Background = styled.div<{ sticky?: boolean }>`
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
|
||||
+29
-27
@@ -120,30 +120,33 @@ function Table({
|
||||
<Anchor ref={topRef} />
|
||||
<InnerTable {...getTableProps()}>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<tr {...headerGroup.getHeaderGroupProps()} key={headerGroup.id}>
|
||||
{headerGroup.headers.map((column) => (
|
||||
<Head
|
||||
{...column.getHeaderProps(column.getSortByToggleProps())}
|
||||
key={column.id}
|
||||
>
|
||||
<SortWrapper
|
||||
align="center"
|
||||
$sortable={!column.disableSortBy}
|
||||
gap={4}
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const groupProps = headerGroup.getHeaderGroupProps();
|
||||
return (
|
||||
<tr {...groupProps} key={groupProps.key}>
|
||||
{headerGroup.headers.map((column) => (
|
||||
<Head
|
||||
{...column.getHeaderProps(column.getSortByToggleProps())}
|
||||
key={column.id}
|
||||
>
|
||||
{column.render("Header")}
|
||||
{column.isSorted &&
|
||||
(column.isSortedDesc ? (
|
||||
<DescSortIcon />
|
||||
) : (
|
||||
<AscSortIcon />
|
||||
))}
|
||||
</SortWrapper>
|
||||
</Head>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
<SortWrapper
|
||||
align="center"
|
||||
$sortable={!column.disableSortBy}
|
||||
gap={4}
|
||||
>
|
||||
{column.render("Header")}
|
||||
{column.isSorted &&
|
||||
(column.isSortedDesc ? (
|
||||
<DescSortIcon />
|
||||
) : (
|
||||
<AscSortIcon />
|
||||
))}
|
||||
</SortWrapper>
|
||||
</Head>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody {...getTableBodyProps()}>
|
||||
{rows.map((row) => {
|
||||
@@ -250,10 +253,11 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
|
||||
white-space: nowrap;
|
||||
margin: 0 -4px;
|
||||
padding: 0 4px;
|
||||
cursor: ${(props) => (props.$sortable ? `var(--pointer)` : "")};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) =>
|
||||
props.$sortable ? props.theme.secondaryBackground : "none"};
|
||||
props.$sortable ? props.theme.backgroundSecondary : "none"};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -306,15 +310,13 @@ const Row = styled.tr`
|
||||
|
||||
const Head = styled.th`
|
||||
text-align: left;
|
||||
padding: 6px 6px 0;
|
||||
padding: 6px 6px 2px;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
font-size: 14px;
|
||||
color: ${s("textSecondary")};
|
||||
font-weight: 500;
|
||||
z-index: 1;
|
||||
cursor: var(--pointer) !important;
|
||||
|
||||
:first-child {
|
||||
padding-left: 0;
|
||||
|
||||
@@ -45,7 +45,6 @@ const Sticky = styled.div`
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
background: ${s("background")};
|
||||
transition: ${s("backgroundTransition")};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** The size to render the indicator, defaults to 24px */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A component to show an animated typing indicator.
|
||||
*/
|
||||
export default function Typing({ size = 24 }: Props) {
|
||||
return (
|
||||
<Wrapper height={size} width={size}>
|
||||
<Circle cx={size / 4} cy={size / 2} r="2" />
|
||||
<Circle cx={size / 2} cy={size / 2} r="2" />
|
||||
<Circle cx={size / 1.33333} cy={size / 2} r="2" />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.svg`
|
||||
fill: ${s("textTertiary")};
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
fill: transparent;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Circle = styled.circle`
|
||||
animation: 1s blink infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 250ms;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 500ms;
|
||||
}
|
||||
`;
|
||||
@@ -25,8 +25,9 @@ import User from "~/models/User";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import withStores from "~/components/withStores";
|
||||
import {
|
||||
PartialWithId,
|
||||
PartialExcept,
|
||||
WebsocketCollectionUpdateIndexEvent,
|
||||
WebsocketCommentReactionEvent,
|
||||
WebsocketEntitiesEvent,
|
||||
WebsocketEntityDeletedEvent,
|
||||
} from "~/types";
|
||||
@@ -214,23 +215,20 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"documents.update",
|
||||
action(
|
||||
(event: PartialWithId<Document> & { title: string; url: string }) => {
|
||||
documents.add(event);
|
||||
action((event: PartialExcept<Document, "id" | "title" | "url">) => {
|
||||
documents.add(event);
|
||||
|
||||
if (event.collectionId) {
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.updateDocument(event);
|
||||
}
|
||||
if (event.collectionId) {
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.updateDocument(event);
|
||||
}
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.archive",
|
||||
action((event: PartialWithId<Document>) => {
|
||||
documents.add(event);
|
||||
policies.remove(event.id);
|
||||
action((event: PartialExcept<Document, "id">) => {
|
||||
documents.addToArchive(event as Document);
|
||||
|
||||
if (event.collectionId) {
|
||||
const collection = collections.get(event.collectionId);
|
||||
@@ -241,7 +239,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"documents.delete",
|
||||
action((event: PartialWithId<Document>) => {
|
||||
action((event: PartialExcept<Document, "id">) => {
|
||||
documents.add(event);
|
||||
policies.remove(event.id);
|
||||
|
||||
@@ -265,7 +263,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"documents.add_user",
|
||||
async (event: PartialWithId<UserMembership>) => {
|
||||
async (event: PartialExcept<UserMembership, "id">) => {
|
||||
userMemberships.add(event);
|
||||
|
||||
// Any existing child policies are now invalid
|
||||
@@ -286,7 +284,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"documents.remove_user",
|
||||
(event: PartialWithId<UserMembership>) => {
|
||||
(event: PartialExcept<UserMembership, "id">) => {
|
||||
userMemberships.remove(event.id);
|
||||
|
||||
// Any existing child policies are now invalid
|
||||
@@ -308,7 +306,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"documents.add_group",
|
||||
(event: PartialWithId<GroupMembership>) => {
|
||||
(event: PartialExcept<GroupMembership, "id">) => {
|
||||
groupMemberships.add(event);
|
||||
|
||||
const group = groups.get(event.groupId!);
|
||||
@@ -330,16 +328,23 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"documents.remove_group",
|
||||
(event: PartialWithId<GroupMembership>) => {
|
||||
(event: PartialExcept<GroupMembership, "id">) => {
|
||||
groupMemberships.remove(event.id);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("comments.create", (event: PartialWithId<Comment>) => {
|
||||
this.socket.on("comments.create", (event: PartialExcept<Comment, "id">) => {
|
||||
comments.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("comments.update", (event: PartialWithId<Comment>) => {
|
||||
this.socket.on("comments.update", (event: PartialExcept<Comment, "id">) => {
|
||||
const comment = comments.get(event.id);
|
||||
|
||||
// Existing policy becomes invalid when the resolution status has changed and we don't have the latest version.
|
||||
if (comment?.resolvedAt !== event.resolvedAt) {
|
||||
policies.remove(event.id);
|
||||
}
|
||||
|
||||
comments.add(event);
|
||||
});
|
||||
|
||||
@@ -347,11 +352,35 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
comments.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
|
||||
this.socket.on(
|
||||
"comments.add_reaction",
|
||||
(event: WebsocketCommentReactionEvent) => {
|
||||
const comment = comments.get(event.commentId);
|
||||
comment?.updateReaction({
|
||||
type: "add",
|
||||
emoji: event.emoji,
|
||||
user: event.user,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"comments.remove_reaction",
|
||||
(event: WebsocketCommentReactionEvent) => {
|
||||
const comment = comments.get(event.commentId);
|
||||
comment?.updateReaction({
|
||||
type: "remove",
|
||||
emoji: event.emoji,
|
||||
user: event.user,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("groups.create", (event: PartialExcept<Group, "id">) => {
|
||||
groups.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("groups.update", (event: PartialWithId<Group>) => {
|
||||
this.socket.on("groups.update", (event: PartialExcept<Group, "id">) => {
|
||||
groups.add(event);
|
||||
});
|
||||
|
||||
@@ -359,24 +388,36 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
groups.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("groups.add_user", (event: PartialWithId<GroupUser>) => {
|
||||
groupUsers.add(event);
|
||||
});
|
||||
this.socket.on(
|
||||
"groups.add_user",
|
||||
(event: PartialExcept<GroupUser, "id">) => {
|
||||
groupUsers.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("groups.remove_user", (event: PartialWithId<GroupUser>) => {
|
||||
groupUsers.removeAll({
|
||||
groupId: event.groupId,
|
||||
userId: event.userId,
|
||||
});
|
||||
});
|
||||
this.socket.on(
|
||||
"groups.remove_user",
|
||||
(event: PartialExcept<GroupUser, "id">) => {
|
||||
groupUsers.removeAll({
|
||||
groupId: event.groupId,
|
||||
userId: event.userId,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("collections.create", (event: PartialWithId<Collection>) => {
|
||||
collections.add(event);
|
||||
});
|
||||
this.socket.on(
|
||||
"collections.create",
|
||||
(event: PartialExcept<Collection, "id">) => {
|
||||
collections.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("collections.update", (event: PartialWithId<Collection>) => {
|
||||
collections.add(event);
|
||||
});
|
||||
this.socket.on(
|
||||
"collections.update",
|
||||
(event: PartialExcept<Collection, "id">) => {
|
||||
collections.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"collections.delete",
|
||||
@@ -398,7 +439,49 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on("teams.update", (event: PartialWithId<Team>) => {
|
||||
this.socket.on(
|
||||
"collections.archive",
|
||||
async (event: PartialExcept<Collection, "id">) => {
|
||||
const collectionId = event.id;
|
||||
|
||||
// Fetch collection to update policies
|
||||
await collections.fetch(collectionId, { force: true });
|
||||
|
||||
documents.unarchivedInCollection(collectionId).forEach(
|
||||
action((doc) => {
|
||||
if (!doc.publishedAt) {
|
||||
// draft is to be detached from collection, not archived
|
||||
doc.collectionId = null;
|
||||
} else {
|
||||
doc.archivedAt = event.archivedAt as string;
|
||||
}
|
||||
policies.remove(doc.id);
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"collections.restore",
|
||||
async (event: PartialExcept<Collection, "id">) => {
|
||||
const collectionId = event.id;
|
||||
documents
|
||||
.archivedInCollection(collectionId, {
|
||||
archivedAt: event.archivedAt as string,
|
||||
})
|
||||
.forEach(
|
||||
action((doc) => {
|
||||
doc.archivedAt = null;
|
||||
policies.remove(doc.id);
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch collection to update policies
|
||||
await collections.fetch(collectionId, { force: true });
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("teams.update", (event: PartialExcept<Team, "id">) => {
|
||||
if ("sharing" in event && event.sharing !== auth.team?.sharing) {
|
||||
documents.all.forEach((document) => {
|
||||
policies.remove(document.id);
|
||||
@@ -410,23 +493,23 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"notifications.create",
|
||||
(event: PartialWithId<Notification>) => {
|
||||
(event: PartialExcept<Notification, "id">) => {
|
||||
notifications.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"notifications.update",
|
||||
(event: PartialWithId<Notification>) => {
|
||||
(event: PartialExcept<Notification, "id">) => {
|
||||
notifications.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("pins.create", (event: PartialWithId<Pin>) => {
|
||||
this.socket.on("pins.create", (event: PartialExcept<Pin, "id">) => {
|
||||
pins.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.update", (event: PartialWithId<Pin>) => {
|
||||
this.socket.on("pins.update", (event: PartialExcept<Pin, "id">) => {
|
||||
pins.add(event);
|
||||
});
|
||||
|
||||
@@ -434,11 +517,11 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
pins.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("stars.create", (event: PartialWithId<Star>) => {
|
||||
this.socket.on("stars.create", (event: PartialExcept<Star, "id">) => {
|
||||
stars.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("stars.update", (event: PartialWithId<Star>) => {
|
||||
this.socket.on("stars.update", (event: PartialExcept<Star, "id">) => {
|
||||
stars.add(event);
|
||||
});
|
||||
|
||||
@@ -446,13 +529,6 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
stars.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"user.typing",
|
||||
(event: { userId: string; documentId: string; commentId: string }) => {
|
||||
comments.setTyping(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("collections.add_user", async (event: Membership) => {
|
||||
memberships.add(event);
|
||||
await collections.fetch(event.collectionId, {
|
||||
@@ -496,14 +572,14 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"fileOperations.create",
|
||||
(event: PartialWithId<FileOperation>) => {
|
||||
(event: PartialExcept<FileOperation, "id">) => {
|
||||
fileOperations.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"fileOperations.update",
|
||||
(event: PartialWithId<FileOperation>) => {
|
||||
(event: PartialExcept<FileOperation, "id">) => {
|
||||
fileOperations.add(event);
|
||||
|
||||
if (
|
||||
@@ -520,7 +596,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"subscriptions.create",
|
||||
(event: PartialWithId<Subscription>) => {
|
||||
(event: PartialExcept<Subscription, "id">) => {
|
||||
subscriptions.add(event);
|
||||
}
|
||||
);
|
||||
@@ -532,11 +608,11 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("users.update", (event: PartialWithId<User>) => {
|
||||
this.socket.on("users.update", (event: PartialExcept<User, "id">) => {
|
||||
users.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("users.demote", async (event: PartialWithId<User>) => {
|
||||
this.socket.on("users.demote", async (event: PartialExcept<User, "id">) => {
|
||||
if (event.id === auth.user?.id) {
|
||||
documents.all.forEach((document) => policies.remove(document.id));
|
||||
await collections.fetchAll();
|
||||
@@ -545,7 +621,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"userMemberships.update",
|
||||
async (event: PartialWithId<UserMembership>) => {
|
||||
async (event: PartialExcept<UserMembership, "id">) => {
|
||||
userMemberships.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
import { Node as ProsemirrorNode } from "prosemirror-model";
|
||||
import { EditorView, Decoration } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { ThemeProvider } from "styled-components";
|
||||
import { FunctionComponent } from "react";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { ComponentProps } from "@shared/editor/types";
|
||||
import { Editor } from "~/editor";
|
||||
import { NodeViewRenderer } from "./NodeViewRenderer";
|
||||
|
||||
type Component = (props: ComponentProps) => React.ReactElement;
|
||||
type ComponentViewConstructor = {
|
||||
/** The editor instance. */
|
||||
editor: Editor;
|
||||
/** The extension the view belongs to. */
|
||||
extension: Extension;
|
||||
/** The node that the view is responsible for. */
|
||||
node: ProsemirrorNode;
|
||||
/** The editor view instance. */
|
||||
view: EditorView;
|
||||
/** A function that returns the current position of the node. */
|
||||
getPos: () => number;
|
||||
/** The decorations applied to the node. */
|
||||
decorations: Decoration[];
|
||||
};
|
||||
|
||||
export default class ComponentView {
|
||||
component: Component;
|
||||
/** The React component to render. */
|
||||
component: FunctionComponent<ComponentProps>;
|
||||
/** The editor instance. */
|
||||
editor: Editor;
|
||||
/** The extension the view belongs to. */
|
||||
extension: Extension;
|
||||
/** The node that the view is responsible for. */
|
||||
node: ProsemirrorNode;
|
||||
/** The editor view instance. */
|
||||
view: EditorView;
|
||||
/** A function that returns the current position of the node. */
|
||||
getPos: () => number;
|
||||
/** The decorations applied to the node. */
|
||||
decorations: Decoration[];
|
||||
|
||||
/** The renderer instance. */
|
||||
renderer: NodeViewRenderer<ComponentProps>;
|
||||
/** Whether the node is selected. */
|
||||
isSelected = false;
|
||||
/** The DOM element that the node is rendered into. */
|
||||
dom: HTMLElement | null;
|
||||
|
||||
// See https://prosemirror.net/docs/ref/#view.NodeView
|
||||
constructor(
|
||||
component: Component,
|
||||
component: FunctionComponent<ComponentProps>,
|
||||
{
|
||||
editor,
|
||||
extension,
|
||||
@@ -31,14 +53,7 @@ export default class ComponentView {
|
||||
view,
|
||||
getPos,
|
||||
decorations,
|
||||
}: {
|
||||
editor: Editor;
|
||||
extension: Extension;
|
||||
node: ProsemirrorNode;
|
||||
view: EditorView;
|
||||
getPos: () => number;
|
||||
decorations: Decoration[];
|
||||
}
|
||||
}: ComponentViewConstructor
|
||||
) {
|
||||
this.component = component;
|
||||
this.editor = editor;
|
||||
@@ -52,51 +67,33 @@ export default class ComponentView {
|
||||
: document.createElement("div");
|
||||
|
||||
this.dom.classList.add(`component-${node.type.name}`);
|
||||
this.renderer = new NodeViewRenderer(this.dom, this.component, this.props);
|
||||
|
||||
this.renderElement();
|
||||
window.addEventListener("theme-changed", this.renderElement);
|
||||
window.addEventListener("location-changed", this.renderElement);
|
||||
// Add the renderer to the editor's set of renderers so that it is included in the React tree.
|
||||
this.editor.renderers.add(this.renderer);
|
||||
}
|
||||
|
||||
renderElement = () => {
|
||||
const { theme } = this.editor.props;
|
||||
|
||||
const children = this.component({
|
||||
theme,
|
||||
node: this.node,
|
||||
view: this.view,
|
||||
isSelected: this.isSelected,
|
||||
isEditable: this.view.editable,
|
||||
getPos: this.getPos,
|
||||
});
|
||||
|
||||
ReactDOM.render(
|
||||
<ThemeProvider theme={theme}>{children}</ThemeProvider>,
|
||||
this.dom
|
||||
);
|
||||
};
|
||||
|
||||
update(node: ProsemirrorNode) {
|
||||
if (node.type !== this.node.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.node = node;
|
||||
this.renderElement();
|
||||
this.renderer.updateProps(this.props);
|
||||
return true;
|
||||
}
|
||||
|
||||
selectNode() {
|
||||
if (this.view.editable) {
|
||||
this.isSelected = true;
|
||||
this.renderElement();
|
||||
this.renderer.updateProps(this.props);
|
||||
}
|
||||
}
|
||||
|
||||
deselectNode() {
|
||||
if (this.view.editable) {
|
||||
this.isSelected = false;
|
||||
this.renderElement();
|
||||
this.renderer.updateProps(this.props);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,16 +102,21 @@ export default class ComponentView {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener("theme-changed", this.renderElement);
|
||||
window.removeEventListener("location-changed", this.renderElement);
|
||||
|
||||
if (this.dom) {
|
||||
ReactDOM.unmountComponentAtNode(this.dom);
|
||||
}
|
||||
this.editor.renderers.delete(this.renderer);
|
||||
this.dom = null;
|
||||
}
|
||||
|
||||
ignoreMutation() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get props() {
|
||||
return {
|
||||
node: this.node,
|
||||
view: this.view,
|
||||
isSelected: this.isSelected,
|
||||
isEditable: this.view.editable,
|
||||
getPos: this.getPos,
|
||||
} as ComponentProps;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ const EmojiMenu = (props: Props) => {
|
||||
.map((item) => {
|
||||
// We snake_case the shortcode for backwards compatability with gemoji to
|
||||
// avoid multiple formats being written into documents.
|
||||
// @ts-expect-error emojiMartToGemoji key
|
||||
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
|
||||
const emoji = item.value;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isCode } from "@shared/editor/lib/isCode";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -134,55 +135,62 @@ function usePosition({
|
||||
|
||||
// Images are wrapped which impacts positioning - need to get the element
|
||||
// specifically tagged as the handle
|
||||
const imageElement = (element as HTMLElement).getElementsByClassName(
|
||||
EditorStyleHelper.imageHandle
|
||||
)[0];
|
||||
const { left, top, width } = imageElement.getBoundingClientRect();
|
||||
const imageElement = element
|
||||
? (element as HTMLElement).getElementsByClassName(
|
||||
EditorStyleHelper.imageHandle
|
||||
)[0]
|
||||
: undefined;
|
||||
if (imageElement) {
|
||||
const { left, top, width } = imageElement.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
left: Math.round(left + width / 2 - menuWidth / 2 - offsetParent.left),
|
||||
top: Math.round(top - menuHeight - offsetParent.top),
|
||||
offset: 0,
|
||||
visible: true,
|
||||
};
|
||||
} else {
|
||||
// calculate the horizontal center of the selection
|
||||
const halfSelection =
|
||||
Math.abs(selectionBounds.right - selectionBounds.left) / 2;
|
||||
const centerOfSelection = selectionBounds.left + halfSelection;
|
||||
return {
|
||||
left: Math.round(left + width / 2 - menuWidth / 2 - offsetParent.left),
|
||||
top: Math.round(top - menuHeight - offsetParent.top),
|
||||
offset: 0,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// position the menu so that it is centered over the selection except in
|
||||
// the cases where it would extend off the edge of the screen. In these
|
||||
// instances leave a margin
|
||||
const margin = 12;
|
||||
const left = Math.min(
|
||||
Math.min(
|
||||
offsetParent.x + offsetParent.width - menuWidth - margin,
|
||||
window.innerWidth - margin
|
||||
),
|
||||
Math.max(
|
||||
Math.max(offsetParent.x, margin),
|
||||
centerOfSelection - menuWidth / 2
|
||||
)
|
||||
);
|
||||
const top = Math.min(
|
||||
// calculate the horizontal center of the selection
|
||||
const halfSelection =
|
||||
Math.abs(selectionBounds.right - selectionBounds.left) / 2;
|
||||
const centerOfSelection = selectionBounds.left + halfSelection;
|
||||
|
||||
// position the menu so that it is centered over the selection except in
|
||||
// the cases where it would extend off the edge of the screen. In these
|
||||
// instances leave a margin
|
||||
const margin = 12;
|
||||
const left = Math.min(
|
||||
Math.min(
|
||||
offsetParent.x + offsetParent.width - menuWidth - margin,
|
||||
window.innerWidth - margin
|
||||
),
|
||||
Math.max(
|
||||
Math.max(offsetParent.x, margin),
|
||||
centerOfSelection - menuWidth / 2
|
||||
)
|
||||
);
|
||||
const top = Math.max(
|
||||
HEADER_HEIGHT,
|
||||
Math.min(
|
||||
window.innerHeight - menuHeight - margin,
|
||||
Math.max(margin, selectionBounds.top - menuHeight)
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
// if the menu has been offset to not extend offscreen then we should adjust
|
||||
// the position of the triangle underneath to correctly point to the center
|
||||
// of the selection still
|
||||
const offset = left - (centerOfSelection - menuWidth / 2);
|
||||
return {
|
||||
left: Math.round(left - offsetParent.left),
|
||||
top: Math.round(top - offsetParent.top),
|
||||
offset: Math.round(offset),
|
||||
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
|
||||
blockSelection: codeBlock || isColSelection || isRowSelection,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
// if the menu has been offset to not extend offscreen then we should adjust
|
||||
// the position of the triangle underneath to correctly point to the center
|
||||
// of the selection still
|
||||
const offset = left - (centerOfSelection - menuWidth / 2);
|
||||
return {
|
||||
left: Math.round(left - offsetParent.left),
|
||||
top: Math.round(top - offsetParent.top),
|
||||
offset: Math.round(offset),
|
||||
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
|
||||
blockSelection: codeBlock || isColSelection || isRowSelection,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
|
||||
|
||||
@@ -92,7 +92,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
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 }} won't be notified, as they do not have access to this document",
|
||||
{
|
||||
userName: item.attrs.label,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export class NodeViewRenderer<T extends object> {
|
||||
@observable public props: T;
|
||||
|
||||
public constructor(
|
||||
public element: HTMLElement,
|
||||
private Component: FunctionComponent,
|
||||
props: T
|
||||
) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
@computed
|
||||
public get content() {
|
||||
return createPortal(<this.Component {...this.props} />, this.element);
|
||||
}
|
||||
|
||||
@action
|
||||
public updateProps(props: T) {
|
||||
if (!isEqual(props, this.props)) {
|
||||
this.props = props;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,8 +216,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
const colIndex = getColumnIndex(state);
|
||||
const rowIndex = getRowIndex(state);
|
||||
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
|
||||
const link = isMarkActive(state.schema.marks.link)(state);
|
||||
const range = getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const link = getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const isImageSelection =
|
||||
selection instanceof NodeSelection && selection.node.type.name === "image";
|
||||
const isAttachmentSelection =
|
||||
@@ -266,7 +265,8 @@ export default function SelectionToolbar(props: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showLinkToolbar = link && range;
|
||||
const showLinkToolbar =
|
||||
link && link.from === selection.from && link.to === selection.to;
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
@@ -276,12 +276,12 @@ export default function SelectionToolbar(props: Props) {
|
||||
>
|
||||
{showLinkToolbar ? (
|
||||
<LinkEditor
|
||||
key={`${range.from}-${range.to}`}
|
||||
key={`${link.from}-${link.to}`}
|
||||
dictionary={dictionary}
|
||||
view={view}
|
||||
mark={range.mark}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
mark={link.mark}
|
||||
from={link.from}
|
||||
to={link.to}
|
||||
onClickLink={props.onClickLink}
|
||||
onSearchLink={props.onSearchLink}
|
||||
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import deburr from "lodash/deburr";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import { observable } from "mobx";
|
||||
import { Node } from "prosemirror-model";
|
||||
@@ -91,6 +92,10 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
|
||||
public replace(replace: string): Command {
|
||||
return (state, dispatch) => {
|
||||
// Redo the search to ensure we have the latest results, the document may
|
||||
// have changed underneath us since the last search.
|
||||
this.search(state.doc);
|
||||
|
||||
const result = this.results[this.currentResultIndex];
|
||||
|
||||
if (!result) {
|
||||
@@ -105,7 +110,12 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
}
|
||||
|
||||
public replaceAll(replace: string): Command {
|
||||
return ({ tr }, dispatch) => {
|
||||
return (state, dispatch) => {
|
||||
// Redo the search to ensure we have the latest results, the document may
|
||||
// have changed underneath us since the last search.
|
||||
this.search(state.doc);
|
||||
|
||||
const tr = state.tr;
|
||||
let offset: number | undefined;
|
||||
|
||||
if (!this.results.length) {
|
||||
@@ -243,19 +253,29 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
});
|
||||
|
||||
mergedTextNodes.forEach(({ text = "", pos }) => {
|
||||
const search = this.findRegExp;
|
||||
let m;
|
||||
|
||||
try {
|
||||
while ((m = search.exec(text))) {
|
||||
let m;
|
||||
const search = this.findRegExp;
|
||||
|
||||
// We construct a string with the text stripped of diacritics plus the original text for
|
||||
// search allowing to search for diacritics-insensitive matches easily.
|
||||
while ((m = search.exec(deburr(text) + text))) {
|
||||
if (m[0] === "") {
|
||||
break;
|
||||
}
|
||||
|
||||
this.results.push({
|
||||
from: pos + m.index,
|
||||
to: pos + m.index + m[0].length,
|
||||
});
|
||||
// Reconstruct the correct match position
|
||||
const i = m.index >= text.length ? m.index - text.length : m.index;
|
||||
const from = pos + i;
|
||||
const to = from + m[0].length;
|
||||
|
||||
// Check if already exists in results, possible due to duplicated
|
||||
// search string on L257
|
||||
if (this.results.some((r) => r.from === from && r.to === to)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.results.push({ from, to });
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid RegExp
|
||||
|
||||
@@ -8,7 +8,7 @@ export default class MentionMenuExtension extends Suggestion {
|
||||
get defaultOptions() {
|
||||
return {
|
||||
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
|
||||
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d]+)?$/u,
|
||||
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d\s{1}@\.]+)?$/u,
|
||||
closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s{2})|(\s+[\p{L}\p{M}\d]+))$/u,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import {
|
||||
ySyncPlugin,
|
||||
yCursorPlugin,
|
||||
yUndoPlugin,
|
||||
undo,
|
||||
redo,
|
||||
} from "@getoutline/y-prosemirror";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
} from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { Second } from "@shared/utils/time";
|
||||
|
||||
type UserAwareness = {
|
||||
user?: {
|
||||
id: string;
|
||||
};
|
||||
anchor: object;
|
||||
head: object;
|
||||
};
|
||||
|
||||
export default class Multiplayer extends Extension {
|
||||
get name() {
|
||||
@@ -18,6 +27,7 @@ export default class Multiplayer extends Extension {
|
||||
const { user, provider, document: doc } = this.options;
|
||||
const type = doc.get("default", Y.XmlFragment);
|
||||
|
||||
// Assign a user to a client ID once they've made a change and then remove the listener
|
||||
const assignUser = (tr: Y.Transaction) => {
|
||||
const clientIds = Array.from(doc.store.clients.keys());
|
||||
|
||||
@@ -32,6 +42,54 @@ export default class Multiplayer extends Extension {
|
||||
}
|
||||
};
|
||||
|
||||
const userAwarenessCache = new Map<
|
||||
string,
|
||||
{ aw: UserAwareness; changedAt: Date }
|
||||
>();
|
||||
|
||||
// The opacity of a remote user's selection.
|
||||
const selectionOpacity = 70;
|
||||
|
||||
// The time in milliseconds after which a remote user's selection will be hidden.
|
||||
const selectionTimeout = 10 * Second.ms;
|
||||
|
||||
// We're hijacking this method to store the last time a user's awareness changed as a side
|
||||
// effect, and otherwise behaving as the default.
|
||||
const awarenessStateFilter = (
|
||||
currentClientId: number,
|
||||
userClientId: number,
|
||||
aw: UserAwareness
|
||||
) => {
|
||||
if (currentClientId === userClientId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userId = aw.user?.id;
|
||||
const cached = userId ? userAwarenessCache.get(userId) : undefined;
|
||||
if (!cached || !isEqual(cached?.aw, aw)) {
|
||||
if (userId) {
|
||||
userAwarenessCache.set(userId, { aw, changedAt: new Date() });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Override the default selection builder to add a background color to the selection
|
||||
// only if the user's awareness has changed recently – this stops selections from lingering.
|
||||
const selectionBuilder = (u: { id: string; color: string }) => {
|
||||
const cached = userAwarenessCache.get(u.id);
|
||||
const opacity =
|
||||
!cached || cached?.changedAt > new Date(Date.now() - selectionTimeout)
|
||||
? selectionOpacity
|
||||
: 0;
|
||||
|
||||
return {
|
||||
style: `background-color: ${u.color}${opacity}`,
|
||||
class: "ProseMirror-yjs-selection",
|
||||
};
|
||||
};
|
||||
|
||||
provider.setAwarenessField("user", user);
|
||||
|
||||
// only once an actual change has been made do we add the userId <> clientId
|
||||
@@ -40,13 +98,18 @@ export default class Multiplayer extends Extension {
|
||||
|
||||
return [
|
||||
ySyncPlugin(type),
|
||||
yCursorPlugin(provider.awareness),
|
||||
yUndoPlugin(),
|
||||
keymap({
|
||||
"Mod-z": undo,
|
||||
"Mod-y": redo,
|
||||
"Mod-Shift-z": redo,
|
||||
yCursorPlugin(provider.awareness, {
|
||||
awarenessStateFilter,
|
||||
selectionBuilder,
|
||||
}),
|
||||
yUndoPlugin(),
|
||||
];
|
||||
}
|
||||
|
||||
commands() {
|
||||
return {
|
||||
undo: () => undo,
|
||||
redo: () => redo,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,14 +90,8 @@ export default class PasteHandler extends Extension {
|
||||
},
|
||||
},
|
||||
handlePaste: (view, event: ClipboardEvent) => {
|
||||
// Do nothing if the document isn't currently editable
|
||||
if (!view.editable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default behavior if there is nothing on the clipboard or were
|
||||
// special pasting with no formatting (Shift held)
|
||||
if (!event.clipboardData || this.shiftKey) {
|
||||
// Do nothing if the document isn't currently editable or there is no clipboard data
|
||||
if (!view.editable || !event.clipboardData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -137,76 +131,6 @@ export default class PasteHandler extends Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the clipboard contents can be parsed as a single url
|
||||
if (isUrl(text)) {
|
||||
// If there is selected text then we want to wrap it in a link to the url
|
||||
if (!state.selection.empty) {
|
||||
toggleMark(this.editor.schema.marks.link, { href: text })(
|
||||
state,
|
||||
dispatch
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Is this link embeddable? Create an embed!
|
||||
const { embeds } = this.editor.props;
|
||||
if (
|
||||
embeds &&
|
||||
this.editor.commands.embed &&
|
||||
!isInCode(state) &&
|
||||
!isInList(state)
|
||||
) {
|
||||
for (const embed of embeds) {
|
||||
if (!embed.matchOnInput) {
|
||||
continue;
|
||||
}
|
||||
const matches = embed.matcher(text);
|
||||
if (matches) {
|
||||
this.editor.commands.embed({
|
||||
href: text,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Is the link a link to a document? If so, we can grab the title and insert it.
|
||||
if (isDocumentUrl(text)) {
|
||||
const slug = parseDocumentSlug(text);
|
||||
|
||||
if (slug) {
|
||||
void stores.documents
|
||||
.fetch(slug)
|
||||
.then((document) => {
|
||||
if (view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
if (document) {
|
||||
const { hash } = new URL(text);
|
||||
|
||||
const hasEmoji =
|
||||
determineIconType(document.icon) === IconType.Emoji;
|
||||
|
||||
const title = `${hasEmoji ? document.icon + " " : ""}${
|
||||
document.titleWithDefault
|
||||
}`;
|
||||
insertLink(`${document.path}${hash}`, title);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
insertLink(text);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
insertLink(text);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Because VSCode is an especially popular editor that places metadata
|
||||
// on the clipboard, we can parse it to find out what kind of content
|
||||
// was pasted.
|
||||
@@ -215,52 +139,129 @@ export default class PasteHandler extends Extension {
|
||||
const supportsCodeBlock = !!state.schema.nodes.code_block;
|
||||
const supportsCodeMark = !!state.schema.marks.code_inline;
|
||||
|
||||
if (pasteCodeLanguage && pasteCodeLanguage !== "markdown") {
|
||||
if (text.includes("\n") && supportsCodeBlock) {
|
||||
event.preventDefault();
|
||||
view.dispatch(
|
||||
state.tr
|
||||
.replaceSelectionWith(
|
||||
state.schema.nodes.code_block.create({
|
||||
language: Object.keys(LANGUAGES).includes(
|
||||
vscodeMeta.mode
|
||||
)
|
||||
? vscodeMeta.mode
|
||||
: null,
|
||||
if (!this.shiftKey) {
|
||||
// Check if the clipboard contents can be parsed as a single url
|
||||
if (isUrl(text)) {
|
||||
// If there is selected text then we want to wrap it in a link to the url
|
||||
if (!state.selection.empty) {
|
||||
toggleMark(this.editor.schema.marks.link, { href: text })(
|
||||
state,
|
||||
dispatch
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Is this link embeddable? Create an embed!
|
||||
const { embeds } = this.editor.props;
|
||||
if (
|
||||
embeds &&
|
||||
this.editor.commands.embed &&
|
||||
!isInCode(state) &&
|
||||
!isInList(state)
|
||||
) {
|
||||
for (const embed of embeds) {
|
||||
if (!embed.matchOnInput) {
|
||||
continue;
|
||||
}
|
||||
const matches = embed.matcher(text);
|
||||
if (matches) {
|
||||
this.editor.commands.embed({
|
||||
href: text,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Is the link a link to a document? If so, we can grab the title and insert it.
|
||||
if (isDocumentUrl(text)) {
|
||||
const slug = parseDocumentSlug(text);
|
||||
|
||||
if (slug) {
|
||||
void stores.documents
|
||||
.fetch(slug)
|
||||
.then((document) => {
|
||||
if (view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
if (document) {
|
||||
const { hash } = new URL(text);
|
||||
|
||||
const hasEmoji =
|
||||
determineIconType(document.icon) === IconType.Emoji;
|
||||
|
||||
const title = `${
|
||||
hasEmoji ? document.icon + " " : ""
|
||||
}${document.titleWithDefault}`;
|
||||
insertLink(`${document.path}${hash}`, title);
|
||||
}
|
||||
})
|
||||
)
|
||||
.insertText(text)
|
||||
);
|
||||
.catch(() => {
|
||||
if (view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
insertLink(text);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
insertLink(text);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (supportsCodeMark) {
|
||||
event.preventDefault();
|
||||
view.dispatch(
|
||||
state.tr
|
||||
.insertText(text, state.selection.from, state.selection.to)
|
||||
.addMark(
|
||||
state.selection.from,
|
||||
state.selection.to + text.length,
|
||||
state.schema.marks.code_inline.create()
|
||||
)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (pasteCodeLanguage && pasteCodeLanguage !== "markdown") {
|
||||
if (text.includes("\n") && supportsCodeBlock) {
|
||||
event.preventDefault();
|
||||
view.dispatch(
|
||||
state.tr
|
||||
.replaceSelectionWith(
|
||||
state.schema.nodes.code_block.create({
|
||||
language: Object.keys(LANGUAGES).includes(
|
||||
vscodeMeta.mode
|
||||
)
|
||||
? vscodeMeta.mode
|
||||
: null,
|
||||
})
|
||||
)
|
||||
.insertText(text)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the HTML on the clipboard is from Prosemirror then the best
|
||||
// compatability is to just use the HTML parser, regardless of
|
||||
// whether it "looks" like Markdown, see: outline/outline#2416
|
||||
if (html?.includes("data-pm-slice")) {
|
||||
return false;
|
||||
if (supportsCodeMark) {
|
||||
event.preventDefault();
|
||||
view.dispatch(
|
||||
state.tr
|
||||
.insertText(
|
||||
text,
|
||||
state.selection.from,
|
||||
state.selection.to
|
||||
)
|
||||
.addMark(
|
||||
state.selection.from,
|
||||
state.selection.to + text.length,
|
||||
state.schema.marks.code_inline.create()
|
||||
)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If the HTML on the clipboard is from Prosemirror then the best
|
||||
// compatability is to just use the HTML parser, regardless of
|
||||
// whether it "looks" like Markdown, see: outline/outline#2416
|
||||
if (html?.includes("data-pm-slice")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If the text on the clipboard looks like Markdown OR there is no
|
||||
// html on the clipboard then try to parse content as Markdown
|
||||
if (
|
||||
(isMarkdown(text) && !isDropboxPaper(html)) ||
|
||||
pasteCodeLanguage === "markdown"
|
||||
pasteCodeLanguage === "markdown" ||
|
||||
this.shiftKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
|
||||
@@ -30,19 +30,23 @@ export default class SmartText extends Extension {
|
||||
}
|
||||
|
||||
inputRules() {
|
||||
return [
|
||||
rightArrow,
|
||||
emdash,
|
||||
oneHalf,
|
||||
threeQuarters,
|
||||
copyright,
|
||||
registered,
|
||||
trademarked,
|
||||
ellipsis,
|
||||
openDoubleQuote,
|
||||
closeDoubleQuote,
|
||||
openSingleQuote,
|
||||
closeSingleQuote,
|
||||
];
|
||||
if (this.options.userPreferences?.enableSmartText ?? true) {
|
||||
return [
|
||||
rightArrow,
|
||||
emdash,
|
||||
oneHalf,
|
||||
threeQuarters,
|
||||
copyright,
|
||||
registered,
|
||||
trademarked,
|
||||
ellipsis,
|
||||
openDoubleQuote,
|
||||
closeDoubleQuote,
|
||||
openSingleQuote,
|
||||
closeSingleQuote,
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
+32
-17
@@ -4,7 +4,6 @@ import { darken, transparentize } from "polished";
|
||||
import { baseKeymap } from "prosemirror-commands";
|
||||
import { dropCursor } from "prosemirror-dropcursor";
|
||||
import { gapCursor } from "prosemirror-gapcursor";
|
||||
import { redo, undo } from "prosemirror-history";
|
||||
import { inputRules, InputRule } from "prosemirror-inputrules";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import { MarkdownParser } from "prosemirror-markdown";
|
||||
@@ -38,7 +37,7 @@ import Mark from "@shared/editor/marks/Mark";
|
||||
import { basicExtensions as extensions } from "@shared/editor/nodes";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
import ReactNode from "@shared/editor/nodes/ReactNode";
|
||||
import { EventType } from "@shared/editor/types";
|
||||
import { ComponentProps, EventType } from "@shared/editor/types";
|
||||
import { ProsemirrorData, UserPreferences } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import EventEmitter from "@shared/utils/events";
|
||||
@@ -50,6 +49,7 @@ import ComponentView from "./components/ComponentView";
|
||||
import EditorContext from "./components/EditorContext";
|
||||
import { SearchResult } from "./components/LinkEditor";
|
||||
import LinkToolbar from "./components/LinkToolbar";
|
||||
import { NodeViewRenderer } from "./components/NodeViewRenderer";
|
||||
import SelectionToolbar from "./components/SelectionToolbar";
|
||||
import WithTheme from "./components/WithTheme";
|
||||
|
||||
@@ -90,6 +90,10 @@ export type Props = {
|
||||
scrollTo?: string;
|
||||
/** Callback for handling uploaded images, should return the url of uploaded file */
|
||||
uploadFile?: (file: File) => Promise<string>;
|
||||
/** Callback when prosemirror nodes are initialized on document mount. */
|
||||
onInit?: () => void;
|
||||
/** Callback when prosemirror nodes are destroyed on document unmount. */
|
||||
onDestroy?: () => void;
|
||||
/** Callback when editor is blurred, as native input */
|
||||
onBlur?: () => void;
|
||||
/** Callback when editor is focused, as native input */
|
||||
@@ -175,6 +179,7 @@ export class Editor extends React.PureComponent<
|
||||
linkToolbarOpen: false,
|
||||
};
|
||||
|
||||
isInitialized = false;
|
||||
isBlurred = true;
|
||||
extensions: ExtensionManager;
|
||||
elementRef = React.createRef<HTMLDivElement>();
|
||||
@@ -192,6 +197,7 @@ export class Editor extends React.PureComponent<
|
||||
};
|
||||
|
||||
widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
|
||||
renderers: Set<NodeViewRenderer<ComponentProps>> = new Set();
|
||||
nodes: { [name: string]: NodeSpec };
|
||||
marks: { [name: string]: MarkSpec };
|
||||
commands: Record<string, CommandFactory>;
|
||||
@@ -281,6 +287,7 @@ export class Editor extends React.PureComponent<
|
||||
window.removeEventListener("theme-changed", this.dispatchThemeChanged);
|
||||
this.view?.destroy();
|
||||
this.mutationObserver?.disconnect();
|
||||
this.handleEditorDestroy();
|
||||
}
|
||||
|
||||
private init() {
|
||||
@@ -480,6 +487,8 @@ export class Editor extends React.PureComponent<
|
||||
self.handleChange();
|
||||
}
|
||||
|
||||
self.handleEditorInit();
|
||||
|
||||
self.calculateDir();
|
||||
|
||||
// Because Prosemirror and React are not linked we must tell React that
|
||||
@@ -598,20 +607,6 @@ export class Editor extends React.PureComponent<
|
||||
this.props
|
||||
);
|
||||
|
||||
/**
|
||||
* Undo the last change in the editor.
|
||||
*
|
||||
* @returns True if the undo was successful
|
||||
*/
|
||||
public undo = () => undo(this.view.state, this.view.dispatch, this.view);
|
||||
|
||||
/**
|
||||
* Redo the last change in the editor.
|
||||
*
|
||||
* @returns True if the change was successful
|
||||
*/
|
||||
public redo = () => redo(this.view.state, this.view.dispatch, this.view);
|
||||
|
||||
/**
|
||||
* Returns true if the trimmed content of the editor is an empty string.
|
||||
*
|
||||
@@ -680,7 +675,10 @@ export class Editor extends React.PureComponent<
|
||||
* @param commentId The id of the comment to remove
|
||||
* @param attrs The attributes to update
|
||||
*/
|
||||
public updateComment = (commentId: string, attrs: { resolved: boolean }) => {
|
||||
public updateComment = (
|
||||
commentId: string,
|
||||
attrs: { resolved?: boolean; draft?: boolean }
|
||||
) => {
|
||||
const { state, dispatch } = this.view;
|
||||
const tr = state.tr;
|
||||
|
||||
@@ -738,6 +736,22 @@ export class Editor extends React.PureComponent<
|
||||
);
|
||||
};
|
||||
|
||||
private handleEditorInit = () => {
|
||||
if (!this.props.onInit || this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onInit();
|
||||
this.isInitialized = true;
|
||||
};
|
||||
|
||||
private handleEditorDestroy = () => {
|
||||
if (!this.props.onDestroy) {
|
||||
return;
|
||||
}
|
||||
this.props.onDestroy();
|
||||
};
|
||||
|
||||
private handleEditorBlur = () => {
|
||||
this.setState({ isEditorFocused: false });
|
||||
return false;
|
||||
@@ -838,6 +852,7 @@ export class Editor extends React.PureComponent<
|
||||
Object.values(this.widgets).map((Widget, index) => (
|
||||
<Widget key={String(index)} rtl={isRTL} readOnly={readOnly} />
|
||||
))}
|
||||
{Array.from(this.renderers).map((view) => view.content)}
|
||||
</Flex>
|
||||
</EditorContext.Provider>
|
||||
</PortalContext.Provider>
|
||||
|
||||
@@ -14,7 +14,10 @@ export default function codeMenuItems(
|
||||
): MenuItem[] {
|
||||
const node = state.selection.$from.node();
|
||||
|
||||
const allLanguages = Object.entries(LANGUAGES);
|
||||
const allLanguages = Object.entries(LANGUAGES) as [
|
||||
keyof typeof LANGUAGES,
|
||||
string
|
||||
][];
|
||||
const frequentLanguages = getFrequentCodeLanguages();
|
||||
|
||||
const frequentLangMenuItems = frequentLanguages.map((value) => {
|
||||
@@ -49,6 +52,7 @@ export default function codeMenuItems(
|
||||
visible: !readOnly,
|
||||
name: "code_block",
|
||||
icon: <ExpandedIcon />,
|
||||
// @ts-expect-error We have a fallback for incorrect mapping
|
||||
label: LANGUAGES[node.attrs.language ?? "none"],
|
||||
children: languageMenuItems,
|
||||
},
|
||||
|
||||
@@ -214,7 +214,6 @@ export default function formattingMenuItems(
|
||||
name: "link",
|
||||
tooltip: dictionary.createLink,
|
||||
icon: <LinkIcon />,
|
||||
active: isMarkActive(schema.marks.link),
|
||||
attrs: { href: "" },
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
|
||||
@@ -11,10 +11,11 @@ export default function readOnlyMenuItems(
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const { schema } = state;
|
||||
const isEmpty = state.selection.empty;
|
||||
|
||||
return [
|
||||
{
|
||||
visible: canUpdate,
|
||||
visible: canUpdate && !isEmpty,
|
||||
name: "comment",
|
||||
tooltip: dictionary.comment,
|
||||
label: dictionary.comment,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlignFullWidthIcon, TrashIcon } from "outline-icons";
|
||||
import { AlignFullWidthIcon, DownloadIcon, TrashIcon } from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
@@ -32,5 +32,15 @@ export default function tableMenuItems(
|
||||
tooltip: dictionary.deleteTable,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "exportTable",
|
||||
tooltip: dictionary.exportAsCSV,
|
||||
label: "CSV",
|
||||
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function useAutoRefresh() {
|
||||
return;
|
||||
}
|
||||
if (isReloaded) {
|
||||
Logger.error("lifecycle", new Error("Attempted to reload twice"));
|
||||
Logger.warn("Attempted to reload twice");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@ export default function useComponentSize(
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const [size, setSize] = useState({
|
||||
width: ref.current?.clientWidth || 0,
|
||||
height: ref.current?.clientHeight || 0,
|
||||
});
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const sizeObserver = new ResizeObserver((entries) => {
|
||||
@@ -24,6 +21,10 @@ export default function useComponentSize(
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
setSize({
|
||||
width: ref.current?.clientWidth,
|
||||
height: ref.current?.clientHeight,
|
||||
});
|
||||
sizeObserver.observe(ref.current);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ export default function useDictionary() {
|
||||
sortAsc: t("Sort ascending"),
|
||||
sortDesc: t("Sort descending"),
|
||||
table: t("Table"),
|
||||
exportAsCSV: t("Export as CSV"),
|
||||
toggleHeader: t("Toggle header"),
|
||||
mathInline: t("Math inline (LaTeX)"),
|
||||
mathBlock: t("Math block (LaTeX)"),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import useUnmount from "./useUnmount";
|
||||
|
||||
type Props = {
|
||||
/** Ref to the element that needs to be observed. */
|
||||
ref: React.RefObject<HTMLElement>;
|
||||
/** Duration to wait until it's considered as a hover event. */
|
||||
duration: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that will trigger the first time an element is hovered.
|
||||
*
|
||||
* @returns {boolean} hovered - Signals when an element is hovered by the user.
|
||||
*/
|
||||
const useHover = ({ ref, duration }: Props): boolean => {
|
||||
const [hovered, setHovered] = React.useState(false);
|
||||
const timer = React.useRef<number>();
|
||||
|
||||
const onMouseEnter = React.useCallback(() => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
|
||||
timer.current = window.setTimeout(() => setHovered(true), duration);
|
||||
}, [duration]);
|
||||
|
||||
const onMouseLeave = React.useCallback(() => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useUnmount(() => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.onmouseenter = onMouseEnter;
|
||||
ref.current.onmouseleave = onMouseLeave;
|
||||
}
|
||||
}, [ref, onMouseEnter, onMouseLeave]);
|
||||
|
||||
return hovered;
|
||||
};
|
||||
|
||||
export default useHover;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user