Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support paste and drop images to draft editor #4700

Merged
merged 22 commits into from
Aug 5, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
512d642
feat(editor): add pasteDropFile extention
robertu7 Jul 31, 2024
82a42a6
feat(editor): move PasteDropFile to @matters/matters-editor
robertu7 Aug 1, 2024
80ba2ab
feat(editor): cache previous upload
robertu7 Aug 1, 2024
e7428e7
feat(editor): use figureImageUploader in UploadImageButton
robertu7 Aug 1, 2024
db93b86
feat(editor): delete node before insert figureImage
robertu7 Aug 1, 2024
b1194f3
fix(editor): skip figcaption length check if content is empty
robertu7 Aug 1, 2024
2dc8673
feat(editor): support insert mutiple files
robertu7 Aug 1, 2024
c13d790
fix: fix typo
robertu7 Aug 1, 2024
170488b
fix(ts): correct types
robertu7 Aug 2, 2024
c78fe87
feat(draft): move useCreateDraft to DraftDetailStateProvider
robertu7 Aug 2, 2024
44c9439
feat(editor): resolve with result for addRequest
robertu7 Aug 2, 2024
03b2557
feat(editor): custom drop cursor style
robertu7 Aug 2, 2024
1ff4fa1
feat(editor): fallback to singleFileUpload if fail to directImageUpload
robertu7 Aug 2, 2024
25cf46e
feat(editor): make draft `upload` run in sequence
robertu7 Aug 2, 2024
d0e02b8
feat(editor): remove unused
robertu7 Aug 2, 2024
558ac63
feat(editor): restore cursor position after insert image uploader
robertu7 Aug 2, 2024
2eef533
feat(editor): insert new paragraph after image uploader
robertu7 Aug 2, 2024
f22fb97
feat(editor): outline for selected figure
robertu7 Aug 2, 2024
a881c7c
feat(editor): move captionLimit and figurePlaceholder to @matters/mat…
robertu7 Aug 4, 2024
c14da96
feat(editor): skip invalid images
robertu7 Aug 4, 2024
8bf8d76
feat(editor): show toast if not supported formats
robertu7 Aug 4, 2024
0053fbf
feat(editor): fallback drop handler for non-editor area
robertu7 Aug 4, 2024
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
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.0",
"@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
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
4 changes: 3 additions & 1 deletion src/components/Editor/Article/extensions/captionLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const CaptionLimit = Node.create<CaptionLimitOptions>({
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('captionLimit'),
key: new PluginKey(pluginName),
filterTransaction: (transaction, state) => {
// Nothing has changed, ignore it.
if (!transaction.docChanged || !this.options.maxCaptionLength) {
Expand All @@ -41,6 +41,8 @@ export const CaptionLimit = Node.create<CaptionLimitOptions>({
}

// limit figcaption length
if (anchorParent.content.size <= 0) return true

const figcaptionText = anchorParent.content.child(0).text || ''
if (figcaptionText.length > this.options.maxCaptionLength) {
return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ const Input: React.FC<NodeViewProps> = (props) => {
// restore paragraph node
if (isRestoreParagraph) {
props.editor.commands.insertContentAt(props.editor.state.selection.to, [
{
type: 'paragraph',
},
{ type: 'paragraph' },
])
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const FigureEmbedLinkInput = Node.create({
return {
placeholder: {
default: null,
parseHTML: (element) => element.getAttribute('data-placeholder'),
// parseHTML: (element) => element.getAttribute('data-placeholder'),
},
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react'
import { useEffect, useState } from 'react'
import { FormattedMessage } from 'react-intl'

import { ASSET_TYPE } from '~/common/enums'
import { validateImage } from '~/common/utils'
import { toast } from '~/components'

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

export type UploaderProps = {
file: File
upload: (input: {
file?: File
url?: string
type?: ASSET_TYPE.embed | ASSET_TYPE.embedaudio
mime?: string
}) => Promise<{
id: string
path: string
}>
}

const Uploader: React.FC<NodeViewProps> = (props) => {
const { editor, node, deleteNode, getPos } = props
const { file, upload } = node.attrs as UploaderProps
const [previewSrc] = useState(URL.createObjectURL(file))
const [progress, setProgress] = useState(0)
const duration = 3000 // 3 seconds
const intervalTime = 100 // Update every 100ms
const maxProgress = 99

const uploadAndReplace = async (file: File) => {
const mime = await validateImage(file)

if (!mime) return

try {
// read from storage cache to prevent duplicate upload
// when redo and undo
const assets = editor.storage.figureImageUploader.assets as {
[key: string]: string
}
let path = assets[previewSrc]

// upload and update cache
if (!path) {
path = (await upload({ file, type: ASSET_TYPE.embed, mime })).path

editor.storage.figureImageUploader.assets = {
...assets,
[previewSrc]: path,
}
}

// position to insert
const pos = getPos()

// delete node view
deleteNode()

// insert figure image
editor
.chain()
.insertContentAt(pos, [{ type: 'figureImage', attrs: { src: path } }])
.run()
} catch (e) {
deleteNode()

toast.error({
message: (
<FormattedMessage
defaultMessage="Failed to upload, please try again."
id="qfi4cg"
/>
),
})
}
}

// Simulate upload progress
useEffect(() => {
const increment = (maxProgress / duration) * intervalTime
const intervalId = setInterval(() => {
setProgress((prevProgress) => {
const newProgress = Math.floor(prevProgress + increment)
if (newProgress >= maxProgress) {
clearInterval(intervalId)
return maxProgress
}
return newProgress
})
}, intervalTime)

return () => clearInterval(intervalId)
}, [])

// Upload image
useEffect(() => {
uploadAndReplace(file)
}, [])

return (
<NodeViewWrapper>
<img src={previewSrc} alt="Uploading..." />
<span className={styles.progressIndicator}>{progress}%</span>
</NodeViewWrapper>
)
}

export default Uploader
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { mergeAttributes, ReactNodeViewRenderer } from '@matters/matters-editor'
import { Node } from '@tiptap/core'

import Uploader, { UploaderProps } from './Uploader'

/**
* FigureImageUploader is a extension to upload image and replace with FigureImage node after upload.
*/

declare module '@tiptap/core' {
interface Commands<ReturnType> {
figureImageUploader: {
insertFigureImageUploaders: (
options: Pick<UploaderProps, 'upload'> & { files: File[]; pos?: number }
) => ReturnType
}
}
}

const pluginName = 'figureImageUploader'

export const FigureImageUploader = Node.create({
name: pluginName,
group: 'block',
atom: true,

addAttributes() {
return {
file: { default: null },
upload: { default: null },
} as { [key in keyof UploaderProps]: { default: null } }
},

addStorage() {
return {
assets: {},
}
},

parseHTML() {
return [{ tag: 'figure-image-uploader' }]
},

renderHTML({ HTMLAttributes }) {
return ['figure-image-uploader', mergeAttributes(HTMLAttributes)]
},

addNodeView() {
return ReactNodeViewRenderer(Uploader, {
className: 'figure-image-uploader',
})
},

addCommands() {
return {
insertFigureImageUploaders:
({ files, pos, ...restAttrs }) =>
({ chain }) => {
const content = files.map((file) => ({
type: this.name,
attrs: {
...restAttrs,
file,
},
content: [],
}))

if (!pos) {
return chain().insertContent(content).run()
}

return chain().insertContentAt(pos, content).run()
},
}
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.progressIndicator {
position: absolute;
right: var(--sp12);
bottom: var(--sp12);
padding: var(--sp2) var(--sp12);
font-size: var(--text12);
font-weight: var(--font-normal);
line-height: 1.125rem;
color: var(--color-white);
content: attr(data-upload-progress);
background: rgb(0 0 0 / 40%);
border-radius: 4px;
}
Loading
Loading