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) {