diff --git a/src/commands/insertAllFiles.ts b/src/commands/insertAllFiles.ts new file mode 100644 index 000000000..8035b27bd --- /dev/null +++ b/src/commands/insertAllFiles.ts @@ -0,0 +1,125 @@ +import { ToastType } from "../types"; + + +// function findPlaceholderLink(doc, href) { +// let result; + +// function findLinks(node, pos = 0) { +// // get text nodes +// if (node.type.name === "text") { +// // get marks for text nodes +// node.marks.forEach(mark => { +// // any of the marks links? +// if (mark.type.name === "link") { +// // any of the links to other docs? +// if (mark.attrs.href === href) { +// result = { node, pos }; +// if (result) return false; +// } +// } +// }); +// } + +// if (!node.content.size) { +// return; +// } + +// node.descendants(findLinks); +// } + +// findLinks(doc); +// return result; +// } + +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(); + + // 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; + + console.log("state.selection: " + state.selection + "," + from + "," + to); + + // the user might have dropped multiple files at once, we need to loop + for (const file of files) { + + // Insert a placeholder link + var placeholder = `[${file.name} uploading...]` + // const phref = `#uploading_${file.name}`; + view.dispatch( + view.state.tr + .insertText(placeholder, from, to-1) + ); + // 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 title = file.name; + const href = src; + + // const result = findPlaceholderLink(view.state.doc, phref); + // console.log("placeholder: " + result + "," + result.pos + "," + result.node.nodeSize); + // if (result) { + // view.dispatch( + // view.state.tr + // .removeMark( + // result.pos, + // result.pos + result.node.nodeSize, + // state.schema.marks.link + // ) + // ); + // } + + view.dispatch( + view.state.tr + .delete(from, to + placeholder.length) + .insertText(title, from, to) + .addMark( + from, + to + title.length, + state.schema.marks.link.create({ href }) + ) + ); + }) + .catch(error => { + console.error(error); + 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 9b5227633..9a0f285f8 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"; @@ -29,8 +30,11 @@ export type Props = { view: EditorView; search: string; uploadImage?: (file: File) => Promise; + uploadFile?: (file: File) => Promise; onImageUploadStart?: () => void; onImageUploadStop?: () => void; + 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 "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,38 @@ 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 } = view; + const parent = findParentNode(node => !!node)(state.selection); + + this.clearSearch(); + + if (parent) { + insertAllFiles(view, event, parent.pos, files, { + uploadFile, + onFileUploadStart, + onFileUploadStop, + onShowToast, + dictionary: this.props.dictionary, + }); + } + + if (this.inputRef.current) { + this.inputRef.current.value = ""; + } + + this.props.onClose(); + }; + clearSearch = () => { this.props.onClearSearch(); }; @@ -400,6 +445,7 @@ class CommandMenu extends React.Component, State> { embeds = [], search = "", uploadImage, + uploadFile, commands, filterable = true, } = this.props; @@ -425,6 +471,9 @@ class CommandMenu extends React.Component, State> { const filtered = items.filter(item => { if (item.name === "separator") return true; + // If file upload callback has been passed, filter the file block in + if (uploadFile && item.name === "file") return true; + // Some extensions may be disabled, remove corresponding menu items if ( item.name && @@ -454,7 +503,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; @@ -522,6 +571,16 @@ class CommandMenu extends React.Component, State> { /> )} + {uploadFile && ( + + + + )} ); diff --git a/src/dictionary.ts b/src/dictionary.ts index 809729805..9e0ebb726 100644 --- a/src/dictionary.ts +++ b/src/dictionary.ts @@ -25,6 +25,7 @@ export const base = { alignImageDefault: "Center large", em: "Italic", embedInvalidLink: "Sorry, that link won’t work for this embed type", + file: "File", findOrCreateDoc: "Find or create a doc…", h1: "Big heading", h2: "Medium heading", diff --git a/src/index.tsx b/src/index.tsx index 29eed9fba..e678f7789 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -110,6 +110,7 @@ export type Props = { | "heading" | "hr" | "image" + | "file" | "list_item" | "container_notice" | "ordered_list" @@ -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; @@ -180,6 +184,12 @@ class RichMarkdownEditor extends React.PureComponent { onImageUploadStop: () => { // no default behavior }, + onFileUploadStart: () => { + // no default behavior + }, + onFileUploadStop: () => { + // no default behavior + }, onClickLink: href => { window.open(href, "_blank"); }, @@ -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/menus/block.ts b/src/menus/block.ts index 59d355c96..0d4c5821a 100644 --- a/src/menus/block.ts +++ b/src/menus/block.ts @@ -11,6 +11,7 @@ import { TableIcon, TodoListIcon, ImageIcon, + NotepadIcon, StarredIcon, WarningIcon, InfoIcon, @@ -115,6 +116,12 @@ export default function blockMenuItems( icon: ImageIcon, keywords: "picture photo", }, + { + name: "file", + title: dictionary.file, + icon: NotepadIcon, + keywords: "doc pdf", + }, { name: "link", title: dictionary.link, diff --git a/tsconfig.json b/tsconfig.json index 950397d17..3a4f70612 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,4 +29,4 @@ "dist", "node_modules" ] -} \ No newline at end of file +}