mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93482739f6 | |||
| fce6ec8372 | |||
| 86ec0acf30 | |||
| 72ec267a9c | |||
| e0cffaca12 | |||
| fb26e4a88e | |||
| 0a27882748 | |||
| cde2508871 | |||
| 556cc6a502 | |||
| 06b51ff62f | |||
| 855e7fd30d | |||
| 9264d5ab00 | |||
| 086ad546cc | |||
| 00ef7c46f6 | |||
| e1a12fd78b | |||
| 0b1fb7fb2e | |||
| 4512c6558c | |||
| c5b4608e4e |
@@ -1,9 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { PullRequestIcon } from "../Icons/PullRequestIcon";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import {
|
||||
|
||||
+1
-71
@@ -1,73 +1,3 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** The type of text to render */
|
||||
type?: "secondary" | "tertiary" | "danger";
|
||||
/** The size of the text */
|
||||
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
|
||||
/** The direction of the text (defaults to ltr) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the text should be selectable (defaults to false) */
|
||||
selectable?: boolean;
|
||||
/** The font weight of the text */
|
||||
weight?: "xbold" | "bold" | "normal";
|
||||
/** Whether the text should be italic */
|
||||
italic?: boolean;
|
||||
/** Whether the text should be truncated with an ellipsis */
|
||||
ellipsis?: boolean;
|
||||
/** Whether the text should be monospaced */
|
||||
monospace?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this component for all interface text that should not be selectable
|
||||
* by the user, this is the majority of UI text explainers, notes, headings.
|
||||
*/
|
||||
const Text = styled.span<Props>`
|
||||
margin-top: 0;
|
||||
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
|
||||
color: ${(props) =>
|
||||
props.type === "secondary"
|
||||
? props.theme.textSecondary
|
||||
: props.type === "tertiary"
|
||||
? props.theme.textTertiary
|
||||
: props.type === "danger"
|
||||
? props.theme.brand.red
|
||||
: props.theme.text};
|
||||
font-size: ${(props) =>
|
||||
props.size === "xlarge"
|
||||
? "26px"
|
||||
: props.size === "large"
|
||||
? "18px"
|
||||
: props.size === "medium"
|
||||
? "16px"
|
||||
: props.size === "small"
|
||||
? "14px"
|
||||
: props.size === "xsmall"
|
||||
? "13px"
|
||||
: "inherit"};
|
||||
|
||||
${(props) =>
|
||||
props.weight &&
|
||||
css`
|
||||
font-weight: ${props.weight === "xbold"
|
||||
? 600
|
||||
: props.weight === "bold"
|
||||
? 500
|
||||
: props.weight === "normal"
|
||||
? 400
|
||||
: "inherit"};
|
||||
`}
|
||||
|
||||
font-style: ${(props) => (props.italic ? "italic" : "normal")};
|
||||
font-family: ${(props) =>
|
||||
props.monospace ? props.theme.fontFamilyMono : "inherit"};
|
||||
|
||||
white-space: normal;
|
||||
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||
|
||||
${(props) => props.ellipsis && ellipsis()}
|
||||
`;
|
||||
import Text from "@shared/components/Text";
|
||||
|
||||
export default Text;
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { LinkIcon } from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import { EmailIcon, LinkIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v4 } from "uuid";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { determineMentionType, isURLMentionable } from "~/utils/mention";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
@@ -15,34 +23,65 @@ type Props = Omit<
|
||||
embeds: EmbedDescriptor[];
|
||||
};
|
||||
|
||||
const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { integrations } = useStores();
|
||||
const user = useCurrentUser();
|
||||
|
||||
let mentionType: MentionType | undefined;
|
||||
const url = pastedText ? new URL(pastedText) : undefined;
|
||||
|
||||
if (url) {
|
||||
const integration = integrations.find((intg: Integration) =>
|
||||
isURLMentionable({ url, integration: intg })
|
||||
);
|
||||
|
||||
mentionType = integration
|
||||
? determineMentionType({ url, integration })
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const embed = React.useMemo(() => {
|
||||
for (const e of embeds) {
|
||||
const matches = e.matcher(props.pastedText);
|
||||
const matches = e.matcher(pastedText);
|
||||
if (matches) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}, [embeds, props.pastedText]);
|
||||
}, [embeds, pastedText]);
|
||||
|
||||
const items = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "noop",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
],
|
||||
[embed, t]
|
||||
() =>
|
||||
[
|
||||
{
|
||||
name: "noop",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
{
|
||||
name: "mention",
|
||||
title: t("Mention"),
|
||||
icon: <EmailIcon />,
|
||||
visible: !!mentionType,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: mentionType,
|
||||
label: pastedText,
|
||||
href: pastedText,
|
||||
modelId: v4(),
|
||||
actorId: user.id,
|
||||
},
|
||||
appendSpace: true,
|
||||
},
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
] satisfies MenuItem[],
|
||||
[t, embed, mentionType, pastedText, user]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -52,9 +91,7 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={() => {
|
||||
props.onSelect?.(item);
|
||||
}}
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
@@ -63,6 +100,4 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasteMenu;
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import stores from "~/stores";
|
||||
import PasteMenu from "../components/PasteMenu";
|
||||
import { PasteMenu } from "../components/PasteMenu";
|
||||
|
||||
export default class PasteHandler extends Extension {
|
||||
state: {
|
||||
@@ -415,6 +415,21 @@ export default class PasteHandler extends Extension {
|
||||
});
|
||||
};
|
||||
|
||||
private insertMention = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const result = this.findPlaceholder(state, this.state.pastedText);
|
||||
|
||||
// Remove just the placeholder here.
|
||||
// Mention node will be created by SuggestionsMenu.
|
||||
if (result) {
|
||||
const tr = state.tr.deleteRange(result[0], result[1]);
|
||||
view.dispatch(
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private removePlaceholder = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
@@ -450,6 +465,11 @@ export default class PasteHandler extends Extension {
|
||||
this.insertEmbed();
|
||||
break;
|
||||
}
|
||||
case "mention": {
|
||||
this.hidePasteMenu();
|
||||
this.insertMention();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
import * as React from "react";
|
||||
import useIsMounted from "@shared/hooks/useIsMounted";
|
||||
|
||||
/**
|
||||
* Hook to check if component is still mounted
|
||||
*
|
||||
* @returns {boolean} true if the component is mounted, false otherwise
|
||||
*/
|
||||
export default function useIsMounted() {
|
||||
const isMounted = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return React.useCallback(() => isMounted.current, []);
|
||||
}
|
||||
export default useIsMounted;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { observable } from "mobx";
|
||||
import type {
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationSettings,
|
||||
IntegrationType,
|
||||
type IntegrationSettings,
|
||||
type IntegrationType,
|
||||
} from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import Model from "~/models/base/Model";
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import Spinner from "@shared/components/Spinner";
|
||||
import {
|
||||
FileOperationFormat,
|
||||
FileOperationState,
|
||||
@@ -13,7 +14,6 @@ import FileOperation from "~/models/FileOperation";
|
||||
import { Action } from "~/components/Actions";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Spinner from "~/components/Spinner";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -5,12 +5,12 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import Spinner from "@shared/components/Spinner";
|
||||
import { ImportState } from "@shared/types";
|
||||
import Import from "~/models/Import";
|
||||
import { Action } from "~/components/Actions";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Spinner from "~/components/Spinner";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationSettings,
|
||||
IntegrationType,
|
||||
MentionType,
|
||||
} from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
|
||||
export const isURLMentionable = ({
|
||||
url,
|
||||
integration,
|
||||
}: {
|
||||
url: URL;
|
||||
integration: Integration;
|
||||
}): boolean => {
|
||||
const { hostname, pathname } = url;
|
||||
const pathParts = pathname.split("/");
|
||||
|
||||
switch (integration.service) {
|
||||
case IntegrationService.GitHub: {
|
||||
const settings =
|
||||
integration.settings as IntegrationSettings<IntegrationType.Embed>;
|
||||
|
||||
return (
|
||||
hostname === "github.com" &&
|
||||
settings.github?.installation.account.name === pathParts[1] // ensure installed org/account name matches with the provided url.
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const determineMentionType = ({
|
||||
url,
|
||||
integration,
|
||||
}: {
|
||||
url: URL;
|
||||
integration: Integration;
|
||||
}): MentionType | undefined => {
|
||||
const { pathname } = url;
|
||||
const pathParts = pathname.split("/");
|
||||
|
||||
switch (integration.service) {
|
||||
case IntegrationService.GitHub: {
|
||||
const type = pathParts[3];
|
||||
return type === "pull"
|
||||
? MentionType.PullRequest
|
||||
: type === "issues"
|
||||
? MentionType.Issue
|
||||
: undefined;
|
||||
}
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import compact from "lodash/compact";
|
||||
import flatten from "lodash/flatten";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import isMatch from "lodash/isMatch";
|
||||
import uniq from "lodash/uniq";
|
||||
import { Node, DOMSerializer, Fragment } from "prosemirror-model";
|
||||
import * as React from "react";
|
||||
@@ -12,7 +12,7 @@ import * as Y from "yjs";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import GlobalStyles from "@shared/styles/globals";
|
||||
import light from "@shared/styles/theme";
|
||||
import { MentionType, ProsemirrorData } from "@shared/types";
|
||||
import { MentionType, ProsemirrorData, UnfurlResponse } from "@shared/types";
|
||||
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
@@ -42,6 +42,8 @@ export type MentionAttrs = {
|
||||
modelId: string;
|
||||
actorId: string | undefined;
|
||||
id: string;
|
||||
href?: string;
|
||||
unfurl?: UnfurlResponse[keyof UnfurlResponse];
|
||||
};
|
||||
|
||||
@trace()
|
||||
@@ -194,7 +196,7 @@ export class ProsemirrorHelper {
|
||||
node.descendants((childNode: Node) => {
|
||||
if (
|
||||
childNode.type.name === "mention" &&
|
||||
isEqual(childNode.attrs, mention)
|
||||
isMatch(childNode.attrs, mention)
|
||||
) {
|
||||
foundMention = true;
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { ellipsis } from "../styles";
|
||||
|
||||
type Props = {
|
||||
/** The type of text to render */
|
||||
type?: "secondary" | "tertiary" | "danger";
|
||||
/** The size of the text */
|
||||
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
|
||||
/** The direction of the text (defaults to ltr) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the text should be selectable (defaults to false) */
|
||||
selectable?: boolean;
|
||||
/** The font weight of the text */
|
||||
weight?: "xbold" | "bold" | "normal";
|
||||
/** Whether the text should be italic */
|
||||
italic?: boolean;
|
||||
/** Whether the text should be truncated with an ellipsis */
|
||||
ellipsis?: boolean;
|
||||
/** Whether the text should be monospaced */
|
||||
monospace?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this component for all interface text that should not be selectable
|
||||
* by the user, this is the majority of UI text explainers, notes, headings.
|
||||
*/
|
||||
const Text = styled.span<Props>`
|
||||
margin-top: 0;
|
||||
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
|
||||
color: ${(props) =>
|
||||
props.type === "secondary"
|
||||
? props.theme.textSecondary
|
||||
: props.type === "tertiary"
|
||||
? props.theme.textTertiary
|
||||
: props.type === "danger"
|
||||
? props.theme.brand.red
|
||||
: props.theme.text};
|
||||
font-size: ${(props) =>
|
||||
props.size === "xlarge"
|
||||
? "26px"
|
||||
: props.size === "large"
|
||||
? "18px"
|
||||
: props.size === "medium"
|
||||
? "16px"
|
||||
: props.size === "small"
|
||||
? "14px"
|
||||
: props.size === "xsmall"
|
||||
? "13px"
|
||||
: "inherit"};
|
||||
|
||||
${(props) =>
|
||||
props.weight &&
|
||||
css`
|
||||
font-weight: ${props.weight === "xbold"
|
||||
? 600
|
||||
: props.weight === "bold"
|
||||
? 500
|
||||
: props.weight === "normal"
|
||||
? 400
|
||||
: "inherit"};
|
||||
`}
|
||||
|
||||
font-style: ${(props) => (props.italic ? "italic" : "normal")};
|
||||
font-family: ${(props) =>
|
||||
props.monospace ? props.theme.fontFamilyMono : "inherit"};
|
||||
|
||||
white-space: normal;
|
||||
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||
|
||||
${(props) => props.ellipsis && ellipsis()}
|
||||
`;
|
||||
|
||||
export default Text;
|
||||
@@ -1,17 +1,49 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon, EmailIcon, CollectionIcon } from "outline-icons";
|
||||
import {
|
||||
DocumentIcon,
|
||||
EmailIcon,
|
||||
CollectionIcon,
|
||||
WarningIcon,
|
||||
} from "outline-icons";
|
||||
import { Node } from "prosemirror-model";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Flex from "../../components/Flex";
|
||||
import Icon from "../../components/Icon";
|
||||
import { IssueStatusIcon } from "../../components/IssueStatusIcon";
|
||||
import { PullRequestIcon } from "../../components/PullRequestIcon";
|
||||
import Spinner from "../../components/Spinner";
|
||||
import Text from "../../components/Text";
|
||||
import useIsMounted from "../../hooks/useIsMounted";
|
||||
import useStores from "../../hooks/useStores";
|
||||
import theme from "../../styles/theme";
|
||||
import type {
|
||||
JSONValue,
|
||||
UnfurlResourceType,
|
||||
UnfurlResponse,
|
||||
} from "../../types";
|
||||
import { cn } from "../styles/utils";
|
||||
import { ComponentProps } from "../types";
|
||||
|
||||
const getAttributesFromNode = (node: Node) => {
|
||||
const spec = node.type.spec.toDOM?.(node) as any as Record<string, string>[];
|
||||
const { class: className, ...attrs } = spec[1];
|
||||
return { className, ...attrs };
|
||||
type Attrs = {
|
||||
className: string;
|
||||
unfurl?: UnfurlResponse[keyof UnfurlResponse];
|
||||
} & Record<string, JSONValue>;
|
||||
|
||||
const getAttributesFromNode = (node: Node): Attrs => {
|
||||
const spec = node.type.spec.toDOM?.(node) as any as Record<
|
||||
string,
|
||||
JSONValue
|
||||
>[];
|
||||
const { class: className, "data-unfurl": unfurl, ...attrs } = spec[1];
|
||||
|
||||
return {
|
||||
className: className as Attrs["className"],
|
||||
unfurl: unfurl ? (JSON.parse(unfurl as any) as Attrs["unfurl"]) : undefined,
|
||||
...attrs,
|
||||
};
|
||||
};
|
||||
|
||||
export const MentionUser = observer(function MentionUser_(
|
||||
@@ -20,7 +52,7 @@ export const MentionUser = observer(function MentionUser_(
|
||||
const { isSelected, node } = props;
|
||||
const { users } = useStores();
|
||||
const user = users.get(node.attrs.modelId);
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -42,7 +74,7 @@ export const MentionDocument = observer(function MentionDocument_(
|
||||
const { documents } = useStores();
|
||||
const doc = documents.get(node.attrs.modelId);
|
||||
const modelId = node.attrs.modelId;
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (modelId) {
|
||||
@@ -75,7 +107,7 @@ export const MentionCollection = observer(function MentionCollection_(
|
||||
const { collections } = useStores();
|
||||
const collection = collections.get(node.attrs.modelId);
|
||||
const modelId = node.attrs.modelId;
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (modelId) {
|
||||
@@ -100,3 +132,183 @@ export const MentionCollection = observer(function MentionCollection_(
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
type IssuePrProps = ComponentProps & {
|
||||
onChangeUnfurl: (
|
||||
unfurl:
|
||||
| UnfurlResponse[UnfurlResourceType.Issue]
|
||||
| UnfurlResponse[UnfurlResourceType.PR]
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const MentionIssue = observer((props: IssuePrProps) => {
|
||||
const { unfurls } = useStores();
|
||||
const isMounted = useIsMounted();
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
|
||||
|
||||
const { isSelected, node } = props;
|
||||
const {
|
||||
className,
|
||||
unfurl: unfurlAttr,
|
||||
...attrs
|
||||
} = getAttributesFromNode(node);
|
||||
|
||||
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchIssue = async () => {
|
||||
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
|
||||
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (unfurlModel) {
|
||||
onChangeUnfurl({
|
||||
...unfurlModel.data,
|
||||
description: null,
|
||||
} satisfies UnfurlResponse[UnfurlResourceType.Issue]);
|
||||
}
|
||||
|
||||
setLoaded(true);
|
||||
};
|
||||
|
||||
void fetchIssue();
|
||||
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
|
||||
|
||||
if (!unfurl) {
|
||||
return !loaded ? (
|
||||
<MentionLoading className={className} />
|
||||
) : (
|
||||
<MentionError className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
const issue = unfurl as UnfurlResponse[UnfurlResourceType.Issue];
|
||||
|
||||
return (
|
||||
<a
|
||||
{...attrs}
|
||||
className={cn(className, {
|
||||
"ProseMirror-selectednode": isSelected,
|
||||
})}
|
||||
href={attrs.href as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<Flex align="center" gap={6}>
|
||||
<IssueStatusIcon
|
||||
size={14}
|
||||
status={issue.state.name}
|
||||
color={issue.state.color}
|
||||
/>
|
||||
<Flex align="center" gap={4}>
|
||||
<Text>{issue.title}</Text>
|
||||
<Text type="tertiary">{issue.id}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
export const MentionPullRequest = observer((props: IssuePrProps) => {
|
||||
const { unfurls } = useStores();
|
||||
const isMounted = useIsMounted();
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
|
||||
|
||||
const { isSelected, node } = props;
|
||||
const {
|
||||
className,
|
||||
unfurl: unfurlAttr,
|
||||
...attrs
|
||||
} = getAttributesFromNode(node);
|
||||
|
||||
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchPR = async () => {
|
||||
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
|
||||
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (unfurlModel) {
|
||||
onChangeUnfurl({
|
||||
...unfurlModel.data,
|
||||
description: null,
|
||||
} satisfies UnfurlResponse[UnfurlResourceType.PR]);
|
||||
}
|
||||
|
||||
setLoaded(true);
|
||||
};
|
||||
|
||||
void fetchPR();
|
||||
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
|
||||
|
||||
const sharedProps = {
|
||||
className: cn(className, {
|
||||
"ProseMirror-selectednode": isSelected,
|
||||
}),
|
||||
};
|
||||
|
||||
if (!unfurl) {
|
||||
return !loaded ? (
|
||||
<MentionLoading {...sharedProps} />
|
||||
) : (
|
||||
<MentionError {...sharedProps} />
|
||||
);
|
||||
}
|
||||
|
||||
const pullRequest = unfurl as UnfurlResponse[UnfurlResourceType.PR];
|
||||
|
||||
return (
|
||||
<a
|
||||
{...attrs}
|
||||
{...sharedProps}
|
||||
href={attrs.href as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<Flex align="center" gap={6}>
|
||||
<PullRequestIcon
|
||||
size={14}
|
||||
status={pullRequest.state.name}
|
||||
color={pullRequest.state.color}
|
||||
/>
|
||||
<Flex align="center" gap={4}>
|
||||
<Text>{pullRequest.title}</Text>
|
||||
<Text type="tertiary">{pullRequest.id}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
const MentionLoading = ({ className }: { className: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
<Spinner />
|
||||
<Text type="tertiary">{`${t("Loading")}…`}</Text>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const MentionError = ({ className }: { className: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
<StyledWarningIcon size={20} color={theme.danger} />
|
||||
<Text type="secondary">{`${t("Error loading data")}`}</Text>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledWarningIcon = styled(WarningIcon)`
|
||||
margin: 0 -2px;
|
||||
`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import isMatch from "lodash/isMatch";
|
||||
import { Token } from "markdown-it";
|
||||
import {
|
||||
NodeSpec,
|
||||
@@ -15,10 +16,12 @@ import * as React from "react";
|
||||
import { Primitive } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import env from "../../env";
|
||||
import { MentionType } from "../../types";
|
||||
import { MentionType, UnfurlResourceType, UnfurlResponse } from "../../types";
|
||||
import {
|
||||
MentionCollection,
|
||||
MentionDocument,
|
||||
MentionIssue,
|
||||
MentionPullRequest,
|
||||
MentionUser,
|
||||
} from "../components/Mentions";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
@@ -50,6 +53,12 @@ export default class Mention extends Node {
|
||||
id: {
|
||||
default: undefined,
|
||||
},
|
||||
href: {
|
||||
default: undefined,
|
||||
},
|
||||
unfurl: {
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
inline: true,
|
||||
marks: "",
|
||||
@@ -73,6 +82,10 @@ export default class Mention extends Node {
|
||||
actorId: dom.dataset.actorid,
|
||||
label: dom.innerText,
|
||||
id: dom.id,
|
||||
href: dom.getAttribute("href"),
|
||||
unfurl: dom.dataset.unfurl
|
||||
? JSON.parse(dom.dataset.unfurl)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -87,11 +100,18 @@ export default class Mention extends Node {
|
||||
? undefined
|
||||
: node.attrs.type === MentionType.Document
|
||||
? `${env.URL}/doc/${node.attrs.modelId}`
|
||||
: `${env.URL}/collection/${node.attrs.modelId}`,
|
||||
: node.attrs.type === MentionType.Collection
|
||||
? `${env.URL}/collection/${node.attrs.modelId}`
|
||||
: node.attrs.href,
|
||||
"data-type": node.attrs.type,
|
||||
"data-id": node.attrs.modelId,
|
||||
"data-actorid": node.attrs.actorId,
|
||||
"data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
|
||||
"data-url":
|
||||
node.attrs.type === MentionType.PullRequest ||
|
||||
node.attrs.type === MentionType.Issue
|
||||
? node.attrs.href
|
||||
: `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
|
||||
"data-unfurl": JSON.stringify(node.attrs.unfurl),
|
||||
},
|
||||
toPlainText(node),
|
||||
],
|
||||
@@ -107,6 +127,20 @@ export default class Mention extends Node {
|
||||
return <MentionDocument {...props} />;
|
||||
case MentionType.Collection:
|
||||
return <MentionCollection {...props} />;
|
||||
case MentionType.Issue:
|
||||
return (
|
||||
<MentionIssue
|
||||
{...props}
|
||||
onChangeUnfurl={this.handleChangeUnfurl(props)}
|
||||
/>
|
||||
);
|
||||
case MentionType.PullRequest:
|
||||
return (
|
||||
<MentionPullRequest
|
||||
{...props}
|
||||
onChangeUnfurl={this.handleChangeUnfurl(props)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -149,29 +183,42 @@ export default class Mention extends Node {
|
||||
}
|
||||
|
||||
keys(): Record<string, Command> {
|
||||
const NavigableMention = [
|
||||
MentionType.Collection,
|
||||
MentionType.Document,
|
||||
MentionType.Issue,
|
||||
MentionType.PullRequest,
|
||||
];
|
||||
|
||||
return {
|
||||
Enter: (state) => {
|
||||
const { selection } = state;
|
||||
if (
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === this.name &&
|
||||
(selection.node.attrs.type === MentionType.Document ||
|
||||
selection.node.attrs.type === MentionType.Collection)
|
||||
NavigableMention.includes(selection.node.attrs.type)
|
||||
) {
|
||||
const { modelId } = selection.node.attrs;
|
||||
const mentionType = selection.node.attrs.type;
|
||||
|
||||
const linkType =
|
||||
selection.node.attrs.type === MentionType.Document
|
||||
? "doc"
|
||||
: selection.node.attrs.type === MentionType.Collection
|
||||
? "collection"
|
||||
: undefined;
|
||||
let link: string;
|
||||
|
||||
if (!linkType) {
|
||||
return false;
|
||||
if (
|
||||
mentionType === MentionType.Issue ||
|
||||
mentionType === MentionType.PullRequest
|
||||
) {
|
||||
link = selection.node.attrs.href;
|
||||
} else {
|
||||
const { modelId } = selection.node.attrs;
|
||||
|
||||
const linkType =
|
||||
selection.node.attrs.type === MentionType.Document
|
||||
? "doc"
|
||||
: "collection";
|
||||
|
||||
link = `/${linkType}/${modelId}`;
|
||||
}
|
||||
|
||||
this.editor.props.onClickLink?.(`/${linkType}/${modelId}`);
|
||||
this.editor.props.onClickLink?.(link);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -218,4 +265,30 @@ export default class Mention extends Node {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
handleChangeUnfurl =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(unfurl: UnfurlResponse[keyof UnfurlResponse]) => {
|
||||
const { view } = this.editor;
|
||||
const { tr } = view.state;
|
||||
|
||||
const label =
|
||||
unfurl.type === UnfurlResourceType.Issue ||
|
||||
unfurl.type === UnfurlResourceType.PR
|
||||
? unfurl.title
|
||||
: undefined;
|
||||
|
||||
const overrides: Record<string, unknown> = label ? { label } : {};
|
||||
overrides.unfurl = unfurl;
|
||||
|
||||
const pos = getPos();
|
||||
|
||||
if (!isMatch(node.attrs, overrides)) {
|
||||
const transaction = tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
...overrides,
|
||||
});
|
||||
view.dispatch(transaction);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Hook to check if component is still mounted
|
||||
*
|
||||
* @returns {boolean} true if the component is mounted, false otherwise
|
||||
*/
|
||||
export default function useIsMounted() {
|
||||
const isMounted = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return React.useCallback(() => isMounted.current, []);
|
||||
}
|
||||
@@ -428,6 +428,7 @@
|
||||
"Create a new doc": "Create a new doc",
|
||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
|
||||
"Keep as link": "Keep as link",
|
||||
"Mention": "Mention",
|
||||
"Embed": "Embed",
|
||||
"Add column after": "Add column after",
|
||||
"Add column before": "Add column before",
|
||||
@@ -1155,5 +1156,6 @@
|
||||
"You updated {{ timeAgo }}": "You updated {{ timeAgo }}",
|
||||
"{{ user }} updated {{ timeAgo }}": "{{ user }} updated {{ timeAgo }}",
|
||||
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}"
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
|
||||
"Error loading data": "Error loading data"
|
||||
}
|
||||
|
||||
+4
-2
@@ -75,6 +75,8 @@ export enum MentionType {
|
||||
User = "user",
|
||||
Document = "document",
|
||||
Collection = "collection",
|
||||
Issue = "issue",
|
||||
PullRequest = "pull_request",
|
||||
}
|
||||
|
||||
export type PublicEnv = {
|
||||
@@ -416,7 +418,7 @@ export type UnfurlResponse = {
|
||||
/** Issue title */
|
||||
title: string;
|
||||
/** Issue description */
|
||||
description: string;
|
||||
description: string | null;
|
||||
/** Issue's author */
|
||||
author: { name: string; avatarUrl: string };
|
||||
/** Issue's labels */
|
||||
@@ -436,7 +438,7 @@ export type UnfurlResponse = {
|
||||
/** Pull Request title */
|
||||
title: string;
|
||||
/** Pull Request description */
|
||||
description: string;
|
||||
description: string | null;
|
||||
/** Pull Request author */
|
||||
author: { name: string; avatarUrl: string };
|
||||
/** Pull Request status */
|
||||
|
||||
Reference in New Issue
Block a user