mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1bad8dbf4 | |||
| 58a70ab62e | |||
| aff26aa809 | |||
| e1cc6395f5 | |||
| 38186d64e5 | |||
| fa70735585 | |||
| 8d694e666c | |||
| 324ce96aaf | |||
| cc7f9d1a72 | |||
| 0116441a58 | |||
| be93b4ffe9 | |||
| 11cb90b4fa | |||
| d1b7d0ee45 | |||
| 029161002b | |||
| 1e10985626 | |||
| e5fdaae09a | |||
| cfdb213cc1 | |||
| 64106979ba | |||
| 6dffa023b1 | |||
| 869b6e7394 | |||
| 73086139d2 | |||
| 92b257381b | |||
| 28d5d0da5d | |||
| 79df75e09d | |||
| 4517cd6ab1 | |||
| 3c86b48533 | |||
| bcba35550a | |||
| 4af3ac98d1 | |||
| 7421a9fbdc | |||
| 56b9c60388 | |||
| 8fec6758b8 | |||
| 1aaabf113b | |||
| 5eb95f7bd9 | |||
| a0d78378d7 | |||
| 78bf8fd641 | |||
| 5374d32801 | |||
| 68de78ead8 | |||
| c3ba07cee4 | |||
| 3998a80ae9 | |||
| e910ecf559 | |||
| e42b533b07 | |||
| 81d7492e5e | |||
| 3c5ce8cb3d | |||
| cf3e29bbab | |||
| 92a5954ec7 | |||
| 4afa225967 | |||
| 48feaf9bc0 | |||
| 3f2ac2d23b | |||
| 38c12bd2a9 | |||
| fafaddf07f | |||
| 25f264a763 | |||
| 085785a94c | |||
| 9c71566d66 | |||
| 4a64a767e1 | |||
| 9bc1788bc0 | |||
| e93ef8b392 | |||
| db30d080ae | |||
| 63d70c2cd5 | |||
| 98fef1bb1f | |||
| 9cab404194 | |||
| d6459150fe | |||
| 4789ddd947 | |||
| 1c179a3c6b | |||
| b8c07eb298 | |||
| adfca1e5ca | |||
| 6ca3c25d35 | |||
| 05a2c6ae1e | |||
| 234915f4a0 | |||
| 538a1274ab | |||
| 63422373ac | |||
| 708bd8a544 | |||
| 120191d4d7 | |||
| 6a2ab299a8 | |||
| 74dc7094e1 | |||
| 5dd993adf5 | |||
| 41832bbaf1 | |||
| f448be5830 | |||
| f0fcb26b50 | |||
| ad237a619c | |||
| 5f49938267 | |||
| 68a469daa7 | |||
| 3d5a167f7f | |||
| b58671cbd1 | |||
| b3a3b0763f | |||
| a4becd66bd | |||
| 3437bd3a6c | |||
| 86cfd62afa | |||
| 85b62d3146 | |||
| 1fa0a5ea98 | |||
| 2b4c8d981c | |||
| ce55719626 | |||
| b9f0f67fb2 | |||
| 02aa4c2928 | |||
| 77e8dbefd6 | |||
| 1e5d281870 | |||
| 9b68e6835e | |||
| f17926f912 | |||
| 2397196be8 | |||
| 133db9c22c | |||
| 0dd14cdf1a | |||
| cc8ec28a39 | |||
| c8cbb9ef9c | |||
| 4af07ab6c4 | |||
| 742c138b3d | |||
| ec1eacaeea | |||
| 8b15cc45b0 | |||
| e89c32424f | |||
| a458690bfc | |||
| df03a6da8c | |||
| 6dfe7d707a | |||
| c063709f1c | |||
| dd8f6a987c | |||
| fa117870a2 | |||
| 40b1e3c8c6 | |||
| e3b0f7db86 | |||
| 6fddb29ff6 | |||
| 569a7876ae | |||
| bea56159ec | |||
| 908f053920 | |||
| 033c298bff | |||
| 22f02ad713 | |||
| 92b1c578f6 | |||
| a738ea97b5 | |||
| 7fbe442863 | |||
| 2db7690e27 | |||
| 06b89635be | |||
| 1ff23756ac | |||
| a00b677076 | |||
| 6c1e4a5b40 | |||
| 59078704c8 | |||
| f1a20b27fd | |||
| 313b046e4e |
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots or videos to help explain your problem.
|
||||
|
||||
**Outline (please complete the following information):**
|
||||
- Install: [getoutline.com or self hosted]
|
||||
- Version: [commit sha if self hosted]
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Mobile (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Bug report
|
||||
description: File a bug to help us improve
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you encountered.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: This is not related to configuring Outline
|
||||
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
|
||||
options:
|
||||
- label: The issue is not related to self-hosting config
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: A concise description of what you're experiencing.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A concise description of what you expected to happen.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. In this environment...
|
||||
1. With this config...
|
||||
1. Run '...'
|
||||
1. See error...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Environment
|
||||
description: |
|
||||
examples:
|
||||
- **Outline**: Outline 0.80.0
|
||||
- **Browser**: Safari
|
||||
value: |
|
||||
- Outline:
|
||||
- Browser:
|
||||
render: markdown
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: false
|
||||
@@ -53,9 +53,13 @@ export const resolveCommentFactory = ({
|
||||
perform: async ({ t }) => {
|
||||
await comment.resolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: null,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onResolve();
|
||||
@@ -81,9 +85,13 @@ export const unresolveCommentFactory = ({
|
||||
perform: async () => {
|
||||
await comment.unresolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: null,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onUnresolve();
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import {
|
||||
ExportContentType,
|
||||
TeamPreference,
|
||||
@@ -46,7 +47,6 @@ import DocumentPublish from "~/scenes/DocumentPublish";
|
||||
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DocumentCopy from "~/components/DocumentCopy";
|
||||
import Icon from "~/components/Icon";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
@@ -732,7 +732,6 @@ export const importDocument = createAction({
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
|
||||
|
||||
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
|
||||
const activeDocument = stores.documents.active;
|
||||
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
|
||||
@@ -34,6 +36,8 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
UserSection.priority = 0.5;
|
||||
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
|
||||
@@ -37,7 +37,11 @@ function Breadcrumb(
|
||||
return (
|
||||
<Flex justify="flex-start" align="center" ref={ref}>
|
||||
{topLevelItems.map((item, index) => (
|
||||
<React.Fragment key={String(item.to) || index}>
|
||||
<React.Fragment
|
||||
key={
|
||||
(typeof item.to === "string" ? item.to : item.to.pathname) || index
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
{item.to ? (
|
||||
<Item
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
@@ -11,7 +12,6 @@ import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import Input from "~/components/Input";
|
||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||
import Switch from "~/components/Switch";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import { RecentSection } from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
ActiveCollectionSection,
|
||||
|
||||
@@ -8,8 +8,8 @@ import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the dialog is submitted */
|
||||
onSubmit: () => Promise<void> | void;
|
||||
/** Callback when the dialog is submitted. Return false to prevent closing. */
|
||||
onSubmit: () => Promise<void | boolean> | void;
|
||||
/** Text to display on the submit button */
|
||||
submitText?: string;
|
||||
/** Text to display while the form is saving */
|
||||
@@ -38,7 +38,10 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSubmit();
|
||||
const res = await onSubmit();
|
||||
if (res === false) {
|
||||
return;
|
||||
}
|
||||
dialogs.closeAllModals();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
|
||||
@@ -23,7 +23,7 @@ type Props = {
|
||||
as?: string | React.ComponentType<any>;
|
||||
hide?: () => void;
|
||||
level?: number;
|
||||
icon?: React.ReactElement;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
|
||||
};
|
||||
@@ -109,6 +109,8 @@ const Title = styled.div`
|
||||
${ellipsis()}
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
type MenuAnchorProps = {
|
||||
|
||||
@@ -3,20 +3,16 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Breadcrumb from "~/components/Breadcrumb";
|
||||
import Icon from "~/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import {
|
||||
archivePath,
|
||||
collectionPath,
|
||||
settingsPath,
|
||||
trashPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -64,6 +60,7 @@ function DocumentBreadcrumb(
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
@@ -80,7 +77,10 @@ function DocumentBreadcrumb(
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.path),
|
||||
to: {
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
};
|
||||
} else if (document.isCollectionDeleted) {
|
||||
collectionNode = {
|
||||
@@ -114,11 +114,14 @@ function DocumentBreadcrumb(
|
||||
) : (
|
||||
node.title
|
||||
),
|
||||
to: node.url,
|
||||
to: {
|
||||
pathname: node.url,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}, [path, category, collectionNode]);
|
||||
}, [path, category, sidebarContext, collectionNode]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
|
||||
@@ -7,18 +7,17 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { s, hover, ellipsis } from "@shared/styles";
|
||||
import { IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import Document from "~/models/Document";
|
||||
import Pin from "~/models/Pin";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import Text from "./Text";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
@@ -14,18 +14,18 @@ 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";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { Outline } from "~/components/Input";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { ancestors, descendants } from "~/utils/tree";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -9,21 +9,22 @@ import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Badge from "~/components/Badge";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Flex from "~/components/Flex";
|
||||
import Highlight from "~/components/Highlight";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import StarButton, { AnimatedStar } from "~/components/Star";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { hover } from "~/styles";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -50,6 +51,7 @@ function DocumentListItem(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
let itemRef: React.Ref<HTMLAnchorElement> =
|
||||
@@ -78,6 +80,12 @@ function DocumentListItem(
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
|
||||
const sidebarContext = determineSidebarContext({
|
||||
document,
|
||||
user,
|
||||
currentContext: locationSidebarContext,
|
||||
});
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
@@ -89,6 +97,7 @@ function DocumentListItem(
|
||||
pathname: documentPath(document),
|
||||
state: {
|
||||
title: document.titleWithDefault,
|
||||
sidebarContext,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
|
||||
@@ -185,9 +185,9 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
{showCollection && collection && (
|
||||
<span>
|
||||
{t("in")}
|
||||
<strong>
|
||||
<Strong>
|
||||
<DocumentBreadcrumb document={document} onlyText />
|
||||
</strong>
|
||||
</Strong>
|
||||
</span>
|
||||
)}
|
||||
{showParentDocuments && nestedDocumentsCount > 0 && (
|
||||
@@ -210,6 +210,10 @@ const DocumentMeta: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Strong = styled.strong`
|
||||
font-weight: 550;
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
color: ${s("textTertiary")};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import deburr from "lodash/deburr";
|
||||
import difference from "lodash/difference";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
@@ -9,10 +7,7 @@ import { mergeRefs } from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { dateLocale, dateToRelative } from "@shared/utils/date";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
@@ -22,12 +17,8 @@ import useDictionary from "~/hooks/useDictionary";
|
||||
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { NotFoundError } from "~/utils/errors";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
|
||||
|
||||
@@ -50,76 +41,13 @@ export type Props = Optional<
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
|
||||
props;
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
const { comments, documents } = useStores();
|
||||
const { comments } = useStores();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
const handleSearchLink = React.useCallback(
|
||||
async (term: string) => {
|
||||
if (isInternalUrl(term)) {
|
||||
// search for exact internal document
|
||||
const slug = parseDocumentSlug(term);
|
||||
if (!slug) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const document = await documents.fetch(slug);
|
||||
const time = dateToRelative(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
shorten: true,
|
||||
locale,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
title: document.title,
|
||||
subtitle: `Updated ${time}`,
|
||||
url: document.url,
|
||||
icon: document.icon ? (
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
/>
|
||||
) : undefined,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
// NotFoundError could not find document for slug
|
||||
if (!(error instanceof NotFoundError)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// default search for anything that doesn't look like a URL
|
||||
const results = await documents.searchTitles({ query: term });
|
||||
|
||||
return sortBy(
|
||||
results.map(({ document }) => ({
|
||||
title: document.title,
|
||||
subtitle: <DocumentBreadcrumb document={document} onlyText />,
|
||||
url: document.url,
|
||||
icon: document.icon ? (
|
||||
<Icon value={document.icon} color={document.color ?? undefined} />
|
||||
) : undefined,
|
||||
})),
|
||||
(document) =>
|
||||
deburr(document.title)
|
||||
.toLowerCase()
|
||||
.startsWith(deburr(term).toLowerCase())
|
||||
? -1
|
||||
: 1
|
||||
);
|
||||
},
|
||||
[locale, documents]
|
||||
);
|
||||
|
||||
const handleUploadFile = React.useCallback(
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, {
|
||||
@@ -263,7 +191,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onClickLink={handleClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
|
||||
@@ -13,15 +13,15 @@ import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RevisionMenu from "~/menus/RevisionMenu";
|
||||
import { hover } from "~/styles";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { documentHistoryPath } from "~/utils/routeHelpers";
|
||||
|
||||
@@ -35,6 +35,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { revisions } = useStores();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const opts = {
|
||||
userName: event.actor.name,
|
||||
};
|
||||
@@ -66,7 +67,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
);
|
||||
to = {
|
||||
pathname: documentHistoryPath(document, event.modelId || "latest"),
|
||||
state: { retainScrollPosition: true },
|
||||
state: {
|
||||
sidebarContext,
|
||||
retainScrollPosition: true,
|
||||
},
|
||||
};
|
||||
break;
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ const FilterOptions = ({
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
modal: false,
|
||||
});
|
||||
const selectedItems = options.filter((option) =>
|
||||
selectedKeys.includes(option.key)
|
||||
@@ -229,7 +229,7 @@ const SearchInput = styled(Input)`
|
||||
${Outline} {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid rgb(34 40 52);
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("menuBackground")};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import { s } from "@shared/styles";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import GroupMembers from "~/scenes/GroupMembers";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Modal from "~/components/Modal";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
membership?: GroupMembership;
|
||||
showFacepile?: boolean;
|
||||
showAvatar?: boolean;
|
||||
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
|
||||
};
|
||||
|
||||
function GroupListItem({ group, showFacepile, renderActions }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
|
||||
useBoolean();
|
||||
const memberCount = group.memberCount;
|
||||
const users = group.users.slice(0, MAX_AVATAR_DISPLAY);
|
||||
const overflow = memberCount - users.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Image>
|
||||
<GroupIcon size={24} />
|
||||
</Image>
|
||||
}
|
||||
title={<Title onClick={setMembersModalOpen}>{group.name}</Title>}
|
||||
subtitle={t("{{ count }} member", { count: memberCount })}
|
||||
actions={
|
||||
<Flex align="center" gap={8}>
|
||||
{showFacepile && (
|
||||
<NudeButton
|
||||
width="auto"
|
||||
height="auto"
|
||||
onClick={setMembersModalOpen}
|
||||
>
|
||||
<Facepile users={users} overflow={overflow} />
|
||||
</NudeButton>
|
||||
)}
|
||||
{renderActions({
|
||||
openMembersModal: setMembersModalOpen,
|
||||
})}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Group members")}
|
||||
onRequestClose={setMembersModalClosed}
|
||||
isOpen={membersModalOpen}
|
||||
>
|
||||
<GroupMembers group={group} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Image = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${s("backgroundSecondary")};
|
||||
border-radius: 32px;
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
&: ${hover} {
|
||||
text-decoration: underline;
|
||||
cursor: var(--pointer);
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(GroupListItem);
|
||||
+34
-31
@@ -17,6 +17,7 @@ import useMobile from "~/hooks/useMobile";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TooltipProvider } from "./TooltipContext";
|
||||
|
||||
export const HEADER_HEIGHT = 64;
|
||||
|
||||
@@ -71,38 +72,40 @@ function Header(
|
||||
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
ref={mergeRefs([ref, internalRef])}
|
||||
align="center"
|
||||
shrink={false}
|
||||
className={className}
|
||||
$passThrough={passThrough}
|
||||
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
||||
>
|
||||
{left || hasMobileSidebar ? (
|
||||
<Breadcrumbs ref={setBreadcrumbRef}>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
{left}
|
||||
</Breadcrumbs>
|
||||
) : null}
|
||||
<TooltipProvider>
|
||||
<Wrapper
|
||||
ref={mergeRefs([ref, internalRef])}
|
||||
align="center"
|
||||
shrink={false}
|
||||
className={className}
|
||||
$passThrough={passThrough}
|
||||
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
||||
>
|
||||
{left || hasMobileSidebar ? (
|
||||
<Breadcrumbs ref={setBreadcrumbRef}>
|
||||
{hasMobileSidebar && (
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
neutral
|
||||
/>
|
||||
)}
|
||||
{left}
|
||||
</Breadcrumbs>
|
||||
) : null}
|
||||
|
||||
{isScrolled && !isCompact ? (
|
||||
<Title onClick={handleClickTitle}>
|
||||
<Fade>{title}</Fade>
|
||||
</Title>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Actions align="center" justify="flex-end">
|
||||
{typeof actions === "function" ? actions({ isCompact }) : actions}
|
||||
</Actions>
|
||||
</Wrapper>
|
||||
{isScrolled && !isCompact ? (
|
||||
<Title onClick={handleClickTitle}>
|
||||
<Fade>{title}</Fade>
|
||||
</Title>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Actions align="center" justify="flex-end">
|
||||
{typeof actions === "function" ? actions({ isCompact }) : actions}
|
||||
</Actions>
|
||||
</Wrapper>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { BackIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { breakpoints, s } from "@shared/styles";
|
||||
import { breakpoints, s, hover } from "@shared/styles";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { validateColorHex } from "@shared/utils/color";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
enum Panel {
|
||||
Builtin,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
export const IconButton = styled(NudeButton)<{ delay?: number }>`
|
||||
width: 32px;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
|
||||
&: ${hover},
|
||||
|
||||
@@ -2,13 +2,12 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { depths, s, hover } from "@shared/styles";
|
||||
import { EmojiSkinTone } from "@shared/types";
|
||||
import { getEmojiVariants } from "@shared/utils/emoji";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { hover } from "~/styles";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
const SkinTonePicker = ({
|
||||
|
||||
@@ -10,19 +10,18 @@ import {
|
||||
useTabState,
|
||||
} from "reakit";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import theme from "@shared/styles/theme";
|
||||
import { IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import Flex from "~/components/Flex";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Popover from "~/components/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import { hover } from "~/styles";
|
||||
import EmojiPanel from "./components/EmojiPanel";
|
||||
import IconPanel from "./components/IconPanel";
|
||||
import { PopoverButton } from "./components/PopoverButton";
|
||||
|
||||
@@ -2,9 +2,9 @@ import { observer } from "mobx-react";
|
||||
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
|
||||
import { getLuminance } from "polished";
|
||||
import * as React from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import Collection from "~/models/Collection";
|
||||
import Icon from "~/components/Icon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -4,9 +4,9 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import Input, { Outline } from "./Input";
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Helmet } from "react-helmet-async";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
|
||||
import SkipNavContent from "~/components/SkipNavContent";
|
||||
@@ -13,7 +14,6 @@ import useAutoRefresh from "~/hooks/useAutoRefresh";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { MenuProvider } from "~/hooks/useMenuContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
|
||||
@@ -6,10 +6,9 @@ import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { s, hover, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import NavLink from "~/components/NavLink";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
|
||||
/** An icon or image to display to the left of the list item */
|
||||
|
||||
@@ -4,11 +4,10 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover, truncateMultiline } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import CommentEditor from "~/scenes/Document/components/CommentEditor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover, truncateMultiline } from "~/styles";
|
||||
import { Avatar, AvatarSize } from "../Avatar";
|
||||
import Flex from "../Flex";
|
||||
import Text from "../Text";
|
||||
|
||||
@@ -3,13 +3,12 @@ import { MarkAsReadIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import Notification from "~/models/Notification";
|
||||
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NotificationMenu from "~/menus/NotificationMenu";
|
||||
import { hover } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Empty from "../Empty";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
|
||||
@@ -10,13 +10,23 @@ import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type Props = PopoverProps & {
|
||||
children: React.ReactNode;
|
||||
/** The width of the popover, defaults to 380px. */
|
||||
width?: number;
|
||||
/** The minimum width of the popover, use instead of width if contents adjusts size. */
|
||||
minWidth?: number;
|
||||
/** Shrink the padding of the popover */
|
||||
shrink?: boolean;
|
||||
/** Make the popover flex */
|
||||
flex?: boolean;
|
||||
/** The tab index of the popover */
|
||||
tabIndex?: number;
|
||||
/** Whether the popover should be scrollable, defaults to true. */
|
||||
scrollable?: boolean;
|
||||
/** The position of the popover on mobile, defaults to "top". */
|
||||
mobilePosition?: "top" | "bottom";
|
||||
/** Function to show the popover */
|
||||
show: () => void;
|
||||
/** Function to hide the popover */
|
||||
hide: () => void;
|
||||
};
|
||||
|
||||
@@ -25,6 +35,7 @@ const Popover = (
|
||||
children,
|
||||
shrink,
|
||||
width = 380,
|
||||
minWidth,
|
||||
scrollable = true,
|
||||
flex,
|
||||
mobilePosition,
|
||||
@@ -71,6 +82,7 @@ const Popover = (
|
||||
ref={ref}
|
||||
$shrink={shrink}
|
||||
$width={width}
|
||||
$minWidth={minWidth}
|
||||
$scrollable={scrollable}
|
||||
$flex={flex}
|
||||
>
|
||||
@@ -83,6 +95,7 @@ const Popover = (
|
||||
type ContentsProps = {
|
||||
$shrink?: boolean;
|
||||
$width?: number;
|
||||
$minWidth?: number;
|
||||
$flex?: boolean;
|
||||
$scrollable: boolean;
|
||||
$mobilePosition?: "top" | "bottom";
|
||||
@@ -101,7 +114,8 @@ const Contents = styled.div<ContentsProps>`
|
||||
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
|
||||
max-height: 75vh;
|
||||
box-shadow: ${s("menuShadow")};
|
||||
width: ${(props) => props.$width}px;
|
||||
${(props) => props.$width && `width: ${props.$width}px`};
|
||||
${(props) => props.$minWidth && `min-width: ${props.$minWidth}px`};
|
||||
|
||||
${(props) =>
|
||||
props.$scrollable
|
||||
|
||||
@@ -3,7 +3,7 @@ import { transparentize } from "polished";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import type { ReactionSummary } from "@shared/types";
|
||||
import { getEmojiId } from "@shared/utils/emoji";
|
||||
import User from "~/models/User";
|
||||
@@ -13,7 +13,6 @@ import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = {
|
||||
/** Thin reaction data - contains the emoji & active user ids for this reaction. */
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Tab, TabPanel, useTabState } from "reakit";
|
||||
import { toast } from "sonner";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import Comment from "~/models/Comment";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
@@ -13,7 +13,6 @@ import Flex from "~/components/Flex";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = {
|
||||
/** Model for which to show the reactions. */
|
||||
|
||||
@@ -7,10 +7,9 @@ import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import { s, hover, ellipsis } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import Highlight, { Mark } from "~/components/Highlight";
|
||||
import { hover } from "~/styles";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import styled from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import BaseListItem from "~/components/List/Item";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
export const InviteIcon = styled(PlusIcon)`
|
||||
opacity: 0;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CheckmarkIcon, CloseIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { stringToColor } from "@shared/utils/color";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -20,7 +20,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMaxHeight from "~/hooks/useMaxHeight";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useThrottledCallback from "~/hooks/useThrottledCallback";
|
||||
import { hover } from "~/styles";
|
||||
import { InviteIcon, ListItem } from "./ListItem";
|
||||
|
||||
type Suggestion = IAvatar & {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { darken } from "polished";
|
||||
import styled from "styled-components";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
// TODO: Temp until Button/NudeButton styles are normalized
|
||||
export const Wrapper = styled.div`
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
@@ -14,7 +15,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
@@ -5,12 +5,12 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Sidebar from "./Sidebar";
|
||||
|
||||
@@ -3,16 +3,16 @@ import { SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import SearchPopover from "~/components/SearchPopover";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { hover } from "~/styles";
|
||||
import history from "~/utils/history";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { useTeamContext } from "../TeamContext";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
@@ -67,6 +67,7 @@ function SharedSidebar({ rootNode, shareId }: Props) {
|
||||
depth={0}
|
||||
shareId={shareId}
|
||||
node={rootNode}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
activeDocumentId={ui.activeDocumentId}
|
||||
activeDocument={documents.active}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import NotificationIcon from "../Notifications/NotificationIcon";
|
||||
import NotificationsPopover from "../Notifications/NotificationsPopover";
|
||||
import { TooltipProvider } from "../TooltipContext";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
|
||||
import ToggleButton from "./components/ToggleButton";
|
||||
@@ -194,8 +195,9 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Container
|
||||
id="sidebar"
|
||||
ref={ref}
|
||||
style={style}
|
||||
$hidden={hidden}
|
||||
@@ -242,7 +244,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
/>
|
||||
</Container>
|
||||
{ui.mobileSidebarVisible && <Backdrop onClick={ui.toggleMobileSidebar} />}
|
||||
</>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarAction from "./SidebarAction";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
function Collections() {
|
||||
@@ -49,38 +50,40 @@ function Collections() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.allActive}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
position="top"
|
||||
<SidebarContext.Provider value="collections">
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.allActive}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
position="top"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection, index) => (
|
||||
<DraggableCollectionLink
|
||||
key={item.id}
|
||||
collection={item}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection, index) => (
|
||||
<DraggableCollectionLink
|
||||
key={item.id}
|
||||
collection={item}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
)}
|
||||
/>
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import Fade from "~/components/Fade";
|
||||
import Icon from "~/components/Icon";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
|
||||
@@ -7,8 +7,8 @@ import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import DropCursor from "./DropCursor";
|
||||
@@ -29,7 +29,7 @@ function DraggableCollectionLink({
|
||||
prefetchDocument,
|
||||
belowCollection,
|
||||
}: Props) {
|
||||
const locationSidebarContext = useLocationState();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const { ui, policies, collections } = useStores();
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
|
||||
@@ -2,10 +2,11 @@ import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Group from "~/models/Group";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SharedWithMeLink from "./SharedWithMeLink";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarContext, { groupSidebarContext } from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
@@ -14,13 +15,23 @@ type Props = {
|
||||
};
|
||||
|
||||
const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = groupSidebarContext(group.id);
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
locationSidebarContext === sidebarContext
|
||||
);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev?.preventDefault();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (locationSidebarContext === sidebarContext) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [sidebarContext, locationSidebarContext, setExpanded]);
|
||||
|
||||
return (
|
||||
<Relative>
|
||||
<SidebarLink
|
||||
@@ -30,7 +41,7 @@ const GroupLink: React.FC<Props> = ({ group }) => {
|
||||
onClick={handleDisclosureClick}
|
||||
depth={0}
|
||||
/>
|
||||
<SidebarContext.Provider value={group.id}>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Folder expanded={expanded}>
|
||||
{group.documentMemberships.map((membership) => (
|
||||
<SharedWithMeLink
|
||||
|
||||
@@ -93,15 +93,11 @@ const NavLink = ({
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isActive && linkRef.current && scrollIntoViewIfNeeded !== false) {
|
||||
// If the page has an anchor hash then this means we're linking to an
|
||||
// anchor in the document – smooth scrolling the sidebar may the scrolling
|
||||
// to the anchor of the document so we must avoid it.
|
||||
if (!window.location.hash) {
|
||||
scrollIntoView(linkRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
});
|
||||
}
|
||||
scrollIntoView(linkRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
boundary: (parent) => parent.id !== "sidebar",
|
||||
});
|
||||
}
|
||||
}, [linkRef, scrollIntoViewIfNeeded, isActive]);
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import includes from "lodash/includes";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import Icon from "~/components/Icon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { descendants } from "~/utils/tree";
|
||||
@@ -16,6 +16,7 @@ type Props = {
|
||||
collection?: Collection;
|
||||
activeDocumentId?: string;
|
||||
activeDocument?: Document;
|
||||
prefetchDocument?: (documentId: string) => Promise<Document | void>;
|
||||
isDraft?: boolean;
|
||||
depth: number;
|
||||
index: number;
|
||||
@@ -29,6 +30,7 @@ function DocumentLink(
|
||||
collection,
|
||||
activeDocument,
|
||||
activeDocumentId,
|
||||
prefetchDocument,
|
||||
isDraft,
|
||||
depth,
|
||||
shareId,
|
||||
@@ -97,6 +99,10 @@ function DocumentLink(
|
||||
node,
|
||||
]);
|
||||
|
||||
const handlePrefetch = React.useCallback(() => {
|
||||
void prefetchDocument?.(node.id);
|
||||
}, [prefetchDocument, node]);
|
||||
|
||||
const title =
|
||||
(activeDocument?.id === node.id ? activeDocument.title : node.title) ||
|
||||
t("Untitled");
|
||||
@@ -114,6 +120,7 @@ function DocumentLink(
|
||||
}}
|
||||
expanded={hasChildDocuments && depth !== 0 ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={icon && <Icon value={icon} color={node.color} />}
|
||||
label={title}
|
||||
depth={depth}
|
||||
@@ -132,6 +139,7 @@ function DocumentLink(
|
||||
node={childNode}
|
||||
activeDocumentId={activeDocumentId}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
index={index}
|
||||
|
||||
@@ -9,6 +9,7 @@ import GroupMembership from "~/models/GroupMembership";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import {
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
useDropToReorderUserMembership,
|
||||
useDropToReparentDocument,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
@@ -36,7 +36,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId } = membership;
|
||||
const isActiveDocument = documentId === ui.activeDocumentId;
|
||||
const locationSidebarContext = useLocationState();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const document = documentId ? documents.get(documentId) : undefined;
|
||||
|
||||
|
||||
@@ -1,9 +1,57 @@
|
||||
import * as React from "react";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
|
||||
export type SidebarContextType = "collections" | "starred" | string | undefined;
|
||||
export type SidebarContextType =
|
||||
| "collections"
|
||||
| "shared"
|
||||
| `group-${string}`
|
||||
| `starred-${string}`
|
||||
| undefined;
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextType>(undefined);
|
||||
|
||||
export const useSidebarContext = () => React.useContext(SidebarContext);
|
||||
|
||||
export const groupSidebarContext = (groupId: string): SidebarContextType =>
|
||||
`group-${groupId}`;
|
||||
|
||||
export const starredSidebarContext = (modelId: string): SidebarContextType =>
|
||||
`starred-${modelId}`;
|
||||
|
||||
export const determineSidebarContext = ({
|
||||
document,
|
||||
user,
|
||||
currentContext,
|
||||
}: {
|
||||
document: Document;
|
||||
user: User;
|
||||
currentContext?: SidebarContextType;
|
||||
}): SidebarContextType => {
|
||||
const isStarred = document.isStarred || !!document.collection?.isStarred;
|
||||
const preferStarred = !currentContext || currentContext.startsWith("starred");
|
||||
|
||||
if (isStarred && preferStarred) {
|
||||
const currentlyInStarredCollection =
|
||||
currentContext === starredSidebarContext(document.collectionId ?? "");
|
||||
|
||||
return document.isStarred && !currentlyInStarredCollection
|
||||
? starredSidebarContext(document.id)
|
||||
: starredSidebarContext(document.collectionId!);
|
||||
}
|
||||
|
||||
if (document.collection) {
|
||||
return "collections";
|
||||
} else if (
|
||||
user.documentMemberships.find((m) => m.documentId === document.id)
|
||||
) {
|
||||
return "shared";
|
||||
} else {
|
||||
const group = user.groupsWithDocumentMemberships.find(
|
||||
(g) => !!g.documentMemberships.find((m) => m.documentId === document.id)
|
||||
);
|
||||
return groupSidebarContext(group?.id ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
export default SidebarContext;
|
||||
|
||||
@@ -15,7 +15,6 @@ import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import StarredLink from "./StarredLink";
|
||||
|
||||
@@ -42,48 +41,46 @@ function Starred() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value="starred">
|
||||
<Flex column>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
</DelayedMount>
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
</SidebarContext.Provider>
|
||||
<Flex column>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
</DelayedMount>
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import styled, { useTheme } from "styled-components";
|
||||
import Star from "~/models/Star";
|
||||
import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import {
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
useDropToCreateStar,
|
||||
useDropToReorderStar,
|
||||
} from "../hooks/useDragAndDrop";
|
||||
import { useLocationState } from "../hooks/useLocationState";
|
||||
import { useSidebarLabelAndIcon } from "../hooks/useSidebarLabelAndIcon";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
@@ -25,7 +25,7 @@ import Folder from "./Folder";
|
||||
import Relative from "./Relative";
|
||||
import SidebarContext, {
|
||||
SidebarContextType,
|
||||
useSidebarContext,
|
||||
starredSidebarContext,
|
||||
} from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
@@ -39,10 +39,14 @@ function StarredLink({ star }: Props) {
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const { documentId, collectionId } = star;
|
||||
const collection = collections.get(collectionId);
|
||||
const locationSidebarContext = useLocationState();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const locationSidebarContext = useLocationSidebarContext();
|
||||
const sidebarContext = starredSidebarContext(
|
||||
star.documentId ?? star.collectionId
|
||||
);
|
||||
const [expanded, setExpanded] = useState(
|
||||
star.collectionId === ui.activeCollectionId &&
|
||||
(star.documentId
|
||||
? star.documentId === ui.activeDocumentId
|
||||
: star.collectionId === ui.activeCollectionId) &&
|
||||
sidebarContext === locationSidebarContext
|
||||
);
|
||||
|
||||
@@ -159,7 +163,7 @@ function StarredLink({ star }: Props) {
|
||||
}
|
||||
/>
|
||||
</Draggable>
|
||||
<SidebarContext.Provider value={document.id}>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
@@ -183,7 +187,7 @@ function StarredLink({ star }: Props) {
|
||||
|
||||
if (collection) {
|
||||
return (
|
||||
<>
|
||||
<SidebarContext.Provider value={sidebarContext}>
|
||||
<Draggable key={star?.id} ref={draggableRef} $isDragging={isDragging}>
|
||||
<CollectionLink
|
||||
collection={collection}
|
||||
@@ -193,16 +197,14 @@ function StarredLink({ star }: Props) {
|
||||
isDraggingAnyCollection={reorderStarProps.isDragging}
|
||||
/>
|
||||
</Draggable>
|
||||
<SidebarContext.Provider value={collection.id}>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={displayChildDocuments}
|
||||
/>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
</>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={displayChildDocuments}
|
||||
/>
|
||||
{cursor}
|
||||
</Relative>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import styled from "styled-components";
|
||||
import { hover } from "~/styles";
|
||||
import { hover } from "@shared/styles";
|
||||
import SidebarButton from "./SidebarButton";
|
||||
|
||||
const ToggleButton = styled(SidebarButton)`
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -13,7 +14,6 @@ import GroupMembership from "~/models/GroupMembership";
|
||||
import Star from "~/models/Star";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import ConfirmMoveDialog from "~/components/ConfirmMoveDialog";
|
||||
import Icon from "~/components/Icon";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DragObject } from "../components/SidebarLink";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "~/components/Icon";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ColumnSort } from "@tanstack/react-table";
|
||||
import * as React from "react";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import type { Props as TableProps } from "./Table";
|
||||
|
||||
const Table = lazyWithRetry(() => import("~/components/Table"));
|
||||
|
||||
export type Props<T> = Omit<TableProps<T>, "onChangeSort">;
|
||||
|
||||
export function SortableTable<T>(props: Props<T>) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const params = useQuery();
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(sort: ColumnSort) => {
|
||||
params.set("sort", sort.id);
|
||||
params.set("direction", sort.desc ? "desc" : "asc");
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
return <Table onChangeSort={handleChangeSort} {...props} />;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { StarredIcon, UnstarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { hover } from "@shared/styles";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import {
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
} from "~/actions/definitions/collections";
|
||||
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -4,9 +4,8 @@ import isEqual from "lodash/isEqual";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import NavLink from "~/components/NavLink";
|
||||
import { hover } from "~/styles";
|
||||
|
||||
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
|
||||
/**
|
||||
|
||||
+302
-240
@@ -1,231 +1,283 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
SortingState,
|
||||
flexRender,
|
||||
ColumnSort,
|
||||
functionalUpdate,
|
||||
Row as TRow,
|
||||
createColumnHelper,
|
||||
AccessorFn,
|
||||
CellContext,
|
||||
} from "@tanstack/react-table";
|
||||
import { useWindowVirtualizer } from "@tanstack/react-virtual";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTable, useSortBy, usePagination } from "react-table";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Button from "~/components/Button";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
|
||||
export type Props = {
|
||||
data: any[];
|
||||
offset?: number;
|
||||
isLoading: boolean;
|
||||
empty?: React.ReactNode;
|
||||
currentPage?: number;
|
||||
page: number;
|
||||
pageSize?: number;
|
||||
totalPages?: number;
|
||||
defaultSort?: string;
|
||||
topRef?: React.Ref<any>;
|
||||
onChangePage: (index: number) => void;
|
||||
onChangeSort: (
|
||||
sort: string | null | undefined,
|
||||
direction: "ASC" | "DESC"
|
||||
) => void;
|
||||
columns: any;
|
||||
defaultSortDirection: "ASC" | "DESC";
|
||||
const HEADER_HEIGHT = 40;
|
||||
|
||||
type DataColumn<TData> = {
|
||||
type: "data";
|
||||
header: string;
|
||||
accessor: AccessorFn<TData>;
|
||||
sortable?: boolean;
|
||||
};
|
||||
|
||||
function Table({
|
||||
data,
|
||||
isLoading,
|
||||
totalPages,
|
||||
empty,
|
||||
columns,
|
||||
page,
|
||||
pageSize = 50,
|
||||
defaultSort = "name",
|
||||
topRef,
|
||||
onChangeSort,
|
||||
onChangePage,
|
||||
defaultSortDirection,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
canNextPage,
|
||||
nextPage,
|
||||
canPreviousPage,
|
||||
previousPage,
|
||||
state: { pageIndex, sortBy },
|
||||
} = useTable(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
manualPagination: true,
|
||||
manualSortBy: true,
|
||||
autoResetSortBy: false,
|
||||
autoResetPage: false,
|
||||
pageCount: totalPages,
|
||||
initialState: {
|
||||
sortBy: [
|
||||
{
|
||||
id: defaultSort,
|
||||
desc: defaultSortDirection === "DESC" ? true : false,
|
||||
},
|
||||
],
|
||||
pageSize,
|
||||
pageIndex: page,
|
||||
},
|
||||
stateReducer: (newState, action, prevState) => {
|
||||
if (!isEqual(newState.sortBy, prevState.sortBy)) {
|
||||
return { ...newState, pageIndex: 0 };
|
||||
}
|
||||
type ActionColumn = {
|
||||
type: "action";
|
||||
header?: string;
|
||||
};
|
||||
|
||||
return newState;
|
||||
},
|
||||
},
|
||||
useSortBy,
|
||||
usePagination
|
||||
export type Column<TData> = {
|
||||
id: string;
|
||||
component: (data: TData) => React.ReactNode;
|
||||
width: string;
|
||||
} & (DataColumn<TData> | ActionColumn);
|
||||
|
||||
export type Props<TData> = {
|
||||
data: TData[];
|
||||
columns: Column<TData>[];
|
||||
sort: ColumnSort;
|
||||
onChangeSort: (sort: ColumnSort) => void;
|
||||
loading: boolean;
|
||||
page: {
|
||||
hasNext: boolean;
|
||||
fetchNext?: () => void;
|
||||
};
|
||||
rowHeight: number;
|
||||
stickyOffset?: number;
|
||||
};
|
||||
|
||||
function Table<TData>({
|
||||
data,
|
||||
columns,
|
||||
sort,
|
||||
onChangeSort,
|
||||
loading,
|
||||
page,
|
||||
rowHeight,
|
||||
stickyOffset = 0,
|
||||
}: Props<TData>) {
|
||||
const { t } = useTranslation();
|
||||
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [virtualContainerTop, setVirtualContainerTop] =
|
||||
React.useState<number>();
|
||||
|
||||
const columnHelper = React.useMemo(() => createColumnHelper<TData>(), []);
|
||||
const observedColumns = React.useMemo(
|
||||
() =>
|
||||
columns.map((column) => {
|
||||
const cell = ({ row }: CellContext<TData, unknown>) => (
|
||||
<ObservedCell data={row.original} render={column.component} />
|
||||
);
|
||||
|
||||
return column.type === "data"
|
||||
? columnHelper.accessor(column.accessor, {
|
||||
id: column.id,
|
||||
header: column.header,
|
||||
enableSorting: column.sortable ?? true,
|
||||
cell,
|
||||
})
|
||||
: columnHelper.display({
|
||||
id: column.id,
|
||||
header: column.header ?? "",
|
||||
cell,
|
||||
});
|
||||
}),
|
||||
[columns, columnHelper]
|
||||
);
|
||||
const prevSortBy = React.useRef(sortBy);
|
||||
|
||||
const gridColumns = React.useMemo(
|
||||
() => columns.map((column) => column.width).join(" "),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(sortState: SortingState) => {
|
||||
const newState = functionalUpdate(sortState, [sort]);
|
||||
const newSort = newState[0];
|
||||
onChangeSort(newSort);
|
||||
},
|
||||
[sort, onChangeSort]
|
||||
);
|
||||
|
||||
const prevSort = usePrevious(sort);
|
||||
const sortChanged = sort !== prevSort;
|
||||
|
||||
const isEmpty = !loading && data.length === 0;
|
||||
const showPlaceholder = loading && data.length === 0;
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: observedColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualSorting: true,
|
||||
enableMultiSort: false,
|
||||
enableSortingRemoval: false,
|
||||
state: {
|
||||
sorting: [sort],
|
||||
},
|
||||
onSortingChange: handleChangeSort,
|
||||
});
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useWindowVirtualizer({
|
||||
count: rows.length,
|
||||
estimateSize: () => rowHeight,
|
||||
scrollMargin: virtualContainerTop,
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isEqual(sortBy, prevSortBy.current)) {
|
||||
prevSortBy.current = sortBy;
|
||||
onChangePage(0);
|
||||
onChangeSort(
|
||||
sortBy.length ? sortBy[0].id : undefined,
|
||||
!sortBy.length ? defaultSortDirection : sortBy[0].desc ? "DESC" : "ASC"
|
||||
if (!sortChanged || !virtualContainerTop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollThreshold =
|
||||
virtualContainerTop - (stickyOffset + HEADER_HEIGHT);
|
||||
const reset = window.scrollY > scrollThreshold;
|
||||
|
||||
if (reset) {
|
||||
rowVirtualizer.scrollToOffset(scrollThreshold, {
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, [rowVirtualizer, sortChanged, virtualContainerTop, stickyOffset]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (virtualContainerRef.current) {
|
||||
// determine the scrollable virtual container offsetTop on mount
|
||||
setVirtualContainerTop(
|
||||
virtualContainerRef.current.getBoundingClientRect().top
|
||||
);
|
||||
}
|
||||
}, [defaultSortDirection, onChangePage, onChangeSort, sortBy]);
|
||||
|
||||
const handleNextPage = () => {
|
||||
nextPage();
|
||||
onChangePage(pageIndex + 1);
|
||||
};
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
previousPage();
|
||||
onChangePage(pageIndex - 1);
|
||||
};
|
||||
|
||||
const isEmpty = !isLoading && data.length === 0;
|
||||
const showPlaceholder = isLoading && data.length === 0;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<Anchor ref={topRef} />
|
||||
<InnerTable {...getTableProps()}>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const groupProps = headerGroup.getHeaderGroupProps();
|
||||
return (
|
||||
<tr {...groupProps} key={groupProps.key}>
|
||||
{headerGroup.headers.map((column) => (
|
||||
<Head
|
||||
{...column.getHeaderProps(column.getSortByToggleProps())}
|
||||
key={column.id}
|
||||
<>
|
||||
<InnerTable role="table">
|
||||
<THead role="rowgroup" $topPos={stickyOffset}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TR role="row" key={headerGroup.id} $columns={gridColumns}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TH role="columnheader" key={header.id}>
|
||||
<SortWrapper
|
||||
align="center"
|
||||
gap={4}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
$sortable={header.column.getCanSort()}
|
||||
>
|
||||
<SortWrapper
|
||||
align="center"
|
||||
$sortable={!column.disableSortBy}
|
||||
gap={4}
|
||||
>
|
||||
{column.render("Header")}
|
||||
{column.isSorted &&
|
||||
(column.isSortedDesc ? (
|
||||
<DescSortIcon />
|
||||
) : (
|
||||
<AscSortIcon />
|
||||
))}
|
||||
</SortWrapper>
|
||||
</Head>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody {...getTableBodyProps()}>
|
||||
{rows.map((row) => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<Row {...row.getRowProps()} key={row.id}>
|
||||
{row.cells.map((cell) => (
|
||||
<Cell
|
||||
{...cell.getCellProps([
|
||||
{
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'className' does not exist on type 'Colum... Remove this comment to see the full error message
|
||||
className: cell.column.className,
|
||||
},
|
||||
])}
|
||||
key={cell.column.id}
|
||||
>
|
||||
{cell.render("Cell")}
|
||||
</Cell>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{showPlaceholder && <Placeholder columns={columns.length} />}
|
||||
</InnerTable>
|
||||
{isEmpty ? (
|
||||
empty || <Empty>{t("No results")}</Empty>
|
||||
) : (
|
||||
<Pagination
|
||||
justify={canPreviousPage ? "space-between" : "flex-end"}
|
||||
gap={8}
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{header.column.getIsSorted() === "asc" ? (
|
||||
<AscSortIcon />
|
||||
) : header.column.getIsSorted() === "desc" ? (
|
||||
<DescSortIcon />
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</SortWrapper>
|
||||
</TH>
|
||||
))}
|
||||
</TR>
|
||||
))}
|
||||
</THead>
|
||||
|
||||
<TBody
|
||||
ref={virtualContainerRef}
|
||||
role="rowgroup"
|
||||
$height={rowVirtualizer.getTotalSize()}
|
||||
>
|
||||
{/* Note: the page > 0 check shouldn't be needed here but is */}
|
||||
{canPreviousPage && page > 0 && (
|
||||
<Button onClick={handlePreviousPage} neutral>
|
||||
{t("Previous page")}
|
||||
</Button>
|
||||
)}
|
||||
{canNextPage && (
|
||||
<Button onClick={handleNextPage} neutral>
|
||||
{t("Next page")}
|
||||
</Button>
|
||||
)}
|
||||
</Pagination>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = rows[virtualRow.index] as TRow<TData>;
|
||||
return (
|
||||
<TR
|
||||
role="row"
|
||||
key={row.id}
|
||||
data-index={virtualRow.index}
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: `translateY(${
|
||||
virtualRow.start - rowVirtualizer.options.scrollMargin
|
||||
}px)`,
|
||||
height: `${virtualRow.size}px`,
|
||||
}}
|
||||
$columns={gridColumns}
|
||||
>
|
||||
{row.getAllCells().map((cell) => (
|
||||
<TD role="cell" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TD>
|
||||
))}
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
{showPlaceholder && (
|
||||
<Placeholder columns={columns.length} gridColumns={gridColumns} />
|
||||
)}
|
||||
</InnerTable>
|
||||
{page.hasNext && (
|
||||
<Waypoint
|
||||
key={data?.length}
|
||||
onEnter={page.fetchNext}
|
||||
bottomOffset={-rowHeight * 5}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isEmpty && <Empty>{t("No results")}</Empty>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const Placeholder = ({
|
||||
const ObservedCell = observer(function <TData>({
|
||||
data,
|
||||
render,
|
||||
}: {
|
||||
data: TData;
|
||||
render: (data: TData) => React.ReactNode;
|
||||
}) {
|
||||
return <>{render(data)}</>;
|
||||
});
|
||||
|
||||
function Placeholder({
|
||||
columns,
|
||||
rows = 3,
|
||||
gridColumns,
|
||||
}: {
|
||||
columns: number;
|
||||
rows?: number;
|
||||
}) => (
|
||||
<DelayedMount>
|
||||
<tbody>
|
||||
{new Array(rows).fill(1).map((_, row) => (
|
||||
<Row key={row}>
|
||||
{new Array(columns).fill(1).map((_, col) => (
|
||||
<Cell key={col}>
|
||||
<PlaceholderText minWidth={25} maxWidth={75} />
|
||||
</Cell>
|
||||
))}
|
||||
</Row>
|
||||
))}
|
||||
</tbody>
|
||||
</DelayedMount>
|
||||
);
|
||||
|
||||
const Anchor = styled.div`
|
||||
top: -32px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Pagination = styled(Flex)`
|
||||
margin: 0 0 32px;
|
||||
`;
|
||||
gridColumns: string;
|
||||
}) {
|
||||
return (
|
||||
<DelayedMount>
|
||||
<TBody $height={150}>
|
||||
{new Array(rows).fill(1).map((_r, row) => (
|
||||
<TR key={row} $columns={gridColumns}>
|
||||
{new Array(columns).fill(1).map((_c, col) => (
|
||||
<TD key={col}>
|
||||
<PlaceholderText minWidth={25} maxWidth={75} />
|
||||
</TD>
|
||||
))}
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</DelayedMount>
|
||||
);
|
||||
}
|
||||
|
||||
const DescSortIcon = styled(CollapsedIcon)`
|
||||
margin-left: -2px;
|
||||
@@ -239,12 +291,6 @@ const AscSortIcon = styled(DescSortIcon)`
|
||||
transform: rotate(180deg);
|
||||
`;
|
||||
|
||||
const InnerTable = styled.table`
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
min-width: 100%;
|
||||
`;
|
||||
|
||||
const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
|
||||
display: inline-flex;
|
||||
height: 24px;
|
||||
@@ -261,15 +307,66 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
const Cell = styled.td`
|
||||
padding: 10px 6px;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
const InnerTable = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const THead = styled.div<{ $topPos: number }>`
|
||||
position: sticky;
|
||||
top: ${({ $topPos }) => `${$topPos}px`};
|
||||
height: ${HEADER_HEIGHT}px;
|
||||
z-index: 1;
|
||||
font-size: 14px;
|
||||
text-wrap: nowrap;
|
||||
color: ${s("textSecondary")};
|
||||
font-weight: 500;
|
||||
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("background")};
|
||||
`;
|
||||
|
||||
const TBody = styled.div<{ $height: number }>`
|
||||
position: relative;
|
||||
height: ${({ $height }) => `${$height}px`};
|
||||
`;
|
||||
|
||||
const TR = styled.div<{ $columns: string }>`
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: ${({ $columns }) => `${$columns}`};
|
||||
align-items: center;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const TH = styled.span`
|
||||
padding: 6px 6px 2px;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const TD = styled.span`
|
||||
padding: 10px 6px;
|
||||
font-size: 14px;
|
||||
text-wrap: wrap;
|
||||
word-break: break-word;
|
||||
|
||||
&:first-child {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
&.actions,
|
||||
@@ -292,39 +389,4 @@ const Cell = styled.td`
|
||||
}
|
||||
`;
|
||||
|
||||
const Row = styled.tr`
|
||||
${Cell} {
|
||||
&:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
&:last-child {
|
||||
${Cell} {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Head = styled.th`
|
||||
text-align: left;
|
||||
padding: 6px 6px 2px;
|
||||
border-bottom: 1px solid ${s("divider")};
|
||||
background: ${s("background")};
|
||||
font-size: 14px;
|
||||
color: ${s("textSecondary")};
|
||||
font-weight: 500;
|
||||
z-index: 1;
|
||||
|
||||
:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(Table);
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import type { Props } from "./Table";
|
||||
|
||||
const Table = lazyWithRetry(() => import("~/components/Table"));
|
||||
|
||||
const TableFromParams = (
|
||||
props: Omit<Props, "onChangeSort" | "onChangePage" | "topRef">
|
||||
) => {
|
||||
const topRef = React.useRef();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const params = useQuery();
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(sort, direction) => {
|
||||
if (sort) {
|
||||
params.set("sort", sort);
|
||||
} else {
|
||||
params.delete("sort");
|
||||
}
|
||||
|
||||
params.set("direction", direction.toLowerCase());
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleChangePage = React.useCallback(
|
||||
(page) => {
|
||||
if (page) {
|
||||
params.set("page", page.toString());
|
||||
} else {
|
||||
params.delete("page");
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
|
||||
if (topRef.current) {
|
||||
scrollIntoView(topRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
return (
|
||||
<Table
|
||||
topRef={topRef}
|
||||
onChangeSort={handleChangeSort}
|
||||
onChangePage={handleChangePage}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(TableFromParams);
|
||||
@@ -1,6 +1,8 @@
|
||||
import Tippy, { TippyProps } from "@tippyjs/react";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { createGlobalStyle } from "styled-components";
|
||||
import { roundArrow } from "tippy.js";
|
||||
import { s } from "@shared/styles";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { useTooltipContext } from "./TooltipContext";
|
||||
@@ -10,9 +12,24 @@ export type Props = Omit<TippyProps, "content" | "theme"> & {
|
||||
content?: React.ReactChild | React.ReactChild[];
|
||||
/** A keyboard shortcut to display next to the content */
|
||||
shortcut?: React.ReactNode;
|
||||
/** Whether to show the shortcut on a new line */
|
||||
shortcutOnNewline?: boolean;
|
||||
};
|
||||
|
||||
function Tooltip({ shortcut, content: tooltip, delay = 500, ...rest }: Props) {
|
||||
/**
|
||||
* A tooltip component that wraps Tippy and provides a consistent look and feel. Optionally
|
||||
* displays a keyboard shortcut next to the content.
|
||||
*
|
||||
* Wrap this component in a TooltipProvider to allow multiple tooltips to share the same
|
||||
* singleton instance (delay, animation, etc).
|
||||
*/
|
||||
function Tooltip({
|
||||
shortcut,
|
||||
shortcutOnNewline,
|
||||
content: tooltip,
|
||||
delay = 500,
|
||||
...rest
|
||||
}: Props) {
|
||||
const isMobile = useMobile();
|
||||
const singleton = useTooltipContext();
|
||||
|
||||
@@ -25,28 +42,50 @@ function Tooltip({ shortcut, content: tooltip, delay = 500, ...rest }: Props) {
|
||||
if (shortcut) {
|
||||
content = (
|
||||
<>
|
||||
{tooltip} · <Shortcut>{shortcut}</Shortcut>
|
||||
{tooltip}
|
||||
{shortcutOnNewline ? <br /> : " "}
|
||||
{typeof shortcut === "string" ? (
|
||||
shortcut
|
||||
.split("+")
|
||||
.map((key, i) => (
|
||||
<Shortcut key={`${key}${i}`}>
|
||||
{key.length === 1 ? key.toUpperCase() : key}
|
||||
</Shortcut>
|
||||
))
|
||||
) : (
|
||||
<Shortcut>{shortcut}</Shortcut>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tippy content={content} delay={delay} singleton={singleton} {...rest} />
|
||||
<Tippy
|
||||
arrow={roundArrow}
|
||||
content={content}
|
||||
delay={delay}
|
||||
animation="shift-away"
|
||||
singleton={singleton}
|
||||
duration={[200, 150]}
|
||||
inertia
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Shortcut = styled.kbd`
|
||||
position: relative;
|
||||
top: -2px;
|
||||
top: -1px;
|
||||
|
||||
margin-left: 2px;
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
font: 10px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
|
||||
monospace;
|
||||
font-size: 12px;
|
||||
font-family: ${s("fontFamilyMono")};
|
||||
line-height: 10px;
|
||||
color: ${s("tooltipBackground")};
|
||||
color: ${s("tooltipText")};
|
||||
border: 1px solid ${(props) => transparentize(0.75, props.theme.tooltipText)};
|
||||
vertical-align: middle;
|
||||
background-color: ${s("tooltipText")};
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
||||
|
||||
@@ -11,9 +11,13 @@ export function useTooltipContext() {
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
/** Props to pass to the Tippy component */
|
||||
tippyProps?: TippyProps;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap a collection of tooltips in a provider to allow them to share the same singleton instance.
|
||||
*/
|
||||
export function TooltipProvider({ children, tippyProps }: Props) {
|
||||
const [source, target] = useSingleton();
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input from "~/components/Input";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
@@ -85,7 +89,11 @@ export function UserSuspendDialog({ user, onSubmit }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog onSubmit={handleSubmit} savingText={`${t("Saving")}…`}>
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
savingText={`${t("Saving")}…`}
|
||||
danger
|
||||
>
|
||||
{t(
|
||||
"Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.",
|
||||
{
|
||||
@@ -123,6 +131,68 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) {
|
||||
onChange={handleChange}
|
||||
error={!name ? t("Name can't be empty") : undefined}
|
||||
value={name}
|
||||
autoSelect
|
||||
required
|
||||
flex
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserChangeEmailDialog({ user, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const actor = useCurrentUser();
|
||||
const [email, setEmail] = React.useState<string>(user.email);
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await client.post(`/users.updateEmail`, { id: user.id, email });
|
||||
onSubmit();
|
||||
toast.info(
|
||||
actor.id === user.id
|
||||
? t("Check your email to verify the new address.")
|
||||
: t("The email will be changed once verified.")
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(ev.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Save")}
|
||||
savingText={`${t("Saving")}…`}
|
||||
disabled={!email || email === user.email}
|
||||
>
|
||||
<Text as="p">
|
||||
{actor.id === user.id ? (
|
||||
<Trans>
|
||||
You will receive an email to verify your new address. It must be
|
||||
unique in the workspace.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
A confirmation email will be sent to the new address before it is
|
||||
changed.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
label={t("New email")}
|
||||
onChange={handleChange}
|
||||
error={!email ? t("Email can't be empty") : error}
|
||||
value={email}
|
||||
autoSelect
|
||||
required
|
||||
flex
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState } from "reakit/Popover";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { altDisplay, isModKey, metaDisplay } from "@shared/utils/keyboard";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
@@ -21,7 +22,6 @@ import Tooltip from "~/components/Tooltip";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { altDisplay, isModKey, metaDisplay } from "~/utils/keyboard";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type Props = {
|
||||
@@ -314,7 +314,8 @@ export default function FindAndReplace({
|
||||
style={style}
|
||||
aria-label={t("Find and replace")}
|
||||
scrollable={false}
|
||||
width={420}
|
||||
minWidth={420}
|
||||
width={0}
|
||||
>
|
||||
<Content column>
|
||||
<Flex gap={4}>
|
||||
|
||||
@@ -1,43 +1,24 @@
|
||||
import {
|
||||
ArrowIcon,
|
||||
DocumentIcon,
|
||||
CloseIcon,
|
||||
PlusIcon,
|
||||
OpenIcon,
|
||||
} from "outline-icons";
|
||||
import { ArrowIcon, CloseIcon, OpenIcon } from "outline-icons";
|
||||
import { Mark } from "prosemirror-model";
|
||||
import { Selection } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { s, hideScrollbars } from "@shared/styles";
|
||||
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
|
||||
import Flex from "~/components/Flex";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Input from "./Input";
|
||||
import LinkSearchResult from "./LinkSearchResult";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
export type SearchResult = {
|
||||
title: string;
|
||||
subtitle?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
mark?: Mark;
|
||||
from: number;
|
||||
to: number;
|
||||
dictionary: Dictionary;
|
||||
onRemoveLink?: () => void;
|
||||
onCreateLink?: (title: string, nested?: boolean) => Promise<void>;
|
||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||
onSelectLink: (options: {
|
||||
href: string;
|
||||
title?: string;
|
||||
@@ -52,46 +33,25 @@ type Props = {
|
||||
};
|
||||
|
||||
type State = {
|
||||
results: {
|
||||
[keyword: string]: SearchResult[];
|
||||
};
|
||||
value: string;
|
||||
previousValue: string;
|
||||
selectedIndex: number;
|
||||
};
|
||||
|
||||
class LinkEditor extends React.Component<Props, State> {
|
||||
discardInputValue = false;
|
||||
initialValue = this.href;
|
||||
initialSelectionLength = this.props.to - this.props.from;
|
||||
resultsRef = React.createRef<HTMLDivElement>();
|
||||
inputRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
state: State = {
|
||||
selectedIndex: -1,
|
||||
value: this.href,
|
||||
previousValue: "",
|
||||
results: {},
|
||||
};
|
||||
|
||||
get href(): string {
|
||||
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
|
||||
}
|
||||
|
||||
get selectedText(): string {
|
||||
const { state } = this.props.view;
|
||||
const selectionText = state.doc.cut(
|
||||
state.selection.from,
|
||||
state.selection.to
|
||||
).textContent;
|
||||
|
||||
return selectionText.trim();
|
||||
}
|
||||
|
||||
get suggestedLinkTitle(): string {
|
||||
return this.state.value.trim() || this.selectedText;
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
window.addEventListener("keydown", this.handleGlobalKeyDown);
|
||||
}
|
||||
@@ -139,25 +99,12 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
};
|
||||
|
||||
handleKeyDown = (event: React.KeyboardEvent): void => {
|
||||
const results = this.results;
|
||||
|
||||
switch (event.key) {
|
||||
case "Enter": {
|
||||
event.preventDefault();
|
||||
const { selectedIndex, value } = this.state;
|
||||
const { onCreateLink } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
if (selectedIndex >= 0) {
|
||||
const result = results[selectedIndex];
|
||||
if (result) {
|
||||
this.save(result.url, result.title);
|
||||
} else if (onCreateLink && selectedIndex === results.length) {
|
||||
void this.handleCreateLink(this.suggestedLinkTitle);
|
||||
}
|
||||
} else {
|
||||
// saves the raw input as href
|
||||
this.save(value, value);
|
||||
}
|
||||
this.save(value, value);
|
||||
|
||||
if (this.initialSelectionLength) {
|
||||
this.moveSelectionToEnd();
|
||||
@@ -176,45 +123,9 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
case "ArrowUp": {
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const prevIndex = this.state.selectedIndex - 1;
|
||||
|
||||
this.setState({
|
||||
selectedIndex: Math.max(-1, prevIndex),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "ArrowDown":
|
||||
case "Tab": {
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const { selectedIndex } = this.state;
|
||||
const total = results.length + 1;
|
||||
const nextIndex = selectedIndex + 1;
|
||||
|
||||
this.setState({
|
||||
selectedIndex: Math.min(nextIndex, total),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleFocusLink = (selectedIndex: number) => {
|
||||
this.setState({ selectedIndex });
|
||||
};
|
||||
|
||||
handleSearch = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
): Promise<void> => {
|
||||
@@ -222,21 +133,15 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
|
||||
this.setState({
|
||||
value,
|
||||
selectedIndex: -1,
|
||||
});
|
||||
|
||||
const trimmedValue = value.trim() || this.selectedText;
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue && this.props.onSearchLink) {
|
||||
if (trimmedValue) {
|
||||
try {
|
||||
const results = await this.props.onSearchLink(trimmedValue);
|
||||
this.setState((state) => ({
|
||||
results: {
|
||||
...state.results,
|
||||
[trimmedValue]: results,
|
||||
},
|
||||
this.setState({
|
||||
previousValue: trimmedValue,
|
||||
}));
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error("Error searching for link", err);
|
||||
}
|
||||
@@ -257,20 +162,6 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
handleCreateLink = async (title: string, nested?: boolean) => {
|
||||
this.discardInputValue = true;
|
||||
const { onCreateLink } = this.props;
|
||||
|
||||
title = title.trim();
|
||||
if (title.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onCreateLink) {
|
||||
return onCreateLink(title, nested);
|
||||
}
|
||||
};
|
||||
|
||||
handleRemoveLink = (): void => {
|
||||
this.discardInputValue = true;
|
||||
|
||||
@@ -285,16 +176,6 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
view.focus();
|
||||
};
|
||||
|
||||
handleSelectLink =
|
||||
(url: string, title: string) => (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
this.save(url, title);
|
||||
|
||||
if (this.initialSelectionLength) {
|
||||
this.moveSelectionToEnd();
|
||||
}
|
||||
};
|
||||
|
||||
moveSelectionToEnd = () => {
|
||||
const { to, view } = this.props;
|
||||
const { state, dispatch } = view;
|
||||
@@ -305,42 +186,17 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
view.focus();
|
||||
};
|
||||
|
||||
get results() {
|
||||
const { value } = this.state;
|
||||
return (
|
||||
this.state.results[value.trim()] ||
|
||||
this.state.results[this.state.previousValue] ||
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dictionary } = this.props;
|
||||
const { value, selectedIndex } = this.state;
|
||||
const results = this.results;
|
||||
const looksLikeUrl = value.match(/^https?:\/\//i);
|
||||
const suggestedLinkTitle = this.suggestedLinkTitle;
|
||||
const { value } = this.state;
|
||||
const isInternal = isInternalUrl(value);
|
||||
|
||||
const showCreateLink =
|
||||
!!this.props.onCreateLink &&
|
||||
!(suggestedLinkTitle === this.initialValue) &&
|
||||
suggestedLinkTitle.length > 0 &&
|
||||
!looksLikeUrl;
|
||||
|
||||
const hasResults =
|
||||
!!suggestedLinkTitle && (showCreateLink || results.length > 0);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Input
|
||||
ref={this.inputRef}
|
||||
value={value}
|
||||
placeholder={
|
||||
showCreateLink
|
||||
? dictionary.findOrCreateDoc
|
||||
: dictionary.searchOrPasteLink
|
||||
}
|
||||
placeholder={dictionary.enterLink}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onPaste={this.handlePaste}
|
||||
onChange={this.handleSearch}
|
||||
@@ -360,70 +216,6 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
<CloseIcon />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
|
||||
<SearchResults
|
||||
ref={this.resultsRef}
|
||||
$hasResults={hasResults}
|
||||
role="menu"
|
||||
>
|
||||
<ResizingHeightContainer>
|
||||
{hasResults && (
|
||||
<>
|
||||
{results.map((result, index) => (
|
||||
<LinkSearchResult
|
||||
key={result.url}
|
||||
title={result.title}
|
||||
subtitle={result.subtitle}
|
||||
icon={result.icon ?? <DocumentIcon />}
|
||||
onPointerMove={() => this.handleFocusLink(index)}
|
||||
onClick={this.handleSelectLink(result.url, result.title)}
|
||||
selected={index === selectedIndex}
|
||||
containerRef={this.resultsRef}
|
||||
/>
|
||||
))}
|
||||
|
||||
{showCreateLink && (
|
||||
<>
|
||||
<LinkSearchResult
|
||||
key="create"
|
||||
containerRef={this.resultsRef}
|
||||
title={suggestedLinkTitle}
|
||||
subtitle={dictionary.createNewDoc}
|
||||
icon={<PlusIcon />}
|
||||
onPointerMove={() => this.handleFocusLink(results.length)}
|
||||
onClick={async () => {
|
||||
await this.handleCreateLink(suggestedLinkTitle);
|
||||
|
||||
if (this.initialSelectionLength) {
|
||||
this.moveSelectionToEnd();
|
||||
}
|
||||
}}
|
||||
selected={results.length === selectedIndex}
|
||||
/>
|
||||
<LinkSearchResult
|
||||
key="create-nested"
|
||||
containerRef={this.resultsRef}
|
||||
title={suggestedLinkTitle}
|
||||
subtitle={dictionary.createNewChildDoc}
|
||||
icon={<PlusIcon />}
|
||||
onPointerMove={() =>
|
||||
this.handleFocusLink(results.length + 1)
|
||||
}
|
||||
onClick={async () => {
|
||||
await this.handleCreateLink(suggestedLinkTitle, true);
|
||||
|
||||
if (this.initialSelectionLength) {
|
||||
this.moveSelectionToEnd();
|
||||
}
|
||||
}}
|
||||
selected={results.length + 1 === selectedIndex}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
</SearchResults>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -434,29 +226,4 @@ const Wrapper = styled(Flex)`
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const SearchResults = styled(Scrollable)<{ $hasResults: boolean }>`
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${(props) => (props.$hasResults ? s("menuShadow") : "none")};
|
||||
clip-path: inset(0px -100px -100px -100px);
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
left: 0;
|
||||
margin-top: -6px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: ${(props) => (props.$hasResults ? "8px 0" : "0")};
|
||||
max-height: 240px;
|
||||
${hideScrollbars()}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 40px;
|
||||
border-radius: 0;
|
||||
max-height: 50vh;
|
||||
padding: 8px 8px 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default LinkEditor;
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||
icon: React.ReactNode;
|
||||
selected: boolean;
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
function LinkSearchResult({
|
||||
title,
|
||||
subtitle,
|
||||
containerRef,
|
||||
selected,
|
||||
icon,
|
||||
...rest
|
||||
}: Props) {
|
||||
const ref = React.useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (selected && node) {
|
||||
scrollIntoView(node, {
|
||||
scrollMode: "if-needed",
|
||||
block: "center",
|
||||
boundary: (parent) =>
|
||||
// Prevents body and other parent elements from being scrolled
|
||||
parent !== containerRef.current,
|
||||
});
|
||||
}
|
||||
},
|
||||
[containerRef, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
ref={ref}
|
||||
compact={!subtitle}
|
||||
selected={selected}
|
||||
role="menuitem"
|
||||
{...rest}
|
||||
>
|
||||
<IconWrapper selected={selected}>{icon}</IconWrapper>
|
||||
<Content>
|
||||
<Title title={title}>{title}</Title>
|
||||
{subtitle ? <Subtitle selected={selected}>{subtitle}</Subtitle> : null}
|
||||
</Content>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled.div`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span<{ selected: boolean }>`
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
height: 24px;
|
||||
opacity: 0.8;
|
||||
|
||||
${(props) =>
|
||||
props.selected &&
|
||||
css`
|
||||
svg {
|
||||
fill: ${s("accentText")};
|
||||
color: ${s("accentText")};
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const ListItem = styled.div<{
|
||||
selected: boolean;
|
||||
compact: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin: 0 6px;
|
||||
color: ${(props) => (props.selected ? s("accentText") : s("textSecondary"))};
|
||||
background: ${(props) => (props.selected ? s("accent") : "transparent")};
|
||||
font-family: ${s("fontFamily")};
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
cursor: var(--pointer);
|
||||
user-select: none;
|
||||
line-height: ${(props) => (props.compact ? "inherit" : "1.2")};
|
||||
height: ${(props) => (props.compact ? "28px" : "auto")};
|
||||
`;
|
||||
|
||||
const Title = styled.div`
|
||||
${ellipsis()}
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.div<{
|
||||
selected: boolean;
|
||||
}>`
|
||||
${ellipsis()}
|
||||
font-size: 13px;
|
||||
opacity: ${(props) => (props.selected ? 0.75 : 0.5)};
|
||||
`;
|
||||
|
||||
export default LinkSearchResult;
|
||||
@@ -1,146 +0,0 @@
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
|
||||
import { creatingUrlPrefix } from "@shared/utils/urls";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import FloatingToolbar from "./FloatingToolbar";
|
||||
import LinkEditor, { SearchResult } from "./LinkEditor";
|
||||
|
||||
type Props = {
|
||||
isActive: boolean;
|
||||
onCreateLink?: (title: string) => Promise<string>;
|
||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||
onClickLink: (
|
||||
href: string,
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function isActive(view: EditorView, active: boolean): boolean {
|
||||
try {
|
||||
const { selection } = view.state;
|
||||
const paragraph = view.domAtPos(selection.from);
|
||||
return active && !!paragraph.node;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function LinkToolbar({
|
||||
onCreateLink,
|
||||
onSearchLink,
|
||||
onClickLink,
|
||||
onClose,
|
||||
...rest
|
||||
}: Props) {
|
||||
const dictionary = useDictionary();
|
||||
const { view } = useEditor();
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useEventListener("mousedown", (event: Event) => {
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
menuRef.current &&
|
||||
menuRef.current.contains(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
});
|
||||
|
||||
const handleOnCreateLink = React.useCallback(
|
||||
async (title: string, nested?: boolean) => {
|
||||
onClose();
|
||||
view.focus();
|
||||
|
||||
if (!onCreateLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dispatch, state } = view;
|
||||
const { from, to } = state.selection;
|
||||
if (from !== to) {
|
||||
// selection must be collapsed
|
||||
return;
|
||||
}
|
||||
|
||||
const href = `${creatingUrlPrefix}#${title}…`;
|
||||
|
||||
// Insert a placeholder link
|
||||
dispatch(
|
||||
view.state.tr
|
||||
.insertText(title, from, to)
|
||||
.addMark(
|
||||
from,
|
||||
to + title.length,
|
||||
state.schema.marks.link.create({ href })
|
||||
)
|
||||
);
|
||||
|
||||
return createAndInsertLink(view, title, href, {
|
||||
nested,
|
||||
onCreateLink,
|
||||
dictionary,
|
||||
});
|
||||
},
|
||||
[onCreateLink, onClose, view, dictionary]
|
||||
);
|
||||
|
||||
const handleOnSelectLink = React.useCallback(
|
||||
({
|
||||
href,
|
||||
title,
|
||||
}: {
|
||||
href: string;
|
||||
title: string;
|
||||
from: number;
|
||||
to: number;
|
||||
}) => {
|
||||
onClose();
|
||||
view.focus();
|
||||
|
||||
const { dispatch, state } = view;
|
||||
const { from, to } = state.selection;
|
||||
if (from !== to) {
|
||||
// selection must be collapsed
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
view.state.tr
|
||||
.insertText(title, from, to)
|
||||
.addMark(
|
||||
from,
|
||||
to + title.length,
|
||||
state.schema.marks.link.create({ href })
|
||||
)
|
||||
);
|
||||
},
|
||||
[onClose, view]
|
||||
);
|
||||
|
||||
const { selection } = view.state;
|
||||
const active = isActive(view, rest.isActive);
|
||||
|
||||
return (
|
||||
<FloatingToolbar ref={menuRef} active={active} width={336}>
|
||||
{active && (
|
||||
<LinkEditor
|
||||
key={`${selection.from}-${selection.to}`}
|
||||
from={selection.from}
|
||||
to={selection.to}
|
||||
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
|
||||
onSelectLink={handleOnSelectLink}
|
||||
onRemoveLink={onClose}
|
||||
onClickLink={onClickLink}
|
||||
onSearchLink={onSearchLink}
|
||||
dictionary={dictionary}
|
||||
view={view}
|
||||
/>
|
||||
)}
|
||||
</FloatingToolbar>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,29 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { v4 } from "uuid";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import Document from "~/models/Document";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { DocumentsSection, UserSection } from "~/actions/sections";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import MentionMenuItem from "./MentionMenuItem";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
interface MentionItem extends MenuItem {
|
||||
name: string;
|
||||
user: User;
|
||||
appendSpace: boolean;
|
||||
attrs: {
|
||||
id: string;
|
||||
type: MentionType;
|
||||
@@ -40,17 +42,24 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [items, setItems] = React.useState<MentionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const { auth, users } = useStores();
|
||||
const { auth, documents, users } = useStores();
|
||||
const actorId = auth.currentUserId;
|
||||
const location = useLocation();
|
||||
const documentId = parseDocumentSlug(location.pathname);
|
||||
const { data, loading, request } = useRequest(
|
||||
React.useCallback(
|
||||
() =>
|
||||
documentId
|
||||
? users.fetchPage({ id: documentId, query: search })
|
||||
: Promise.resolve([]),
|
||||
[users, documentId, search]
|
||||
)
|
||||
const maxResultsInSection = search ? 25 : 5;
|
||||
|
||||
const { loading, request } = useRequest<{
|
||||
documents: Document[];
|
||||
users: User[];
|
||||
}>(
|
||||
React.useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query: search });
|
||||
|
||||
return {
|
||||
documents: res.data.documents.map(documents.add),
|
||||
users: res.data.users.map(users.add),
|
||||
};
|
||||
}, [search, documents, users])
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -60,28 +69,95 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
}, [request, isActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (data && !loading) {
|
||||
const items = data.map((user) => ({
|
||||
name: "mention",
|
||||
user,
|
||||
title: user.name,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId: auth.currentUserId ?? undefined,
|
||||
label: user.name,
|
||||
},
|
||||
}));
|
||||
if (actorId && !loading) {
|
||||
const items = users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(user) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24 }}
|
||||
>
|
||||
<Avatar
|
||||
model={user}
|
||||
showBorder={false}
|
||||
alt={t("Profile picture")}
|
||||
size={AvatarSize.Small}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
title: user.name,
|
||||
section: UserSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId,
|
||||
label: user.name,
|
||||
},
|
||||
} as MentionItem)
|
||||
)
|
||||
.concat(
|
||||
documents
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
(doc) =>
|
||||
({
|
||||
name: "mention",
|
||||
icon: doc.icon ? (
|
||||
<Icon value={doc.icon} color={doc.color ?? undefined} />
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
title: doc.title,
|
||||
subtitle: doc.collection?.name,
|
||||
section: DocumentsSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: MentionType.Document,
|
||||
modelId: doc.id,
|
||||
actorId,
|
||||
label: doc.title,
|
||||
},
|
||||
} 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: v4(),
|
||||
type: MentionType.Document,
|
||||
modelId: v4(),
|
||||
actorId,
|
||||
label: search,
|
||||
},
|
||||
} as MentionItem,
|
||||
]);
|
||||
|
||||
setItems(items);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [auth.currentUserId, loading, data]);
|
||||
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
async (item: MentionItem) => {
|
||||
if (item.attrs.type === MentionType.Document) {
|
||||
return;
|
||||
}
|
||||
// Check if the mentioned user has access to the document
|
||||
const res = await client.post("/documents.users", {
|
||||
id: documentId,
|
||||
@@ -122,25 +198,12 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<MentionMenuItem
|
||||
<SuggestionsMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
subtitle={item.subtitle}
|
||||
title={item.title}
|
||||
label={item.attrs.label}
|
||||
icon={
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24 }}
|
||||
>
|
||||
<Avatar
|
||||
model={item.user}
|
||||
showBorder={false}
|
||||
alt={t("Profile picture")}
|
||||
size={AvatarSize.Small}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
icon={item.icon}
|
||||
/>
|
||||
)}
|
||||
items={items}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as React from "react";
|
||||
import SuggestionsMenuItem, {
|
||||
Props as SuggestionsMenuItemProps,
|
||||
} from "./SuggestionsMenuItem";
|
||||
|
||||
type MentionMenuItemProps = Omit<
|
||||
SuggestionsMenuItemProps,
|
||||
"shortcut" | "theme"
|
||||
> & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export default function MentionMenuItem({
|
||||
label,
|
||||
...rest
|
||||
}: MentionMenuItemProps) {
|
||||
return <SuggestionsMenuItem {...rest} title={label} />;
|
||||
}
|
||||
@@ -25,4 +25,9 @@ export class NodeViewRenderer<T extends object> {
|
||||
this.props = props;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
public setProp<K extends keyof T>(key: K, value: T[K]) {
|
||||
this.props[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { LinkIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps,
|
||||
"renderMenuItem" | "items" | "embeds"
|
||||
> & {
|
||||
pastedText: string;
|
||||
embeds: EmbedDescriptor[];
|
||||
};
|
||||
|
||||
const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const embed = React.useMemo(() => {
|
||||
for (const e of embeds) {
|
||||
const matches = e.matcher(props.pastedText);
|
||||
if (matches) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}, [embeds, props.pastedText]);
|
||||
|
||||
const items = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "link",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
],
|
||||
[embed, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
{...props}
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={() => {
|
||||
props.onSelect?.(item);
|
||||
}}
|
||||
selected={options.selected}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
/>
|
||||
)}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasteMenu;
|
||||
@@ -1,7 +1,6 @@
|
||||
import some from "lodash/some";
|
||||
import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
@@ -9,7 +8,6 @@ import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { creatingUrlPrefix } from "@shared/utils/urls";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -26,7 +24,7 @@ import getTableColMenuItems from "../menus/tableCol";
|
||||
import getTableRowMenuItems from "../menus/tableRow";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import FloatingToolbar from "./FloatingToolbar";
|
||||
import LinkEditor, { SearchResult } from "./LinkEditor";
|
||||
import LinkEditor from "./LinkEditor";
|
||||
import ToolbarMenu from "./ToolbarMenu";
|
||||
|
||||
type Props = {
|
||||
@@ -37,12 +35,10 @@ type Props = {
|
||||
canUpdate?: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||
onClickLink: (
|
||||
href: string,
|
||||
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
onCreateLink?: (title: string) => Promise<string>;
|
||||
};
|
||||
|
||||
function useIsActive(state: EditorState) {
|
||||
@@ -149,40 +145,6 @@ export default function SelectionToolbar(props: Props) {
|
||||
};
|
||||
}, [isActive, previousIsActive, readOnly, view]);
|
||||
|
||||
const handleOnCreateLink = async (
|
||||
title: string,
|
||||
nested?: boolean
|
||||
): Promise<void> => {
|
||||
const { onCreateLink } = props;
|
||||
|
||||
if (!onCreateLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dispatch, state } = view;
|
||||
const { from, to } = state.selection;
|
||||
if (from === to) {
|
||||
// Do not display a selection toolbar for collapsed selections
|
||||
return;
|
||||
}
|
||||
|
||||
const href = `${creatingUrlPrefix}${title}…`;
|
||||
const markType = state.schema.marks.link;
|
||||
|
||||
// Insert a placeholder link
|
||||
dispatch(
|
||||
view.state.tr
|
||||
.removeMark(from, to, markType)
|
||||
.addMark(from, to, markType.create({ href }))
|
||||
);
|
||||
|
||||
return createAndInsertLink(view, title, href, {
|
||||
nested,
|
||||
onCreateLink,
|
||||
dictionary,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOnSelectLink = ({
|
||||
href,
|
||||
from,
|
||||
@@ -203,8 +165,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
);
|
||||
};
|
||||
|
||||
const { onCreateLink, isTemplate, rtl, canComment, canUpdate, ...rest } =
|
||||
props;
|
||||
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
@@ -283,8 +244,6 @@ export default function SelectionToolbar(props: Props) {
|
||||
from={link.from}
|
||||
to={link.to}
|
||||
onClickLink={props.onClickLink}
|
||||
onSearchLink={props.onSearchLink}
|
||||
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
|
||||
onSelectLink={handleOnSelectLink}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import commandScore from "command-score";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
@@ -13,6 +14,7 @@ import { MenuItem } from "@shared/editor/types";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import Header from "~/components/ContextMenu/Header";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
@@ -78,8 +80,9 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
};
|
||||
|
||||
function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const { view, commands } = useEditor();
|
||||
const { view, commands, props: editorProps } = useEditor();
|
||||
const dictionary = useDictionary();
|
||||
const { t } = useTranslation();
|
||||
const hasActivated = React.useRef(false);
|
||||
const pointerRef = React.useRef<{ clientX: number; clientY: number }>({
|
||||
clientX: 0,
|
||||
@@ -250,6 +253,16 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
props.onSelect?.(item);
|
||||
|
||||
switch (item.name) {
|
||||
case "link":
|
||||
insertNode({
|
||||
...item,
|
||||
name: "mention",
|
||||
});
|
||||
void editorProps.onCreateLink?.({
|
||||
title: item.attrs.label,
|
||||
id: item.attrs.modelId,
|
||||
});
|
||||
return;
|
||||
case "image":
|
||||
return triggerFilePick(
|
||||
AttachmentValidation.imageContentTypes.join(", ")
|
||||
@@ -264,7 +277,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
insertNode(item);
|
||||
}
|
||||
},
|
||||
[insertNode]
|
||||
[editorProps, props, insertNode]
|
||||
);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
@@ -414,6 +427,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (item.visible === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Some extensions may be disabled, remove corresponding menu items
|
||||
if (
|
||||
item.name &&
|
||||
@@ -445,16 +462,22 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
});
|
||||
|
||||
return filterExcessSeparators(
|
||||
filtered
|
||||
.map((item) => ({
|
||||
orderBy(
|
||||
filtered.map((item) => ({
|
||||
item,
|
||||
section:
|
||||
"section" in item && item.section && "priority" in item.section
|
||||
? (item.section.priority as number) ?? 0
|
||||
: 0,
|
||||
priority: "priority" in item ? item.priority : 0,
|
||||
score:
|
||||
searchInput && item.title
|
||||
? commandScore(item.title, searchInput)
|
||||
: 0,
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ item }) => item)
|
||||
})),
|
||||
["section", "priority", "score"],
|
||||
["desc", "desc", "desc"]
|
||||
).map(({ item }) => item)
|
||||
);
|
||||
}, [commands, props]);
|
||||
|
||||
@@ -555,6 +578,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
|
||||
const { isActive, uploadFile } = props;
|
||||
const items = filtered;
|
||||
let previousHeading: string | undefined;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
@@ -614,18 +638,29 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={index}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
onClick: () => handleClickItem(item),
|
||||
})}
|
||||
</ListItem>
|
||||
const currentHeading =
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const response = (
|
||||
<>
|
||||
{currentHeading !== previousHeading && (
|
||||
<Header key={currentHeading}>{currentHeading}</Header>
|
||||
)}
|
||||
<ListItem
|
||||
key={index}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{props.renderMenuItem(item as any, index, {
|
||||
selected: index === selectedIndex,
|
||||
onClick: () => handleClickItem(item),
|
||||
})}
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
|
||||
previousHeading = currentHeading;
|
||||
return response;
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<ListItem>
|
||||
|
||||
@@ -12,9 +12,11 @@ export type Props = {
|
||||
/** Callback when the item is clicked */
|
||||
onClick: (event: React.SyntheticEvent) => void;
|
||||
/** An optional icon for the item */
|
||||
icon?: React.ReactElement;
|
||||
icon?: React.ReactNode;
|
||||
/** The title of the item */
|
||||
title: React.ReactNode;
|
||||
/** An optional subtitle for the item */
|
||||
subtitle?: React.ReactNode;
|
||||
/** A string representing the keyboard shortcut for the item */
|
||||
shortcut?: string;
|
||||
};
|
||||
@@ -24,6 +26,7 @@ function SuggestionsMenuItem({
|
||||
disabled,
|
||||
onClick,
|
||||
title,
|
||||
subtitle,
|
||||
shortcut,
|
||||
icon,
|
||||
}: Props) {
|
||||
@@ -53,11 +56,17 @@ function SuggestionsMenuItem({
|
||||
icon={icon}
|
||||
>
|
||||
{title}
|
||||
{subtitle && <Subtitle $active={selected}>· {subtitle}</Subtitle>}
|
||||
{shortcut && <Shortcut $active={selected}>{shortcut}</Shortcut>}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const Subtitle = styled.span<{ $active?: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.$active ? props.theme.white50 : props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
const Shortcut = styled.span<{ $active?: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.$active ? props.theme.white50 : props.theme.textTertiary};
|
||||
|
||||
@@ -108,8 +108,9 @@ function ToolbarMenu(props: Props) {
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={item.label === item.tooltip ? undefined : item.tooltip}
|
||||
key={index}
|
||||
shortcut={item.shortcut}
|
||||
content={item.label === item.tooltip ? undefined : item.tooltip}
|
||||
>
|
||||
{item.children ? (
|
||||
<ToolbarDropdown active={isActive && !item.label} item={item} />
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
/** The content to display in the tooltip. */
|
||||
content?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
import Tooltip, { Props } from "~/components/Tooltip";
|
||||
|
||||
const WrappedTooltip: React.FC<Props> = ({
|
||||
children,
|
||||
@@ -18,6 +12,7 @@ const WrappedTooltip: React.FC<Props> = ({
|
||||
delay={150}
|
||||
content={content}
|
||||
placement="top"
|
||||
shortcutOnNewline
|
||||
{...rest}
|
||||
>
|
||||
<TooltipContent>{children}</TooltipContent>
|
||||
|
||||
@@ -12,9 +12,10 @@ import BlockMenu from "../components/BlockMenu";
|
||||
export default class BlockMenuExtension extends Suggestion {
|
||||
get defaultOptions() {
|
||||
return {
|
||||
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
|
||||
openRegex: /(?:^|\s|\()\/([\p{L}\p{M}\d]+)?$/u,
|
||||
closeRegex: /(?:^|\s|\()\/(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
|
||||
trigger: "/",
|
||||
allowSpaces: false,
|
||||
requireSearchTerm: false,
|
||||
enabledInCode: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
|
||||
|
||||
/**
|
||||
* A plugin that allows overriding the default behavior of the editor to allow
|
||||
@@ -13,11 +14,7 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const textSerializers = Object.fromEntries(
|
||||
Object.entries(this.editor.schema.nodes)
|
||||
.filter(([, node]) => node.spec.toPlainText)
|
||||
.map(([name, node]) => [name, node.spec.toPlainText])
|
||||
);
|
||||
const textSerializers = getTextSerializers(this.editor.schema);
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { action } from "mobx";
|
||||
import * as React from "react";
|
||||
import { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import { isBrowser } from "@shared/utils/browser";
|
||||
import Suggestion from "~/editor/extensions/Suggestion";
|
||||
import EmojiMenu from "../components/EmojiMenu";
|
||||
|
||||
@@ -13,17 +14,15 @@ const languagesUsingColon = ["fr"];
|
||||
|
||||
export default class EmojiMenuExtension extends Suggestion {
|
||||
get defaultOptions() {
|
||||
const languageIsUsingColon =
|
||||
typeof window === "undefined"
|
||||
? false
|
||||
: languagesUsingColon.includes(window.navigator.language.slice(0, 2));
|
||||
const languageIsUsingColon = isBrowser
|
||||
? languagesUsingColon.includes(window.navigator.language.slice(0, 2))
|
||||
: false;
|
||||
|
||||
return {
|
||||
openRegex: new RegExp(
|
||||
`(?:^|\\s|\\():([0-9a-zA-Z_+-]+)${languageIsUsingColon ? "" : "?"}$`
|
||||
),
|
||||
closeRegex:
|
||||
/(?:^|\s|\():(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/,
|
||||
trigger: ":",
|
||||
allowSpaces: false,
|
||||
requireSearchTerm: languageIsUsingColon,
|
||||
enabledInCode: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -79,15 +79,17 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
}
|
||||
|
||||
private get decorations() {
|
||||
return this.results.map((deco, index) =>
|
||||
Decoration.inline(deco.from, deco.to, {
|
||||
return this.results.map((deco, index) => {
|
||||
const decorationType =
|
||||
deco.type === "node" ? Decoration.node : Decoration.inline;
|
||||
return decorationType(deco.from, deco.to, {
|
||||
class:
|
||||
this.options.resultClassName +
|
||||
(this.currentResultIndex === index
|
||||
? ` ${this.options.resultCurrentClassName}`
|
||||
: ""),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public replace(replace: string): Command {
|
||||
@@ -175,7 +177,7 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
private goToMatch(direction: number): Command {
|
||||
return (state, dispatch) => {
|
||||
if (direction > 0) {
|
||||
if (this.currentResultIndex === this.results.length - 1) {
|
||||
if (this.currentResultIndex >= this.results.length - 1) {
|
||||
this.currentResultIndex = 0;
|
||||
} else {
|
||||
this.currentResultIndex += 1;
|
||||
@@ -212,11 +214,12 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
|
||||
const { from: currentFrom, to: currentTo } = this.results[index];
|
||||
const offset = currentTo - currentFrom - replace.length + lastOffset;
|
||||
const { from, to } = this.results[nextIndex];
|
||||
const { from, to, type } = this.results[nextIndex];
|
||||
|
||||
this.results[nextIndex] = {
|
||||
to: to - offset,
|
||||
from: from - offset,
|
||||
type,
|
||||
};
|
||||
|
||||
return offset;
|
||||
@@ -224,10 +227,19 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
|
||||
private search(doc: Node) {
|
||||
this.results = [];
|
||||
const mergedTextNodes: {
|
||||
text: string | undefined;
|
||||
pos: number;
|
||||
}[] = [];
|
||||
const mergedTextNodes: (
|
||||
| {
|
||||
text: string | undefined;
|
||||
pos: number;
|
||||
type: "inline";
|
||||
}
|
||||
| {
|
||||
text: string | undefined;
|
||||
pos: number;
|
||||
type: "node";
|
||||
nodeSize: number;
|
||||
}
|
||||
)[] = [];
|
||||
let index = 0;
|
||||
|
||||
if (!this.searchTerm) {
|
||||
@@ -238,21 +250,32 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
if (node.isText) {
|
||||
if (mergedTextNodes[index]) {
|
||||
mergedTextNodes[index] = {
|
||||
type: "inline",
|
||||
text: mergedTextNodes[index].text + (node.text ?? ""),
|
||||
pos: mergedTextNodes[index].pos,
|
||||
};
|
||||
} else {
|
||||
mergedTextNodes[index] = {
|
||||
type: "inline",
|
||||
text: node.text,
|
||||
pos,
|
||||
};
|
||||
}
|
||||
} else if (node.type.name === "mention") {
|
||||
mergedTextNodes[++index] = {
|
||||
type: "node",
|
||||
nodeSize: node.nodeSize,
|
||||
text: node.attrs.label,
|
||||
pos,
|
||||
};
|
||||
++index;
|
||||
} else {
|
||||
index += 1;
|
||||
++index;
|
||||
}
|
||||
});
|
||||
|
||||
mergedTextNodes.forEach(({ text = "", pos }) => {
|
||||
mergedTextNodes.forEach((node) => {
|
||||
const { text = "", pos, type } = node;
|
||||
try {
|
||||
let m;
|
||||
const search = this.findRegExp;
|
||||
@@ -266,8 +289,8 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
|
||||
// Reconstruct the correct match position
|
||||
const i = m.index >= text.length ? m.index - text.length : m.index;
|
||||
const from = pos + i;
|
||||
const to = from + m[0].length;
|
||||
const from = type === "inline" ? pos + i : pos;
|
||||
const to = from + (type === "inline" ? m[0].length : node.nodeSize);
|
||||
|
||||
// Check if already exists in results, possible due to duplicated
|
||||
// search string on L257
|
||||
@@ -275,7 +298,7 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.results.push({ from, to });
|
||||
this.results.push({ from, to, type });
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid RegExp
|
||||
@@ -349,7 +372,7 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
private open = false;
|
||||
|
||||
@observable
|
||||
private results: { from: number; to: number }[] = [];
|
||||
private results: { from: number; to: number; type: "inline" | "node" }[] = [];
|
||||
|
||||
@observable
|
||||
private currentResultIndex = 0;
|
||||
|
||||
@@ -7,9 +7,10 @@ import MentionMenu from "../components/MentionMenu";
|
||||
export default class MentionMenuExtension extends Suggestion {
|
||||
get defaultOptions() {
|
||||
return {
|
||||
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
|
||||
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d\s{1}@\.]+)?$/u,
|
||||
closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s{2})|(\s+[\p{L}\p{M}\d]+))$/u,
|
||||
trigger: "@",
|
||||
allowSpaces: true,
|
||||
requireSearchTerm: false,
|
||||
enabledInCode: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { action, observable } from "mobx";
|
||||
import { toggleMark } from "prosemirror-commands";
|
||||
import { Slice } from "prosemirror-model";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import {
|
||||
EditorState,
|
||||
Plugin,
|
||||
PluginKey,
|
||||
TextSelection,
|
||||
} from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { v4 } from "uuid";
|
||||
import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
import { recreateTransform } from "@shared/editor/lib/prosemirror-recreate-transform";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInList } from "@shared/editor/queries/isInList";
|
||||
import { IconType } from "@shared/types";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { IconType, MentionType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import stores from "~/stores";
|
||||
import PasteMenu from "../components/PasteMenu";
|
||||
|
||||
/**
|
||||
* Checks if the HTML string is likely coming from Dropbox Paper.
|
||||
@@ -60,13 +72,26 @@ function parseSingleIframeSrc(html: string) {
|
||||
}
|
||||
|
||||
export default class PasteHandler extends Extension {
|
||||
state: {
|
||||
open: boolean;
|
||||
query: string;
|
||||
pastedText: string;
|
||||
} = observable({
|
||||
open: false,
|
||||
query: "",
|
||||
pastedText: "",
|
||||
});
|
||||
|
||||
get name() {
|
||||
return "paste-handler";
|
||||
}
|
||||
|
||||
private key = new PluginKey(this.name);
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: this.key,
|
||||
props: {
|
||||
transformPastedHTML(html: string) {
|
||||
if (isDropboxPaper(html)) {
|
||||
@@ -106,23 +131,6 @@ export default class PasteHandler extends Extension {
|
||||
const html = event.clipboardData.getData("text/html");
|
||||
const vscode = event.clipboardData.getData("vscode-editor-data");
|
||||
|
||||
function insertLink(href: string, title?: string) {
|
||||
// If it's not an embed and there is no text selected – just go ahead and insert the
|
||||
// link directly
|
||||
const transaction = view.state.tr
|
||||
.insertText(
|
||||
title ?? href,
|
||||
state.selection.from,
|
||||
state.selection.to
|
||||
)
|
||||
.addMark(
|
||||
state.selection.from,
|
||||
state.selection.to + (title ?? href).length,
|
||||
state.schema.marks.link.create({ href })
|
||||
);
|
||||
view.dispatch(transaction);
|
||||
}
|
||||
|
||||
// If the users selection is currently in a code block then paste
|
||||
// as plain text, ignore all formatting and HTML content.
|
||||
if (isInCode(state)) {
|
||||
@@ -151,28 +159,6 @@ export default class PasteHandler extends Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Is this link embeddable? Create an embed!
|
||||
const { embeds } = this.editor.props;
|
||||
if (
|
||||
embeds &&
|
||||
this.editor.commands.embed &&
|
||||
!isInCode(state) &&
|
||||
!isInList(state)
|
||||
) {
|
||||
for (const embed of embeds) {
|
||||
if (!embed.matchOnInput) {
|
||||
continue;
|
||||
}
|
||||
const matches = embed.matcher(text);
|
||||
if (matches) {
|
||||
this.editor.commands.embed({
|
||||
href: text,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Is the link a link to a document? If so, we can grab the title and insert it.
|
||||
if (isDocumentUrl(text)) {
|
||||
const slug = parseDocumentSlug(text);
|
||||
@@ -185,26 +171,42 @@ export default class PasteHandler extends Extension {
|
||||
return;
|
||||
}
|
||||
if (document) {
|
||||
const { hash } = new URL(text);
|
||||
if (state.schema.nodes.mention) {
|
||||
view.dispatch(
|
||||
view.state.tr.replaceWith(
|
||||
state.selection.from,
|
||||
state.selection.to,
|
||||
state.schema.nodes.mention.create({
|
||||
type: MentionType.Document,
|
||||
modelId: document.id,
|
||||
label: document.titleWithDefault,
|
||||
id: v4(),
|
||||
})
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const { hash } = new URL(text);
|
||||
const hasEmoji =
|
||||
determineIconType(document.icon) ===
|
||||
IconType.Emoji;
|
||||
|
||||
const hasEmoji =
|
||||
determineIconType(document.icon) === IconType.Emoji;
|
||||
const title = `${
|
||||
hasEmoji ? document.icon + " " : ""
|
||||
}${document.titleWithDefault}`;
|
||||
|
||||
const title = `${
|
||||
hasEmoji ? document.icon + " " : ""
|
||||
}${document.titleWithDefault}`;
|
||||
insertLink(`${document.path}${hash}`, title);
|
||||
this.insertLink(`${document.path}${hash}`, title);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
insertLink(text);
|
||||
this.insertLink(text);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
insertLink(text);
|
||||
this.insertLink(text);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -306,10 +308,171 @@ export default class PasteHandler extends Extension {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
state: {
|
||||
init: () => DecorationSet.empty,
|
||||
apply: (tr, set) => {
|
||||
let mapping = tr.mapping;
|
||||
|
||||
// See if the transaction adds or removes any placeholders
|
||||
const meta = tr.getMeta(this.key);
|
||||
const hasDecorations = set.find().length;
|
||||
|
||||
// We only want a single paste placeholder at a time, so if we're adding a new
|
||||
// placeholder we can just return a new DecorationSet and avoid mapping logic.
|
||||
if (meta?.add) {
|
||||
const { from, to, id } = meta.add;
|
||||
const decorations = [
|
||||
Decoration.inline(
|
||||
from,
|
||||
to,
|
||||
{
|
||||
class: "paste-placeholder",
|
||||
},
|
||||
{
|
||||
id,
|
||||
}
|
||||
),
|
||||
];
|
||||
return DecorationSet.create(tr.doc, decorations);
|
||||
}
|
||||
|
||||
if (hasDecorations && (isRemoteTransaction(tr) || meta)) {
|
||||
try {
|
||||
mapping = recreateTransform(tr.before, tr.doc, {
|
||||
complexSteps: true,
|
||||
wordDiffs: false,
|
||||
simplifyDiff: true,
|
||||
}).mapping;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to recreate transform: ", err);
|
||||
}
|
||||
}
|
||||
|
||||
set = set.map(mapping, tr.doc);
|
||||
|
||||
if (meta?.remove) {
|
||||
const { id } = meta.remove;
|
||||
const decorations = set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id === id
|
||||
);
|
||||
return set.remove(decorations);
|
||||
}
|
||||
|
||||
return set;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** Tracks whether the Shift key is currently held down */
|
||||
private shiftKey = false;
|
||||
|
||||
private showPasteMenu = action((text: string) => {
|
||||
this.state.pastedText = text;
|
||||
this.state.open = true;
|
||||
});
|
||||
|
||||
private hidePasteMenu = action(() => {
|
||||
this.state.open = false;
|
||||
});
|
||||
|
||||
private insertLink(href: string, title?: string) {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const { from } = state.selection;
|
||||
const to = from + (title ?? href).length;
|
||||
|
||||
const transaction = view.state.tr
|
||||
.insertText(title ?? href, state.selection.from, state.selection.to)
|
||||
.addMark(from, to, state.schema.marks.link.create({ href }))
|
||||
.setMeta(this.key, { add: { from, to, id: href } });
|
||||
view.dispatch(transaction);
|
||||
this.showPasteMenu(href);
|
||||
}
|
||||
|
||||
private insertEmbed = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const result = this.findPlaceholder(state, this.state.pastedText);
|
||||
|
||||
if (result) {
|
||||
const tr = state.tr.deleteRange(result[0], result[1]);
|
||||
view.dispatch(
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
|
||||
);
|
||||
}
|
||||
|
||||
this.editor.commands.embed({
|
||||
href: this.state.pastedText,
|
||||
});
|
||||
};
|
||||
|
||||
private removePlaceholder = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const result = this.findPlaceholder(state, this.state.pastedText);
|
||||
|
||||
if (result) {
|
||||
view.dispatch(
|
||||
state.tr.setMeta(this.key, {
|
||||
remove: { id: this.state.pastedText },
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private findPlaceholder = (
|
||||
state: EditorState,
|
||||
id: string
|
||||
): [number, number] | null => {
|
||||
const decos = this.key.getState(state) as DecorationSet;
|
||||
const found = decos?.find(undefined, undefined, (spec) => spec.id === id);
|
||||
return found?.length ? [found[0].from, found[0].to] : null;
|
||||
};
|
||||
|
||||
private handleSelect = (item: MenuItem) => {
|
||||
switch (item.name) {
|
||||
case "link": {
|
||||
this.hidePasteMenu();
|
||||
this.removePlaceholder();
|
||||
break;
|
||||
}
|
||||
case "embed": {
|
||||
this.hidePasteMenu();
|
||||
this.insertEmbed();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
keys() {
|
||||
return {
|
||||
Backspace: () => {
|
||||
this.hidePasteMenu();
|
||||
return false;
|
||||
},
|
||||
"Mod-z": () => {
|
||||
this.hidePasteMenu();
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
widget = ({ rtl }: WidgetProps) => (
|
||||
<PasteMenu
|
||||
rtl={rtl}
|
||||
trigger=""
|
||||
embeds={this.editor.props.embeds}
|
||||
pastedText={this.state.pastedText}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={this.hidePasteMenu}
|
||||
onSelect={this.handleSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import { action, observable } from "mobx";
|
||||
import { InputRule } from "prosemirror-inputrules";
|
||||
import { NodeType, Schema } from "prosemirror-model";
|
||||
@@ -6,35 +7,37 @@ import Extension from "@shared/editor/lib/Extension";
|
||||
import { SuggestionsMenuPlugin } from "@shared/editor/plugins/Suggestions";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
|
||||
type Options = {
|
||||
enabledInCode: boolean;
|
||||
trigger: string;
|
||||
allowSpaces: boolean;
|
||||
requireSearchTerm: boolean;
|
||||
};
|
||||
|
||||
export default class Suggestion extends Extension {
|
||||
state: {
|
||||
open: boolean;
|
||||
query: string;
|
||||
} = observable({
|
||||
open: false,
|
||||
query: "",
|
||||
});
|
||||
constructor(options: Options) {
|
||||
super(options);
|
||||
|
||||
this.openRegex = new RegExp(
|
||||
`(?:^|\\s|\\()${escapeRegExp(this.options.trigger)}(${`[\\p{L}\\p{M}\\d${
|
||||
this.options.allowSpaces ? "\\s{1}" : ""
|
||||
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
"u"
|
||||
);
|
||||
}
|
||||
|
||||
get plugins(): Plugin[] {
|
||||
return [new SuggestionsMenuPlugin(this.options, this.state)];
|
||||
return [
|
||||
new SuggestionsMenuPlugin(this.options, this.state, this.openRegex),
|
||||
];
|
||||
}
|
||||
|
||||
keys() {
|
||||
return {
|
||||
Backspace: action((state: EditorState) => {
|
||||
const { $from } = state.selection;
|
||||
const textBefore = $from.parent.textBetween(
|
||||
Math.max(0, $from.parentOffset - 500), // 500 = max match
|
||||
Math.max(0, $from.parentOffset - 1), // 1 = account for deleted character
|
||||
null,
|
||||
"\ufffc"
|
||||
);
|
||||
|
||||
if (this.options.openRegex.test(textBefore)) {
|
||||
return false;
|
||||
Space: action(() => {
|
||||
if (this.state.open && !this.options.allowSpaces) {
|
||||
this.state.open = false;
|
||||
}
|
||||
|
||||
this.state.open = false;
|
||||
return false;
|
||||
}),
|
||||
};
|
||||
@@ -42,7 +45,7 @@ export default class Suggestion extends Extension {
|
||||
|
||||
inputRules = (_options: { type: NodeType; schema: Schema }) => [
|
||||
new InputRule(
|
||||
this.options.openRegex,
|
||||
this.openRegex,
|
||||
action((state: EditorState, match: RegExpMatchArray) => {
|
||||
const { parent } = state.selection.$from;
|
||||
if (
|
||||
@@ -51,21 +54,23 @@ export default class Suggestion extends Extension {
|
||||
parent.type.name === "heading") &&
|
||||
(!isInCode(state) || this.options.enabledInCode)
|
||||
) {
|
||||
this.state.open = true;
|
||||
if (match[0].length <= 2) {
|
||||
this.state.open = true;
|
||||
}
|
||||
this.state.query = match[1];
|
||||
}
|
||||
return null;
|
||||
})
|
||||
),
|
||||
new InputRule(
|
||||
this.options.closeRegex,
|
||||
action((_: EditorState, match: RegExpMatchArray) => {
|
||||
if (match) {
|
||||
this.state.open = false;
|
||||
this.state.query = "";
|
||||
}
|
||||
return null;
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
protected openRegex: RegExp;
|
||||
|
||||
protected state: {
|
||||
open: boolean;
|
||||
query: string;
|
||||
} = observable({
|
||||
open: false,
|
||||
query: "",
|
||||
});
|
||||
}
|
||||
|
||||
+23
-56
@@ -1,5 +1,7 @@
|
||||
/* global File Promise */
|
||||
import { PluginSimple } from "markdown-it";
|
||||
import { observable } from "mobx";
|
||||
import { Observer } from "mobx-react";
|
||||
import { darken, transparentize } from "polished";
|
||||
import { baseKeymap } from "prosemirror-commands";
|
||||
import { dropCursor } from "prosemirror-dropcursor";
|
||||
@@ -33,22 +35,23 @@ import Extension, {
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { getTextSerializers } from "@shared/editor/lib/textSerializers";
|
||||
import Mark from "@shared/editor/marks/Mark";
|
||||
import { basicExtensions as extensions } from "@shared/editor/nodes";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
import ReactNode from "@shared/editor/nodes/ReactNode";
|
||||
import { ComponentProps, EventType } from "@shared/editor/types";
|
||||
import { ComponentProps } from "@shared/editor/types";
|
||||
import { ProsemirrorData, UserPreferences } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import EventEmitter from "@shared/utils/events";
|
||||
import Document from "~/models/Document";
|
||||
import Flex from "~/components/Flex";
|
||||
import { PortalContext } from "~/components/Portal";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { Properties } from "~/types";
|
||||
import Logger from "~/utils/Logger";
|
||||
import ComponentView from "./components/ComponentView";
|
||||
import EditorContext from "./components/EditorContext";
|
||||
import { SearchResult } from "./components/LinkEditor";
|
||||
import LinkToolbar from "./components/LinkToolbar";
|
||||
import { NodeViewRenderer } from "./components/NodeViewRenderer";
|
||||
import SelectionToolbar from "./components/SelectionToolbar";
|
||||
import WithTheme from "./components/WithTheme";
|
||||
@@ -115,13 +118,11 @@ export type Props = {
|
||||
/** Callback when a file upload ends */
|
||||
onFileUploadStop?: () => void;
|
||||
/** Callback when a link is created, should return url to created document */
|
||||
onCreateLink?: (title: string) => Promise<string>;
|
||||
/** Callback when user searches for documents from link insert interface */
|
||||
onSearchLink?: (term: string) => Promise<SearchResult[]>;
|
||||
onCreateLink?: (params: Properties<Document>) => Promise<string>;
|
||||
/** Callback when user clicks on any link in the document */
|
||||
onClickLink: (
|
||||
href: string,
|
||||
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||
event?: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
/** Callback when user presses any key with document focused */
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
@@ -145,8 +146,6 @@ type State = {
|
||||
isEditorFocused: boolean;
|
||||
/** If the toolbar for a text selection is visible */
|
||||
selectionToolbarOpen: boolean;
|
||||
/** If the insert link toolbar is visible */
|
||||
linkToolbarOpen: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -176,7 +175,6 @@ export class Editor extends React.PureComponent<
|
||||
isRTL: false,
|
||||
isEditorFocused: false,
|
||||
selectionToolbarOpen: false,
|
||||
linkToolbarOpen: false,
|
||||
};
|
||||
|
||||
isInitialized = false;
|
||||
@@ -197,7 +195,7 @@ export class Editor extends React.PureComponent<
|
||||
};
|
||||
|
||||
widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
|
||||
renderers: Set<NodeViewRenderer<ComponentProps>> = new Set();
|
||||
renderers: Set<NodeViewRenderer<ComponentProps>> = observable.set();
|
||||
nodes: { [name: string]: NodeSpec };
|
||||
marks: { [name: string]: MarkSpec };
|
||||
commands: Record<string, CommandFactory>;
|
||||
@@ -205,11 +203,6 @@ export class Editor extends React.PureComponent<
|
||||
events = new EventEmitter();
|
||||
mutationObserver?: MutationObserver;
|
||||
|
||||
public constructor(props: Props & ThemeProps<DefaultTheme>) {
|
||||
super(props);
|
||||
this.events.on(EventType.LinkToolbarOpen, this.handleOpenLinkToolbar);
|
||||
}
|
||||
|
||||
/**
|
||||
* We use componentDidMount instead of constructor as the init method requires
|
||||
* that the dom is already mounted.
|
||||
@@ -246,6 +239,12 @@ export class Editor extends React.PureComponent<
|
||||
...this.view.props,
|
||||
editable: () => !this.props.readOnly,
|
||||
});
|
||||
|
||||
// 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)
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.scrollTo && this.props.scrollTo !== prevProps.scrollTo) {
|
||||
@@ -265,7 +264,6 @@ export class Editor extends React.PureComponent<
|
||||
if (
|
||||
!this.isBlurred &&
|
||||
!this.state.isEditorFocused &&
|
||||
!this.state.linkToolbarOpen &&
|
||||
!this.state.selectionToolbarOpen
|
||||
) {
|
||||
this.isBlurred = true;
|
||||
@@ -274,9 +272,7 @@ export class Editor extends React.PureComponent<
|
||||
|
||||
if (
|
||||
this.isBlurred &&
|
||||
(this.state.isEditorFocused ||
|
||||
this.state.linkToolbarOpen ||
|
||||
this.state.selectionToolbarOpen)
|
||||
(this.state.isEditorFocused || this.state.selectionToolbarOpen)
|
||||
) {
|
||||
this.isBlurred = false;
|
||||
this.props.onFocus?.();
|
||||
@@ -713,11 +709,7 @@ export class Editor extends React.PureComponent<
|
||||
*/
|
||||
public getPlainText = () => {
|
||||
const { doc } = this.view.state;
|
||||
const textSerializers = Object.fromEntries(
|
||||
Object.entries(this.schema.nodes)
|
||||
.filter(([, node]) => node.spec.toPlainText)
|
||||
.map(([name, node]) => [name, node.spec.toPlainText])
|
||||
);
|
||||
const textSerializers = getTextSerializers(this.schema);
|
||||
|
||||
return textBetween(doc, 0, doc.content.size, textSerializers);
|
||||
};
|
||||
@@ -779,25 +771,8 @@ export class Editor extends React.PureComponent<
|
||||
}));
|
||||
};
|
||||
|
||||
private handleOpenLinkToolbar = () => {
|
||||
if (this.state.selectionToolbarOpen) {
|
||||
return;
|
||||
}
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
linkToolbarOpen: true,
|
||||
}));
|
||||
};
|
||||
|
||||
private handleCloseLinkToolbar = () => {
|
||||
this.setState((state) => ({
|
||||
...state,
|
||||
linkToolbarOpen: false,
|
||||
}));
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { dir, readOnly, canUpdate, grow, style, className, onKeyDown } =
|
||||
const { readOnly, canUpdate, grow, style, className, onKeyDown } =
|
||||
this.props;
|
||||
const { isRTL } = this.state;
|
||||
|
||||
@@ -814,7 +789,6 @@ export class Editor extends React.PureComponent<
|
||||
column
|
||||
>
|
||||
<EditorContainer
|
||||
dir={dir}
|
||||
rtl={isRTL}
|
||||
grow={grow}
|
||||
readOnly={readOnly}
|
||||
@@ -834,25 +808,18 @@ export class Editor extends React.PureComponent<
|
||||
isTemplate={this.props.template === true}
|
||||
onOpen={this.handleOpenSelectionToolbar}
|
||||
onClose={this.handleCloseSelectionToolbar}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onClickLink={this.props.onClickLink}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && this.view && this.marks.link && (
|
||||
<LinkToolbar
|
||||
isActive={this.state.linkToolbarOpen}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onClickLink={this.props.onClickLink}
|
||||
onClose={this.handleCloseLinkToolbar}
|
||||
/>
|
||||
)}
|
||||
{this.widgets &&
|
||||
Object.values(this.widgets).map((Widget, index) => (
|
||||
<Widget key={String(index)} rtl={isRTL} readOnly={readOnly} />
|
||||
))}
|
||||
{Array.from(this.renderers).map((view) => view.content)}
|
||||
<Observer>
|
||||
{() => (
|
||||
<>{Array.from(this.renderers).map((view) => view.content)}</>
|
||||
)}
|
||||
</Observer>
|
||||
</Flex>
|
||||
</EditorContext.Provider>
|
||||
</PortalContext.Provider>
|
||||
|
||||
@@ -26,8 +26,8 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Image from "@shared/editor/components/Img";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
|
||||
const Img = styled(Image)`
|
||||
border-radius: 2px;
|
||||
|
||||
@@ -28,6 +28,7 @@ import { isInList } from "@shared/editor/queries/isInList";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
@@ -63,6 +64,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "strong",
|
||||
tooltip: dictionary.strong,
|
||||
shortcut: `${metaDisplay}+B`,
|
||||
icon: <BoldIcon />,
|
||||
active: isMarkActive(schema.marks.strong),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
@@ -70,6 +72,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "em",
|
||||
tooltip: dictionary.em,
|
||||
shortcut: `${metaDisplay}+I`,
|
||||
icon: <ItalicIcon />,
|
||||
active: isMarkActive(schema.marks.em),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
@@ -77,12 +80,14 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "strikethrough",
|
||||
tooltip: dictionary.strikethrough,
|
||||
shortcut: `${metaDisplay}+D`,
|
||||
icon: <StrikethroughIcon />,
|
||||
active: isMarkActive(schema.marks.strikethrough),
|
||||
visible: !isCode && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
tooltip: dictionary.mark,
|
||||
shortcut: `${metaDisplay}+Ctrl+H`,
|
||||
icon: highlight ? (
|
||||
<CircleIcon color={highlight.mark.attrs.color || Highlight.colors[0]} />
|
||||
) : (
|
||||
@@ -114,6 +119,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "code_inline",
|
||||
tooltip: dictionary.codeInline,
|
||||
shortcut: `${metaDisplay}+E`,
|
||||
icon: <CodeIcon />,
|
||||
active: isMarkActive(schema.marks.code_inline),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
@@ -125,6 +131,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "heading",
|
||||
tooltip: dictionary.heading,
|
||||
shortcut: `⇧+Ctrl+1`,
|
||||
icon: <Heading1Icon />,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 1 }),
|
||||
attrs: { level: 1 },
|
||||
@@ -133,6 +140,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "heading",
|
||||
tooltip: dictionary.subheading,
|
||||
shortcut: `⇧+Ctrl+2`,
|
||||
icon: <Heading2Icon />,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 2 }),
|
||||
attrs: { level: 2 },
|
||||
@@ -141,6 +149,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "heading",
|
||||
tooltip: dictionary.subheading,
|
||||
shortcut: `⇧+Ctrl+3`,
|
||||
icon: <Heading3Icon />,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 3 }),
|
||||
attrs: { level: 3 },
|
||||
@@ -149,6 +158,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "blockquote",
|
||||
tooltip: dictionary.quote,
|
||||
shortcut: `${metaDisplay}+]`,
|
||||
icon: <BlockQuoteIcon />,
|
||||
active: isNodeActive(schema.nodes.blockquote),
|
||||
attrs: { level: 2 },
|
||||
@@ -161,6 +171,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "checkbox_list",
|
||||
tooltip: dictionary.checkboxList,
|
||||
shortcut: `⇧+Ctrl+7`,
|
||||
icon: <TodoListIcon />,
|
||||
keywords: "checklist checkbox task",
|
||||
active: isNodeActive(schema.nodes.checkbox_list),
|
||||
@@ -169,6 +180,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "bullet_list",
|
||||
tooltip: dictionary.bulletList,
|
||||
shortcut: `⇧+Ctrl+8`,
|
||||
icon: <BulletedListIcon />,
|
||||
active: isNodeActive(schema.nodes.bullet_list),
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
@@ -176,6 +188,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "ordered_list",
|
||||
tooltip: dictionary.orderedList,
|
||||
shortcut: `⇧+Ctrl+9`,
|
||||
icon: <OrderedListIcon />,
|
||||
active: isNodeActive(schema.nodes.ordered_list),
|
||||
visible: !isCodeBlock && (!isMobile || isEmpty),
|
||||
@@ -183,6 +196,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "outdentList",
|
||||
tooltip: dictionary.outdent,
|
||||
shortcut: `⇧+Tab`,
|
||||
icon: <OutdentIcon />,
|
||||
visible:
|
||||
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
@@ -190,6 +204,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "indentList",
|
||||
tooltip: dictionary.indent,
|
||||
shortcut: `Tab`,
|
||||
icon: <IndentIcon />,
|
||||
visible:
|
||||
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
|
||||
@@ -197,12 +212,14 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "outdentCheckboxList",
|
||||
tooltip: dictionary.outdent,
|
||||
shortcut: `⇧+Tab`,
|
||||
icon: <OutdentIcon />,
|
||||
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
|
||||
},
|
||||
{
|
||||
name: "indentCheckboxList",
|
||||
tooltip: dictionary.indent,
|
||||
shortcut: `Tab`,
|
||||
icon: <IndentIcon />,
|
||||
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
|
||||
},
|
||||
@@ -213,6 +230,7 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "link",
|
||||
tooltip: dictionary.createLink,
|
||||
shortcut: `${metaDisplay}+K`,
|
||||
icon: <LinkIcon />,
|
||||
attrs: { href: "" },
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
@@ -220,9 +238,14 @@ export default function formattingMenuItems(
|
||||
{
|
||||
name: "comment",
|
||||
tooltip: dictionary.comment,
|
||||
shortcut: `${metaDisplay}+⌥+M`,
|
||||
icon: <CommentIcon />,
|
||||
label: isCodeBlock ? dictionary.comment : undefined,
|
||||
active: isMarkActive(schema.marks.comment, { resolved: false }),
|
||||
active: isMarkActive(
|
||||
schema.marks.comment,
|
||||
{ resolved: false },
|
||||
{ exact: true }
|
||||
),
|
||||
visible: !isMobile || !isEmpty,
|
||||
},
|
||||
{
|
||||
@@ -233,6 +256,7 @@ export default function formattingMenuItems(
|
||||
name: "copyToClipboard",
|
||||
icon: <CopyIcon />,
|
||||
tooltip: dictionary.copy,
|
||||
shortcut: `${metaDisplay}+C`,
|
||||
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function useDictionary() {
|
||||
em: t("Italic"),
|
||||
embedInvalidLink: t("Sorry, that link won’t work for this embed type"),
|
||||
file: t("File attachment"),
|
||||
findOrCreateDoc: `${t("Paste a link, search, or create")}…`,
|
||||
enterLink: `${t("Enter a link")}…`,
|
||||
h1: t("Big heading"),
|
||||
h2: t("Medium heading"),
|
||||
h3: t("Small heading"),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import { isDocumentUrl, isInternalUrl } from "@shared/utils/urls";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { isHash } from "~/utils/urls";
|
||||
import useStores from "./useStores";
|
||||
|
||||
type Params = {
|
||||
/** The share ID of the document being viewed, if any */
|
||||
@@ -12,8 +13,9 @@ type Params = {
|
||||
|
||||
export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
const history = useHistory();
|
||||
const { documents } = useStores();
|
||||
const handleClickLink = React.useCallback(
|
||||
(href: string, event: MouseEvent) => {
|
||||
(href: string, event?: MouseEvent) => {
|
||||
// on page hash
|
||||
if (isHash(href)) {
|
||||
window.location.href = href;
|
||||
@@ -49,14 +51,21 @@ export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
navigateTo = sharedDocumentPath(shareId, navigateTo);
|
||||
}
|
||||
|
||||
if (isDocumentUrl(navigateTo)) {
|
||||
const document = documents.getByUrl(navigateTo);
|
||||
if (document) {
|
||||
navigateTo = document.path;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're navigating to a share link from a non-share link then open it in a new tab
|
||||
if (!shareId && navigateTo.startsWith("/s/")) {
|
||||
window.open(href, "_blank");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isModKey(event) && !event.shiftKey) {
|
||||
history.push(navigateTo);
|
||||
if (!event || (!isModKey(event) && !event.shiftKey)) {
|
||||
history.push(navigateTo, { sidebarContext: "collections" }); // optimistic preference of "collections"
|
||||
} else {
|
||||
window.open(navigateTo, "_blank");
|
||||
}
|
||||
|
||||
@@ -45,12 +45,16 @@ export default function useImportDocument(
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const doc = await documents.import(file, documentId, cId, {
|
||||
publish: true,
|
||||
});
|
||||
try {
|
||||
const doc = await documents.import(file, documentId, cId, {
|
||||
publish: true,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
history.push(documentPath(doc));
|
||||
if (redirect) {
|
||||
history.push(documentPath(doc));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import isTextInput from "~/utils/isTextInput";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
|
||||
type Callback = (event: KeyboardEvent) => void;
|
||||
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { SidebarContextType } from "../components/SidebarContext";
|
||||
import { SidebarContextType } from "../components/Sidebar/components/SidebarContext";
|
||||
|
||||
/**
|
||||
* Hook to retrieve the sidebar context from the current location state.
|
||||
*/
|
||||
export function useLocationState() {
|
||||
export function useLocationSidebarContext() {
|
||||
const location = useLocation<{
|
||||
sidebarContext?: SidebarContextType;
|
||||
}>();
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { Primitive } from "utility-types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { isBrowser } from "@shared/utils/browser";
|
||||
import Logger from "~/utils/Logger";
|
||||
import useEventListener from "./useEventListener";
|
||||
|
||||
@@ -41,7 +42,7 @@ export default function usePersistedState<T extends Primitive | object>(
|
||||
options?: Options
|
||||
): [T, (value: T) => void] {
|
||||
const [storedValue, setStoredValue] = React.useState(() => {
|
||||
if (typeof window === "undefined") {
|
||||
if (!isBrowser) {
|
||||
return defaultValue;
|
||||
}
|
||||
return Storage.get(key) ?? defaultValue;
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import React from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export default function useQuery() {
|
||||
return new URLSearchParams(useLocation().search);
|
||||
const location = useLocation();
|
||||
|
||||
const query = React.useMemo(
|
||||
() => new URLSearchParams(location.search),
|
||||
[location.search]
|
||||
);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ColumnSort } from "@tanstack/react-table";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import React from "react";
|
||||
import {
|
||||
FetchPageParams,
|
||||
PaginatedResponse,
|
||||
PAGINATION_SYMBOL,
|
||||
} from "~/stores/base/Store";
|
||||
import useRequest from "./useRequest";
|
||||
|
||||
const INITIAL_OFFSET = 0;
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
type Props<T> = {
|
||||
data: T[];
|
||||
sort: ColumnSort;
|
||||
reqFn: (params: FetchPageParams) => Promise<PaginatedResponse<T>>;
|
||||
reqParams: Omit<FetchPageParams, "offset" | "limit">;
|
||||
};
|
||||
|
||||
type Response<T> = {
|
||||
data: T[] | undefined;
|
||||
error: unknown;
|
||||
loading: boolean;
|
||||
next: (() => void) | undefined;
|
||||
};
|
||||
|
||||
export function useTableRequest<T extends { id: string }>({
|
||||
data,
|
||||
sort,
|
||||
reqFn,
|
||||
reqParams,
|
||||
}: Props<T>): Response<T> {
|
||||
const [total, setTotal] = React.useState<number>();
|
||||
const [offset, setOffset] = React.useState({ value: INITIAL_OFFSET });
|
||||
const prevParamsRef = React.useRef(reqParams);
|
||||
const sortRef = React.useRef<ColumnSort>(sort);
|
||||
|
||||
const fetchPage = React.useCallback(
|
||||
() => reqFn({ ...reqParams, offset: offset.value, limit: PAGE_SIZE }),
|
||||
[reqFn, reqParams, offset]
|
||||
);
|
||||
|
||||
const { request, loading, error } = useRequest(fetchPage);
|
||||
|
||||
const nextPage = React.useCallback(
|
||||
() =>
|
||||
setOffset((prev) => ({
|
||||
value: prev.value + PAGE_SIZE,
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
const sortedData = data
|
||||
? orderBy(data, sortRef.current.id, sortRef.current.desc ? "desc" : "asc")
|
||||
: undefined;
|
||||
|
||||
const next =
|
||||
!loading && total && sortedData && sortedData.length < total
|
||||
? nextPage
|
||||
: undefined;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prevParamsRef.current !== reqParams) {
|
||||
prevParamsRef.current = reqParams;
|
||||
setOffset({ value: INITIAL_OFFSET });
|
||||
return;
|
||||
}
|
||||
|
||||
let ignore = false;
|
||||
|
||||
const handleRequest = async () => {
|
||||
const response = await request();
|
||||
if (!response || ignore) {
|
||||
return;
|
||||
}
|
||||
|
||||
sortRef.current = sort; // Change sort once we receive a response from server - avoids flicker with stale data.
|
||||
setTotal(response[PAGINATION_SYMBOL]?.total);
|
||||
};
|
||||
|
||||
void handleRequest();
|
||||
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [sort, reqParams, offset, request]);
|
||||
|
||||
return {
|
||||
data: sortedData,
|
||||
error,
|
||||
loading,
|
||||
next,
|
||||
};
|
||||
}
|
||||
+15
-18
@@ -20,7 +20,6 @@ import { initI18n } from "~/utils/i18n";
|
||||
import Desktop from "./components/DesktopEventHandler";
|
||||
import LazyPolyfill from "./components/LazyPolyfills";
|
||||
import PageScroll from "./components/PageScroll";
|
||||
import { TooltipProvider } from "./components/TooltipContext";
|
||||
import Routes from "./routes";
|
||||
import Logger from "./utils/Logger";
|
||||
import { PluginManager } from "./utils/PluginManager";
|
||||
@@ -56,23 +55,21 @@ if (element) {
|
||||
<Theme>
|
||||
<ErrorBoundary showTitle>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<TooltipProvider>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<Router history={history}>
|
||||
<PageScroll>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<Desktop />
|
||||
</PageScroll>
|
||||
</Router>
|
||||
</LazyMotion>
|
||||
</LazyPolyfill>
|
||||
</TooltipProvider>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<Router history={history}>
|
||||
<PageScroll>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<Desktop />
|
||||
</PageScroll>
|
||||
</Router>
|
||||
</LazyMotion>
|
||||
</LazyPolyfill>
|
||||
</KBarProvider>
|
||||
</ErrorBoundary>
|
||||
</Theme>
|
||||
|
||||
@@ -123,7 +123,6 @@ function CollectionMenu({
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
ev.target.value = "";
|
||||
}
|
||||
|
||||
@@ -136,14 +136,14 @@ type MenuContentProps = {
|
||||
showToggleEmbeds?: boolean;
|
||||
};
|
||||
|
||||
const MenuContent: React.FC<MenuContentProps> = ({
|
||||
const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
|
||||
onOpen,
|
||||
onClose,
|
||||
onFindAndReplace,
|
||||
onRename,
|
||||
showDisplayOptions,
|
||||
showToggleEmbeds,
|
||||
}) => {
|
||||
}) {
|
||||
const user = useCurrentUser();
|
||||
const { model: document, menuState } = useMenuContext<Document>();
|
||||
const can = usePolicy(document);
|
||||
@@ -348,7 +348,7 @@ const MenuContent: React.FC<MenuContentProps> = ({
|
||||
)}
|
||||
</ContextMenu>
|
||||
) : null;
|
||||
};
|
||||
});
|
||||
|
||||
function DocumentMenu({
|
||||
document,
|
||||
|
||||
+47
-24
@@ -4,44 +4,57 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Group from "~/models/Group";
|
||||
import GroupDelete from "~/scenes/GroupDelete";
|
||||
import GroupEdit from "~/scenes/GroupEdit";
|
||||
import {
|
||||
DeleteGroupDialog,
|
||||
EditGroupDialog,
|
||||
ViewGroupMembersDialog,
|
||||
} from "~/scenes/Settings/components/GroupDialogs";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import Modal from "~/components/Modal";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
onMembers: () => void;
|
||||
};
|
||||
|
||||
function GroupMenu({ group, onMembers }: Props) {
|
||||
function GroupMenu({ group }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const [editModalOpen, setEditModalOpen] = React.useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
|
||||
const can = usePolicy(group);
|
||||
|
||||
const handleViewMembers = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={group} />,
|
||||
fullscreen: true,
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleEditGroup = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Edit group"),
|
||||
content: (
|
||||
<EditGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleDeleteGroup = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete group"),
|
||||
content: (
|
||||
<DeleteGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Edit group")}
|
||||
onRequestClose={() => setEditModalOpen(false)}
|
||||
isOpen={editModalOpen}
|
||||
>
|
||||
<GroupEdit group={group} onSubmit={() => setEditModalOpen(false)} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Delete group")}
|
||||
onRequestClose={() => setDeleteModalOpen(false)}
|
||||
isOpen={deleteModalOpen}
|
||||
>
|
||||
<GroupDelete group={group} onSubmit={() => setDeleteModalOpen(false)} />
|
||||
</Modal>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Group options")}>
|
||||
<Template
|
||||
@@ -51,7 +64,7 @@ function GroupMenu({ group, onMembers }: Props) {
|
||||
type: "button",
|
||||
title: `${t("Members")}…`,
|
||||
icon: <GroupIcon />,
|
||||
onClick: onMembers,
|
||||
onClick: handleViewMembers,
|
||||
visible: !!(group && can.read),
|
||||
},
|
||||
{
|
||||
@@ -61,7 +74,7 @@ function GroupMenu({ group, onMembers }: Props) {
|
||||
type: "button",
|
||||
title: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
onClick: () => setEditModalOpen(true),
|
||||
onClick: handleEditGroup,
|
||||
visible: !!(group && can.update),
|
||||
},
|
||||
{
|
||||
@@ -69,9 +82,19 @@ function GroupMenu({ group, onMembers }: Props) {
|
||||
title: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
onClick: () => setDeleteModalOpen(true),
|
||||
onClick: handleDeleteGroup,
|
||||
visible: !!(group && can.delete),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
href: "",
|
||||
title: group.externalId,
|
||||
disabled: true,
|
||||
visible: !!group.externalId,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -3,14 +3,13 @@ import { MoreIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import { toggleViewerInsights } from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { hover } from "~/styles";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
const InsightsMenu: React.FC = () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user