From 1aea8be941ac204e08c5e27c9709d8ae49d5af32 Mon Sep 17 00:00:00 2001 From: Vyrtsev Mikhail Date: Fri, 12 Apr 2019 05:00:09 +0300 Subject: [PATCH] add image upload drag and drop --- web/app/common/api.ts | 24 ++- web/app/common/fetcher.ts | 43 ++-- web/app/common/static_store.ts | 1 + web/app/common/types.ts | 21 ++ web/app/components/comment/comment.tsx | 5 +- .../components/comment/connected-comment.ts | 4 + .../input/_theme/_dark/input_theme_dark.scss | 2 +- web/app/components/input/input.tsx | 202 +++++++++++++++++- .../components/input/textarea-autosize.tsx | 23 ++ web/app/components/root/root.tsx | 5 + web/app/utils/replaceSelection.ts | 3 + web/app/utils/sleep.ts | 3 + 12 files changed, 311 insertions(+), 25 deletions(-) create mode 100644 web/app/utils/replaceSelection.ts create mode 100644 web/app/utils/sleep.ts diff --git a/web/app/common/api.ts b/web/app/common/api.ts index b5948148..68133ad0 100644 --- a/web/app/common/api.ts +++ b/web/app/common/api.ts @@ -1,6 +1,6 @@ import { siteId, url } from './settings'; -import { BASE_URL } from './constants'; -import { Config, Comment, Tree, User, BlockedUser, Sorting, AuthProvider, BlockTTL } from './types'; +import { BASE_URL, API_BASE } from './constants'; +import { Config, Comment, Tree, User, BlockedUser, Sorting, AuthProvider, BlockTTL, Image } from './types'; import fetcher from './fetcher'; /* common */ @@ -234,6 +234,25 @@ export const enableComments = (): Promise => withCredentials: true, }); +export const uploadImage = (image: File): Promise => { + const data = new FormData(); + data.append('file', image); + + return fetcher + .post<{ id: string }>({ + url: `/picture`, + withCredentials: true, + contentType: 'multipart/form-data', + body: data, + }) + .then(resp => ({ + name: image.name, + size: image.size, + type: image.type, + url: BASE_URL + API_BASE + '/picture/' + resp.id, + })); +}; + export default { logIn, logOut, @@ -260,4 +279,5 @@ export default { getBlocked, disableComments, enableComments, + uploadImage, }; diff --git a/web/app/common/fetcher.ts b/web/app/common/fetcher.ts index 25ce3fba..a72c4c0d 100644 --- a/web/app/common/fetcher.ts +++ b/web/app/common/fetcher.ts @@ -6,29 +6,46 @@ import { getCookie } from './cookies'; export type FetcherMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head'; const methods: FetcherMethod[] = ['get', 'post', 'put', 'patch', 'delete', 'head']; -type FetcherInit = - | string - | { - url: string; - body?: string | object | Blob | ArrayBuffer; - overriddenApiBase?: string; - withCredentials?: boolean; - }; +interface FetcherInitBase { + url: string; + overriddenApiBase?: string; + withCredentials?: boolean; +} + +interface FetcherInitJSON extends FetcherInitBase { + contentType?: 'application/json'; + body?: string | object | Blob | ArrayBuffer; +} + +interface FetcherInitMultipart extends FetcherInitBase { + contentType: 'multipart/form-data'; + body: FormData; +} + +type FetcherInit = string | FetcherInitJSON | FetcherInitMultipart; type FetcherObject = { [K in FetcherMethod]: (data: FetcherInit) => Promise }; const fetcher = methods.reduce>((acc, method) => { acc[method] = (data: FetcherInit): Promise => { - const { url, body = undefined, withCredentials = false, overriddenApiBase = API_BASE } = - typeof data === 'string' ? { url: data } : data; + const { + url, + body = undefined, + withCredentials = false, + overriddenApiBase = API_BASE, + contentType = 'application/json', + } = typeof data === 'string' ? { url: data } : data; const basename = `${BASE_URL}${overriddenApiBase}`; const headers = new Headers({ Accept: 'application/json', - 'Content-Type': 'application/json', 'X-XSRF-TOKEN': getCookie('XSRF-TOKEN') || '', }); + if (contentType !== 'multipart/form-data') { + headers.append('Content-Type', contentType); + } + let rurl = `${basename}${url}`; const parameters: RequestInit = { @@ -39,7 +56,9 @@ const fetcher = methods.reduce>((acc, method) => { }; if (body) { - if (typeof body === 'object') { + if (contentType === 'multipart/form-data') { + parameters.body = body as FormData; + } else if (typeof body === 'object' && !(body instanceof Blob) && !(body instanceof ArrayBuffer)) { parameters.body = JSON.stringify(body); } else { parameters.body = body; diff --git a/web/app/common/static_store.ts b/web/app/common/static_store.ts index ad317971..ea566b6d 100644 --- a/web/app/common/static_store.ts +++ b/web/app/common/static_store.ts @@ -25,6 +25,7 @@ export const StaticStore: StaticStoreType = { low_score: 0, positive_score: false, readonly_age: 0, + max_image_size: 0, }, query: querySettings as QuerySettingsType, }; diff --git a/web/app/common/types.ts b/web/app/common/types.ts index d1843789..14095f29 100644 --- a/web/app/common/types.ts +++ b/web/app/common/types.ts @@ -101,6 +101,7 @@ export interface Config { critical_score: number; positive_score: boolean; readonly_age: number; + max_image_size: number; } export interface RemarkConfig { @@ -138,3 +139,23 @@ export enum CommentMode { Reply, Edit, } + +/** + * Used as api.uploadImage response type + */ +export interface Image { + name: string; + size: number; + /** mime type of an image */ + type: string; + url: string; +} + +/** error struct returned in case of api call error */ +export interface ApiError { + code: number; + /** simple explanation */ + details: string; + /** in-depth explanation */ + error: string; +} diff --git a/web/app/components/comment/comment.tsx b/web/app/components/comment/comment.tsx index c4076905..575d0db8 100644 --- a/web/app/components/comment/comment.tsx +++ b/web/app/components/comment/comment.tsx @@ -11,7 +11,7 @@ import { API_BASE, BASE_URL, COMMENT_NODE_CLASSNAME_PREFIX, BLOCKING_DURATIONS } import { StaticStore } from '@app/common/static_store'; import debounce from '@app/utils/debounce'; import copy from '@app/common/copy'; -import { Theme, BlockTTL, Comment as CommentType, PostInfo, User, CommentMode } from '@app/common/types'; +import { Theme, BlockTTL, Comment as CommentType, PostInfo, User, CommentMode, Image } from '@app/common/types'; import { extractErrorMessageFromResponse, FetcherResponse } from '@app/utils/errorUtils'; import { Input } from '@app/components/input'; @@ -54,6 +54,7 @@ export interface Props { blockUser?(id: User['id'], name: User['name'], ttl: BlockTTL): Promise; unblockUser?(id: User['id']): Promise; setVerifyStatus?(id: User['id'], value: boolean): Promise; + uploadImage?(image: File): Promise; } export interface State { @@ -675,6 +676,7 @@ export class Comment extends Component { onCancel={this.toggleReplying} getPreview={this.props.getPreview!} autofocus={true} + uploadImage={this.props.uploadImage!} /> )} @@ -689,6 +691,7 @@ export class Comment extends Component { getPreview={this.props.getPreview!} errorMessage={state.editDeadline === null ? 'Editing time has expired.' : undefined} autofocus={true} + uploadImage={this.props.uploadImage!} /> )} diff --git a/web/app/components/comment/connected-comment.ts b/web/app/components/comment/connected-comment.ts index cf84725b..daf5f694 100644 --- a/web/app/components/comment/connected-comment.ts +++ b/web/app/components/comment/connected-comment.ts @@ -21,6 +21,7 @@ import { blockUser, unblockUser, setVirifiedStatus } from '@app/store/user/actio import { Comment, Props } from './comment'; import { getCommentMode } from '@app/store/comments/getters'; +import { uploadImage } from '@app/common/api'; const mapProps = (state: StoreState, cprops: { data: CommentType }) => { const props: Pick< @@ -51,6 +52,7 @@ const mapDispatchToProps = (dispatch: StoreDispatch) => { | 'blockUser' | 'unblockUser' | 'setVerifyStatus' + | 'uploadImage' > = { addComment: (text: string, title: string, pid?: CommentType['id']) => dispatch(addComment(text, title, pid)), updateComment: (id: CommentType['id'], text: string) => dispatch(updateComment(id, text)), @@ -63,6 +65,8 @@ const mapDispatchToProps = (dispatch: StoreDispatch) => { blockUser: (id: User['id'], name: User['name'], ttl: BlockTTL) => dispatch(blockUser(id, name, ttl)), unblockUser: (id: User['id']) => dispatch(unblockUser(id)), setVerifyStatus: (id: User['id'], value: boolean) => dispatch(setVirifiedStatus(id, value)), + // should i made it as store action? + uploadImage: (image: File) => uploadImage(image), }; return props; diff --git a/web/app/components/input/_theme/_dark/input_theme_dark.scss b/web/app/components/input/_theme/_dark/input_theme_dark.scss index 252cb424..8cbc314a 100644 --- a/web/app/components/input/_theme/_dark/input_theme_dark.scss +++ b/web/app/components/input/_theme/_dark/input_theme_dark.scss @@ -33,6 +33,6 @@ } .input__preview-wrapper { - background: #eee; + background: #333; } } diff --git a/web/app/components/input/input.tsx b/web/app/components/input/input.tsx index daab9afd..0bf22f00 100644 --- a/web/app/components/input/input.tsx +++ b/web/app/components/input/input.tsx @@ -7,13 +7,15 @@ import './styles'; import { h, Component, RenderableProps } from 'preact'; import b, { Mix } from 'bem-react-helper'; -import { User, Theme } from '@app/common/types'; +import { User, Theme, Image, ApiError } from '@app/common/types'; import { BASE_URL, API_BASE } from '@app/common/constants'; import { StaticStore } from '@app/common/static_store'; import { siteId, url, pageTitle } from '@app/common/settings'; import { extractErrorMessageFromResponse } from '@app/utils/errorUtils'; import TextareaAutosize from './textarea-autosize'; +import { sleep } from '@app/utils/sleep'; +import { replaceSelection } from '@app/utils/replaceSelection'; const RSS_THREAD_URL = `${BASE_URL}${API_BASE}/rss/post?site=${siteId}&url=${url}`; const RSS_SITE_URL = `${BASE_URL}${API_BASE}/rss/site?site=${siteId}`; @@ -33,15 +35,22 @@ interface Props { getPreview(text: string): Promise; /** action on cancel. optional as root input has no cancel option */ onCancel?: () => void; + uploadImage: (image: File) => Promise; } interface State { preview: string | null; isErrorShown: boolean; + /** error message, if contains newlines, it will be splitted to multiple errors */ errorMessage: string | null; + /** prevents error hiding on input event */ + errorLock: boolean; isDisabled: boolean; maxLength: number; + /** main input value */ text: string; + /** override main button text */ + buttonText: null | string; } const Labels = { @@ -50,7 +59,10 @@ const Labels = { reply: 'Reply', }; +const ImageMimeRegex = /image\//i; + export class Input extends Component { + /** reference to textarea element */ textAreaRef?: TextareaAutosize; constructor(props: Props) { @@ -60,15 +72,22 @@ export class Input extends Component { preview: null, isErrorShown: false, errorMessage: null, + errorLock: false, isDisabled: false, maxLength: StaticStore.config.max_comment_size, text: props.value || '', + buttonText: null, }; this.send = this.send.bind(this); this.getPreview = this.getPreview.bind(this); this.onInput = this.onInput.bind(this); this.onKeyDown = this.onKeyDown.bind(this); + this.onDragOver = this.onDragOver.bind(this); + this.onDrop = this.onDrop.bind(this); + this.appendError = this.appendError.bind(this); + this.uploadImage = this.uploadImage.bind(this); + this.uploadImages = this.uploadImages.bind(this); } componentWillReceiveProps(nextProps: Props) { @@ -97,10 +116,17 @@ export class Input extends Component { } onInput(e: Event) { + if (this.state.errorLock) { + this.setState({ + preview: null, + text: (e.target as HTMLInputElement).value, + }); + return; + } this.setState({ - preview: null, isErrorShown: false, errorMessage: null, + preview: null, text: (e.target as HTMLInputElement).value, }); } @@ -148,10 +174,165 @@ export class Input extends Component { }); } - render(props: RenderableProps, { isDisabled, isErrorShown, errorMessage, preview, maxLength, text }: State) { + /** appends error to input's error block */ + appendError(...errors: string[]) { + if (!this.state.errorMessage) { + this.setState({ + errorMessage: errors.join('\n'), + isErrorShown: true, + }); + return; + } + this.setState({ + errorMessage: this.state.errorMessage + '\n' + errors.join('\n'), + isErrorShown: true, + }); + } + + onDragOver(e: DragEvent) { + if (StaticStore.config.max_image_size === 0) return; + if (!this.textAreaRef) return; + if (!e.dataTransfer) return; + const items = Array.from(e.dataTransfer.items); + if (Array.from(items).filter(i => i.kind === 'file' && ImageMimeRegex.test(i.type)).length === 0) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + } + + onDrop(e: DragEvent) { + if (StaticStore.config.max_image_size === 0) return; + if (!e.dataTransfer) return; + + const data = Array.from(e.dataTransfer.files).filter(f => ImageMimeRegex.test(f.type)); + if (data.length === 0) return; + + e.preventDefault(); + + this.uploadImages(data); + } + + /** wrapper with error handling for props.uploadImage */ + uploadImage(file: File): Promise { + return this.props + .uploadImage(file) + .catch( + (e: ApiError | string) => + new Error( + typeof e === 'string' + ? `${file.name} upload failed with "${e}"` + : `${file.name} upload failed with "${e.error}"` + ) + ); + } + + /** performs upload process */ + async uploadImages(files: File[]) { + if (!this.textAreaRef) return; + + /** Human readable image size limit, i.e 5MB */ + const maxImageSizeString = (StaticStore.config.max_image_size / 1024 / 1024).toFixed(2) + 'MB'; + /** upload delay to avoid server rate limiter */ + const uploadDelay = 5000; + + const isSelectionSupported = this.textAreaRef.isSelectionSupported(); + + this.setState({ + errorLock: true, + errorMessage: null, + isErrorShown: false, + isDisabled: true, + buttonText: 'Uploading...', + }); + + // fallback for ie < 9 + if (!isSelectionSupported) { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const isFirst = i === 0; + const placeholderStart = this.state.text.length === 0 ? '' : '\n'; + + if (file.size > StaticStore.config.max_image_size) { + this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`); + continue; + } + + !isFirst && (await sleep(uploadDelay)); + + const result = await this.uploadImage(file); + + if (result instanceof Error) { + this.appendError(result.message); + continue; + } + + const markdownString = `${placeholderStart}![${result.name}](${result.url})`; + this.setState({ + text: this.state.text + markdownString, + }); + } + + this.setState({ errorLock: false, isDisabled: false, buttonText: null }); + return; + } + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const isFirst = i === 0; + const placeholderStart = this.state.text.length === 0 ? '' : '\n'; + + const uploadPlaceholder = `${placeholderStart}![uploading ${file.name}...]()`; + const uploadPlaceholderLength = uploadPlaceholder.length; + const selection = this.textAreaRef.getSelection(); + /** saved selection in case of error */ + const originalText = this.state.text; + const restoreSelection = async () => { + this.setState({ + text: originalText, + }); + /** sleeping awhile so textarea catch state change and its selection */ + await sleep(100); + this.textAreaRef!.setSelection(selection); + }; + + if (file.size > StaticStore.config.max_image_size) { + this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`); + continue; + } + + this.setState({ + text: replaceSelection(this.state.text, selection, uploadPlaceholder), + }); + + !isFirst && (await sleep(uploadDelay)); + + const result = await this.uploadImage(file); + + if (result instanceof Error) { + this.appendError(result.message); + await restoreSelection(); + continue; + } + + const markdownString = `${placeholderStart}![${result.name}](${result.url})`; + this.setState({ + text: replaceSelection(this.state.text, [selection[0], selection[0] + uploadPlaceholderLength], markdownString), + }); + /** sleeping awhile so textarea catch state change and its selection */ + await sleep(100); + const selectionPointer = selection[0] + markdownString.length; + this.textAreaRef.setSelection([selectionPointer, selectionPointer]); + } + + this.setState({ errorLock: false, isDisabled: false, buttonText: null }); + } + + render( + props: RenderableProps, + { isDisabled, isErrorShown, errorMessage, preview, maxLength, text, buttonText }: State + ) { const charactersLeft = maxLength - text.length; errorMessage = props.errorMessage || errorMessage; - const label = Labels[props.mode || 'main']; + const label = buttonText || Labels[props.mode || 'main']; return (
{ })} onSubmit={this.send} aria-label="New comment" + onDragOver={this.onDragOver} + onDrop={this.onDrop} >
{ {charactersLeft < 100 && {charactersLeft}}
- {(isErrorShown || !!errorMessage) && ( -

- {errorMessage || 'Something went wrong. Please try again a bit later.'} -

- )} + {(isErrorShown || !!errorMessage) && + (errorMessage || 'Something went wrong. Please try again a bit later.').split('\n').map(e => ( +

+ {e} +

+ ))}