Show paste menu to convert a list of URLs to mentions (#9646)

This commit is contained in:
Hemachandar
2025-07-17 05:55:24 +05:30
committed by GitHub
parent c7231ffd8b
commit 4c1caf6025
4 changed files with 320 additions and 82 deletions
+113 -58
View File
@@ -20,71 +20,18 @@ type Props = Omit<
SuggestionsMenuProps,
"renderMenuItem" | "items" | "embeds" | "trigger"
> & {
pastedText: string;
pastedText: string | string[];
embeds: EmbedDescriptor[];
};
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const { t } = useTranslation();
const { integrations } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
const items = useItems({ pastedText, embeds });
let mentionType: MentionType | undefined;
if (pastedText && isUrl(pastedText)) {
const url = new URL(pastedText);
const integration = integrations.find((intg: Integration) =>
isURLMentionable({ url, integration: intg })
);
mentionType = integration
? determineMentionType({ url, integration })
: undefined;
if (!items) {
props.onClose();
return null;
}
const embed = React.useMemo(() => {
for (const e of embeds) {
const matches = e.matcher(pastedText);
if (matches) {
return e;
}
}
return;
}, [embeds, pastedText]);
const items = React.useMemo(
() =>
[
{
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 (
<SuggestionsMenu
{...props}
@@ -102,3 +49,111 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
/>
);
});
function useItems({
pastedText,
embeds,
}: Pick<Props, "pastedText" | "embeds">): MenuItem[] | undefined {
const { t } = useTranslation();
const { integrations } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
const embed = React.useMemo(() => {
if (typeof pastedText === "string") {
for (const e of embeds) {
const matches = e.matcher(pastedText);
if (matches) {
return e;
}
}
}
return;
}, [embeds, pastedText]);
// single item is pasted.
if (typeof pastedText === "string") {
let mentionType: MentionType | undefined;
if (pastedText && isUrl(pastedText)) {
const url = new URL(pastedText);
const integration = integrations.find((intg: Integration) =>
isURLMentionable({ url, integration: intg })
);
mentionType = integration
? determineMentionType({ url, integration })
: undefined;
}
return [
{
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,
},
];
}
const linksToMentionType: Record<string, MentionType> = {};
// list is pasted.
const convertibleToMentionList = pastedText.every((text) => {
if (!isUrl(text)) {
return false;
}
const url = new URL(text);
const integration = integrations.find((intg: Integration) =>
isURLMentionable({ url, integration: intg })
);
const mentionType = integration
? determineMentionType({ url, integration })
: undefined;
if (mentionType) {
linksToMentionType[text] = mentionType;
}
return !!mentionType;
});
// don't render the menu when it can't be converted to mention.
if (!convertibleToMentionList) {
return;
}
return [
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "mention_list",
title: t("Mention"),
icon: <EmailIcon />,
attrs: { actorId: user?.id, ...linksToMentionType },
},
];
}
+88 -9
View File
@@ -1,6 +1,6 @@
import { action, observable } from "mobx";
import { toggleMark } from "prosemirror-commands";
import { Slice } from "prosemirror-model";
import { Node, Slice } from "prosemirror-model";
import {
EditorState,
Plugin,
@@ -16,6 +16,7 @@ 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 { isList } from "@shared/editor/queries/isList";
import { MenuItem } from "@shared/editor/types";
import { IconType, MentionType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
@@ -29,7 +30,7 @@ export default class PasteHandler extends Extension {
state: {
open: boolean;
query: string;
pastedText: string;
pastedText: string | string[];
} = observable({
open: false,
query: "",
@@ -294,7 +295,12 @@ export default class PasteHandler extends Extension {
});
} else {
if (singleNode) {
tr.replaceSelectionWith(singleNode, this.shiftKey);
if (isList(singleNode, this.editor.schema)) {
this.handleList(singleNode);
return true;
} else {
tr.replaceSelectionWith(singleNode, this.shiftKey);
}
} else {
tr.replaceSelection(slice);
}
@@ -376,7 +382,7 @@ export default class PasteHandler extends Extension {
private shiftKey = false;
private showPasteMenu = action((text: string) => {
private showPasteMenu = action((text: string | string[]) => {
this.state.pastedText = text;
this.state.open = true;
});
@@ -402,7 +408,7 @@ export default class PasteHandler extends Extension {
private insertEmbed = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
const result = this.findPlaceholder(state, this.placeholderId());
if (result) {
const tr = state.tr.deleteRange(result[0], result[1]);
@@ -412,14 +418,14 @@ export default class PasteHandler extends Extension {
}
this.editor.commands.embed({
href: this.state.pastedText,
href: this.state.pastedText as string,
});
};
private insertMention = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
const result = this.findPlaceholder(state, this.placeholderId());
// Remove just the placeholder here.
// Mention node will be created by SuggestionsMenu.
@@ -431,15 +437,83 @@ export default class PasteHandler extends Extension {
}
};
private insertMentionList = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.placeholderId());
// Remove just the placeholder here.
// Mention list will be created by SuggestionsMenu.
if (result) {
const tr = state.tr.setMeta(this.key, {
remove: { id: this.placeholderId() },
});
view.dispatch(
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
);
}
};
private handleList(listNode: Node) {
const { view, schema } = this.editor;
const { state } = view;
const { from } = state.selection;
const links: string[] = [];
let allLinks = true;
listNode.descendants((node) => {
if (!allLinks) {
return false;
}
if (isList(node, schema) || node.type.name === "list_item") {
return true;
}
if (node.type.name === "paragraph" && isUrl(node.textContent)) {
links.push(node.textContent);
return false;
}
allLinks = false;
return false;
});
if (!allLinks || !links.length) {
return;
}
const placeholderId = links[0];
const to = from + listNode.nodeSize;
const transaction = state.tr
.replaceSelectionWith(listNode)
.setMeta(this.key, {
add: { from, to, id: placeholderId },
});
view.dispatch(transaction);
this.showPasteMenu(links);
}
private placeholderId = () =>
typeof this.state.pastedText === "string"
? this.state.pastedText
: this.state.pastedText[0];
private removePlaceholder = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
const placeholderId = this.placeholderId();
const result = this.findPlaceholder(state, placeholderId);
if (result) {
view.dispatch(
state.tr.setMeta(this.key, {
remove: { id: this.state.pastedText },
remove: { id: placeholderId },
})
);
}
@@ -471,6 +545,11 @@ export default class PasteHandler extends Extension {
this.insertMention();
break;
}
case "mention_list": {
this.hidePasteMenu();
this.insertMentionList();
break;
}
default:
break;
}
+56
View File
@@ -0,0 +1,56 @@
import { Node, Schema } from "prosemirror-model";
import { Primitive } from "utility-types";
import { v4 } from "uuid";
import { isList } from "../queries/isList";
export function transformListToMentions(
listNode: Node,
schema: Schema,
attrs: Record<string, Primitive>
): Node {
const childNodes: Node[] = [];
listNode.forEach((node) => {
childNodes.push(transformListItemToMentions(node, schema, attrs));
});
return listNode.type.create(listNode.attrs, childNodes);
}
function transformListItemToMentions(
listItemNode: Node,
schema: Schema,
attrs: Record<string, Primitive>
) {
const childNodes: Node[] = [];
listItemNode.forEach((node) => {
if (node.type.name === "paragraph") {
const link = node.textContent;
const mentionType = attrs[link];
if (mentionType) {
childNodes.push(
node.type.create(
node.attrs,
schema.nodes.mention.create({
id: v4(),
type: mentionType,
label: link,
href: link,
modelId: v4(),
actorId: attrs.actorId,
})
)
);
} else {
childNodes.push(node);
}
} else if (isList(node, schema)) {
const subListNode = transformListToMentions(node, schema, attrs);
childNodes.push(subListNode);
}
});
return listItemNode.type.create(listItemNode.attrs, childNodes);
}
+63 -15
View File
@@ -25,6 +25,10 @@ import {
MentionUser,
} from "../components/Mentions";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { transformListToMentions } from "../lib/mention";
import { findParentNodeClosestToPos } from "../queries/findParentNode";
import { isInList } from "../queries/isInList";
import { isList } from "../queries/isList";
import mentionRule from "../rules/mention";
import { ComponentProps } from "../types";
import Node from "./Node";
@@ -227,22 +231,66 @@ export default class Mention extends Node {
}
commands({ type }: { type: NodeType; schema: Schema }) {
return (attrs: Record<string, Primitive>): Command =>
(state, dispatch) => {
const { selection } = state;
const position =
selection instanceof TextSelection
? selection.$cursor?.pos
: selection.$to.pos;
if (position === undefined) {
return false;
}
return {
mention:
(attrs: Record<string, Primitive>): Command =>
(state, dispatch) => {
const { selection } = state;
const position =
selection instanceof TextSelection
? selection.$cursor?.pos
: selection.$to.pos;
if (position === undefined) {
return false;
}
const node = type.create(attrs);
const transaction = state.tr.insert(position, node);
dispatch?.(transaction);
return true;
};
const node = type.create(attrs);
const transaction = state.tr.insert(position, node);
dispatch?.(transaction);
return true;
},
mention_list:
(attrs: Record<string, Primitive>): Command =>
(state, dispatch) => {
const { selection } = state;
const position =
selection instanceof TextSelection
? selection.$cursor?.pos
: selection.$to.pos;
if (position === undefined || !isInList(state)) {
return false;
}
const resolvedPos = state.tr.doc.resolve(position);
const nodeWithPos = findParentNodeClosestToPos(resolvedPos, (node) =>
isList(node, this.editor.schema)
);
if (!nodeWithPos) {
return false;
}
const listNode = nodeWithPos.node,
from = nodeWithPos.pos,
to = from + listNode.nodeSize;
const listNodeWithMentions = transformListToMentions(
listNode,
this.editor.schema,
attrs
);
const tr = state.tr.deleteRange(from, to);
dispatch?.(
tr
.setSelection(TextSelection.near(tr.doc.resolve(from)))
.replaceSelectionWith(listNodeWithMentions)
);
return true;
},
};
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {