import Token from "markdown-it/lib/token"; import { DownloadIcon } from "outline-icons"; import { InputRule } from "prosemirror-inputrules"; import { Node as ProsemirrorNode, NodeSpec, NodeType } from "prosemirror-model"; import { Plugin, TextSelection, NodeSelection, EditorState, } from "prosemirror-state"; import * as React from "react"; import ImageZoom from "react-medium-image-zoom"; import styled from "styled-components"; import { supportedImageMimeTypes } from "../../utils/files"; import getDataTransferFiles from "../../utils/getDataTransferFiles"; import insertFiles, { Options } from "../commands/insertFiles"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; import uploadPlaceholderPlugin from "../lib/uploadPlaceholder"; import { ComponentProps, Dispatch } from "../types"; import Node from "./Node"; /** * 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 = /!\[(?[^\][]*?)]\((?[^\][]*?)(?=“|\))“?(?[^\][”]+)?”?\)$/; const uploadPlugin = (options: Options) => new Plugin({ props: { handleDOMEvents: { paste(view, event: ClipboardEvent): boolean { if ( (view.props.editable && !view.props.editable(view.state)) || !options.uploadFile ) { return false; } if (!event.clipboardData) { return false; } // check if we actually pasted any files const files = Array.prototype.slice .call(event.clipboardData.items) .filter((dt: DataTransferItem) => dt.kind !== "string") .map((dt: DataTransferItem) => dt.getAsFile()) .filter(Boolean); if (files.length === 0) { return false; } const { tr } = view.state; if (!tr.selection.empty) { tr.deleteSelection(); } const pos = tr.selection.from; insertFiles(view, event, pos, files, options); return true; }, drop(view, event: DragEvent): boolean { if ( (view.props.editable && !view.props.editable(view.state)) || !options.uploadFile ) { return false; } // filter to only include image files const files = getDataTransferFiles(event).filter( (dt: any) => dt.kind !== "string" ); if (files.length === 0) { return false; } // grab the position in the document for the cursor const result = view.posAtCoords({ left: event.clientX, top: event.clientY, }); if (result) { insertFiles(view, event, result.pos, files, options); return true; } return false; }, }, }, }); const IMAGE_CLASSES = ["right-50", "left-50"]; const getLayoutAndTitle = (tokenTitle: string | null) => { if (!tokenTitle) { return {}; } if (IMAGE_CLASSES.includes(tokenTitle)) { return { layoutClass: tokenTitle, }; } else { return { title: tokenTitle, }; } }; 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 Node { options: Options; get name() { return "image"; } get schema(): NodeSpec { return { inline: true, attrs: { src: {}, 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; return { src: img?.getAttribute("src"), alt: img?.getAttribute("alt"), title: img?.getAttribute("title"), layoutClass: layoutClass, }; }, }, { tag: "img", getAttrs: (dom: HTMLImageElement) => { return { src: dom.getAttribute("src"), alt: dom.getAttribute("alt"), title: dom.getAttribute("title"), }; }, }, ], toDOM: (node) => { const className = node.attrs.layoutClass ? `image image-${node.attrs.layoutClass}` : "image"; return [ "div", { class: className, }, ["img", { ...node.attrs, contentEditable: "false" }], ["p", { class: "caption" }, 0], ]; }, }; } handleKeyDown = ({ node, getPos, }: { node: ProsemirrorNode; getPos: () => number; }) => (event: React.KeyboardEvent) => { // Pressing Enter in the caption field should move the cursor/selection // below the image if (event.key === "Enter") { event.preventDefault(); const { view } = this.editor; const $pos = view.state.doc.resolve(getPos() + node.nodeSize); view.dispatch( view.state.tr.setSelection(new TextSelection($pos)).split($pos.pos) ); view.focus(); return; } // Pressing Backspace in an an empty caption field should remove the entire // image, leaving an empty paragraph if (event.key === "Backspace" && event.currentTarget.innerText === "") { const { view } = this.editor; const $pos = view.state.doc.resolve(getPos()); const tr = view.state.tr.setSelection(new NodeSelection($pos)); view.dispatch(tr.deleteSelection()); view.focus(); return; } }; handleBlur = ({ node, getPos, }: { node: ProsemirrorNode; getPos: () => number; }) => (event: React.FocusEvent) => { const alt = event.currentTarget.innerText; const { src, title, layoutClass } = node.attrs; if (alt === node.attrs.alt) { return; } const { view } = this.editor; const { tr } = view.state; // update meta on object const pos = getPos(); const transaction = tr.setNodeMarkup(pos, undefined, { src, alt, title, layoutClass, }); view.dispatch(transaction); }; handleSelect = ({ getPos }: { getPos: () => number }) => ( event: React.MouseEvent ) => { event.preventDefault(); const { view } = this.editor; const $pos = view.state.doc.resolve(getPos()); const transaction = view.state.tr.setSelection(new NodeSelection($pos)); view.dispatch(transaction); }; handleDownload = ({ node }: { node: ProsemirrorNode }) => ( event: React.MouseEvent ) => { event.preventDefault(); event.stopPropagation(); downloadImageNode(node); }; handleMouseDown = (ev: React.MouseEvent) => { if (document.activeElement !== ev.currentTarget) { ev.preventDefault(); ev.stopPropagation(); ev.currentTarget.focus(); } }; component = (props: ComponentProps) => { return ( {props.node.attrs.alt} ); }; toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { let markdown = " ![" + state.esc((node.attrs.alt || "").replace("\n", "") || "", false) + "](" + state.esc(node.attrs.src || "", false); if (node.attrs.layoutClass) { markdown += ' "' + state.esc(node.attrs.layoutClass, false) + '"'; } else if (node.attrs.title) { markdown += ' "' + state.esc(node.attrs.title, false) + '"'; } markdown += ")"; state.write(markdown); } parseMarkdown() { return { node: "image", getAttrs: (token: Token) => { return { src: token.attrGet("src"), alt: (token?.children && token.children[0] && token.children[0].content) || null, ...getLayoutAndTitle(token?.attrGet("title")), }; }, }; } commands({ type }: { type: NodeType }) { return { 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; }, deleteImage: () => (state: EditorState, dispatch: Dispatch) => { dispatch(state.tr.deleteSelection()); 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; }, replaceImage: () => (state: EditorState) => { const { view } = this.editor; const { uploadFile, onFileUploadStart, onFileUploadStop, onShowToast, } = this.editor.props; if (!uploadFile) { throw new Error("uploadFile prop is required to replace images"); } // create an input element and click to trigger picker const inputElement = document.createElement("input"); inputElement.type = "file"; inputElement.accept = supportedImageMimeTypes.join(", "); inputElement.onchange = (event: Event) => { const files = getDataTransferFiles(event); insertFiles(view, event, state.selection.from, files, { uploadFile, onFileUploadStart, onFileUploadStop, onShowToast, dictionary: this.options.dictionary, replaceExisting: true, }); }; inputElement.click(); 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; }, createImage: (attrs: Record) => ( state: EditorState, dispatch: Dispatch ) => { const { selection } = state; const position = selection instanceof TextSelection ? selection.$cursor?.pos : selection.$to.pos; if (position === undefined) { return false; } const node = type.create(attrs); const transaction = state.tr.insert(position, node); dispatch(transaction); return true; }, }; } inputRules({ type }: { type: NodeType }) { 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, ...getLayoutAndTitle(matchedTitle), }) ); } return tr; }), ]; } get plugins() { return [uploadPlaceholderPlugin, uploadPlugin(this.options)]; } } const ImageComponent = ( props: ComponentProps & { onClick: (event: React.MouseEvent) => void; onDownload: (event: React.MouseEvent) => void; children: React.ReactNode; } ) => { const { theme, isSelected, node } = props; const { alt, src, layoutClass } = node.attrs; const className = layoutClass ? `image image-${layoutClass}` : "image"; const [width, setWidth] = React.useState(0); return (
{ // For some SVG's Firefox does not provide the naturalWidth, in this // rare case we need to provide a default so that the image can be // seen and is not sized to 0px setWidth(ev.target.naturalWidth || "50%"); }, }} defaultStyles={{ overlay: { backgroundColor: theme.background, }, }} shouldRespectMaxDimension /> {props.children}
); }; const Button = styled.button` position: absolute; top: 8px; right: 8px; border: 0; margin: 0; padding: 0; border-radius: 4px; background: ${(props) => props.theme.background}; color: ${(props) => props.theme.textSecondary}; width: 24px; height: 24px; display: inline-block; cursor: pointer; opacity: 0; transition: opacity 100ms ease-in-out; &:active { transform: scale(0.98); } &:hover { color: ${(props) => props.theme.text}; opacity: 1; } `; const Caption = styled.p` border: 0; display: block; font-size: 13px; font-style: italic; font-weight: normal; color: ${(props) => props.theme.textSecondary}; padding: 8px 0 4px; line-height: 16px; text-align: center; min-height: 1em; outline: none; background: none; resize: none; user-select: text; margin: 0 !important; cursor: text; &:empty:not(:focus) { display: none; } &:empty:before { color: ${(props) => props.theme.placeholder}; content: attr(data-caption); pointer-events: none; } `; const ImageWrapper = styled.div` line-height: 0; position: relative; margin-left: auto; margin-right: auto; max-width: 100%; &:hover { ${Button} { opacity: 0.9; } } &.ProseMirror-selectednode + ${Caption} { display: block; } `;