mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
Show paste menu to convert a list of URLs to mentions (#9646)
This commit is contained in:
@@ -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 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user