diff --git a/src/commands/insertAllFiles.ts b/src/commands/insertAllFiles.ts new file mode 100644 index 000000000..b20c35fdf --- /dev/null +++ b/src/commands/insertAllFiles.ts @@ -0,0 +1,95 @@ +import uploadFilePlaceholderPlugin, { + findPlaceholder, +} from "../lib/uploadFilePlaceholder"; +import { ToastType } from "../types"; + +const insertAllFiles = function(view, event, pos, files, options) { + if (files.length === 0) return; + + const { + dictionary, + uploadFile, + onFileUploadStart, + onFileUploadStop, + onShowToast, + } = options; + + if (!uploadFile) { + console.warn("uploadFile callback must be defined to handle file uploads."); + return; + } + + // okay, we have some dropped files and a handler – lets stop this + // event going any further up the stack + event.preventDefault(); + + // let the user know we're starting to process the files + if (onFileUploadStart) onFileUploadStart(); + + const { schema } = view.state; + + // we'll use this to track of how many files have succeeded or failed + let complete = 0; + // const { state } = view; + // const { from, to } = state.selection; + // the user might have dropped multiple files at once, we need to loop + for (const file of files) { + // Use an object to act as the ID for this upload, clever. + const id = {}; + + const { tr } = view.state; + + // insert a placeholder at this position + tr.setMeta(uploadFilePlaceholderPlugin, { + add: { id, file, pos }, + }); + view.dispatch(tr); + + // start uploading the file file to the server. Using "then" syntax + // to allow all placeholders to be entered at once with the uploads + // happening in the background in parallel. + uploadFile(file) + .then(src => { + const pos = findPlaceholder(view.state, id); + + // if the content around the placeholder has been deleted + // then forget about inserting this file + if (pos === null) return; + + const transaction = view.state.tr + .replaceWith( + pos, + pos, + schema.nodes.container_file.create({ src, alt: file.name }) + ) + .setMeta(uploadFilePlaceholderPlugin, { remove: { id } }); + + view.dispatch(transaction); + }) + .catch(error => { + console.error(error); + + // cleanup the placeholder if there is a failure + const transaction = view.state.tr.setMeta(uploadFilePlaceholderPlugin, { + remove: { id }, + }); + view.dispatch(transaction); + + // let the user know + if (onShowToast) { + onShowToast(dictionary.fileUploadError, ToastType.Error); + } + }) + // eslint-disable-next-line no-loop-func + .finally(() => { + complete++; + + // once everything is done, let the user know + if (complete === files.length) { + if (onFileUploadStop) onFileUploadStop(); + } + }); + } +}; + +export default insertAllFiles; diff --git a/src/components/CommandMenu.tsx b/src/components/CommandMenu.tsx index 390512d90..4be395dbc 100644 --- a/src/components/CommandMenu.tsx +++ b/src/components/CommandMenu.tsx @@ -10,6 +10,7 @@ import VisuallyHidden from "./VisuallyHidden"; import getDataTransferFiles from "../lib/getDataTransferFiles"; import filterExcessSeparators from "../lib/filterExcessSeparators"; import insertFiles from "../commands/insertFiles"; +import insertAllFiles from "../commands/insertAllFiles"; import baseDictionary from "../dictionary"; const SSR = typeof window === "undefined"; @@ -31,6 +32,9 @@ export type Props = { uploadImage?: (file: File) => Promise; onImageUploadStart?: () => void; onImageUploadStop?: () => void; + uploadFile?: (file: File) => Promise; + onFileUploadStart?: () => void; + onFileUploadStop?: () => void; onShowToast?: (message: string, id: string) => void; onLinkToolbarOpen?: () => void; onClose: () => void; @@ -61,6 +65,7 @@ type State = { class CommandMenu extends React.Component, State> { menuRef = React.createRef(); inputRef = React.createRef(); + fileInputRef = React.createRef(); state: State = { left: -1000, @@ -177,6 +182,8 @@ class CommandMenu extends React.Component, State> { switch (item.name) { case "image": return this.triggerImagePick(); + case "container_file": + return this.triggerFilePick(); case "embed": return this.triggerLinkInput(item); case "link": { @@ -254,6 +261,12 @@ class CommandMenu extends React.Component, State> { } }; + triggerFilePick = () => { + if (this.fileInputRef.current) { + this.fileInputRef.current.click(); + } + }; + triggerLinkInput = item => { this.setState({ insertItem: item }); }; @@ -294,6 +307,40 @@ class CommandMenu extends React.Component, State> { this.props.onClose(); }; + handleFilePicked = event => { + const files = getDataTransferFiles(event); + + const { + view, + uploadFile, + onFileUploadStart, + onFileUploadStop, + onShowToast, + } = this.props; + const { state, dispatch } = view; + const parent = findParentNode(node => !!node)(state.selection); + + if (parent) { + dispatch( + state.tr.insertText( + "", + parent.pos, + parent.pos + parent.node.textContent.length + 1 + ) + ); + + insertAllFiles(view, event, parent.pos, files, { + uploadFile, + onFileUploadStart, + onFileUploadStop, + onShowToast, + dictionary: this.props.dictionary, + }); + } + + this.props.onClose(); + }; + clearSearch = () => { this.props.onClearSearch(); }; @@ -401,6 +448,7 @@ class CommandMenu extends React.Component, State> { embeds = [], search = "", uploadImage, + uploadFile, commands, filterable = true, } = this.props; @@ -438,6 +486,9 @@ class CommandMenu extends React.Component, State> { // If no image upload callback has been passed, filter the image block out if (!uploadImage && item.name === "image") return false; + // If no file upload callback has been passed, filter the file block out + if (!uploadFile && item.name === "file") return false; + // some items (defaultHidden) are not visible until a search query exists if (!search) return !item.defaultHidden; @@ -455,7 +506,7 @@ class CommandMenu extends React.Component, State> { } render() { - const { dictionary, isActive, uploadImage } = this.props; + const { dictionary, isActive, uploadImage, uploadFile } = this.props; const items = this.filtered; const { insertItem, ...positioning } = this.state; @@ -523,6 +574,16 @@ class CommandMenu extends React.Component, State> { /> )} + {uploadFile && ( + + + + )} ); diff --git a/src/dictionary.ts b/src/dictionary.ts index 809729805..db6831e84 100644 --- a/src/dictionary.ts +++ b/src/dictionary.ts @@ -32,6 +32,7 @@ export const base = { heading: "Heading", hr: "Divider", image: "Image", + file: "File", imageUploadError: "Sorry, an error occurred uploading the image", imageCaptionPlaceholder: "Write a caption", info: "Info", diff --git a/src/index.tsx b/src/index.tsx index 29eed9fba..f5a897c42 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -48,6 +48,7 @@ import HorizontalRule from "./nodes/HorizontalRule"; import Image from "./nodes/Image"; import ListItem from "./nodes/ListItem"; import Notice from "./nodes/Notice"; +import FileDoc from "./nodes/FileDoc"; import OrderedList from "./nodes/OrderedList"; import Paragraph from "./nodes/Paragraph"; import Table from "./nodes/Table"; @@ -135,6 +136,7 @@ export type Props = { [name: string]: (view: EditorView, event: Event) => boolean; }; uploadImage?: (file: File) => Promise; + uploadFile?: (file: File) => Promise; onBlur?: () => void; onFocus?: () => void; onSave?: ({ done: boolean }) => void; @@ -142,6 +144,8 @@ export type Props = { onChange?: (value: () => string) => void; onImageUploadStart?: () => void; onImageUploadStop?: () => void; + onFileUploadStart?: () => void; + onFileUploadStop?: () => void; onCreateLink?: (title: string) => Promise; onSearchLink?: (term: string) => Promise; onClickLink: (href: string, event: MouseEvent) => void; @@ -335,6 +339,13 @@ class RichMarkdownEditor extends React.PureComponent { new Notice({ dictionary, }), + new FileDoc({ + dictionary, + uploadFile: this.props.uploadFile, + onFileUploadStart: this.props.onFileUploadStart, + onFileUploadStop: this.props.onFileUploadStop, + onShowToast: this.props.onShowToast, + }), new Heading({ dictionary, onShowToast: this.props.onShowToast, @@ -527,7 +538,6 @@ class RichMarkdownEditor extends React.PureComponent { if (!this.element) { throw new Error("createView called before ref available"); } - const isEditingCheckbox = tr => { return tr.steps.some( (step: Step) => @@ -805,9 +815,12 @@ class RichMarkdownEditor extends React.PureComponent { search={this.state.blockMenuSearch} onClose={this.handleCloseBlockMenu} uploadImage={this.props.uploadImage} + uploadFile={this.props.uploadFile} onLinkToolbarOpen={this.handleOpenLinkMenu} onImageUploadStart={this.props.onImageUploadStart} onImageUploadStop={this.props.onImageUploadStop} + onFileUploadStart={this.props.onFileUploadStart} + onFileUploadStop={this.props.onFileUploadStop} onShowToast={this.props.onShowToast} embeds={this.props.embeds} /> diff --git a/src/lib/renderToHtml.ts b/src/lib/renderToHtml.ts index 76c4a3593..c0d97f2a1 100644 --- a/src/lib/renderToHtml.ts +++ b/src/lib/renderToHtml.ts @@ -6,6 +6,7 @@ import embedsRule from "../rules/embeds"; import breakRule from "../rules/breaks"; import tablesRule from "../rules/tables"; import noticesRule from "../rules/notices"; +import filesRule from "../rules/files"; import underlinesRule from "../rules/underlines"; import emojiRule from "../rules/emoji"; @@ -18,6 +19,7 @@ const defaultRules = [ underlinesRule, tablesRule, noticesRule, + filesRule, emojiRule, ]; diff --git a/src/lib/uploadFilePlaceholder.ts b/src/lib/uploadFilePlaceholder.ts new file mode 100644 index 000000000..975f1e221 --- /dev/null +++ b/src/lib/uploadFilePlaceholder.ts @@ -0,0 +1,46 @@ +import { Plugin } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; + +// based on the example at: https://prosemirror.net/examples/upload/ +const uploadFilePlaceholder = new Plugin({ + state: { + init() { + return DecorationSet.empty; + }, + apply(tr, set) { + // Adjust decoration positions to changes made by the transaction + set = set.map(tr.mapping, tr.doc); + + // See if the transaction adds or removes any placeholders + const action = tr.getMeta(this); + + if (action && action.add) { + const element = document.createElement("div"); + element.className = "loader"; + + const deco = Decoration.widget(action.add.pos, element, { + id: action.add.id, + }); + set = set.add(tr.doc, [deco]); + } else if (action && action.remove) { + set = set.remove( + set.find(null, null, spec => spec.id === action.remove.id) + ); + } + return set; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, +}); + +export default uploadFilePlaceholder; + +export function findPlaceholder(state, id) { + const decos = uploadFilePlaceholder.getState(state); + const found = decos.find(null, null, spec => spec.id === id); + return found.length ? found[0].from : null; +} diff --git a/src/menus/block.ts b/src/menus/block.ts index 59d355c96..1b953a632 100644 --- a/src/menus/block.ts +++ b/src/menus/block.ts @@ -12,6 +12,7 @@ import { TodoListIcon, ImageIcon, StarredIcon, + DocumentIcon, WarningIcon, InfoIcon, LinkIcon, @@ -146,5 +147,11 @@ export default function blockMenuItems( keywords: "container_notice card suggestion", attrs: { style: "tip" }, }, + { + name: "container_file", + title: dictionary.file, + icon: DocumentIcon, + keywords: "file", + }, ]; } diff --git a/src/nodes/FileDoc.tsx b/src/nodes/FileDoc.tsx new file mode 100644 index 000000000..a4faf45fd --- /dev/null +++ b/src/nodes/FileDoc.tsx @@ -0,0 +1,169 @@ +import { wrappingInputRule } from "prosemirror-inputrules"; +import { Plugin } from "prosemirror-state"; +import toggleWrap from "../commands/toggleWrap"; +import { LinkIcon } from "outline-icons"; +import * as React from "react"; +import ReactDOM from "react-dom"; +import Node from "./Node"; +import filesRule from "../rules/files"; +import uploadFilePlaceholderPlugin from "../lib/uploadFilePlaceholder"; +import getDataTransferFiles from "../lib/getDataTransferFiles"; +import insertAllFiles from "../commands/insertAllFiles"; + +const uploadPlugin = 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) + .map(dt => dt.getAsFile()) + .filter(file => file); + + if (files.length === 0) return false; + + const { tr } = view.state; + if (!tr.selection.empty) { + tr.deleteSelection(); + } + const pos = tr.selection.from; + + insertAllFiles(view, event, pos, files, options); + return true; + }, + drop(view, event: DragEvent): boolean { + if ( + (view.props.editable && !view.props.editable(view.state)) || + !options.uploadImage + ) { + return false; + } + + const files = getDataTransferFiles(event); + 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) { + insertAllFiles(view, event, result.pos, files, options); + return true; + } + + return false; + }, + }, + }, + }); + +export default class File extends Node { + get name() { + return "container_file"; + } + + get rulePlugins() { + return [filesRule]; + } + + get schema() { + return { + attrs: { + src: {}, + alt: { + default: "", + }, + }, + content: "block+", + group: "block", + defining: true, + draggable: true, + parseDOM: [ + { + tag: "div.file-block", + preserveWhitespace: "full", + contentElement: "div:last-child", + getAttrs: (dom: HTMLDivElement) => ({ + alt: dom.className.includes("a"), + }), + }, + ], + toDOM: node => { + const a = document.createElement("a"); + a.href = node.attrs.src; + const fileName = document.createTextNode(node.attrs.alt); + a.appendChild(fileName); + + const component = ; + + const icon = document.createElement("div"); + icon.className = "icon"; + ReactDOM.render(component, icon); + + return [ + "div", + { class: `file-block` }, + icon, + a, + ["div", { contentEditable: true }], + ["div", { class: "content" }, 0], + ]; + }, + }; + } + + commands({ type }) { + return attrs => toggleWrap(type, attrs); + } + + inputRules({ type }) { + return [wrappingInputRule(/^@@@$/, type)]; + } + + toMarkdown(state, node) { + state.write("\n@@@"); + state.write( + "[" + + state.esc(node.attrs.alt) + + "]" + + "(" + + state.esc(node.attrs.src) + + ")" + ); + state.ensureNewLine(); + state.write("@@@"); + state.closeBlock(node); + } + + parseMarkdown() { + return { + block: "container_file", + getAttrs: token => { + const file_regex = /\[(?[^]*?)\]\((?[^]*?)\)/g; + const result = file_regex.exec(token.info); + return { + src: result ? result[2] : null, + alt: result ? result[1] : null, + }; + }, + }; + } + + get plugins() { + return [uploadFilePlaceholderPlugin, uploadPlugin(this.options)]; + } +} diff --git a/src/rules/files.ts b/src/rules/files.ts new file mode 100644 index 000000000..7f18b46bb --- /dev/null +++ b/src/rules/files.ts @@ -0,0 +1,18 @@ +import customFence from "markdown-it-container"; + +export default function file(md): void { + return customFence(md, "file", { + marker: "@", + validate: () => true, + render: function(tokens, idx) { + const { info } = tokens[idx]; + if (tokens[idx].nesting === 1) { + // opening tag + return `
\n`; + } else { + // closing tag + return "
\n"; + } + }, + }); +} diff --git a/src/server.ts b/src/server.ts index fe7a16155..def057e9d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,6 +19,7 @@ import HorizontalRule from "./nodes/HorizontalRule"; import Image from "./nodes/Image"; import ListItem from "./nodes/ListItem"; import Notice from "./nodes/Notice"; +import FileDoc from "./nodes/FileDoc"; import OrderedList from "./nodes/OrderedList"; import Paragraph from "./nodes/Paragraph"; import Table from "./nodes/Table"; @@ -54,6 +55,7 @@ const extensions = new ExtensionManager([ new Heading(), new HorizontalRule(), new Image(), + new FileDoc(), new Table(), new TableCell(), new TableHeadCell(), diff --git a/src/styles/editor.ts b/src/styles/editor.ts index 668fd9afb..dd162964f 100644 --- a/src/styles/editor.ts +++ b/src/styles/editor.ts @@ -81,6 +81,23 @@ export const StyledEditor = styled("div")<{ clear: initial; } + .loader { + border: 5px solid #f3f3f3; + border-radius: 50%; + border-top: 5px solid #3498db; + width: 2.5em; + height: 2.5em; + margin: 80px auto; + position: relative; + -webkit-animation: spin 2s linear infinite; /* Safari */ + animation: spin 2s linear infinite; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + .ProseMirror-hideselection *::selection { background: transparent; } @@ -307,6 +324,38 @@ export const StyledEditor = styled("div")<{ opacity: 1; } + .file-block { + display: flex; + align-items: center; + background: #F0F8FF; + color: #181A1B; + border-radius: 4px; + padding: 8px 16px; + margin: 8px 0; + + a { + color: #181A1B; + } + + a:not(.heading-name) { + text-decoration: underline; + } + } + + .file-block .content { + flex-grow: 1; + min-width: 0; + } + + .file-block .icon { + width: 24px; + height: 24px; + align-self: flex-start; + margin-${props => (props.rtl ? "left" : "right")}: 4px; + position: relative; + top: 1px; + } + .notice-block { display: flex; align-items: center; @@ -586,6 +635,13 @@ export const StyledEditor = styled("div")<{ } } + &.file-block { + select, + button { + ${props => (props.rtl ? "left" : "right")}: 4px; + } + } + button { padding: 2px 4px; }