mirror of
https://github.com/outline/outline.git
synced 2026-01-05 18:49:53 -06:00
322 lines
9.0 KiB
TypeScript
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,
|
|
}),
|
|
};
|
|
}
|
|
}
|