mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
166 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c0f375901 | |||
| 4894e92d6b | |||
| 5e6eebd0ec | |||
| a4badbea9c | |||
| f22bc4a0b2 | |||
| 5693618de4 | |||
| a0039b2a09 | |||
| fa17f78ae6 | |||
| beec9f5675 | |||
| 5256cdc185 | |||
| 1bd6ad830e | |||
| 9efcb2d534 | |||
| 14fc3b01db | |||
| 05eac5bc3b | |||
| 64dc5e8ea7 | |||
| f03ac1f8de | |||
| 07099bb4f6 | |||
| 4673ff0840 | |||
| 500c3f91b0 | |||
| f8098ab464 | |||
| 3740e09e5c | |||
| 62cfd4e9bc | |||
| 85072dab92 | |||
| 1e8d9b5f80 | |||
| 613877714b | |||
| cc1c4b22d4 | |||
| a9401c9bb6 | |||
| 1345471338 | |||
| 0ddddac9c9 | |||
| 24954204ea | |||
| 1a893b0e45 | |||
| 255efe9844 | |||
| 20e55141de | |||
| 9940f48efa | |||
| b1a192c078 | |||
| 22138957ab | |||
| ff0a1766f8 | |||
| d1203408b5 | |||
| 576117e27b | |||
| 4bc0f15323 | |||
| 36d555f3fb | |||
| 350f69e194 | |||
| a92a1785ff | |||
| 631a4b0efa | |||
| 52077e4d47 | |||
| 79fc0b90b9 | |||
| ea4fbdb7bb | |||
| 88f7ef9d03 | |||
| 951fb8a34a | |||
| 0b5bd31017 | |||
| 48c7bd990a | |||
| 54f2994b13 | |||
| 8d9cd25b4e | |||
| 16a4b8417e | |||
| c993305c1b | |||
| 70891d5fa7 | |||
| 89511d4026 | |||
| bd573c44c1 | |||
| 2e50fb0344 | |||
| fee9791cc9 | |||
| e913075d75 | |||
| bb3d72cb83 | |||
| 0d8d9a1798 | |||
| 0c6e4f349b | |||
| a8b701aff3 | |||
| 83977f85bd | |||
| 9f1e6d8b40 | |||
| 257d01af89 | |||
| 1a54625cdb | |||
| 1a380f844c | |||
| 03a78ab6d6 | |||
| b63225fa73 | |||
| 3066b7ba4e | |||
| aeb6d12f17 | |||
| db19a5cf0d | |||
| c875930430 | |||
| 3d1c2a8759 | |||
| 2681a2cfaf | |||
| 1b88189f9c | |||
| ace351035a | |||
| 3222a77cc3 | |||
| d8b0e731ef | |||
| 80c1e5a10b | |||
| 5b89882e5e | |||
| f85ad1a7e1 | |||
| 38a3e651a7 | |||
| e06ff9d7d2 | |||
| 89cfc76d8f | |||
| c22ba4fa0c | |||
| 8dabc7f3cf | |||
| bd7f0cdc12 | |||
| 9a849418b1 | |||
| 0ab3a962d9 | |||
| 2c7ec179fa | |||
| 526833ec6e | |||
| d8f39e3b67 | |||
| 0565616b02 | |||
| dd9b28e898 | |||
| f5ef2f2b30 | |||
| 1f251d4829 | |||
| 749bf49ebe | |||
| 371665b35c | |||
| d20779e7ea | |||
| 1d86a4aee4 | |||
| 19e5888216 | |||
| a4039ccc81 | |||
| f0a7ee6df8 | |||
| c428d551b8 | |||
| 904f1a9726 | |||
| 8dc4f8b422 | |||
| 1ceb476a04 | |||
| 5439afa5c8 | |||
| 8619b219e7 | |||
| 5a14944d0c | |||
| 93509564e0 | |||
| aacad48585 | |||
| 8dbbcb6dce | |||
| 597da9f1ee | |||
| eab57a7144 | |||
| 20f928332e | |||
| 12847d3d79 | |||
| b298456126 | |||
| 4e773d7cb0 | |||
| 0dfab5b245 | |||
| a40ab4867d | |||
| 3270e05abc | |||
| 36f5ce26e2 | |||
| 10303220a5 | |||
| 1c52a3e7da | |||
| 8a7501d0ad | |||
| 8048b7e530 | |||
| d54167dbdf | |||
| 6b98da54bd | |||
| 03e322f1ad | |||
| e876b2131e | |||
| 1e62c09dd0 | |||
| 0f23b63e35 | |||
| 658be50dcd | |||
| 9288ac87e0 | |||
| 6aa7f34b01 | |||
| 7999cf0b98 | |||
| 4ebb0e1790 | |||
| 0ce992e87a | |||
| 3c3d18637e | |||
| 6708f5dbc2 | |||
| 38920cd1fe | |||
| 053133f2b7 | |||
| 505e41661a | |||
| 3c4ed666ce | |||
| 25e222bb22 | |||
| 7c84c67077 | |||
| d3fd0fc537 | |||
| 7f88ab55fb | |||
| 9038db525e | |||
| 9c7a53102b | |||
| 7dc00b7d31 | |||
| 112731a52b | |||
| c68c6af8f4 | |||
| 957ce69d2e | |||
| ff6a20ef38 | |||
| 6a69990833 | |||
| 0f45778d79 | |||
| 3886c179c5 | |||
| ccde98ce82 | |||
| 86a106e8e9 | |||
| 3fe5f907db |
+7
-3
@@ -119,14 +119,18 @@ SSL_CERT=
|
||||
# false if you can be sure that SSL is terminated at an external loadbalancer.
|
||||
FORCE_HTTPS=true
|
||||
|
||||
# When behind a reverse proxy, the header to use for the client IP.
|
||||
# The default value is "X-Forwarded-For", common values are "X-Real-IP"
|
||||
# and "X-Client-IP".
|
||||
# PROXY_IP_HEADER=
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
|
||||
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
|
||||
# Discord, or Microsoft is required for a working installation or you'll
|
||||
# have no sign-in options.
|
||||
# Third party signin credentials, at least ONE OF these is required for a
|
||||
# working installation or you'll have no sign-in options.
|
||||
|
||||
# Slack sign-in provider
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
|
||||
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
|
||||
bundle-size:
|
||||
needs: [setup, types, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
|
||||
if: ${{ (needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true') && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 86400
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- outline-icons
|
||||
|
||||
@@ -70,7 +70,7 @@ yarn install
|
||||
### Exports
|
||||
|
||||
- Exported members must appear at the top of the file.
|
||||
- Prefer named exports for components & classes.
|
||||
- Always use named exports for new components & classes.
|
||||
- Document ALL public/exported functions with JSDoc.
|
||||
|
||||
## React Usage
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 1.5.0
|
||||
Licensed Work: Outline 1.6.1
|
||||
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-02-15
|
||||
Change Date: 2030-03-18
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ There is a short guide for [setting up a development environment](https://docs.g
|
||||
|
||||
## Contributing
|
||||
|
||||
Outline is built and maintained by a small team – we'd love your help to fix bugs and add features!
|
||||
Outline is built and maintained by a small team – your help finding and fixing bugs is appreciated, though AI assisted PR's from new contributors are discouraged and unlikely to be merged.
|
||||
|
||||
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) – we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher likelihood of your code being accepted.
|
||||
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) – we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
|
||||
|
||||
If you’re looking for ways to get started, here's a list of ways to help us improve Outline:
|
||||
|
||||
|
||||
@@ -528,7 +528,7 @@ export const createTemplate = createInternalLinkAction({
|
||||
keywords: "new create template",
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
(policy) => policy.abilities.createTemplate
|
||||
),
|
||||
to: ({ getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
CaseSensitiveIcon,
|
||||
RestoreIcon,
|
||||
EditIcon,
|
||||
EmbedIcon,
|
||||
OpenIcon,
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
@@ -73,6 +75,7 @@ import {
|
||||
searchPath,
|
||||
documentPath,
|
||||
urlify,
|
||||
desktopify,
|
||||
trashPath,
|
||||
documentEditPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
@@ -86,6 +89,8 @@ import type {
|
||||
} from "~/types";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import env from "~/env";
|
||||
import { isMac, isWindows } from "@shared/utils/browser";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import DocumentMove from "~/components/DocumentExplorer/DocumentMove";
|
||||
|
||||
const Insights = lazyWithRetry(
|
||||
@@ -201,48 +206,6 @@ export const createDraftDocument = createInternalLinkAction({
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createInternalLinkAction({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({
|
||||
currentTeamId,
|
||||
activeCollectionId,
|
||||
activeDocumentId,
|
||||
stores,
|
||||
}) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
if (!currentTeamId || !!document?.isDraft || !!document?.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeCollectionId) {
|
||||
return stores.policies.abilities(activeCollectionId).createDocument;
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
to: ({ activeDocumentId, activeCollectionId, sidebarContext }) => {
|
||||
if (!activeDocumentId || !activeCollectionId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId, {
|
||||
templateId: activeDocumentId,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Finds the index of a document among its siblings in the collection tree.
|
||||
*
|
||||
@@ -377,8 +340,15 @@ export const createNewDocument = createActionWithChildren({
|
||||
section: ActiveDocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||
visible: ({ currentTeamId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
|
||||
);
|
||||
},
|
||||
children: [createDocumentBefore, createDocumentAfter, createNestedDocument],
|
||||
});
|
||||
|
||||
@@ -607,7 +577,10 @@ export const shareDocument = createAction({
|
||||
section: ActiveDocumentSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
const can = stores.policies.abilities(activeDocumentId!);
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const can = stores.policies.abilities(activeDocumentId);
|
||||
return can.manageUsers || can.share;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
@@ -982,7 +955,50 @@ export const printDocument = createAction({
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
|
||||
perform: () => {
|
||||
queueMicrotask(window.print);
|
||||
setTimeout(window.print, 0);
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentInDesktop = createAction({
|
||||
name: ({ t }) => t("Open in desktop app"),
|
||||
analyticsName: "Open in desktop",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <OpenIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
isCloudHosted && (isMac || isWindows) && !!document && !document.isDeleted
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
window.location.href = desktopify(documentPath(document));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const presentDocument = createAction({
|
||||
name: ({ t, isMenu }) => (isMenu ? t("Present") : t("Present document")),
|
||||
analyticsName: "Present document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <EmbedIcon />,
|
||||
shortcut: ["Meta+Alt+p"],
|
||||
visible: ({ activeDocumentId }) => !!activeDocumentId,
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.setPresentingDocument(document);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1050,7 +1066,7 @@ export const createTemplateFromDocument = createAction({
|
||||
}
|
||||
return !!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).updateDocument
|
||||
stores.policies.abilities(activeCollectionId).createTemplate
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
@@ -1381,7 +1397,7 @@ export const openDocumentComments = createAction({
|
||||
return;
|
||||
}
|
||||
|
||||
stores.ui.toggleComments();
|
||||
stores.ui.set({ rightSidebar: "comments" });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1529,11 +1545,13 @@ export const rootDocumentActions = [
|
||||
openRandomDocument,
|
||||
permanentlyDeleteDocument,
|
||||
permanentlyDeleteDocumentsInTrash,
|
||||
presentDocument,
|
||||
printDocument,
|
||||
pinDocumentToCollection,
|
||||
pinDocumentToHome,
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
openDocumentInDesktop,
|
||||
shareDocument,
|
||||
];
|
||||
|
||||
@@ -18,7 +18,13 @@ import {
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { newDocumentPath, newTemplatePath, urlify } from "~/utils/routeHelpers";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
newDocumentPath,
|
||||
newTemplatePath,
|
||||
settingsPath,
|
||||
urlify,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { ActiveTemplateSection, TemplateSection } from "../sections";
|
||||
import Template from "~/models/Template";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
@@ -57,6 +63,7 @@ export const deleteTemplate = createAction({
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await template.delete();
|
||||
history.push(settingsPath("templates"));
|
||||
toast.success(t("Template deleted"));
|
||||
}}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
@@ -217,7 +224,7 @@ export const printTemplate = createAction({
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
|
||||
perform: () => {
|
||||
queueMicrotask(window.print);
|
||||
setTimeout(window.print, 0);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Switch,
|
||||
Route,
|
||||
useLocation,
|
||||
matchPath,
|
||||
Redirect,
|
||||
} from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import { RightSidebarProvider } from "~/components/RightSidebarContext";
|
||||
import Sidebar from "~/components/Sidebar";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
@@ -23,8 +16,6 @@ import {
|
||||
searchPath,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug as slug,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
@@ -32,12 +23,6 @@ import NotificationBadge from "./NotificationBadge";
|
||||
import { PortalContext } from "./Portal";
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
const DocumentComments = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Comments/Comments")
|
||||
);
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
const SettingsSidebar = lazyWithRetry(
|
||||
() => import("~/components/Sidebar/Settings")
|
||||
);
|
||||
@@ -48,9 +33,7 @@ type Props = {
|
||||
|
||||
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const { ui, auth } = useStores();
|
||||
const location = useLocation();
|
||||
const layoutRef = React.useRef<HTMLDivElement>(null);
|
||||
const can = usePolicy(ui.activeDocumentId);
|
||||
const canCollection = usePolicy(ui.activeCollectionId);
|
||||
const team = useCurrentTeam();
|
||||
const [spendPostLoginPath] = usePostLoginPath();
|
||||
@@ -74,15 +57,17 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
history.push(newDocumentPath(activeCollectionId));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
history.replace(postLoginPath);
|
||||
}
|
||||
}, [spendPostLoginPath]);
|
||||
|
||||
if (auth.isSuspended) {
|
||||
return <ErrorSuspended />;
|
||||
}
|
||||
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
return <Redirect to={postLoginPath} />;
|
||||
}
|
||||
|
||||
const sidebar = (
|
||||
<Fade>
|
||||
<Switch>
|
||||
@@ -92,50 +77,20 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
</Fade>
|
||||
);
|
||||
|
||||
const showHistory =
|
||||
!!matchPath(location.pathname, {
|
||||
path: matchDocumentHistory,
|
||||
}) && can.listRevisions;
|
||||
const showComments =
|
||||
!showHistory &&
|
||||
can.comment &&
|
||||
ui.activeDocumentId &&
|
||||
ui.commentsExpanded &&
|
||||
!!team.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
key={ui.activeDocumentId ? "active" : "inactive"}
|
||||
>
|
||||
{(showHistory || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</Route>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentContextProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout
|
||||
title={team.name}
|
||||
sidebar={sidebar}
|
||||
sidebarRight={sidebarRight}
|
||||
ref={layoutRef}
|
||||
>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
<RightSidebarProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</PortalContext.Provider>
|
||||
</RightSidebarProvider>
|
||||
</DocumentContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import { DisclosureIcon } from "outline-icons";
|
||||
import { darken, lighten, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import type { HapticInput } from "web-haptics";
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { s } from "@shared/styles";
|
||||
import type { Props as ActionButtonProps } from "~/components/ActionButton";
|
||||
import ActionButton from "~/components/ActionButton";
|
||||
@@ -152,6 +154,8 @@ export type Props<T> = ActionButtonProps & {
|
||||
fullwidth?: boolean;
|
||||
as?: T;
|
||||
to?: LocationDescriptor;
|
||||
/** Haptic feedback to trigger on click. Pass a preset name or custom pattern. */
|
||||
haptic?: HapticInput;
|
||||
borderOnHover?: boolean;
|
||||
hideIcon?: boolean;
|
||||
href?: string;
|
||||
@@ -176,11 +180,13 @@ const Button = <T extends React.ElementType = "button">(
|
||||
hideIcon,
|
||||
fullwidth,
|
||||
danger,
|
||||
haptic,
|
||||
...rest
|
||||
} = props;
|
||||
const hasText = !!children || value !== undefined;
|
||||
const ic = hideIcon ? undefined : (action?.icon ?? icon);
|
||||
const hasIcon = ic !== undefined;
|
||||
const { trigger } = useWebHaptics();
|
||||
|
||||
return (
|
||||
<RealButton
|
||||
@@ -191,6 +197,7 @@ const Button = <T extends React.ElementType = "button">(
|
||||
$danger={danger}
|
||||
$fullwidth={fullwidth}
|
||||
$borderOnHover={borderOnHover}
|
||||
onClickCapture={haptic ? () => void trigger(haptic) : undefined}
|
||||
{...rest}
|
||||
>
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
|
||||
@@ -23,12 +23,9 @@ const Container = styled.div<Props>`
|
||||
type ContentProps = { $maxWidth?: string };
|
||||
|
||||
const Content = styled.div<ContentProps>`
|
||||
max-width: ${(props) => props.$maxWidth ?? "46em"};
|
||||
max-width: ${(props: ContentProps) =>
|
||||
props.$maxWidth ?? EditorStyleHelper.documentWidth};
|
||||
margin: 0 auto;
|
||||
|
||||
${breakpoint("desktopLarge")`
|
||||
max-width: ${(props: ContentProps) => props.$maxWidth ?? EditorStyleHelper.documentWidth};
|
||||
`};
|
||||
`;
|
||||
|
||||
const CenteredContent: React.FC<Props> = ({
|
||||
|
||||
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
{...rest}
|
||||
key={collaborator.id}
|
||||
{...rest}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { randomElement } from "@shared/random";
|
||||
import type { CollectionPermission } from "@shared/types";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { CollectionPermission, TeamPreference } from "@shared/types";
|
||||
import type { Option } from "~/components/InputSelect";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
@@ -15,6 +15,7 @@ import type Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import { Collapsible } from "~/components/Collapsible";
|
||||
import Input from "~/components/Input";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import { InputSelectPermission } from "~/components/InputSelectPermission";
|
||||
import { createLazyComponent } from "~/components/LazyLoad";
|
||||
import Switch from "~/components/Switch";
|
||||
@@ -34,6 +35,7 @@ export interface FormData {
|
||||
sharing: boolean;
|
||||
permission: CollectionPermission | undefined;
|
||||
commenting?: boolean | null;
|
||||
templateManagement: CollectionPermission;
|
||||
}
|
||||
|
||||
const useIconColor = (collection?: Collection) => {
|
||||
@@ -68,6 +70,22 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
|
||||
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
||||
|
||||
const templateManagementOptions = useMemo<Option[]>(
|
||||
() => [
|
||||
{
|
||||
type: "item",
|
||||
label: t("Managers"),
|
||||
value: CollectionPermission.Admin,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Members"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const iconColor = useIconColor(collection);
|
||||
const fallbackIcon = (
|
||||
<Icon
|
||||
@@ -93,6 +111,8 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
sharing: collection?.sharing ?? true,
|
||||
permission: collection?.permission,
|
||||
commenting: collection?.commenting ?? true,
|
||||
templateManagement:
|
||||
collection?.templateManagement ?? CollectionPermission.Admin,
|
||||
color: iconColor,
|
||||
},
|
||||
});
|
||||
@@ -135,6 +155,71 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
|
||||
const initial = values.name.charAt(0).toUpperCase();
|
||||
|
||||
const options = (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="templateManagement"
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<InputSelect
|
||||
value={field.value}
|
||||
onChange={(value: string) => {
|
||||
field.onChange(value as CollectionPermission);
|
||||
}}
|
||||
options={templateManagementOptions}
|
||||
label={t("Manage templates")}
|
||||
/>
|
||||
<Text
|
||||
type="secondary"
|
||||
size="small"
|
||||
as="p"
|
||||
style={{ paddingTop: 4 }}
|
||||
>
|
||||
{t(
|
||||
"Choose who can create and edit templates in this collection."
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<Text as="p">
|
||||
@@ -190,44 +275,10 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
/>
|
||||
)}
|
||||
|
||||
{(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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
{collection ? (
|
||||
options
|
||||
) : (
|
||||
<Collapsible label={t("Advanced options")}>{options}</Collapsible>
|
||||
)}
|
||||
|
||||
<HStack justify="flex-end">
|
||||
|
||||
@@ -128,7 +128,14 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
|
||||
|
||||
React.useEffect(() => {
|
||||
if (contentRef.current && value !== contentRef.current.textContent) {
|
||||
setInnerValue(value);
|
||||
if (document.activeElement === contentRef.current) {
|
||||
// Don't reset content while the user is actively editing. Update
|
||||
// lastValue so that the next input or blur event will push the
|
||||
// current DOM text back to the model via onChange.
|
||||
lastValue.current = value;
|
||||
} else {
|
||||
setInnerValue(value);
|
||||
}
|
||||
}
|
||||
}, [value, contentRef]);
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon from "@shared/components/Icon";
|
||||
@@ -42,6 +41,28 @@ type Props = {
|
||||
showDocuments?: boolean;
|
||||
};
|
||||
|
||||
const VERTICAL_PADDING = 6;
|
||||
const HORIZONTAL_PADDING = 24;
|
||||
|
||||
const innerElementType = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(function innerElementType(
|
||||
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function DocumentExplorer({
|
||||
onSubmit,
|
||||
onSelect,
|
||||
@@ -67,8 +88,6 @@ function DocumentExplorer({
|
||||
return node || null;
|
||||
}
|
||||
);
|
||||
const [initialScrollOffset, setInitialScrollOffset] =
|
||||
React.useState<number>(0);
|
||||
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
|
||||
if (defaultValue) {
|
||||
@@ -91,9 +110,6 @@ function DocumentExplorer({
|
||||
);
|
||||
const listRef = React.useRef<List<NavigationNode[]>>(null);
|
||||
|
||||
const VERTICAL_PADDING = 6;
|
||||
const HORIZONTAL_PADDING = 24;
|
||||
|
||||
const searchIndex = React.useMemo(
|
||||
() =>
|
||||
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
|
||||
@@ -144,7 +160,8 @@ function DocumentExplorer({
|
||||
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
|
||||
}
|
||||
}
|
||||
}, [defaultValue, selectedNode, nodes]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultValue]);
|
||||
const baseDepth = nodes.reduce(
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
@@ -152,17 +169,9 @@ function DocumentExplorer({
|
||||
const normalizedBaseDepth =
|
||||
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
if (itemRefs[node] && itemRefs[node].current) {
|
||||
scrollIntoView(itemRefs[node].current as HTMLSpanElement, {
|
||||
behavior: "auto",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
},
|
||||
[itemRefs]
|
||||
);
|
||||
const scrollNodeIntoView = React.useCallback((node: number) => {
|
||||
listRef.current?.scrollToItem(node, "smart");
|
||||
}, []);
|
||||
|
||||
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(ev.target.value);
|
||||
@@ -170,16 +179,16 @@ function DocumentExplorer({
|
||||
|
||||
const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);
|
||||
|
||||
const calculateInitialScrollOffset = (itemCount: number) => {
|
||||
const preserveScrollOffset = (itemCount: number) => {
|
||||
if (listRef.current) {
|
||||
const { height, itemSize } = listRef.current.props;
|
||||
const { scrollOffset } = listRef.current.state as {
|
||||
scrollOffset: number;
|
||||
};
|
||||
const itemsHeight = itemCount * itemSize;
|
||||
return itemsHeight < Number(height) ? 0 : scrollOffset;
|
||||
const offset = itemsHeight < Number(height) ? 0 : scrollOffset;
|
||||
setTimeout(() => listRef.current?.scrollTo(offset), 0);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const collapse = (node: number) => {
|
||||
@@ -190,8 +199,7 @@ function DocumentExplorer({
|
||||
|
||||
// remove children
|
||||
const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
preserveScrollOffset(newNodes.length);
|
||||
};
|
||||
|
||||
const expand = (node: number) => {
|
||||
@@ -200,8 +208,7 @@ function DocumentExplorer({
|
||||
// add children
|
||||
const newNodes = nodes.slice();
|
||||
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
|
||||
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||
setInitialScrollOffset(scrollOffset);
|
||||
preserveScrollOffset(newNodes.length);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -225,7 +232,8 @@ function DocumentExplorer({
|
||||
};
|
||||
|
||||
const hasChildren = (node: number) =>
|
||||
nodes[node].children.length > 0 || showDocuments !== false;
|
||||
nodes[node].children.length > 0 ||
|
||||
(showDocuments !== false && nodes[node].type === "collection");
|
||||
|
||||
const toggleCollapse = (node: number) => {
|
||||
if (!hasChildren(node)) {
|
||||
@@ -387,25 +395,6 @@ function DocumentExplorer({
|
||||
}
|
||||
};
|
||||
|
||||
const innerElementType = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(function innerElementType(
|
||||
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
|
||||
<ListSearch
|
||||
@@ -425,14 +414,12 @@ function DocumentExplorer({
|
||||
<Flex role="listbox" column>
|
||||
<List
|
||||
ref={listRef}
|
||||
key={nodes.length}
|
||||
width={width}
|
||||
height={height}
|
||||
itemData={nodes}
|
||||
itemCount={nodes.length}
|
||||
itemSize={isMobile ? 48 : 32}
|
||||
innerElementType={innerElementType}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemKey={(index, results) => results[index].id}
|
||||
>
|
||||
{ListItem}
|
||||
|
||||
@@ -40,10 +40,8 @@ function DocumentExplorerNode(
|
||||
ref: React.RefObject<HTMLSpanElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const OFFSET = 12;
|
||||
const DISCLOSURE = 20;
|
||||
|
||||
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
|
||||
const DISCLOSURE = 24;
|
||||
const width = (depth + (hasChildren ? 2 : 1)) * DISCLOSURE;
|
||||
|
||||
return (
|
||||
<Node
|
||||
@@ -80,7 +78,7 @@ const Title = styled(Text)`
|
||||
const StyledDisclosure = styled(Disclosure)`
|
||||
position: relative;
|
||||
left: auto;
|
||||
margin-top: 2px;
|
||||
margin: 2px 0;
|
||||
`;
|
||||
|
||||
const Spacer = styled(Flex)<{ width: number }>`
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
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 "./DocumentExplorerNode";
|
||||
@@ -32,22 +31,8 @@ function DocumentExplorerSearchResult({
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ref = React.useCallback(
|
||||
(node: HTMLSpanElement | null) => {
|
||||
if (active && node) {
|
||||
scrollIntoView(node, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
},
|
||||
[active]
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchResult
|
||||
ref={ref}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -22,6 +22,7 @@ import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
@@ -58,6 +59,7 @@ function DocumentListItem(
|
||||
const { userMemberships, groupMemberships } = useStores();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const isMobile = useMobile();
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
React.useRef<HTMLAnchorElement>(null);
|
||||
@@ -159,7 +161,7 @@ function DocumentListItem(
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && <StarButton document={document} />}
|
||||
{canStar && !isMobile && <StarButton document={document} />}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
|
||||
@@ -88,6 +88,7 @@ function Header(
|
||||
<Breadcrumbs ref={setBreadcrumbRef}>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
haptic="light"
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
neutral
|
||||
|
||||
@@ -43,9 +43,9 @@ export const Info = styled(StyledText).attrs(() => ({
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const Description = styled(StyledText)`
|
||||
export const Description = styled(StyledText)<{ $margin?: string }>`
|
||||
${sharedVars}
|
||||
margin-top: 0.5em;
|
||||
margin-top: ${(props) => props.$margin ?? "0.5em"};
|
||||
line-height: var(--line-height);
|
||||
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
|
||||
overflow: hidden;
|
||||
@@ -64,8 +64,6 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
|
||||
width: fit-content;
|
||||
border-radius: 2em;
|
||||
padding: 1px 8px 1px 20px;
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.5em;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -75,8 +73,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: ${(props) =>
|
||||
props.color || props.theme.backgroundSecondary};
|
||||
|
||||
@@ -17,6 +17,7 @@ import HoverPreviewGroup from "./HoverPreviewGroup";
|
||||
import HoverPreviewIssue from "./HoverPreviewIssue";
|
||||
import HoverPreviewLink from "./HoverPreviewLink";
|
||||
import HoverPreviewMention from "./HoverPreviewMention";
|
||||
import HoverPreviewProject from "./HoverPreviewProject";
|
||||
import HoverPreviewPullRequest from "./HoverPreviewPullRequest";
|
||||
|
||||
const DELAY_CLOSE = 500;
|
||||
@@ -192,6 +193,18 @@ const HoverPreviewDesktop = observer(
|
||||
createdAt={data.createdAt}
|
||||
state={data.state}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Project ? (
|
||||
<HoverPreviewProject
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
name={data.name}
|
||||
color={data.color}
|
||||
lead={data.lead}
|
||||
labels={data.labels}
|
||||
description={data.description}
|
||||
state={data.state}
|
||||
targetDate={data.targetDate}
|
||||
/>
|
||||
) : (
|
||||
<HoverPreviewLink
|
||||
ref={cardRef}
|
||||
|
||||
@@ -75,7 +75,7 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
|
||||
</Description>
|
||||
)}
|
||||
|
||||
<Flex wrap>
|
||||
<Flex wrap gap={6} style={{ marginTop: 8 }}>
|
||||
{labels.map((label, index) => (
|
||||
<Label key={index} color={label.color}>
|
||||
{label.name}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { Backticks } from "@shared/components/Backticks";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import Editor from "~/components/Editor";
|
||||
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import {
|
||||
Preview,
|
||||
Title,
|
||||
Card,
|
||||
CardContent,
|
||||
Label,
|
||||
Description,
|
||||
} from "./Components";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
|
||||
type Props = Pick<
|
||||
UnfurlResponse[UnfurlResourceType.Project],
|
||||
| "url"
|
||||
| "name"
|
||||
| "color"
|
||||
| "lead"
|
||||
| "labels"
|
||||
| "state"
|
||||
| "targetDate"
|
||||
| "description"
|
||||
>;
|
||||
|
||||
const HoverPreviewProject = React.forwardRef(function HoverPreviewProject_(
|
||||
{ url, name, color, lead, labels, state, description, targetDate }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Flex column ref={ref}>
|
||||
<Card fadeOut={false}>
|
||||
<CardContent>
|
||||
<Flex gap={4} column>
|
||||
<Title>
|
||||
<StyledSquircle color={color} size={16} />
|
||||
<span>
|
||||
<Backticks content={name} />
|
||||
</span>
|
||||
</Title>
|
||||
{description && (
|
||||
<Description as="div" $margin="0">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
extensions={richExtensions}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
)}
|
||||
<Text
|
||||
type="tertiary"
|
||||
size="small"
|
||||
style={{ textTransform: "capitalize" }}
|
||||
>
|
||||
{state.name}
|
||||
</Text>
|
||||
|
||||
{(lead || targetDate) && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
{lead && (
|
||||
<MetadataRow>
|
||||
<MetadataLabel>{t("Lead")}</MetadataLabel>
|
||||
<Flex align="center" gap={6}>
|
||||
<Avatar src={lead.avatarUrl} size={AvatarSize.Toast} />
|
||||
<Text size="small">{lead.name}</Text>
|
||||
</Flex>
|
||||
</MetadataRow>
|
||||
)}
|
||||
|
||||
{targetDate && (
|
||||
<MetadataRow>
|
||||
<MetadataLabel>{t("Target date")}</MetadataLabel>
|
||||
<Text size="small">
|
||||
<Time dateTime={targetDate} addSuffix />
|
||||
</Text>
|
||||
</MetadataRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{labels.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<MetadataRow>
|
||||
<MetadataLabel>{t("Labels")}</MetadataLabel>
|
||||
<Flex wrap gap={6}>
|
||||
{labels.map((label, index) => (
|
||||
<Label key={index} color={label.color}>
|
||||
{label.name}
|
||||
</Label>
|
||||
))}
|
||||
</Flex>
|
||||
</MetadataRow>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Flex>
|
||||
</Preview>
|
||||
);
|
||||
});
|
||||
|
||||
const StyledSquircle = styled(Squircle)`
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const Divider = styled.div`
|
||||
height: 1px;
|
||||
background: ${s("divider")};
|
||||
margin: 4px 0;
|
||||
`;
|
||||
|
||||
const MetadataRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 28px;
|
||||
`;
|
||||
|
||||
const MetadataLabel = styled(Text).attrs({
|
||||
type: "tertiary",
|
||||
size: "small",
|
||||
})`
|
||||
flex-shrink: 0;
|
||||
min-width: 80px;
|
||||
`;
|
||||
|
||||
export default HoverPreviewProject;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@@ -7,6 +8,7 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
||||
import { useRightSidebarContent } from "~/components/RightSidebarContext";
|
||||
import SkipNavContent from "~/components/SkipNavContent";
|
||||
import SkipNavLink from "~/components/SkipNavLink";
|
||||
import env from "~/env";
|
||||
@@ -19,16 +21,15 @@ type Props = {
|
||||
title?: string;
|
||||
/** Left sidebar content. */
|
||||
sidebar?: React.ReactNode;
|
||||
/** Right sidebar content. */
|
||||
sidebarRight?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Layout = React.forwardRef(function Layout_(
|
||||
{ title, children, sidebar, sidebarRight }: Props,
|
||||
{ title, children, sidebar }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { ui } = useStores();
|
||||
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
|
||||
const sidebarRight = useRightSidebarContent();
|
||||
|
||||
return (
|
||||
<Container column auto ref={ref}>
|
||||
@@ -61,7 +62,7 @@ const Layout = React.forwardRef(function Layout_(
|
||||
{children}
|
||||
</Content>
|
||||
|
||||
{sidebarRight}
|
||||
<AnimatePresence initial={false}>{sidebarRight}</AnimatePresence>
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
|
||||
+22
-17
@@ -9,39 +9,44 @@ export interface LazyComponent<T extends React.ComponentType<any>> {
|
||||
interface LazyLoadOptions {
|
||||
retries?: number;
|
||||
interval?: number;
|
||||
/** If provided, picks this named export from the module instead of `default`. */
|
||||
exportName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
|
||||
* Supports both default and named exports.
|
||||
*
|
||||
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
|
||||
* @param options Optional configuration for retry behavior
|
||||
* @returns An object containing the lazy Component and a preload function
|
||||
* @param factory A function that returns a promise of a module.
|
||||
* @param options Optional configuration for retry behavior and export name.
|
||||
* @returns An object containing the lazy Component and a preload function.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Default export
|
||||
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
|
||||
*
|
||||
* function App() {
|
||||
* return (
|
||||
* <Suspense fallback={<div>Loading...</div>}>
|
||||
* <MyComponent.Component />
|
||||
* </Suspense>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Preload when needed:
|
||||
* MyComponent.preload();
|
||||
* // Named export
|
||||
* const MyComponent = createLazyComponent(() => import('./MyComponent'), {
|
||||
* exportName: 'MyComponent',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createLazyComponent<T extends React.ComponentType<any>>(
|
||||
factory: () => Promise<{ default: T }>,
|
||||
factory: () => Promise<Record<string, T>>,
|
||||
options: LazyLoadOptions = {}
|
||||
): LazyComponent<T> {
|
||||
const { retries, interval } = options;
|
||||
const { retries, interval, exportName } = options;
|
||||
|
||||
const wrappedFactory = exportName
|
||||
? () =>
|
||||
factory().then((m) => ({
|
||||
default: m[exportName],
|
||||
}))
|
||||
: (factory as () => Promise<{ default: T }>);
|
||||
|
||||
return {
|
||||
Component: lazyWithRetry(factory, retries, interval),
|
||||
preload: factory,
|
||||
Component: lazyWithRetry(wrappedFactory, retries, interval),
|
||||
preload: wrappedFactory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import type { ActionVariant, ActionWithChildren } from "~/types";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import { toMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
@@ -61,11 +62,6 @@ export const ContextMenu = observer(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile || !action || menuItems.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -80,7 +76,7 @@ export const ContextMenu = observer(
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
onCloseAutoFocus={preventDefault}
|
||||
>
|
||||
{content}
|
||||
</MenuContent>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import type {
|
||||
ActionVariant,
|
||||
ActionWithChildren,
|
||||
@@ -98,11 +99,6 @@ export const DropdownMenu = observer(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileDropdown
|
||||
@@ -129,7 +125,7 @@ export const DropdownMenu = observer(
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
onCloseAutoFocus={preventDefault}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
|
||||
@@ -42,7 +42,6 @@ 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}
|
||||
@@ -95,13 +94,11 @@ 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}
|
||||
>
|
||||
|
||||
@@ -103,6 +103,7 @@ const StyledLink = styled(Link)`
|
||||
const StyledCommentEditor = styled(CommentEditor)`
|
||||
font-size: 0.9em;
|
||||
margin-top: 4px;
|
||||
pointer-events: none;
|
||||
|
||||
${truncateMultiline(3)}
|
||||
`;
|
||||
|
||||
@@ -20,6 +20,55 @@ import Tooltip from "../Tooltip";
|
||||
import NotificationListItem from "./NotificationListItem";
|
||||
import { HStack } from "../primitives/HStack";
|
||||
|
||||
/**
|
||||
* Hook that returns filtered notifications in a stable order. The order is
|
||||
* snapshotted on first call (when the popover mounts) so that toggling
|
||||
* read/unread does not cause items to jump positions. Notifications that
|
||||
* arrive after the snapshot are prepended at the top.
|
||||
*
|
||||
* @param active - the current list of active notifications.
|
||||
* @param filter - the selected notification filter category.
|
||||
* @returns filtered notifications in snapshot order.
|
||||
*/
|
||||
function useStableOrderedNotifications(
|
||||
active: Notification[],
|
||||
filter: NotificationFilter
|
||||
) {
|
||||
const orderSnapshotRef = React.useRef<string[] | null>(null);
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (orderSnapshotRef.current === null) {
|
||||
orderSnapshotRef.current = active.map((n) => n.id);
|
||||
}
|
||||
|
||||
const filtered =
|
||||
filter === "all"
|
||||
? active
|
||||
: active.filter((notification) =>
|
||||
Notification.filterCategories[filter].includes(notification.event)
|
||||
);
|
||||
|
||||
const snapshot = orderSnapshotRef.current;
|
||||
const orderMap = new Map(snapshot.map((id, index) => [id, index]));
|
||||
const inSnapshot: Notification[] = [];
|
||||
const newItems: Notification[] = [];
|
||||
|
||||
for (const notification of filtered) {
|
||||
if (orderMap.has(notification.id)) {
|
||||
inSnapshot.push(notification);
|
||||
} else {
|
||||
newItems.push(notification);
|
||||
}
|
||||
}
|
||||
|
||||
inSnapshot.sort(
|
||||
(a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)
|
||||
);
|
||||
|
||||
return [...newItems, ...inSnapshot];
|
||||
}, [active, filter]);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** Callback when the notification panel wants to close. */
|
||||
onRequestClose: () => void;
|
||||
@@ -49,16 +98,10 @@ function Notifications(
|
||||
[t]
|
||||
);
|
||||
|
||||
const filteredNotifications = React.useMemo(() => {
|
||||
if (filter === "all") {
|
||||
return notifications.active;
|
||||
}
|
||||
|
||||
const eventTypes = Notification.filterCategories[filter];
|
||||
return notifications.active.filter((notification) =>
|
||||
eventTypes.includes(notification.event)
|
||||
);
|
||||
}, [notifications.active, filter]);
|
||||
const filteredNotifications = useStableOrderedNotifications(
|
||||
notifications.active,
|
||||
filter
|
||||
);
|
||||
|
||||
const unreadCount = notifications.approximateUnreadCount;
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Suspense } from "react";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const PresentationMode = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/PresentationMode")
|
||||
);
|
||||
|
||||
function Presentation() {
|
||||
const { ui } = useStores();
|
||||
|
||||
if (!ui.presentationData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PresentationMode
|
||||
title={ui.presentationData.title}
|
||||
icon={ui.presentationData.icon}
|
||||
iconColor={ui.presentationData.color}
|
||||
data={ui.presentationData.data}
|
||||
onClose={() => {
|
||||
ui.setPresentingDocument(null);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Presentation);
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
|
||||
type SetSidebarFn = (content: React.ReactNode) => void;
|
||||
|
||||
const RightSidebarSetterContext = React.createContext<SetSidebarFn | null>(
|
||||
null
|
||||
);
|
||||
const RightSidebarContentContext = React.createContext<React.ReactNode>(null);
|
||||
|
||||
/**
|
||||
* Provider that holds right sidebar content state. Wrap at the layout level
|
||||
* so that scenes can set sidebar content via the setter hook.
|
||||
*/
|
||||
export function RightSidebarProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [content, setContent] = React.useState<React.ReactNode>(null);
|
||||
|
||||
return (
|
||||
<RightSidebarSetterContext.Provider value={setContent}>
|
||||
<RightSidebarContentContext.Provider value={content}>
|
||||
{children}
|
||||
</RightSidebarContentContext.Provider>
|
||||
</RightSidebarSetterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable setter function to set the right sidebar content.
|
||||
* Used by scenes (e.g. Document) to populate the sidebar.
|
||||
*/
|
||||
export function useSetRightSidebar(): SetSidebarFn {
|
||||
const setter = React.useContext(RightSidebarSetterContext);
|
||||
if (!setter) {
|
||||
throw new Error(
|
||||
"useSetRightSidebar must be used within a RightSidebarProvider"
|
||||
);
|
||||
}
|
||||
return setter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current right sidebar content. Used by Layout to render
|
||||
* the sidebar.
|
||||
*/
|
||||
export function useRightSidebarContent(): React.ReactNode {
|
||||
return React.useContext(RightSidebarContentContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Context indicating whether the Right sidebar wrapper is already rendered
|
||||
* by an ancestor. When true, SidebarLayout skips rendering its own Right
|
||||
* wrapper to avoid duplicate animated containers.
|
||||
*/
|
||||
export const RightSidebarWrappedContext = React.createContext(false);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useKBar } from "kbar";
|
||||
import { observer } from "mobx-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
|
||||
@@ -14,7 +15,7 @@ interface CacheEntry {
|
||||
// Cache configuration
|
||||
const cacheTTL = Minute.ms * 5;
|
||||
|
||||
export default function SearchActions() {
|
||||
function SearchActions() {
|
||||
const { searches, documents } = useStores();
|
||||
|
||||
// Cache structure: Map of search queries to timestamp of last search
|
||||
@@ -58,3 +59,5 @@ export default function SearchActions() {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default observer(SearchActions);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { id as bodyContentId } from "~/components/SkipNavContent";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import type { SearchResult } from "~/types";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
|
||||
@@ -28,73 +29,112 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const focusRef = React.useRef<HTMLElement | null>(null);
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const [searchResults, setSearchResults] = React.useState<
|
||||
SearchResult[] | undefined
|
||||
>();
|
||||
const [cachedQuery, setCachedQuery] = React.useState(query);
|
||||
const [cachedSearchResults, setCachedSearchResults] = React.useState<
|
||||
SearchResult[] | undefined
|
||||
>(searchResults);
|
||||
|
||||
// Cache search results by query string to avoid redundant API calls
|
||||
const cacheRef = React.useRef(new Map<string, SearchResult[]>());
|
||||
const queryRef = React.useRef(query);
|
||||
queryRef.current = query;
|
||||
|
||||
// When the query changes, restore cached results (including empty) or keep
|
||||
// previous results visible until new results arrive to avoid layout shift
|
||||
React.useEffect(() => {
|
||||
if (searchResults) {
|
||||
setCachedQuery(query);
|
||||
setCachedSearchResults(searchResults);
|
||||
setOpen(true);
|
||||
if (!query) {
|
||||
setSearchResults(undefined);
|
||||
return;
|
||||
}
|
||||
}, [searchResults, query]);
|
||||
|
||||
// Clear search results when the query changes to prevent stale results
|
||||
React.useEffect(() => {
|
||||
setSearchResults(undefined);
|
||||
const cached = cacheRef.current.get(query);
|
||||
if (cached !== undefined) {
|
||||
setSearchResults(cached);
|
||||
if (cached.length) {
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
const performSearch = React.useCallback(
|
||||
async ({ query: searchQuery, ...options }) => {
|
||||
if (searchQuery?.length > 0) {
|
||||
const response = await documents.search({
|
||||
query: searchQuery,
|
||||
shareId,
|
||||
...options,
|
||||
});
|
||||
|
||||
if (response.length) {
|
||||
setSearchResults((state) => [...(state ?? []), ...response]);
|
||||
}
|
||||
|
||||
return response;
|
||||
async ({
|
||||
query: searchQuery,
|
||||
offset = 0,
|
||||
...options
|
||||
}: Record<string, any>) => {
|
||||
if (!searchQuery?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
// Return cached results for first-page lookups
|
||||
if (offset === 0 && cacheRef.current.has(searchQuery)) {
|
||||
return cacheRef.current.get(searchQuery)!;
|
||||
}
|
||||
|
||||
// Force offset to 0 for new queries — PaginatedList's reset() sets
|
||||
// offset via setState but fetchResults still uses the stale value
|
||||
// from its closure
|
||||
if (!cacheRef.current.has(searchQuery)) {
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
const response = await documents.search({
|
||||
query: searchQuery,
|
||||
shareId,
|
||||
offset,
|
||||
...options,
|
||||
});
|
||||
|
||||
// Build complete result set in cache: replace for new queries, append
|
||||
// for pagination of an existing query
|
||||
const existing = cacheRef.current.get(searchQuery);
|
||||
cacheRef.current.set(
|
||||
searchQuery,
|
||||
existing ? [...existing, ...response] : response
|
||||
);
|
||||
|
||||
// Only update state if this query is still current to prevent stale
|
||||
// results from overwriting newer results after a race condition
|
||||
if (queryRef.current === searchQuery) {
|
||||
setSearchResults(cacheRef.current.get(searchQuery)!);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
[documents, shareId]
|
||||
);
|
||||
|
||||
const handleSearchInputChange = React.useMemo(
|
||||
const debouncedSetQuery = React.useMemo(
|
||||
() =>
|
||||
debounce(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
const trimmedValue = value.trim();
|
||||
setQuery(trimmedValue);
|
||||
setOpen(!!trimmedValue);
|
||||
}, 300),
|
||||
[cachedQuery]
|
||||
debounce((value: string) => {
|
||||
setQuery(value);
|
||||
setOpen(!!value);
|
||||
}, 250),
|
||||
[]
|
||||
);
|
||||
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
|
||||
const handleSearchInputChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
debouncedSetQuery(event.target.value.trim());
|
||||
},
|
||||
[debouncedSetQuery]
|
||||
);
|
||||
|
||||
React.useEffect(() => () => debouncedSetQuery.cancel(), [debouncedSetQuery]);
|
||||
|
||||
const handleEscapeList = React.useCallback(
|
||||
() => searchInputRef?.current?.focus(),
|
||||
[searchInputRef]
|
||||
() => searchInputRef.current?.focus(),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchInputFocus = React.useCallback(() => {
|
||||
focusRef.current = searchInputRef.current;
|
||||
}, [searchInputRef]);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -106,6 +146,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
if (searchResults) {
|
||||
setOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowDown" && !ev.shiftKey) {
|
||||
@@ -116,12 +157,12 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
if (atEnd) {
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
if (open || atEnd) {
|
||||
ev.preventDefault();
|
||||
firstSearchItem.current?.focus();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowUp") {
|
||||
@@ -131,21 +172,17 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.currentTarget.value) {
|
||||
if (ev.currentTarget.selectionEnd === 0) {
|
||||
ev.currentTarget.selectionStart = 0;
|
||||
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
if (ev.currentTarget.value && ev.currentTarget.selectionEnd === 0) {
|
||||
ev.currentTarget.selectionStart = 0;
|
||||
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
|
||||
ev.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
ev.preventDefault();
|
||||
}
|
||||
},
|
||||
[open, searchResults]
|
||||
@@ -153,11 +190,12 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
|
||||
const handleSearchItemClick = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.value = "";
|
||||
focusRef.current = document.getElementById(bodyContentId);
|
||||
}
|
||||
}, [searchInputRef]);
|
||||
}, []);
|
||||
|
||||
useKeyDown("/", (ev) => {
|
||||
if (
|
||||
@@ -193,7 +231,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
align="start"
|
||||
shrink
|
||||
onEscapeKeyDown={handleEscapeList}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onOpenAutoFocus={preventDefault}
|
||||
onInteractOutside={(event) => {
|
||||
const target = event.target as Element | null;
|
||||
if (target === searchInputRef.current) {
|
||||
@@ -203,8 +241,13 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
>
|
||||
<PaginatedList<SearchResult>
|
||||
role="listbox"
|
||||
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
|
||||
items={cachedSearchResults}
|
||||
options={{
|
||||
query,
|
||||
snippetMinWords: 10,
|
||||
snippetMaxWords: 11,
|
||||
limit: 10,
|
||||
}}
|
||||
items={searchResults}
|
||||
fetch={performSearch}
|
||||
onEscape={handleEscapeList}
|
||||
empty={
|
||||
@@ -218,7 +261,7 @@ function SearchPopover({ shareId, className }: Props) {
|
||||
ref={index === 0 ? firstSearchItem : undefined}
|
||||
document={item.document}
|
||||
context={item.context}
|
||||
highlight={cachedQuery}
|
||||
highlight={query}
|
||||
onClick={handleSearchItemClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -89,7 +89,11 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
|
||||
const members = React.useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
document.members,
|
||||
Array.from(
|
||||
new Map(
|
||||
document.members.map((memberUser) => [memberUser.id, memberUser])
|
||||
).values()
|
||||
),
|
||||
(memberUser) =>
|
||||
(invitedInSession.includes(memberUser.id) ? "_" : "") +
|
||||
memberUser.name.toLocaleLowerCase(),
|
||||
@@ -124,12 +128,19 @@ function DocumentMemberList({ document, invitedInSession }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupMemberships
|
||||
.inDocument(document.id)
|
||||
{Array.from(
|
||||
new Map(
|
||||
groupMemberships
|
||||
.inDocument(document.id)
|
||||
.map((membership) => [membership.group.id, membership])
|
||||
).values()
|
||||
)
|
||||
.sort((a, b) =>
|
||||
(
|
||||
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
|
||||
).localeCompare(b.group.name)
|
||||
).localeCompare(
|
||||
(invitedInSession.includes(b.group.id) ? "_" : "") + b.group.name
|
||||
)
|
||||
)
|
||||
.map((membership) => {
|
||||
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
|
||||
|
||||
@@ -193,8 +193,8 @@ export const Suggestions = observer(
|
||||
...pending.map((suggestion) => (
|
||||
<PendingListItem
|
||||
keyboardNavigation
|
||||
{...getListItemProps(suggestion)}
|
||||
key={suggestion.id}
|
||||
{...getListItemProps(suggestion)}
|
||||
onClick={() => removePendingId(suggestion.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
@@ -218,8 +218,8 @@ export const Suggestions = observer(
|
||||
...suggestionsWithPending.map((suggestion) => (
|
||||
<ListItem
|
||||
keyboardNavigation
|
||||
{...getListItemProps(suggestion as User)}
|
||||
key={suggestion.id}
|
||||
{...getListItemProps(suggestion as User)}
|
||||
onClick={() => addPendingId(suggestion.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
|
||||
@@ -8,19 +8,23 @@ import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useWindowScrollbarWidth from "~/hooks/useWindowScrollbarWidth";
|
||||
import { sidebarAppearDuration } from "~/styles/animations";
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
border?: boolean;
|
||||
/** When true, skip the entrance animation and render at full width immediately. */
|
||||
skipInitialAnimation?: boolean;
|
||||
}
|
||||
|
||||
function Right({ children, border, className }: Props) {
|
||||
function Right({ children, border, className, skipInitialAnimation }: Props) {
|
||||
const theme = useTheme();
|
||||
const { ui } = useStores();
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const windowScrollbarWidth = useWindowScrollbarWidth();
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
@@ -67,16 +71,20 @@ function Right({ children, border, className }: Props) {
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${ui.sidebarRightWidth}px`,
|
||||
width: windowScrollbarWidth
|
||||
? `${ui.sidebarRightWidth - windowScrollbarWidth}px`
|
||||
: `${ui.sidebarRightWidth}px`,
|
||||
}),
|
||||
[ui.sidebarRightWidth]
|
||||
[ui.sidebarRightWidth, windowScrollbarWidth]
|
||||
);
|
||||
|
||||
const animationProps = {
|
||||
initial: {
|
||||
width: 0,
|
||||
opacity: 0.9,
|
||||
},
|
||||
initial: skipInitialAnimation
|
||||
? false
|
||||
: {
|
||||
width: 0,
|
||||
opacity: 0.9,
|
||||
},
|
||||
animate: {
|
||||
transition: isResizing
|
||||
? { duration: 0 }
|
||||
|
||||
@@ -76,7 +76,8 @@ function SettingsSidebar() {
|
||||
to={item.path}
|
||||
onClickIntent={item.preload}
|
||||
active={
|
||||
item.path.startsWith(settingsPath("templates"))
|
||||
item.path.startsWith(settingsPath("templates")) ||
|
||||
item.path.startsWith(settingsPath("groups"))
|
||||
? location.pathname.startsWith(item.path)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import type Share from "~/models/Share";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
@@ -48,7 +47,7 @@ function SharedSidebar({ share }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledSidebar $hoverTransition={!teamAvailable} canCollapse={false}>
|
||||
<Sidebar canCollapse={false}>
|
||||
{teamAvailable && (
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
@@ -90,7 +89,7 @@ function SharedSidebar({ share }: Props) {
|
||||
)}
|
||||
</Section>
|
||||
</ScrollContainer>
|
||||
</StyledSidebar>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,33 +112,4 @@ const StyledSearchPopover = styled(SearchPopover)`
|
||||
margin: 8px 0;
|
||||
`;
|
||||
|
||||
const ToggleWrapper = styled.div`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
transition:
|
||||
opacity 100ms ease-out,
|
||||
transform 100ms ease-out;
|
||||
`;
|
||||
|
||||
const StyledSidebar = styled(Sidebar)<{ $hoverTransition: boolean }>`
|
||||
${({ $hoverTransition }) =>
|
||||
$hoverTransition &&
|
||||
`
|
||||
@media (hover: hover) {
|
||||
&:${hover} {
|
||||
${StyledSearchPopover} {
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
${ToggleWrapper} {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(SharedSidebar);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -53,6 +54,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
const collapsed = ui.sidebarIsClosed && canCollapse;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const { trigger } = useWebHaptics();
|
||||
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isHovering, setHovering] = React.useState(false);
|
||||
@@ -224,6 +226,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
[width]
|
||||
);
|
||||
|
||||
const handleCloseSidebar = () => {
|
||||
trigger("light");
|
||||
ui.toggleMobileSidebar();
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Container
|
||||
@@ -275,7 +282,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
|
||||
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
|
||||
/>
|
||||
</Container>
|
||||
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
|
||||
{ui.mobileSidebarVisible && <Backdrop onClick={handleCloseSidebar} />}
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ function SidebarLink(
|
||||
$isActiveDrop={isActiveDrop}
|
||||
$isDraft={isDraft}
|
||||
$disabled={disabled}
|
||||
style={style}
|
||||
style={active ? activeStyle : style}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
onClick={handleClick}
|
||||
onActiveClick={handleDisclosureClick}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface Props extends Omit<
|
||||
disabled?: boolean;
|
||||
/** Callback when the switch state changes */
|
||||
onChange?: (checked: boolean) => void;
|
||||
inForm?: boolean;
|
||||
}
|
||||
|
||||
function Switch(
|
||||
@@ -35,6 +36,7 @@ function Switch(
|
||||
width = 32,
|
||||
height = 18,
|
||||
labelPosition = "left",
|
||||
inForm = true,
|
||||
label,
|
||||
disabled,
|
||||
className,
|
||||
@@ -71,7 +73,7 @@ function Switch(
|
||||
|
||||
if (label) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Wrapper $inForm={inForm}>
|
||||
<Label
|
||||
disabled={disabled}
|
||||
htmlFor={props.id}
|
||||
@@ -100,8 +102,8 @@ function Switch(
|
||||
return component;
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
padding-bottom: 8px;
|
||||
const Wrapper = styled.div<{ $inForm?: boolean }>`
|
||||
padding-bottom: ${(props) => (props.$inForm ? 8 : 0)}px;
|
||||
${undraggableOnDesktop()}
|
||||
`;
|
||||
|
||||
|
||||
@@ -95,6 +95,13 @@ const transition = {
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
/** Restrict shared layout animation to the X axis only. */
|
||||
const horizontalOnly = (transform: Record<string, string>, generated: string) =>
|
||||
generated.replace(
|
||||
/translate3d\(([^,]+),\s*[^,]+,\s*([^)]+)\)/,
|
||||
"translate3d($1, 0px, $2)"
|
||||
);
|
||||
|
||||
const Tab: React.FC<Props> = (props: Props) => {
|
||||
const { children, exact, exactQueryString } = props;
|
||||
const theme = useTheme();
|
||||
@@ -112,6 +119,7 @@ const Tab: React.FC<Props> = (props: Props) => {
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
transformTemplate={horizontalOnly}
|
||||
/>
|
||||
)}
|
||||
</TabButton>
|
||||
@@ -140,6 +148,7 @@ const Tab: React.FC<Props> = (props: Props) => {
|
||||
layoutId="underline"
|
||||
initial={false}
|
||||
transition={transition}
|
||||
transformTemplate={horizontalOnly}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnimateSharedLayout } from "framer-motion";
|
||||
import { LayoutGroup } from "framer-motion";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
@@ -84,13 +84,13 @@ const Tabs: React.FC = ({ children }: Props) => {
|
||||
}, [width, updateShadows]);
|
||||
|
||||
return (
|
||||
<AnimateSharedLayout>
|
||||
<LayoutGroup>
|
||||
<Sticky>
|
||||
<Nav ref={ref} onScroll={updateShadows} $shadowVisible={shadowVisible}>
|
||||
{children}
|
||||
</Nav>
|
||||
</Sticky>
|
||||
</AnimateSharedLayout>
|
||||
</LayoutGroup>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
|
||||
collections.orderedData.reduce<Option[]>((memo, collection) => {
|
||||
const canCollection = policies.abilities(collection.id);
|
||||
|
||||
if (canCollection.createDocument) {
|
||||
if (canCollection.createTemplate) {
|
||||
memo.push({
|
||||
type: "item",
|
||||
label: collection.name,
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Toaster } from "sonner";
|
||||
import * as React from "react";
|
||||
import { Toaster, useSonner } from "sonner";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
function Toasts() {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
const { toasts } = useSonner();
|
||||
const { trigger } = useWebHaptics();
|
||||
const prevCountRef = React.useRef(toasts.length);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (toasts.length > prevCountRef.current) {
|
||||
const latest = toasts[toasts.length - 1];
|
||||
if (latest.type === "error") {
|
||||
void trigger("error");
|
||||
} else if (latest.type === "success") {
|
||||
void trigger("success");
|
||||
}
|
||||
}
|
||||
prevCountRef.current = toasts.length;
|
||||
}, [toasts, trigger]);
|
||||
|
||||
return (
|
||||
<StyledToaster
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, depths } from "@shared/styles";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useTooltipContext } from "./TooltipContext";
|
||||
|
||||
@@ -267,7 +267,7 @@ const StyledContent = styled(TooltipPrimitive.Content)`
|
||||
white-space: normal;
|
||||
outline: 0;
|
||||
padding: 5px 9px;
|
||||
z-index: 9999;
|
||||
z-index: ${depths.tooltip};
|
||||
max-width: calc(100vw - 10px);
|
||||
|
||||
/* Animation */
|
||||
|
||||
@@ -23,29 +23,24 @@ 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> & {
|
||||
$hidden?: boolean;
|
||||
}
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>((props, ref) => {
|
||||
const { children, $hidden, ...rest } = props;
|
||||
const { children, ...rest } = props;
|
||||
const [measureRef, bounds] = useMeasure();
|
||||
|
||||
return (
|
||||
<DrawerPrimitive.Portal>
|
||||
{!$hidden && (
|
||||
<DrawerPrimitive.Overlay asChild>
|
||||
<Overlay />
|
||||
</DrawerPrimitive.Overlay>
|
||||
)}
|
||||
<DrawerPrimitive.Overlay asChild>
|
||||
<Overlay />
|
||||
</DrawerPrimitive.Overlay>
|
||||
<DrawerPrimitive.Content ref={ref} asChild>
|
||||
<StyledContent
|
||||
$hidden={$hidden}
|
||||
animate={{
|
||||
height: bounds.height,
|
||||
transition: { bounce: 0, duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<StyledInnerContent ref={measureRef} {...rest}>
|
||||
<StyledInnerContent column ref={measureRef} {...rest}>
|
||||
{children}
|
||||
</StyledInnerContent>
|
||||
</StyledContent>
|
||||
@@ -63,9 +58,9 @@ const DrawerTitle = React.forwardRef<
|
||||
const { hidden, children, ...rest } = props;
|
||||
|
||||
const title = (
|
||||
<Text size="medium" weight="bold" as={TitleWrapper} justify="center">
|
||||
<StyledText size="medium" weight="bold" as={TitleWrapper} justify="center">
|
||||
{children}
|
||||
</Text>
|
||||
</StyledText>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -80,8 +75,12 @@ const DrawerTitle = React.forwardRef<
|
||||
});
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(m.div)<{ $hidden?: boolean }>`
|
||||
const StyledContent = styled(m.div)`
|
||||
z-index: ${depths.menu};
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -95,11 +94,9 @@ const StyledContent = styled(m.div)<{ $hidden?: boolean }>`
|
||||
border-radius: 6px;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
|
||||
${({ $hidden }) => $hidden && "display: none;"}
|
||||
`;
|
||||
|
||||
const StyledInnerContent = styled.div`
|
||||
const StyledInnerContent = styled(Flex)`
|
||||
padding: 6px;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
@@ -1,38 +1,11 @@
|
||||
import type { RefObject } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type MenuVariant = "dropdown" | "context" | "inline";
|
||||
type MenuVariant = "dropdown" | "context";
|
||||
|
||||
type MenuContextType = {
|
||||
const MenuContext = createContext<{
|
||||
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({
|
||||
@@ -42,54 +15,7 @@ export function MenuProvider({
|
||||
variant: MenuVariant;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
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,
|
||||
]
|
||||
);
|
||||
const ctx = useMemo(() => ({ variant }), [variant]);
|
||||
|
||||
return <MenuContext.Provider value={ctx}>{children}</MenuContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -3,31 +3,18 @@ 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
|
||||
@@ -44,10 +31,6 @@ 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
|
||||
@@ -85,77 +68,16 @@ MenuTrigger.displayName = "MenuTrigger";
|
||||
type ContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Content
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
|
||||
pos?: {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
};
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>;
|
||||
|
||||
const MenuContent = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Content>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Content>
|
||||
| HTMLDivElement,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
ContentProps
|
||||
>((props, ref) => {
|
||||
const { variant, mainMenuRef, activeSubmenu } = useMenuContext();
|
||||
const isMobile = useMobile();
|
||||
const { view } = useEditor();
|
||||
|
||||
const { variant } = useMenuContext();
|
||||
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
|
||||
@@ -198,45 +120,11 @@ type SubMenuTriggerProps = BaseItemProps &
|
||||
|
||||
const SubMenuTrigger = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>
|
||||
| HTMLDivElement,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
SubMenuTriggerProps
|
||||
>((props, ref) => {
|
||||
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 { variant } = useMenuContext();
|
||||
const { label, icon, disabled, ...rest } = props;
|
||||
|
||||
const Trigger =
|
||||
variant === "dropdown"
|
||||
@@ -255,12 +143,6 @@ 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
|
||||
> &
|
||||
@@ -268,166 +150,11 @@ type SubMenuContentProps = React.ComponentPropsWithoutRef<
|
||||
|
||||
const SubMenuContent = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>
|
||||
| HTMLDivElement,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
SubMenuContentProps
|
||||
>((props, ref) => {
|
||||
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 { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
|
||||
const Portal =
|
||||
variant === "dropdown"
|
||||
@@ -476,8 +203,7 @@ type MenuGroupProps = {
|
||||
|
||||
const MenuGroup = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Group>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Group>
|
||||
| HTMLDivElement,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Group>,
|
||||
MenuGroupProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
@@ -498,7 +224,6 @@ const MenuGroup = React.forwardRef<
|
||||
MenuGroup.displayName = "MenuGroup";
|
||||
|
||||
type BaseItemProps = {
|
||||
id?: string;
|
||||
label: string;
|
||||
icon?: React.ReactElement;
|
||||
disabled?: boolean;
|
||||
@@ -523,9 +248,7 @@ const MenuButton = React.forwardRef<
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
MenuButtonProps
|
||||
>((props, ref) => {
|
||||
const { variant, activeSubmenu, setActiveSubmenu } = useMenuContext();
|
||||
const { view } = useEditor();
|
||||
const [active, setActive] = React.useState(false);
|
||||
const { variant } = useMenuContext();
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
@@ -537,63 +260,28 @@ 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 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 ? (
|
||||
const button = (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<Components.MenuButton
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
disabled={disabled}
|
||||
$dangerous={dangerous}
|
||||
$active={active}
|
||||
onClick={(e) => {
|
||||
onClick(e);
|
||||
closeMenu(view);
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{buttonContent}
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
{selected !== undefined && (
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon size={18} /> : null}
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
</Components.MenuButton>
|
||||
) : (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<Components.MenuButton
|
||||
disabled={disabled}
|
||||
$dangerous={dangerous}
|
||||
onClick={onClick}
|
||||
>
|
||||
{buttonContent}
|
||||
</Components.MenuButton>
|
||||
</Item>
|
||||
);
|
||||
</Item>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip content={tooltip} placement="bottom">
|
||||
@@ -687,16 +375,11 @@ type MenuSeparatorProps = React.ComponentPropsWithoutRef<
|
||||
|
||||
const MenuSeparator = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Separator>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Separator>
|
||||
| HTMLDivElement,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
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
|
||||
@@ -736,82 +419,6 @@ 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,
|
||||
|
||||
@@ -129,7 +129,7 @@ const StyledContent = styled(PopoverPrimitive.Content)<StyledContentProps>`
|
||||
`}
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1); // ease-out-circ
|
||||
animation: ${fadeAndScaleIn} 150ms cubic-bezier(0.08, 0.82, 0.17, 1);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
|
||||
!props.disabled &&
|
||||
`
|
||||
&[data-highlighted],
|
||||
&[data-state="open"],
|
||||
&:focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
|
||||
@@ -107,25 +108,6 @@ 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,17 +1,126 @@
|
||||
import { useCallback } from "react";
|
||||
import { DocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import getMenuItems from "../menus/block";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
|
||||
import SuggestionsMenu from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
/**
|
||||
* Hook that returns a template menu item with children for inserting template
|
||||
* content into the editor, or undefined if no templates are available.
|
||||
*/
|
||||
function useTemplateMenuItem(): MenuItem | undefined {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const { documents, templates: templatesStore } = useStores();
|
||||
const editor = useEditor();
|
||||
const documentId = editor.props.id;
|
||||
const document = documentId ? documents.get(documentId) : undefined;
|
||||
const collectionId = document?.collectionId;
|
||||
|
||||
return useMemo(() => {
|
||||
if (!user) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allTemplates = templatesStore.orderedData.filter(
|
||||
(template) => template.isActive
|
||||
);
|
||||
const hasTemplates = allTemplates.some(
|
||||
(template) =>
|
||||
template.isWorkspaceTemplate || template.collectionId === collectionId
|
||||
);
|
||||
|
||||
if (!hasTemplates) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const toMenuItem = (template: (typeof allTemplates)[0]): MenuItem => ({
|
||||
name: "noop",
|
||||
title: TextHelper.replaceTemplateVariables(
|
||||
template.titleWithDefault,
|
||||
user
|
||||
),
|
||||
icon: template.icon ? (
|
||||
<Icon
|
||||
value={template.icon}
|
||||
initial={template.initial}
|
||||
color={template.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
keywords: template.titleWithDefault,
|
||||
onClick: () => {
|
||||
const data = cloneDeep(template.data);
|
||||
ProsemirrorHelper.replaceTemplateVariables(data, user);
|
||||
editor.insertContent(data);
|
||||
},
|
||||
});
|
||||
|
||||
const children = (): MenuItem[] => {
|
||||
const collectionTemplates = allTemplates.filter(
|
||||
(template) =>
|
||||
!template.isWorkspaceTemplate &&
|
||||
template.collectionId === collectionId
|
||||
);
|
||||
const workspaceTemplates = allTemplates.filter(
|
||||
(tmpl) => tmpl.isWorkspaceTemplate
|
||||
);
|
||||
|
||||
const items: MenuItem[] = collectionTemplates.map(toMenuItem);
|
||||
|
||||
if (collectionTemplates.length && workspaceTemplates.length) {
|
||||
items.push({ name: "separator" });
|
||||
}
|
||||
|
||||
if (workspaceTemplates.length) {
|
||||
for (const template of workspaceTemplates) {
|
||||
items.push(toMenuItem(template));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
return {
|
||||
name: "noop",
|
||||
title: t("Templates"),
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "template",
|
||||
children,
|
||||
} satisfies MenuItem;
|
||||
}, [user, templatesStore.orderedData, collectionId, editor, t]);
|
||||
}
|
||||
|
||||
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
|
||||
Required<Pick<SuggestionsMenuProps, "embeds">>;
|
||||
|
||||
function BlockMenu(props: Props) {
|
||||
const dictionary = useDictionary();
|
||||
const { elementRef } = useEditor();
|
||||
const templateMenuItem = useTemplateMenuItem();
|
||||
|
||||
const items = useMemo(() => {
|
||||
const baseItems = getMenuItems(dictionary, elementRef);
|
||||
|
||||
if (!templateMenuItem) {
|
||||
return baseItems;
|
||||
}
|
||||
|
||||
return [...baseItems, { name: "separator" } as MenuItem, templateMenuItem];
|
||||
}, [dictionary, elementRef, templateMenuItem]);
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
(item, _index, options) => (
|
||||
@@ -20,6 +129,7 @@ function BlockMenu(props: Props) {
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
shortcut={item.shortcut}
|
||||
disclosure={options.disclosure}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
@@ -31,9 +141,9 @@ function BlockMenu(props: Props) {
|
||||
filterable
|
||||
trigger="/"
|
||||
renderMenuItem={renderMenuItem}
|
||||
items={getMenuItems(dictionary, elementRef)}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlockMenu;
|
||||
export default observer(BlockMenu);
|
||||
|
||||
@@ -39,7 +39,6 @@ const EmojiMenu = (props: Props) => {
|
||||
.map((item) => {
|
||||
// We snake_case the shortcode for backwards compatability with gemoji to
|
||||
// avoid multiple formats being written into documents.
|
||||
// @ts-expect-error emojiMartToGemoji key
|
||||
const id = emojiMartToGemoji[item.id] || item.id;
|
||||
const type = determineIconType(id);
|
||||
const value = type === IconType.Custom ? id : snakeCase(id);
|
||||
|
||||
@@ -36,16 +36,14 @@ const defaultPosition = {
|
||||
visible: false,
|
||||
};
|
||||
|
||||
export function usePosition({
|
||||
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;
|
||||
@@ -92,12 +90,19 @@ export function usePosition({
|
||||
} as DOMRect);
|
||||
|
||||
// position at the top right of code blocks
|
||||
const codeBlock = findParentNode(isCode)(view.state.selection);
|
||||
const isCodeNodeSelection =
|
||||
selection instanceof NodeSelection && isCode(selection.node);
|
||||
const codeBlock = isCodeNodeSelection
|
||||
? { pos: selection.from, node: selection.node }
|
||||
: findParentNode(isCode)(view.state.selection);
|
||||
const noticeBlock = findParentNode(
|
||||
(node) => node.type.name === "container_notice"
|
||||
)(view.state.selection);
|
||||
|
||||
if ((codeBlock || noticeBlock) && view.state.selection.empty) {
|
||||
if (
|
||||
(codeBlock || noticeBlock) &&
|
||||
(view.state.selection.empty || isCodeNodeSelection)
|
||||
) {
|
||||
const position = codeBlock
|
||||
? codeBlock.pos
|
||||
: noticeBlock
|
||||
@@ -122,14 +127,13 @@ export 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 - (inline ? 160 : 16);
|
||||
selectionBounds.left = bounds.left;
|
||||
selectionBounds.top = bounds.top - 16;
|
||||
selectionBounds.left = bounds.left - 10;
|
||||
selectionBounds.right = bounds.left - 10;
|
||||
} else if (isColSelection) {
|
||||
const rect = selectedRect(view.state);
|
||||
@@ -139,7 +143,6 @@ export 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;
|
||||
@@ -152,8 +155,8 @@ export function usePosition({
|
||||
);
|
||||
if (element instanceof HTMLElement) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top + (inline ? 55 : 0);
|
||||
selectionBounds.left = bounds.left - (inline ? 410 : 10);
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.left = bounds.left - 10;
|
||||
selectionBounds.right = bounds.left - 10;
|
||||
}
|
||||
}
|
||||
@@ -202,13 +205,11 @@ export function usePosition({
|
||||
),
|
||||
Math.max(
|
||||
Math.max(offsetParent.x, margin),
|
||||
isColSelection && colWidth < 300
|
||||
? selectionBounds.right + margin
|
||||
: align === "center"
|
||||
? centerOfSelection - menuWidth / 2
|
||||
: align === "start"
|
||||
? selectionBounds.left
|
||||
: selectionBounds.right
|
||||
align === "center"
|
||||
? centerOfSelection - menuWidth / 2
|
||||
: align === "start"
|
||||
? selectionBounds.left
|
||||
: selectionBounds.right
|
||||
)
|
||||
);
|
||||
const top = Math.max(
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
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;
|
||||
@@ -1,7 +1,12 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
|
||||
import {
|
||||
DocumentIcon,
|
||||
PlusIcon,
|
||||
NewDocumentIcon,
|
||||
CollectionIcon,
|
||||
} from "outline-icons";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
@@ -82,154 +87,171 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
// 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,
|
||||
const items: MentionItem[] = actorId
|
||||
? 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.Document,
|
||||
modelId: uuidv4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId,
|
||||
label: search,
|
||||
label: user.name,
|
||||
},
|
||||
} as MentionItem,
|
||||
])
|
||||
: [];
|
||||
}) 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,
|
||||
{
|
||||
name: "link",
|
||||
icon: <NewDocumentIcon />,
|
||||
title: search?.trim(),
|
||||
section: DocumentsSection,
|
||||
subtitle: t("Create a nested doc"),
|
||||
visible: !!search && !isEmail(search) && !!documentId,
|
||||
priority: -2,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Document,
|
||||
modelId: uuidv4(),
|
||||
actorId,
|
||||
label: search,
|
||||
nested: true,
|
||||
},
|
||||
} as MentionItem,
|
||||
])
|
||||
: [];
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import { isUrl } from "@shared/utils/urls";
|
||||
import { isInternalUrl, isUrl } from "@shared/utils/urls";
|
||||
import type Integration from "~/models/Integration";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -67,11 +67,15 @@ function useItems({
|
||||
|
||||
const singleUrl =
|
||||
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
|
||||
const embed = singleUrl ? getMatchingEmbed(embeds, singleUrl)?.embed : null;
|
||||
const isInternal = singleUrl ? isInternalUrl(singleUrl) : false;
|
||||
const matchedEmbed = singleUrl
|
||||
? getMatchingEmbed(embeds, singleUrl)?.embed
|
||||
: null;
|
||||
const embed = matchedEmbed?.disabled ? null : matchedEmbed;
|
||||
|
||||
// Check embeddability for single URL
|
||||
useEffect(() => {
|
||||
if (!singleUrl || !embed) {
|
||||
if (!singleUrl || !embed || isInternal) {
|
||||
setEmbedCheck({ loading: false });
|
||||
return;
|
||||
}
|
||||
@@ -98,7 +102,7 @@ function useItems({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [singleUrl, embed]);
|
||||
}, [singleUrl, embed, isInternal]);
|
||||
|
||||
// single item is pasted.
|
||||
if (typeof pastedText === "string") {
|
||||
@@ -140,8 +144,10 @@ function useItems({
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
subtitle:
|
||||
embedCheck.embeddable === false ? t("Not supported") : undefined,
|
||||
disabled: embedCheck.loading || !embedCheck.embeddable,
|
||||
embedCheck.embeddable === false || isInternal
|
||||
? t("Not supported")
|
||||
: undefined,
|
||||
disabled: isInternal || embedCheck.loading || !embedCheck.embeddable,
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
getRowIndex,
|
||||
isTableSelected,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { MenuType, type MenuItem } from "@shared/editor/types";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -40,9 +40,6 @@ 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 */
|
||||
@@ -243,7 +240,10 @@ export function SelectionToolbar(props: Props) {
|
||||
let items: MenuItem[] = [];
|
||||
let align: "center" | "start" | "end" = "center";
|
||||
|
||||
if (isCodeSelection && selection.empty) {
|
||||
if (
|
||||
isCodeSelection &&
|
||||
(selection.empty || selection instanceof NodeSelection)
|
||||
) {
|
||||
items = getCodeMenuItems(state, readOnly, dictionary);
|
||||
align = "end";
|
||||
} else if (isTableSelected(state)) {
|
||||
@@ -272,8 +272,6 @@ 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") {
|
||||
@@ -320,14 +318,6 @@ 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}
|
||||
@@ -372,20 +362,3 @@ 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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -6,21 +6,26 @@ import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { s } from "@shared/styles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { MouseSafeArea } from "~/components/MouseSafeArea";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -29,38 +34,6 @@ import { useEditor } from "./EditorContext";
|
||||
import Input from "./Input";
|
||||
import { MenuHeader } from "~/components/primitives/components/Menu";
|
||||
|
||||
type TopAnchor = {
|
||||
top: number;
|
||||
bottom: undefined;
|
||||
};
|
||||
|
||||
type BottomAnchor = {
|
||||
top: undefined;
|
||||
bottom: number;
|
||||
};
|
||||
|
||||
type LeftAnchor = {
|
||||
left: number;
|
||||
right: undefined;
|
||||
};
|
||||
|
||||
type RightAnchor = {
|
||||
left: undefined;
|
||||
right: number;
|
||||
};
|
||||
|
||||
type Position = ((TopAnchor | BottomAnchor) & (LeftAnchor | RightAnchor)) & {
|
||||
isAbove: boolean;
|
||||
};
|
||||
|
||||
const defaultPosition: Position = {
|
||||
top: 0,
|
||||
bottom: undefined,
|
||||
left: -10000,
|
||||
right: undefined,
|
||||
isAbove: false,
|
||||
};
|
||||
|
||||
export type Props<T extends MenuItem = MenuItem> = {
|
||||
rtl: boolean;
|
||||
isActive: boolean;
|
||||
@@ -80,6 +53,7 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
index: number,
|
||||
options: {
|
||||
selected: boolean;
|
||||
disclosure?: boolean;
|
||||
onClick: (event: React.SyntheticEvent) => void;
|
||||
}
|
||||
) => React.ReactNode;
|
||||
@@ -92,25 +66,70 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const dictionary = useDictionary();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const hasActivated = React.useRef(false);
|
||||
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
});
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const selectionRef = React.useRef<{ from: number; to: number } | null>(null);
|
||||
const [position, setPosition] = React.useState<Position>(defaultPosition);
|
||||
const [insertItem, setInsertItem] = React.useState<
|
||||
MenuItem | EmbedDescriptor
|
||||
>();
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const [submenu, setSubmenu] = React.useState<{
|
||||
index: number;
|
||||
items: MenuItem[];
|
||||
selectedIndex: number;
|
||||
} | null>(null);
|
||||
const itemRefs = React.useRef<Map<number, HTMLElement>>(new Map());
|
||||
const submenuContentRef = React.useRef<HTMLDivElement>(null);
|
||||
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Stores the caret bounding rect, snapshotted when the menu opens
|
||||
const caretRectRef = React.useRef(new DOMRect());
|
||||
|
||||
// Stable virtual element for Radix PopoverAnchor – never replaced so the
|
||||
// popper does not trigger unnecessary anchor-change cycles.
|
||||
const caretRef = React.useRef({
|
||||
getBoundingClientRect: () => caretRectRef.current,
|
||||
});
|
||||
|
||||
// Compute and store the caret rect during render so it is available before
|
||||
// the Radix popper effect runs for the first time.
|
||||
const caretRect = React.useMemo(() => {
|
||||
if (!props.isActive) {
|
||||
return new DOMRect();
|
||||
}
|
||||
|
||||
try {
|
||||
const { selection } = view.state;
|
||||
const fromPos = view.coordsAtPos(selection.from);
|
||||
const toPos = view.coordsAtPos(selection.to, -1);
|
||||
const top = Math.min(fromPos.top, toPos.top);
|
||||
const bottom = Math.max(fromPos.bottom, toPos.bottom);
|
||||
const left = Math.min(fromPos.left, toPos.left);
|
||||
const right = Math.max(fromPos.right, toPos.right);
|
||||
return new DOMRect(left, top, right - left, bottom - top);
|
||||
} catch (err) {
|
||||
Logger.warn("Unable to calculate caret position", err);
|
||||
return new DOMRect();
|
||||
}
|
||||
}, [props.isActive, view]);
|
||||
|
||||
caretRectRef.current = caretRect;
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem["children"]
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.isActive) {
|
||||
hasActivated.current = true;
|
||||
// Save the selection position when the menu opens. On mobile, the editor
|
||||
// may lose focus/selection when tapping on menu items, so we restore it.
|
||||
// Save the selection position when the menu opens and as the user types.
|
||||
// On mobile, the editor may lose focus/selection when tapping on menu
|
||||
// items, so we restore it. The position must stay current as the search
|
||||
// text grows, otherwise the deletion range calculated in handleClearSearch
|
||||
// will be wrong.
|
||||
requestAnimationFrame(() => {
|
||||
const { from, to } = view.state.selection;
|
||||
selectionRef.current = { from, to };
|
||||
@@ -119,83 +138,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
selectionRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.isActive, props.search]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSubmenu(null);
|
||||
|
||||
if (!props.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedIndex(0);
|
||||
setInsertItem(undefined);
|
||||
}, [props.isActive]);
|
||||
|
||||
const calculatePosition = React.useCallback(
|
||||
(props: Props) => {
|
||||
if (!props.isActive) {
|
||||
return defaultPosition;
|
||||
}
|
||||
|
||||
const caretPosition = () => {
|
||||
let fromPos;
|
||||
let toPos;
|
||||
try {
|
||||
fromPos = view.coordsAtPos(selection.from);
|
||||
toPos = view.coordsAtPos(selection.to, -1);
|
||||
} catch (err) {
|
||||
Logger.warn("Unable to calculate caret position", err);
|
||||
return {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ensure that start < end for the menu to be positioned correctly
|
||||
return {
|
||||
top: Math.min(fromPos.top, toPos.top),
|
||||
bottom: Math.max(fromPos.bottom, toPos.bottom),
|
||||
left: Math.min(fromPos.left, toPos.left),
|
||||
right: Math.max(fromPos.right, toPos.right),
|
||||
};
|
||||
};
|
||||
|
||||
const { selection } = view.state;
|
||||
const ref = menuRef.current;
|
||||
const offsetWidth = ref ? ref.offsetWidth : 0;
|
||||
const offsetHeight = ref ? ref.offsetHeight : 0;
|
||||
const { top, bottom, right, left } = caretPosition();
|
||||
const margin = 12;
|
||||
|
||||
const offsetParent = ref?.offsetParent
|
||||
? ref.offsetParent.getBoundingClientRect()
|
||||
: ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
} as DOMRect);
|
||||
|
||||
let leftPos = Math.min(
|
||||
left - offsetParent.left,
|
||||
window.innerWidth - offsetParent.left - offsetWidth - margin
|
||||
);
|
||||
if (props.rtl) {
|
||||
leftPos = right - offsetWidth;
|
||||
}
|
||||
|
||||
if (top - offsetHeight > margin) {
|
||||
return {
|
||||
left: leftPos,
|
||||
top: undefined,
|
||||
bottom: offsetParent.bottom - top,
|
||||
right: undefined,
|
||||
isAbove: false,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
left: leftPos,
|
||||
top: bottom - offsetParent.top,
|
||||
bottom: undefined,
|
||||
right: undefined,
|
||||
isAbove: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
[view]
|
||||
);
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
setSubmenu(null);
|
||||
}, [props.search]);
|
||||
|
||||
const handleClearSearch = React.useCallback(() => {
|
||||
const { state, dispatch } = view;
|
||||
@@ -226,26 +185,6 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
);
|
||||
}, [props.search, props.trigger, view]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!props.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// reset scroll position to top when opening menu as the contents are
|
||||
// hidden, not unrendered
|
||||
if (menuRef.current) {
|
||||
menuRef.current.scroll({ top: 0 });
|
||||
}
|
||||
|
||||
setPosition(calculatePosition(props));
|
||||
setSelectedIndex(0);
|
||||
setInsertItem(undefined);
|
||||
}, [calculatePosition, props.isActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [props.search]);
|
||||
|
||||
const restoreSelection = React.useCallback(() => {
|
||||
if (!isMobile) {
|
||||
return;
|
||||
@@ -274,7 +213,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
|
||||
|
||||
if (item.name === "noop") {
|
||||
// Do nothing
|
||||
if ("onClick" in item) {
|
||||
item.onClick?.();
|
||||
}
|
||||
} else if (command) {
|
||||
command(attrs);
|
||||
} else {
|
||||
@@ -304,10 +245,13 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
...item,
|
||||
name: "mention",
|
||||
});
|
||||
void editorProps.onCreateLink?.({
|
||||
title: item.attrs.label,
|
||||
id: item.attrs.modelId,
|
||||
});
|
||||
void editorProps.onCreateLink?.(
|
||||
{
|
||||
title: item.attrs.label,
|
||||
id: item.attrs.modelId,
|
||||
},
|
||||
!!item.attrs.nested
|
||||
);
|
||||
return;
|
||||
case "image":
|
||||
return triggerFilePick(
|
||||
@@ -461,7 +405,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const embedItems: EmbedDescriptor[] = [];
|
||||
|
||||
for (const embed of embeds) {
|
||||
if (embed.title && embed.visible !== false) {
|
||||
if (embed.title && embed.visible !== false && !embed.disabled) {
|
||||
embedItems.push(
|
||||
new EmbedDescriptor({
|
||||
...embed,
|
||||
@@ -481,11 +425,47 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
|
||||
const searchInput = search.toLowerCase();
|
||||
|
||||
const matchesSearch = (item: MenuItem | EmbedDescriptor) =>
|
||||
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.keywords || "").toLocaleLowerCase().includes(searchInput);
|
||||
|
||||
// When searching, flatten matching children into the top-level list so
|
||||
// they are directly navigable with the keyboard. If all children match,
|
||||
// exclude the parent item since it would be redundant.
|
||||
const fullyFlattenedParents = new Set<MenuItem | EmbedDescriptor>();
|
||||
if (search && filterable) {
|
||||
const flattened: (EmbedDescriptor | MenuItem)[] = [];
|
||||
for (const item of items) {
|
||||
if ("children" in item && item.children) {
|
||||
const children = resolveChildren(item.children);
|
||||
if (children) {
|
||||
const matching = children.filter(matchesSearch);
|
||||
if (matching.length > 0) {
|
||||
for (const child of matching) {
|
||||
const { children: _, ...flat } = child;
|
||||
flattened.push(flat);
|
||||
}
|
||||
if (matching.length === children.length) {
|
||||
fullyFlattenedParents.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items = items.concat(flattened);
|
||||
}
|
||||
|
||||
const filtered = items.filter((item) => {
|
||||
if (item.name === "separator") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fullyFlattenedParents.has(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.visible === false) {
|
||||
return false;
|
||||
}
|
||||
@@ -514,11 +494,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return (
|
||||
(item.name || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.title || "").toLocaleLowerCase().includes(searchInput) ||
|
||||
(item.keywords || "").toLocaleLowerCase().includes(searchInput)
|
||||
);
|
||||
return matchesSearch(item);
|
||||
});
|
||||
|
||||
return filterExcessSeparators(
|
||||
@@ -541,18 +517,40 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
);
|
||||
}, [commands, props]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (
|
||||
!menuRef.current ||
|
||||
menuRef.current.contains(event.target as Element)
|
||||
) {
|
||||
const openSubmenu = React.useCallback(
|
||||
(index: number) => {
|
||||
const item = filtered[index];
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const children = resolveChildren(
|
||||
"children" in item ? item.children : undefined
|
||||
);
|
||||
if (!children?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onClose();
|
||||
};
|
||||
const normalized = filterExcessSeparators(
|
||||
children.filter((child) => child.visible !== false)
|
||||
);
|
||||
const firstSelectable = normalized.findIndex(
|
||||
(child) =>
|
||||
child.name !== "separator" && !("disabled" in child && child.disabled)
|
||||
);
|
||||
if (firstSelectable === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmenu({
|
||||
index,
|
||||
items: normalized,
|
||||
selectedIndex: firstSelectable,
|
||||
});
|
||||
},
|
||||
[filtered]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.isComposing) {
|
||||
return;
|
||||
@@ -561,18 +559,109 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Let the link input's own handlers manage navigation keys
|
||||
if (insertItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Submenu open: route keys into it ---
|
||||
if (submenu) {
|
||||
if (event.key === "ArrowDown" || (event.ctrlKey && event.key === "n")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const total = submenu.items.length - 1;
|
||||
let next = submenu.selectedIndex + 1;
|
||||
while (next <= total) {
|
||||
const child = submenu.items[next];
|
||||
if (
|
||||
child?.name !== "separator" &&
|
||||
!("disabled" in child && child.disabled)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
next++;
|
||||
}
|
||||
if (next <= total) {
|
||||
setSubmenu((s) => (s ? { ...s, selectedIndex: next } : s));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" || (event.ctrlKey && event.key === "p")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
let prev = submenu.selectedIndex - 1;
|
||||
while (prev >= 0) {
|
||||
const child = submenu.items[prev];
|
||||
if (
|
||||
child?.name !== "separator" &&
|
||||
!("disabled" in child && child.disabled)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
prev--;
|
||||
}
|
||||
if (prev >= 0) {
|
||||
setSubmenu((s) => (s ? { ...s, selectedIndex: prev } : s));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft" || event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSubmenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const child = submenu.items[submenu.selectedIndex];
|
||||
if (child) {
|
||||
handleClickItem(child);
|
||||
setSubmenu(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Normal (no submenu) ---
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
||||
const item = filtered[selectedIndex];
|
||||
|
||||
if (item) {
|
||||
handleClickItem(item);
|
||||
const children = resolveChildren(
|
||||
"children" in item ? item.children : undefined
|
||||
);
|
||||
if (children?.length) {
|
||||
openSubmenu(selectedIndex);
|
||||
} else {
|
||||
handleClickItem(item);
|
||||
}
|
||||
} else {
|
||||
props.onClose(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "ArrowRight") {
|
||||
const item = filtered[selectedIndex];
|
||||
if (item) {
|
||||
const children = resolveChildren(
|
||||
"children" in item ? item.children : undefined
|
||||
);
|
||||
if (children?.length) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openSubmenu(selectedIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "ArrowUp" ||
|
||||
(event.key === "Tab" && event.shiftKey) ||
|
||||
@@ -636,18 +725,25 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousedown", handleMouseDown);
|
||||
window.addEventListener("keydown", handleKeyDown, {
|
||||
capture: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", handleMouseDown);
|
||||
window.removeEventListener("keydown", handleKeyDown, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}, [close, filtered, handleClickItem, props, selectedIndex]);
|
||||
}, [
|
||||
close,
|
||||
filtered,
|
||||
handleClickItem,
|
||||
insertItem,
|
||||
openSubmenu,
|
||||
props,
|
||||
selectedIndex,
|
||||
submenu,
|
||||
]);
|
||||
|
||||
const { isActive, uploadFile } = props;
|
||||
const items = filtered;
|
||||
@@ -664,7 +760,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const fileInput = uploadFile && (
|
||||
<VisuallyHidden.Root>
|
||||
<label>
|
||||
<Trans>Import document</Trans>
|
||||
<Trans>Upload file</Trans>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
@@ -675,6 +771,23 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
</VisuallyHidden.Root>
|
||||
);
|
||||
|
||||
// Close submenu when parent selection moves away from the trigger
|
||||
React.useEffect(() => {
|
||||
if (submenu && submenu.index !== selectedIndex) {
|
||||
setSubmenu(null);
|
||||
}
|
||||
}, [selectedIndex, submenu]);
|
||||
|
||||
// Cleanup hover timer on unmount
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const renderItems = () => {
|
||||
let prevHeading: string | undefined;
|
||||
|
||||
@@ -693,6 +806,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasChildren = !!(
|
||||
"children" in item && resolveChildren(item.children)?.length
|
||||
);
|
||||
|
||||
const handlePointerMove = (ev: React.PointerEvent) => {
|
||||
if (
|
||||
!("disabled" in item && item.disabled) &&
|
||||
@@ -708,6 +825,22 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
clientX: ev.clientX,
|
||||
clientY: ev.clientY,
|
||||
};
|
||||
|
||||
// Hover to open submenu with delay
|
||||
if (hasChildren) {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
openSubmenu(index);
|
||||
}, 150);
|
||||
} else {
|
||||
// Close submenu when hovering a regular item
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
setSubmenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = () => {
|
||||
@@ -722,23 +855,37 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const handleOnClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
handleClickItem(item);
|
||||
if (hasChildren) {
|
||||
openSubmenu(index);
|
||||
} else {
|
||||
handleClickItem(item);
|
||||
}
|
||||
};
|
||||
|
||||
const currentHeading =
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const itemRef = (node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
itemRefs.current.set(index, node);
|
||||
} else {
|
||||
itemRefs.current.delete(index);
|
||||
}
|
||||
};
|
||||
|
||||
const response = (
|
||||
<React.Fragment key={`${index}-${item.name}`}>
|
||||
{currentHeading !== prevHeading && (
|
||||
<MenuHeader key={currentHeading}>{currentHeading}</MenuHeader>
|
||||
)}
|
||||
<ListItem
|
||||
ref={itemRef}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
disclosure: hasChildren,
|
||||
onClick: handleOnClick,
|
||||
})}
|
||||
</ListItem>
|
||||
@@ -792,37 +939,146 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Wrapper active={isActive} ref={menuRef} hiddenScrollbars {...position}>
|
||||
{(isActive || hasActivated.current) && (
|
||||
<>
|
||||
{insertItem ? (
|
||||
<LinkInputWrapper>
|
||||
<LinkInput
|
||||
type="text"
|
||||
placeholder={
|
||||
"placeholder" in insertItem && !!insertItem.placeholder
|
||||
? insertItem.placeholder
|
||||
: insertItem.title
|
||||
? dictionary.pasteLinkWithTitle(insertItem.title)
|
||||
: dictionary.pasteLink
|
||||
<>
|
||||
<Popover open={isActive} onOpenChange={handleOpenChange} modal={false}>
|
||||
<PopoverAnchor virtualRef={caretRef} />
|
||||
<BouncyPopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
width={280}
|
||||
shrink
|
||||
style={{
|
||||
padding: 0,
|
||||
maxHeight:
|
||||
"min(324px, var(--radix-popover-content-available-height))",
|
||||
}}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => {
|
||||
if (submenuContentRef.current?.contains(e.target as Node)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{insertItem ? (
|
||||
<LinkInputWrapper>
|
||||
<LinkInput
|
||||
type="text"
|
||||
placeholder={
|
||||
"placeholder" in insertItem && !!insertItem.placeholder
|
||||
? insertItem.placeholder
|
||||
: insertItem.title
|
||||
? dictionary.pasteLinkWithTitle(insertItem.title)
|
||||
: dictionary.pasteLink
|
||||
}
|
||||
onKeyDown={handleLinkInputKeydown}
|
||||
onPaste={handleLinkInputPaste}
|
||||
autoFocus
|
||||
/>
|
||||
</LinkInputWrapper>
|
||||
) : (
|
||||
<List>{renderItems()}</List>
|
||||
)}
|
||||
</BouncyPopoverContent>
|
||||
</Popover>
|
||||
{fileInput}
|
||||
{submenu && itemRefs.current.get(submenu.index) && (
|
||||
<Popover open modal={false}>
|
||||
<PopoverAnchor
|
||||
virtualRef={{
|
||||
current: {
|
||||
getBoundingClientRect: () =>
|
||||
itemRefs.current.get(submenu.index)!.getBoundingClientRect(),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<SubmenuPopoverContent
|
||||
ref={submenuContentRef}
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={0}
|
||||
width={220}
|
||||
shrink
|
||||
style={{ padding: 0 }}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onPointerLeave={() => setSubmenu(null)}
|
||||
>
|
||||
<MouseSafeArea parentRef={submenuContentRef} />
|
||||
<List>
|
||||
{submenu.items.map((child, childIndex) => {
|
||||
if (child.name === "separator") {
|
||||
return (
|
||||
<ListItem key={childIndex}>
|
||||
<hr />
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
if (!child.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChildPointerMove = (ev: React.PointerEvent) => {
|
||||
if (
|
||||
submenu.selectedIndex !== childIndex &&
|
||||
(pointerRef.current.clientX !== ev.clientX ||
|
||||
pointerRef.current.clientY !== ev.clientY)
|
||||
) {
|
||||
setSubmenu((s) =>
|
||||
s ? { ...s, selectedIndex: childIndex } : s
|
||||
);
|
||||
}
|
||||
onKeyDown={handleLinkInputKeydown}
|
||||
onPaste={handleLinkInputPaste}
|
||||
autoFocus
|
||||
/>
|
||||
</LinkInputWrapper>
|
||||
) : (
|
||||
<List>{renderItems()}</List>
|
||||
)}
|
||||
{fileInput}
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Portal>
|
||||
pointerRef.current = {
|
||||
clientX: ev.clientX,
|
||||
clientY: ev.clientY,
|
||||
};
|
||||
};
|
||||
|
||||
const handleChildClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
handleClickItem(child);
|
||||
setSubmenu(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={`sub-${childIndex}-${child.name}`}
|
||||
onPointerMove={handleChildPointerMove}
|
||||
>
|
||||
{props.renderMenuItem(child as any, childIndex, {
|
||||
selected: childIndex === submenu.selectedIndex,
|
||||
onClick: handleChildClick,
|
||||
})}
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</SubmenuPopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const bouncyFadeIn = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
`;
|
||||
|
||||
const BouncyPopoverContent = styled(PopoverContent)`
|
||||
&[data-state="open"] {
|
||||
animation: ${bouncyFadeIn} 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
`;
|
||||
|
||||
const SubmenuPopoverContent = styled(PopoverContent)`
|
||||
max-height: min(324px, var(--radix-popover-content-available-height));
|
||||
`;
|
||||
|
||||
const LinkInputWrapper = styled.div`
|
||||
margin: 8px;
|
||||
`;
|
||||
@@ -839,6 +1095,13 @@ const List = styled.ol`
|
||||
height: 100%;
|
||||
padding: 6px;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 1px solid ${s("divider")};
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled.li`
|
||||
@@ -860,61 +1123,4 @@ const MobileScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
export const Wrapper = styled(Scrollable)<{
|
||||
active: boolean;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
isAbove: boolean;
|
||||
}>`
|
||||
color: ${s("textSecondary")};
|
||||
font-family: ${s("fontFamily")};
|
||||
position: absolute;
|
||||
z-index: ${depths.editorToolbar};
|
||||
${(props) => props.top !== undefined && `top: ${props.top}px`};
|
||||
${(props) => props.bottom !== undefined && `bottom: ${props.bottom}px`};
|
||||
left: ${(props) => props.left}px;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.05) 0px 0px 0px 1px,
|
||||
rgba(0, 0, 0, 0.08) 0px 4px 8px,
|
||||
rgba(0, 0, 0, 0.08) 0px 2px 4px;
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
|
||||
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
transition-delay: 150ms;
|
||||
line-height: 0;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
width: 280px;
|
||||
height: auto;
|
||||
max-height: 324px;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
border-top: 1px solid ${s("divider")};
|
||||
}
|
||||
|
||||
${({ active, isAbove }) =>
|
||||
active &&
|
||||
`
|
||||
transform: translateY(${isAbove ? "6px" : "-6px"}) scale(1);
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default SuggestionsMenu;
|
||||
|
||||
@@ -5,6 +5,7 @@ import styled from "styled-components";
|
||||
import { usePortalContext } from "~/components/Portal";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuDisclosure,
|
||||
MenuIconWrapper,
|
||||
MenuLabel,
|
||||
} from "~/components/primitives/components/Menu";
|
||||
@@ -26,6 +27,8 @@ export type Props = {
|
||||
subtitle?: React.ReactNode;
|
||||
/** A string representing the keyboard shortcut for the item */
|
||||
shortcut?: string;
|
||||
/** Whether to show a disclosure arrow indicating a submenu */
|
||||
disclosure?: boolean;
|
||||
};
|
||||
|
||||
function SuggestionsMenuItem({
|
||||
@@ -37,6 +40,7 @@ function SuggestionsMenuItem({
|
||||
subtitle,
|
||||
shortcut,
|
||||
icon,
|
||||
disclosure,
|
||||
}: Props) {
|
||||
const portal = usePortalContext();
|
||||
const ref = React.useCallback(
|
||||
@@ -75,6 +79,7 @@ function SuggestionsMenuItem({
|
||||
)}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuLabel>
|
||||
{disclosure && <MenuDisclosure />}
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,10 +48,67 @@ 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
|
||||
? mapMenuItems(resolvedItemChildren, commands, state)
|
||||
: [];
|
||||
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
|
||||
}, [isOpen, commands]);
|
||||
|
||||
const handleCloseAutoFocus = useCallback((ev: Event) => {
|
||||
@@ -163,78 +220,6 @@ 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;
|
||||
|
||||
@@ -55,12 +55,10 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
Decoration.widget(
|
||||
parent.pos,
|
||||
() => {
|
||||
button.addEventListener(
|
||||
"click",
|
||||
action(() => {
|
||||
this.state.open = true;
|
||||
})
|
||||
);
|
||||
button.onclick = action(() => {
|
||||
this.state.query = "";
|
||||
this.state.open = true;
|
||||
});
|
||||
return button;
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,6 +12,10 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
return "clipboardTextSerializer";
|
||||
}
|
||||
|
||||
get allowInReadOnly() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const mdSerializer = this.editor.extensions.serializer();
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ export default class HoverPreviews extends Extension {
|
||||
return "hover-previews";
|
||||
}
|
||||
|
||||
get allowInReadOnly() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const isHoverTarget = (target: Element | null, view: EditorView) =>
|
||||
target instanceof HTMLElement &&
|
||||
|
||||
@@ -25,6 +25,10 @@ export default class Multiplayer extends Extension {
|
||||
return "multiplayer";
|
||||
}
|
||||
|
||||
get allowInReadOnly() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const { user, provider, document: doc } = this.options;
|
||||
const type = doc.get("default", Y.XmlFragment);
|
||||
|
||||
@@ -45,9 +45,8 @@ export default class SelectionToolbarExtension extends Extension {
|
||||
}
|
||||
|
||||
if (
|
||||
(isNodeActive(schema.nodes.code_block)(state) ||
|
||||
isNodeActive(schema.nodes.code_fence)(state)) &&
|
||||
selection.from > 0
|
||||
isNodeActive(schema.nodes.code_block)(state) ||
|
||||
isNodeActive(schema.nodes.code_fence)(state)
|
||||
) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
+60
-32
@@ -133,7 +133,10 @@ export type Props = {
|
||||
/** Callback when file upload progress changes */
|
||||
onFileUploadProgress?: (id: string, fractionComplete: number) => void;
|
||||
/** Callback when a link is created, should return url to created document */
|
||||
onCreateLink?: (params: Properties<Document>) => Promise<string>;
|
||||
onCreateLink?: (
|
||||
params: Properties<Document>,
|
||||
nested?: boolean
|
||||
) => Promise<string>;
|
||||
/** Callback when user clicks on any link in the document */
|
||||
onClickLink: (
|
||||
href: string,
|
||||
@@ -250,17 +253,25 @@ export class Editor extends React.PureComponent<
|
||||
this.view.updateState(newState);
|
||||
}
|
||||
|
||||
// pass readOnly changes through to underlying editor instance
|
||||
if (prevProps.readOnly !== this.props.readOnly) {
|
||||
// When transitioning from readOnly to editable, reinitialize to create
|
||||
// editing extensions, keymaps, input rules, and commands that were skipped.
|
||||
if (prevProps.readOnly && !this.props.readOnly) {
|
||||
const docJSON = this.view.state.doc.toJSON();
|
||||
this.view.destroy();
|
||||
this.init();
|
||||
const newState = this.createState(docJSON);
|
||||
this.view.updateState(newState);
|
||||
} else if (!prevProps.readOnly && this.props.readOnly) {
|
||||
// pass readOnly changes through to underlying editor instance
|
||||
this.view.update({
|
||||
...this.view.props,
|
||||
editable: () => !this.props.readOnly,
|
||||
editable: () => false,
|
||||
});
|
||||
|
||||
// NodeView will not automatically render when editable changes so we must trigger an update
|
||||
// manually, see: https://discuss.prosemirror.net/t/re-render-custom-nodeview-when-view-editable-changes/6441
|
||||
Array.from(this.renderers).forEach((view) =>
|
||||
view.setProp("isEditable", !this.props.readOnly)
|
||||
view.setProp("isEditable", false)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,15 +312,24 @@ export class Editor extends React.PureComponent<
|
||||
this.nodes = this.createNodes();
|
||||
this.marks = this.createMarks();
|
||||
this.schema = this.createSchema();
|
||||
this.widgets = this.createWidgets();
|
||||
this.plugins = this.createPlugins();
|
||||
this.rulePlugins = this.createRulePlugins();
|
||||
this.keymaps = this.createKeymaps();
|
||||
this.serializer = this.createSerializer();
|
||||
this.parser = this.createParser();
|
||||
this.pasteParser = this.createPasteParser();
|
||||
this.inputRules = this.createInputRules();
|
||||
this.nodeViews = this.createNodeViews();
|
||||
|
||||
this.widgets = this.createWidgets();
|
||||
|
||||
if (this.props.readOnly) {
|
||||
this.keymaps = [];
|
||||
this.inputRules = [];
|
||||
this.pasteParser = this.parser;
|
||||
} else {
|
||||
this.keymaps = this.createKeymaps();
|
||||
this.inputRules = this.createInputRules();
|
||||
this.pasteParser = this.createPasteParser();
|
||||
}
|
||||
|
||||
this.view = this.createView();
|
||||
this.commands = this.createCommands();
|
||||
}
|
||||
@@ -411,12 +431,20 @@ export class Editor extends React.PureComponent<
|
||||
private createState(value?: string | ProsemirrorData | ProsemirrorNode) {
|
||||
const doc = this.createDocument(value || this.props.defaultValue);
|
||||
|
||||
if (this.props.readOnly) {
|
||||
return EditorState.create({
|
||||
schema: this.schema,
|
||||
doc,
|
||||
plugins: [...this.plugins, anchorPlugin()],
|
||||
});
|
||||
}
|
||||
|
||||
return EditorState.create({
|
||||
schema: this.schema,
|
||||
doc,
|
||||
plugins: [
|
||||
...this.keymaps,
|
||||
...this.plugins,
|
||||
...this.keymaps,
|
||||
anchorPlugin(),
|
||||
dropCursor({
|
||||
color: this.props.theme.cursor,
|
||||
@@ -620,12 +648,25 @@ export class Editor extends React.PureComponent<
|
||||
window?.getSelection()?.removeAllRanges();
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert content into the editor, replacing the block at the current selection.
|
||||
*
|
||||
* @param content The prosemirror data to insert.
|
||||
*/
|
||||
public insertContent = (content: ProsemirrorData) => {
|
||||
const doc = ProsemirrorNode.fromJSON(this.schema, content);
|
||||
const { $from } = this.view.state.selection;
|
||||
const start = $from.before($from.depth);
|
||||
const end = $from.after($from.depth);
|
||||
this.view.dispatch(this.view.state.tr.replaceWith(start, end, doc.content));
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert files at the current selection.
|
||||
* =
|
||||
* @param event The source event
|
||||
* @param files The files to insert
|
||||
* @returns True if the files were inserted
|
||||
*
|
||||
* @param event The source event.
|
||||
* @param files The files to insert.
|
||||
* @returns True if the files were inserted.
|
||||
*/
|
||||
public insertFiles = (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
@@ -692,19 +733,14 @@ export class Editor extends React.PureComponent<
|
||||
public removeComment = (commentId: string) => {
|
||||
const { state, dispatch } = this.view;
|
||||
const tr = state.tr;
|
||||
let markRemoved = false;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (markRemoved) {
|
||||
return false;
|
||||
}
|
||||
const mark = node.marks.find(
|
||||
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
|
||||
);
|
||||
|
||||
if (mark) {
|
||||
tr.removeMark(pos, pos + node.nodeSize, mark);
|
||||
markRemoved = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -718,10 +754,7 @@ export class Editor extends React.PureComponent<
|
||||
marks: updatedMarks,
|
||||
};
|
||||
tr.setNodeMarkup(pos, undefined, attrs);
|
||||
markRemoved = true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
@@ -739,13 +772,8 @@ export class Editor extends React.PureComponent<
|
||||
) => {
|
||||
const { state, dispatch } = this.view;
|
||||
const tr = state.tr;
|
||||
let markUpdated = false;
|
||||
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (markUpdated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mark = node.marks.find(
|
||||
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
|
||||
);
|
||||
@@ -758,7 +786,6 @@ export class Editor extends React.PureComponent<
|
||||
...attrs,
|
||||
});
|
||||
tr.removeMark(from, to, mark).addMark(from, to, newMark);
|
||||
markUpdated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -774,10 +801,7 @@ export class Editor extends React.PureComponent<
|
||||
marks: updatedMarks,
|
||||
};
|
||||
tr.setNodeMarkup(pos, undefined, newAttrs);
|
||||
markUpdated = true;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
@@ -911,7 +935,11 @@ const EditorContainer = styled(Styles)<{
|
||||
css`
|
||||
span#comment-${props.focusedCommentId} {
|
||||
background: ${transparentize(0.5, props.theme.brand.marine)};
|
||||
border-bottom: 2px solid ${props.theme.commentMarkBackground};
|
||||
text-decoration: underline 2px ${props.theme.commentMarkBackground};
|
||||
|
||||
* {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
a#comment-${props.focusedCommentId}
|
||||
~ span.component-image
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TrashIcon, DownloadIcon, ReplaceIcon } from "outline-icons";
|
||||
import { TrashIcon, DownloadIcon, ReplaceIcon, PDFIcon } from "outline-icons";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
@@ -17,6 +17,9 @@ export default function attachmentMenuItems(
|
||||
const isAttachmentWithPreview = isNodeActive(schema.nodes.attachment, {
|
||||
preview: true,
|
||||
});
|
||||
const isPdfAttachment = isNodeActive(schema.nodes.attachment, {
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -29,6 +32,13 @@ export default function attachmentMenuItems(
|
||||
tooltip: dictionary.deleteAttachment,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
{
|
||||
name: "toggleAttachmentPreview",
|
||||
tooltip: dictionary.previewAttachment,
|
||||
icon: <PDFIcon />,
|
||||
active: isAttachmentWithPreview,
|
||||
visible: isPdfAttachment(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
|
||||
@@ -126,6 +126,7 @@ export default function blockMenuItems(
|
||||
accept: "application/pdf",
|
||||
width: 300,
|
||||
height: 424,
|
||||
preview: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -164,6 +165,12 @@ export default function blockMenuItems(
|
||||
icon: <MathIcon />,
|
||||
keywords: "math katex latex",
|
||||
},
|
||||
{
|
||||
name: "container_toggle",
|
||||
title: dictionary.toggleBlock,
|
||||
icon: <CollapseIcon />,
|
||||
keywords: "toggle collapsible collapse fold",
|
||||
},
|
||||
{
|
||||
name: "hr",
|
||||
title: dictionary.hr,
|
||||
@@ -243,12 +250,6 @@ export default function blockMenuItems(
|
||||
icon: <Img src="/images/diagrams.png" alt="Diagrams.net Diagram" />,
|
||||
keywords: "diagram flowchart draw.io",
|
||||
},
|
||||
{
|
||||
name: "container_toggle",
|
||||
title: dictionary.toggleBlock,
|
||||
icon: <CollapseIcon />,
|
||||
keywords: "toggle collapsible collapse fold",
|
||||
},
|
||||
];
|
||||
|
||||
// Filter out diagrams.net in desktop app
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CopyIcon, EditIcon, ExpandedIcon } from "outline-icons";
|
||||
import { CopyIcon, EditIcon, ExpandedIcon, TextWrapIcon } from "outline-icons";
|
||||
import type { Node as ProseMirrorNode } from "prosemirror-model";
|
||||
import { NodeSelection } from "prosemirror-state";
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import {
|
||||
pluginKey as mermaidPluginKey,
|
||||
@@ -13,13 +14,17 @@ import {
|
||||
import { isMermaid } from "@shared/editor/lib/isCode";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
|
||||
export default function codeMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean | undefined,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const node = state.selection.$from.node();
|
||||
const node =
|
||||
state.selection instanceof NodeSelection
|
||||
? state.selection.node
|
||||
: state.selection.$from.node();
|
||||
|
||||
const frequentLanguages = getFrequentCodeLanguages();
|
||||
|
||||
@@ -44,6 +49,9 @@ export default function codeMenuItems(
|
||||
]
|
||||
: remainingLangMenuItems;
|
||||
|
||||
const isEditingMermaid = !!(mermaidPluginKey.getState(state) as MermaidState)
|
||||
?.editingId;
|
||||
|
||||
return [
|
||||
{
|
||||
name: "copyToClipboard",
|
||||
@@ -53,17 +61,22 @@ export default function codeMenuItems(
|
||||
: undefined,
|
||||
tooltip: dictionary.copy,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "edit_mermaid",
|
||||
icon: <EditIcon />,
|
||||
tooltip: dictionary.editDiagram,
|
||||
visible:
|
||||
!(mermaidPluginKey.getState(state) as MermaidState)?.editingId &&
|
||||
isMermaid(node) &&
|
||||
!readOnly,
|
||||
shortcut: `${metaDisplay} Enter`,
|
||||
visible: isMermaid(node) && !isEditingMermaid && !readOnly,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "toggleCodeBlockWrap",
|
||||
icon: <TextWrapIcon />,
|
||||
tooltip: dictionary.wrapText,
|
||||
active: () => node.attrs.wrap,
|
||||
visible: !readOnly && (!isMermaid(node) || isEditingMermaid),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
+31
-37
@@ -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 { MenuType, TableLayout } from "@shared/editor/types";
|
||||
import { TableLayout } from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function tableMenuItems(
|
||||
@@ -26,42 +26,36 @@ export default function tableMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
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 />,
|
||||
},
|
||||
],
|
||||
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 />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+107
-129
@@ -5,6 +5,7 @@ import {
|
||||
AlignCenterIcon,
|
||||
InsertLeftIcon,
|
||||
InsertRightIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderColumnIcon,
|
||||
TableMergeCellsIcon,
|
||||
@@ -23,11 +24,7 @@ import {
|
||||
isMultipleCellSelection,
|
||||
tableHasRowspan,
|
||||
} from "@shared/editor/queries/table";
|
||||
import {
|
||||
MenuType,
|
||||
type MenuItem,
|
||||
type NodeAttrMark,
|
||||
} from "@shared/editor/types";
|
||||
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
@@ -91,138 +88,119 @@ export default function tableColMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
type: MenuType.inline,
|
||||
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 />
|
||||
),
|
||||
children: [
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: dictionary.align,
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
children: [
|
||||
{
|
||||
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",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
},
|
||||
],
|
||||
...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: [
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleColumnBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
},
|
||||
],
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleColumnBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "toggleHeaderColumn",
|
||||
label: dictionary.toggleHeader,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
TrashIcon,
|
||||
InsertAboveIcon,
|
||||
InsertBelowIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderRowIcon,
|
||||
TableSplitCellsIcon,
|
||||
@@ -14,11 +15,7 @@ import {
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import {
|
||||
MenuType,
|
||||
type MenuItem,
|
||||
type NodeAttrMark,
|
||||
} from "@shared/editor/types";
|
||||
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
@@ -80,66 +77,66 @@ export default function tableRowMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
type: MenuType.inline,
|
||||
tooltip: dictionary.background,
|
||||
icon:
|
||||
rowColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : rowColors.size === 1 ? (
|
||||
<CircleIcon color={rowColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
children: [
|
||||
{
|
||||
label: dictionary.background,
|
||||
icon:
|
||||
rowColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : rowColors.size === 1 ? (
|
||||
<CircleIcon color={rowColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
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
|
||||
? [
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
},
|
||||
],
|
||||
...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 },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleRowBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
},
|
||||
],
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleRowBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "toggleHeaderRow",
|
||||
label: dictionary.toggleHeader,
|
||||
|
||||
@@ -19,7 +19,6 @@ 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"),
|
||||
@@ -33,6 +32,7 @@ export default function useDictionary() {
|
||||
codeBlock: t("Code block"),
|
||||
codeCopied: t("Copied to clipboard"),
|
||||
codeInline: t("Code"),
|
||||
collapseCode: t("Collapse"),
|
||||
comment: t("Comment"),
|
||||
copy: t("Copy"),
|
||||
createLink: t("Create link"),
|
||||
@@ -45,6 +45,7 @@ export default function useDictionary() {
|
||||
deleteRow: t("Delete"),
|
||||
deleteTable: t("Delete table"),
|
||||
deleteAttachment: t("Delete file"),
|
||||
previewAttachment: t("Show preview"),
|
||||
dimensions: `${t("Width")} × ${t("Height")}`,
|
||||
download: t("Download"),
|
||||
downloadAttachment: t("Download file"),
|
||||
@@ -54,6 +55,7 @@ export default function useDictionary() {
|
||||
replaceImage: t("Replace image"),
|
||||
em: t("Italic"),
|
||||
embedInvalidLink: t("Sorry, that link won’t work for this embed type"),
|
||||
expandCode: t("Expand"),
|
||||
file: t("File attachment"),
|
||||
pdf: t("Embed PDF"),
|
||||
enterLink: `${t("Enter a link")}…`,
|
||||
@@ -94,7 +96,6 @@ 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"),
|
||||
@@ -125,6 +126,7 @@ export default function useDictionary() {
|
||||
uploadImage: t("Upload an image"),
|
||||
formattingControls: t("Formatting controls"),
|
||||
distributeColumns: t("Distribute columns"),
|
||||
wrapText: t("Wrap text"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
openDocumentInDesktop,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
presentDocument,
|
||||
printDocument,
|
||||
searchInDocument,
|
||||
deleteDocument,
|
||||
@@ -106,6 +108,8 @@ export function useDocumentMenuAction({
|
||||
openDocumentComments,
|
||||
openDocumentHistory,
|
||||
openDocumentInsights,
|
||||
openDocumentInDesktop,
|
||||
presentDocument,
|
||||
downloadDocument,
|
||||
copyDocument,
|
||||
printDocument,
|
||||
|
||||
+10
-2
@@ -1,9 +1,10 @@
|
||||
import find from "lodash/find";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import { IntegrationType, TeamPreference } from "@shared/types";
|
||||
import type Integration from "~/models/Integration";
|
||||
import Logger from "~/utils/Logger";
|
||||
import useCurrentTeam from "./useCurrentTeam";
|
||||
import useStores from "./useStores";
|
||||
|
||||
/**
|
||||
@@ -14,6 +15,7 @@ import useStores from "./useStores";
|
||||
*/
|
||||
export default function useEmbeds(loadIfMissing = false) {
|
||||
const { integrations } = useStores();
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchEmbedIntegrations() {
|
||||
@@ -31,6 +33,9 @@ export default function useEmbeds(loadIfMissing = false) {
|
||||
}
|
||||
}, [integrations, loadIfMissing]);
|
||||
|
||||
const disabledEmbeds =
|
||||
(team?.getPreference(TeamPreference.DisabledEmbeds) as string[]) || [];
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
embeds.map((e) => {
|
||||
@@ -42,8 +47,11 @@ export default function useEmbeds(loadIfMissing = false) {
|
||||
e.settings = integration.settings;
|
||||
}
|
||||
|
||||
e.disabled = disabledEmbeds.includes(e.id);
|
||||
|
||||
return e;
|
||||
}),
|
||||
[integrations.orderedData]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[integrations.orderedData, team?.preferences]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
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";
|
||||
@@ -16,27 +16,35 @@ import {
|
||||
} from "~/actions";
|
||||
import { GroupSection } from "~/actions/sections";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
interface Options {
|
||||
/** Whether to hide the "Members" navigation action. */
|
||||
hideMembers?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for group management operations.
|
||||
*
|
||||
*
|
||||
* @param targetGroup - the group to build actions for, or null to skip.
|
||||
* @param options - optional configuration for the menu.
|
||||
* @returns action with children for use in menus, or undefined if group is null.
|
||||
*/
|
||||
export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
export function useGroupMenuActions(
|
||||
targetGroup: Group | null,
|
||||
options?: Options
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const history = useHistory();
|
||||
const can = usePolicy(targetGroup ?? ({} as Group));
|
||||
|
||||
const openMembersDialog = React.useCallback(() => {
|
||||
const navigateToMembers = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={targetGroup} />,
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
history.push(settingsPath("groups", targetGroup.id, "members"));
|
||||
}, [targetGroup, history]);
|
||||
|
||||
const openEditDialog = React.useCallback(() => {
|
||||
if (!targetGroup) {
|
||||
@@ -45,7 +53,10 @@ export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
dialogs.openModal({
|
||||
title: t("Edit group"),
|
||||
content: (
|
||||
<EditGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
|
||||
<EditGroupDialog
|
||||
group={targetGroup}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
@@ -57,7 +68,10 @@ export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
dialogs.openModal({
|
||||
title: t("Delete group"),
|
||||
content: (
|
||||
<DeleteGroupDialog group={targetGroup} onSubmit={dialogs.closeAllModals} />
|
||||
<DeleteGroupDialog
|
||||
group={targetGroup}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [t, targetGroup, dialogs]);
|
||||
@@ -67,26 +81,30 @@ export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
!targetGroup
|
||||
? []
|
||||
: [
|
||||
createAction({
|
||||
name: `${t("Members")}…`,
|
||||
icon: <GroupIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.read),
|
||||
perform: openMembersDialog,
|
||||
}),
|
||||
ActionSeparator,
|
||||
...(options?.hideMembers
|
||||
? []
|
||||
: [
|
||||
createAction({
|
||||
name: t("Members"),
|
||||
icon: <GroupIcon />,
|
||||
section: GroupSection,
|
||||
visible: can.read,
|
||||
perform: navigateToMembers,
|
||||
}),
|
||||
ActionSeparator,
|
||||
]),
|
||||
createAction({
|
||||
name: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.update),
|
||||
visible: can.update,
|
||||
perform: openEditDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
section: GroupSection,
|
||||
visible: !!(targetGroup && can.delete),
|
||||
visible: can.delete,
|
||||
dangerous: true,
|
||||
perform: openDeleteDialog,
|
||||
}),
|
||||
@@ -98,6 +116,13 @@ export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
disabled: true,
|
||||
url: "",
|
||||
}),
|
||||
createExternalLinkAction({
|
||||
name: `External ID: ${targetGroup.externalGroup?.externalId ?? ""}`,
|
||||
section: GroupSection,
|
||||
visible: !!targetGroup.externalGroup?.externalId,
|
||||
disabled: true,
|
||||
url: "",
|
||||
}),
|
||||
],
|
||||
[
|
||||
t,
|
||||
@@ -105,7 +130,8 @@ export function useGroupMenuActions(targetGroup: Group | null) {
|
||||
can.read,
|
||||
can.update,
|
||||
can.delete,
|
||||
openMembersDialog,
|
||||
options?.hideMembers,
|
||||
navigateToMembers,
|
||||
openEditDialog,
|
||||
openDeleteDialog,
|
||||
]
|
||||
|
||||
@@ -63,9 +63,14 @@ window.addEventListener("keydown", (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track whether defaultPrevented was already set by an external handler (e.g.
|
||||
// Radix UI's DismissableLayer) so we only break on preventDefault calls made
|
||||
// by our own callbacks.
|
||||
const wasDefaultPrevented = event.defaultPrevented;
|
||||
|
||||
// reverse so that the last registered callbacks get executed first
|
||||
for (const registered of callbacks.reverse()) {
|
||||
if (event.defaultPrevented === true) {
|
||||
for (const registered of [...callbacks].reverse()) {
|
||||
if (!wasDefaultPrevented && event.defaultPrevented) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export const useLocaleTime = ({
|
||||
const dateFormatLong: Record<string, string> = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
|
||||
de_DE: "d. MMMM yyyy 'um' H:mm",
|
||||
};
|
||||
const formatLocaleLong =
|
||||
(userLocale ? dateFormatLong[userLocale] : undefined) ??
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
PlusIcon,
|
||||
InternetIcon,
|
||||
SmileyIcon,
|
||||
BuildingBlocksIcon,
|
||||
BrowserIcon,
|
||||
} from "outline-icons";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -32,7 +32,7 @@ import useStores from "./useStores";
|
||||
|
||||
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
|
||||
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
|
||||
const APIAndApps = lazy(() => import("~/scenes/Settings/APIAndApps"));
|
||||
const APIAndAccess = lazy(() => import("~/scenes/Settings/APIAndAccess"));
|
||||
const Authentication = lazy(() => import("~/scenes/Settings/Authentication"));
|
||||
const Details = lazy(() => import("~/scenes/Settings/Details"));
|
||||
const Export = lazy(() => import("~/scenes/Settings/Export"));
|
||||
@@ -48,6 +48,7 @@ const Security = lazy(() => import("~/scenes/Settings/Security"));
|
||||
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
|
||||
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
|
||||
const CustomEmojis = lazy(() => import("~/scenes/Settings/CustomEmojis"));
|
||||
const Embeds = lazy(() => import("~/scenes/Settings/Embeds"));
|
||||
|
||||
export type ConfigItem = {
|
||||
name: string;
|
||||
@@ -107,13 +108,13 @@ const useSettingsConfig = () => {
|
||||
icon: EmailIcon,
|
||||
},
|
||||
{
|
||||
name: t("API & Apps"),
|
||||
path: settingsPath("api-and-apps"),
|
||||
component: APIAndApps.Component,
|
||||
preload: APIAndApps.preload,
|
||||
name: t("API & Access"),
|
||||
path: settingsPath("api-and-access"),
|
||||
component: APIAndAccess.Component,
|
||||
preload: APIAndAccess.preload,
|
||||
enabled: true,
|
||||
group: t("Account"),
|
||||
icon: BuildingBlocksIcon,
|
||||
icon: PadlockIcon,
|
||||
},
|
||||
// Workspace
|
||||
{
|
||||
@@ -175,7 +176,7 @@ const useSettingsConfig = () => {
|
||||
path: settingsPath("templates"),
|
||||
component: Templates.Component,
|
||||
preload: Templates.preload,
|
||||
enabled: can.createTemplate,
|
||||
enabled: can.readTemplate,
|
||||
group: t("Workspace"),
|
||||
icon: ShapesIcon,
|
||||
},
|
||||
@@ -234,6 +235,18 @@ const useSettingsConfig = () => {
|
||||
icon: ExportIcon,
|
||||
},
|
||||
// Integrations
|
||||
{
|
||||
name: t("Embeds"),
|
||||
path: integrationSettingsPath("embeds"),
|
||||
component: Embeds.Component,
|
||||
preload: Embeds.preload,
|
||||
description: t(
|
||||
"Configure which embed providers are available in the editor."
|
||||
),
|
||||
enabled: can.update,
|
||||
group: t("Integrations"),
|
||||
icon: BrowserIcon,
|
||||
},
|
||||
{
|
||||
name: `${t("Install")}…`,
|
||||
path: settingsPath("integrations"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type Template from "~/models/Template";
|
||||
import { ActionSeparator, createAction } from "~/actions";
|
||||
import {
|
||||
copyTemplate,
|
||||
createDocumentFromTemplate,
|
||||
deleteTemplate,
|
||||
moveTemplate,
|
||||
} from "~/actions/definitions/templates";
|
||||
@@ -49,6 +50,7 @@ export function useTemplateSettingsActions(
|
||||
}),
|
||||
moveTemplate,
|
||||
ActionSeparator,
|
||||
createDocumentFromTemplate,
|
||||
copyTemplate,
|
||||
ActionSeparator,
|
||||
deleteTemplate,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Returns the width of the window's vertical scrollbar in pixels, or null
|
||||
* if not yet measured. Continuously re-measures as the scrollbar appears or
|
||||
* disappears.
|
||||
*
|
||||
* @returns the scrollbar width, or null before measurement.
|
||||
*/
|
||||
export default function useWindowScrollbarWidth(): number | null {
|
||||
const [width, setWidth] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const htmlElement = document.documentElement;
|
||||
|
||||
const measure = () => {
|
||||
const scrollbarWidth = htmlElement.scrollWidth - htmlElement.clientWidth;
|
||||
setWidth(scrollbarWidth);
|
||||
};
|
||||
|
||||
// Defer initial measurement to after browser has painted
|
||||
const timeout = setTimeout(measure);
|
||||
|
||||
// Re-measure when html element resizes (scrollbar appears/disappears)
|
||||
const resizeObserver = new ResizeObserver(measure);
|
||||
resizeObserver.observe(htmlElement);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return width;
|
||||
}
|
||||
+4
-5
@@ -1,6 +1,6 @@
|
||||
// oxlint-disable-next-line import/no-unresolved
|
||||
import "vite/modulepreload-polyfill";
|
||||
import { LazyMotion } from "framer-motion";
|
||||
import { LazyMotion, domMax } from "framer-motion";
|
||||
import { KBarProvider } from "kbar";
|
||||
import { Provider } from "mobx-react";
|
||||
import { configure as configureMobx } from "mobx";
|
||||
@@ -11,6 +11,7 @@ import { Router } from "react-router-dom";
|
||||
import stores from "~/stores";
|
||||
import Analytics from "~/components/Analytics";
|
||||
import Dialogs from "~/components/Dialogs";
|
||||
import Presentation from "~/components/Presentation";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import PageTheme from "~/components/PageTheme";
|
||||
import ScrollToTop from "~/components/ScrollToTop";
|
||||
@@ -45,9 +46,6 @@ configureMobx({
|
||||
isolateGlobalState: true,
|
||||
});
|
||||
|
||||
// Make sure to return the specific export containing the feature bundle.
|
||||
const loadFeatures = () => import("./utils/motion").then((res) => res.default);
|
||||
|
||||
const commandBarOptions = {
|
||||
animations: {
|
||||
enterMs: 250,
|
||||
@@ -67,7 +65,7 @@ if (element) {
|
||||
<ErrorBoundary showTitle>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<LazyMotion features={domMax}>
|
||||
<PageScroll>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
@@ -75,6 +73,7 @@ if (element) {
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<Presentation />
|
||||
<Desktop />
|
||||
</PageScroll>
|
||||
</LazyMotion>
|
||||
|
||||
@@ -8,11 +8,13 @@ import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
/** Whether to hide the "Members" navigation action. */
|
||||
hideMembers?: boolean;
|
||||
};
|
||||
|
||||
function GroupMenu({ group }: Props) {
|
||||
function GroupMenu({ group, hideMembers }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const rootAction = useGroupMenuActions(group);
|
||||
const rootAction = useGroupMenuActions(group, { hideMembers });
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
|
||||
@@ -33,7 +33,7 @@ function NewTemplateMenu() {
|
||||
name: collection.name,
|
||||
section: DocumentSection,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
visible: !!canCollection.createDocument,
|
||||
visible: !!canCollection.createTemplate,
|
||||
to: newTemplatePath(collection.id),
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, observable } from "mobx";
|
||||
import type { AuthenticationProviderSettings } from "@shared/types";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { AfterDelete } from "./decorators/Lifecycle";
|
||||
@@ -13,6 +14,10 @@ class AuthenticationProvider extends Model {
|
||||
|
||||
providerId: string;
|
||||
|
||||
groupSyncSupported: boolean;
|
||||
|
||||
groupSyncUsesClaim: boolean;
|
||||
|
||||
@observable
|
||||
isConnected: boolean;
|
||||
|
||||
@@ -20,6 +25,10 @@ class AuthenticationProvider extends Model {
|
||||
@observable
|
||||
isEnabled: boolean;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
settings: AuthenticationProviderSettings | undefined;
|
||||
|
||||
@computed
|
||||
get isActive() {
|
||||
return this.isEnabled && this.isConnected;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
NavigationNodeType,
|
||||
type ProsemirrorData,
|
||||
} from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import type CollectionsStore from "~/stores/CollectionsStore";
|
||||
import type Document from "~/models/Document";
|
||||
@@ -68,6 +67,11 @@ export default class Collection extends ParanoidModel {
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
/** The minimum permission level required to manage templates in this collection. */
|
||||
@Field
|
||||
@observable
|
||||
templateManagement: CollectionPermission;
|
||||
|
||||
/**
|
||||
* Whether commenting is enabled for the collection.
|
||||
*/
|
||||
@@ -125,13 +129,7 @@ export default class Collection extends ParanoidModel {
|
||||
* @returns boolean
|
||||
*/
|
||||
get isPrivate(): boolean {
|
||||
return !this.permission;
|
||||
}
|
||||
|
||||
/** Returns whether the collection description is not empty. */
|
||||
@computed
|
||||
get hasDescription(): boolean {
|
||||
return this.data ? !ProsemirrorHelper.isEmptyData(this.data) : false;
|
||||
return this.permission === null;
|
||||
}
|
||||
|
||||
@computed
|
||||
|
||||
@@ -5,6 +5,22 @@ import Field from "./decorators/Field";
|
||||
import { GroupPermission } from "@shared/types";
|
||||
import type { Searchable } from "./interfaces/Searchable";
|
||||
|
||||
/**
|
||||
* Information about a group that is managed by an external provider.
|
||||
*/
|
||||
interface ExternalGroupInfo {
|
||||
/** The unique identifier of the external group record in Outline. */
|
||||
id: string;
|
||||
/** The unique identifier of the group in the external provider. */
|
||||
externalId: string;
|
||||
/** The name of the external provider (e.g. google, slack, azure). */
|
||||
provider: string;
|
||||
/** The display name of the group in the external provider. */
|
||||
displayName: string;
|
||||
/** The date and time the group was last synced from the external provider. */
|
||||
lastSyncedAt: string | null;
|
||||
}
|
||||
|
||||
class Group extends Model implements Searchable {
|
||||
static modelName = "Group";
|
||||
|
||||
@@ -26,6 +42,17 @@ class Group extends Model implements Searchable {
|
||||
@observable
|
||||
disableMentions: boolean;
|
||||
|
||||
@observable
|
||||
externalGroup: ExternalGroupInfo | undefined;
|
||||
|
||||
/**
|
||||
* Whether this group's membership is managed by an external authentication provider.
|
||||
*/
|
||||
@computed
|
||||
get isExternallyManaged(): boolean {
|
||||
return !!this.externalGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the users that are members of this group.
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,6 @@ import ParanoidModel from "./base/ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
import type RevisionsStore from "~/stores/RevisionsStore";
|
||||
import { ChangesetHelper } from "@shared/editor/lib/ChangesetHelper";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
class Revision extends ParanoidModel {
|
||||
@@ -97,11 +96,6 @@ class Revision extends ParanoidModel {
|
||||
: null;
|
||||
}
|
||||
|
||||
@computed
|
||||
get changeset() {
|
||||
return ChangesetHelper.getChangeset(this.data, this.before?.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a download of the revision in the specified format.
|
||||
*
|
||||
|
||||
+3
-3
@@ -114,10 +114,10 @@ class Team extends Model {
|
||||
/**
|
||||
* Set the value for a specific preference key.
|
||||
*
|
||||
* @param key The TeamPreference key to retrieve
|
||||
* @param value The value to set
|
||||
* @param key The TeamPreference key to set.
|
||||
* @param value The value to set.
|
||||
*/
|
||||
setPreference(key: TeamPreference, value: boolean) {
|
||||
setPreference<T extends TeamPreference>(key: T, value: TeamPreferences[T]) {
|
||||
this.preferences = {
|
||||
...this.preferences,
|
||||
[key]: value,
|
||||
|
||||
@@ -9,6 +9,13 @@ interface HasData {
|
||||
data: ProsemirrorData;
|
||||
}
|
||||
|
||||
const extensionManager = new ExtensionManager(withComments(richExtensions));
|
||||
const schema = new Schema({
|
||||
nodes: extensionManager.nodes,
|
||||
marks: extensionManager.marks,
|
||||
});
|
||||
const serializer = extensionManager.serializer();
|
||||
|
||||
export class ProsemirrorHelper {
|
||||
/**
|
||||
* Returns the markdown representation of the document derived from the ProseMirror data.
|
||||
@@ -16,13 +23,6 @@ export class ProsemirrorHelper {
|
||||
* @returns The markdown representation of the document as a string.
|
||||
*/
|
||||
static toMarkdown = (document: HasData) => {
|
||||
const extensionManager = new ExtensionManager(withComments(richExtensions));
|
||||
const serializer = extensionManager.serializer();
|
||||
const schema = new Schema({
|
||||
nodes: extensionManager.nodes,
|
||||
marks: extensionManager.marks,
|
||||
});
|
||||
|
||||
const doc = Node.fromJSON(
|
||||
schema,
|
||||
SharedProsemirrorHelper.attachmentsToAbsoluteUrls(document.data)
|
||||
@@ -40,11 +40,6 @@ export class ProsemirrorHelper {
|
||||
* @returns The plain text representation of the document as a string.
|
||||
*/
|
||||
static toPlainText = (document: HasData) => {
|
||||
const extensionManager = new ExtensionManager(withComments(richExtensions));
|
||||
const schema = new Schema({
|
||||
nodes: extensionManager.nodes,
|
||||
marks: extensionManager.marks,
|
||||
});
|
||||
const text = SharedProsemirrorHelper.toPlainText(
|
||||
Node.fromJSON(schema, document.data)
|
||||
);
|
||||
|
||||
+12
-4
@@ -1,12 +1,15 @@
|
||||
import { Switch } from "react-router-dom";
|
||||
import Error404 from "~/scenes/Errors/Error404";
|
||||
import { createLazyComponent as lazy } from "~/components/LazyLoad";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
const Application = lazy(() => import("~/scenes/Settings/Application"));
|
||||
const GroupMembers = lazy(() => import("~/scenes/Settings/GroupMembers"), {
|
||||
exportName: "GroupMembersScene",
|
||||
});
|
||||
const Template = lazy(() => import("~/scenes/Settings/Template"));
|
||||
const TemplateNew = lazy(() => import("~/scenes/Settings/TemplateNew"));
|
||||
|
||||
@@ -24,20 +27,25 @@ function SettingsRoutes() {
|
||||
/>
|
||||
))}
|
||||
{/* TODO: Refactor these exceptions into config? */}
|
||||
<Route
|
||||
exact
|
||||
path={settingsPath("groups", ":id", "members")}
|
||||
component={GroupMembers.Component}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={settingsPath("applications", ":id")}
|
||||
component={Application}
|
||||
component={Application.Component}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={settingsPath("templates", "new")}
|
||||
component={TemplateNew}
|
||||
component={TemplateNew.Component}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={settingsPath("templates", ":id")}
|
||||
component={Template}
|
||||
component={Template.Component}
|
||||
/>
|
||||
<Route component={Error404} />
|
||||
</Switch>
|
||||
|
||||
@@ -66,7 +66,12 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
|
||||
shortcut="e"
|
||||
placement="bottom"
|
||||
>
|
||||
<Button icon={<EditIcon />} onClick={goToEdit} neutral>
|
||||
<Button
|
||||
icon={<EditIcon />}
|
||||
onClick={goToEdit}
|
||||
haptic="light"
|
||||
neutral
|
||||
>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -75,7 +80,9 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
|
||||
{isEditing && user?.separateEditMode && (
|
||||
<Action>
|
||||
<RegisterKeyDown trigger="Escape" handler={goBack} />
|
||||
<Button onClick={goBack}>{t("Done editing")}</Button>
|
||||
<Button onClick={goBack} haptic="medium">
|
||||
{t("Done editing")}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
{can.createDocument && (
|
||||
|
||||
@@ -3,7 +3,9 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import first from "lodash/first";
|
||||
import { Suspense, useCallback } from "react";
|
||||
import styled from "styled-components";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Heading from "~/components/Heading";
|
||||
import ContentEditable from "~/components/ContentEditable";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type Collection from "~/models/Collection";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
@@ -16,16 +18,32 @@ const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
|
||||
type Props = {
|
||||
/** The collection for which to render a header */
|
||||
collection: Collection;
|
||||
/** Whether the header is in editing mode */
|
||||
isEditing?: boolean;
|
||||
};
|
||||
|
||||
export const Header = observer(function Header_({ collection }: Props) {
|
||||
export const Header = observer(function Header_({
|
||||
collection,
|
||||
isEditing,
|
||||
}: Props) {
|
||||
const can = usePolicy(collection);
|
||||
const canEdit = can.update && isEditing;
|
||||
const handleIconChange = useCallback(
|
||||
(icon: string | null, color: string | null) =>
|
||||
collection?.save({ icon, color }),
|
||||
[collection]
|
||||
);
|
||||
|
||||
const handleTitleChange = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length > 0 && trimmed !== collection.name) {
|
||||
void collection.save({ name: trimmed });
|
||||
}
|
||||
},
|
||||
[collection]
|
||||
);
|
||||
|
||||
const fallbackIcon = collection ? (
|
||||
<CollectionIcon collection={collection} size={40} expanded />
|
||||
) : null;
|
||||
@@ -33,7 +51,7 @@ export const Header = observer(function Header_({ collection }: Props) {
|
||||
return (
|
||||
<StyledHeading>
|
||||
<IconTitleWrapper>
|
||||
{can.update ? (
|
||||
{canEdit ? (
|
||||
<Suspense fallback={fallbackIcon}>
|
||||
<IconPicker
|
||||
icon={collection.icon ?? "collection"}
|
||||
@@ -51,7 +69,16 @@ export const Header = observer(function Header_({ collection }: Props) {
|
||||
fallbackIcon
|
||||
)}
|
||||
</IconTitleWrapper>
|
||||
{collection.name}
|
||||
{canEdit ? (
|
||||
<ContentEditable
|
||||
value={collection.name}
|
||||
onChange={handleTitleChange}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
dir="auto"
|
||||
/>
|
||||
) : (
|
||||
collection.name
|
||||
)}
|
||||
</StyledHeading>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ function Overview({ collection, readOnly }: Props) {
|
||||
() => ({
|
||||
padding: "0 32px",
|
||||
margin: "0 -32px",
|
||||
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
|
||||
paddingBottom: `calc(30vh - ${childOffsetHeight}px)`,
|
||||
}),
|
||||
[childOffsetHeight]
|
||||
);
|
||||
@@ -87,7 +87,7 @@ function Overview({ collection, readOnly }: Props) {
|
||||
return (
|
||||
<>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
{(collection.hasDescription || can.update) && (
|
||||
{(can.update || readOnly) && (
|
||||
<Suspense fallback={<Placeholder>Loading…</Placeholder>}>
|
||||
<MeasuredContainer name="document">
|
||||
<Editor
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { preventDefault } from "~/utils/events";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
const SharePopover = lazyWithRetry(
|
||||
@@ -64,6 +65,7 @@ function ShareButton({ collection }: Props) {
|
||||
minHeight={175}
|
||||
side="bottom"
|
||||
align="end"
|
||||
onEscapeKeyDown={preventDefault}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<SharePopover
|
||||
|
||||
@@ -49,6 +49,7 @@ import Overview from "./components/Overview";
|
||||
import { Header } from "./components/Header";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
|
||||
const CollectionScene = observer(function CollectionScene_() {
|
||||
const params = useParams<{ collectionSlug?: string }>();
|
||||
@@ -67,14 +68,17 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
const id = params.collectionSlug || "";
|
||||
const urlId = id.split("-").pop() ?? "";
|
||||
|
||||
const collection: Collection | null | undefined = collections.get(id);
|
||||
const collection = collections.get(id);
|
||||
const can = usePolicy(collection);
|
||||
const hasDescription = collection?.data
|
||||
? !ProsemirrorHelper.isEmptyData(collection.data)
|
||||
: false;
|
||||
|
||||
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
|
||||
|
||||
const [collectionTab, setCollectionTab] = usePersistedState<CollectionTab>(
|
||||
`collection-tab:${collection?.id}`,
|
||||
collection?.hasDescription ? CollectionTab.Overview : CollectionTab.Recent,
|
||||
hasDescription ? CollectionTab.Overview : CollectionTab.Recent,
|
||||
{
|
||||
listen: false,
|
||||
}
|
||||
@@ -130,7 +134,7 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const showOverview = can.update || collection?.hasDescription;
|
||||
const showOverview = can.update || hasDescription;
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -172,7 +176,10 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
>
|
||||
<CenteredContent withStickyHeader>
|
||||
<Notices collection={collection} />
|
||||
<Header collection={collection} />
|
||||
<Header
|
||||
collection={collection}
|
||||
isEditing={isEditRoute && !!user?.separateEditMode}
|
||||
/>
|
||||
|
||||
<PinnedDocuments
|
||||
pins={pins}
|
||||
@@ -205,7 +212,9 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
{showOverview ? (
|
||||
<Overview
|
||||
collection={collection}
|
||||
readOnly={!isEditRoute && !!user?.separateEditMode}
|
||||
readOnly={
|
||||
!can.update || (!isEditRoute && !!user?.separateEditMode)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Redirect
|
||||
|
||||
@@ -15,6 +15,7 @@ import usePersistedState from "~/hooks/usePersistedState";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Switch from "~/components/Switch";
|
||||
import { action } from "mobx";
|
||||
import { ChangesetHelper } from "@shared/editor/lib/ChangesetHelper";
|
||||
|
||||
/**
|
||||
* Changesets scene for developer playground.
|
||||
@@ -89,6 +90,10 @@ function Changesets() {
|
||||
const mockDiffRevision = stores.revisions.get("mock-diff-revision-" + id);
|
||||
const mockBeforeRevision = stores.revisions.get("mock-before-revision-" + id);
|
||||
const mockAfterRevision = stores.revisions.get("mock-after-revision-" + id);
|
||||
const changeset = ChangesetHelper.getChangeset(
|
||||
mockDiffRevision?.data,
|
||||
mockDiffRevision?.before?.data
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title="Changeset Playground" centered>
|
||||
@@ -155,9 +160,7 @@ function Changesets() {
|
||||
{showChangeset && (
|
||||
<>
|
||||
<Heading>Changeset</Heading>
|
||||
<Pre>
|
||||
{JSON.stringify(mockDiffRevision.changeset?.changes, null, 2)}
|
||||
</Pre>
|
||||
<Pre>{JSON.stringify(changeset?.changes, null, 2)}</Pre>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user