Compare commits

...

18 Commits

Author SHA1 Message Date
Tom Moor 93482739f6 Merge main 2025-04-12 18:58:06 -04:00
Tom Moor fce6ec8372 Switch order in paste menu 2025-04-12 18:56:06 -04:00
Tom Moor 86ec0acf30 fix: Copy/paste of issue mentions
fix: Icon alignment
fix: Error and loading mentions are unselectable
2025-04-12 18:52:22 -04:00
Tom Moor 72ec267a9c Optimize lodash isMatch import statement 2025-04-11 15:50:00 -07:00
hmacr e0cffaca12 use unfurl from store 2025-04-11 16:05:07 +05:30
hmacr fb26e4a88e Merge branch 'main' into hmacr/8506-github-mention 2025-04-11 11:26:48 +05:30
hmacr 0a27882748 delete local UnfurlsStore 2025-04-11 11:26:32 +05:30
hmacr cde2508871 update node when pos is available 2025-04-09 13:51:53 +05:30
hmacr 556cc6a502 base feedback 2025-04-09 11:41:43 +05:30
hmacr 06b51ff62f test fix 2025-04-02 22:05:42 +05:30
hmacr 855e7fd30d simplify mention code creation 2025-04-02 21:33:50 +05:30
hmacr 9264d5ab00 store unfurl in mention attrs 2025-04-02 20:57:58 +05:30
hmacr 086ad546cc tidy 2025-04-02 16:54:30 +05:30
hmacr 00ef7c46f6 handle multiple creation error 2025-04-02 16:44:41 +05:30
hmacr e1a12fd78b tweak mention display 2025-04-02 15:58:32 +05:30
hmacr 0b1fb7fb2e error node 2025-04-02 15:38:37 +05:30
hmacr 4512c6558c pr and loading works 2025-04-02 15:12:09 +05:30
hmacr c5b4608e4e mention issue works 2025-04-02 14:53:04 +05:30
20 changed files with 560 additions and 150 deletions
@@ -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
View File
@@ -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;
+59 -24
View File
@@ -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;
});
+21 -1
View File
@@ -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;
}
+2 -18
View File
@@ -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;
+3 -3
View File
@@ -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";
+58
View File
@@ -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;
}
};
+5 -3
View File
@@ -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;
+73
View File
@@ -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;
+220 -8
View File
@@ -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;
`;
+88 -15
View File
@@ -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);
}
};
}
+19
View File
@@ -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, []);
}
+3 -1
View File
@@ -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
View File
@@ -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 */