Compare commits

..

7 Commits

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

Some files were not shown because too many files have changed in this diff Show More