* 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
387 lines
11 KiB
TypeScript
387 lines
11 KiB
TypeScript
import Token from "markdown-it/lib/token";
|
|
import { InputRule } from "prosemirror-inputrules";
|
|
import { Node as ProsemirrorNode, NodeSpec, NodeType } from "prosemirror-model";
|
|
import { NodeSelection, EditorState, Plugin } from "prosemirror-state";
|
|
import * as React from "react";
|
|
import { sanitizeUrl } from "../../utils/urls";
|
|
import { default as ImageComponent, Caption } from "../components/Image";
|
|
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
|
import { ComponentProps, Dispatch } from "../types";
|
|
import SimpleImage from "./SimpleImage";
|
|
|
|
const imageSizeRegex = /\s=(\d+)?x(\d+)?$/;
|
|
|
|
type TitleAttributes = {
|
|
layoutClass?: string;
|
|
title?: string;
|
|
width?: number;
|
|
height?: number;
|
|
};
|
|
|
|
const parseTitleAttribute = (tokenTitle: string): TitleAttributes => {
|
|
const attributes: TitleAttributes = {
|
|
layoutClass: undefined,
|
|
title: undefined,
|
|
width: undefined,
|
|
height: undefined,
|
|
};
|
|
if (!tokenTitle) {
|
|
return attributes;
|
|
}
|
|
|
|
["right-50", "left-50", "full-width"].map((className) => {
|
|
if (tokenTitle.includes(className)) {
|
|
attributes.layoutClass = className;
|
|
tokenTitle = tokenTitle.replace(className, "");
|
|
}
|
|
});
|
|
|
|
const match = tokenTitle.match(imageSizeRegex);
|
|
if (match) {
|
|
attributes.width = parseInt(match[1], 10);
|
|
attributes.height = parseInt(match[2], 10);
|
|
tokenTitle = tokenTitle.replace(imageSizeRegex, "");
|
|
}
|
|
|
|
attributes.title = tokenTitle;
|
|
|
|
return attributes;
|
|
};
|
|
|
|
const downloadImageNode = async (node: ProsemirrorNode) => {
|
|
const image = await fetch(node.attrs.src);
|
|
const imageBlob = await image.blob();
|
|
const imageURL = URL.createObjectURL(imageBlob);
|
|
const extension = imageBlob.type.split(/\/|\+/g)[1];
|
|
const potentialName = node.attrs.alt || "image";
|
|
|
|
// create a temporary link node and click it with our image data
|
|
const link = document.createElement("a");
|
|
link.href = imageURL;
|
|
link.download = `${potentialName}.${extension}`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
|
|
// cleanup
|
|
document.body.removeChild(link);
|
|
};
|
|
|
|
export default class Image extends SimpleImage {
|
|
get schema(): NodeSpec {
|
|
return {
|
|
inline: true,
|
|
attrs: {
|
|
src: {
|
|
default: "",
|
|
},
|
|
width: {
|
|
default: undefined,
|
|
},
|
|
height: {
|
|
default: undefined,
|
|
},
|
|
alt: {
|
|
default: null,
|
|
},
|
|
layoutClass: {
|
|
default: null,
|
|
},
|
|
title: {
|
|
default: null,
|
|
},
|
|
},
|
|
content: "text*",
|
|
marks: "",
|
|
group: "inline",
|
|
selectable: true,
|
|
draggable: true,
|
|
parseDOM: [
|
|
{
|
|
tag: "div[class~=image]",
|
|
getAttrs: (dom: HTMLDivElement) => {
|
|
const img = dom.getElementsByTagName("img")[0];
|
|
const className = dom.className;
|
|
const layoutClassMatched =
|
|
className && className.match(/image-(.*)$/);
|
|
const layoutClass = layoutClassMatched
|
|
? layoutClassMatched[1]
|
|
: null;
|
|
|
|
const width = img.getAttribute("width");
|
|
const height = img.getAttribute("height");
|
|
return {
|
|
src: img?.getAttribute("src"),
|
|
alt: img?.getAttribute("alt"),
|
|
title: img?.getAttribute("title"),
|
|
width: width ? parseInt(width, 10) : undefined,
|
|
height: height ? parseInt(height, 10) : undefined,
|
|
layoutClass,
|
|
};
|
|
},
|
|
},
|
|
{
|
|
tag: "img",
|
|
getAttrs: (dom: HTMLImageElement) => {
|
|
const width = dom.getAttribute("width");
|
|
const height = dom.getAttribute("height");
|
|
return {
|
|
src: dom.getAttribute("src"),
|
|
alt: dom.getAttribute("alt"),
|
|
title: dom.getAttribute("title"),
|
|
width: width ? parseInt(width, 10) : undefined,
|
|
height: height ? parseInt(height, 10) : undefined,
|
|
};
|
|
},
|
|
},
|
|
],
|
|
toDOM: (node) => {
|
|
const className = node.attrs.layoutClass
|
|
? `image image-${node.attrs.layoutClass}`
|
|
: "image";
|
|
return [
|
|
"div",
|
|
{
|
|
class: className,
|
|
},
|
|
[
|
|
"img",
|
|
{
|
|
...node.attrs,
|
|
src: sanitizeUrl(node.attrs.src),
|
|
width: node.attrs.width,
|
|
height: node.attrs.height,
|
|
contentEditable: "false",
|
|
},
|
|
],
|
|
["p", { class: "caption" }, 0],
|
|
];
|
|
},
|
|
};
|
|
}
|
|
|
|
get plugins() {
|
|
return [
|
|
...super.plugins,
|
|
new Plugin({
|
|
props: {
|
|
handleKeyDown: (view, event) => {
|
|
// prevent prosemirror's default spacebar behavior
|
|
// & zoom in if the selected node is image
|
|
if (event.key === " ") {
|
|
const { state } = view;
|
|
const { selection } = state;
|
|
if (selection instanceof NodeSelection) {
|
|
const { node } = selection;
|
|
if (node.type.name === "image") {
|
|
const image = document.querySelector(
|
|
".ProseMirror-selectednode img"
|
|
) as HTMLImageElement;
|
|
image.click();
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
},
|
|
},
|
|
}),
|
|
];
|
|
}
|
|
|
|
handleChangeSize = ({
|
|
node,
|
|
getPos,
|
|
}: {
|
|
node: ProsemirrorNode;
|
|
getPos: () => number;
|
|
}) => ({ width, height }: { width: number; height?: number }) => {
|
|
const { view } = this.editor;
|
|
const { tr } = view.state;
|
|
|
|
const pos = getPos();
|
|
const transaction = tr
|
|
.setNodeMarkup(pos, undefined, {
|
|
...node.attrs,
|
|
width,
|
|
height,
|
|
})
|
|
.setMeta("addToHistory", true);
|
|
const $pos = transaction.doc.resolve(getPos());
|
|
view.dispatch(transaction.setSelection(new NodeSelection($pos)));
|
|
};
|
|
|
|
handleDownload = ({ node }: { node: ProsemirrorNode }) => (
|
|
event: React.MouseEvent
|
|
) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
downloadImageNode(node);
|
|
};
|
|
|
|
component = (props: ComponentProps) => (
|
|
<ImageComponent
|
|
{...props}
|
|
onClick={this.handleSelect(props)}
|
|
onDownload={this.handleDownload(props)}
|
|
onChangeSize={this.handleChangeSize(props)}
|
|
>
|
|
<Caption
|
|
onKeyDown={this.handleKeyDown(props)}
|
|
onBlur={this.handleBlur(props)}
|
|
onMouseDown={this.handleMouseDown}
|
|
className="caption"
|
|
tabIndex={-1}
|
|
role="textbox"
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
data-caption={this.options.dictionary.imageCaptionPlaceholder}
|
|
>
|
|
{props.node.attrs.alt}
|
|
</Caption>
|
|
</ImageComponent>
|
|
);
|
|
|
|
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
|
|
let markdown =
|
|
" ;
|
|
|
|
let size = "";
|
|
if (node.attrs.width || node.attrs.height) {
|
|
size = ` =${state.esc(
|
|
node.attrs.width ? String(node.attrs.width) : "",
|
|
false
|
|
)}x${state.esc(
|
|
node.attrs.height ? String(node.attrs.height) : "",
|
|
false
|
|
)}`;
|
|
}
|
|
if (node.attrs.layoutClass) {
|
|
markdown += ' "' + state.esc(node.attrs.layoutClass, false) + size + '"';
|
|
} else if (node.attrs.title) {
|
|
markdown += ' "' + state.esc(node.attrs.title, false) + size + '"';
|
|
} else if (size) {
|
|
markdown += ' "' + size + '"';
|
|
}
|
|
markdown += ")";
|
|
state.write(markdown);
|
|
}
|
|
|
|
parseMarkdown() {
|
|
return {
|
|
node: "image",
|
|
getAttrs: (token: Token) => ({
|
|
src: token.attrGet("src"),
|
|
alt:
|
|
(token?.children && token.children[0] && token.children[0].content) ||
|
|
null,
|
|
...parseTitleAttribute(token?.attrGet("title") || ""),
|
|
}),
|
|
};
|
|
}
|
|
|
|
commands({ type }: { type: NodeType }) {
|
|
return {
|
|
...super.commands({ type }),
|
|
downloadImage: () => (state: EditorState) => {
|
|
if (!(state.selection instanceof NodeSelection)) {
|
|
return false;
|
|
}
|
|
const { node } = state.selection;
|
|
|
|
if (node.type.name !== "image") {
|
|
return false;
|
|
}
|
|
|
|
downloadImageNode(node);
|
|
|
|
return true;
|
|
},
|
|
alignRight: () => (state: EditorState, dispatch: Dispatch) => {
|
|
if (!(state.selection instanceof NodeSelection)) {
|
|
return false;
|
|
}
|
|
const attrs = {
|
|
...state.selection.node.attrs,
|
|
title: null,
|
|
layoutClass: "right-50",
|
|
};
|
|
const { selection } = state;
|
|
dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs));
|
|
return true;
|
|
},
|
|
alignLeft: () => (state: EditorState, dispatch: Dispatch) => {
|
|
if (!(state.selection instanceof NodeSelection)) {
|
|
return false;
|
|
}
|
|
const attrs = {
|
|
...state.selection.node.attrs,
|
|
title: null,
|
|
layoutClass: "left-50",
|
|
};
|
|
const { selection } = state;
|
|
dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs));
|
|
return true;
|
|
},
|
|
alignFullWidth: () => (state: EditorState, dispatch: Dispatch) => {
|
|
if (!(state.selection instanceof NodeSelection)) {
|
|
return false;
|
|
}
|
|
const attrs = {
|
|
...state.selection.node.attrs,
|
|
title: null,
|
|
layoutClass: "full-width",
|
|
};
|
|
const { selection } = state;
|
|
dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs));
|
|
return true;
|
|
},
|
|
alignCenter: () => (state: EditorState, dispatch: Dispatch) => {
|
|
if (!(state.selection instanceof NodeSelection)) {
|
|
return false;
|
|
}
|
|
const attrs = { ...state.selection.node.attrs, layoutClass: null };
|
|
const { selection } = state;
|
|
dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs));
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
|
|
inputRules({ type }: { type: NodeType }) {
|
|
/**
|
|
* Matches following attributes in Markdown-typed image: [, alt, src, class]
|
|
*
|
|
* Example:
|
|
*  -> [, "Lorem", "image.jpg"]
|
|
*  -> [, "", "image.jpg", "small"]
|
|
*  -> [, "Lorem", "image.jpg", "small"]
|
|
*/
|
|
const IMAGE_INPUT_REGEX = /!\[(?<alt>[^\][]*?)]\((?<filename>[^\][]*?)(?=“|\))“?(?<layoutclass>[^\][”]+)?”?\)$/;
|
|
|
|
return [
|
|
new InputRule(IMAGE_INPUT_REGEX, (state, match, start, end) => {
|
|
const [okay, alt, src, matchedTitle] = match;
|
|
const { tr } = state;
|
|
|
|
if (okay) {
|
|
tr.replaceWith(
|
|
start - 1,
|
|
end,
|
|
type.create({
|
|
src,
|
|
alt,
|
|
...parseTitleAttribute(matchedTitle),
|
|
})
|
|
);
|
|
}
|
|
|
|
return tr;
|
|
}),
|
|
];
|
|
}
|
|
}
|