Skip to content
This repository has been archived by the owner on Jan 20, 2022. It is now read-only.

feat: Attachment of files as block to documents using s3 #624

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/commands/insertAllFiles.ts
Original file line number Diff line number Diff line change
@@ -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;
63 changes: 62 additions & 1 deletion src/components/CommandMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -31,6 +32,9 @@ export type Props<T extends MenuItem = MenuItem> = {
uploadImage?: (file: File) => Promise<string>;
onImageUploadStart?: () => void;
onImageUploadStop?: () => void;
uploadFile?: (file: File) => Promise<string>;
onFileUploadStart?: () => void;
onFileUploadStop?: () => void;
onShowToast?: (message: string, id: string) => void;
onLinkToolbarOpen?: () => void;
onClose: () => void;
Expand Down Expand Up @@ -61,6 +65,7 @@ type State = {
class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
menuRef = React.createRef<HTMLDivElement>();
inputRef = React.createRef<HTMLInputElement>();
fileInputRef = React.createRef<HTMLInputElement>();

state: State = {
left: -1000,
Expand Down Expand Up @@ -177,6 +182,8 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
switch (item.name) {
case "image":
return this.triggerImagePick();
case "container_file":
return this.triggerFilePick();
case "embed":
return this.triggerLinkInput(item);
case "link": {
Expand Down Expand Up @@ -254,6 +261,12 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
}
};

triggerFilePick = () => {
if (this.fileInputRef.current) {
this.fileInputRef.current.click();
}
};

triggerLinkInput = item => {
this.setState({ insertItem: item });
};
Expand Down Expand Up @@ -294,6 +307,40 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, 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();
};
Expand Down Expand Up @@ -401,6 +448,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
embeds = [],
search = "",
uploadImage,
uploadFile,
commands,
filterable = true,
} = this.props;
Expand Down Expand Up @@ -438,6 +486,9 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, 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;

Expand All @@ -455,7 +506,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
}

render() {
const { dictionary, isActive, uploadImage } = this.props;
const { dictionary, isActive, uploadImage, uploadFile } = this.props;
const items = this.filtered;
const { insertItem, ...positioning } = this.state;

Expand Down Expand Up @@ -523,6 +574,16 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
/>
</VisuallyHidden>
)}
{uploadFile && (
<VisuallyHidden>
<input
type="file"
ref={this.fileInputRef}
onChange={this.handleFilePicked}
accept="*"
/>
</VisuallyHidden>
)}
</Wrapper>
</Portal>
);
Expand Down
1 change: 1 addition & 0 deletions src/dictionary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 14 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -135,13 +136,16 @@ export type Props = {
[name: string]: (view: EditorView, event: Event) => boolean;
};
uploadImage?: (file: File) => Promise<string>;
uploadFile?: (file: File) => Promise<string>;
onBlur?: () => void;
onFocus?: () => void;
onSave?: ({ done: boolean }) => void;
onCancel?: () => void;
onChange?: (value: () => string) => void;
onImageUploadStart?: () => void;
onImageUploadStop?: () => void;
onFileUploadStart?: () => void;
onFileUploadStop?: () => void;
onCreateLink?: (title: string) => Promise<string>;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
onClickLink: (href: string, event: MouseEvent) => void;
Expand Down Expand Up @@ -335,6 +339,13 @@ class RichMarkdownEditor extends React.PureComponent<Props, State> {
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,
Expand Down Expand Up @@ -527,7 +538,6 @@ class RichMarkdownEditor extends React.PureComponent<Props, State> {
if (!this.element) {
throw new Error("createView called before ref available");
}

const isEditingCheckbox = tr => {
return tr.steps.some(
(step: Step) =>
Expand Down Expand Up @@ -805,9 +815,12 @@ class RichMarkdownEditor extends React.PureComponent<Props, State> {
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}
/>
Expand Down
2 changes: 2 additions & 0 deletions src/lib/renderToHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -18,6 +19,7 @@ const defaultRules = [
underlinesRule,
tablesRule,
noticesRule,
filesRule,
emojiRule,
];

Expand Down
46 changes: 46 additions & 0 deletions src/lib/uploadFilePlaceholder.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions src/menus/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
TodoListIcon,
ImageIcon,
StarredIcon,
DocumentIcon,
WarningIcon,
InfoIcon,
LinkIcon,
Expand Down Expand Up @@ -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",
},
];
}
Loading