* fix: Logic error in toast fix: Remove useless component * fix: Logout not clearing all stores * Add icons to notification settings * Add eslint rule to enforce spaced comment * Add eslint rule for arrow-body-style * Add eslint rule to enforce self-closing components * Add menu to api key settings Fix: Deleting webhook subscription does not remove from UI Split webhook subscriptions into active and inactive Styling updates
321 lines
8.9 KiB
TypeScript
321 lines
8.9 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 color="currentColor" 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.linkMenuOpen);
|
|
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 &&
|
|
!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,
|
|
}),
|
|
};
|
|
}
|
|
}
|