Skip to content

Commit

Permalink
feat(RichTextEditor): integrate Image plugin (#3723)
Browse files Browse the repository at this point in the history
  • Loading branch information
TomasSlama authored Jul 17, 2023
1 parent 0efcb95 commit 11d8463
Show file tree
Hide file tree
Showing 37 changed files with 961 additions and 33 deletions.
8 changes: 8 additions & 0 deletions .changeset/fresh-suns-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@toptal/picasso-rich-text-editor': major
---

### RichText

- add support for images ([example](https://picasso.toptal.net/?path=/story/forms-richtexteditor--richtexteditor#image-upload))
- update peer dependency of Picasso to `37.1.0` (BREAKING CHANGE)
2 changes: 1 addition & 1 deletion cypress.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default defineConfig({

return config
},
specPattern: 'cypress/component/*.spec.tsx',
specPattern: 'cypress/component/**/*.spec.tsx',
devServer: {
framework: 'react',
bundler: 'webpack',
Expand Down
File renamed without changes.
159 changes: 159 additions & 0 deletions cypress/component/RichTextEditor/ImagePlugin.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import React, { useState } from 'react'
import type {
RichTextEditorProps,
UploadedImage,
} from '@toptal/picasso-rich-text-editor'
import { ImagePlugin, RichTextEditor } from '@toptal/picasso-rich-text-editor'
import { Container } from '@toptal/picasso'

const editorTestId = 'editor'
const imageUploadButtonTestId = 'image-upload-button'
const resultContainerTestId = 'result-container'

const defaultProps = {
id: 'foo',
onChange: () => {},
placeholder: 'placeholder',
testIds: {
editor: editorTestId,
imageUploadButton: imageUploadButtonTestId,
},
}

const editorSelector = `#${defaultProps.id}`

const Editor = (props: RichTextEditorProps) => {
const [value, setValue] = useState('')

return (
<Container style={{ maxWidth: '600px' }} padded='small'>
<RichTextEditor {...props} onChange={value => setValue(value)} />
<Container padded='small' data-testid={resultContainerTestId}>
{value}
</Container>
</Container>
)
}

const component = 'RichTextEditor'

const setAliases = () => {
cy.get(editorSelector).as('editor')
cy.getByTestId(imageUploadButtonTestId).as('imageUploadButton')
cy.getByTestId(resultContainerTestId).as('resultContainer')
cy.contains('placeholder').as('placeholder')
}

const getSubmitButton = () =>
cy.get('button').contains('Confirm').closest('button')

describe('ImagePlugin', () => {
describe('when image upload is successful', () => {
it('inserts image into rich text editor', () => {
const uploadedFileName = 'uploaded-image.png'
const uploadedFileContent =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAANklEQVR42u3OMQ0AAAgDsM2/aFBBwtEqaJOZPFZBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQcEbC6LIT9nCVOrVAAAAAElFTkSuQmCC'
const uploadedFileAltText = 'alt text'

cy.mount(
<Editor
{...{
...defaultProps,
plugins: [
<ImagePlugin
data-testid={imageUploadButtonTestId}
onUpload={(file: UploadedImage) =>
new Promise<UploadedImage>(resolve => {
setTimeout(
() =>
resolve({
...file,
url: uploadedFileContent,
}),
200
)
})
}
/>,
],
}}
/>
)
setAliases()

cy.get('@editor').click()
cy.get('@imageUploadButton').click()

cy.getByRole('dialog').contains('No file chosen')

getSubmitButton().should('be.disabled')

cy.get('input[type=file]').selectFile(
{
contents: Cypress.Buffer.from(''),
fileName: uploadedFileName,
},
{ force: true }
)

cy.getByRole('dialog').contains('Uploading ' + uploadedFileName)
cy.getByRole('dialog').contains(uploadedFileName)
cy.get('[placeholder="An Image Description"]').type(uploadedFileAltText)

getSubmitButton().should('not.be.disabled')
getSubmitButton().click()

cy.get('@resultContainer').contains(
`<p><img src="${uploadedFileContent}" alt="${uploadedFileAltText}"></p>`
)

cy.get('body').happoScreenshot({
component,
variant: 'image-plugin/successful-upload',
})
})
})

describe('when image upload fails', () => {
it('shows error', () => {
const fileUploadErrorMessage = 'Upload failed'

cy.mount(
<Editor
{...{
...defaultProps,
plugins: [
<ImagePlugin
data-testid={imageUploadButtonTestId}
onUpload={() =>
new Promise<UploadedImage>((resolve, reject) => {
setTimeout(() => reject(fileUploadErrorMessage), 200)
})
}
/>,
],
}}
/>
)
setAliases()

cy.get('@editor').click()
cy.get('@imageUploadButton').click()

cy.get('input[type=file]').selectFile(
{
contents: Cypress.Buffer.from('file contents'),
fileName: 'test.png',
},
{ force: true }
)

cy.get('p').contains(fileUploadErrorMessage).should('be.visible')

cy.get('[role="presentation"]').happoScreenshot({
component,
variant: 'image-plugin/failed-upload',
})
})
})
})
2 changes: 1 addition & 1 deletion packages/picasso-rich-text-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"url": "https://github.com/toptal/picasso/issues"
},
"peerDependencies": {
"@toptal/picasso": "^37.0.0",
"@toptal/picasso": "^37.1.0",
"@toptal/picasso-shared": "^12.0.0",
"@material-ui/core": "4.12.4",
"@lexical/utils": "0.11.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jest.mock('../plugins', () => ({
TextLengthPlugin: jest.fn(),
HeadingsReplacementPlugin: jest.fn(),
LinkPlugin: jest.fn(),
ImagePlugin: jest.fn(),
}))

jest.mock('@lexical/react/LexicalComposerContext', () => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type { ReactElement } from 'react'
import React, { cloneElement } from 'react'

import type { EditorPlugin } from '../..'
import { LinkPlugin } from '../../../plugins'
import type { RTEPlugin } from '../../../plugins/api'
import { isRTEPluginElement, RTEPluginMeta } from '../../../plugins/api'
import type { RTEPlugin } from '../../../plugins/api'
import { LinkPlugin, EmojiPlugin } from '../../../plugins'
import type { EditorPlugin } from '../..'
import type { CustomEmojiGroup } from '../../../plugins/EmojiPlugin'
import EmojiPlugin from '../../../plugins/EmojiPlugin'

const uniquePlugins = () => {
const plugins = new Set()
Expand All @@ -31,7 +30,6 @@ export const useComponentPlugins = (
switch (plugin) {
case 'link':
return <LinkPlugin />

case 'emoji':
return <EmojiPlugin customEmojis={customEmojis} />

Expand All @@ -42,7 +40,6 @@ export const useComponentPlugins = (
.filter(uniquePlugins())

const componentPlugins = mappedPlugins.filter(isRTEPluginElement)

const lexicalNodes = componentPlugins.flatMap(
plugin => plugin.type[RTEPluginMeta]?.lexical?.nodes ?? []
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react'
import type { ReactNode } from 'react'
import { makeStyles } from '@material-ui/core'

const useStyles = makeStyles({
default: {
maxWidth: 24,
height: 24,
verticalAlign: 'bottom',
},
})

const Emoji = (props: { children?: ReactNode }) => {
const classes = useStyles()

return <img className={classes.default} {...props} />
}

export default Emoji
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react'
import type { ReactNode } from 'react'
import { makeStyles } from '@material-ui/core'

const useStyles = makeStyles({
default: {
maxWidth: '100%',
},
})

const Image = (props: { children?: ReactNode }) => {
const classes = useStyles()

return <img className={classes.default} {...props} />
}

export default Image
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as Emoji } from './Emoji'
export { default as Image } from './Image'
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import toH from 'hast-to-hyperscript'
import type { ReactElement, ReactNode, FC } from 'react'
import React, { useMemo, createElement, isValidElement } from 'react'
import { makeStyles } from '@material-ui/core'
import Typography from '@toptal/picasso/Typography'
import Container from '@toptal/picasso/Container'
import List from '@toptal/picasso/List'
import ListItem from '@toptal/picasso/ListItem'
import Link from '@toptal/picasso/Link'

import type { ASTType } from '../../types'
import { Emoji, Image } from '../../components'
import { isCustomEmoji } from '../../../utils'

type Props = {
children?: React.ReactNode
Expand All @@ -19,14 +20,6 @@ const Li = ({ children, ...props }: Props) => (
<ListItem {...props}>{children}</ListItem>
)

const useStyles = makeStyles({
emoji: {
width: 24,
height: 24,
verticalAlign: 'bottom',
},
})

/* eslint-disable id-length */
const P = ({ children }: Props) => (
<Typography size='medium'>{children}</Typography>
Expand All @@ -51,11 +44,8 @@ const H3 = ({ children }: Props) => (
const Ul = ({ children }: Props) => <List variant='unordered'>{children}</List>
const Ol = ({ children }: Props) => <List variant='ordered'>{children}</List>
const A = ({ children, ...props }: Props) => <Link {...props}>{children}</Link>
const Emoji = ({ ...props }: Props) => {
const classes = useStyles()

return <img className={classes.emoji} {...props} />
}
const Img = ({ ...props }: Props) =>
isCustomEmoji(props) ? <Emoji {...props} /> : <Image {...props} />

const componentMap: Record<string, FC> = {
p: P,
Expand All @@ -66,7 +56,7 @@ const componentMap: Record<string, FC> = {
ol: Ol,
ul: Ul,
a: A,
img: Emoji,
img: Img,
} as const

const picassoMapper = (child: ReactNode): ReactNode => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ const ast: ASTType = {
properties: {},
children: [{ type: 'text', value: 'Position Description' }],
},
{
type: 'element',
tagName: 'p',
properties: {},
children: [
{
type: 'element',
tagName: 'img',
properties: {
src: './jacqueline/128x88.jpg',
},
children: [],
},
],
},
{
type: 'element',
tagName: 'p',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import React, { useState } from 'react'
import { Grid } from '@toptal/picasso'
import { RichText, RichTextEditor } from '@toptal/picasso-rich-text-editor'
import {
ImagePlugin,
RichText,
RichTextEditor,
} from '@toptal/picasso-rich-text-editor'
import { htmlToHast } from '@toptal/picasso-rich-text-editor/utils'
import type { CustomEmojiGroup } from '@toptal/picasso-rich-text-editor/RichTextEditor'

Expand All @@ -16,7 +20,13 @@ const Example = () => {
defaultValue={defaultValue}
onChange={setHtml}
id='editor'
plugins={['link', 'emoji']}
plugins={[
'link',
'emoji',
<ImagePlugin
onUpload={() => new Promise(resolve => setTimeout(resolve, 2000))}
/>,
]}
customEmojis={customEmojis}
/>
</Grid.Item>
Expand Down Expand Up @@ -55,6 +65,22 @@ const defaultValue: ASTType = {
properties: {},
children: [{ type: 'text', value: 'Position Description' }],
},
{
type: 'element',
tagName: 'p',
properties: {},
children: [
{
type: 'element',
tagName: 'img',
properties: {
src: './jacqueline/128x88.jpg',
alt: 'Jacqueline',
},
children: [],
},
],
},
{
type: 'element',
tagName: 'p',
Expand Down
Loading

0 comments on commit 11d8463

Please sign in to comment.