From 11d846365006281218dcf36a4b322618183963ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Sl=C3=A1ma?= Date: Mon, 17 Jul 2023 16:41:17 +0200 Subject: [PATCH] feat(RichTextEditor): integrate Image plugin (#3723) --- .changeset/fresh-suns-hang.md | 8 + cypress.config.mjs | 2 +- .../Default.spec.tsx} | 0 .../RichTextEditor/ImagePlugin.spec.tsx | 159 ++++++++++++++++++ .../picasso-rich-text-editor/package.json | 2 +- .../src/LexicalEditor/LexicalEditor.test.tsx | 1 + .../useComponentPlugins.tsx | 9 +- .../src/RichText/components/Emoji.tsx | 19 +++ .../src/RichText/components/Image.tsx | 17 ++ .../src/RichText/components/index.ts | 2 + .../hooks/useRichText/useRichText.tsx | 20 +-- .../src/RichText/story/Default.example.tsx | 15 ++ .../src/RichText/story/HTML.example.tsx | 30 +++- .../src/RichText/types.ts | 16 +- .../story/ImageUpload.example.tsx | 77 +++++++++ .../src/RichTextEditor/story/index.jsx | 1 + .../picasso-rich-text-editor/src/index.ts | 2 + .../EmojiPlugin/nodes/CustomEmojiNode.tsx | 22 ++- .../src/plugins/ImagePlugin/ImagePlugin.tsx | 52 ++++++ .../src/plugins/ImagePlugin/commands/index.ts | 7 + .../ImagePluginButton/ImagePluginButton.tsx | 25 +++ .../components/ImagePluginButton/index.ts | 1 + .../ImagePluginModal/ImagePluginModal.tsx | 92 ++++++++++ .../components/ImagePluginModal/index.ts | 2 + .../plugins/ImagePlugin/components/index.ts | 2 + .../src/plugins/ImagePlugin/hooks/index.ts | 2 + .../ImagePlugin/hooks/use-image-plugin.ts | 66 ++++++++ .../hooks/use-image-uploader.test.tsx | 49 ++++++ .../ImagePlugin/hooks/use-image-uploader.ts | 78 +++++++++ .../src/plugins/ImagePlugin/index.ts | 3 + .../plugins/ImagePlugin/nodes/ImageNode.tsx | 138 +++++++++++++++ .../src/plugins/ImagePlugin/nodes/index.ts | 1 + .../src/plugins/ImagePlugin/types.ts | 7 + .../src/plugins/index.ts | 3 + .../utils/__tests__/is-custom-emoji.test.ts | 45 +++++ .../src/utils/index.ts | 1 + .../src/utils/is-custom-emoji.ts | 18 ++ 37 files changed, 961 insertions(+), 33 deletions(-) create mode 100644 .changeset/fresh-suns-hang.md rename cypress/component/{RichTextEditor.spec.tsx => RichTextEditor/Default.spec.tsx} (100%) create mode 100644 cypress/component/RichTextEditor/ImagePlugin.spec.tsx create mode 100644 packages/picasso-rich-text-editor/src/RichText/components/Emoji.tsx create mode 100644 packages/picasso-rich-text-editor/src/RichText/components/Image.tsx create mode 100644 packages/picasso-rich-text-editor/src/RichText/components/index.ts create mode 100644 packages/picasso-rich-text-editor/src/RichTextEditor/story/ImageUpload.example.tsx create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/ImagePlugin.tsx create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/commands/index.ts create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginButton/ImagePluginButton.tsx create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginButton/index.ts create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginModal/ImagePluginModal.tsx create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginModal/index.ts create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/index.ts create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/index.ts create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/use-image-plugin.ts create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/use-image-uploader.test.tsx create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/use-image-uploader.ts create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/index.ts create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/nodes/ImageNode.tsx create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/nodes/index.ts create mode 100644 packages/picasso-rich-text-editor/src/plugins/ImagePlugin/types.ts create mode 100644 packages/picasso-rich-text-editor/src/utils/__tests__/is-custom-emoji.test.ts create mode 100644 packages/picasso-rich-text-editor/src/utils/is-custom-emoji.ts diff --git a/.changeset/fresh-suns-hang.md b/.changeset/fresh-suns-hang.md new file mode 100644 index 0000000000..b0227ac2b6 --- /dev/null +++ b/.changeset/fresh-suns-hang.md @@ -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) diff --git a/cypress.config.mjs b/cypress.config.mjs index c0f0e07acb..a7cb9a6345 100644 --- a/cypress.config.mjs +++ b/cypress.config.mjs @@ -17,7 +17,7 @@ export default defineConfig({ return config }, - specPattern: 'cypress/component/*.spec.tsx', + specPattern: 'cypress/component/**/*.spec.tsx', devServer: { framework: 'react', bundler: 'webpack', diff --git a/cypress/component/RichTextEditor.spec.tsx b/cypress/component/RichTextEditor/Default.spec.tsx similarity index 100% rename from cypress/component/RichTextEditor.spec.tsx rename to cypress/component/RichTextEditor/Default.spec.tsx diff --git a/cypress/component/RichTextEditor/ImagePlugin.spec.tsx b/cypress/component/RichTextEditor/ImagePlugin.spec.tsx new file mode 100644 index 0000000000..146e079232 --- /dev/null +++ b/cypress/component/RichTextEditor/ImagePlugin.spec.tsx @@ -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 ( + + setValue(value)} /> + + {value} + + + ) +} + +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( + + new Promise(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( + `

${uploadedFileAltText}

` + ) + + cy.get('body').happoScreenshot({ + component, + variant: 'image-plugin/successful-upload', + }) + }) + }) + + describe('when image upload fails', () => { + it('shows error', () => { + const fileUploadErrorMessage = 'Upload failed' + + cy.mount( + + new Promise((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', + }) + }) + }) +}) diff --git a/packages/picasso-rich-text-editor/package.json b/packages/picasso-rich-text-editor/package.json index 8ee439fbcf..ffdefb0ac1 100644 --- a/packages/picasso-rich-text-editor/package.json +++ b/packages/picasso-rich-text-editor/package.json @@ -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", diff --git a/packages/picasso-rich-text-editor/src/LexicalEditor/LexicalEditor.test.tsx b/packages/picasso-rich-text-editor/src/LexicalEditor/LexicalEditor.test.tsx index e6498b9114..f925bc8fbf 100644 --- a/packages/picasso-rich-text-editor/src/LexicalEditor/LexicalEditor.test.tsx +++ b/packages/picasso-rich-text-editor/src/LexicalEditor/LexicalEditor.test.tsx @@ -26,6 +26,7 @@ jest.mock('../plugins', () => ({ TextLengthPlugin: jest.fn(), HeadingsReplacementPlugin: jest.fn(), LinkPlugin: jest.fn(), + ImagePlugin: jest.fn(), })) jest.mock('@lexical/react/LexicalComposerContext', () => ({ diff --git a/packages/picasso-rich-text-editor/src/LexicalEditor/hooks/useComponentPlugins/useComponentPlugins.tsx b/packages/picasso-rich-text-editor/src/LexicalEditor/hooks/useComponentPlugins/useComponentPlugins.tsx index c2b4a98f69..47afb45749 100644 --- a/packages/picasso-rich-text-editor/src/LexicalEditor/hooks/useComponentPlugins/useComponentPlugins.tsx +++ b/packages/picasso-rich-text-editor/src/LexicalEditor/hooks/useComponentPlugins/useComponentPlugins.tsx @@ -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() @@ -31,7 +30,6 @@ export const useComponentPlugins = ( switch (plugin) { case 'link': return - case 'emoji': return @@ -42,7 +40,6 @@ export const useComponentPlugins = ( .filter(uniquePlugins()) const componentPlugins = mappedPlugins.filter(isRTEPluginElement) - const lexicalNodes = componentPlugins.flatMap( plugin => plugin.type[RTEPluginMeta]?.lexical?.nodes ?? [] ) diff --git a/packages/picasso-rich-text-editor/src/RichText/components/Emoji.tsx b/packages/picasso-rich-text-editor/src/RichText/components/Emoji.tsx new file mode 100644 index 0000000000..87ce57281b --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichText/components/Emoji.tsx @@ -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 +} + +export default Emoji diff --git a/packages/picasso-rich-text-editor/src/RichText/components/Image.tsx b/packages/picasso-rich-text-editor/src/RichText/components/Image.tsx new file mode 100644 index 0000000000..de7ce731ce --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichText/components/Image.tsx @@ -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 +} + +export default Image diff --git a/packages/picasso-rich-text-editor/src/RichText/components/index.ts b/packages/picasso-rich-text-editor/src/RichText/components/index.ts new file mode 100644 index 0000000000..280537aac7 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichText/components/index.ts @@ -0,0 +1,2 @@ +export { default as Emoji } from './Emoji' +export { default as Image } from './Image' diff --git a/packages/picasso-rich-text-editor/src/RichText/hooks/useRichText/useRichText.tsx b/packages/picasso-rich-text-editor/src/RichText/hooks/useRichText/useRichText.tsx index c58ab1757d..02b3a01a17 100644 --- a/packages/picasso-rich-text-editor/src/RichText/hooks/useRichText/useRichText.tsx +++ b/packages/picasso-rich-text-editor/src/RichText/hooks/useRichText/useRichText.tsx @@ -1,7 +1,6 @@ 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' @@ -9,6 +8,8 @@ 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 @@ -19,14 +20,6 @@ const Li = ({ children, ...props }: Props) => ( {children} ) -const useStyles = makeStyles({ - emoji: { - width: 24, - height: 24, - verticalAlign: 'bottom', - }, -}) - /* eslint-disable id-length */ const P = ({ children }: Props) => ( {children} @@ -51,11 +44,8 @@ const H3 = ({ children }: Props) => ( const Ul = ({ children }: Props) => {children} const Ol = ({ children }: Props) => {children} const A = ({ children, ...props }: Props) => {children} -const Emoji = ({ ...props }: Props) => { - const classes = useStyles() - - return -} +const Img = ({ ...props }: Props) => + isCustomEmoji(props) ? : const componentMap: Record = { p: P, @@ -66,7 +56,7 @@ const componentMap: Record = { ol: Ol, ul: Ul, a: A, - img: Emoji, + img: Img, } as const const picassoMapper = (child: ReactNode): ReactNode => { diff --git a/packages/picasso-rich-text-editor/src/RichText/story/Default.example.tsx b/packages/picasso-rich-text-editor/src/RichText/story/Default.example.tsx index 1adaf00119..d6734699e2 100644 --- a/packages/picasso-rich-text-editor/src/RichText/story/Default.example.tsx +++ b/packages/picasso-rich-text-editor/src/RichText/story/Default.example.tsx @@ -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', diff --git a/packages/picasso-rich-text-editor/src/RichText/story/HTML.example.tsx b/packages/picasso-rich-text-editor/src/RichText/story/HTML.example.tsx index 9066cee607..5544b58bf3 100644 --- a/packages/picasso-rich-text-editor/src/RichText/story/HTML.example.tsx +++ b/packages/picasso-rich-text-editor/src/RichText/story/HTML.example.tsx @@ -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' @@ -16,7 +20,13 @@ const Example = () => { defaultValue={defaultValue} onChange={setHtml} id='editor' - plugins={['link', 'emoji']} + plugins={[ + 'link', + 'emoji', + new Promise(resolve => setTimeout(resolve, 2000))} + />, + ]} customEmojis={customEmojis} /> @@ -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', diff --git a/packages/picasso-rich-text-editor/src/RichText/types.ts b/packages/picasso-rich-text-editor/src/RichText/types.ts index e6ba32e4c2..9b7fe9d73b 100644 --- a/packages/picasso-rich-text-editor/src/RichText/types.ts +++ b/packages/picasso-rich-text-editor/src/RichText/types.ts @@ -31,7 +31,21 @@ type CustomEmojiType = { children: [] } -export type ASTChildType = ElementType | TextType | CustomEmojiType +type CustomImageType = { + type: 'element' + tagName: 'img' + properties: { + src: string + alt?: string + } + children: [] +} + +export type ASTChildType = + | ElementType + | TextType + | CustomEmojiType + | CustomImageType export type ASTType = { type: 'root' diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/story/ImageUpload.example.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/story/ImageUpload.example.tsx new file mode 100644 index 0000000000..f1bbd1bfaa --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/story/ImageUpload.example.tsx @@ -0,0 +1,77 @@ +import React, { useState } from 'react' +import { Container, Radio } from '@toptal/picasso' +import type { UploadedImage } from '@toptal/picasso-rich-text-editor' +import { ImagePlugin, RichTextEditor } from '@toptal/picasso-rich-text-editor' + +import type { RichTextEditorChangeHandler } from '../types' + +// Imitate file upload function that sets image URL +const onUploadSucceeded = (uploadedImage: UploadedImage) => + new Promise(resolve => { + setTimeout(() => { + const fileUrl = `./jacqueline/128x128.jpg?originalFileName=${encodeURIComponent( + uploadedImage.file.name + )}` + + resolve({ ...uploadedImage, url: fileUrl }) + }, 2000) + }) + +// Imitate failure during upload +const onUploadFailed = () => + new Promise((resolve, reject) => { + setTimeout(() => { + reject('Upload failed') + }, 2000) + }) + +const Example = () => { + const [value, setValue] = useState() + const [useSuccessfulUpload, setUseSuccessfullUpload] = useState('true') + + const handleChange: RichTextEditorChangeHandler = newValue => + setValue(newValue) + + return ( + <> + + ) => { + setUseSuccessfullUpload(event.target.value) + }} + value={useSuccessfulUpload} + > + + + + + , + ]} + /> + + {value} + + + ) +} + +export default Example diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/story/index.jsx b/packages/picasso-rich-text-editor/src/RichTextEditor/story/index.jsx index 0c778b5662..29ba496fca 100644 --- a/packages/picasso-rich-text-editor/src/RichTextEditor/story/index.jsx +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/story/index.jsx @@ -37,3 +37,4 @@ page title: 'Emojis', takeScreenshot: false, }) + .addExample('RichTextEditor/story/ImageUpload.example.tsx', 'Image upload') diff --git a/packages/picasso-rich-text-editor/src/index.ts b/packages/picasso-rich-text-editor/src/index.ts index 90946a1649..fd324ce0ac 100644 --- a/packages/picasso-rich-text-editor/src/index.ts +++ b/packages/picasso-rich-text-editor/src/index.ts @@ -5,3 +5,5 @@ export type { } from './RichTextEditor' export { default as RichText } from './RichText' export type { RichTextProps, ASTType } from './RichText' +export { ImagePlugin, EmojiPlugin, LinkPlugin } from './plugins' +export type { UploadedImage, CustomEmoji, CustomEmojiGroup } from './plugins' diff --git a/packages/picasso-rich-text-editor/src/plugins/EmojiPlugin/nodes/CustomEmojiNode.tsx b/packages/picasso-rich-text-editor/src/plugins/EmojiPlugin/nodes/CustomEmojiNode.tsx index 736208f361..371463422f 100644 --- a/packages/picasso-rich-text-editor/src/plugins/EmojiPlugin/nodes/CustomEmojiNode.tsx +++ b/packages/picasso-rich-text-editor/src/plugins/EmojiPlugin/nodes/CustomEmojiNode.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { $applyNodeReplacement, DecoratorNode } from 'lexical' +import { DecoratorNode } from 'lexical' import type { DOMConversionMap, DOMConversionOutput, @@ -11,6 +11,8 @@ import type { Spread, } from 'lexical' +import { isCustomEmoji } from '../../../utils/' + export interface CustomEmojiPayload { src: string id: string @@ -101,10 +103,17 @@ export class CustomEmojiNode extends DecoratorNode { static importDOM(): DOMConversionMap | null { return { - img: () => ({ - conversion: convertImageElement, - priority: 0, - }), + img: (element: HTMLElement) => { + if (isCustomEmoji(element)) { + return { + conversion: convertImageElement, + priority: 1, + } + } + + // Return null to pass the parsing to other plugins + return null + }, } } @@ -136,6 +145,5 @@ export const $createCustomEmojiNode = ({ src, id, }: CustomEmojiPayload): CustomEmojiNode => { - // return new CustomEmojiNode(src, id) - return $applyNodeReplacement(new CustomEmojiNode(src, id)) + return new CustomEmojiNode(src, id) } diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/ImagePlugin.tsx b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/ImagePlugin.tsx new file mode 100644 index 0000000000..7467399df2 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/ImagePlugin.tsx @@ -0,0 +1,52 @@ +import React from 'react' + +import type { RTEPlugin } from '../api' +import { RTEPluginMeta, Toolbar } from '../api' +import { ImagePluginButton, ImagePluginModal } from './components' +import { ImageNode } from './nodes/ImageNode' +import type { OnUploadCallback } from './types' +import type { ImagePluginModalProps } from './components/ImagePluginModal' +import { useImagePlugin } from './hooks' + +const PLUGIN_NAME = 'image' + +export type Props = { + accept?: ImagePluginModalProps['accept'] + maxSize?: ImagePluginModalProps['maxSize'] + onUpload: OnUploadCallback + 'data-testid'?: string +} + +const ImagePlugin: RTEPlugin = ({ + accept, + maxSize, + onUpload, + 'data-testid': testId, +}: Props) => { + const { modalIsOpen, hideModal, showModal, onSubmit } = useImagePlugin() + + return ( + <> + + + + + + ) +} + +ImagePlugin[RTEPluginMeta] = { + name: PLUGIN_NAME, + lexical: { + nodes: [ImageNode], + }, +} + +export default ImagePlugin diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/commands/index.ts b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/commands/index.ts new file mode 100644 index 0000000000..0ab9034071 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/commands/index.ts @@ -0,0 +1,7 @@ +import { createCommand } from 'lexical' +import type { LexicalCommand } from 'lexical' + +import type { ImageNodePayload } from '../nodes/ImageNode' + +export const INSERT_IMAGE_COMMAND: LexicalCommand = + createCommand('INSERT_IMAGE_COMMAND') diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginButton/ImagePluginButton.tsx b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginButton/ImagePluginButton.tsx new file mode 100644 index 0000000000..e4290ab9f4 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginButton/ImagePluginButton.tsx @@ -0,0 +1,25 @@ +import { Image16 } from '@toptal/picasso' +import React from 'react' + +import { useRTEPluginContext } from '../../../api' +import RichTextEditorButton from '../../../../RichTextEditorButton' + +export type Props = { + 'data-testid'?: string + onClick: () => void +} + +const ImagePluginButton = ({ 'data-testid': testId, onClick }: Props) => { + const { disabled, focused } = useRTEPluginContext() + + return ( + } + onClick={onClick} + disabled={disabled || !focused} + data-testid={testId} + /> + ) +} + +export default ImagePluginButton diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginButton/index.ts b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginButton/index.ts new file mode 100644 index 0000000000..54dfa28173 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginButton/index.ts @@ -0,0 +1 @@ +export { default as ImagePluginButton } from './ImagePluginButton' diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginModal/ImagePluginModal.tsx b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginModal/ImagePluginModal.tsx new file mode 100644 index 0000000000..3150a630d7 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginModal/ImagePluginModal.tsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react' +import type { FileInputProps } from '@toptal/picasso' +import { Button, Modal, Form, Input, FileInput } from '@toptal/picasso' + +import type { OnUploadCallback, UploadedImage } from '../../types' +import { useImageUploader } from '../../hooks' + +export type Props = { + isOpen: boolean + accept?: FileInputProps['accept'] + maxSize?: number + onClose: () => void + onUpload: OnUploadCallback + onSubmit: (image: UploadedImage, altText: string) => void +} + +const MAX_NUMBER_OF_IMAGES = 1 + +const ImagePluginModal = ({ + accept = 'image/png, image/jpeg', + maxSize = 2, + isOpen, + onClose, + onUpload, + onSubmit, +}: Props) => { + const { + image: uploadedImage, + reset: resetUploader, + upload, + uploading, + } = useImageUploader({ onUpload, maxSize }) + const [altText, setAltText] = useState('') + + const resetModal = () => { + resetUploader() + setAltText('') + onClose() + } + + const handleConfirm = () => { + if (uploadedImage) { + onSubmit(uploadedImage, altText) + resetModal() + } + } + + const imageHasUrl = !!uploadedImage?.url + + return ( + + Select file + + + + + + Alt Text + setAltText(event.target.value)} + type='text' + /> + + + + + + + ) +} + +export default ImagePluginModal diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginModal/index.ts b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginModal/index.ts new file mode 100644 index 0000000000..df5b5c49da --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/ImagePluginModal/index.ts @@ -0,0 +1,2 @@ +export { default as ImagePluginModal } from './ImagePluginModal' +export type { Props as ImagePluginModalProps } from './ImagePluginModal' diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/index.ts b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/index.ts new file mode 100644 index 0000000000..c24bc89aaf --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/components/index.ts @@ -0,0 +1,2 @@ +export { ImagePluginButton } from './ImagePluginButton' +export { ImagePluginModal } from './ImagePluginModal' diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/index.ts b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/index.ts new file mode 100644 index 0000000000..852a0559f1 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/index.ts @@ -0,0 +1,2 @@ +export { useImageUploader } from './use-image-uploader' +export { useImagePlugin } from './use-image-plugin' diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/use-image-plugin.ts b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/use-image-plugin.ts new file mode 100644 index 0000000000..82d424b68f --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/use-image-plugin.ts @@ -0,0 +1,66 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { useModal } from '@toptal/picasso/utils' +import { useEffect } from 'react' +import { + $createParagraphNode, + $getSelection, + $insertNodes, + $isRootOrShadowRoot, + COMMAND_PRIORITY_EDITOR, +} from 'lexical' +import { $wrapNodeInElement } from '@lexical/utils' + +import { INSERT_IMAGE_COMMAND } from '../commands' +import type { ImageNodePayload } from '../nodes/ImageNode' +import { $createImageNode } from '../nodes/ImageNode' +import type { ImagePluginModalProps } from '../components/ImagePluginModal' +import type { UploadedImage } from '../types' + +export const useImagePlugin = () => { + const { isOpen: modalIsOpen, hideModal, showModal } = useModal() + const [editor] = useLexicalComposerContext() + + useEffect(() => { + return editor.registerCommand( + INSERT_IMAGE_COMMAND, + (imagePayload: ImageNodePayload) => { + const imageNode = $createImageNode(imagePayload) + + $insertNodes([imageNode]) + if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) { + $wrapNodeInElement(imageNode, $createParagraphNode).selectEnd() + } + + return true + }, + COMMAND_PRIORITY_EDITOR + ) + }, [editor]) + + const onSubmit: ImagePluginModalProps['onSubmit'] = ( + image: UploadedImage, + altText: string + ) => { + editor.update(() => { + if (image.url) { + const imageContainer = $createParagraphNode() + const imageNode = $createImageNode({ + alt: altText, + src: image.url, + }) + + imageContainer.append(imageNode) + const selection = $getSelection() + + selection?.insertNodes([imageContainer]) + } + }) + } + + return { + onSubmit, + modalIsOpen, + hideModal, + showModal, + } +} diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/use-image-uploader.test.tsx b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/use-image-uploader.test.tsx new file mode 100644 index 0000000000..4302483654 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/use-image-uploader.test.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { render, fireEvent } from '@toptal/picasso/test-utils' + +import { useImageUploader } from './use-image-uploader' +import type { Props } from './use-image-uploader' + +const TestComponent = (props: Props & { imageToUpload: File }) => { + const { image, upload } = useImageUploader(props) + + return ( + <> + +
{image?.error}
+ + ) +} + +describe('useImageUploader', () => { + describe('when uploaded file exceeds the size limit', () => { + it('returns file with error', () => { + const sizeLimitMB = 1 + const { getByText, getByTestId } = render( + new Promise(() => {})} + imageToUpload={ + { + size: sizeLimitMB * 2 * 1024 * 1024, + } as File + } + /> + ) + + const button = getByText('upload') + + fireEvent.click(button) + + expect(getByTestId('error-container')).toHaveTextContent( + `File size exceeds the ${sizeLimitMB}MB limit.` + ) + }) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/use-image-uploader.ts b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/use-image-uploader.ts new file mode 100644 index 0000000000..00eb852083 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/hooks/use-image-uploader.ts @@ -0,0 +1,78 @@ +import type React from 'react' +import { useState } from 'react' + +import type { OnUploadCallback, UploadedImage } from '../types' + +export type Props = { + maxSize?: number + onUpload: OnUploadCallback +} + +export const useImageUploader = ({ onUpload, maxSize }: Props) => { + const [image, setImage] = useState() + const [uploading, setUploading] = useState(false) + + const upload = (event: React.ChangeEvent) => { + if (!event.target || !event.target.files || !event.target.files.length) { + return null + } + + const uploadedFile = event.target.files[0] + const fileSizeIsInvalid = + (maxSize && uploadedFile.size / 1024 / 1024 > maxSize) || false + + if (fileSizeIsInvalid) { + setImage({ + file: uploadedFile, + uploading: false, + error: `File size exceeds the ${maxSize}MB limit.`, + url: '', + }) + + return + } + + const uploadedImage = { + file: uploadedFile, + uploading: false, + error: undefined, + url: '', + } + + const promise = onUpload(uploadedImage) + + setImage(uploadedImage) + setUploading(true) + + // eslint-disable-next-line promise/catch-or-return + promise + .then(image => { + if (!image.url) { + throw new Error('"url" has to be provided after upload') + } + + setImage(image) + + return image + }) + .catch((errorMessage: string) => { + setImage({ + ...uploadedImage, + error: errorMessage || 'Error uploading image', + }) + }) + .finally(() => setUploading(false)) + } + + const reset = () => { + setUploading(false) + setImage(undefined) + } + + return { + image, + upload, + uploading, + reset, + } +} diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/index.ts b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/index.ts new file mode 100644 index 0000000000..8814c479ec --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/index.ts @@ -0,0 +1,3 @@ +export { default } from './ImagePlugin' +export type { Props } from './ImagePlugin' +export type { UploadedImage } from './types' diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/nodes/ImageNode.tsx b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/nodes/ImageNode.tsx new file mode 100644 index 0000000000..729ad3cc07 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/nodes/ImageNode.tsx @@ -0,0 +1,138 @@ +import React from 'react' +import type { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalNode, + NodeKey, + SerializedLexicalNode, + Spread, +} from 'lexical' +import { DecoratorNode } from 'lexical' +import { Image } from '@toptal/picasso' + +export interface ImageNodePayload { + src: string + alt?: string +} + +type SerializedImageNode = Spread< + { + src: string + alt?: string + }, + SerializedLexicalNode +> + +const convertImageElement = (domNode: Node): null | DOMConversionOutput => { + if (domNode instanceof HTMLImageElement) { + const src = domNode.getAttribute('src') + const alt = domNode.getAttribute('alt') ?? undefined + + if (src) { + return { + node: $createImageNode({ + src, + alt, + }), + } + } + + return null + } + + return null +} + +export class ImageNode extends DecoratorNode { + src: string + alt?: string + + static getType() { + return 'img' + } + + static clone(node: ImageNode): ImageNode { + return new ImageNode(node.src, node.alt) + } + + constructor(src: string, alt?: string, key?: NodeKey) { + super(key) + this.src = src + this.alt = alt + } + + static importJSON(serializedNode: SerializedImageNode): ImageNode { + const { src, alt } = serializedNode + + return $createImageNode({ src, alt }) + } + + createDOM(config: EditorConfig): HTMLElement { + const container = document.createElement('span') + + const theme = config.theme + const className = theme.image + + if (className !== undefined) { + container.className = className + } + + return container + } + + updateDOM() { + return false + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('img') + + element.setAttribute('src', this.src) + if (this.alt) { + element.setAttribute('alt', this.alt) + } + + return { element } + } + + static importDOM(): DOMConversionMap | null { + return { + img: () => { + return { + conversion: convertImageElement, + priority: 0, + } + }, + } + } + + exportJSON(): SerializedImageNode { + return { + version: 1, + type: 'img', + src: this.src, + alt: this.alt, + } + } + + isInline() { + // Technically it is not inline in DOM, but setting this to false prevents image deletion + return true + } + + decorate() { + return {this.alt + } +} + +export const $isImageNode = ( + node: LexicalNode | null | undefined +): node is ImageNode => { + return node instanceof ImageNode +} + +export const $createImageNode = ({ src, alt }: ImageNodePayload): ImageNode => { + return new ImageNode(src, alt) +} diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/nodes/index.ts b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/nodes/index.ts new file mode 100644 index 0000000000..9336a747e6 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/nodes/index.ts @@ -0,0 +1 @@ +export { ImageNode } from './ImageNode' diff --git a/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/types.ts b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/types.ts new file mode 100644 index 0000000000..cc077d18df --- /dev/null +++ b/packages/picasso-rich-text-editor/src/plugins/ImagePlugin/types.ts @@ -0,0 +1,7 @@ +import type { FileUpload } from '@toptal/picasso/FileInput' + +export type UploadedImage = FileUpload & { + url?: string +} + +export type OnUploadCallback = (image: UploadedImage) => Promise diff --git a/packages/picasso-rich-text-editor/src/plugins/index.ts b/packages/picasso-rich-text-editor/src/plugins/index.ts index c6be082c09..8c55061168 100644 --- a/packages/picasso-rich-text-editor/src/plugins/index.ts +++ b/packages/picasso-rich-text-editor/src/plugins/index.ts @@ -1,8 +1,11 @@ export { default as EditorMaxIndentLevelPlugin } from './EditorMaxIndentLevelPlugin' export { default as EmojiPlugin } from './EmojiPlugin' +export type { CustomEmoji, CustomEmojiGroup } from './EmojiPlugin' export { default as HeadingsReplacementPlugin } from './HeadingsReplacementPlugin' export { default as LinkPlugin, Props as ListPluginProps } from './LinkPlugin' export { default as ListPlugin } from './ListPlugin' export { default as TextLengthPlugin } from './TextLengthPlugin' export { default as TriggerInitialOnChangePlugin } from './TriggerInitialOnChangePlugin' export { default as FocusOnLabelClickPlugin } from './FocusOnLabelClickPlugin' +export { default as ImagePlugin } from './ImagePlugin' +export type { UploadedImage } from './ImagePlugin' diff --git a/packages/picasso-rich-text-editor/src/utils/__tests__/is-custom-emoji.test.ts b/packages/picasso-rich-text-editor/src/utils/__tests__/is-custom-emoji.test.ts new file mode 100644 index 0000000000..a50ca815ea --- /dev/null +++ b/packages/picasso-rich-text-editor/src/utils/__tests__/is-custom-emoji.test.ts @@ -0,0 +1,45 @@ +import { isCustomEmoji } from '../' + +describe('isCustomEmoji', () => { + describe('when React props are passed', () => { + describe('when component props are specific to Emoji', () => { + it('returns true', () => { + const props = { + 'data-src': 'test', + 'data-emoji-name': 'test', + } + + expect(isCustomEmoji(props)).toBeTruthy() + }) + }) + + describe('when component props are not specific to Emoji', () => { + it('returns false', () => { + const props = {} + + expect(isCustomEmoji(props)).toBeFalsy() + }) + }) + }) + + describe('when HTMLElement is passed', () => { + describe('when element props are specific to Emoji', () => { + it('returns true', () => { + const element = document.createElement('img') + + element.dataset.src = 'test' + element.dataset.emojiName = 'test' + + expect(isCustomEmoji(element)).toBeTruthy() + }) + }) + + describe('when element props are not specific to Emoji', () => { + it('returns false', () => { + const element = document.createElement('img') + + expect(isCustomEmoji(element)).toBeFalsy() + }) + }) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/utils/index.ts b/packages/picasso-rich-text-editor/src/utils/index.ts index b55173b41c..828e17d802 100644 --- a/packages/picasso-rich-text-editor/src/utils/index.ts +++ b/packages/picasso-rich-text-editor/src/utils/index.ts @@ -1 +1,2 @@ export { default as htmlToHast, hastSanitizeSchema } from './html-to-hast' +export { default as isCustomEmoji } from './is-custom-emoji' diff --git a/packages/picasso-rich-text-editor/src/utils/is-custom-emoji.ts b/packages/picasso-rich-text-editor/src/utils/is-custom-emoji.ts new file mode 100644 index 0000000000..7b434e9dd0 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/utils/is-custom-emoji.ts @@ -0,0 +1,18 @@ +// Determine if the element is a custom emoji +// based on the presence of the `src` and `emojiName` attributes either in the +// element's `dataset` or in the `props` object for React Component. +const isCustomEmoji = (elementOrProps: HTMLElement | object) => { + if (elementOrProps instanceof HTMLElement) { + const { dataset } = elementOrProps + + return dataset && 'src' in dataset && 'emojiName' in dataset + } + + return ( + elementOrProps && + 'data-src' in elementOrProps && + 'data-emoji-name' in elementOrProps + ) +} + +export default isCustomEmoji