diff --git a/app/editor/components/PasteMenu.tsx b/app/editor/components/PasteMenu.tsx
index 8f6f74c69b..2e8bf83985 100644
--- a/app/editor/components/PasteMenu.tsx
+++ b/app/editor/components/PasteMenu.tsx
@@ -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: ,
- },
- {
- name: "mention",
- title: t("Mention"),
- icon: ,
- 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 (
{
/>
);
});
+
+function useItems({
+ pastedText,
+ embeds,
+}: Pick): 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: ,
+ },
+ {
+ name: "mention",
+ title: t("Mention"),
+ icon: ,
+ 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 = {};
+
+ // 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: ,
+ },
+ {
+ name: "mention_list",
+ title: t("Mention"),
+ icon: ,
+ attrs: { actorId: user?.id, ...linksToMentionType },
+ },
+ ];
+}
diff --git a/app/editor/extensions/PasteHandler.tsx b/app/editor/extensions/PasteHandler.tsx
index 7806e46fb4..2deaaf948b 100644
--- a/app/editor/extensions/PasteHandler.tsx
+++ b/app/editor/extensions/PasteHandler.tsx
@@ -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;
}
diff --git a/shared/editor/lib/mention.ts b/shared/editor/lib/mention.ts
new file mode 100644
index 0000000000..ef59c0e8af
--- /dev/null
+++ b/shared/editor/lib/mention.ts
@@ -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
+): 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
+) {
+ 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);
+}
diff --git a/shared/editor/nodes/Mention.tsx b/shared/editor/nodes/Mention.tsx
index 63d8a6f9bc..5f6393aaf9 100644
--- a/shared/editor/nodes/Mention.tsx
+++ b/shared/editor/nodes/Mention.tsx
@@ -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): 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): 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): 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) {