Files
outline/shared/editor/marks/Link.tsx
Tom Moor ea885133ac Notifications interface (#5354)
Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>
2023-05-20 07:47:32 -07:00

322 lines
9.0 KiB
TypeScript

import Token from "markdown-it/lib/token";
import { OpenIcon } from "outline-icons";
import { toggleMark } from "prosemirror-commands";
import { InputRule } from "prosemirror-inputrules";
import { MarkdownSerializerState } from "prosemirror-markdown";
import {
MarkSpec,
MarkType,
Node,
Mark as ProsemirrorMark,
} from "prosemirror-model";
import { EditorState, Plugin } from "prosemirror-state";
import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
import * as React from "react";
import ReactDOM from "react-dom";
import { isExternalUrl, sanitizeUrl } from "../../utils/urls";
import findLinkNodes from "../queries/findLinkNodes";
import getMarkRange from "../queries/getMarkRange";
import isMarkActive from "../queries/isMarkActive";
import { EventType, Dispatch } from "../types";
import Mark from "./Mark";
const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/;
let icon: HTMLSpanElement;
if (typeof window !== "undefined") {
const component = <OpenIcon size={16} />;
icon = document.createElement("span");
icon.className = "external-link";
ReactDOM.render(component, icon);
}
function isPlainURL(
link: ProsemirrorMark,
parent: Node,
index: number,
side: -1 | 1
) {
if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) {
return false;
}
const content = parent.child(index + (side < 0 ? -1 : 0));
if (
!content.isText ||
content.text !== link.attrs.href ||
content.marks[content.marks.length - 1] !== link
) {
return false;
}
if (index === (side < 0 ? 1 : parent.childCount - 1)) {
return true;
}
const next = parent.child(index + (side < 0 ? -2 : 1));
return !link.isInSet(next.marks);
}
export default class Link extends Mark {
get name() {
return "link";
}
get schema(): MarkSpec {
return {
attrs: {
href: {
default: "",
},
title: {
default: null,
},
},
inclusive: false,
parseDOM: [
{
tag: "a[href]",
getAttrs: (dom: HTMLElement) => ({
href: dom.getAttribute("href"),
title: dom.getAttribute("title"),
}),
},
],
toDOM: (node) => [
"a",
{
...node.attrs,
href: sanitizeUrl(node.attrs.href),
rel: "noopener noreferrer nofollow",
},
0,
],
};
}
inputRules({ type }: { type: MarkType }) {
return [
new InputRule(LINK_INPUT_REGEX, (state, match, start, end) => {
const [okay, alt, href] = match;
const { tr } = state;
if (okay) {
tr.replaceWith(start, end, this.editor.schema.text(alt)).addMark(
start,
start + alt.length,
type.create({ href })
);
}
return tr;
}),
];
}
keys({ type }: { type: MarkType }) {
return {
"Mod-k": (state: EditorState, dispatch: Dispatch) => {
if (state.selection.empty) {
this.editor.events.emit(EventType.LinkToolbarOpen);
return true;
}
return toggleMark(type, { href: "" })(state, dispatch);
},
"Mod-Enter": (state: EditorState) => {
if (isMarkActive(type)(state)) {
const range = getMarkRange(
state.selection.$from,
state.schema.marks.link
);
if (range && range.mark && this.options.onClickLink) {
try {
const event = new KeyboardEvent("keydown", { metaKey: false });
this.options.onClickLink(
sanitizeUrl(range.mark.attrs.href),
event
);
} catch (err) {
this.editor.props.onShowToast(
this.options.dictionary.openLinkError
);
}
return true;
}
}
return false;
},
};
}
get plugins() {
const getLinkDecorations = (state: EditorState) => {
const decorations: Decoration[] = [];
const links = findLinkNodes(state.doc);
links.forEach((nodeWithPos) => {
const linkMark = nodeWithPos.node.marks.find(
(mark) => mark.type.name === "link"
);
if (linkMark && isExternalUrl(linkMark.attrs.href)) {
decorations.push(
Decoration.widget(
// place the decoration at the end of the link
nodeWithPos.pos + nodeWithPos.node.nodeSize,
() => {
const cloned = icon.cloneNode(true);
cloned.addEventListener("click", (event) => {
try {
if (this.options.onClickLink) {
event.stopPropagation();
event.preventDefault();
this.options.onClickLink(
sanitizeUrl(linkMark.attrs.href),
event
);
}
} catch (err) {
this.editor.props.onShowToast(
this.options.dictionary.openLinkError
);
}
});
return cloned;
},
{
// position on the right side of the position
side: 1,
key: "external-link",
}
)
);
}
});
return DecorationSet.create(state.doc, decorations);
};
const plugin: Plugin = new Plugin({
state: {
init: (config, state) => getLinkDecorations(state),
apply: (tr, decorationSet, _oldState, newState) =>
tr.docChanged ? getLinkDecorations(newState) : decorationSet,
},
props: {
decorations: (state: EditorState) => plugin.getState(state),
handleDOMEvents: {
mouseover: (view: EditorView, event: MouseEvent) => {
const target = (event.target as HTMLElement)?.closest("a");
if (
target instanceof HTMLAnchorElement &&
this.editor.elementRef.current?.contains(target) &&
!target.className.includes("ProseMirror-widget") &&
(!view.editable || (view.editable && !view.hasFocus()))
) {
if (this.options.onHoverLink) {
return this.options.onHoverLink(target);
}
}
return false;
},
mousedown: (view: EditorView, event: MouseEvent) => {
const target = (event.target as HTMLElement)?.closest("a");
if (!(target instanceof HTMLAnchorElement) || event.button !== 0) {
return false;
}
if (target.matches(".component-attachment *")) {
return false;
}
// clicking a link while editing should show the link toolbar,
// clicking in read-only will navigate
if (!view.editable || (view.editable && !view.hasFocus())) {
const href =
target.href ||
(target.parentNode instanceof HTMLAnchorElement
? target.parentNode.href
: "");
try {
if (this.options.onClickLink) {
event.stopPropagation();
event.preventDefault();
this.options.onClickLink(sanitizeUrl(href), event);
}
} catch (err) {
this.editor.props.onShowToast(
this.options.dictionary.openLinkError
);
}
return true;
}
return false;
},
click: (view: EditorView, event: MouseEvent) => {
if (
!(event.target instanceof HTMLAnchorElement) ||
event.button !== 0
) {
return false;
}
if (event.target.matches(".component-attachment *")) {
return false;
}
// Prevent all default click behavior of links, see mousedown above
// for custom link handling.
if (this.options.onClickLink) {
event.stopPropagation();
event.preventDefault();
}
return false;
},
},
},
});
return [plugin];
}
toMarkdown() {
return {
open(
_state: MarkdownSerializerState,
mark: ProsemirrorMark,
parent: Node,
index: number
) {
return isPlainURL(mark, parent, index, 1) ? "<" : "[";
},
close(
state: MarkdownSerializerState,
mark: ProsemirrorMark,
parent: Node,
index: number
) {
return isPlainURL(mark, parent, index, -1)
? ">"
: "](" +
state.esc(mark.attrs.href) +
(mark.attrs.title ? " " + state.quote(mark.attrs.title) : "") +
")";
},
};
}
parseMarkdown() {
return {
mark: "link",
getAttrs: (token: Token) => ({
href: token.attrGet("href"),
title: token.attrGet("title") || null,
}),
};
}
}