Skip to content

Commit

Permalink
Merge pull request #4700 from thematters/feat/file-handler
Browse files Browse the repository at this point in the history
Support paste and drop images to draft editor
  • Loading branch information
robertu7 authored Aug 5, 2024
2 parents 7e9eeab + 0053fbf commit 8e55d6e
Show file tree
Hide file tree
Showing 29 changed files with 957 additions and 666 deletions.
3 changes: 3 additions & 0 deletions lang/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,9 @@
"defaultMessage": "Edit",
"description": "src/components/CircleComment/DropdownActions/index.tsx"
},
"91AzwP": {
"defaultMessage": "Only JPEG, PNG, and GIF and WebP images are supported."
},
"91IQdk": {
"defaultMessage": "Followed",
"description": "src/components/Buttons/FollowUser/Unfollow.tsx"
Expand Down
3 changes: 3 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,9 @@
"defaultMessage": "Edit",
"description": "src/components/CircleComment/DropdownActions/index.tsx"
},
"91AzwP": {
"defaultMessage": "Only JPEG, PNG, and GIF and WebP images are supported."
},
"91IQdk": {
"defaultMessage": "Followed",
"description": "src/components/Buttons/FollowUser/Unfollow.tsx"
Expand Down
3 changes: 3 additions & 0 deletions lang/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,9 @@
"defaultMessage": "编辑评论",
"description": "src/components/CircleComment/DropdownActions/index.tsx"
},
"91AzwP": {
"defaultMessage": "仅支持 JPEG、PNG、GIF 和 WebP 图片"
},
"91IQdk": {
"defaultMessage": "已追踪",
"description": "src/components/Buttons/FollowUser/Unfollow.tsx"
Expand Down
3 changes: 3 additions & 0 deletions lang/zh-Hant.json
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,9 @@
"defaultMessage": "編輯",
"description": "src/components/CircleComment/DropdownActions/index.tsx"
},
"91AzwP": {
"defaultMessage": "僅支持 JPEG、PNG、GIF 和 WebP 圖片"
},
"91IQdk": {
"defaultMessage": "已追蹤",
"description": "src/components/Buttons/FollowUser/Unfollow.tsx"
Expand Down
650 changes: 340 additions & 310 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 1 addition & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@artsy/fresnel": "^6.1.0",
"@ensdomains/content-hash": "^2.5.7",
"@matters/apollo-upload-client": "^11.1.0",
"@matters/matters-editor": "^0.2.5-alpha.6",
"@matters/matters-editor": "^0.3.0-alpha.3",
"@next/bundle-analyzer": "^13.4.9",
"@reach/alert": "^0.18.0",
"@reach/dialog": "^0.18.0",
Expand All @@ -55,10 +55,6 @@
"@stripe/react-stripe-js": "^2.1.1",
"@stripe/stripe-js": "^1.54.1",
"@tippyjs/react": "^4.2.6",
"@tiptap/extension-bubble-menu": "^2.4.0",
"@tiptap/extension-floating-menu": "^2.4.0",
"@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/suggestion": "^2.4.0",
"@use-gesture/react": "^10.3.1",
"apollo-cache-inmemory": "^1.6.6",
"apollo-client": "^2.6.10",
Expand Down
17 changes: 16 additions & 1 deletion src/common/utils/form/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import FileType from 'file-type/browser'
import { FormattedMessage } from 'react-intl'

import {
ACCEPTED_UPLOAD_IMAGE_TYPES,
UPLOAD_GIF_AVATAR_SIZE_LIMIT,
UPLOAD_IMAGE_AREA_LIMIT,
UPLOAD_IMAGE_DIMENSION_LIMIT,
Expand All @@ -16,11 +17,25 @@ export const getFileType = (
// return meme type or null if not valid
export const validateImage = (image: File, isAvatar: boolean = false) =>
new Promise<string | null>((resolve, reject) => {
// size limits
getFileType(image).then((fileType) => {
// mime type
if (!fileType) {
return resolve(null)
}
const isAcceptedType = ACCEPTED_UPLOAD_IMAGE_TYPES.includes(image.type)
if (!isAcceptedType) {
toast.error({
message: (
<FormattedMessage
defaultMessage="Only JPEG, PNG, and GIF and WebP images are supported."
id="91AzwP"
/>
),
})
return resolve(null)
}

// size limits
const isGIF = fileType.mime === 'image/gif'
const sizeLimit =
isAvatar && isGIF
Expand Down
89 changes: 68 additions & 21 deletions src/components/Context/DraftDetailState/index.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import { useMutation } from '@apollo/react-hooks'
import { createContext, useRef } from 'react'

import { randomString } from '~/common/utils'
import { useRoute } from '~/components'
import CREATE_DRAFT from '~/components/GQL/mutations/createDraft'
import { CreateDraftMutation } from '~/gql/graphql'
import { ME_DRAFTS_FEED } from '~/views/Me/Drafts/gql'
import { ME_WORKS_TABS } from '~/views/Me/Works/WorksTabs/gql'

type Job = {
id: string
fn: () => Promise<any>
resolve: (value: any) => void
reject: (reason?: any) => void
}

const NEW_DRAFT_ID = 'new'

export const DraftDetailStateContext = createContext(
{} as {
addRequest: (fn: () => Promise<any>) => void
addRequest: (fn: () => Promise<any>) => Promise<any>
getDraftId: () => string | undefined
isNewDraft: () => boolean
createDraft: (props: { onCreate: (draftId: string) => any }) => any
}
)

Expand All @@ -22,20 +31,25 @@ export const DraftDetailStateProvider = ({
}: {
children: React.ReactNode
}) => {
/**
* Request job queue
*/
// Run request jobs in sequence
const jobsRef = useRef<Job[]>()
const jobsRef = useRef<Job[]>([])
const runningRef = useRef<string>()

// push request job
const addRequest = (fn: () => Promise<any>) => {
const id = randomString()
const jobs = jobsRef.current || []
const newJobs = [...jobs, { id, fn }]
jobsRef.current = newJobs

if (!runningRef.current) {
runFirstJob()
}
const addRequest = (fn: () => Promise<any>): Promise<any> => {
return new Promise((resolve, reject) => {
const id = randomString()
const jobs = jobsRef.current
const newJob = { id, fn, resolve, reject }
jobsRef.current = [...jobs, newJob]

if (!runningRef.current) {
runFirstJob()
}
})
}

const getJobs = () => {
Expand All @@ -53,17 +67,50 @@ export const DraftDetailStateProvider = ({
return
}

// run first job
// Run first job
runningRef.current = firstJob.id
await firstJob.fn()
runningRef.current = ''

// set to rest jobs
const { restJobs } = getJobs()
jobsRef.current = restJobs
try {
const result = await firstJob.fn()
firstJob.resolve(result) // Resolve the promise with the job result
} catch (error) {
firstJob.reject(error) // Reject the promise if there's an error
} finally {
runningRef.current = ''

// Set to rest jobs
const { restJobs } = getJobs()
jobsRef.current = restJobs

// Run next job
runFirstJob()
}
}

// run next job
runFirstJob()
/**
* Draft getter and setter
*/
const { router } = useRoute()
const [create] = useMutation<CreateDraftMutation>(CREATE_DRAFT, {
// refetch /me/drafts once a new draft has been created
refetchQueries: [{ query: ME_DRAFTS_FEED }, { query: ME_WORKS_TABS }],
})

// create draft and shallow replace URL
const createDraft = async ({
onCreate,
}: {
onCreate: (draftId: string) => any
}) => {
const result = await create()
const { id } = result?.data?.putDraft || {}

if (!id) return

await onCreate(id)

await router.replace({ query: { draftId: id } }, undefined, {
shallow: true,
})
}

// get draft id from URL instead of `useRouter.getQuery`
Expand All @@ -84,7 +131,7 @@ export const DraftDetailStateProvider = ({

return (
<DraftDetailStateContext.Provider
value={{ addRequest, getDraftId, isNewDraft }}
value={{ addRequest, createDraft, getDraftId, isNewDraft }}
>
{children}
</DraftDetailStateContext.Provider>
Expand Down
8 changes: 5 additions & 3 deletions src/components/Editor/Article/BubbleMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
isTextSelection,
} from '@matters/matters-editor'
import classNames from 'classnames'
import { Node } from 'prosemirror-model'
import { useRef, useState } from 'react'
import { FormattedMessage, useIntl } from 'react-intl'

Expand Down Expand Up @@ -87,10 +88,11 @@ export const BubbleMenu: React.FC<BubbleMenuProps> = ({
const hasEditorFocus = view.hasFocus()
// || isChildOfMenu

// figureImage, figureAudio, figureEmbed contain `<figcaption>`
const isFigure = $anchor.parent.type.name.includes('figure')
const isFigure =
('node' in selection &&
(selection.node as Node).type.name.includes('figure')) ||
$anchor.parent.type.name.includes('figure')
const isHr = $anchor.nodeAfter?.type.name === 'horizontalRule'

const $grandParent = $anchor.node($anchor.depth - 1)
const isInBlockquote = $grandParent?.type.name === 'blockquote'

Expand Down
56 changes: 9 additions & 47 deletions src/components/Editor/Article/FloatingMenu/UploadImageButton.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { Editor } from '@matters/matters-editor'
import { VisuallyHidden } from '@reach/visually-hidden'
import classNames from 'classnames'
import { useState } from 'react'
import { FormattedMessage, useIntl } from 'react-intl'
import { useIntl } from 'react-intl'

import { ReactComponent as IconEditorImage } from '@/public/static/icons/editor-image.svg'
import { ACCEPTED_UPLOAD_IMAGE_TYPES, ASSET_TYPE } from '~/common/enums'
import { getFileType, validateImage } from '~/common/utils'
import { Icon, toast } from '~/components'
import { Icon } from '~/components'

import styles from './styles.module.css'

Expand All @@ -29,64 +26,30 @@ const UploadImageButton: React.FC<UploadImageButtonProps> = ({
upload,
}) => {
const intl = useIntl()
const [uploading, setUploading] = useState(false)

const acceptTypes = ACCEPTED_UPLOAD_IMAGE_TYPES.join(',')
const fieldId = 'editor-image-upload-form'

const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
event.stopPropagation()

if (!upload || !event.target || !event.target.files) {
return
}

const files = event.target.files
const files = event.target?.files

const hasInvalidImage = await Promise.all(
Array.from(files).map((file) => validateImage(file))
).then((results) => results.some((result) => !result))
if (hasInvalidImage) {
event.target.value = ''
if (!upload || !files || files.length <= 0) {
return
}

try {
setUploading(true)

for (const file of files) {
const mime = (await getFileType(file))!.mime
const { path } = await upload({ file, type: ASSET_TYPE.embed, mime })
editor.chain().focus().setFigureImage({ src: path }).run()
toast.success({
message: (
<FormattedMessage defaultMessage="Image uploaded" id="TcTp+J" />
),
})
}
} catch (e) {
toast.error({
message: (
<FormattedMessage
defaultMessage="Failed to upload, please try again."
id="qfi4cg"
/>
),
})
}
editor.commands.insertFigureImageUploaders({
files: Array.from(files),
upload,
})

event.target.value = ''
setUploading(false)
}

const labelClasses = classNames({
[styles.uploadButton]: true,
'u-area-disable': uploading,
})

return (
<label
className={labelClasses}
className={styles.uploadButton}
htmlFor={fieldId}
title={intl.formatMessage({
defaultMessage: 'Insert image',
Expand All @@ -104,7 +67,6 @@ const UploadImageButton: React.FC<UploadImageButtonProps> = ({
defaultMessage: 'Insert image',
id: 'Pv2PlK',
})}
disabled={uploading}
accept={acceptTypes}
multiple={true}
onChange={handleChange}
Expand Down
Loading

0 comments on commit 8e55d6e

Please sign in to comment.