mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f06eda36b | |||
| 2dcfe4be0c | |||
| 8b56b47eb0 | |||
| b20f70da42 | |||
| 315992d55b | |||
| 8427778c46 | |||
| fd4dab23f2 | |||
| edcdb6f8c0 | |||
| 41a5097240 | |||
| bc248dc190 | |||
| 496b89c7f8 | |||
| 46dd13fc7f | |||
| f3eec09125 | |||
| ac29295dd2 | |||
| 0e8fde3bb1 | |||
| cad670f19c | |||
| afb849ac98 | |||
| 00ef17b913 | |||
| 05381ff101 | |||
| 519fd024f9 | |||
| 7be893f9a3 | |||
| 52448714d9 | |||
| 9b67d55f76 | |||
| 6e92313f73 | |||
| dfd969084b | |||
| 758d2b62f5 | |||
| b90ff98cef | |||
| 23642fbd85 | |||
| 3fa429977a | |||
| 8ddebb920e | |||
| 7ff6f1defb | |||
| f2016bb1ca | |||
| ba5e4dddbc | |||
| bb8f73cb8d | |||
| 4aeea4f73c | |||
| 2e0bc66ad1 | |||
| c4d861e0ae | |||
| f02520444e | |||
| 6695ae1f3e | |||
| 924db0a3fd | |||
| c9fe7b3d5c | |||
| 1937043aed | |||
| 957648a588 | |||
| 5c01909909 | |||
| 84d6ed01e3 | |||
| c758f0d93a | |||
| c54194f97a | |||
| a860cfc9ec | |||
| 08d58f7a6d | |||
| 45a19d52cf | |||
| de69a4e671 | |||
| 7824f6b363 | |||
| f6709520fa | |||
| 66b0341cfa | |||
| 057d57e21a | |||
| 13c00c4663 | |||
| eb584ed6b6 | |||
| 40c81a5e30 | |||
| 5e976fe732 | |||
| fe9daa0a75 | |||
| 08227ce4da | |||
| 4f6ee1a00b | |||
| 797c28a12e | |||
| 129e872578 | |||
| b4053f344f | |||
| ffe7cda26b | |||
| b792945d01 | |||
| 38880f8335 | |||
| 1caca05876 | |||
| 0722b42613 | |||
| 5d749efd84 | |||
| 0363481a6a | |||
| c8fbdc35fb | |||
| c382e1233b | |||
| 3a875d4466 | |||
| 66f9113975 | |||
| a52391842f | |||
| 20e84c8e1d | |||
| 1488341f66 | |||
| a06174b627 | |||
| 22556b2121 | |||
| 7252701e9b | |||
| 7c8ba7d2c1 | |||
| 5fd6ef646a | |||
| 54a90b05a8 | |||
| 0e9f34bd6a | |||
| 23177578b2 | |||
| 40bbfc78cd | |||
| dc9aad99e9 | |||
| ea9e9675fb | |||
| db42af7fe1 | |||
| eb59aed5b7 | |||
| 8209f56e56 | |||
| a097676e9c | |||
| 2da35f2504 | |||
| 3e38164366 | |||
| f28ce8f0cd |
+8
-1
@@ -1,4 +1,3 @@
|
||||
__mocks__
|
||||
.git
|
||||
.vscode
|
||||
.github
|
||||
@@ -8,11 +7,19 @@ __mocks__
|
||||
.eslint*
|
||||
.oxlintrc*
|
||||
.log
|
||||
*.md
|
||||
Makefile
|
||||
Procfile
|
||||
app.json
|
||||
crowdin.yml
|
||||
lint-staged.config.mjs
|
||||
build
|
||||
docker-compose.yml
|
||||
node_modules
|
||||
.yarn
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.test.js
|
||||
**/*.test.jsx
|
||||
**/__tests__
|
||||
**/__mocks__
|
||||
|
||||
@@ -212,6 +212,11 @@ GITHUB_APP_NAME=
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# The GitLab integration allows previewing issue and merge request links
|
||||
# DOCS:
|
||||
GITLAB_CLIENT_ID=
|
||||
GITLAB_CLIENT_SECRET=
|
||||
|
||||
# Linear integration allows previewing issue links as rich mentions
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_CLIENT_SECRET=
|
||||
|
||||
@@ -18,6 +18,9 @@ GITHUB_CLIENT_ID=123;
|
||||
GITHUB_CLIENT_SECRET=123;
|
||||
GITHUB_APP_NAME=outline-test;
|
||||
|
||||
GITLAB_CLIENT_ID=123
|
||||
GITLAB_CLIENT_SECRET=123
|
||||
|
||||
OIDC_CLIENT_ID=client-id
|
||||
OIDC_CLIENT_SECRET=client-secret
|
||||
OIDC_AUTH_URI=http://localhost/authorize
|
||||
|
||||
@@ -20,4 +20,5 @@ data/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
.yarn/releases
|
||||
!.yarn/sdks
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 1.4.0
|
||||
Licensed Work: Outline 1.5.0
|
||||
The Licensed Work is (c) 2026 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2030-01-27
|
||||
Change Date: 2030-02-15
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./public/logos/outline-logo-dark.png" height="29">
|
||||
<source media="(prefers-color-scheme: light)" srcset="./public/logos/outline-logo-light.png" height="29">
|
||||
<img src="./public/logos/outline-logo-light.png" height="29" alt="Outline" />
|
||||
</picture>
|
||||
</p>
|
||||
<p align="center">
|
||||
<i>A fast, collaborative, knowledge base for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
|
||||
|
||||
@@ -29,8 +29,8 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import {
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
createActionWithChildren,
|
||||
} from "~/actions";
|
||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
@@ -152,7 +152,7 @@ export const importDocument = createAction({
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
),
|
||||
perform: ({ getActiveModel, stores }) => {
|
||||
perform: ({ t, getActiveModel, stores }) => {
|
||||
const { documents } = stores;
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
@@ -165,6 +165,7 @@ export const importDocument = createAction({
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
const file = files[0];
|
||||
const toastId = toast.loading(`${t("Uploading")}…`);
|
||||
|
||||
try {
|
||||
const document = await documents.import(file, null, collection.id, {
|
||||
@@ -173,6 +174,8 @@ export const importDocument = createAction({
|
||||
history.push(document.path);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -527,15 +530,9 @@ export const createTemplate = createInternalLinkAction({
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
),
|
||||
to: ({ getActiveModel, sidebarContext }) => {
|
||||
to: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
const [pathname, search] = newTemplatePath(collection?.id).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
return newTemplatePath(collection?.id);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -42,12 +42,11 @@ import { Week } from "@shared/utils/time";
|
||||
import type UserMembership from "~/models/UserMembership";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
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 DocumentCopy from "~/components/DocumentCopy";
|
||||
import DocumentCopy from "~/components/DocumentExplorer/DocumentCopy";
|
||||
import { DocumentDownload } from "~/components/DocumentDownload";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
@@ -70,6 +69,7 @@ import {
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
newNestedDocumentPath,
|
||||
newSiblingDocumentPath,
|
||||
searchPath,
|
||||
documentPath,
|
||||
urlify,
|
||||
@@ -78,9 +78,15 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type { Action, ActionGroup, ActionSeparator } from "~/types";
|
||||
import type {
|
||||
Action,
|
||||
ActionContext,
|
||||
ActionGroup,
|
||||
ActionSeparator,
|
||||
} from "~/types";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import env from "~/env";
|
||||
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
|
||||
|
||||
const Insights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
@@ -132,18 +138,13 @@ export const editDocument = createInternalLinkAction({
|
||||
keywords: "edit",
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const { auth, documents, policies } = stores;
|
||||
const { auth, policies } = stores;
|
||||
|
||||
const document = activeDocumentId
|
||||
? documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
const can = activeDocumentId
|
||||
? policies.abilities(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!!can?.update && !!auth.user?.separateEditMode && !document?.template
|
||||
);
|
||||
return !!can?.update && !!auth.user?.separateEditMode;
|
||||
},
|
||||
to: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -216,12 +217,7 @@ export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
!currentTeamId ||
|
||||
!document?.isTemplate ||
|
||||
!!document?.isDraft ||
|
||||
!!document?.isDeleted
|
||||
) {
|
||||
if (!currentTeamId || !!document?.isDraft || !!document?.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -247,12 +243,41 @@ export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Finds the index of a document among its siblings in the collection tree.
|
||||
*
|
||||
* @param stores - the root stores.
|
||||
* @param document - the document to find the index of.
|
||||
* @returns the index of the document among its siblings, or -1 if not found.
|
||||
*/
|
||||
function findDocumentSiblingIndex(
|
||||
stores: ActionContext["stores"],
|
||||
document: {
|
||||
id: string;
|
||||
collectionId?: string | null;
|
||||
parentDocumentId?: string;
|
||||
}
|
||||
): number {
|
||||
if (!document.collectionId) {
|
||||
return -1;
|
||||
}
|
||||
const collection = stores.collections.get(document.collectionId);
|
||||
if (!collection) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const siblings = document.parentDocumentId
|
||||
? collection.getChildrenForDocument(document.parentDocumentId)
|
||||
: collection.sortedDocuments;
|
||||
|
||||
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
|
||||
}
|
||||
|
||||
export const createNestedDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("New nested document"),
|
||||
name: ({ t }) => t("Nested document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
keywords: "create nested",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) =>
|
||||
!!currentTeamId &&
|
||||
!!activeDocumentId &&
|
||||
@@ -270,6 +295,93 @@ export const createNestedDocument = createInternalLinkAction({
|
||||
},
|
||||
});
|
||||
|
||||
const createDocumentBefore = createInternalLinkAction({
|
||||
name: ({ t }) => t("Before"),
|
||||
analyticsName: "New document before",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "create before",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) => {
|
||||
if (!currentTeamId || !activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!!document?.collectionId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const index = findDocumentSiblingIndex(stores, document);
|
||||
const [pathname, search] = newSiblingDocumentPath({
|
||||
collectionId: document.collectionId,
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
index: Math.max(0, index),
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const createDocumentAfter = createInternalLinkAction({
|
||||
name: ({ t }) => t("After"),
|
||||
analyticsName: "New document after",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "create after",
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) => {
|
||||
if (!currentTeamId || !activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!!document?.collectionId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const index = findDocumentSiblingIndex(stores, document);
|
||||
const [pathname, search] = newSiblingDocumentPath({
|
||||
collectionId: document.collectionId,
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
index: index + 1,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createNewDocument = createActionWithChildren({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
@@ -346,7 +458,7 @@ export const publishDocument = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
if (document?.collectionId || document?.template) {
|
||||
if (document?.collectionId) {
|
||||
await document.save(undefined, {
|
||||
publish: true,
|
||||
});
|
||||
@@ -891,7 +1003,7 @@ export const importDocument = createAction({
|
||||
|
||||
return false;
|
||||
},
|
||||
perform: ({ activeDocumentId, activeCollectionId, stores }) => {
|
||||
perform: ({ t, activeDocumentId, activeCollectionId, stores }) => {
|
||||
const { documents } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
@@ -900,6 +1012,7 @@ export const importDocument = createAction({
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
const file = files[0];
|
||||
const toastId = toast.loading(`${t("Uploading")}…`);
|
||||
|
||||
try {
|
||||
const document = await documents.import(
|
||||
@@ -913,6 +1026,8 @@ export const importDocument = createAction({
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -930,7 +1045,7 @@ export const createTemplateFromDocument = createAction({
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document?.isTemplate || !document?.isActive) {
|
||||
if (!document?.isActive) {
|
||||
return false;
|
||||
}
|
||||
return !!(
|
||||
@@ -982,46 +1097,8 @@ export const searchDocumentsForQuery = (query: string) =>
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const moveTemplateToWorkspace = createAction({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document || !document.template || document.isWorkspaceTemplate) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.move({
|
||||
collectionId: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocumentToCollection = createAction({
|
||||
name: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return t("Move");
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return document?.template && document?.collectionId
|
||||
? t("Move to collection")
|
||||
: t("Move");
|
||||
},
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
@@ -1059,8 +1136,7 @@ export const moveDocument = createAction({
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
// Don't show the button if this is a non-workspace template.
|
||||
if (!document || (document.template && !document.isWorkspaceTemplate)) {
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
@@ -1068,25 +1144,6 @@ export const moveDocument = createAction({
|
||||
perform: moveDocumentToCollection.perform,
|
||||
});
|
||||
|
||||
export const moveTemplate = createActionWithChildren({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
// Don't show the menu if this is not a template (or) a workspace template.
|
||||
if (!document || !document.template || document.isWorkspaceTemplate) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
},
|
||||
children: [moveTemplateToWorkspace, moveDocumentToCollection],
|
||||
});
|
||||
|
||||
export const archiveDocument = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive document",
|
||||
@@ -1145,10 +1202,7 @@ export const restoreDocument = createAction({
|
||||
: undefined;
|
||||
const can = stores.policies.abilities(document.id);
|
||||
|
||||
return (
|
||||
!!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
return !!collection?.isActive && !!(can.restore || can.unarchive);
|
||||
},
|
||||
perform: async ({ t, stores, activeDocumentId }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -1185,10 +1239,7 @@ export const restoreDocumentToCollection = createActionWithChildren({
|
||||
? stores.collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
return !collection?.isActive && !!(can.restore || can.unarchive);
|
||||
},
|
||||
children: ({ t, activeDocumentId, stores }) => {
|
||||
const { collections, documents, policies } = stores;
|
||||
@@ -1365,6 +1416,7 @@ export const openDocumentInsights = createAction({
|
||||
name: ({ t }) => t("Insights"),
|
||||
analyticsName: "Open document insights",
|
||||
section: ActiveDocumentSection,
|
||||
shortcut: [`Meta+Shift+I`],
|
||||
icon: <GraphIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
@@ -1372,12 +1424,7 @@ export const openDocumentInsights = createAction({
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!!activeDocumentId &&
|
||||
can.listViews &&
|
||||
!document?.isTemplate &&
|
||||
!document?.isDeleted
|
||||
);
|
||||
return !!activeDocumentId && can.listViews && !document?.isDeleted;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -1456,6 +1503,7 @@ export const rootDocumentActions = [
|
||||
archiveDocument,
|
||||
createDocument,
|
||||
createDraftDocument,
|
||||
createNewDocument,
|
||||
createNestedDocument,
|
||||
createTemplateFromDocument,
|
||||
deleteDocument,
|
||||
@@ -1477,7 +1525,6 @@ export const rootDocumentActions = [
|
||||
searchInDocument,
|
||||
duplicateDocument,
|
||||
leaveDocument,
|
||||
moveTemplateToWorkspace,
|
||||
moveDocumentToCollection,
|
||||
openRandomDocument,
|
||||
permanentlyDeleteDocument,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
CaseSensitiveIcon,
|
||||
CollectionIcon,
|
||||
CopyIcon,
|
||||
MoveIcon,
|
||||
NewDocumentIcon,
|
||||
PlusIcon,
|
||||
PrintIcon,
|
||||
TrashIcon,
|
||||
} from "outline-icons";
|
||||
import { Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import TemplateMove from "~/components/DocumentExplorer/TemplateMove";
|
||||
import {
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { newDocumentPath, newTemplatePath, urlify } from "~/utils/routeHelpers";
|
||||
import { ActiveTemplateSection, TemplateSection } from "../sections";
|
||||
import Template from "~/models/Template";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
|
||||
export const createTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: TemplateSection,
|
||||
icon: <PlusIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!stores.policies.abilities(currentTeamId!).createTemplate,
|
||||
to: newTemplatePath(),
|
||||
});
|
||||
|
||||
export const deleteTemplate = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Template).some((policy) => policy.abilities.delete),
|
||||
perform: ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete {{ documentName }}", {
|
||||
documentName: t("template"),
|
||||
}),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await template.delete();
|
||||
toast.success(t("Template deleted"));
|
||||
}}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent."
|
||||
values={{
|
||||
templateName: template.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplateToWorkspace = createAction({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: ActiveTemplateSection,
|
||||
icon: ({ stores }) => {
|
||||
const { team } = stores.auth;
|
||||
return <TeamLogo model={team} size={AvatarSize.Small} />;
|
||||
},
|
||||
visible: ({ getActiveModel }) => {
|
||||
const template = getActiveModel(Template);
|
||||
return !!template?.collectionId;
|
||||
},
|
||||
perform: async ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await template.save({ collectionId: null });
|
||||
toast.success(t("Template moved"));
|
||||
stores.dialogs.closeAllModals();
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn't move the template, try again?"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplateToCollection = createAction({
|
||||
name: ({ t }) => t("Move to collection"),
|
||||
analyticsName: "Move template to collection",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CollectionIcon />,
|
||||
perform: ({ getActiveModel, stores, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Move template"),
|
||||
content: <TemplateMove template={template} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const moveTemplate = createActionWithChildren({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Template).some((policy) => policy.abilities.move),
|
||||
children: [moveTemplateToWorkspace, moveTemplateToCollection],
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document from template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, getActiveModel, stores }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template || !currentTeamId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (template.collectionId) {
|
||||
return !!stores.policies.abilities(template.collectionId).createDocument;
|
||||
}
|
||||
return !!stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
to: ({ getActiveModel, activeCollectionId, sidebarContext }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (!template) {
|
||||
return "";
|
||||
}
|
||||
const collectionId = template?.collectionId ?? activeCollectionId;
|
||||
|
||||
const [pathname, search] = newDocumentPath(collectionId, {
|
||||
templateId: template.id,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplateLink = createAction({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy template link",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CopyIcon />,
|
||||
iconInContextMenu: false,
|
||||
perform: ({ getActiveModel, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (template) {
|
||||
copy(urlify(template.path));
|
||||
toast.success(t("Link copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplateAsPlainText = createAction({
|
||||
name: ({ t }) => t("Copy as text"),
|
||||
analyticsName: "Copy template as text",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CaseSensitiveIcon />,
|
||||
iconInContextMenu: false,
|
||||
perform: async ({ getActiveModel, t }) => {
|
||||
const template = getActiveModel(Template);
|
||||
if (template) {
|
||||
const { ProsemirrorHelper } =
|
||||
await import("~/models/helpers/ProsemirrorHelper");
|
||||
copy(ProsemirrorHelper.toPlainText(template));
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyTemplate = createActionWithChildren({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <CopyIcon />,
|
||||
keywords: "clipboard",
|
||||
children: [copyTemplateLink, copyTemplateAsPlainText],
|
||||
});
|
||||
|
||||
export const printTemplate = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print template")),
|
||||
analyticsName: "Print template",
|
||||
section: ActiveTemplateSection,
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
|
||||
perform: () => {
|
||||
queueMicrotask(window.print);
|
||||
},
|
||||
});
|
||||
|
||||
export const rootTemplateActions = [moveTemplate, createDocumentFromTemplate];
|
||||
@@ -24,6 +24,15 @@ export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
|
||||
|
||||
ActiveDocumentSection.priority = 0.9;
|
||||
|
||||
export const TemplateSection = ({ t }: ActionContext) => t("Template");
|
||||
|
||||
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
|
||||
const activeTemplate = stores.templates.active;
|
||||
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
|
||||
};
|
||||
|
||||
ActiveTemplateSection.priority = 0.9;
|
||||
|
||||
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
|
||||
|
||||
RecentSection.priority = 1;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
@@ -132,6 +133,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</DocumentContextProvider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
@@ -109,4 +110,4 @@ const Image = styled.img<{ size: number }>`
|
||||
height: ${(props) => props.size}px;
|
||||
`;
|
||||
|
||||
export default Avatar;
|
||||
export default observer(Avatar);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GoToIcon } from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
@@ -121,4 +122,4 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
|
||||
export default observer(React.forwardRef<HTMLDivElement, Props>(Breadcrumb));
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as RadixCollapsible from "@radix-ui/react-collapsible";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
interface CollapsibleProps {
|
||||
/** The label displayed on the trigger button. */
|
||||
label: React.ReactNode;
|
||||
/** The content to show/hide inside the collapsible panel. */
|
||||
children: React.ReactNode;
|
||||
/** Whether the collapsible is open by default. */
|
||||
defaultOpen?: boolean;
|
||||
/** Controlled open state. */
|
||||
open?: boolean;
|
||||
/** Callback fired when the open state changes. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Additional class name for the root element. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An accessible collapsible section built on Radix UI Collapsible.
|
||||
* Renders a trigger button with a disclosure chevron and animated content panel.
|
||||
*
|
||||
* @param props - component props.
|
||||
* @returns the collapsible component.
|
||||
*/
|
||||
export function Collapsible({
|
||||
label,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
className,
|
||||
}: CollapsibleProps) {
|
||||
return (
|
||||
<RadixCollapsible.Root
|
||||
defaultOpen={defaultOpen}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
className={className}
|
||||
>
|
||||
<StyledTrigger>
|
||||
<StyledExpandedIcon aria-hidden="true" />
|
||||
{label}
|
||||
</StyledTrigger>
|
||||
<StyledContent>{children}</StyledContent>
|
||||
</RadixCollapsible.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledExpandedIcon = styled(ExpandedIcon)`
|
||||
flex-shrink: 0;
|
||||
transition: transform 150ms ease-out;
|
||||
margin-left: -4px;
|
||||
`;
|
||||
|
||||
const StyledTrigger = styled(RadixCollapsible.Trigger)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 0 8px 0;
|
||||
cursor: var(--pointer);
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 14pxte
|
||||
|
||||
&:hover {
|
||||
color: ${s("textSecondary")};
|
||||
}
|
||||
|
||||
&[data-state="closed"] {
|
||||
${StyledExpandedIcon} {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContent = styled(RadixCollapsible.Content)`
|
||||
overflow: hidden;
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: slideDown 200ms ease-out;
|
||||
}
|
||||
|
||||
&[data-state="closed"] {
|
||||
animation: slideUp 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -13,6 +13,7 @@ import { colorPalette } from "@shared/utils/collections";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import type Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import { Collapsible } from "~/components/Collapsible";
|
||||
import Input from "~/components/Input";
|
||||
import { InputSelectPermission } from "~/components/InputSelectPermission";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
@@ -144,7 +145,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
<HStack>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("Name")}
|
||||
label={t("Name")}
|
||||
{...register("name", {
|
||||
required: true,
|
||||
maxLength: CollectionValidation.maxNameLength,
|
||||
@@ -189,38 +190,44 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
/>
|
||||
)}
|
||||
|
||||
{team.sharing && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="sharing"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
{(team.sharing || team.getPreference(TeamPreference.Commenting)) && (
|
||||
<Collapsible label={t("Advanced options")}>
|
||||
{team.sharing && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="sharing"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="sharing"
|
||||
label={t("Public document sharing")}
|
||||
note={t(
|
||||
"Allow documents within this collection to be shared publicly on the internet."
|
||||
)}
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{team.getPreference(TeamPreference.Commenting) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="commenting"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="commenting"
|
||||
label={t("Commenting")}
|
||||
note={t("Allow commenting on documents within this collection.")}
|
||||
checked={!!field.value}
|
||||
onChange={field.onChange}
|
||||
{team.getPreference(TeamPreference.Commenting) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="commenting"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="commenting"
|
||||
label={t("Commenting")}
|
||||
note={t(
|
||||
"Allow commenting on documents within this collection."
|
||||
)}
|
||||
checked={!!field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
<HStack justify="flex-end">
|
||||
|
||||
@@ -11,15 +11,15 @@ import useStores from "~/hooks/useStores";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
const useTemplatesAction = () => {
|
||||
const { documents } = useStores();
|
||||
const { templates } = useStores();
|
||||
|
||||
useEffect(() => {
|
||||
void documents.fetchAllTemplates();
|
||||
}, [documents]);
|
||||
void templates.fetchAll();
|
||||
}, [templates]);
|
||||
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
documents.templatesAlphabetical.map((template) =>
|
||||
templates.alphabetical.map((template) =>
|
||||
createInternalLinkAction({
|
||||
name: template.titleWithDefault,
|
||||
analyticsName: "New document",
|
||||
@@ -66,7 +66,7 @@ const useTemplatesAction = () => {
|
||||
},
|
||||
})
|
||||
),
|
||||
[documents.templatesAlphabetical]
|
||||
[templates.alphabetical]
|
||||
);
|
||||
|
||||
const newFromTemplate = useMemo(
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { HomeIcon } from "outline-icons";
|
||||
import {
|
||||
CollectionIcon as CollectionIconComponent,
|
||||
HomeIcon,
|
||||
PrivateCollectionIcon,
|
||||
} from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import { getLuminance } from "polished";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import type { Option } from "~/components/InputSelect";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -12,74 +19,112 @@ type DefaultCollectionInputSelectProps = {
|
||||
defaultCollectionId: string | null;
|
||||
};
|
||||
|
||||
const DefaultCollectionInputSelect = ({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [fetchError, setFetchError] = useState();
|
||||
const DefaultCollectionInputSelect = observer(
|
||||
({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections, ui } = useStores();
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [fetchError, setFetchError] = useState();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!collections.isLoaded && !fetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
await collections.fetchPage({
|
||||
limit: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("Collections could not be loaded, please reload the app")
|
||||
);
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
React.useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!collections.isLoaded && !fetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
await collections.fetchPage({
|
||||
limit: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("Collections could not be loaded, please reload the app")
|
||||
);
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
void fetchData();
|
||||
}, [fetchError, t, fetching, collections]);
|
||||
void fetchData();
|
||||
}, [fetchError, t, fetching, collections]);
|
||||
|
||||
const options: Option[] = React.useMemo(
|
||||
() =>
|
||||
collections.nonPrivate.reduce(
|
||||
(acc, collection) => [
|
||||
if (fetching) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDark = ui.resolvedTheme === "dark";
|
||||
|
||||
// Eagerly resolve collection icon properties within this observer context
|
||||
// to avoid MobX warnings when Radix Select clones elements for the trigger.
|
||||
const options: Option[] = collections.nonPrivate.reduce(
|
||||
(acc, collection) => {
|
||||
const collectionIcon = collection.icon;
|
||||
const rawColor = collection.color ?? colorPalette[0];
|
||||
|
||||
let icon: React.ReactElement;
|
||||
if (!collectionIcon || collectionIcon === "collection") {
|
||||
const color =
|
||||
isDark && rawColor !== "currentColor"
|
||||
? getLuminance(rawColor) > 0.09
|
||||
? rawColor
|
||||
: "currentColor"
|
||||
: rawColor;
|
||||
const Component = collection.isPrivate
|
||||
? PrivateCollectionIcon
|
||||
: CollectionIconComponent;
|
||||
icon = <Component color={color} />;
|
||||
} else {
|
||||
let color = rawColor;
|
||||
if (color !== "currentColor") {
|
||||
if (isDark) {
|
||||
color = getLuminance(color) > 0.09 ? color : "currentColor";
|
||||
} else {
|
||||
color = getLuminance(color) < 0.9 ? color : "currentColor";
|
||||
}
|
||||
}
|
||||
icon = (
|
||||
<Icon
|
||||
value={collectionIcon}
|
||||
color={color}
|
||||
initial={collection.initial}
|
||||
forceColor
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
type: "item",
|
||||
type: "item" as const,
|
||||
label: collection.name,
|
||||
value: collection.id,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
icon,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "item",
|
||||
label: t("Home"),
|
||||
value: "home",
|
||||
icon: <HomeIcon />,
|
||||
},
|
||||
] satisfies Option[]
|
||||
),
|
||||
[collections.nonPrivate, t]
|
||||
);
|
||||
];
|
||||
},
|
||||
[
|
||||
{
|
||||
type: "item",
|
||||
label: t("Home"),
|
||||
value: "home",
|
||||
icon: <HomeIcon />,
|
||||
},
|
||||
] satisfies Option[]
|
||||
);
|
||||
|
||||
if (fetching) {
|
||||
return null;
|
||||
return (
|
||||
<InputSelect
|
||||
options={options}
|
||||
value={defaultCollectionId ?? "home"}
|
||||
onChange={onSelectCollection}
|
||||
label={t("Start view")}
|
||||
hideLabel
|
||||
short
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
options={options}
|
||||
value={defaultCollectionId ?? "home"}
|
||||
onChange={onSelectCollection}
|
||||
label={t("Start view")}
|
||||
hideLabel
|
||||
short
|
||||
/>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
export default DefaultCollectionInputSelect;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
|
||||
import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
@@ -11,7 +11,7 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
|
||||
import { archivePath, trashPath } from "~/utils/routeHelpers";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
|
||||
@@ -67,13 +67,6 @@ function DocumentBreadcrumb(
|
||||
visible: document.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkAction({
|
||||
name: t("Templates"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: document.template,
|
||||
to: settingsPath("templates"),
|
||||
}),
|
||||
createInternalLinkAction({
|
||||
name: collection?.name,
|
||||
section: ActiveDocumentSection,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import styled from "styled-components";
|
||||
import Flex from "../Flex";
|
||||
|
||||
export const FlexContainer = styled(Flex)`
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
margin-bottom: -24px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
export const Footer = styled(Flex)`
|
||||
height: 64px;
|
||||
border-top: 1px solid ${(props) => props.theme.horizontalRule};
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
+31
-38
@@ -5,13 +5,13 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import type Document from "~/models/Document";
|
||||
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
|
||||
import Button from "~/components/Button";
|
||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Switch from "./Switch";
|
||||
import Text from "./Text";
|
||||
import { FlexContainer, Footer } from "./Components";
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
type Props = {
|
||||
/** The original document to duplicate */
|
||||
@@ -37,13 +37,8 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
: true
|
||||
);
|
||||
|
||||
if (document.isTemplate) {
|
||||
return nodes
|
||||
.filter((node) => node.type === "collection")
|
||||
.map((node) => ({ ...node, children: [] }));
|
||||
}
|
||||
return nodes;
|
||||
}, [policies, collectionTrees, document.isTemplate]);
|
||||
}, [policies, collectionTrees]);
|
||||
|
||||
const copy = async () => {
|
||||
if (!selectedPath) {
|
||||
@@ -80,34 +75,32 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
onSelect={selectPath}
|
||||
defaultValue={document.parentDocumentId || document.collectionId || ""}
|
||||
/>
|
||||
{!document.isTemplate && (
|
||||
<OptionsContainer>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={setPublish}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={setRecursive}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</OptionsContainer>
|
||||
)}
|
||||
<OptionsContainer>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={setPublish}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={setRecursive}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</OptionsContainer>
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<StyledText type="secondary">
|
||||
<Text ellipsis type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Copy to <em>{{ location }}</em>"
|
||||
@@ -117,7 +110,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
) : (
|
||||
t("Select a location to copy")
|
||||
)}
|
||||
</StyledText>
|
||||
</Text>
|
||||
<Button disabled={!selectedPath || copying} onClick={copy}>
|
||||
{copying ? `${t("Copying")}…` : t("Copy")}
|
||||
</Button>
|
||||
+19
-6
@@ -19,8 +19,8 @@ import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import DocumentExplorerNode from "./DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { Outline } from "~/components/Input";
|
||||
@@ -38,9 +38,17 @@ type Props = {
|
||||
items: NavigationNode[];
|
||||
/** Automatically expand to and select item with the given id */
|
||||
defaultValue?: string;
|
||||
/** Whether to show child documents */
|
||||
showDocuments?: boolean;
|
||||
};
|
||||
|
||||
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
function DocumentExplorer({
|
||||
onSubmit,
|
||||
onSelect,
|
||||
items,
|
||||
defaultValue,
|
||||
showDocuments,
|
||||
}: Props) {
|
||||
const isMobile = useMobile();
|
||||
const { collections, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
@@ -141,7 +149,8 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
);
|
||||
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
|
||||
const normalizedBaseDepth =
|
||||
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
@@ -216,7 +225,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
};
|
||||
|
||||
const hasChildren = (node: number) =>
|
||||
nodes[node].children.length > 0 || nodes[node].type === "collection";
|
||||
nodes[node].children.length > 0 || showDocuments !== false;
|
||||
|
||||
const toggleCollapse = (node: number) => {
|
||||
if (!hasChildren(node)) {
|
||||
@@ -402,7 +411,11 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
<ListSearch
|
||||
ref={inputSearchRef}
|
||||
onChange={handleSearch}
|
||||
placeholder={`${t("Search collections & documents")}…`}
|
||||
placeholder={
|
||||
showDocuments
|
||||
? `${t("Search collections & documents")}…`
|
||||
: `${t("Search collections")}…`
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
<ListContainer>
|
||||
+1
@@ -54,6 +54,7 @@ function DocumentExplorerNode(
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
>
|
||||
<Spacer width={width}>
|
||||
{hasChildren && (
|
||||
+2
-1
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
|
||||
import { Node as SearchResult } from "./DocumentExplorerNode";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
@@ -54,6 +54,7 @@ function DocumentExplorerSearchResult({
|
||||
style={style}
|
||||
onPointerMove={onPointerMove}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
>
|
||||
{icon}
|
||||
<Flex>
|
||||
@@ -2,16 +2,14 @@ import { observer } from "mobx-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import type Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { FlexContainer, Footer } from "./Components";
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -44,21 +42,8 @@ function DocumentMove({ document }: Props) {
|
||||
: true
|
||||
);
|
||||
|
||||
// If the document we're moving is a template, only show collections as
|
||||
// move targets.
|
||||
if (document.isTemplate) {
|
||||
return nodes
|
||||
.filter((node) => node.type === "collection")
|
||||
.map((node) => ({ ...node, children: [] }));
|
||||
}
|
||||
return nodes;
|
||||
}, [
|
||||
policies,
|
||||
collectionTrees,
|
||||
document.id,
|
||||
document.parentDocumentId,
|
||||
document.isTemplate,
|
||||
]);
|
||||
}, [policies, collectionTrees, document.id, document.parentDocumentId]);
|
||||
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
@@ -92,7 +77,7 @@ function DocumentMove({ document }: Props) {
|
||||
<FlexContainer column>
|
||||
<DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<StyledText type="secondary">
|
||||
<Text ellipsis type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Move to <em>{{ location }}</em>"
|
||||
@@ -106,7 +91,7 @@ function DocumentMove({ document }: Props) {
|
||||
) : (
|
||||
t("Select a location to move")
|
||||
)}
|
||||
</StyledText>
|
||||
</Text>
|
||||
<Button disabled={!selectedPath || moving} onClick={move}>
|
||||
{moving ? `${t("Moving")}…` : t("Move")}
|
||||
</Button>
|
||||
@@ -115,23 +100,4 @@ function DocumentMove({ document }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export const FlexContainer = styled(Flex)`
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
margin-bottom: -24px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
export const Footer = styled(Flex)`
|
||||
height: 64px;
|
||||
border-top: 1px solid ${(props) => props.theme.horizontalRule};
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
`;
|
||||
|
||||
export const StyledText = styled(Text)`
|
||||
${ellipsis()}
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
export default observer(DocumentMove);
|
||||
@@ -0,0 +1,87 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import type Template from "~/models/Template";
|
||||
import Button from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { FlexContainer, Footer } from "./Components";
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
type Props = {
|
||||
template: Template;
|
||||
};
|
||||
|
||||
function TemplateMove({ template }: Props) {
|
||||
const { dialogs, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const collectionTrees = useCollectionTrees();
|
||||
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
collectionTrees
|
||||
.map((node) => ({ ...node, children: [] }))
|
||||
.filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
: true
|
||||
),
|
||||
[policies, collectionTrees]
|
||||
);
|
||||
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
toast.message(t("Select a location to move"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const collectionId = (selectedPath.collectionId ??
|
||||
selectedPath.id) as string;
|
||||
await template.save({ collectionId });
|
||||
|
||||
toast.success(t("Template moved"));
|
||||
|
||||
dialogs.closeAllModals();
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn’t move the template, try again?"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FlexContainer column>
|
||||
<DocumentExplorer
|
||||
items={items}
|
||||
onSubmit={move}
|
||||
onSelect={selectPath}
|
||||
showDocuments={false}
|
||||
/>
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<Text ellipsis type="secondary">
|
||||
{selectedPath ? (
|
||||
<Trans
|
||||
defaults="Move to <em>{{ location }}</em>"
|
||||
values={{
|
||||
location: selectedPath.title,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t("Select a location to move")
|
||||
)}
|
||||
</Text>
|
||||
<Button disabled={!selectedPath} onClick={move}>
|
||||
{t("Move")}
|
||||
</Button>
|
||||
</Footer>
|
||||
</FlexContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(TemplateMove);
|
||||
@@ -0,0 +1,3 @@
|
||||
import DocumentExplorer from "./DocumentExplorer";
|
||||
|
||||
export default DocumentExplorer;
|
||||
@@ -39,7 +39,6 @@ type Props = {
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
|
||||
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
@@ -75,7 +74,6 @@ function DocumentListItem(
|
||||
showCollection,
|
||||
showPublished,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
context,
|
||||
...rest
|
||||
@@ -83,7 +81,7 @@ function DocumentListItem(
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
const canStar = !document.isArchived;
|
||||
|
||||
const isShared = !!(
|
||||
userMemberships.getByDocumentId(document.id) ||
|
||||
@@ -101,11 +99,10 @@ function DocumentListItem(
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId
|
||||
? document.collectionId
|
||||
: undefined,
|
||||
activeModels: [
|
||||
document,
|
||||
...(!isShared && document.collection ? [document.collection] : []),
|
||||
],
|
||||
}}
|
||||
>
|
||||
<ContextMenu
|
||||
@@ -163,9 +160,6 @@ function DocumentListItem(
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && <StarButton document={document} />}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
|
||||
@@ -52,7 +52,6 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
isDraft,
|
||||
lastViewedAt,
|
||||
isTasks,
|
||||
isTemplate,
|
||||
} = document;
|
||||
|
||||
// Prevent meta information from displaying if updatedBy is not available.
|
||||
@@ -142,7 +141,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getChildrenForDocument(document.id).length
|
||||
: 0;
|
||||
const canShowProgressBar = isTasks && !isTemplate;
|
||||
const canShowProgressBar = isTasks;
|
||||
|
||||
const timeSinceNow = () => {
|
||||
if (isDraft || !showLastViewed) {
|
||||
@@ -170,7 +169,7 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
<Container align="center" $rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
{to ? (
|
||||
<Link to={to} replace={replace}>
|
||||
{content}
|
||||
@@ -219,8 +218,8 @@ const Strong = styled.strong`
|
||||
font-weight: 550;
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
const Container = styled(Flex)<{ $rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.$rtl ? "flex-end" : "flex-start")};
|
||||
color: ${s("textTertiary")};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -266,7 +266,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
<>
|
||||
{paragraphs ? (
|
||||
<EditorContainer
|
||||
rtl={props.dir === "rtl"}
|
||||
$rtl={props.dir === "rtl"}
|
||||
grow={props.grow}
|
||||
style={props.style}
|
||||
editorStyle={props.editorStyle}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Editor from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
@@ -28,9 +30,11 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
|
||||
const authorName = author.name;
|
||||
const urlObj = new URL(url);
|
||||
const service =
|
||||
urlObj.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.Linear;
|
||||
urlObj.hostname === "linear.app"
|
||||
? IntegrationService.Linear
|
||||
: urlObj.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.GitLab;
|
||||
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -58,7 +62,18 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
|
||||
</Trans>
|
||||
</Info>
|
||||
</Flex>
|
||||
<Description>{description}</Description>
|
||||
{description && (
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
extensions={richExtensions}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
)}
|
||||
|
||||
<Flex wrap>
|
||||
{labels.map((label, index) => (
|
||||
|
||||
@@ -3,8 +3,10 @@ import { Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Editor from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
@@ -48,7 +50,18 @@ const HoverPreviewPullRequest = React.forwardRef(
|
||||
</Trans>
|
||||
</Info>
|
||||
</Flex>
|
||||
<Description>{description}</Description>
|
||||
{description && (
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
extensions={richExtensions}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
)}
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -95,7 +95,7 @@ const IconWrapper = styled.span`
|
||||
export const Outline = styled(Flex)<{
|
||||
margin?: string | number;
|
||||
hasError?: boolean;
|
||||
focused?: boolean;
|
||||
$focused?: boolean;
|
||||
}>`
|
||||
flex: 1;
|
||||
margin: ${(props) =>
|
||||
@@ -106,7 +106,7 @@ export const Outline = styled(Flex)<{
|
||||
border-color: ${(props) =>
|
||||
props.hasError
|
||||
? props.theme.danger
|
||||
: props.focused
|
||||
: props.$focused
|
||||
? props.theme.inputBorderFocused
|
||||
: props.theme.inputBorder};
|
||||
border-radius: 4px;
|
||||
@@ -224,7 +224,7 @@ function Input(
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline focused={focused} margin={margin}>
|
||||
<Outline $focused={focused} margin={margin}>
|
||||
{prefix}
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
{type === "textarea" ? (
|
||||
|
||||
@@ -42,6 +42,7 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
case "button":
|
||||
return (
|
||||
<MenuButton
|
||||
id={item.id}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -94,11 +95,13 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
return (
|
||||
<SubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<SubMenuTrigger
|
||||
id={item.id}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
<SubMenuContent
|
||||
id={item.id}
|
||||
ref={parentRef}
|
||||
onFocusOutside={preventCloseHandler}
|
||||
>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { fadeAndScaleIn, fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -93,9 +94,11 @@ const Modal: React.FC<Props> = ({
|
||||
</DesktopContent>
|
||||
<Header>
|
||||
{title && <Text size="large">{title}</Text>}
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
<Tooltip content={t("Close")} shortcut="Esc">
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Header>
|
||||
</Centered>
|
||||
</Wrapper>
|
||||
|
||||
@@ -39,7 +39,7 @@ const Container = styled(Text)`
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
margin: 1em 0 0;
|
||||
margin: 1em 0;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { NotificationBadgeType, UserPreference } from "@shared/types";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
/**
|
||||
* Component that keeps the app icon notification badge in sync with unread
|
||||
* notification count. Renders nothing visible — mount near the app root so it
|
||||
* stays alive as long as the user is authenticated.
|
||||
*/
|
||||
function NotificationBadge() {
|
||||
const { notifications } = useStores();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const badgeType = user.getPreference(UserPreference.NotificationBadge);
|
||||
const unreadCount = notifications.approximateUnreadCount;
|
||||
|
||||
React.useEffect(() => {
|
||||
// Desktop app badge
|
||||
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
|
||||
if (badgeType === NotificationBadgeType.Disabled || unreadCount === 0) {
|
||||
void Desktop.bridge.setNotificationCount(0);
|
||||
} else if (badgeType === NotificationBadgeType.Count) {
|
||||
void Desktop.bridge.setNotificationCount(unreadCount);
|
||||
} else {
|
||||
void Desktop.bridge.setNotificationCount("・");
|
||||
}
|
||||
}
|
||||
|
||||
// PWA badge
|
||||
if ("setAppBadge" in navigator) {
|
||||
if (unreadCount > 0 && badgeType !== NotificationBadgeType.Disabled) {
|
||||
void navigator.setAppBadge(
|
||||
badgeType === NotificationBadgeType.Count ? unreadCount : undefined
|
||||
);
|
||||
} else {
|
||||
void navigator.clearAppBadge();
|
||||
}
|
||||
}
|
||||
}, [unreadCount, badgeType]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default observer(NotificationBadge);
|
||||
@@ -8,7 +8,6 @@ import Notification, { type NotificationFilter } from "~/models/Notification";
|
||||
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NotificationMenu from "~/menus/NotificationMenu";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Empty from "../Empty";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
import Flex from "../Flex";
|
||||
@@ -61,25 +60,7 @@ function Notifications(
|
||||
);
|
||||
}, [notifications.active, filter]);
|
||||
|
||||
// Update the notification count in the dock icon, if possible.
|
||||
React.useEffect(() => {
|
||||
// Account for old versions of the desktop app that don't have the
|
||||
// setNotificationCount method on the bridge.
|
||||
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
|
||||
void Desktop.bridge.setNotificationCount(
|
||||
notifications.approximateUnreadCount
|
||||
);
|
||||
}
|
||||
|
||||
// PWA badging
|
||||
if ("setAppBadge" in navigator) {
|
||||
if (notifications.approximateUnreadCount) {
|
||||
void navigator.setAppBadge(notifications.approximateUnreadCount);
|
||||
} else {
|
||||
void navigator.clearAppBadge();
|
||||
}
|
||||
}
|
||||
}, [notifications.approximateUnreadCount]);
|
||||
const unreadCount = notifications.approximateUnreadCount;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
@@ -105,7 +86,7 @@ function Notifications(
|
||||
short
|
||||
nude
|
||||
/>
|
||||
{notifications.approximateUnreadCount > 0 && (
|
||||
{unreadCount > 0 && (
|
||||
<Tooltip content={t("Mark all as read")}>
|
||||
<Button
|
||||
action={markNotificationsAsRead}
|
||||
|
||||
@@ -49,7 +49,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
showParentDocuments={showParentDocuments}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showTemplate={showTemplate}
|
||||
showDraft={showDraft}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -125,7 +125,7 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeDocumentId: document.id }}>
|
||||
<ActionContextProvider value={{ activeModels: [document] }}>
|
||||
<ContextMenu
|
||||
action={contextMenuAction}
|
||||
ariaLabel={t("Revision options")}
|
||||
|
||||
@@ -212,7 +212,9 @@ export const Suggestions = observer(
|
||||
/>
|
||||
)),
|
||||
pending.length > 0 &&
|
||||
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />,
|
||||
(suggestionsWithPending.length > 0 || isEmpty) && (
|
||||
<Separator key="separator" />
|
||||
),
|
||||
...suggestionsWithPending.map((suggestion) => (
|
||||
<ListItem
|
||||
keyboardNavigation
|
||||
@@ -230,7 +232,9 @@ export const Suggestions = observer(
|
||||
/>
|
||||
)),
|
||||
isEmpty && (
|
||||
<Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>
|
||||
<Empty key="empty" style={{ marginTop: 22 }}>
|
||||
{t("No matches")}
|
||||
</Empty>
|
||||
),
|
||||
]}
|
||||
</ArrowKeyNavigation>
|
||||
|
||||
@@ -15,6 +15,7 @@ import EditableTitle from "~/components/EditableTitle";
|
||||
import Fade from "~/components/Fade";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -122,10 +123,11 @@ const CollectionLink: React.FC<Props> = ({
|
||||
|
||||
const contextMenuAction = useCollectionMenuAction({
|
||||
collectionId: collection.id,
|
||||
onRename: handleRename,
|
||||
});
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
|
||||
<ActionContextProvider value={{ activeModels: [collection] }}>
|
||||
<Relative ref={mergeRefs([parentRef, dropRef])}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
@@ -141,7 +143,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
icon={
|
||||
<CollectionIcon collection={collection} expanded={expanded} />
|
||||
}
|
||||
showActions={menuOpen}
|
||||
$showActions={menuOpen}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
isActive={(
|
||||
match,
|
||||
@@ -165,17 +167,18 @@ const CollectionLink: React.FC<Props> = ({
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
{can.createDocument && (
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
handleExpand();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
<Tooltip content={t("New doc")} delay={500}>
|
||||
<NudeButton
|
||||
aria-label={t("New nested document")}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
handleExpand();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
|
||||
@@ -40,6 +40,10 @@ import type UserMembership from "~/models/UserMembership";
|
||||
import type GroupMembership from "~/models/GroupMembership";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosure,
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
|
||||
type Props = {
|
||||
node: NavigationNode;
|
||||
@@ -119,6 +123,13 @@ function InnerDocumentLink(
|
||||
|
||||
const [expanded, setExpanded, setCollapsed] = useBoolean(showChildren);
|
||||
|
||||
// Context-based recursive expand/collapse for descendant DocumentLinks
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
// Subscribe to recursive expand/collapse events from an ancestor
|
||||
useSidebarDisclosure(setExpanded, setCollapsed);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showChildren) {
|
||||
setExpanded();
|
||||
@@ -132,13 +143,18 @@ function InnerDocumentLink(
|
||||
}
|
||||
}, [setCollapsed, expanded, hasChildDocuments]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback(() => {
|
||||
if (expanded) {
|
||||
setCollapsed();
|
||||
} else {
|
||||
setExpanded();
|
||||
}
|
||||
}, [setCollapsed, setExpanded, expanded]);
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev: React.MouseEvent<HTMLElement>) => {
|
||||
const willExpand = !expanded;
|
||||
if (willExpand) {
|
||||
setExpanded();
|
||||
} else {
|
||||
setCollapsed();
|
||||
}
|
||||
onDisclosureClick(willExpand, ev.altKey);
|
||||
},
|
||||
[setCollapsed, setExpanded, expanded, onDisclosureClick]
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void prefetchDocument?.(node.id);
|
||||
@@ -336,7 +352,10 @@ function InnerDocumentLink(
|
||||
]
|
||||
);
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: node.id });
|
||||
const contextMenuAction = useDocumentMenuAction({
|
||||
documentId: node.id,
|
||||
onRename: handleRename,
|
||||
});
|
||||
|
||||
const labelElement = React.useMemo(
|
||||
() => (
|
||||
@@ -397,7 +416,7 @@ function InnerDocumentLink(
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: node.id,
|
||||
activeModels: document ? [document] : [],
|
||||
}}
|
||||
>
|
||||
<Relative ref={parentRef}>
|
||||
@@ -432,7 +451,7 @@ function InnerDocumentLink(
|
||||
isActiveDrop={isOverReparent && canDropToReparent}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
$showActions={menuOpen}
|
||||
scrollIntoViewIfNeeded={sidebarContext === "collections"}
|
||||
isDraft={isDraft}
|
||||
ref={ref}
|
||||
@@ -464,22 +483,24 @@ function InnerDocumentLink(
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={childIndex}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Folder expanded={expanded && !isDragging}>
|
||||
{nodeChildren.map((childNode, childIndex) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={childIndex}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import useStores from "~/hooks/useStores";
|
||||
import type { DragObject } from "../hooks/useDragAndDrop";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import Relative from "./Relative";
|
||||
import { useSidebarContext } from "./SidebarContext";
|
||||
|
||||
@@ -36,6 +39,10 @@ function DraggableCollectionLink({
|
||||
);
|
||||
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
|
||||
|
||||
// Context-based recursive expand/collapse for descendant DocumentLinks
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
// Drop to reorder collection
|
||||
const [
|
||||
{ isCollectionDropping, isDraggingAnyCollection },
|
||||
@@ -91,15 +98,22 @@ function DraggableCollectionLink({
|
||||
locationSidebarContext,
|
||||
]);
|
||||
|
||||
const handleDisclosureClick = useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
const handleDisclosureClick = useCallback(
|
||||
(ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => {
|
||||
const willExpand = !e;
|
||||
onDisclosureClick(willExpand, !!ev?.altKey);
|
||||
return willExpand;
|
||||
});
|
||||
},
|
||||
[onDisclosureClick]
|
||||
);
|
||||
|
||||
const displayChildDocuments = expanded && !isDragging;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Draggable
|
||||
key={collection.id}
|
||||
ref={dragToReorderCollection}
|
||||
@@ -121,7 +135,7 @@ function DraggableCollectionLink({
|
||||
/>
|
||||
)}
|
||||
</Relative>
|
||||
</>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
@@ -21,10 +24,20 @@ const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
locationSidebarContext === sidebarContext
|
||||
);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
const handleDisclosureClick = React.useCallback(
|
||||
(ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => {
|
||||
const willExpand = !e;
|
||||
onDisclosureClick(willExpand, !!ev?.altKey);
|
||||
return willExpand;
|
||||
});
|
||||
},
|
||||
[onDisclosureClick]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (locationSidebarContext === sidebarContext) {
|
||||
@@ -42,15 +55,17 @@ const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
depth={0}
|
||||
/>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Folder expanded={expanded}>
|
||||
{group.documentMemberships.map((membership) => (
|
||||
<SharedWithMeLink
|
||||
key={membership.id}
|
||||
membership={membership}
|
||||
depth={1}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Folder expanded={expanded}>
|
||||
{group.documentMemberships.map((membership) => (
|
||||
<SharedWithMeLink
|
||||
key={membership.id}
|
||||
membership={membership}
|
||||
depth={1}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
</SidebarContext.Provider>
|
||||
</Relative>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,10 @@ import type Document from "~/models/Document";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { sharedModelPath } from "~/utils/routeHelpers";
|
||||
import { descendants } from "@shared/utils/tree";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosure,
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
@@ -62,6 +66,14 @@ function DocumentLink(
|
||||
|
||||
const [expanded, setExpanded] = React.useState(showChildren);
|
||||
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
const handleExpand = React.useCallback(() => setExpanded(true), []);
|
||||
const handleCollapse = React.useCallback(() => setExpanded(false), []);
|
||||
|
||||
useSidebarDisclosure(handleExpand, handleCollapse);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showChildren) {
|
||||
setExpanded(showChildren);
|
||||
@@ -72,9 +84,12 @@ function DocumentLink(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
const willExpand = !expanded;
|
||||
setExpanded(willExpand);
|
||||
const altKey = "altKey" in ev && (ev as React.MouseEvent).altKey;
|
||||
onDisclosureClick(willExpand, !!altKey);
|
||||
},
|
||||
[expanded]
|
||||
[expanded, onDisclosureClick]
|
||||
);
|
||||
|
||||
// since we don't have access to the collection sort here, we just put any
|
||||
@@ -133,22 +148,24 @@ function DocumentLink(
|
||||
ref={ref}
|
||||
isActive={() => !!isActiveDocument}
|
||||
/>
|
||||
{expanded &&
|
||||
nodeChildren.map((childNode, index) => (
|
||||
<SharedDocumentLink
|
||||
shareId={shareId}
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
activeDocumentId={activeDocumentId}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
{expanded &&
|
||||
nodeChildren.map((childNode, index) => (
|
||||
<SharedDocumentLink
|
||||
shareId={shareId}
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
activeDocumentId={activeDocumentId}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</SidebarDisclosureContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosure,
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import { useSidebarContext, type SidebarContextType } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
@@ -48,6 +52,12 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
isActiveDocumentInPath && locationSidebarContext === sidebarContext
|
||||
);
|
||||
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
// Subscribe to recursive expand/collapse events from an ancestor (e.g. GroupLink)
|
||||
useSidebarDisclosure(setExpanded, setCollapsed);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActiveDocumentInPath && locationSidebarContext === sidebarContext) {
|
||||
setExpanded();
|
||||
@@ -76,13 +86,15 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (expanded) {
|
||||
setCollapsed();
|
||||
} else {
|
||||
const willExpand = !expanded;
|
||||
if (willExpand) {
|
||||
setExpanded();
|
||||
} else {
|
||||
setCollapsed();
|
||||
}
|
||||
onDisclosureClick(willExpand, ev.altKey);
|
||||
},
|
||||
[expanded, setExpanded, setCollapsed]
|
||||
[expanded, setExpanded, setCollapsed, onDisclosureClick]
|
||||
);
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -158,7 +170,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
NotificationEventType.AddUserToDocument
|
||||
).length > 0
|
||||
}
|
||||
showActions={menuOpen}
|
||||
$showActions={menuOpen}
|
||||
menu={
|
||||
document && !isDragging ? (
|
||||
<Fade>
|
||||
@@ -174,20 +186,22 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
</div>
|
||||
</Draggable>
|
||||
</Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((childNode, index) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
activeDocument={documents.active}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((childNode, index) => (
|
||||
<DocumentLink
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
collection={collection}
|
||||
membership={membership}
|
||||
activeDocument={documents.active}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
{reorderProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderProps.isOverCursor}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { extraArea, hover, s } from "@shared/styles";
|
||||
@@ -18,44 +19,46 @@ export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
|
||||
function SidebarButton_(
|
||||
{
|
||||
position = "top",
|
||||
showMoreMenu,
|
||||
image,
|
||||
title,
|
||||
children,
|
||||
onClick,
|
||||
...rest
|
||||
}: SidebarButtonProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<Container
|
||||
justify="space-between"
|
||||
align="center"
|
||||
shrink={false}
|
||||
$position={position}
|
||||
>
|
||||
<Button
|
||||
{...rest}
|
||||
onClick={onClick}
|
||||
const SidebarButton = observer(
|
||||
React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
|
||||
function SidebarButton_(
|
||||
{
|
||||
position = "top",
|
||||
showMoreMenu,
|
||||
image,
|
||||
title,
|
||||
children,
|
||||
onClick,
|
||||
...rest
|
||||
}: SidebarButtonProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<Container
|
||||
justify="space-between"
|
||||
align="center"
|
||||
shrink={false}
|
||||
$position={position}
|
||||
as="button"
|
||||
ref={ref}
|
||||
role="button"
|
||||
>
|
||||
<Content>
|
||||
{image}
|
||||
{title && <Title>{title}</Title>}
|
||||
</Content>
|
||||
{showMoreMenu && <StyledMoreIcon />}
|
||||
</Button>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
<Button
|
||||
{...rest}
|
||||
onClick={onClick}
|
||||
$position={position}
|
||||
as="button"
|
||||
ref={ref}
|
||||
role="button"
|
||||
>
|
||||
<Content>
|
||||
{image}
|
||||
{title && <Title>{title}</Title>}
|
||||
</Content>
|
||||
{showMoreMenu && <StyledMoreIcon />}
|
||||
</Button>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const StyledMoreIcon = styled(MoreIcon)`
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* Represents a recursive expand/collapse event broadcast through context.
|
||||
*/
|
||||
export interface SidebarDisclosureEvent {
|
||||
/** Whether descendants should expand or collapse. */
|
||||
action: "expand" | "collapse";
|
||||
/**
|
||||
* Monotonically increasing counter used to detect new events.
|
||||
* Each increment represents a distinct user interaction.
|
||||
*/
|
||||
generation: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for broadcasting recursive expand/collapse events from a parent
|
||||
* (e.g. a collection or document disclosure toggle with alt-click) to all
|
||||
* descendant DocumentLinks in the sidebar tree.
|
||||
*
|
||||
* The nearest provider determines the scope — only descendants within that
|
||||
* provider react to the event. Each DocumentLink should both consume and
|
||||
* provide this context so that alt-click at any level only affects its subtree.
|
||||
*/
|
||||
const SidebarDisclosureContext = createContext<SidebarDisclosureEvent | null>(
|
||||
null
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook that subscribes to recursive expand/collapse events from an ancestor
|
||||
* provider. When a new event is detected, the appropriate callback is invoked.
|
||||
*
|
||||
* Newly mounted components will also react to the current event, which enables
|
||||
* cascading: expanding a parent reveals children, which mount and see the
|
||||
* expand event, then expand themselves to reveal grandchildren, and so on.
|
||||
*
|
||||
* @param onExpand - called when a recursive expand event is received.
|
||||
* @param onCollapse - called when a recursive collapse event is received.
|
||||
*/
|
||||
export function useSidebarDisclosure(
|
||||
onExpand: () => void,
|
||||
onCollapse: () => void
|
||||
): void {
|
||||
const event = useContext(SidebarDisclosureContext);
|
||||
const lastHandledGeneration = useRef(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!event || event.generation === lastHandledGeneration.current) {
|
||||
return;
|
||||
}
|
||||
lastHandledGeneration.current = event.generation;
|
||||
|
||||
if (event.action === "expand") {
|
||||
onExpand();
|
||||
} else {
|
||||
onCollapse();
|
||||
}
|
||||
}, [event, onExpand, onCollapse]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the producing side of the disclosure context. Returns the current
|
||||
* event value (to pass to a Provider) and a single callback to handle
|
||||
* alt-click expand/collapse broadcasts.
|
||||
*
|
||||
* This hook also reads the parent context and automatically forwards any
|
||||
* incoming disclosure events so that the cascade propagates through the
|
||||
* entire tree — even when intermediate nodes each create their own provider.
|
||||
*
|
||||
* @returns object with `event` to spread onto the Provider's value and
|
||||
* `onDisclosureClick` to call from disclosure click handlers.
|
||||
*/
|
||||
export function useSidebarDisclosureState() {
|
||||
const parentEvent = useContext(SidebarDisclosureContext);
|
||||
const [event, setEvent] = useState<SidebarDisclosureEvent | null>(null);
|
||||
const lastForwardedParentGeneration = useRef(-1);
|
||||
|
||||
// Forward parent disclosure events into our own provider value so that
|
||||
// grandchildren (and beyond) see the event even though each level creates
|
||||
// its own independent provider.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!parentEvent ||
|
||||
parentEvent.generation === lastForwardedParentGeneration.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastForwardedParentGeneration.current = parentEvent.generation;
|
||||
setEvent((prev) => ({
|
||||
action: parentEvent.action,
|
||||
generation: (prev?.generation ?? 0) + 1,
|
||||
}));
|
||||
}, [parentEvent]);
|
||||
|
||||
/**
|
||||
* Call from a disclosure click handler after toggling expand/collapse state.
|
||||
* When alt is held, broadcasts a recursive expand or collapse event to all
|
||||
* descendants. Otherwise, clears any stale event.
|
||||
*
|
||||
* @param willExpand - whether the node is expanding or collapsing.
|
||||
* @param altKey - whether the alt/option key was held during the click.
|
||||
*/
|
||||
const onDisclosureClick = useCallback(
|
||||
(willExpand: boolean, altKey: boolean) => {
|
||||
if (altKey) {
|
||||
setEvent((prev) => ({
|
||||
action: willExpand ? "expand" : "collapse",
|
||||
generation: (prev?.generation ?? 0) + 1,
|
||||
}));
|
||||
} else {
|
||||
setEvent(null);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { event, onDisclosureClick };
|
||||
}
|
||||
|
||||
export default SidebarDisclosureContext;
|
||||
@@ -40,7 +40,7 @@ type Props = Omit<NavLinkProps, "to"> & {
|
||||
/** Whether to show an unread badge indicator */
|
||||
unreadBadge?: boolean;
|
||||
/** Whether to show action buttons on hover */
|
||||
showActions?: boolean;
|
||||
$showActions?: boolean;
|
||||
/** Whether the link is disabled and non-interactive */
|
||||
disabled?: boolean;
|
||||
/** Whether the link is currently active */
|
||||
@@ -81,7 +81,7 @@ function SidebarLink(
|
||||
isActiveDrop,
|
||||
isDraft,
|
||||
menu,
|
||||
showActions,
|
||||
$showActions,
|
||||
exact,
|
||||
href,
|
||||
depth,
|
||||
@@ -183,7 +183,7 @@ function SidebarLink(
|
||||
{unreadBadge && <UnreadBadge style={unreadStyle} />}
|
||||
</Content>
|
||||
</ContextMenu>
|
||||
{menu && <Actions showActions={showActions}>{menu}</Actions>}
|
||||
{menu && <Actions $showActions={$showActions}>{menu}</Actions>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -205,9 +205,9 @@ const Content = styled.span`
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
|
||||
const Actions = styled(EventBoundary)<{ $showActions?: boolean }>`
|
||||
display: inline-flex;
|
||||
visibility: ${(props) => (props.showActions ? "visible" : "hidden")};
|
||||
visibility: ${(props) => (props.$showActions ? "visible" : "hidden")};
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 4px;
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import SidebarDisclosureContext, {
|
||||
useSidebarDisclosureState,
|
||||
} from "./SidebarDisclosureContext";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
@@ -100,7 +103,7 @@ const StarredDocumentLink = observer(function StarredDocumentLink({
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document.id,
|
||||
activeModels: [document],
|
||||
}}
|
||||
>
|
||||
<Draggable key={star.id} ref={draggableRef} $isDragging={isDragging}>
|
||||
@@ -121,7 +124,7 @@ const StarredDocumentLink = observer(function StarredDocumentLink({
|
||||
) => !!match && location.state?.sidebarContext === sidebarContext}
|
||||
label={label}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
$showActions={menuOpen}
|
||||
menu={
|
||||
document && !isDragging ? (
|
||||
<Fade>
|
||||
@@ -204,6 +207,9 @@ function StarredLink({ star }: Props) {
|
||||
sidebarContext === locationSidebarContext
|
||||
);
|
||||
|
||||
const { event: disclosureEvent, onDisclosureClick } =
|
||||
useSidebarDisclosureState();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
star.documentId === ui.activeDocumentId &&
|
||||
@@ -235,9 +241,13 @@ function StarredLink({ star }: Props) {
|
||||
(ev?: React.MouseEvent<HTMLElement>) => {
|
||||
ev?.preventDefault();
|
||||
ev?.stopPropagation();
|
||||
setExpanded((prevExpanded) => !prevExpanded);
|
||||
setExpanded((prevExpanded) => {
|
||||
const willExpand = !prevExpanded;
|
||||
onDisclosureClick(willExpand, !!ev?.altKey);
|
||||
return willExpand;
|
||||
});
|
||||
},
|
||||
[]
|
||||
[onDisclosureClick]
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
@@ -284,39 +294,43 @@ function StarredLink({ star }: Props) {
|
||||
|
||||
if (documentId) {
|
||||
return (
|
||||
<StarredDocumentLink
|
||||
star={star}
|
||||
documentId={documentId}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
handlePrefetch={handlePrefetch}
|
||||
icon={icon}
|
||||
label={label}
|
||||
menuOpen={menuOpen}
|
||||
handleMenuOpen={handleMenuOpen}
|
||||
handleMenuClose={handleMenuClose}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
/>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<StarredDocumentLink
|
||||
star={star}
|
||||
documentId={documentId}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
handlePrefetch={handlePrefetch}
|
||||
icon={icon}
|
||||
label={label}
|
||||
menuOpen={menuOpen}
|
||||
handleMenuOpen={handleMenuOpen}
|
||||
handleMenuClose={handleMenuClose}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
/>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
return (
|
||||
<StarredCollectionLink
|
||||
star={star}
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
displayChildDocuments={displayChildDocuments}
|
||||
reorderStarProps={reorderStarProps}
|
||||
/>
|
||||
<SidebarDisclosureContext.Provider value={disclosureEvent}>
|
||||
<StarredCollectionLink
|
||||
star={star}
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
sidebarContext={sidebarContext}
|
||||
isDragging={isDragging}
|
||||
handleDisclosureClick={handleDisclosureClick}
|
||||
draggableRef={draggableRef}
|
||||
cursor={cursor}
|
||||
displayChildDocuments={displayChildDocuments}
|
||||
reorderStarProps={reorderStarProps}
|
||||
/>
|
||||
</SidebarDisclosureContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,8 +37,9 @@ function Star({ size, document, collection, color, ...rest }: Props) {
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document?.id,
|
||||
activeCollectionId: collection?.id,
|
||||
activeModels: [document, collection].filter(
|
||||
(m): m is Document | Collection => !!m
|
||||
),
|
||||
}}
|
||||
>
|
||||
<NudeButton
|
||||
|
||||
@@ -26,6 +26,7 @@ import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import { transparentize } from "polished";
|
||||
|
||||
const HEADER_HEIGHT = 40;
|
||||
|
||||
@@ -59,6 +60,7 @@ export type Props<TData> = {
|
||||
};
|
||||
rowHeight: number;
|
||||
stickyOffset?: number;
|
||||
decorateRow?: (item: TData, rowElement: React.ReactNode) => React.ReactNode;
|
||||
};
|
||||
|
||||
function Table<TData>({
|
||||
@@ -70,6 +72,7 @@ function Table<TData>({
|
||||
page,
|
||||
rowHeight,
|
||||
stickyOffset = 0,
|
||||
decorateRow,
|
||||
}: Props<TData>) {
|
||||
const { t } = useTranslation();
|
||||
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -206,7 +209,7 @@ function Table<TData>({
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = rows[virtualRow.index] as TRow<TData>;
|
||||
return (
|
||||
const baseRow = (
|
||||
<TR
|
||||
role="row"
|
||||
key={row.id}
|
||||
@@ -231,6 +234,14 @@ function Table<TData>({
|
||||
))}
|
||||
</TR>
|
||||
);
|
||||
|
||||
return decorateRow ? (
|
||||
<React.Fragment key={row.id}>
|
||||
{decorateRow(row.original, baseRow)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
baseRow
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
{showPlaceholder && (
|
||||
@@ -326,7 +337,8 @@ const THead = styled.div<{ $topPos: number }>`
|
||||
color: ${s("textSecondary")};
|
||||
font-weight: 500;
|
||||
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
border-bottom: 1px solid
|
||||
${(props) => transparentize(0.3, props.theme.divider)};
|
||||
background: ${s("background")};
|
||||
`;
|
||||
|
||||
@@ -340,12 +352,17 @@ const TR = styled.div<{ $columns: string }>`
|
||||
display: grid;
|
||||
grid-template-columns: ${({ $columns }) => `${$columns}`};
|
||||
align-items: center;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
border-bottom: 1px solid
|
||||
${(props) => transparentize(0.3, props.theme.divider)};
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover ${NudeButton}[aria-haspopup="menu"] {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const TH = styled.span`
|
||||
@@ -391,11 +408,17 @@ const TD = styled.span`
|
||||
|
||||
${NudeButton}[aria-haspopup="menu"] {
|
||||
vertical-align: middle;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
|
||||
&[aria-expanded="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { TemplateForm } from "./TemplateForm";
|
||||
import type Template from "~/models/Template";
|
||||
|
||||
type Props = {
|
||||
template: Template;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export const TemplateEdit = observer(function TemplateEdit_({
|
||||
template,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
await template?.save();
|
||||
onSubmit?.();
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
}, [template, onSubmit]);
|
||||
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { InputIcon, ShapesIcon } from "outline-icons";
|
||||
import React, { useRef } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import type Template from "~/models/Template";
|
||||
import Editor from "~/scenes/Document/components/Editor";
|
||||
import { DocumentContextProvider } from "~/components/DocumentContext";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Notice from "~/components/Notice";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
export const TemplateForm = observer(function TemplateForm_({
|
||||
handleSubmit,
|
||||
template,
|
||||
}: {
|
||||
handleSubmit: (template: Template) => void;
|
||||
template: Template;
|
||||
}) {
|
||||
const { dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(template);
|
||||
const dataRef = useRef(template.data);
|
||||
const ref = useRef(null);
|
||||
const [isUploading, handleStartUpload, handleStopUpload] = useBoolean();
|
||||
const readOnly = !can.update && !template.isNew;
|
||||
|
||||
const handleChangeTitle = (title: string) => {
|
||||
template.title = title;
|
||||
};
|
||||
|
||||
const handleChangeIcon = (icon: string, color: string) => {
|
||||
template.icon = icon;
|
||||
template.color = color;
|
||||
};
|
||||
|
||||
const handleChange = (value: (asString: boolean) => ProsemirrorData) => {
|
||||
dataRef.current = value(false);
|
||||
template.data = dataRef.current;
|
||||
};
|
||||
|
||||
const handleSave = (options: { autosave?: boolean }) => {
|
||||
if (options.autosave) {
|
||||
return;
|
||||
}
|
||||
handleSubmit(template);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
dialogs.closeAllModals();
|
||||
};
|
||||
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentContextProvider>
|
||||
<React.Suspense fallback={null}>
|
||||
{isUploading && <LoadingIndicator />}
|
||||
<Notice
|
||||
icon={<ShapesIcon />}
|
||||
description={
|
||||
<Trans>
|
||||
Highlight some text and use the <PlaceholderIcon /> control to add
|
||||
placeholders that can be filled out when creating new documents
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
{t("You’re editing a template")}
|
||||
</Notice>
|
||||
<Editor
|
||||
id={template.id}
|
||||
ref={ref}
|
||||
isDraft={false}
|
||||
document={template}
|
||||
value={readOnly ? template.data : undefined}
|
||||
defaultValue={template.data}
|
||||
onFileUploadStart={handleStartUpload}
|
||||
onFileUploadStop={handleStopUpload}
|
||||
onChangeTitle={handleChangeTitle}
|
||||
onChangeIcon={handleChangeIcon}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onChange={handleChange}
|
||||
readOnly={readOnly}
|
||||
canUpdate={can.update}
|
||||
autoFocus={template.createdAt === template.updatedAt}
|
||||
template
|
||||
/>
|
||||
</React.Suspense>
|
||||
</DocumentContextProvider>
|
||||
);
|
||||
});
|
||||
|
||||
const PlaceholderIcon = styled(InputIcon)`
|
||||
position: relative;
|
||||
top: 6px;
|
||||
margin-top: -6px;
|
||||
`;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import Template from "~/models/Template";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { TemplateForm } from "./TemplateForm";
|
||||
|
||||
type Props = {
|
||||
collectionId?: string | null;
|
||||
onSubmit?: () => void;
|
||||
};
|
||||
|
||||
export const TemplateNew = observer(function TemplateNew_({
|
||||
collectionId,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { templates } = useStores();
|
||||
const [template] = useState(
|
||||
new Template({ title: "", collectionId }, templates)
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
await template.save();
|
||||
onSubmit?.();
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
}, [template, onSubmit]);
|
||||
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Switch from "~/components/Switch";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import SelectLocation from "./SelectLocation";
|
||||
|
||||
type Props = {
|
||||
@@ -18,7 +17,7 @@ type Props = {
|
||||
function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const { documents, templates } = useStores();
|
||||
const document = documents.get(documentId);
|
||||
invariant(document, "Document must exist");
|
||||
|
||||
@@ -28,15 +27,17 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
|
||||
);
|
||||
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
const template = await document?.templatize({
|
||||
const template = await templates.templatize({
|
||||
id: documentId,
|
||||
collectionId,
|
||||
publish,
|
||||
});
|
||||
|
||||
if (template) {
|
||||
history.push(documentPath(template));
|
||||
history.push(template.path);
|
||||
toast.success(t("Template created, go ahead and customize it"));
|
||||
}
|
||||
}, [t, document, history, collectionId, publish]);
|
||||
}, [t, templates, documentId, history, collectionId, publish]);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
|
||||
@@ -23,18 +23,23 @@ const DrawerHandle = DrawerPrimitive.Handle;
|
||||
/** Drawer's content - renders the overlay and the actual content. */
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & {
|
||||
$hidden?: boolean;
|
||||
}
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
const { children, $hidden, ...rest } = props;
|
||||
const [measureRef, bounds] = useMeasure();
|
||||
|
||||
return (
|
||||
<DrawerPrimitive.Portal>
|
||||
<DrawerPrimitive.Overlay asChild>
|
||||
<Overlay />
|
||||
</DrawerPrimitive.Overlay>
|
||||
{!$hidden && (
|
||||
<DrawerPrimitive.Overlay asChild>
|
||||
<Overlay />
|
||||
</DrawerPrimitive.Overlay>
|
||||
)}
|
||||
<DrawerPrimitive.Content ref={ref} asChild>
|
||||
<StyledContent
|
||||
$hidden={$hidden}
|
||||
animate={{
|
||||
height: bounds.height,
|
||||
transition: { bounce: 0, duration: 0.2 },
|
||||
@@ -76,7 +81,7 @@ const DrawerTitle = React.forwardRef<
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(m.div)`
|
||||
const StyledContent = styled(m.div)<{ $hidden?: boolean }>`
|
||||
z-index: ${depths.menu};
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -90,6 +95,8 @@ const StyledContent = styled(m.div)`
|
||||
border-radius: 6px;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
|
||||
${({ $hidden }) => $hidden && "display: none;"}
|
||||
`;
|
||||
|
||||
const StyledInnerContent = styled.div`
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
|
||||
type MenuVariant = "dropdown" | "context";
|
||||
type MenuVariant = "dropdown" | "context" | "inline";
|
||||
|
||||
const MenuContext = createContext<{
|
||||
type MenuContextType = {
|
||||
variant: MenuVariant;
|
||||
}>({
|
||||
activeSubmenu: string | null;
|
||||
setActiveSubmenu: (id: string | null) => void;
|
||||
submenuTriggerRefs: Record<string, RefObject<HTMLDivElement>>;
|
||||
addSubmenuTriggerRef: (id: string, ref: RefObject<HTMLDivElement>) => void;
|
||||
submenuContentRefs: Record<string, RefObject<HTMLDivElement | null>>;
|
||||
addSubmenuContentRef: (
|
||||
id: string,
|
||||
ref: RefObject<HTMLDivElement | null>
|
||||
) => void;
|
||||
mainMenuRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
const MenuContext = createContext<MenuContextType>({
|
||||
variant: "dropdown",
|
||||
activeSubmenu: null,
|
||||
setActiveSubmenu: () => {},
|
||||
submenuTriggerRefs: {},
|
||||
addSubmenuTriggerRef: () => {},
|
||||
submenuContentRefs: {},
|
||||
addSubmenuContentRef: () => {},
|
||||
mainMenuRef: { current: null },
|
||||
});
|
||||
|
||||
export function MenuProvider({
|
||||
@@ -15,7 +42,54 @@ export function MenuProvider({
|
||||
variant: MenuVariant;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ctx = useMemo(() => ({ variant }), [variant]);
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||
const [submenuTriggerRefs, setSubmenuTriggerRefs] = useState<
|
||||
Record<string, RefObject<HTMLDivElement>>
|
||||
>({});
|
||||
const [submenuContentRefs, setSubmenuContentRefs] = useState<
|
||||
Record<string, RefObject<HTMLDivElement | null>>
|
||||
>({});
|
||||
const mainMenuRef = useRef<HTMLDivElement>(null);
|
||||
const addSubmenuTriggerRef = useCallback(
|
||||
(key: string, ref: RefObject<HTMLDivElement>) => {
|
||||
setSubmenuTriggerRefs((prevRefs) => ({
|
||||
...prevRefs,
|
||||
[key]: ref,
|
||||
}));
|
||||
},
|
||||
[setSubmenuTriggerRefs]
|
||||
);
|
||||
const addSubmenuContentRef = useCallback(
|
||||
(key: string, ref: RefObject<HTMLDivElement | null>) => {
|
||||
setSubmenuContentRefs((prevRefs) => ({
|
||||
...prevRefs,
|
||||
[key]: ref,
|
||||
}));
|
||||
},
|
||||
[setSubmenuContentRefs]
|
||||
);
|
||||
|
||||
const ctx = useMemo(
|
||||
() => ({
|
||||
variant,
|
||||
activeSubmenu,
|
||||
setActiveSubmenu,
|
||||
submenuTriggerRefs,
|
||||
addSubmenuTriggerRef,
|
||||
submenuContentRefs,
|
||||
addSubmenuContentRef,
|
||||
mainMenuRef,
|
||||
}),
|
||||
[
|
||||
variant,
|
||||
activeSubmenu,
|
||||
mainMenuRef,
|
||||
submenuTriggerRefs,
|
||||
addSubmenuTriggerRef,
|
||||
submenuContentRefs,
|
||||
addSubmenuContentRef,
|
||||
]
|
||||
);
|
||||
|
||||
return <MenuContext.Provider value={ctx}>{children}</MenuContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -3,18 +3,31 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import * as Components from "../components/Menu";
|
||||
import type { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import { useMenuContext } from "./MenuContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { Drawer, DrawerContent } from "../Drawer";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { Portal as ReactPortal } from "~/components/Portal";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import { MenuType } from "@shared/editor/types";
|
||||
import { collapseSelection } from "@shared/editor/commands/collapseSelection";
|
||||
import { useEditor } from "~/editor/components/EditorContext";
|
||||
import type { EditorView } from "prosemirror-view";
|
||||
|
||||
type MenuProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Root
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Root>;
|
||||
|
||||
const Menu = ({ children, ...rest }: MenuProps) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const Root =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Root
|
||||
@@ -31,6 +44,10 @@ type SubMenuProps = React.ComponentPropsWithoutRef<
|
||||
const SubMenu = ({ children, ...rest }: SubMenuProps) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
const Sub =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Sub
|
||||
@@ -68,16 +85,77 @@ MenuTrigger.displayName = "MenuTrigger";
|
||||
type ContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Content
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>;
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
|
||||
pos?: {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
};
|
||||
|
||||
const MenuContent = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Content>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Content>
|
||||
| HTMLDivElement,
|
||||
ContentProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { variant, mainMenuRef, activeSubmenu } = useMenuContext();
|
||||
const isMobile = useMobile();
|
||||
const { view } = useEditor();
|
||||
|
||||
const { children, ...rest } = props;
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
const contentProps = {
|
||||
maxHeightVar: "--radix-dropdown-menu-content-available-height",
|
||||
transformOriginVar: "--radix-dropdown-menu-content-transform-origin",
|
||||
};
|
||||
const { pos } = props;
|
||||
|
||||
return isMobile ? (
|
||||
<Drawer
|
||||
open={true}
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closeMenu(view);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DrawerContent $hidden={!!activeSubmenu} {...rest}>
|
||||
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<ReactPortal>
|
||||
<InlineMenuContentWrapper
|
||||
ref={(node) => {
|
||||
// Set the main menu ref for submenu positioning
|
||||
if (mainMenuRef) {
|
||||
(
|
||||
mainMenuRef as React.MutableRefObject<HTMLElement | null>
|
||||
).current = node;
|
||||
}
|
||||
if (typeof ref === "function") {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
(ref as React.MutableRefObject<HTMLDivElement | null>).current =
|
||||
node;
|
||||
}
|
||||
}}
|
||||
{...contentProps}
|
||||
{...rest}
|
||||
hiddenScrollbars
|
||||
style={{
|
||||
top: pos?.top,
|
||||
left: pos?.left,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InlineMenuContentWrapper>
|
||||
</ReactPortal>
|
||||
);
|
||||
}
|
||||
|
||||
const Portal =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Portal
|
||||
@@ -120,11 +198,45 @@ type SubMenuTriggerProps = BaseItemProps &
|
||||
|
||||
const SubMenuTrigger = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>
|
||||
| HTMLDivElement,
|
||||
SubMenuTriggerProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, icon, disabled, ...rest } = props;
|
||||
const { variant, setActiveSubmenu, addSubmenuTriggerRef } = useMenuContext();
|
||||
const { label, icon, disabled, id, ...rest } = props;
|
||||
const triggerRef = React.useRef<HTMLDivElement>(null);
|
||||
const isMobile = useMobile();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (id && triggerRef.current) {
|
||||
addSubmenuTriggerRef(id, triggerRef);
|
||||
}
|
||||
}, [triggerRef, id, addSubmenuTriggerRef]);
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
return (
|
||||
<Components.MenuSubTrigger
|
||||
ref={triggerRef}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled && id && isMobile) {
|
||||
setActiveSubmenu(id);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (!disabled && id && !isMobile) {
|
||||
setActiveSubmenu(id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Components.MenuLabel style={{ marginRight: 20 }}>
|
||||
{label}
|
||||
</Components.MenuLabel>
|
||||
<Components.MenuDisclosure />
|
||||
</Components.MenuSubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
const Trigger =
|
||||
variant === "dropdown"
|
||||
@@ -143,6 +255,12 @@ const SubMenuTrigger = React.forwardRef<
|
||||
});
|
||||
SubMenuTrigger.displayName = "SubMenuTrigger";
|
||||
|
||||
const MARGIN_RIGHT_FOR_UX = 20; // Margin for better UX
|
||||
const NESTED_OFFSET_LEFT = 95; // Offset for nested submenu when it renders on the left
|
||||
const TOP_OFFSET_LEFT = 75; // Offset for top submenu when it renders on the left
|
||||
const NESTED_OFFSET_RIGHT = 75; // Offset for nested submenu when it renders on the right
|
||||
const TOP_OFFSET_RIGHT = 65; // Offset for top submenu when it renders on the right
|
||||
|
||||
type SubMenuContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.SubContent
|
||||
> &
|
||||
@@ -150,11 +268,166 @@ type SubMenuContentProps = React.ComponentPropsWithoutRef<
|
||||
|
||||
const SubMenuContent = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>
|
||||
| HTMLDivElement,
|
||||
SubMenuContentProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
const submenuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const {
|
||||
variant,
|
||||
activeSubmenu,
|
||||
submenuTriggerRefs,
|
||||
submenuContentRefs,
|
||||
addSubmenuContentRef,
|
||||
mainMenuRef,
|
||||
setActiveSubmenu,
|
||||
} = useMenuContext();
|
||||
const { children, id, ...rest } = props;
|
||||
const [position, setPosition] = React.useState({ top: 0, left: 0 });
|
||||
const isMobile = useMobile();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (id) {
|
||||
addSubmenuContentRef(id, submenuRef);
|
||||
}
|
||||
}, [id, addSubmenuContentRef]);
|
||||
|
||||
const handleClickOutside = React.useCallback(
|
||||
(event: MouseEvent | TouchEvent) => {
|
||||
const isInsideDescendant =
|
||||
id &&
|
||||
Object.entries(submenuContentRefs).some(
|
||||
([refId, contentRef]) =>
|
||||
refId !== id &&
|
||||
refId.startsWith(id + "-") &&
|
||||
contentRef.current?.contains(event.target as Node)
|
||||
);
|
||||
if (isInsideDescendant) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk up the id hierarchy to find the deepest ancestor submenu containing the click.
|
||||
let targetSubmenu: string | null = null;
|
||||
if (id) {
|
||||
const parts = id.split("-");
|
||||
for (let len = parts.length - 1; len >= 2; len--) {
|
||||
const ancestorId = parts.slice(0, len).join("-");
|
||||
const ancestorRef = submenuContentRefs[ancestorId];
|
||||
if (ancestorRef?.current?.contains(event.target as Node)) {
|
||||
targetSubmenu = ancestorId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setActiveSubmenu(targetSubmenu);
|
||||
},
|
||||
[id, submenuContentRefs, setActiveSubmenu]
|
||||
);
|
||||
|
||||
// the submenu drawer handles its own click outside logic
|
||||
useOnClickOutside(submenuRef, isMobile ? undefined : handleClickOutside);
|
||||
|
||||
React.useEffect(() => {
|
||||
const trigger = submenuTriggerRefs[id ?? ""];
|
||||
|
||||
if (trigger?.current) {
|
||||
const triggerRect = trigger.current.getBoundingClientRect();
|
||||
const parentId = id ? getParentSubmenuId(id) : null;
|
||||
const anchorRect = (
|
||||
parentId ? submenuContentRefs[parentId]?.current : mainMenuRef.current
|
||||
)?.getBoundingClientRect();
|
||||
const subMenuRect = submenuRef.current?.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
const spaceOnRight = viewportWidth - triggerRect.right;
|
||||
const anchorWidth = anchorRect?.width;
|
||||
const submenuWidth = subMenuRect?.width;
|
||||
|
||||
const offsetLeft = parentId ? NESTED_OFFSET_LEFT : TOP_OFFSET_LEFT;
|
||||
const offsetRight = parentId ? NESTED_OFFSET_RIGHT : TOP_OFFSET_RIGHT;
|
||||
|
||||
let left = triggerRect.left - offsetLeft;
|
||||
|
||||
// Check if there's enough space on the right
|
||||
if (
|
||||
submenuWidth &&
|
||||
anchorWidth &&
|
||||
spaceOnRight < submenuWidth + MARGIN_RIGHT_FOR_UX
|
||||
) {
|
||||
left = triggerRect.left - submenuWidth - anchorWidth - offsetRight;
|
||||
}
|
||||
|
||||
setPosition({
|
||||
top: triggerRect.top,
|
||||
left,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
variant,
|
||||
activeSubmenu,
|
||||
submenuTriggerRefs,
|
||||
mainMenuRef,
|
||||
id,
|
||||
submenuContentRefs,
|
||||
]);
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
const isVisible =
|
||||
activeSubmenu === id ||
|
||||
(id !== undefined && activeSubmenu?.startsWith(id + "-"));
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentProps = {
|
||||
maxHeightVar: "--inline-menu-max-height",
|
||||
transformOriginVar: "--inline-menu-transform-origin",
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
if (activeSubmenu !== id) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SubMenuDrawer
|
||||
setActiveSubmenu={setActiveSubmenu}
|
||||
submenuRef={submenuRef}
|
||||
forwardedRef={ref}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</SubMenuDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactPortal>
|
||||
<InlineMenuContentWrapper
|
||||
ref={(node) => {
|
||||
submenuRef.current = node;
|
||||
if (typeof ref === "function") {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
(ref as React.MutableRefObject<HTMLDivElement | null>).current =
|
||||
node;
|
||||
}
|
||||
}}
|
||||
{...contentProps}
|
||||
{...rest}
|
||||
hiddenScrollbars
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
zIndex: 1001,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InlineMenuContentWrapper>
|
||||
</ReactPortal>
|
||||
);
|
||||
}
|
||||
|
||||
const Portal =
|
||||
variant === "dropdown"
|
||||
@@ -203,7 +476,8 @@ type MenuGroupProps = {
|
||||
|
||||
const MenuGroup = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Group>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Group>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Group>
|
||||
| HTMLDivElement,
|
||||
MenuGroupProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
@@ -224,6 +498,7 @@ const MenuGroup = React.forwardRef<
|
||||
MenuGroup.displayName = "MenuGroup";
|
||||
|
||||
type BaseItemProps = {
|
||||
id?: string;
|
||||
label: string;
|
||||
icon?: React.ReactElement;
|
||||
disabled?: boolean;
|
||||
@@ -248,7 +523,9 @@ const MenuButton = React.forwardRef<
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
MenuButtonProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { variant, activeSubmenu, setActiveSubmenu } = useMenuContext();
|
||||
const { view } = useEditor();
|
||||
const [active, setActive] = React.useState(false);
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
@@ -260,28 +537,63 @@ const MenuButton = React.forwardRef<
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const buttonContent = (
|
||||
<>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
{selected !== undefined && (
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon size={18} /> : null}
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const Item =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Item
|
||||
: ContextMenuPrimitive.Item;
|
||||
|
||||
const button = (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
const handleMouseEnter = React.useCallback(() => {
|
||||
setActive(true);
|
||||
if (props.id) {
|
||||
// Close any nested submenu that is deeper than this button's parent level.
|
||||
const parentId = getParentSubmenuId(props.id);
|
||||
if (activeSubmenu && activeSubmenu !== parentId) {
|
||||
setActiveSubmenu(parentId);
|
||||
}
|
||||
} else if (activeSubmenu) {
|
||||
setActiveSubmenu(null);
|
||||
}
|
||||
}, [setActive, props.id, activeSubmenu, setActiveSubmenu]);
|
||||
|
||||
const button =
|
||||
variant === MenuType.inline ? (
|
||||
<Components.MenuButton
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
disabled={disabled}
|
||||
$dangerous={dangerous}
|
||||
onClick={onClick}
|
||||
$active={active}
|
||||
onClick={(e) => {
|
||||
onClick(e);
|
||||
closeMenu(view);
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
{selected !== undefined && (
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon size={18} /> : null}
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
{buttonContent}
|
||||
</Components.MenuButton>
|
||||
</Item>
|
||||
);
|
||||
) : (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<Components.MenuButton
|
||||
disabled={disabled}
|
||||
$dangerous={dangerous}
|
||||
onClick={onClick}
|
||||
>
|
||||
{buttonContent}
|
||||
</Components.MenuButton>
|
||||
</Item>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip content={tooltip} placement="bottom">
|
||||
@@ -375,11 +687,16 @@ type MenuSeparatorProps = React.ComponentPropsWithoutRef<
|
||||
|
||||
const MenuSeparator = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Separator>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Separator>
|
||||
| HTMLDivElement,
|
||||
MenuSeparatorProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
return <Components.MenuSeparator ref={ref as React.Ref<HTMLHRElement>} />;
|
||||
}
|
||||
|
||||
const Separator =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Separator
|
||||
@@ -419,6 +736,82 @@ const MenuLabel = React.forwardRef<
|
||||
});
|
||||
MenuLabel.displayName = "MenuLabel";
|
||||
|
||||
const DRAWER_ANIMATION_DURATION_MS = 300;
|
||||
|
||||
type SubMenuDrawerProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
setActiveSubmenu: (id: string | null) => void;
|
||||
submenuRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
forwardedRef: React.ForwardedRef<HTMLDivElement>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const SubMenuDrawer = ({
|
||||
setActiveSubmenu,
|
||||
submenuRef,
|
||||
forwardedRef,
|
||||
children,
|
||||
...rest
|
||||
}: SubMenuDrawerProps) => {
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
const { view } = useEditor();
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
setIsOpen(false);
|
||||
// Let slide-down animation play out before tearing down the tree.
|
||||
setTimeout(() => {
|
||||
setActiveSubmenu(null);
|
||||
closeMenu(view);
|
||||
}, DRAWER_ANIMATION_DURATION_MS);
|
||||
}
|
||||
},
|
||||
[setActiveSubmenu, view]
|
||||
);
|
||||
|
||||
useOnClickOutside(submenuRef, () => handleOpenChange(false));
|
||||
|
||||
return (
|
||||
<Drawer open={isOpen} modal={false} onOpenChange={handleOpenChange}>
|
||||
<DrawerContent
|
||||
ref={(node) => {
|
||||
submenuRef.current = node;
|
||||
if (typeof forwardedRef === "function") {
|
||||
forwardedRef(node);
|
||||
} else if (forwardedRef) {
|
||||
(
|
||||
forwardedRef as React.MutableRefObject<HTMLDivElement | null>
|
||||
).current = node;
|
||||
}
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
const getParentSubmenuId = (id: string): string | null => {
|
||||
const parts = id.split("-");
|
||||
return parts.length > 2 ? parts.slice(0, -1).join("-") : null;
|
||||
};
|
||||
|
||||
const closeMenu = (view: EditorView) => {
|
||||
collapseSelection()(view.state, view.dispatch);
|
||||
};
|
||||
|
||||
const InlineMenuContentWrapper = styled(Components.MenuContent)`
|
||||
position: absolute;
|
||||
height: fit-content;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
// Styled scrollable for mobile drawer content
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
export {
|
||||
Menu,
|
||||
MenuTrigger,
|
||||
|
||||
@@ -107,6 +107,25 @@ export const MenuExternalLink = styled.a`
|
||||
|
||||
export const MenuSubTrigger = styled.div<BaseMenuItemProps>`
|
||||
${BaseMenuItemCSS}
|
||||
|
||||
${(props) =>
|
||||
!props.disabled &&
|
||||
`
|
||||
&:hover {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
|
||||
outline-color: ${
|
||||
props.$dangerous ? props.theme.danger : props.theme.accent
|
||||
};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg:not([data-fixed-color]) {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const MenuSeparator = styled.hr`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useMemo, useEffect } from "react";
|
||||
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
|
||||
import { search as emojiSearch } from "@shared/utils/emoji";
|
||||
@@ -76,4 +77,4 @@ const EmojiMenu = (props: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiMenu;
|
||||
export default observer(EmojiMenu);
|
||||
|
||||
@@ -375,6 +375,10 @@ export default function FindAndReplace({
|
||||
minWidth={420}
|
||||
scrollable={false}
|
||||
onPointerDownOutside={() => setLocalOpen(false)}
|
||||
onFocusOutside={(event) => {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
style={{ marginRight: 16, marginTop: 60 }}
|
||||
>
|
||||
<Content column>
|
||||
|
||||
@@ -36,14 +36,16 @@ const defaultPosition = {
|
||||
visible: false,
|
||||
};
|
||||
|
||||
function usePosition({
|
||||
export function usePosition({
|
||||
menuRef,
|
||||
active,
|
||||
align = "center",
|
||||
inline = false,
|
||||
}: {
|
||||
menuRef: React.RefObject<HTMLDivElement>;
|
||||
active?: boolean;
|
||||
align?: Props["align"];
|
||||
inline?: boolean;
|
||||
}) {
|
||||
const { view } = useEditor();
|
||||
const { selection } = view.state;
|
||||
@@ -120,13 +122,14 @@ function usePosition({
|
||||
selection instanceof ColumnSelection && selection.isColSelection();
|
||||
const isRowSelection =
|
||||
selection instanceof RowSelection && selection.isRowSelection();
|
||||
let colWidth = 0;
|
||||
|
||||
if (isTableSelected(view.state)) {
|
||||
const rect = selectedRect(view.state);
|
||||
const table = view.domAtPos(rect.tableStart);
|
||||
const bounds = (table.node as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top - 16;
|
||||
selectionBounds.left = bounds.left - 10;
|
||||
selectionBounds.top = bounds.top - (inline ? 160 : 16);
|
||||
selectionBounds.left = bounds.left;
|
||||
selectionBounds.right = bounds.left - 10;
|
||||
} else if (isColSelection) {
|
||||
const rect = selectedRect(view.state);
|
||||
@@ -136,6 +139,7 @@ function usePosition({
|
||||
);
|
||||
if (element instanceof HTMLElement) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
colWidth = bounds.width;
|
||||
selectionBounds.top = bounds.top - 16;
|
||||
selectionBounds.left = bounds.left;
|
||||
selectionBounds.right = bounds.right;
|
||||
@@ -148,8 +152,8 @@ function usePosition({
|
||||
);
|
||||
if (element instanceof HTMLElement) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.left = bounds.left - 10;
|
||||
selectionBounds.top = bounds.top + (inline ? 55 : 0);
|
||||
selectionBounds.left = bounds.left - (inline ? 410 : 10);
|
||||
selectionBounds.right = bounds.left - 10;
|
||||
}
|
||||
}
|
||||
@@ -198,11 +202,13 @@ function usePosition({
|
||||
),
|
||||
Math.max(
|
||||
Math.max(offsetParent.x, margin),
|
||||
align === "center"
|
||||
? centerOfSelection - menuWidth / 2
|
||||
: align === "start"
|
||||
? selectionBounds.left
|
||||
: selectionBounds.right
|
||||
isColSelection && colWidth < 300
|
||||
? selectionBounds.right + margin
|
||||
: align === "center"
|
||||
? centerOfSelection - menuWidth / 2
|
||||
: align === "start"
|
||||
? selectionBounds.left
|
||||
: selectionBounds.right
|
||||
)
|
||||
);
|
||||
const top = Math.max(
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import { Menu } from "~/components/primitives/Menu";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MenuContent } from "~/components/primitives/Menu";
|
||||
import { toMenuItems } from "~/components/Menu/transformer";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import { mapMenuItems } from "./ToolbarMenu";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePosition } from "./FloatingToolbar";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
|
||||
type Props = {
|
||||
items: MenuItem[];
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
/*
|
||||
* Renders an inline menu in the floating toolbar, which does not require a trigger.
|
||||
*/
|
||||
const InlineMenu: React.FC<Props> = ({ items, containerRef }) => {
|
||||
const { t } = useTranslation();
|
||||
const { commands, view } = useEditor();
|
||||
const fallbackRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuRef = containerRef || fallbackRef;
|
||||
const isMobile = useMobile();
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||||
|
||||
const position = usePosition({
|
||||
menuRef,
|
||||
active: true,
|
||||
inline: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const menuRect = menuRef.current?.getBoundingClientRect();
|
||||
|
||||
let left = position.left;
|
||||
if (menuRef.current && menuRect) {
|
||||
const spaceOnRight = viewportWidth - left;
|
||||
if (spaceOnRight < menuRect.right) {
|
||||
left = left - spaceOnRight; // double the space on the right
|
||||
}
|
||||
}
|
||||
|
||||
setPos((prevPos) => {
|
||||
if (prevPos.top !== position.top || prevPos.left !== left) {
|
||||
return {
|
||||
top: position.top,
|
||||
left,
|
||||
};
|
||||
}
|
||||
return prevPos;
|
||||
});
|
||||
}, [menuRef, position]);
|
||||
|
||||
const handleCloseAutoFocus = useCallback((ev: Event) => {
|
||||
ev.stopImmediatePropagation();
|
||||
}, []);
|
||||
|
||||
const mappedItems = useMemo(
|
||||
() =>
|
||||
items.map((item) => {
|
||||
const children =
|
||||
typeof item.children === "function" ? item.children() : item.children;
|
||||
|
||||
return {
|
||||
...item,
|
||||
children: children
|
||||
? mapMenuItems(children, commands, view.state)
|
||||
: [],
|
||||
};
|
||||
}),
|
||||
[items, commands, view.state]
|
||||
);
|
||||
|
||||
const content = (
|
||||
<MenuProvider variant="inline">
|
||||
<Menu>
|
||||
<MenuContent
|
||||
pos={pos}
|
||||
align="end"
|
||||
aria-label={t("Options")}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
<EventBoundary>
|
||||
{mappedItems.map((item) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{toMenuItems(item.children || [])}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</EventBoundary>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
);
|
||||
|
||||
return isMobile ? content : <Portal>{content}</Portal>;
|
||||
};
|
||||
|
||||
export default InlineMenu;
|
||||
@@ -33,6 +33,7 @@ type Props = {
|
||||
mark?: Mark;
|
||||
dictionary: Dictionary;
|
||||
view: EditorView;
|
||||
autoFocus?: boolean;
|
||||
onLinkAdd: () => void;
|
||||
onLinkUpdate: () => void;
|
||||
onLinkRemove: () => void;
|
||||
@@ -45,6 +46,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
mark,
|
||||
dictionary,
|
||||
view,
|
||||
autoFocus,
|
||||
onLinkAdd,
|
||||
onLinkUpdate,
|
||||
onLinkRemove,
|
||||
@@ -209,7 +211,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleSearch}
|
||||
onFocus={handleSearch}
|
||||
autoFocus={getHref() === ""}
|
||||
autoFocus={autoFocus}
|
||||
readOnly={!view.editable}
|
||||
/>
|
||||
{actions.map((action, index) => {
|
||||
|
||||
@@ -44,7 +44,6 @@ type Props = Omit<
|
||||
|
||||
function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [items, setItems] = useState<MentionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const { auth, documents, users, collections, groups } = useStores();
|
||||
const actorId = auth.currentUserId;
|
||||
@@ -76,164 +75,161 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (actorId && !loading) {
|
||||
const items: MentionItem[] = users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(user) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24 }}
|
||||
>
|
||||
<Avatar
|
||||
model={user}
|
||||
alt={t("Profile picture")}
|
||||
size={AvatarSize.Small}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
title: user.name,
|
||||
section: UserSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId,
|
||||
label: user.name,
|
||||
},
|
||||
}) as MentionItem
|
||||
)
|
||||
.concat(
|
||||
groups
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map((group) => ({
|
||||
name: "mention",
|
||||
icon: (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24, marginRight: 4 }}
|
||||
>
|
||||
<GroupAvatar group={group} size={AvatarSize.Small} />
|
||||
</Flex>
|
||||
),
|
||||
title: group.name,
|
||||
subtitle: t("{{ count }} members", { count: group.memberCount }),
|
||||
section: GroupSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Group,
|
||||
modelId: group.id,
|
||||
actorId,
|
||||
label: group.name,
|
||||
},
|
||||
}))
|
||||
)
|
||||
.concat(
|
||||
documents
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(doc) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: doc.icon ? (
|
||||
<Icon
|
||||
value={doc.icon}
|
||||
initial={doc.initial}
|
||||
color={doc.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
title: doc.title,
|
||||
subtitle: doc.collectionId ? (
|
||||
<DocumentBreadcrumb
|
||||
document={doc}
|
||||
onlyText
|
||||
reverse
|
||||
maxDepth={2}
|
||||
/>
|
||||
) : undefined,
|
||||
section: DocumentsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Document,
|
||||
modelId: doc.id,
|
||||
actorId,
|
||||
label: doc.title,
|
||||
},
|
||||
}) as MentionItem
|
||||
)
|
||||
)
|
||||
.concat(
|
||||
collections
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(collection) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: collection.icon ? (
|
||||
<Icon
|
||||
value={collection.icon}
|
||||
initial={collection.initial}
|
||||
color={collection.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<CollectionIcon />
|
||||
),
|
||||
title: collection.name,
|
||||
section: CollectionsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Collection,
|
||||
modelId: collection.id,
|
||||
actorId,
|
||||
label: collection.name,
|
||||
},
|
||||
}) as MentionItem
|
||||
)
|
||||
)
|
||||
.concat([
|
||||
{
|
||||
name: "link",
|
||||
icon: <PlusIcon />,
|
||||
title: search?.trim(),
|
||||
section: DocumentsSection,
|
||||
subtitle: t("Create a new doc"),
|
||||
visible: !!search && !isEmail(search),
|
||||
priority: -1,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Document,
|
||||
modelId: uuidv4(),
|
||||
actorId,
|
||||
label: search,
|
||||
},
|
||||
} as MentionItem,
|
||||
]);
|
||||
|
||||
setItems(items);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [
|
||||
t,
|
||||
actorId,
|
||||
loading,
|
||||
search,
|
||||
users,
|
||||
documents,
|
||||
maxResultsInSection,
|
||||
groups,
|
||||
collections,
|
||||
]);
|
||||
}, [actorId, loading]);
|
||||
|
||||
// Computed in the render body so MobX observer can track store access
|
||||
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
|
||||
// runs outside the reactive context and triggered MobX warnings.
|
||||
const items: MentionItem[] =
|
||||
actorId && !loading
|
||||
? users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(user) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24 }}
|
||||
>
|
||||
<Avatar
|
||||
model={user}
|
||||
alt={t("Profile picture")}
|
||||
size={AvatarSize.Small}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
title: user.name,
|
||||
section: UserSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId,
|
||||
label: user.name,
|
||||
},
|
||||
}) as MentionItem
|
||||
)
|
||||
.concat(
|
||||
groups
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map((group) => ({
|
||||
name: "mention",
|
||||
icon: (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24, marginRight: 4 }}
|
||||
>
|
||||
<GroupAvatar group={group} size={AvatarSize.Small} />
|
||||
</Flex>
|
||||
),
|
||||
title: group.name,
|
||||
subtitle: t("{{ count }} members", {
|
||||
count: group.memberCount,
|
||||
}),
|
||||
section: GroupSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Group,
|
||||
modelId: group.id,
|
||||
actorId,
|
||||
label: group.name,
|
||||
},
|
||||
}))
|
||||
)
|
||||
.concat(
|
||||
documents
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(doc) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: doc.icon ? (
|
||||
<Icon
|
||||
value={doc.icon}
|
||||
initial={doc.initial}
|
||||
color={doc.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
title: doc.title,
|
||||
subtitle: doc.collectionId ? (
|
||||
<DocumentBreadcrumb
|
||||
document={doc}
|
||||
onlyText
|
||||
reverse
|
||||
maxDepth={2}
|
||||
/>
|
||||
) : undefined,
|
||||
section: DocumentsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Document,
|
||||
modelId: doc.id,
|
||||
actorId,
|
||||
label: doc.title,
|
||||
},
|
||||
}) as MentionItem
|
||||
)
|
||||
)
|
||||
.concat(
|
||||
collections
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(collection) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: collection.icon ? (
|
||||
<Icon
|
||||
value={collection.icon}
|
||||
initial={collection.initial}
|
||||
color={collection.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<CollectionIcon />
|
||||
),
|
||||
title: collection.name,
|
||||
section: CollectionsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Collection,
|
||||
modelId: collection.id,
|
||||
actorId,
|
||||
label: collection.name,
|
||||
},
|
||||
}) as MentionItem
|
||||
)
|
||||
)
|
||||
.concat([
|
||||
{
|
||||
name: "link",
|
||||
icon: <PlusIcon />,
|
||||
title: search?.trim(),
|
||||
section: DocumentsSection,
|
||||
subtitle: t("Create a new doc"),
|
||||
visible: !!search && !isEmail(search),
|
||||
priority: -1,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Document,
|
||||
modelId: uuidv4(),
|
||||
actorId,
|
||||
label: search,
|
||||
},
|
||||
} as MentionItem,
|
||||
])
|
||||
: [];
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
getRowIndex,
|
||||
isTableSelected,
|
||||
} from "@shared/editor/queries/table";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MenuType, type MenuItem } from "@shared/editor/types";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -40,6 +40,9 @@ import FloatingToolbar from "./FloatingToolbar";
|
||||
import LinkEditor from "./LinkEditor";
|
||||
import ToolbarMenu from "./ToolbarMenu";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import InlineMenu from "./InlineMenu";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** Whether the text direction is right-to-left */
|
||||
@@ -87,25 +90,29 @@ export function SelectionToolbar(props: Props) {
|
||||
const isMobile = useMobile();
|
||||
const isActive = props.isActive || isMobile;
|
||||
const { state } = view;
|
||||
const [autoFocusLinkInput, setAutoFocusLinkInput] = React.useState(false);
|
||||
const isDragging = useIsDragging(state);
|
||||
const { selection } = state;
|
||||
const [activeToolbar, setActiveToolbar] = React.useState<Toolbar | null>(
|
||||
null
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const { selection } = state;
|
||||
const linkMark =
|
||||
selection instanceof NodeSelection
|
||||
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
|
||||
: getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const linkMark =
|
||||
selection instanceof NodeSelection
|
||||
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
|
||||
: getMarkRange(selection.$from, state.schema.marks.link);
|
||||
|
||||
const isEmbedSelection =
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === "embed";
|
||||
const isEmbedSelection =
|
||||
selection instanceof NodeSelection && selection.node.type.name === "embed";
|
||||
|
||||
const isCodeSelection = isInCode(state, { onlyBlock: true });
|
||||
const isNoticeSelection = isInNotice(state);
|
||||
const isCodeSelection = isInCode(state, { onlyBlock: true });
|
||||
const isNoticeSelection = isInNotice(state);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isActive) {
|
||||
setActiveToolbar(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmbedSelection && !readOnly) {
|
||||
setActiveToolbar(Toolbar.Media);
|
||||
@@ -124,22 +131,37 @@ export function SelectionToolbar(props: Props) {
|
||||
} else if (selection.empty) {
|
||||
setActiveToolbar(null);
|
||||
}
|
||||
}, [readOnly, selection]);
|
||||
}, [
|
||||
readOnly,
|
||||
isActive,
|
||||
selection,
|
||||
linkMark,
|
||||
isEmbedSelection,
|
||||
isCodeSelection,
|
||||
isNoticeSelection,
|
||||
]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (autoFocusLinkInput && activeToolbar !== Toolbar.Link) {
|
||||
setAutoFocusLinkInput(false);
|
||||
}
|
||||
}, [activeToolbar]);
|
||||
|
||||
// Refocus the editor when the link toolbar closes to prevent focus loss
|
||||
const prevActiveToolbar = React.useRef(activeToolbar);
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
if (
|
||||
prevActiveToolbar.current === Toolbar.Link &&
|
||||
activeToolbar !== Toolbar.Link &&
|
||||
!readOnly
|
||||
!readOnly &&
|
||||
isActive
|
||||
) {
|
||||
view.focus();
|
||||
}
|
||||
prevActiveToolbar.current = activeToolbar;
|
||||
}, [activeToolbar, readOnly, view]);
|
||||
}, [activeToolbar, readOnly, isActive, view]);
|
||||
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
const handleClickOutside = (ev: MouseEvent): void => {
|
||||
if (
|
||||
ev.target instanceof HTMLElement &&
|
||||
@@ -193,11 +215,10 @@ export function SelectionToolbar(props: Props) {
|
||||
) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (activeToolbar === Toolbar.Link) {
|
||||
setActiveToolbar(Toolbar.Menu);
|
||||
} else if (activeToolbar === Toolbar.Menu) {
|
||||
setActiveToolbar(Toolbar.Link);
|
||||
}
|
||||
setAutoFocusLinkInput(true);
|
||||
setActiveToolbar(
|
||||
activeToolbar === Toolbar.Link ? Toolbar.Menu : Toolbar.Link
|
||||
);
|
||||
}
|
||||
},
|
||||
view.dom,
|
||||
@@ -218,12 +239,6 @@ export function SelectionToolbar(props: Props) {
|
||||
const isAttachmentSelection =
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === "attachment";
|
||||
const isCodeSelection = isInCode(state, { onlyBlock: true });
|
||||
const isNoticeSelection = isInNotice(state);
|
||||
const link =
|
||||
selection instanceof NodeSelection
|
||||
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
|
||||
: getMarkRange(selection.$from, state.schema.marks.link);
|
||||
|
||||
let items: MenuItem[] = [];
|
||||
let align: "center" | "start" | "end" = "center";
|
||||
@@ -257,6 +272,8 @@ export function SelectionToolbar(props: Props) {
|
||||
items = getFormattingMenuItems(state, isTemplate, dictionary);
|
||||
}
|
||||
|
||||
const isInline = items[0].type === MenuType.inline;
|
||||
|
||||
// Some extensions may be disabled, remove corresponding items
|
||||
items = items.filter((item) => {
|
||||
if (item.name === "separator") {
|
||||
@@ -289,6 +306,7 @@ export function SelectionToolbar(props: Props) {
|
||||
|
||||
if (item.name === "linkOnImage" || item.name === "addLink") {
|
||||
item.onClick = () => {
|
||||
setAutoFocusLinkInput(true);
|
||||
setActiveToolbar(Toolbar.Link);
|
||||
};
|
||||
}
|
||||
@@ -302,6 +320,14 @@ export function SelectionToolbar(props: Props) {
|
||||
setActiveToolbar(null);
|
||||
};
|
||||
|
||||
if (isInline && items.length) {
|
||||
return (
|
||||
<InlineMenuWrapper ref={menuRef}>
|
||||
<InlineMenu items={items} containerRef={menuRef} />
|
||||
</InlineMenuWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
align={align}
|
||||
@@ -315,10 +341,11 @@ export function SelectionToolbar(props: Props) {
|
||||
>
|
||||
{activeToolbar === Toolbar.Link ? (
|
||||
<LinkEditor
|
||||
key={`${selection.from}-${selection.to}`}
|
||||
key={`link-${selection.anchor}`}
|
||||
dictionary={dictionary}
|
||||
autoFocus={autoFocusLinkInput}
|
||||
view={view}
|
||||
mark={link ? link.mark : undefined}
|
||||
mark={linkMark ? linkMark.mark : undefined}
|
||||
onLinkAdd={() => setActiveToolbar(null)}
|
||||
onLinkUpdate={() => setActiveToolbar(null)}
|
||||
onLinkRemove={() => setActiveToolbar(null)}
|
||||
@@ -328,7 +355,7 @@ export function SelectionToolbar(props: Props) {
|
||||
/>
|
||||
) : activeToolbar === Toolbar.Media ? (
|
||||
<MediaLinkEditor
|
||||
key={`embed-${selection.from}`}
|
||||
key={`embed-${selection.anchor}`}
|
||||
node={
|
||||
"node" in selection ? (selection as NodeSelection).node : undefined
|
||||
}
|
||||
@@ -345,3 +372,20 @@ export function SelectionToolbar(props: Props) {
|
||||
</FloatingToolbar>
|
||||
);
|
||||
}
|
||||
|
||||
const InlineMenuWrapper = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${depths.editorToolbar};
|
||||
line-height: 0;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -48,67 +48,10 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const handleClick = (menuItem: MenuItem) => () => {
|
||||
if (!menuItem.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (commands[menuItem.name]) {
|
||||
commands[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
} else if (menuItem.onClick) {
|
||||
menuItem.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem[] | (() => MenuItem[]) | undefined
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
|
||||
children.map((child) => {
|
||||
if (child.name === "separator") {
|
||||
return { type: "separator", visible: child.visible };
|
||||
}
|
||||
if ("content" in child) {
|
||||
return {
|
||||
type: "custom",
|
||||
visible: child.visible,
|
||||
content: child.content,
|
||||
};
|
||||
}
|
||||
const resolvedChildren = resolveChildren(child.children);
|
||||
if (resolvedChildren) {
|
||||
const childWithPreventClose = resolvedChildren.find(
|
||||
(c) => "preventCloseCondition" in c
|
||||
);
|
||||
return {
|
||||
type: "submenu",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
visible: child.visible,
|
||||
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
|
||||
items: mapChildren(resolvedChildren),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "button",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
dangerous: child.dangerous,
|
||||
visible: child.visible,
|
||||
selected:
|
||||
child.active !== undefined ? child.active(state) : undefined,
|
||||
onClick: handleClick(child),
|
||||
};
|
||||
});
|
||||
|
||||
const resolvedItemChildren = resolveChildren(item.children);
|
||||
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
|
||||
return resolvedItemChildren
|
||||
? mapMenuItems(resolvedItemChildren, commands, state)
|
||||
: [];
|
||||
}, [isOpen, commands]);
|
||||
|
||||
const handleCloseAutoFocus = useCallback((ev: Event) => {
|
||||
@@ -220,6 +163,78 @@ function ToolbarMenu(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem[] | (() => MenuItem[]) | undefined
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
export const mapMenuItems = (
|
||||
children: MenuItem[],
|
||||
commands: Record<string, Function>,
|
||||
state: any,
|
||||
parentId = "0"
|
||||
): TMenuItem[] => {
|
||||
const handleClick = (menuItem: MenuItem) => () => {
|
||||
if (!menuItem.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (commands[menuItem.name]) {
|
||||
commands[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
} else if (menuItem.onClick) {
|
||||
menuItem.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return children.map((child, idx) => {
|
||||
const id = `${parentId}-${idx}`;
|
||||
|
||||
if (child.name === "separator") {
|
||||
return { id, type: "separator", visible: child.visible };
|
||||
}
|
||||
|
||||
if ("content" in child) {
|
||||
return {
|
||||
id,
|
||||
type: "custom",
|
||||
visible: child.visible,
|
||||
content: child.content,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedChildren = resolveChildren(child.children);
|
||||
if (resolvedChildren) {
|
||||
const childWithPreventClose = resolvedChildren.find(
|
||||
(c) => "preventCloseCondition" in c
|
||||
);
|
||||
return {
|
||||
id,
|
||||
type: "submenu",
|
||||
title: child.label || child.tooltip,
|
||||
icon: child.icon,
|
||||
visible: child.visible,
|
||||
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
|
||||
items: mapMenuItems(resolvedChildren, commands, state, id),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type: "button",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
dangerous: child.dangerous,
|
||||
visible: child.visible,
|
||||
selected: child.active !== undefined ? child.active(state) : undefined,
|
||||
onClick: handleClick(child),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const FlexibleWrapper = styled.div`
|
||||
color: ${s("textSecondary")};
|
||||
overflow: hidden;
|
||||
|
||||
@@ -102,6 +102,10 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
// have changed underneath us since the last search.
|
||||
this.search(state.doc);
|
||||
|
||||
if (this.currentResultIndex >= this.results.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = this.results[this.currentResultIndex];
|
||||
|
||||
if (!result) {
|
||||
@@ -220,6 +224,10 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
* Expand any folded toggle blocks that contain the current match.
|
||||
*/
|
||||
private expandFoldedTogglesForCurrentMatch() {
|
||||
if (this.currentResultIndex >= this.results.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = this.results[this.currentResultIndex];
|
||||
if (!result) {
|
||||
return;
|
||||
@@ -272,7 +280,7 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
private rebaseNextResult(replace: string, index: number, lastOffset = 0) {
|
||||
const nextIndex = index + 1;
|
||||
|
||||
if (!this.results[nextIndex]) {
|
||||
if (nextIndex >= this.results.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,11 @@ export type Props = {
|
||||
focusedCommentId?: string;
|
||||
/** If the editor should not allow editing */
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Whether we are rendering a cached version of the document while multiplayer loads.
|
||||
* This is used to disable some editor functionality
|
||||
*/
|
||||
cacheOnly?: boolean;
|
||||
/** If the editor should still allow editing checkboxes when it is readOnly */
|
||||
canUpdate?: boolean;
|
||||
/** If the editor should still allow commenting when it is readOnly */
|
||||
@@ -854,7 +859,7 @@ export class Editor extends React.PureComponent<
|
||||
column
|
||||
>
|
||||
<EditorContainer
|
||||
rtl={isRTL}
|
||||
$rtl={isRTL}
|
||||
grow={grow}
|
||||
readOnly={readOnly}
|
||||
readOnlyWriteCheckboxes={canUpdate}
|
||||
@@ -867,6 +872,7 @@ export class Editor extends React.PureComponent<
|
||||
/>
|
||||
|
||||
{this.widgets &&
|
||||
!this.props.cacheOnly &&
|
||||
Object.values(this.widgets).map((Widget, index) => (
|
||||
<Widget
|
||||
key={String(index)}
|
||||
@@ -887,7 +893,7 @@ export class Editor extends React.PureComponent<
|
||||
images={this.getLightboxImages()}
|
||||
activeImage={this.state.activeLightboxImage}
|
||||
onUpdate={this.updateActiveLightboxImage}
|
||||
onClose={this.view.focus}
|
||||
onClose={this.view.focus.bind(this.view)}
|
||||
/>
|
||||
)}
|
||||
</EditorContext.Provider>
|
||||
|
||||
+37
-31
@@ -7,7 +7,7 @@ import {
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { TableLayout } from "@shared/editor/types";
|
||||
import { MenuType, TableLayout } from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function tableMenuItems(
|
||||
@@ -26,36 +26,42 @@ export default function tableMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
name: "setTableAttr",
|
||||
tooltip: isFullWidth
|
||||
? dictionary.alignDefaultWidth
|
||||
: dictionary.alignFullWidth,
|
||||
icon: <AlignFullWidthIcon />,
|
||||
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
|
||||
active: () => isFullWidth,
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
tooltip: dictionary.distributeColumns,
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteTable",
|
||||
tooltip: dictionary.deleteTable,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "exportTable",
|
||||
tooltip: dictionary.exportAsCSV,
|
||||
label: "CSV",
|
||||
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
|
||||
icon: <DownloadIcon />,
|
||||
type: MenuType.inline,
|
||||
children: [
|
||||
{
|
||||
name: "setTableAttr",
|
||||
label: isFullWidth
|
||||
? dictionary.alignDefaultWidth
|
||||
: dictionary.alignFullWidth,
|
||||
icon: <AlignFullWidthIcon />,
|
||||
attrs: isFullWidth
|
||||
? { layout: null }
|
||||
: { layout: TableLayout.fullWidth },
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
label: dictionary.distributeColumns,
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "exportTable",
|
||||
label: dictionary.exportAsCSV,
|
||||
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteTable",
|
||||
dangerous: true,
|
||||
label: dictionary.deleteTable,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+130
-108
@@ -5,7 +5,6 @@ import {
|
||||
AlignCenterIcon,
|
||||
InsertLeftIcon,
|
||||
InsertRightIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderColumnIcon,
|
||||
TableMergeCellsIcon,
|
||||
@@ -24,7 +23,11 @@ import {
|
||||
isMultipleCellSelection,
|
||||
tableHasRowspan,
|
||||
} from "@shared/editor/queries/table";
|
||||
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
|
||||
import {
|
||||
MenuType,
|
||||
type MenuItem,
|
||||
type NodeAttrMark,
|
||||
} from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
@@ -88,119 +91,138 @@ export default function tableColMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: dictionary.alignLeft,
|
||||
icon: <AlignLeftIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "left",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: dictionary.alignCenter,
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "center" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "center",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: dictionary.alignRight,
|
||||
icon: <AlignRightIcon />,
|
||||
attrs: { index, alignment: "right" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "right",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: dictionary.sortAsc,
|
||||
attrs: { index, direction: "asc" },
|
||||
icon: <SortAscendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: dictionary.sortDesc,
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <SortDescendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
tooltip: dictionary.background,
|
||||
icon:
|
||||
colColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : colColors.size === 1 ? (
|
||||
<CircleIcon color={colColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
type: MenuType.inline,
|
||||
children: [
|
||||
...[
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
],
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () => colColors.size === 1 && colColors.has(preset.hex),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(customColor
|
||||
? [
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
name: "setColumnAttr",
|
||||
label: dictionary.align,
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
children: [
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleColumnBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
name: "setColumnAttr",
|
||||
label: dictionary.alignLeft,
|
||||
icon: <AlignLeftIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "left",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: dictionary.alignCenter,
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "center" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "center",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: dictionary.alignRight,
|
||||
icon: <AlignRightIcon />,
|
||||
attrs: { index, alignment: "right" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "right",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
label: dictionary.sort,
|
||||
icon: <SortAscendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
children: [
|
||||
{
|
||||
name: "sortTable",
|
||||
label: dictionary.sortAsc,
|
||||
attrs: { index, direction: "asc" },
|
||||
icon: <SortAscendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
label: dictionary.sortDesc,
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <SortDescendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
label: dictionary.background,
|
||||
icon:
|
||||
colColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : colColors.size === 1 ? (
|
||||
<CircleIcon color={colColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
children: [
|
||||
...[
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
],
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () => colColors.size === 1 && colColors.has(preset.hex),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(customColor
|
||||
? [
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleColumnBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "toggleHeaderColumn",
|
||||
label: dictionary.toggleHeader,
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
TrashIcon,
|
||||
InsertAboveIcon,
|
||||
InsertBelowIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderRowIcon,
|
||||
TableSplitCellsIcon,
|
||||
@@ -15,7 +14,11 @@ import {
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
|
||||
import {
|
||||
MenuType,
|
||||
type MenuItem,
|
||||
type NodeAttrMark,
|
||||
} from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
@@ -77,66 +80,66 @@ export default function tableRowMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
tooltip: dictionary.background,
|
||||
icon:
|
||||
rowColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : rowColors.size === 1 ? (
|
||||
<CircleIcon color={rowColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
type: MenuType.inline,
|
||||
children: [
|
||||
...[
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
],
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () => rowColors.size === 1 && rowColors.has(preset.hex),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(customColor
|
||||
? [
|
||||
{
|
||||
label: dictionary.background,
|
||||
icon:
|
||||
rowColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : rowColors.size === 1 ? (
|
||||
<CircleIcon color={rowColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
children: [
|
||||
...[
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
],
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () => rowColors.size === 1 && rowColors.has(preset.hex),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(customColor
|
||||
? [
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleRowBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleRowBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "toggleHeaderRow",
|
||||
label: dictionary.toggleHeader,
|
||||
|
||||
+5
-1
@@ -10,7 +10,11 @@ if (!window.env) {
|
||||
);
|
||||
}
|
||||
|
||||
const env: Record<string, any> = {
|
||||
const env: Record<string, any> & {
|
||||
isDevelopment: boolean;
|
||||
isTest: boolean;
|
||||
isProduction: boolean;
|
||||
} = {
|
||||
...window.env,
|
||||
isDevelopment: window.env.ENVIRONMENT === "development",
|
||||
isTest: window.env.ENVIRONMENT === "test",
|
||||
|
||||
@@ -7,14 +7,25 @@ import useStores from "~/hooks/useStores";
|
||||
import type Model from "~/models/base/Model";
|
||||
import type Policy from "~/models/Policy";
|
||||
import type { ActionContext as ActionContextType } from "~/types";
|
||||
import type { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
|
||||
|
||||
export const ActionContext = createContext<ActionContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
interface ActionContextProviderValue {
|
||||
/** Models to add to the active models context for this subtree. */
|
||||
activeModels?: Model[];
|
||||
isMenu?: boolean;
|
||||
isCommandBar?: boolean;
|
||||
isButton?: boolean;
|
||||
sidebarContext?: SidebarContextType;
|
||||
event?: Event;
|
||||
}
|
||||
|
||||
type ActionContextProviderProps = {
|
||||
children: ReactNode;
|
||||
value?: Partial<ActionContextType>;
|
||||
value?: ActionContextProviderValue;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -23,15 +34,15 @@ type ActionContextProviderProps = {
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Override context for a command bar
|
||||
* <ActionContextProvider value={{ isCommandBar: true }}>
|
||||
* <CommandBar />
|
||||
* // Override active models for a collection menu
|
||||
* <ActionContextProvider value={{ activeModels: [collection] }}>
|
||||
* <CollectionMenu />
|
||||
* </ActionContextProvider>
|
||||
*
|
||||
* // Nested overrides
|
||||
* <ActionContextProvider value={{ activeCollectionId: "collection-1" }}>
|
||||
* <ActionContextProvider value={{ activeModels: [collection] }}>
|
||||
* <CollectionView />
|
||||
* <ActionContextProvider value={{ activeDocumentId: "doc-1" }}>
|
||||
* <ActionContextProvider value={{ activeModels: [document] }}>
|
||||
* <DocumentView />
|
||||
* </ActionContextProvider>
|
||||
* </ActionContextProvider>
|
||||
@@ -45,6 +56,7 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
|
||||
const stores = useStores();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { activeModels: valueModels, ...overrides } = value;
|
||||
|
||||
// Create the base context if we don't have a parent context
|
||||
const baseContext: ActionContextType = parentContext ?? {
|
||||
@@ -56,7 +68,6 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
|
||||
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
|
||||
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
|
||||
|
||||
// New API
|
||||
getActiveModels: <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
): T[] => stores.ui.getActiveModels<T>(modelClass),
|
||||
@@ -74,7 +85,7 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
|
||||
.filter((policy): policy is Policy => policy !== undefined),
|
||||
|
||||
isModelActive: (model: Model): boolean => stores.ui.isModelActive(model),
|
||||
activeModels: stores.ui.activeModels,
|
||||
activeModels: new Set(stores.ui.activeModels.values()),
|
||||
|
||||
currentUserId: stores.auth.user?.id,
|
||||
currentTeamId: stores.auth.team?.id,
|
||||
@@ -83,10 +94,58 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
|
||||
t,
|
||||
};
|
||||
|
||||
// Merge the parent context with the provided overrides
|
||||
// Override model accessors when models are provided in value
|
||||
const getActiveModels =
|
||||
valueModels && valueModels.length > 0
|
||||
? <T extends Model>(modelClass: new (...args: any[]) => T): T[] => {
|
||||
const matching = valueModels.filter(
|
||||
(model): model is T => model instanceof modelClass
|
||||
);
|
||||
return matching.length > 0
|
||||
? matching
|
||||
: baseContext.getActiveModels(modelClass);
|
||||
}
|
||||
: baseContext.getActiveModels;
|
||||
|
||||
const getActiveModel = <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
): T | undefined => getActiveModels(modelClass)[0];
|
||||
|
||||
const getActivePolicies = <T extends Model>(
|
||||
modelClass: new (...args: any[]) => T
|
||||
): Policy[] =>
|
||||
getActiveModels(modelClass)
|
||||
.map((node) => stores.policies.get(node.id))
|
||||
.filter((policy): policy is Policy => policy !== undefined);
|
||||
|
||||
const allActiveModels =
|
||||
valueModels && valueModels.length > 0
|
||||
? new Set([...baseContext.activeModels, ...valueModels])
|
||||
: baseContext.activeModels;
|
||||
|
||||
const isModelActive = (model: Model): boolean => allActiveModels.has(model);
|
||||
|
||||
// Derive legacy IDs from value models, falling back to base context
|
||||
const activeCollectionId =
|
||||
valueModels?.find(
|
||||
(m) => (m.constructor as typeof Model).modelName === "Collection"
|
||||
)?.id ?? baseContext.activeCollectionId;
|
||||
|
||||
const activeDocumentId =
|
||||
valueModels?.find(
|
||||
(m) => (m.constructor as typeof Model).modelName === "Document"
|
||||
)?.id ?? baseContext.activeDocumentId;
|
||||
|
||||
const contextValue: ActionContextType = {
|
||||
...baseContext,
|
||||
...value,
|
||||
...overrides,
|
||||
activeCollectionId,
|
||||
activeDocumentId,
|
||||
getActiveModels,
|
||||
getActiveModel,
|
||||
getActivePolicies,
|
||||
isModelActive,
|
||||
activeModels: allActiveModels,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function useDictionary() {
|
||||
moveColumnRight: t("Move right"),
|
||||
addRowAfter: t("Insert after"),
|
||||
addRowBefore: t("Insert before"),
|
||||
align: t("Align"),
|
||||
alignCenter: t("Align center"),
|
||||
alignLeft: t("Align left"),
|
||||
alignRight: t("Align right"),
|
||||
@@ -26,6 +27,9 @@ export default function useDictionary() {
|
||||
alignFullWidth: t("Full width"),
|
||||
bulletList: t("Bulleted list"),
|
||||
checkboxList: t("Todo list"),
|
||||
showCompleted: (count: number) =>
|
||||
t("Show {{ count }} completed", { count }),
|
||||
hideCompleted: t("Hide completed"),
|
||||
codeBlock: t("Code block"),
|
||||
codeCopied: t("Copied to clipboard"),
|
||||
codeInline: t("Code"),
|
||||
@@ -90,6 +94,7 @@ export default function useDictionary() {
|
||||
strikethrough: t("Strikethrough"),
|
||||
strong: t("Bold"),
|
||||
subheading: t("Subheading"),
|
||||
sort: t("Sort"),
|
||||
sortAsc: t("Sort ascending"),
|
||||
sortDesc: t("Sort descending"),
|
||||
table: t("Table"),
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
unstarDocument,
|
||||
editDocument,
|
||||
shareDocument,
|
||||
createNestedDocument,
|
||||
createNewDocument,
|
||||
importDocument,
|
||||
createTemplateFromDocument,
|
||||
duplicateDocument,
|
||||
@@ -19,10 +19,8 @@ import {
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory,
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
@@ -36,7 +34,7 @@ import {
|
||||
} from "~/actions/definitions/documents";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
import useMobile from "./useMobile";
|
||||
import type Document from "~/models/Document";
|
||||
import type Template from "~/models/Template";
|
||||
import usePolicy from "./usePolicy";
|
||||
import useCurrentUser from "./useCurrentUser";
|
||||
import { useTemplateMenuActions } from "./useTemplateMenuActions";
|
||||
@@ -50,7 +48,7 @@ type Props = {
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
onRename?: () => void;
|
||||
/** Callback when a template is selected to apply its content to the document */
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
onSelectTemplate?: (template: Template) => void;
|
||||
};
|
||||
|
||||
export function useDocumentMenuAction({
|
||||
@@ -94,18 +92,16 @@ export function useDocumentMenuAction({
|
||||
perform: () => requestAnimationFrame(() => onRename?.()),
|
||||
}),
|
||||
shareDocument,
|
||||
createNestedDocument,
|
||||
importDocument,
|
||||
createTemplateFromDocument,
|
||||
duplicateDocument,
|
||||
publishDocument,
|
||||
unpublishDocument,
|
||||
archiveDocument,
|
||||
moveDocument,
|
||||
moveTemplate,
|
||||
applyTemplateFactory({ actions: templateMenuActions }),
|
||||
importDocument,
|
||||
createNewDocument,
|
||||
pinDocument,
|
||||
createDocumentFromTemplate,
|
||||
ActionSeparator,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react";
|
||||
import { TrashIcon } from "outline-icons";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type Emoji from "~/models/Emoji";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { createAction } from "~/actions";
|
||||
import { EmojiSecion } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for emoji management operations.
|
||||
*
|
||||
* @param targetEmoji - the emoji to build actions for, or null to skip.
|
||||
* @returns action with children for use in menus, or undefined if emoji is null.
|
||||
*/
|
||||
export function useEmojiMenuActions(targetEmoji: Emoji | null) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const can = usePolicy(targetEmoji ?? ({} as Emoji));
|
||||
|
||||
const openDeleteDialog = React.useCallback(() => {
|
||||
if (!targetEmoji) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Delete Emoji"),
|
||||
content: (
|
||||
<DeleteEmojiDialog emoji={targetEmoji} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, targetEmoji, dialogs]);
|
||||
|
||||
const actionList = React.useMemo(
|
||||
() =>
|
||||
!targetEmoji || !can.delete
|
||||
? []
|
||||
: [
|
||||
createAction({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
section: EmojiSecion,
|
||||
visible: true,
|
||||
dangerous: true,
|
||||
perform: openDeleteDialog,
|
||||
}),
|
||||
],
|
||||
[t, targetEmoji, can.delete, openDeleteDialog]
|
||||
);
|
||||
|
||||
return useMenuAction(actionList);
|
||||
}
|
||||
|
||||
const DeleteEmojiDialog = ({
|
||||
emoji,
|
||||
onSubmit,
|
||||
}: {
|
||||
emoji: Emoji;
|
||||
onSubmit: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (emoji) {
|
||||
await emoji.delete();
|
||||
onSubmit();
|
||||
toast.success(t("Emoji deleted"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I'm sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections."
|
||||
values={{
|
||||
emojiName: emoji.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import * as React from "react";
|
||||
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Group from "~/models/Group";
|
||||
import {
|
||||
DeleteGroupDialog,
|
||||
EditGroupDialog,
|
||||
ViewGroupMembersDialog,
|
||||
} from "~/scenes/Settings/components/GroupDialogs";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
ActionSeparator,
|
||||
createAction,
|
||||
createExternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { GroupSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for group management operations.
|
||||
*
|
||||
* @param targetGroup - the group to build actions for, or null to skip.
|
||||
* @returns action with children for use in menus, or undefined if group is null.
|
||||
*/
|
||||
export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const can = usePolicy(targetGroup ?? ({} as Group));
|
||||
|
||||
const openMembersDialog = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={targetGroup} />,
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
|
||||
const openEditDialog = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Edit group"),
|
||||
content: (
|
||||
<EditGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
|
||||
const openDeleteDialog = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Delete group"),
|
||||
content: (
|
||||
<DeleteGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
|
||||
const actionList = React.useMemo(
|
||||
() =>
|
||||
!targetGroup
|
||||
? []
|
||||
: [
|
||||
createAction({
|
||||
name: `${t("Members")}…`,
|
||||
icon: <GroupIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.read),
|
||||
perform: openMembersDialog,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createAction({
|
||||
name: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.update),
|
||||
perform: openEditDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.delete),
|
||||
dangerous: true,
|
||||
perform: openDeleteDialog,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createExternalLinkAction({
|
||||
name: targetGroup.externalId ?? "",
|
||||
section: GroupSection,
|
||||
visible: !!targetGroup.externalId,
|
||||
disabled: true,
|
||||
url: "",
|
||||
}),
|
||||
],
|
||||
[
|
||||
t,
|
||||
targetGroup,
|
||||
can.read,
|
||||
can.update,
|
||||
can.delete,
|
||||
openMembersDialog,
|
||||
openEditDialog,
|
||||
openDeleteDialog,
|
||||
]
|
||||
);
|
||||
|
||||
return useMenuAction(actionList);
|
||||
}
|
||||
@@ -45,6 +45,8 @@ export default function useImportDocument(
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const toastId = toast.loading(`${t("Uploading")}…`);
|
||||
|
||||
try {
|
||||
const doc = await documents.import(file, documentId, cId, {
|
||||
publish: true,
|
||||
@@ -55,6 +57,8 @@ export default function useImportDocument(
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { getCookie, removeCookie, setCookie } from "tiny-cookie";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePersistedState, { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import Logger from "~/utils/Logger";
|
||||
import history from "~/utils/history";
|
||||
import { isAllowedLoginRedirect } from "~/utils/urls";
|
||||
@@ -30,6 +30,23 @@ export function useLastVisitedPath(): [string, (path: string) => void] {
|
||||
return [lastVisitedPath, setPathAsLastVisitedPath] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that automatically tracks the current path as the last visited path.
|
||||
* This uses a ref to track the previous path and updates localStorage directly
|
||||
* without using useEffect to avoid React Doctor warnings.
|
||||
*
|
||||
* @param currentPath The current path to track.
|
||||
*/
|
||||
export function useTrackLastVisitedPath(currentPath: string): void {
|
||||
const prevPathRef = useRef<string>();
|
||||
|
||||
// Update localStorage directly if path has changed
|
||||
if (prevPathRef.current !== currentPath && isAllowedLoginRedirect(currentPath)) {
|
||||
prevPathRef.current = currentPath;
|
||||
setPersistedState("lastVisitedPath", currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the path that the user visited before being asked to login.
|
||||
*
|
||||
|
||||
@@ -92,6 +92,14 @@ export default function usePaginatedRequest<T = unknown>(
|
||||
setData(undefined);
|
||||
setPage(0);
|
||||
setOffset(0);
|
||||
setPaginatedReq(
|
||||
() => () =>
|
||||
requestFn({
|
||||
...params,
|
||||
offset: 0,
|
||||
limit: fetchLimit,
|
||||
})
|
||||
);
|
||||
}, [requestFn]);
|
||||
|
||||
return { data, next, loading, error, page, offset, end };
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Icon } from "outline-icons";
|
||||
import {
|
||||
EmailIcon,
|
||||
ProfileIcon,
|
||||
@@ -9,7 +8,7 @@ import {
|
||||
GlobeIcon,
|
||||
ShieldIcon,
|
||||
TeamIcon,
|
||||
BeakerIcon,
|
||||
SparklesIcon,
|
||||
SettingsIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
SmileyIcon,
|
||||
BuildingBlocksIcon,
|
||||
} from "outline-icons";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
@@ -54,7 +52,11 @@ const CustomEmojis = lazy(() => import("~/scenes/Settings/CustomEmojis"));
|
||||
export type ConfigItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
icon: React.FC<ComponentProps<typeof Icon>>;
|
||||
icon: React.FC<{
|
||||
size?: number;
|
||||
fill?: string;
|
||||
monochrome?: boolean;
|
||||
}>;
|
||||
component: React.ComponentType;
|
||||
description?: string;
|
||||
preload?: () => void;
|
||||
@@ -142,13 +144,13 @@ const useSettingsConfig = () => {
|
||||
icon: ShieldIcon,
|
||||
},
|
||||
{
|
||||
name: t("Features"),
|
||||
name: t("AI"),
|
||||
path: settingsPath("features"),
|
||||
component: Features.Component,
|
||||
preload: Features.preload,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: BeakerIcon,
|
||||
icon: SparklesIcon,
|
||||
},
|
||||
{
|
||||
name: t("Members"),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from "react";
|
||||
import type Share from "~/models/Share";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { ActionSeparator } from "~/actions";
|
||||
import {
|
||||
copyShareUrlFactory,
|
||||
goToShareSourceFactory,
|
||||
revokeShareFactory,
|
||||
} from "~/actions/definitions/shares";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for share management operations.
|
||||
*
|
||||
* @param targetShare - the share to build actions for, or null to skip.
|
||||
* @returns action with children for use in menus, or undefined if share is null.
|
||||
*/
|
||||
export function useShareMenuActions(targetShare: Share | null) {
|
||||
const can = usePolicy(targetShare ?? ({} as Share));
|
||||
|
||||
const actionList = React.useMemo(
|
||||
() =>
|
||||
!targetShare
|
||||
? []
|
||||
: [
|
||||
copyShareUrlFactory({ share: targetShare }),
|
||||
goToShareSourceFactory({ share: targetShare }),
|
||||
ActionSeparator,
|
||||
revokeShareFactory({ share: targetShare, can }),
|
||||
],
|
||||
[targetShare, can]
|
||||
);
|
||||
|
||||
return useMenuAction(actionList);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import type Document from "~/models/Document";
|
||||
import type Template from "~/models/Template";
|
||||
import { ActionSeparator, createAction, createActionGroup } from "~/actions";
|
||||
import { DocumentsSection } from "~/actions/sections";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -15,7 +15,7 @@ type Props = {
|
||||
/** The document to which the templates will be applied */
|
||||
documentId: string;
|
||||
/** Callback to handle when a template is selected */
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
onSelectTemplate?: (template: Template) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -34,12 +34,12 @@ export function useTemplateMenuActions({
|
||||
onSelectTemplate,
|
||||
}: Props) {
|
||||
const user = useCurrentUser();
|
||||
const { documents } = useStores();
|
||||
const { documents, templates: templatesStore } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const document = documents.get(documentId);
|
||||
|
||||
const templateToAction = useCallback(
|
||||
(template: Document): Action =>
|
||||
(template: Template): Action =>
|
||||
createAction({
|
||||
name: TextHelper.replaceTemplateVariables(
|
||||
template.titleWithDefault,
|
||||
@@ -66,8 +66,8 @@ export function useTemplateMenuActions({
|
||||
return [];
|
||||
}
|
||||
|
||||
const templates = documents.templates.filter(
|
||||
(template) => template.publishedAt
|
||||
const templates = templatesStore.orderedData.filter(
|
||||
(template) => template.isActive
|
||||
);
|
||||
|
||||
const collectionTemplatesActions = templates
|
||||
@@ -82,6 +82,13 @@ export function useTemplateMenuActions({
|
||||
.filter((tmpl) => tmpl.isWorkspaceTemplate)
|
||||
.map(templateToAction);
|
||||
|
||||
if (
|
||||
!collectionTemplatesActions.length &&
|
||||
!workspaceTemplatesActions.length
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...collectionTemplatesActions,
|
||||
ActionSeparator,
|
||||
@@ -90,5 +97,5 @@ export function useTemplateMenuActions({
|
||||
actions: workspaceTemplatesActions,
|
||||
}),
|
||||
];
|
||||
}, []);
|
||||
}, [document?.collectionId, templateToAction, t]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
import { DuplicateIcon, EditIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Template from "~/models/Template";
|
||||
import { ActionSeparator, createAction } from "~/actions";
|
||||
import {
|
||||
copyTemplate,
|
||||
deleteTemplate,
|
||||
moveTemplate,
|
||||
} from "~/actions/definitions/templates";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for template management operations.
|
||||
*
|
||||
* @param template - the template to build actions for, or null to skip.
|
||||
* @param onEdit - optional callback to handle editing the template.
|
||||
* @returns action with children for use in menus.
|
||||
*/
|
||||
export function useTemplateSettingsActions(
|
||||
template: Template | null,
|
||||
onEdit?: () => void
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { templates } = useStores();
|
||||
const can = usePolicy(template ?? ({} as Template));
|
||||
|
||||
const section = "Template";
|
||||
const actions = React.useMemo(
|
||||
() =>
|
||||
!template
|
||||
? []
|
||||
: [
|
||||
createAction({
|
||||
name: `${t("Edit")}…`,
|
||||
visible: !!can.update && !!onEdit,
|
||||
icon: <EditIcon />,
|
||||
section,
|
||||
perform: () => onEdit?.(),
|
||||
}),
|
||||
createAction({
|
||||
name: t("Duplicate"),
|
||||
visible: !!can.duplicate,
|
||||
icon: <DuplicateIcon />,
|
||||
section,
|
||||
perform: () => templates.duplicate(template),
|
||||
}),
|
||||
moveTemplate,
|
||||
ActionSeparator,
|
||||
copyTemplate,
|
||||
ActionSeparator,
|
||||
deleteTemplate,
|
||||
],
|
||||
[can.update, can.duplicate, onEdit, t, template, templates]
|
||||
);
|
||||
|
||||
return useMenuAction(actions);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import type User from "~/models/User";
|
||||
import {
|
||||
ActionSeparator,
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
} from "~/actions";
|
||||
import {
|
||||
deleteUserActionFactory,
|
||||
updateUserRoleActionFactory,
|
||||
} from "~/actions/definitions/users";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
UserSuspendDialog,
|
||||
UserChangeNameDialog,
|
||||
UserChangeEmailDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for user management operations.
|
||||
*
|
||||
* @param targetUser - the user to build actions for, or null to skip.
|
||||
* @returns action with children for use in menus, or undefined if user is null.
|
||||
*/
|
||||
export function useUserMenuActions(targetUser: User | null) {
|
||||
const { users, dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(targetUser ?? ({} as User));
|
||||
|
||||
const openNameDialog = React.useCallback(() => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Change name"),
|
||||
content: (
|
||||
<UserChangeNameDialog
|
||||
user={targetUser}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, targetUser]);
|
||||
|
||||
const openEmailDialog = React.useCallback(() => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Change email"),
|
||||
content: (
|
||||
<UserChangeEmailDialog
|
||||
user={targetUser}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, targetUser]);
|
||||
|
||||
const openSuspendDialog = React.useCallback(() => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Suspend user"),
|
||||
content: (
|
||||
<UserSuspendDialog
|
||||
user={targetUser}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, targetUser]);
|
||||
|
||||
const revokeInvitation = React.useCallback(async () => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
await users.delete(targetUser);
|
||||
}, [users, targetUser]);
|
||||
|
||||
const resendInvitation = React.useCallback(async () => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await users.resendInvite(targetUser);
|
||||
toast.success(t(`Invite was resent to ${targetUser.name}`));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err.message ?? t(`An error occurred while sending the invite`)
|
||||
);
|
||||
}
|
||||
}, [users, targetUser, t]);
|
||||
|
||||
const activateUser = React.useCallback(async () => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
await users.activate(targetUser);
|
||||
}, [users, targetUser]);
|
||||
|
||||
const roleChangeActions = React.useMemo(
|
||||
() =>
|
||||
targetUser
|
||||
? [UserRole.Admin, UserRole.Member, UserRole.Viewer].map((role) =>
|
||||
updateUserRoleActionFactory(targetUser, role)
|
||||
)
|
||||
: [],
|
||||
[targetUser]
|
||||
);
|
||||
|
||||
const actionList = React.useMemo(
|
||||
() =>
|
||||
!targetUser
|
||||
? []
|
||||
: [
|
||||
createActionWithChildren({
|
||||
name: t("Change role"),
|
||||
section: UserSection,
|
||||
visible: can.demote || can.promote,
|
||||
children: roleChangeActions,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change name")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: openNameDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change email")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: openEmailDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: t("Resend invite"),
|
||||
section: UserSection,
|
||||
visible: can.resendInvite,
|
||||
perform: resendInvitation,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createAction({
|
||||
name: `${t("Revoke invite")}…`,
|
||||
section: UserSection,
|
||||
visible: targetUser.isInvited,
|
||||
dangerous: true,
|
||||
perform: revokeInvitation,
|
||||
}),
|
||||
createAction({
|
||||
name: t("Activate user"),
|
||||
section: UserSection,
|
||||
visible: !targetUser.isInvited && targetUser.isSuspended,
|
||||
perform: activateUser,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Suspend user")}…`,
|
||||
section: UserSection,
|
||||
visible: !targetUser.isInvited && !targetUser.isSuspended,
|
||||
dangerous: true,
|
||||
perform: openSuspendDialog,
|
||||
}),
|
||||
ActionSeparator,
|
||||
deleteUserActionFactory(targetUser.id),
|
||||
],
|
||||
[
|
||||
t,
|
||||
targetUser,
|
||||
can.demote,
|
||||
can.promote,
|
||||
can.update,
|
||||
can.resendInvite,
|
||||
roleChangeActions,
|
||||
openNameDialog,
|
||||
openEmailDialog,
|
||||
resendInvitation,
|
||||
revokeInvitation,
|
||||
activateUser,
|
||||
openSuspendDialog,
|
||||
]
|
||||
);
|
||||
|
||||
return useMenuAction(actionList);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import "vite/modulepreload-polyfill";
|
||||
import { LazyMotion } from "framer-motion";
|
||||
import { KBarProvider } from "kbar";
|
||||
import { Provider } from "mobx-react";
|
||||
import { configure as configureMobx } from "mobx";
|
||||
import { StrictMode } from "react";
|
||||
import { render } from "react-dom";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
@@ -37,6 +38,13 @@ if (env.SENTRY_DSN) {
|
||||
initSentry(history);
|
||||
}
|
||||
|
||||
configureMobx({
|
||||
// TODO: Enable these options and fix any resulting warnings
|
||||
// enforceActions: env.isDevelopment ? "always" : "never",
|
||||
computedRequiresReaction: true,
|
||||
isolateGlobalState: true,
|
||||
});
|
||||
|
||||
// Make sure to return the specific export containing the feature bundle.
|
||||
const loadFeatures = () => import("./utils/motion").then((res) => res.default);
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ function CollectionMenu({
|
||||
});
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeCollectionId: collection.id }}>
|
||||
<ActionContextProvider value={{ activeModels: [collection] }}>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align={align}
|
||||
|
||||
@@ -7,6 +7,7 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { SubscriptionType, UserPreference } from "@shared/types";
|
||||
import type Document from "~/models/Document";
|
||||
import type Template from "~/models/Template";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import Switch from "~/components/Switch";
|
||||
@@ -33,7 +34,7 @@ type Props = {
|
||||
/** Invoked when the "Find and replace" menu item is clicked */
|
||||
onFindAndReplace?: () => void;
|
||||
/** Callback when a template is selected to apply its content to the document */
|
||||
onSelectTemplate?: (template: Document) => void;
|
||||
onSelectTemplate?: (template: Template) => void;
|
||||
/** Invoked when the "Rename" menu item is clicked */
|
||||
onRename?: () => void;
|
||||
/** Invoked when menu is opened */
|
||||
@@ -198,11 +199,10 @@ function DocumentMenu({
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId:
|
||||
!isShared && document.collectionId
|
||||
? document.collectionId
|
||||
: undefined,
|
||||
activeModels: [
|
||||
document,
|
||||
...(!isShared && document.collection ? [document.collection] : []),
|
||||
],
|
||||
}}
|
||||
>
|
||||
<DropdownMenu
|
||||
|
||||
+23
-70
@@ -1,75 +1,28 @@
|
||||
import { TrashIcon } from "outline-icons";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import { IconButton } from "~/components/IconPicker/components/IconButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Emoji from "~/models/Emoji";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import { useEmojiMenuActions } from "~/hooks/useEmojiMenuActions";
|
||||
|
||||
const EmojisMenu = ({ emoji }: { emoji: Emoji }) => {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const can = usePolicy(emoji);
|
||||
|
||||
const handleDelete = () => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete Emoji"),
|
||||
content: (
|
||||
<DeleteEmojiDialog emoji={emoji} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
if (!can.delete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={t("Delete Emoji")}>
|
||||
<IconButton onClick={handleDelete}>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteEmojiDialog = ({
|
||||
emoji,
|
||||
onSubmit,
|
||||
}: {
|
||||
type Props = {
|
||||
emoji: Emoji;
|
||||
onSubmit: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (emoji) {
|
||||
await emoji.delete();
|
||||
onSubmit();
|
||||
toast.success(t("Emoji deleted"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections."
|
||||
values={{
|
||||
emojiName: emoji.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojisMenu;
|
||||
function EmojisMenu({ emoji }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const rootAction = useEmojiMenuActions(emoji);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align="end"
|
||||
ariaLabel={t("Emoji options")}
|
||||
>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(EmojisMenu);
|
||||
|
||||
+3
-91
@@ -1,24 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Group from "~/models/Group";
|
||||
import {
|
||||
DeleteGroupDialog,
|
||||
EditGroupDialog,
|
||||
ViewGroupMembersDialog,
|
||||
} from "~/scenes/Settings/components/GroupDialogs";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
ActionSeparator,
|
||||
createAction,
|
||||
createExternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { GroupSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
@@ -26,81 +12,7 @@ type Props = {
|
||||
|
||||
function GroupMenu({ group }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const can = usePolicy(group);
|
||||
|
||||
const handleViewMembers = useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={group} />,
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleEditGroup = useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Edit group"),
|
||||
content: (
|
||||
<EditGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleDeleteGroup = useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete group"),
|
||||
content: (
|
||||
<DeleteGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
createAction({
|
||||
name: `${t("Members")}…`,
|
||||
icon: <GroupIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(group && can.read),
|
||||
perform: handleViewMembers,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createAction({
|
||||
name: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(group && can.update),
|
||||
perform: handleEditGroup,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(group && can.delete),
|
||||
dangerous: true,
|
||||
perform: handleDeleteGroup,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createExternalLinkAction({
|
||||
name: group.externalId ?? "",
|
||||
section: GroupSection,
|
||||
visible: !!group.externalId,
|
||||
disabled: true,
|
||||
url: "",
|
||||
}),
|
||||
],
|
||||
[
|
||||
t,
|
||||
group,
|
||||
can.read,
|
||||
can.update,
|
||||
can.delete,
|
||||
handleViewMembers,
|
||||
handleEditGroup,
|
||||
handleDeleteGroup,
|
||||
]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
const rootAction = useGroupMenuActions(group);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { newTemplatePath } from "~/utils/routeHelpers";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
|
||||
function NewTemplateMenu() {
|
||||
const { t } = useTranslation();
|
||||
@@ -44,7 +45,7 @@ function NewTemplateMenu() {
|
||||
createInternalLinkAction({
|
||||
name: t("Save in workspace"),
|
||||
section: DocumentSection,
|
||||
icon: <TeamLogo model={team} />,
|
||||
icon: <TeamLogo model={team} size={AvatarSize.Small} />,
|
||||
visible: can.createTemplate,
|
||||
to: newTemplatePath(),
|
||||
}),
|
||||
|
||||
@@ -33,7 +33,7 @@ function RevisionMenu({ document, revisionId }: Props) {
|
||||
const rootAction = useMenuAction(actions);
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeDocumentId: document.id }}>
|
||||
<ActionContextProvider value={{ activeModels: [document] }}>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align="end"
|
||||
|
||||
+2
-21
@@ -4,14 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type Share from "~/models/Share";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { ActionSeparator } from "~/actions";
|
||||
import {
|
||||
copyShareUrlFactory,
|
||||
goToShareSourceFactory,
|
||||
revokeShareFactory,
|
||||
} from "~/actions/definitions/shares";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { useShareMenuActions } from "~/hooks/useShareMenuActions";
|
||||
|
||||
type Props = {
|
||||
share: Share;
|
||||
@@ -19,19 +12,7 @@ type Props = {
|
||||
|
||||
function ShareMenu({ share }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(share);
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
copyShareUrlFactory({ share }),
|
||||
goToShareSourceFactory({ share }),
|
||||
ActionSeparator,
|
||||
revokeShareFactory({ share, can }),
|
||||
],
|
||||
[share, can]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
const rootAction = useShareMenuActions(share);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Template from "~/models/Template";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useTemplateSettingsActions } from "~/hooks/useTemplateSettingsActions";
|
||||
|
||||
type Props = {
|
||||
template: Template;
|
||||
onEdit?: () => void;
|
||||
};
|
||||
|
||||
function TemplateMenu({ template, onEdit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const rootAction = useTemplateSettingsActions(template, onEdit);
|
||||
|
||||
return (
|
||||
<ActionContextProvider value={{ activeModels: [template] }}>
|
||||
<DropdownMenu
|
||||
action={rootAction}
|
||||
align="end"
|
||||
ariaLabel={t("Template options")}
|
||||
>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
</ActionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(TemplateMenu);
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { ShapesIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Document from "~/models/Document";
|
||||
import type Template from "~/models/Template";
|
||||
import Button from "~/components/Button";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
@@ -13,7 +14,7 @@ type Props = {
|
||||
/** Whether to render the button as a compact icon */
|
||||
isCompact?: boolean;
|
||||
/** Callback to handle when a template is selected */
|
||||
onSelectTemplate: (template: Document) => void;
|
||||
onSelectTemplate: (template: Template) => void;
|
||||
};
|
||||
|
||||
function TemplatesMenu({ isCompact, onSelectTemplate, document }: Props) {
|
||||
|
||||
+2
-147
@@ -1,163 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import type User from "~/models/User";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import {
|
||||
UserSuspendDialog,
|
||||
UserChangeNameDialog,
|
||||
UserChangeEmailDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
import {
|
||||
ActionSeparator,
|
||||
createAction,
|
||||
createActionWithChildren,
|
||||
} from "~/actions";
|
||||
import {
|
||||
deleteUserActionFactory,
|
||||
updateUserRoleActionFactory,
|
||||
} from "~/actions/definitions/users";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useUserMenuActions } from "~/hooks/useUserMenuActions";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
function UserMenu({ user }: Props) {
|
||||
const { users, dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(user);
|
||||
|
||||
const handleChangeName = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Change name"),
|
||||
content: (
|
||||
<UserChangeNameDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, user]);
|
||||
|
||||
const handleChangeEmail = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Change email"),
|
||||
content: (
|
||||
<UserChangeEmailDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, user]);
|
||||
|
||||
const handleSuspend = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Suspend user"),
|
||||
content: (
|
||||
<UserSuspendDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, user]);
|
||||
|
||||
const handleRevoke = React.useCallback(async () => {
|
||||
await users.delete(user);
|
||||
}, [users, user]);
|
||||
|
||||
const handleResendInvite = React.useCallback(async () => {
|
||||
try {
|
||||
await users.resendInvite(user);
|
||||
toast.success(t(`Invite was resent to ${user.name}`));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err.message ?? t(`An error occurred while sending the invite`)
|
||||
);
|
||||
}
|
||||
}, [users, user, t]);
|
||||
|
||||
const handleActivate = React.useCallback(async () => {
|
||||
await users.activate(user);
|
||||
}, [users, user]);
|
||||
|
||||
const changeRoleActions = React.useMemo(
|
||||
() =>
|
||||
[UserRole.Admin, UserRole.Member, UserRole.Viewer].map((role) =>
|
||||
updateUserRoleActionFactory(user, role)
|
||||
),
|
||||
[user]
|
||||
);
|
||||
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
createActionWithChildren({
|
||||
name: t("Change role"),
|
||||
section: UserSection,
|
||||
visible: can.demote || can.promote,
|
||||
children: changeRoleActions,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change name")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: handleChangeName,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change email")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: handleChangeEmail,
|
||||
}),
|
||||
createAction({
|
||||
name: t("Resend invite"),
|
||||
section: UserSection,
|
||||
visible: can.resendInvite,
|
||||
perform: handleResendInvite,
|
||||
}),
|
||||
ActionSeparator,
|
||||
createAction({
|
||||
name: `${t("Revoke invite")}…`,
|
||||
section: UserSection,
|
||||
visible: user.isInvited,
|
||||
dangerous: true,
|
||||
perform: handleRevoke,
|
||||
}),
|
||||
createAction({
|
||||
name: t("Activate user"),
|
||||
section: UserSection,
|
||||
visible: !user.isInvited && user.isSuspended,
|
||||
perform: handleActivate,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Suspend user")}…`,
|
||||
section: UserSection,
|
||||
visible: !user.isInvited && !user.isSuspended,
|
||||
dangerous: true,
|
||||
perform: handleSuspend,
|
||||
}),
|
||||
ActionSeparator,
|
||||
deleteUserActionFactory(user.id),
|
||||
],
|
||||
[
|
||||
t,
|
||||
can.demote,
|
||||
can.promote,
|
||||
can.update,
|
||||
can.resendInvite,
|
||||
user.id,
|
||||
user.isInvited,
|
||||
user.isSuspended,
|
||||
changeRoleActions,
|
||||
handleChangeName,
|
||||
handleChangeEmail,
|
||||
handleResendInvite,
|
||||
handleRevoke,
|
||||
handleActivate,
|
||||
handleSuspend,
|
||||
]
|
||||
);
|
||||
|
||||
const rootAction = useMenuAction(actions);
|
||||
const rootAction = useUserMenuActions(user);
|
||||
|
||||
return (
|
||||
<DropdownMenu action={rootAction} align="end" ariaLabel={t("User options")}>
|
||||
|
||||
+3
-30
@@ -21,7 +21,6 @@ import type DocumentsStore from "~/stores/DocumentsStore";
|
||||
import User from "~/models/User";
|
||||
import type { Properties } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import Collection from "./Collection";
|
||||
import type Notification from "./Notification";
|
||||
import type View from "./View";
|
||||
@@ -150,12 +149,6 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
@observable
|
||||
color?: string | null;
|
||||
|
||||
/**
|
||||
* Whether this is a template.
|
||||
*/
|
||||
@observable
|
||||
template: boolean;
|
||||
|
||||
/**
|
||||
* Whether the document layout is displayed full page width.
|
||||
*/
|
||||
@@ -280,8 +273,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
|
||||
@computed
|
||||
get path(): string {
|
||||
const prefix =
|
||||
this.template && !this.isDeleted ? settingsPath("templates") : "/doc";
|
||||
const prefix = "/doc";
|
||||
|
||||
if (!this.title) {
|
||||
return `${prefix}/untitled-${this.urlId}`;
|
||||
@@ -293,7 +285,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
|
||||
@computed
|
||||
get noun(): string {
|
||||
return this.template ? t("template") : t("document");
|
||||
return t("document");
|
||||
}
|
||||
|
||||
@computed
|
||||
@@ -392,11 +384,6 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
return !!this.deletedAt;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isTemplate(): boolean {
|
||||
return !!this.template;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isDraft(): boolean {
|
||||
return !this.publishedAt;
|
||||
@@ -462,11 +449,6 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
return path.map((item) => item.asNavigationNode);
|
||||
}
|
||||
|
||||
@computed
|
||||
get isWorkspaceTemplate() {
|
||||
return this.template && !this.collectionId;
|
||||
}
|
||||
|
||||
get titleWithDefault(): string {
|
||||
return this.title || i18n.t("Untitled");
|
||||
}
|
||||
@@ -580,15 +562,6 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
this.lastViewedAt = view.lastViewedAt;
|
||||
};
|
||||
|
||||
@action
|
||||
templatize = ({
|
||||
collectionId,
|
||||
publish,
|
||||
}: {
|
||||
collectionId: string | null;
|
||||
publish: boolean;
|
||||
}) => this.store.templatize({ id: this.id, collectionId, publish });
|
||||
|
||||
@action
|
||||
save = async (
|
||||
fields?: Properties<typeof this>,
|
||||
@@ -655,7 +628,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
|
||||
@computed
|
||||
get isActive(): boolean {
|
||||
return !this.isDeleted && !this.isTemplate && !this.isArchived;
|
||||
return !this.isDeleted && !this.isArchived;
|
||||
}
|
||||
|
||||
@computed
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { addDays } from "date-fns";
|
||||
import i18n from "i18next";
|
||||
import { computed, observable } from "mobx";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
import slugify from "@shared/utils/slugify";
|
||||
import type TemplatesStore from "~/stores/TemplatesStore";
|
||||
import User from "~/models/User";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import Collection from "./Collection";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
import type { Searchable } from "./interfaces/Searchable";
|
||||
|
||||
export default class Template extends ParanoidModel implements Searchable {
|
||||
static modelName = "Template";
|
||||
|
||||
store: TemplatesStore;
|
||||
|
||||
@Field
|
||||
@observable.shallow
|
||||
data: ProsemirrorData;
|
||||
|
||||
@computed
|
||||
get searchContent(): string {
|
||||
return this.title;
|
||||
}
|
||||
|
||||
@computed
|
||||
get searchSuppressed(): boolean {
|
||||
return this.isDeleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the collection that this template belongs to, if any.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
collectionId?: string | null;
|
||||
|
||||
/**
|
||||
* The collection that this template belongs to.
|
||||
*/
|
||||
@Relation(() => Collection, { onDelete: "cascade" })
|
||||
collection?: Collection;
|
||||
|
||||
/**
|
||||
* The title of the template.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* An icon (or) emoji to use as the template icon.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
icon?: string | null;
|
||||
|
||||
/**
|
||||
* The color to use for the template icon.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
color?: string | null;
|
||||
|
||||
/**
|
||||
* Whether the template layout is displayed full page width.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
fullWidth: boolean;
|
||||
|
||||
/**
|
||||
* The likely language of the template, in ISO 639-1 format.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
language: string | undefined;
|
||||
|
||||
@Relation(() => User)
|
||||
createdBy: User | undefined;
|
||||
|
||||
@Relation(() => User)
|
||||
updatedBy: User | undefined;
|
||||
|
||||
@observable
|
||||
urlId: string;
|
||||
|
||||
/**
|
||||
* Returns the direction of the template text, either "rtl" or "ltr"
|
||||
*/
|
||||
@computed
|
||||
get dir(): "rtl" | "ltr" {
|
||||
return this.rtl ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the template text is right-to-left
|
||||
*/
|
||||
@computed
|
||||
get rtl() {
|
||||
return isRTL(this.title);
|
||||
}
|
||||
|
||||
@computed
|
||||
get path(): string {
|
||||
if (!this.title) {
|
||||
return `${settingsPath("templates")}/untitled-${this.urlId}`;
|
||||
}
|
||||
|
||||
const slugifiedTitle = slugify(this.title);
|
||||
return `${settingsPath("templates")}/${slugifiedTitle}-${this.urlId}`;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isDeleted(): boolean {
|
||||
return !!this.deletedAt;
|
||||
}
|
||||
|
||||
@computed
|
||||
get hasEmptyTitle(): boolean {
|
||||
return this.title === "";
|
||||
}
|
||||
|
||||
@computed
|
||||
get isWorkspaceTemplate(): boolean {
|
||||
return !this.collectionId;
|
||||
}
|
||||
|
||||
@computed
|
||||
get permanentlyDeletedAt(): string | undefined {
|
||||
if (!this.deletedAt) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return addDays(new Date(this.deletedAt), 30).toString();
|
||||
}
|
||||
|
||||
get titleWithDefault(): string {
|
||||
return this.title || i18n.t("Untitled");
|
||||
}
|
||||
|
||||
@computed
|
||||
get initial(): string {
|
||||
return (this.titleWithDefault?.charAt(0) ?? "?").toUpperCase();
|
||||
}
|
||||
|
||||
@computed
|
||||
get isActive(): boolean {
|
||||
return !this.isDeleted;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user