Files
outline/shared/editor/nodes/Image.tsx
2023-05-24 19:24:05 -07:00

384 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, Plugin, Command } 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 } 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 =
" ![" +
state.esc((node.attrs.alt || "").replace("\n", "") || "", false) +
"](" +
state.esc(node.attrs.src || "", false);
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: (): Command => (state) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const { node } = state.selection;
if (node.type.name !== "image") {
return false;
}
downloadImageNode(node);
return true;
},
alignRight: (): Command => (state, 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: (): Command => (state, 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: (): Command => (state, 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: (): Command => (state, 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) -> [, "Lorem", "image.jpg"]
* ![](image.jpg "class") -> [, "", "image.jpg", "small"]
* ![Lorem](image.jpg "class") -> [, "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;
}),
];
}
}