diff --git a/.changeset/tender-poems-beg.md b/.changeset/tender-poems-beg.md new file mode 100644 index 0000000000..3a65266eb7 --- /dev/null +++ b/.changeset/tender-poems-beg.md @@ -0,0 +1,7 @@ +--- +'@toptal/picasso': patch +--- + +### RichTextEditor + +- export utils for Typography diff --git a/.changeset/tender-poems-editor.md b/.changeset/tender-poems-editor.md new file mode 100644 index 0000000000..dd3161e333 --- /dev/null +++ b/.changeset/tender-poems-editor.md @@ -0,0 +1,7 @@ +--- +'@toptal/picasso-rich-text-editor': patch +--- + +### RichTextEditor + +- create `Rich Text Editor` package. diff --git a/.storybook/components/CodeExample/CodeExample.tsx b/.storybook/components/CodeExample/CodeExample.tsx index 9e2c7b96df..3bf053a0bd 100755 --- a/.storybook/components/CodeExample/CodeExample.tsx +++ b/.storybook/components/CodeExample/CodeExample.tsx @@ -57,6 +57,8 @@ const imports: Record = { '@toptal/picasso-provider': require('@toptal/picasso-provider'), '@toptal/picasso-pictograms': require('@toptal/picasso-pictograms'), '@toptal/picasso-pictograms/Pictogram': require('@toptal/picasso-pictograms/Pictogram'), + '@toptal/picasso-rich-text-editor': require('@toptal/picasso-rich-text-editor'), + '@toptal/picasso-rich-text-editor/utils': require('@toptal/picasso-rich-text-editor/utils'), } const resolver = (path: string) => imports[path] @@ -139,6 +141,10 @@ const getOriginalSourceCode = ({ return requireContext(`./picasso-pictograms/src/${src}`).default } catch {} + try { + return requireContext(`./picasso-rich-text-editor/src/${src}`).default + } catch {} + return require(`!raw-loader!~/.storybook/stories/${src}`).default } diff --git a/.storybook/main.js b/.storybook/main.js index 5b065d7580..bba0439370 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -150,6 +150,14 @@ module.exports = { __dirname, '../packages/picasso-pictograms/src' ), + '@toptal/picasso-rich-text-editor': path.resolve( + __dirname, + '../packages/picasso-rich-text-editor/src' + ), + '@toptal/picasso-rich-text-editor/utils': path.resolve( + __dirname, + '../packages/picasso-rich-text-editor/src/utils' + ), }, }, } diff --git a/packages/picasso-rich-text-editor/CHANGELOG.md b/packages/picasso-rich-text-editor/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/picasso-rich-text-editor/README.md b/packages/picasso-rich-text-editor/README.md new file mode 100644 index 0000000000..5809bc04ce --- /dev/null +++ b/packages/picasso-rich-text-editor/README.md @@ -0,0 +1,23 @@ +# @toptal/picasso-rich-text-editor + +[![Picasso NPM package](https://img.shields.io/npm/v/@toptal/picasso-rich-text-editor?color=green&logo=toptal)](https://www.npmjs.com/package/@toptal/picasso-rich-text-editor) + +`RichTextEditor` is an extensible text editor built on top of the Picasso components. It provides an intuitive user experience for rich text editing, leveraging the power and flexibility of Picasso's design system. + +## Prerequisites + +The following peer dependencies are required: + +- `@toptal/picasso` +- `@toptal/picasso-shared` +- `@material-ui/core` +- `react` +- `react-dom` + +## Setup + +- `yarn add @toptal/picasso-rich-text-editor` + +## Documentation + +Documentation and demos are available at [picasso.toptal.net](https://picasso.toptal.net/). \ No newline at end of file diff --git a/packages/picasso-rich-text-editor/package.json b/packages/picasso-rich-text-editor/package.json new file mode 100644 index 0000000000..466e30824a --- /dev/null +++ b/packages/picasso-rich-text-editor/package.json @@ -0,0 +1,53 @@ +{ + "name": "@toptal/picasso-rich-text-editor", + "version": "1.0.0", + "description": "Picasso rich text editor", + "author": "Toptal", + "homepage": "https://github.com/toptal/picasso/tree/master/packages/picasso-rich-text-editor#readme", + "license": "MIT", + "main": "index.js", + "module": "index.js", + "publishConfig": { + "access": "public", + "directory": "dist-package" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/toptal/picasso.git" + }, + "scripts": { + "build:package": "cross-env NODE_ENV=production node ../../bin/build.js --tsConfig=./tsconfig.build.json", + "prepublishOnly": "if [ -d dist-package ]; then cp ./package.json ./dist-package/package.json; fi" + }, + "bugs": { + "url": "https://github.com/toptal/picasso/issues" + }, + "peerDependencies": { + "@toptal/picasso": "^35.2.2", + "@toptal/picasso-shared": "^12.0.0", + "@material-ui/core": "4.12.4", + "react": ">=16.12.0 < 19.0.0", + "react-dom": ">=16.12.0 < 19.0.0", + "typescript": "~4.7.0" + }, + "dependencies": { + "@emoji-mart/data": "^1.1.2", + "@emoji-mart/react": "^1.1.1", + "classnames": "^2.3.1", + "hast-to-hyperscript": "^9.0.1", + "hast-util-from-dom": "^3.0.0", + "hast-util-sanitize": "^3.0.2", + "hast-util-to-html": "^7.1.3", + "quill": "^1.3.7", + "quill-emoji": "^0.2.0", + "quill-paste-smart": "^1.4.9", + "emoji-mart": "^5.5.2" + }, + "devDependencies": { + "@testing-library/react-hooks": "^8.0.1", + "@types/classnames": "^2.3.1", + "storybook-readme": "^5.0.9", + "@material-ui/core": "4.12.4" + }, + "sideEffects": false +} diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/LazyQuillEditor.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/LazyQuillEditor.tsx new file mode 100644 index 0000000000..5dcda27ce8 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/LazyQuillEditor.tsx @@ -0,0 +1,18 @@ +import React, { forwardRef, lazy, Suspense } from 'react' + +import QuillEditorView from '../QuillEditorView' +import type { Props } from './QuillEditor' + +const QuillEditor = lazy(() => import('./QuillEditor')) + +const LazyQuillEditor = forwardRef( + function LazyQuillEditor(props, ref) { + return ( + }> + + + ) + } +) + +export default LazyQuillEditor diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/QuillEditor.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/QuillEditor.tsx new file mode 100644 index 0000000000..3ed64589fd --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/QuillEditor.tsx @@ -0,0 +1,94 @@ +import React, { forwardRef, useRef } from 'react' +import type { BaseProps } from '@toptal/picasso-shared' +import { useCombinedRefs } from '@toptal/picasso/utils' + +import QuillEditorView from '../QuillEditorView' +import useQuillInstance from './hooks/useQuillInstance' +import { + useFocus, + useSubscribeToQuillEvents, + useDisabledEditor, + useKeyBindings, + useSubscribeToTextEditorEvents, +} from './hooks' +import useDefaultValue from './hooks/useDefaultValue' +import type { + TextFormatHandler, + ChangeHandler, + SelectionHandler, + TextLengthChangeHandler, + EditorPlugin, +} from './types' + +export type Props = BaseProps & { + /** + * HTML string + */ + defaultValue?: string + disabled: boolean + id: string + isFocused: boolean + placeholder?: string + plugins?: EditorPlugin[] + onSelectionChange: SelectionHandler + onTextFormat: TextFormatHandler + onTextChange: ChangeHandler + onTextLengthChange: TextLengthChangeHandler +} + +const QuillEditor = forwardRef(function QuillEditor( + { + defaultValue, + disabled, + 'data-testid': dataTestId, + id, + isFocused, + placeholder, + onTextLengthChange, + onSelectionChange, + onTextFormat, + onTextChange, + plugins, + }, + ref +) { + const quill = useQuillInstance({ + id, + placeholder, + plugins, + }) + const editorRef = useCombinedRefs( + ref, + useRef(null) + ) + + useFocus({ isFocused, quill }) + useKeyBindings({ quill, onTextFormat }) + useSubscribeToQuillEvents({ + quill, + onTextChange, + onSelectionChange, + onTextLengthChange, + }) + useSubscribeToTextEditorEvents({ + editorRef, + quill, + }) + useDefaultValue({ defaultValue, quill }) + useDisabledEditor({ disabled, quill }) + + return +}) + +QuillEditor.defaultProps = { + disabled: false, + isFocused: false, + onSelectionChange: () => {}, + onTextFormat: () => {}, + onTextLengthChange: () => {}, + onTextChange: () => {}, +} + +QuillEditor.displayName = 'QuillEditor' + +export default QuillEditor diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/blots/emoji.ts b/packages/picasso-rich-text-editor/src/QuillEditor/blots/emoji.ts new file mode 100644 index 0000000000..f70310536d --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/blots/emoji.ts @@ -0,0 +1,27 @@ +import Quill from 'quill' + +const EmbedBlot = Quill.import('blots/embed') + +export class EmojiBlot extends EmbedBlot { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static create(data: any) { + const node = super.create(data) + + node.setAttribute('data-src', data.src) + node.setAttribute('data-emoji-name', data.emojiId) + node.setAttribute('src', data.src) + node.setAttribute('class', 'emoji-icon') + + return node + } + + static value(domNode: { dataset: { src: string } }) { + const { src } = domNode.dataset + + return { src } + } +} + +EmojiBlot.blotName = 'emojiBlot' +EmojiBlot.className = 'emoji-blot' +EmojiBlot.tagName = 'img' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/constants.ts b/packages/picasso-rich-text-editor/src/QuillEditor/constants.ts new file mode 100644 index 0000000000..d40af2d23b --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/constants.ts @@ -0,0 +1,3 @@ +export const CUSTOM_QUILL_EDITOR_FORMAT_EVENT = 'quill-editor-format' +export const INSERT_DEFAULT_LINK_TEXT = 'quill-editor-insert-default-link-text' +export const INSERT_EMOJI = 'quill-editor-insert-emoji' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/formats/bold.ts b/packages/picasso-rich-text-editor/src/QuillEditor/formats/bold.ts new file mode 100644 index 0000000000..d7920d45d6 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/formats/bold.ts @@ -0,0 +1,24 @@ +import type { Classes } from '@toptal/picasso-shared' +import Quill from 'quill' +import { getTypographyClassName } from '@toptal/picasso' + +const QuillBold = Quill.import('formats/bold') + +const makeBoldFormat = (typographyClasses: Classes) => + class LinkBlot extends QuillBold { + static create(value: string) { + const node = super.create(value) + + node.classList.add( + ...getTypographyClassName(typographyClasses, { + variant: 'body', + size: 'inherit', + weight: 'semibold', + }).split(' ') + ) + + return node + } + } + +export default makeBoldFormat diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/formats/header.ts b/packages/picasso-rich-text-editor/src/QuillEditor/formats/header.ts new file mode 100644 index 0000000000..62d912dc8c --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/formats/header.ts @@ -0,0 +1,23 @@ +import type { Classes } from '@toptal/picasso-shared' +import Quill from 'quill' +import { getTypographyClassName } from '@toptal/picasso' + +const QuillHeader = Quill.import('formats/header') + +const makeHeaderFormat = (typographyClasses: Classes) => + class LinkBlot extends QuillHeader { + static create(value: string) { + const node = super.create(value) + + node.classList.add( + ...getTypographyClassName(typographyClasses, { + variant: 'heading', + size: 'medium', + }).split(' ') + ) + + return node + } + } + +export default makeHeaderFormat diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/formats/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/formats/index.ts new file mode 100644 index 0000000000..903eb07ac5 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/formats/index.ts @@ -0,0 +1,4 @@ +export { default as makeHeaderFormat } from './header' +export { default as makeBoldFormat } from './bold' +export { default as useTypographyClasses } from './use-typography-classes' +export { default as makeLinkFormat } from './link' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/formats/link.ts b/packages/picasso-rich-text-editor/src/QuillEditor/formats/link.ts new file mode 100644 index 0000000000..4a59dd8d8c --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/formats/link.ts @@ -0,0 +1,30 @@ +import type { Classes } from '@toptal/picasso-shared' +import Quill from 'quill' +import { getTypographyClassName } from '@toptal/picasso' + +const QuillLink = Quill.import('formats/link') + +const makeLinkFormat = (typographyClasses: Classes) => + class LinkBlot extends QuillLink { + static create(value: string) { + const node = super.create(value) + + node.classList.add( + ...getTypographyClassName(typographyClasses, { + variant: 'body', + size: 'inherit', + underline: 'solid', + // we don't expose blue color in typography since it should be used only for links. + // in this case we are simulating look of the link + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + color: 'blue', + weight: 'regular', + }).split(' ') + ) + + return node + } + } + +export default makeLinkFormat diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/formats/use-typography-classes.ts b/packages/picasso-rich-text-editor/src/QuillEditor/formats/use-typography-classes.ts new file mode 100644 index 0000000000..923c4b07a1 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/formats/use-typography-classes.ts @@ -0,0 +1,9 @@ +import type { Theme } from '@material-ui/core' +import { makeStyles } from '@material-ui/core' +import typographyStyles from '@toptal/picasso/Typography/styles' + +const useTypographyClasses = makeStyles(typographyStyles, { + name: 'TextEditorTypography', +}) + +export default useTypographyClasses diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/index.ts new file mode 100644 index 0000000000..12e02d5772 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/index.ts @@ -0,0 +1,6 @@ +export { default as useDisabledEditor } from './useDisabledEditor' +export { default as useFocus } from './useFocus' +export { default as useKeyBindings } from './useKeyBindings' +export { default as useQuillInstance } from './useQuillInstance' +export { default as useSubscribeToQuillEvents } from './useSubscribeToQuillEvents' +export { default as useSubscribeToTextEditorEvents } from './useSubscribeToTextEditorEvents' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDefaultValue/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDefaultValue/index.ts new file mode 100644 index 0000000000..3fc2853ea1 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDefaultValue/index.ts @@ -0,0 +1 @@ +export { default } from './useDefaultValue' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDefaultValue/test.ts b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDefaultValue/test.ts new file mode 100644 index 0000000000..13aa35e02a --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDefaultValue/test.ts @@ -0,0 +1,52 @@ +import { renderHook } from '@testing-library/react-hooks' +import type { DeltaStatic } from 'quill' +import type Quill from 'quill' +// eslint-disable-next-line import/no-extraneous-dependencies +import Delta from 'quill-delta' + +import useDefaultValue from './useDefaultValue' + +describe('useDefaultValue', () => { + let deltaMock: DeltaStatic + let quillMock: Quill + + beforeEach(() => { + deltaMock = new Delta() + .insert('Gandalf', { bold: true }) + .insert('the ') + .insert('Grey', { italic: true }) + + quillMock = { + clipboard: { + convert: jest.fn((): DeltaStatic => deltaMock), + }, + setContents: jest.fn(), + } as unknown as Quill + }) + + it('does nothing without defaultValue', () => { + const quill = quillMock + const defaultValue = '' + + renderHook(() => useDefaultValue({ defaultValue, quill })) + + expect(quill.clipboard.convert).not.toHaveBeenCalled() + expect(quill.setContents).not.toHaveBeenCalled() + }) + it('sets to correct contents', () => { + const quill = quillMock + const defaultValue = '

foobar

' + + const { rerender } = renderHook(() => + useDefaultValue({ defaultValue, quill }) + ) + + expect(quill.clipboard.convert).toHaveBeenCalledWith(defaultValue) + expect(quill.clipboard.convert).toHaveBeenCalledTimes(1) + expect(quill.setContents).toHaveBeenCalledWith(deltaMock, 'api') + expect(quill.setContents).toHaveBeenCalledTimes(1) + rerender() + expect(quill.clipboard.convert).toHaveBeenCalledTimes(1) + expect(quill.setContents).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDefaultValue/useDefaultValue.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDefaultValue/useDefaultValue.tsx new file mode 100644 index 0000000000..a3c04c794f --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDefaultValue/useDefaultValue.tsx @@ -0,0 +1,23 @@ +import type Quill from 'quill' +import { useEffect, useRef } from 'react' + +type Props = { + quill?: Quill + defaultValue?: string +} + +const useDefaultValue = ({ defaultValue, quill }: Props) => { + const hasBeenCalled = useRef(false) + + useEffect(() => { + if (!defaultValue || !quill || hasBeenCalled.current) { + return + } + const delta = quill.clipboard.convert(defaultValue) + + quill.setContents(delta, 'api') + hasBeenCalled.current = true + }, [defaultValue, quill]) +} + +export default useDefaultValue diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDisabledEditor/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDisabledEditor/index.ts new file mode 100644 index 0000000000..41a5f2697b --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDisabledEditor/index.ts @@ -0,0 +1 @@ +export { default } from './useDisabledEditor' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDisabledEditor/test.ts b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDisabledEditor/test.ts new file mode 100644 index 0000000000..47d85ef230 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDisabledEditor/test.ts @@ -0,0 +1,77 @@ +import type { DeltaStatic } from 'quill' +import type Quill from 'quill' +import { renderHook } from '@testing-library/react-hooks' +// eslint-disable-next-line import/no-extraneous-dependencies +import Delta from 'quill-delta' + +import useDisabledEditor from './useDisabledEditor' +import useDefaultValue from '../useDefaultValue' + +describe('useDisabledEditor', () => { + describe('when disabled is false', () => { + it('does not disable the editor', () => { + const disabled = false + const quill = { + enable: jest.fn(), + } as unknown as Quill + + renderHook(() => useDisabledEditor({ disabled, quill })) + + expect(quill.enable).toHaveBeenCalledWith(true) + }) + }) + + describe('when disabled is true', () => { + it('does disable the editor', () => { + const disabled = true + const quill = { + enable: jest.fn(), + } as unknown as Quill + + renderHook(() => useDisabledEditor({ disabled, quill })) + + expect(quill.enable).toHaveBeenCalledWith(false) + }) + }) + + describe('when disabled is true and has a default value', () => { + let quillMock: Quill + let deltaMock: DeltaStatic + + beforeEach(() => { + deltaMock = new Delta() + .insert('Gandalf', { bold: true }) + .insert('the ') + .insert('Grey', { italic: true }) + + quillMock = { + clipboard: { + convert: jest.fn((): DeltaStatic => deltaMock), + }, + setContents: jest.fn(), + enable: jest.fn(), + } as unknown as Quill + }) + + it('does disable the editor', () => { + const disabled = true + + const quill = quillMock + const defaultValue = '

foobar

' + + const { rerender } = renderHook(() => { + useDefaultValue({ defaultValue, quill }) + useDisabledEditor({ disabled, quill }) + }) + + expect(quill.clipboard.convert).toHaveBeenCalledWith(defaultValue) + expect(quill.clipboard.convert).toHaveBeenCalledTimes(1) + expect(quill.setContents).toHaveBeenCalledWith(deltaMock, 'api') + expect(quill.setContents).toHaveBeenCalledTimes(1) + rerender() + expect(quill.clipboard.convert).toHaveBeenCalledTimes(1) + expect(quill.setContents).toHaveBeenCalledTimes(1) + expect(quill.enable).toHaveBeenCalledWith(false) + }) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDisabledEditor/useDisabledEditor.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDisabledEditor/useDisabledEditor.tsx new file mode 100644 index 0000000000..e34a971b6a --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useDisabledEditor/useDisabledEditor.tsx @@ -0,0 +1,20 @@ +import type Quill from 'quill' +import { useEffect } from 'react' + +const useDisabledEditor = ({ + disabled, + quill, +}: { + disabled: boolean + quill?: Quill +}) => { + useEffect(() => { + if (!quill) { + return + } + + quill.enable(!disabled) + }, [disabled, quill]) +} + +export default useDisabledEditor diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useFocus/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useFocus/index.ts new file mode 100644 index 0000000000..66e5701eff --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useFocus/index.ts @@ -0,0 +1 @@ +export { default } from './useFocus' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useFocus/test.ts b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useFocus/test.ts new file mode 100644 index 0000000000..f2c0d90483 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useFocus/test.ts @@ -0,0 +1,34 @@ +import type Quill from 'quill' +import { renderHook } from '@testing-library/react-hooks' + +import useFocus from './useFocus' + +describe('useFocus', () => { + describe('when isFocused is off', () => { + it('does not focus quill', () => { + const isFocused = false + const quill = { + focus: jest.fn(), + } as unknown as Quill + + renderHook(() => useFocus({ quill, isFocused })) + + expect(quill.focus).not.toHaveBeenCalled() + }) + }) + + describe('when isFocused is on', () => { + it('does focus editor', () => { + const isFocused = true + const quill = { + focus: jest.fn(), + } as unknown as Quill + + const { rerender } = renderHook(() => useFocus({ quill, isFocused })) + + rerender() + + expect(quill.focus).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useFocus/useFocus.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useFocus/useFocus.tsx new file mode 100644 index 0000000000..b9f7632c04 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useFocus/useFocus.tsx @@ -0,0 +1,22 @@ +import type Quill from 'quill' +import { useEffect } from 'react' + +const useFocus = ({ + isFocused, + quill, +}: { + isFocused: boolean + quill?: Quill +}) => { + useEffect(() => { + if (!quill) { + return + } + + if (isFocused) { + quill.focus() + } + }, [quill, isFocused]) +} + +export default useFocus diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useKeyBindings/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useKeyBindings/index.ts new file mode 100644 index 0000000000..7b8fd552c7 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useKeyBindings/index.ts @@ -0,0 +1 @@ +export { default } from './useKeyBindings' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useKeyBindings/useKeyBindings.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useKeyBindings/useKeyBindings.tsx new file mode 100644 index 0000000000..dc4f16c876 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useKeyBindings/useKeyBindings.tsx @@ -0,0 +1,52 @@ +import type Quill from 'quill' +import { useEffect } from 'react' + +import type { TextFormatHandler } from '../../types' + +const useKeyBindings = ({ + quill, + onTextFormat, +}: { + quill?: Quill + onTextFormat: TextFormatHandler +}) => { + useEffect(() => { + if (!quill) { + return + } + + quill.keyboard.addBinding( + { + key: 'B', + shortKey: true, + }, + function (range, context) { + const isBold = context.format.bold + + quill.format('bold', !isBold) + onTextFormat({ + formatName: 'bold', + value: !isBold, + }) + } + ) + + quill.keyboard.addBinding( + { + key: 'I', + shortKey: true, + }, + function (range, context) { + const isItalic = context.format.italic + + quill.format('italic', !isItalic) + onTextFormat({ + formatName: 'italic', + value: !isItalic, + }) + } + ) + }, [quill, onTextFormat]) +} + +export default useKeyBindings diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useQuillInstance/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useQuillInstance/index.ts new file mode 100644 index 0000000000..09e22d2590 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useQuillInstance/index.ts @@ -0,0 +1 @@ +export { default } from './useQuillInstance' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useQuillInstance/test.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useQuillInstance/test.tsx new file mode 100644 index 0000000000..defbf330df --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useQuillInstance/test.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { TestingPicasso, render } from '@toptal/picasso/test-utils' +import { renderHook } from '@testing-library/react-hooks' +import Quill from 'quill' + +import useQuillInstance from './useQuillInstance' + +type QuilMockType = jest.Mock & { + import: jest.Mock + register: jest.Mock +} + +jest.mock('quill', () => { + const QuillMock = jest.fn() as QuilMockType + + QuillMock.import = jest.fn().mockImplementation(() => jest.fn()) + QuillMock.register = jest.fn() + + return { + __esModule: true, + default: QuillMock, + } +}) + +describe('useQuillInstance', () => { + it('returns quill instance in ref', () => { + const id = 'editor' + + // quill needs to have container for both toolbar and editor + // to successfuly init + const WrapperComponent = ({ children }: { children?: React.ReactNode }) => ( + +
+
+ {children} + + ) + + render() + const { result } = renderHook(() => useQuillInstance({ id }), { + wrapper: WrapperComponent, + }) + + expect(result.current).toBeTruthy() + expect(Quill).toHaveBeenCalledWith(`#${id}`, expect.any(Object)) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useQuillInstance/useQuillInstance.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useQuillInstance/useQuillInstance.tsx new file mode 100644 index 0000000000..a761cfd77d --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useQuillInstance/useQuillInstance.tsx @@ -0,0 +1,196 @@ +import { useEffect, useState } from 'react' +import type { QuillOptionsStatic, RangeStatic } from 'quill' +import Quill from 'quill' +import 'quill-paste-smart' + +import { + useTypographyClasses, + makeHeaderFormat, + makeBoldFormat, + makeLinkFormat, +} from '../../formats' +import type { EditorPlugin } from '../../types' +import { EmojiBlot } from '../../blots/emoji' + +export type EditorOptionsType = { + id: string + placeholder?: string + plugins?: EditorPlugin[] +} + +export const getModules = ( + plugins: EditorOptionsType['plugins'] +): QuillOptionsStatic['modules'] => { + const allowLinks = plugins?.includes('link') + const allowEmojis = plugins?.includes('emoji') + + const allowedTags = [ + 'b', + 'strong', + 'i', + 'em', + 'p', + 'br', + 'ul', + 'ol', + 'li', + 'h3', + ] + const allowedAttributes = ['class'] + + if (allowLinks) { + allowedTags.push('a') + allowedAttributes.push('href') + } + + if (allowEmojis) { + allowedTags.push('img') + allowedAttributes.push('src', 'data-src', 'data-emoji-name', 'class') + } + + return { + clipboard: { + matchVisual: false, + allowed: { + // unsupported tags will be also removed on BE side, so before extending + // make sure, that our API supports new type + tags: allowedTags, + attributes: allowedAttributes, + }, + keepSelection: true, + substituteBlockElements: true, + }, + keyboard: { + // we need to specify default bindings + // because Quill don't allow us to setup bindings via + // quill.keyboard.addBinding for default Quill + // key shortcuts otherwise + bindings: { + bold: { + key: 'B', + metaKey: true, + ctrlKey: true, + handler: function () {}, + }, + italic: { + key: 'I', + metaKey: true, + ctrlKey: true, + handler: function () {}, + }, + indent: { + key: 'Tab', + format: ['blockquote', 'indent', 'list'], + handler: function ( + this: { quill: Quill }, + range: RangeStatic, + context: { + collapsed: boolean + empty: boolean + offset: number + format: { [key: string]: string } + prefix: string + suffix: string + } + ) { + if (context.collapsed && context.offset !== 0) { + return true + } + + const { quill } = this + const { format } = context + const currentIndent = format.indent || 0 + const [line] = quill.getLine(range.index) + const prevLine = line.prev + + const isPrevLineListItem = prevLine?.domNode?.tagName === 'LI' + const prevIndent = + prevLine?.domNode?.className?.match(/\d+/)?.[0] || 0 + + if ( + isPrevLineListItem && + currentIndent <= prevIndent && + Number(currentIndent) < 4 + ) { + quill.format('indent', '+1', 'user') + } + }, + }, + }, + }, + } +} + +/** + * Formats we allow to paste into editor + * + * This is separate from adding a control in the Toolbar. + * For example, you can configure Quill to allow bolded + * content to be pasted into an editor that has no bold + * button in the toolbar. */ +const formats: QuillOptionsStatic['formats'] = [ + 'bold', + 'italic', + 'header', + 'list', + 'indent', +] + +const Inline = Quill.import('blots/inline') + +// We need link to be wrapped by other inline HTML tags to keep proper styling +// Lower index means deeper in the DOM tree, since not found (-1) is for embeds +Inline.order = [ + 'cursor', + 'emojiBlot', + 'link', + 'inline', // Must be lower + 'underline', + 'strike', + 'italic', + 'bold', + 'script', + 'code', // Must be higher +] + +const useQuillInstance = ({ + id, + placeholder, + plugins, +}: EditorOptionsType): Quill | undefined => { + const [quill, setQuill] = useState() + const typographyClasses = useTypographyClasses() + + useEffect(() => { + const extendedFormats: QuillOptionsStatic['formats'] = [...formats] + + Quill.register(makeHeaderFormat(typographyClasses), true) + Quill.register(makeBoldFormat(typographyClasses), true) + + const allowEmojis = plugins?.includes('emoji') + + if (allowEmojis) { + Quill.register({ 'formats/emojiBlot': EmojiBlot }, true) + extendedFormats.push('image', 'emojiBlot') + } + + const allowLinks = plugins?.includes('link') + + if (allowLinks) { + Quill.register(makeLinkFormat(typographyClasses), true) + extendedFormats.push('link') + } + + setQuill( + new Quill(`#${id}`, { + modules: getModules(plugins), + formats: extendedFormats, + placeholder, + }) + ) + }, [typographyClasses, id, placeholder, plugins]) + + return quill +} + +export default useQuillInstance diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToQuillEvents/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToQuillEvents/index.ts new file mode 100644 index 0000000000..35ce8cc9f6 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToQuillEvents/index.ts @@ -0,0 +1 @@ +export { default } from './useSubscribeToQuillEvents' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToQuillEvents/test.ts b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToQuillEvents/test.ts new file mode 100644 index 0000000000..1604c29bac --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToQuillEvents/test.ts @@ -0,0 +1,47 @@ +import { renderHook } from '@testing-library/react-hooks' +import type Quill from 'quill' + +import useSubscribeToQuillEvents from './useSubscribeToQuillEvents' + +describe('useSubscribeToQuillEvents', () => { + it('subscribes to events correctly', () => { + const onTextChange = jest.fn + const onSelectionChange = jest.fn + const onTextLengthChange = jest.fn + const quill = { + on: jest.fn(), + off: jest.fn(), + } as unknown as Quill + + const { unmount } = renderHook(() => + useSubscribeToQuillEvents({ + quill, + onTextChange, + onTextLengthChange, + onSelectionChange, + }) + ) + + expect(quill.on).toHaveBeenCalledTimes(5) + expect(quill.on).toHaveBeenCalledWith( + 'selection-change', + expect.any(Function) + ) + expect(quill.on).toHaveBeenCalledWith('text-change', expect.any(Function)) + expect(quill.on).toHaveBeenCalledWith('editor-change', expect.any(Function)) + expect(quill.off).not.toHaveBeenCalled() + + unmount() + + expect(quill.off).toHaveBeenCalledTimes(5) + expect(quill.off).toHaveBeenCalledWith( + 'selection-change', + expect.any(Function) + ) + expect(quill.off).toHaveBeenCalledWith('text-change', expect.any(Function)) + expect(quill.off).toHaveBeenCalledWith( + 'editor-change', + expect.any(Function) + ) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToQuillEvents/useSubscribeToQuillEvents.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToQuillEvents/useSubscribeToQuillEvents.tsx new file mode 100644 index 0000000000..352645a14c --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToQuillEvents/useSubscribeToQuillEvents.tsx @@ -0,0 +1,101 @@ +import { useMemo, useEffect } from 'react' +import type { + SelectionChangeHandler, + TextChangeHandler, + EditorChangeHandler, +} from 'quill' +import type Quill from 'quill' + +import getTextChangeHandler from '../../utils/getTextChangeHandler' +import getSelectionChangeHandler from '../../utils/getSelectionChangeHandler' +import getEditorChangeHandler from '../../utils/getEditorChangeHandler' +import getTextLengthChangeHandler from '../../utils/getTextLengthChangeHandler' +import getCleanupOnAllContentRemovalHandler from '../../utils/getCleanupOnAllContentRemovalHandler' +import type { + SelectionHandler, + ChangeHandler, + TextLengthChangeHandler, +} from '../../types' + +type Props = { + quill?: Quill + onTextChange: ChangeHandler + onSelectionChange: SelectionHandler + onTextLengthChange: TextLengthChangeHandler +} + +const useSubscribeToQuillEvents = ({ + quill, + onTextChange, + onSelectionChange, + onTextLengthChange, +}: Props) => { + const textLengthChangeHandler = useMemo(() => { + if (!quill) { + return () => {} + } + + return getTextLengthChangeHandler(quill, onTextLengthChange) + }, [quill, onTextLengthChange]) + + const textChangeHandler: TextChangeHandler = useMemo(() => { + if (!quill) { + return () => {} + } + + return getTextChangeHandler(quill, onTextChange) + }, [quill, onTextChange]) + + const selectionChangeHandler: SelectionChangeHandler = useMemo(() => { + if (!quill) { + return () => {} + } + + return getSelectionChangeHandler(quill, onSelectionChange) + }, [quill, onSelectionChange]) + + const editorChangeHandler: EditorChangeHandler = useMemo(() => { + if (!quill) { + return () => {} + } + + return getEditorChangeHandler(quill, onSelectionChange) + }, [quill, onSelectionChange]) + + const cleanupOnAllContentRemovalHandler: TextChangeHandler = useMemo(() => { + if (!quill) { + return () => {} + } + + return getCleanupOnAllContentRemovalHandler(quill) + }, [quill]) + + useEffect(() => { + if (!quill) { + return + } + + quill.on('selection-change', selectionChangeHandler) + quill.on('text-change', textChangeHandler) + quill.on('text-change', textLengthChangeHandler) + quill.on('text-change', cleanupOnAllContentRemovalHandler) + quill.on('editor-change', editorChangeHandler) + + return () => { + quill.off('selection-change', selectionChangeHandler) + quill.off('text-change', textChangeHandler) + quill.off('text-change', textLengthChangeHandler) + quill.off('text-change', cleanupOnAllContentRemovalHandler) + quill.off('editor-change', editorChangeHandler) + } + }, [ + quill, + textChangeHandler, + selectionChangeHandler, + editorChangeHandler, + textLengthChangeHandler, + cleanupOnAllContentRemovalHandler, + ]) +} + +export default useSubscribeToQuillEvents diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToTextEditorEvents/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToTextEditorEvents/index.ts new file mode 100644 index 0000000000..c63433d9b2 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToTextEditorEvents/index.ts @@ -0,0 +1 @@ +export { default } from './useSubscribeToTextEditorEvents' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToTextEditorEvents/useSubscribeToTextEditorEvents.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToTextEditorEvents/useSubscribeToTextEditorEvents.tsx new file mode 100644 index 0000000000..dcd3d6ec8c --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/hooks/useSubscribeToTextEditorEvents/useSubscribeToTextEditorEvents.tsx @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useMemo } from 'react' +import type Quill from 'quill' + +import { + CUSTOM_QUILL_EDITOR_FORMAT_EVENT, + INSERT_DEFAULT_LINK_TEXT, + INSERT_EMOJI, +} from '../../constants' +import getFormatChangeHandler from '../../utils/getFormatChangeHandler' + +type Emoji = { + id: string + name: string + native: string | undefined + unified: string + keywords: string[] + shortcodes: string + emoticons: string[] + src: string | undefined +} + +const useSubscribeToTextEditorEvents = ({ + editorRef, + quill, +}: { + editorRef: React.RefObject + quill?: Quill +}) => { + const formatChangeHandler = useMemo(() => { + if (!quill) { + return () => {} + } + + return getFormatChangeHandler(quill) as EventListener + }, [quill]) + + const insertDefaultLinkText = useCallback( + ({ detail }) => { + if (!quill) { + return + } + + const { link } = detail + + const selection = quill.getSelection(true) ?? { index: 0, length: 0 } + + if (selection.length === 0) { + quill.insertText(selection.index, link) + quill.setSelection( + quill.getLength() - 1 - link.length, + quill.getLength() + ) + } + }, + [quill] + ) + + const insertEmoji = useCallback( + ({ detail }) => { + if (!quill) { + return + } + + const { native, src, id } = detail as Emoji + + if (native) { + const selection = quill.getSelection(true) ?? { index: 0, length: 0 } + + if (selection.length === 0) { + quill.insertText(selection.index, native) + } + + return + } + + const selection = quill.getSelection(true) ?? { index: 0, length: 0 } + + if (selection.length === 0) { + quill.insertEmbed(selection.index, 'emojiBlot', { + src, + emojiId: id, + }) + + quill.setSelection(selection.index + 1, selection.length + 1) + } + }, + [quill] + ) + + useEffect(() => { + const editor = editorRef.current + + if (!editor) { + return + } + + editor.addEventListener( + CUSTOM_QUILL_EDITOR_FORMAT_EVENT, + formatChangeHandler, + false + ) + + editor.addEventListener( + INSERT_DEFAULT_LINK_TEXT, + insertDefaultLinkText, + false + ) + + editor.addEventListener(INSERT_EMOJI, insertEmoji, false) + + return () => { + editor?.removeEventListener( + CUSTOM_QUILL_EDITOR_FORMAT_EVENT, + formatChangeHandler, + false + ) + editor?.removeEventListener( + INSERT_DEFAULT_LINK_TEXT, + insertDefaultLinkText, + false + ) + editor?.removeEventListener(INSERT_EMOJI, insertEmoji, false) + } + }, [editorRef, formatChangeHandler, insertDefaultLinkText, insertEmoji]) +} + +export default useSubscribeToTextEditorEvents diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/index.ts new file mode 100644 index 0000000000..4a39d6c0ae --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/index.ts @@ -0,0 +1,4 @@ +export { default } from './LazyQuillEditor' +export type { Props as QuillEditorProps } from './QuillEditor' +export * from './types' +export { CUSTOM_QUILL_EDITOR_FORMAT_EVENT } from './constants' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/types.ts b/packages/picasso-rich-text-editor/src/QuillEditor/types.ts new file mode 100644 index 0000000000..12e03f813d --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/types.ts @@ -0,0 +1,44 @@ +export type BoldValue = boolean | undefined +export type ItalicValue = boolean | undefined +export type ListValue = 'bullet' | 'ordered' | undefined +export type HeaderValue = 3 | undefined +export type LinkValue = string | undefined +export type EmojiValue = string + +export type FormatType = { + bold: BoldValue + italic: ItalicValue + list: ListValue + header: HeaderValue + link: LinkValue +} + +export type TextFormatHandlerEvent = + | { formatName: 'bold'; value: BoldValue } + | { formatName: 'italic'; value: ItalicValue } + | { formatName: 'list'; value: ListValue } + | { formatName: 'header'; value: HeaderValue } + | { formatName: 'link'; value: LinkValue } + +export type TextFormatHandler = (e: TextFormatHandlerEvent) => void +export type SelectionHandler = (format: FormatType) => void +export type ChangeHandler = (html: string) => void +export type TextLengthChangeHandler = (length: number) => void + +export type EditorPlugin = 'link' | 'emoji' + +export type CustomEmoji = { + id: string + name: string + keywords: string[] + skins: [ + { + src: string + } + ] +} +export type CustomEmojiGroup = { + id: string + name: string + emojis: CustomEmoji[] +} diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getCleanupOnAllContentRemovalHandler/getCleanupOnAllContentRemovalHandler.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getCleanupOnAllContentRemovalHandler/getCleanupOnAllContentRemovalHandler.tsx new file mode 100644 index 0000000000..508298e8e2 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getCleanupOnAllContentRemovalHandler/getCleanupOnAllContentRemovalHandler.tsx @@ -0,0 +1,35 @@ +import type { TextChangeHandler } from 'quill' +import type Quill from 'quill' +// eslint-disable-next-line import/no-extraneous-dependencies +import Delta from 'quill-delta' + +const getCleanupOnAllContentRemovalHandler = (quill: Quill) => { + const handler: TextChangeHandler = newDelta => { + if (!newDelta.ops) { + return + } + const isDeleteOperation = 'delete' in newDelta.ops[0] + + if (!isDeleteOperation) { + return + } + + const textLength = quill.getLength() - 1 + const isEditorEmpty = textLength === 0 + + if (!isEditorEmpty) { + return + } + + const currentFormat = quill.getFormat() + const isFormatApplied = Object.keys(currentFormat).length > 0 + + if (isFormatApplied) { + quill.setContents(new Delta(), 'api') + } + } + + return handler +} + +export default getCleanupOnAllContentRemovalHandler diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getCleanupOnAllContentRemovalHandler/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getCleanupOnAllContentRemovalHandler/index.ts new file mode 100644 index 0000000000..652036ecd2 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getCleanupOnAllContentRemovalHandler/index.ts @@ -0,0 +1 @@ +export { default } from './getCleanupOnAllContentRemovalHandler' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getCleanupOnAllContentRemovalHandler/test.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getCleanupOnAllContentRemovalHandler/test.ts new file mode 100644 index 0000000000..90c38fedca --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getCleanupOnAllContentRemovalHandler/test.ts @@ -0,0 +1,63 @@ +import { act } from '@toptal/picasso/test-utils' +import type Quill from 'quill' +// eslint-disable-next-line import/no-extraneous-dependencies +import Delta from 'quill-delta' + +import getCleanupOnAllContentRemovalHandler from './getCleanupOnAllContentRemovalHandler' + +describe('getCleanupOnAllContentRemovalHandler', () => { + it('does nothing when not delete operation', () => { + const quill = { + getLength: jest.fn(), + } as unknown as Quill + + const handler = getCleanupOnAllContentRemovalHandler(quill) + + act(() => handler(new Delta().insert('foo'), new Delta(), 'user')) + + expect(quill.getLength).not.toHaveBeenCalled() + }) + it('does nothing when all text is not removed', () => { + const quill = { + getLength: jest.fn(() => 10), + getFormat: jest.fn(), + } as unknown as Quill + + const handler = getCleanupOnAllContentRemovalHandler(quill) + + act(() => handler(new Delta().delete(5), new Delta(), 'user')) + + expect(quill.getLength).toHaveBeenCalled() + expect(quill.getFormat).not.toHaveBeenCalled() + }) + it('does nothing when no format is applied after removal', () => { + const quill = { + getLength: jest.fn(() => 1), + getFormat: jest.fn(() => ({})), + setContents: jest.fn(), + } as unknown as Quill + + const handler = getCleanupOnAllContentRemovalHandler(quill) + + act(() => handler(new Delta().delete(5), new Delta(), 'user')) + + expect(quill.getLength).toHaveBeenCalled() + expect(quill.getFormat).toHaveBeenCalled() + expect(quill.setContents).not.toHaveBeenCalled() + }) + it('cleans up the content', () => { + const quill = { + getLength: jest.fn(() => 1), + getFormat: jest.fn(() => ({ list: 'bullet' })), + setContents: jest.fn(), + } as unknown as Quill + + const handler = getCleanupOnAllContentRemovalHandler(quill) + + act(() => handler(new Delta().delete(5), new Delta(), 'user')) + + expect(quill.getLength).toHaveBeenCalled() + expect(quill.getFormat).toHaveBeenCalled() + expect(quill.setContents).toHaveBeenCalled() + }) +}) diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getEditorChangeHandler/getEditorChangeHandler.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getEditorChangeHandler/getEditorChangeHandler.tsx new file mode 100644 index 0000000000..07cb5ffafe --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getEditorChangeHandler/getEditorChangeHandler.tsx @@ -0,0 +1,79 @@ +import type { RangeStatic, Sources, DeltaStatic } from 'quill' +import type Quill from 'quill' + +import type { FormatType } from '../../types' + +type SelectionChangeArgs = [RangeStatic, RangeStatic, Sources] +type TextChangeArgs = [DeltaStatic, DeltaStatic, Sources] + +/** + * When we write block format and enter new empty line, we have unformated text format. + * We need to send this information to the state + */ +const handleNewLineAfterBlock = ({ + quill, + onSelectionChange, + latestDelta, +}: { + quill: Quill + onSelectionChange: (format: FormatType) => void + latestDelta: DeltaStatic +}) => { + const latestAttributes = + latestDelta.ops?.[latestDelta.ops.length - 1]?.attributes + + const isHeaderFormatRemoved = latestAttributes?.header === null + const isListFormatRemoved = latestAttributes?.list === null + + if (isHeaderFormatRemoved || isListFormatRemoved) { + const format = quill.getFormat() as FormatType + + onSelectionChange({ ...format, header: undefined, list: undefined }) + } +} + +const isDeleteOperation = (delta: DeltaStatic) => { + return delta.ops?.some(obj => obj.delete) +} + +const getEditorChangeHandler = ( + quill: Quill, + onSelectionChange: (format: FormatType) => void +) => { + const handler = ( + name: 'text-change' | 'selection-change', + ...args: SelectionChangeArgs | TextChangeArgs + ) => { + if (name === 'text-change') { + const [latestDelta, , source] = args as TextChangeArgs + + const isFromApi = source === 'api' + const isFromUser = source === 'user' + + if (isFromApi) { + // this event is triggered when format of block element is changed + // for example from p > h3 | h3 > ol + if (!latestDelta.ops?.[latestDelta.ops.length - 1].delete) { + // we need to set range index manually, because Safari has an issue with it. See: https://github.com/quilljs/quill/issues/3093 + onSelectionChange( + quill.getFormat( + (quill as any).selection.savedRange.index + ) as FormatType + ) + } + } else if (isFromUser) { + handleNewLineAfterBlock({ latestDelta, quill, onSelectionChange }) + + // when removing formatted text, we automatically remove the format, + // so we need to update the toolbar + if (isDeleteOperation(latestDelta)) { + onSelectionChange(quill.getFormat() as FormatType) + } + } + } + } + + return handler +} + +export default getEditorChangeHandler diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getEditorChangeHandler/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getEditorChangeHandler/index.ts new file mode 100644 index 0000000000..8329ed93d7 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getEditorChangeHandler/index.ts @@ -0,0 +1 @@ +export { default } from './getEditorChangeHandler' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getEditorChangeHandler/test.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getEditorChangeHandler/test.ts new file mode 100644 index 0000000000..55597b2d85 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getEditorChangeHandler/test.ts @@ -0,0 +1,87 @@ +import { act } from '@toptal/picasso/test-utils' +import type Quill from 'quill' +// eslint-disable-next-line import/no-extraneous-dependencies +import Delta from 'quill-delta' + +import getEditorChangeHandler from './getEditorChangeHandler' + +const deltaMock = new Delta().insert('foo') + +describe('getEditorChangeHandler', () => { + describe('text-change event', () => { + describe('when source is silent', () => { + it('does nothing', () => { + const quill = { + getFormat: jest.fn(() => ({})), + } as unknown as Quill + const onSelectionChange = jest.fn() + + const handler = getEditorChangeHandler(quill, onSelectionChange) + + act(() => handler('text-change', deltaMock, deltaMock, 'silent')) + + expect(quill.getFormat).not.toHaveBeenCalled() + expect(onSelectionChange).not.toHaveBeenCalled() + }) + }) + describe('when source is api', () => { + it('calls the callback with quill format', () => { + const quill = { + getFormat: jest.fn(() => ({})), + selection: { + savedRange: { + index: 0, + }, + }, + } as unknown as Quill + const onSelectionChange = jest.fn() + + const handler = getEditorChangeHandler(quill, onSelectionChange) + + act(() => handler('text-change', deltaMock, deltaMock, 'api')) + + expect(quill.getFormat).toHaveBeenCalled() + expect(onSelectionChange).toHaveBeenCalledWith({}) + }) + }) + describe('when source is user', () => { + describe('when quill removes header format', () => { + it('calls the callback with quill format', () => { + const quill = { + getFormat: jest.fn(() => ({})), + } as unknown as Quill + const onSelectionChange = jest.fn() + + const handler = getEditorChangeHandler(quill, onSelectionChange) + + act(() => + handler( + 'text-change', + new Delta([{ attributes: { header: null } }]), + deltaMock, + 'user' + ) + ) + + expect(quill.getFormat).toHaveBeenCalled() + expect(onSelectionChange).toHaveBeenCalledWith({}) + }) + }) + describe('when it is usual text change', () => { + it('does nothing', () => { + const quill = { + getFormat: jest.fn(() => ({})), + } as unknown as Quill + const onSelectionChange = jest.fn() + + const handler = getEditorChangeHandler(quill, onSelectionChange) + + act(() => handler('text-change', deltaMock, deltaMock, 'user')) + + expect(quill.getFormat).not.toHaveBeenCalled() + expect(onSelectionChange).not.toHaveBeenCalled() + }) + }) + }) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getFormatChangeHandler/getFormatChangeHandler.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getFormatChangeHandler/getFormatChangeHandler.ts new file mode 100644 index 0000000000..abeeb53146 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getFormatChangeHandler/getFormatChangeHandler.ts @@ -0,0 +1,19 @@ +import type Quill from 'quill' + +import type { FormatType } from '../../types' + +export const getFormatChangeHandler = (quill: Quill) => { + const handler = (e: CustomEvent) => { + const format = e.detail + + Object.entries(format).forEach(([key, value]) => { + quill.format(key, value) + + if (key === 'header') { + setTimeout(() => quill.focus(), 0) + } + }) + } + + return handler +} diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getFormatChangeHandler/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getFormatChangeHandler/index.ts new file mode 100644 index 0000000000..bd602565a9 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getFormatChangeHandler/index.ts @@ -0,0 +1,3 @@ +import { getFormatChangeHandler } from './getFormatChangeHandler' + +export default getFormatChangeHandler diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getSelectionChangeHandler/getSelectionChangeHandler.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getSelectionChangeHandler/getSelectionChangeHandler.tsx new file mode 100644 index 0000000000..1bc25bd600 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getSelectionChangeHandler/getSelectionChangeHandler.tsx @@ -0,0 +1,32 @@ +import type { SelectionChangeHandler } from 'quill' +import type Quill from 'quill' + +import type { FormatType, SelectionHandler } from '../../types' + +const getSelectionChangeHandler = ( + quill: Quill, + onSelectionChange: SelectionHandler +) => { + const handler: SelectionChangeHandler = (range, _, source) => { + const isSilentEvent = source === 'silent' + const isFromApi = source === 'api' + + if (isSilentEvent) { + return + } + + if (isFromApi) { + return + } + + if (range) { + const format = quill.getFormat(range) as FormatType + + onSelectionChange(format) + } + } + + return handler +} + +export default getSelectionChangeHandler diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getSelectionChangeHandler/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getSelectionChangeHandler/index.ts new file mode 100644 index 0000000000..00e8b9f443 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getSelectionChangeHandler/index.ts @@ -0,0 +1 @@ +export { default } from './getSelectionChangeHandler' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getSelectionChangeHandler/test.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getSelectionChangeHandler/test.ts new file mode 100644 index 0000000000..ff591bf64c --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getSelectionChangeHandler/test.ts @@ -0,0 +1,68 @@ +import { act } from '@toptal/picasso/test-utils' +import type { RangeStatic, Sources } from 'quill' +import type Quill from 'quill' + +import getSelectionChangeHandler from './getSelectionChangeHandler' + +const mockRange: RangeStatic = { index: 0, length: 0 } + +describe('getSelectionChangeHandler', () => { + it('does nothing when silent event', () => { + const onSelectionChange = jest.fn() + const quill = { + getFormat: jest.fn(), + } as unknown as Quill + + const handler = getSelectionChangeHandler(quill, onSelectionChange) + + act(() => handler(mockRange, mockRange, 'silent')) + + expect(onSelectionChange).not.toHaveBeenCalled() + }) + + it('does nothing when api event', () => { + const onSelectionChange = jest.fn() + const quill = { + getFormat: jest.fn(), + } as unknown as Quill + + const handler = getSelectionChangeHandler(quill, onSelectionChange) + + act(() => handler(mockRange, mockRange, 'api')) + + expect(onSelectionChange).not.toHaveBeenCalled() + }) + + it('calls onSelectionChange with proper format when selection has been changed', () => { + const onSelectionChange = jest.fn() + const quill = { + getFormat: jest.fn().mockImplementation(() => ({ bold: true })), + } as unknown as Quill + + const handler = getSelectionChangeHandler(quill, onSelectionChange) + + act(() => handler(mockRange, mockRange, 'user')) + + expect(onSelectionChange).toHaveBeenCalledTimes(1) + expect(onSelectionChange).toHaveBeenCalledWith({ + bold: true, + }) + }) + + it('does not call onSelectionChange when clicking outside of editor', () => { + const onSelectionChange = jest.fn() + const quill = { + getFormat: jest.fn(), + } as unknown as Quill + + const handler = getSelectionChangeHandler(quill, onSelectionChange) as ( + range: RangeStatic | null, + oldRange: RangeStatic | null, + sources: Sources + ) => void + + act(() => handler(null, null, 'user')) + + expect(onSelectionChange).toHaveBeenCalledTimes(0) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextChangeHandler/getTextChangeHandler.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextChangeHandler/getTextChangeHandler.tsx new file mode 100644 index 0000000000..28aee8c132 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextChangeHandler/getTextChangeHandler.tsx @@ -0,0 +1,38 @@ +import type { TextChangeHandler } from 'quill' +import type Quill from 'quill' + +import removeClasses from '../remove-classes' +import removeCursorSpan from '../remove-cursor-span' +import quillDecodeIndent from '../quillDecodeIndent' + +const getTextChangeHandler = ( + quill: Quill, + handleTextChange: (html: string) => void +) => { + const handler: TextChangeHandler = (_, __, source) => { + const isSilenetEvent = source === 'silent' + + if (isSilenetEvent) { + return + } + + const isEmpty = quill.getLength() === 1 + + if (isEmpty) { + handleTextChange('') + + return + } + + const [cleanValue] = [quill.root.innerHTML] + .map(quillDecodeIndent) + .map(removeCursorSpan) + .map(removeClasses) + + handleTextChange(cleanValue) + } + + return handler +} + +export default getTextChangeHandler diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextChangeHandler/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextChangeHandler/index.ts new file mode 100644 index 0000000000..404595791d --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextChangeHandler/index.ts @@ -0,0 +1 @@ +export { default } from './getTextChangeHandler' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextChangeHandler/test.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextChangeHandler/test.ts new file mode 100644 index 0000000000..fb4c1f06e7 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextChangeHandler/test.ts @@ -0,0 +1,58 @@ +import { act } from '@toptal/picasso/test-utils' +import type Quill from 'quill' +// eslint-disable-next-line import/no-extraneous-dependencies +import Delta from 'quill-delta' + +import getTextChangeHandler from './getTextChangeHandler' + +const mockDelta = new Delta() + +describe('getTextChangeHandler', () => { + it('does nothing when silent event', () => { + const quill = { + root: { + innerHTML: '

bar

', + }, + } as unknown as Quill + const handleTextChange = jest.fn() + const handler = getTextChangeHandler(quill, handleTextChange) + + act(() => handler(mockDelta, mockDelta, 'silent')) + + expect(handleTextChange).not.toHaveBeenCalled() + }) + + it('returns cleaned html', () => { + const quill = { + root: { + innerHTML: '

bar

', + }, + getLength: jest.fn(() => 4), + } as unknown as Quill + const handleTextChange = jest.fn() + const handler = getTextChangeHandler(quill, handleTextChange) + + act(() => handler(mockDelta, mockDelta, 'user')) + + expect(handleTextChange).toHaveBeenCalledWith('

bar

') + expect(handleTextChange).toHaveBeenCalledTimes(1) + }) + + describe('when content is removed', () => { + it('returns empty string', () => { + const quill = { + root: { + innerHTML: '


', + }, + getLength: jest.fn(() => 1), + } as unknown as Quill + const handleTextChange = jest.fn() + const handler = getTextChangeHandler(quill, handleTextChange) + + act(() => handler(mockDelta, mockDelta, 'user')) + + expect(handleTextChange).toHaveBeenCalledWith('') + expect(handleTextChange).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextLengthChangeHandler/getTextLengthChangeHandler.tsx b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextLengthChangeHandler/getTextLengthChangeHandler.tsx new file mode 100644 index 0000000000..157a187f41 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextLengthChangeHandler/getTextLengthChangeHandler.tsx @@ -0,0 +1,19 @@ +import type { TextChangeHandler } from 'quill' +import type Quill from 'quill' + +import type { TextLengthChangeHandler } from '../../types' + +const getTextLengthChangeHandler = ( + quill: Quill, + onTextLengthChange: TextLengthChangeHandler +) => { + const handler: TextChangeHandler = () => { + const currentLength = quill.getLength() - 1 + + onTextLengthChange(currentLength) + } + + return handler +} + +export default getTextLengthChangeHandler diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextLengthChangeHandler/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextLengthChangeHandler/index.ts new file mode 100644 index 0000000000..abe28e8646 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextLengthChangeHandler/index.ts @@ -0,0 +1 @@ +export { default } from './getTextLengthChangeHandler' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextLengthChangeHandler/test.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextLengthChangeHandler/test.ts new file mode 100644 index 0000000000..5de17249fd --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/getTextLengthChangeHandler/test.ts @@ -0,0 +1,25 @@ +import { act } from '@toptal/picasso/test-utils' +import type Quill from 'quill' +// eslint-disable-next-line import/no-extraneous-dependencies +import Delta from 'quill-delta' + +import getTextLengthChangeHandler from './getTextLengthChangeHandler' + +describe('getTextLengthChangeHandler', () => { + it('simply calls onTextLengthChange', () => { + const onTextLengthChange = jest.fn() + const currLength = 4 + const quill = { + getLength: jest.fn().mockImplementation(() => currLength), + } as unknown as Quill + + const handler = getTextLengthChangeHandler(quill, onTextLengthChange) + + const delta = new Delta() + + act(() => handler(delta, delta, 'silent')) + + expect(onTextLengthChange).toHaveBeenCalledTimes(1) + expect(onTextLengthChange).toHaveBeenCalledWith(currLength - 1) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/quillDecodeIndent/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/quillDecodeIndent/index.ts new file mode 100644 index 0000000000..44f2f22435 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/quillDecodeIndent/index.ts @@ -0,0 +1 @@ +export { default } from './quillDecodeIndent' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/quillDecodeIndent/quillDecodeIndent.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/quillDecodeIndent/quillDecodeIndent.ts new file mode 100644 index 0000000000..47ca19fd06 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/quillDecodeIndent/quillDecodeIndent.ts @@ -0,0 +1,52 @@ +const quillDecodeIndent = (text: string): string => { + if (!text) { + return text + } + + const wrapper = document.createElement('div') + + wrapper.innerHTML = text + + const listTypes = ['ul', 'ol'] as const + + listTypes.forEach(type => { + const lists = wrapper.querySelectorAll(type) + + lists.forEach(list => { + const items = Array.from(list.children).filter( + (el): el is HTMLLIElement => el.tagName === 'LI' + ) + + let prevLevel = 0 + let currParent = list + + items.forEach(item => { + const currLevel = +item.className.replace(/\D+/g, '') + + item.classList.remove(`ql-indent-${currLevel}`) + + if (currLevel > prevLevel) { + const newParent = document.createElement(type) + + if (currParent.lastChild != item) { + currParent.lastChild?.appendChild(newParent) + currParent = newParent + currParent?.appendChild(item) + } + } else if (currLevel < prevLevel) { + for (let index = 0; index < prevLevel - currLevel; index++) { + currParent?.parentNode?.parentNode?.appendChild(item) as HTMLElement + } + } else { + currParent?.appendChild(item) + } + + prevLevel = currLevel + }) + }) + }) + + return wrapper.innerHTML +} + +export default quillDecodeIndent diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/quillDecodeIndent/test.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/quillDecodeIndent/test.ts new file mode 100644 index 0000000000..cabeff89d1 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/quillDecodeIndent/test.ts @@ -0,0 +1,70 @@ +import quillDecodeIndent from './quillDecodeIndent' + +describe('quillDecodeIndent', () => { + describe('when input is null or empty', () => { + it('should return an empty string', () => { + expect(quillDecodeIndent('')).toBe('') + }) + }) + + it('should correctly process unordered lists', () => { + const input = + '
  • item 1
  • item 1.1
  • item 2
' + const expectedOutput = + '
  • item 1
    • item 1.1
  • item 2
' + + expect(quillDecodeIndent(input)).toBe(expectedOutput) + }) + + it('should correctly process ordered lists', () => { + const input = + '
  1. item 1
  2. item 1.1
  3. item 2
' + const expectedOutput = + '
  1. item 1
    1. item 1.1
  2. item 2
' + + expect(quillDecodeIndent(input)).toBe(expectedOutput) + }) + + it('should correctly process non-list elements', () => { + const input = '

Some text here

' + const expectedOutput = '

Some text here

' + + const input2 = + '

Some text here

  • item 1
  • item 1.1

Some more text here

  1. item 2
  2. item 2.1

Even more text here

' + const expectedOutput2 = + '

Some text here

  • item 1
    • item 1.1

Some more text here

  1. item 2
    1. item 2.1

Even more text here

' + + expect(quillDecodeIndent(input)).toBe(expectedOutput) + expect(quillDecodeIndent(input2)).toBe(expectedOutput2) + }) + + it('should correctly process multiple nested lists', () => { + const input = + '
  • item 1
  • item 1.1
  • item 1.1.1
  • item 1.1.1.1
  • item 1
  • item 1.1
' + const expectedOutput = + '
  • item 1
    • item 1.1
      • item 1.1.1
        • item 1.1.1.1
  • item 1
    • item 1.1
' + + expect(quillDecodeIndent(input)).toBe(expectedOutput) + }) + + describe('when removing first level parent', () => { + it('should not break', () => { + const input = + '
  • s
  • a

b

  • c
' + const expectedOutput = + '
  • s
    • a

b

  • c
' + + expect(quillDecodeIndent(input)).toBe(expectedOutput) + }) + }) + describe('when removing parent level', () => { + it('should not break', () => { + const input = + '
  • aaa
  • a
  • a
  • bbb

aaaa

  • asdfadsf
  • sadfa




' + const expectedOutput = + '
  • aaa
  • a
    • a
  • bbb

aaaa

  • asdfadsf
    • sadfa




' + + expect(quillDecodeIndent(input)).toBe(expectedOutput) + }) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-classes/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-classes/index.ts new file mode 100644 index 0000000000..1e80b1682d --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-classes/index.ts @@ -0,0 +1 @@ +export { default } from './remove-classes' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-classes/remove-classes.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-classes/remove-classes.ts new file mode 100644 index 0000000000..c9ccfa7eb0 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-classes/remove-classes.ts @@ -0,0 +1,3 @@ +const removeClasses = (value: string) => value.replace(/\sclass=".*?"/g, '') + +export default removeClasses diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-classes/test.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-classes/test.ts new file mode 100644 index 0000000000..2addb5990d --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-classes/test.ts @@ -0,0 +1,22 @@ +import removeClasses from './remove-classes' + +describe('removeClasses', () => { + it('does nothing when no classes', () => { + const html = `

foobar

` + + expect(removeClasses(html)).toBe(html) + }) + + it('removes classes from html', () => { + const html = `

Position Description

We’re looking for hardworking, self-starting Designers for our Product Design team to help us define how talent interacts with Toptal. You’ll build beautiful and inspiring design experiences that help users discover and connect with resources they need in truly innovative ways.

Requirements

  1. Collaborate with PMs and other designers to ship your first product features.
  2. Learn about our design system.

Requirements

  • Proficiency with various design and prototyping tools (such as Sketch, Abstract, Marvel, Principle, Figma), as well as knowledge of HTML and CSS.
  • An understanding that phenomenal experiences come from collaborative decision-making with front-end developers, engineers, researchers, content strategists, and other disciplines.
` + const expectedHtml = `

Position Description

We’re looking for hardworking, self-starting Designers for our Product Design team to help us define how talent interacts with Toptal. You’ll build beautiful and inspiring design experiences that help users discover and connect with resources they need in truly innovative ways.

Requirements

  1. Collaborate with PMs and other designers to ship your first product features.
  2. Learn about our design system.

Requirements

  • Proficiency with various design and prototyping tools (such as Sketch, Abstract, Marvel, Principle, Figma), as well as knowledge of HTML and CSS.
  • An understanding that phenomenal experiences come from collaborative decision-making with front-end developers, engineers, researchers, content strategists, and other disciplines.
` + + expect(removeClasses(html)).toBe(expectedHtml) + }) + it('removes classes from html with nesting', () => { + const html = `

Requirements

    1. Collaborate with PMs and other designers to ship your first product features.
    2. Learn about our design system.
  1. Learn about our design system.

Requirements

  • Proficiency with various design and prototyping tools (such as Sketch, Abstract, Marvel, Principle, Figma), as well as knowledge of HTML and CSS.
  • An understanding that phenomenal experiences come from collaborative decision-making with front-end developers, engineers, researchers, content strategists, and other disciplines.
` + const expectedHtml = `

Requirements

    1. Collaborate with PMs and other designers to ship your first product features.
    2. Learn about our design system.
  1. Learn about our design system.

Requirements

  • Proficiency with various design and prototyping tools (such as Sketch, Abstract, Marvel, Principle, Figma), as well as knowledge of HTML and CSS.
  • An understanding that phenomenal experiences come from collaborative decision-making with front-end developers, engineers, researchers, content strategists, and other disciplines.
` + + expect(removeClasses(html)).toBe(expectedHtml) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-cursor-span/index.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-cursor-span/index.ts new file mode 100644 index 0000000000..1525b3455f --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-cursor-span/index.ts @@ -0,0 +1 @@ +export { default } from './remove-cursor-span' diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-cursor-span/remove-cursor-span.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-cursor-span/remove-cursor-span.ts new file mode 100644 index 0000000000..3f3f1cdb92 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-cursor-span/remove-cursor-span.ts @@ -0,0 +1,7 @@ +const pattern = + /* eslint-disable-next-line no-irregular-whitespace */ + /(<(strong|em)>)?(<(strong|em)>)(<\/span>)(<\/\4>)(<\/\2>)?/ + +const removeCursorSpan = (value: string) => value.replace(pattern, '
') + +export default removeCursorSpan diff --git a/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-cursor-span/test.ts b/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-cursor-span/test.ts new file mode 100644 index 0000000000..662c07d9a5 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditor/utils/remove-cursor-span/test.ts @@ -0,0 +1,31 @@ +import removeCursorSpan from './remove-cursor-span' + +describe('removeCursorSpan', () => { + it('has nothing to replace', () => { + const value = '

foobar

' + const expectedOutput = '

foobar

' + + expect(removeCursorSpan(value)).toBe(expectedOutput) + }) + it('replaces span wrapped in strong', () => { + const value = + '

foobar



' + const expectedOutput = '

foobar


' + + expect(removeCursorSpan(value)).toBe(expectedOutput) + }) + it('replaces span wrapped in em', () => { + const value = + '

foobar



' + const expectedOutput = '

foobar


' + + expect(removeCursorSpan(value)).toBe(expectedOutput) + }) + it('replaces span wrapped in strong and em', () => { + const value = + '

foobar



' + const expectedOutput = '

foobar


' + + expect(removeCursorSpan(value)).toBe(expectedOutput) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/QuillEditorView/QuillEditorView.tsx b/packages/picasso-rich-text-editor/src/QuillEditorView/QuillEditorView.tsx new file mode 100644 index 0000000000..f8fe3ea934 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditorView/QuillEditorView.tsx @@ -0,0 +1,36 @@ +import React, { forwardRef } from 'react' +import type { Theme } from '@material-ui/core/styles' +import { makeStyles } from '@material-ui/core/styles' +import type { BaseProps } from '@toptal/picasso-shared' +import Typography from '@toptal/picasso/Typography' + +import styles from './styles' + +const useStyles = makeStyles(styles, { + name: 'QuillEditorView', +}) + +type QuillEditorViewProps = BaseProps & { + id?: string +} + +const QuillEditorView = forwardRef( + function QuillEditorView({ id, 'data-testid': dataTestId }, ref) { + const classes = useStyles() + + return ( + + ) + } +) + +export default QuillEditorView diff --git a/packages/picasso-rich-text-editor/src/QuillEditorView/index.ts b/packages/picasso-rich-text-editor/src/QuillEditorView/index.ts new file mode 100644 index 0000000000..c29e1c955f --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditorView/index.ts @@ -0,0 +1 @@ +export { default } from './QuillEditorView' diff --git a/packages/picasso-rich-text-editor/src/QuillEditorView/styles.ts b/packages/picasso-rich-text-editor/src/QuillEditorView/styles.ts new file mode 100644 index 0000000000..c01e7c628e --- /dev/null +++ b/packages/picasso-rich-text-editor/src/QuillEditorView/styles.ts @@ -0,0 +1,186 @@ +import type { CSSProperties } from '@material-ui/core/styles/withStyles' +import type { Theme } from '@material-ui/core/styles' +import { createStyles } from '@material-ui/core/styles' +import { rem } from '@toptal/picasso-shared' + +const margins = { + '& p': { + margin: '0.5rem 0', + }, + '& h3': { + margin: '1rem 0 0.5rem', + }, + '& p:first-child, & h3:first-child': { + margin: '0 0 0.5rem', + }, + '& li:not(:last-child)': { + margin: '0 0 0.5rem', + }, + '& ol, & ul': { + padding: 0, + margin: '0.5rem 0', + }, +} + +const outlinedBullet = `url("data:image/svg+xml,%3Csvg fill='none' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8 9c.55228 0 1-.44772 1-1s-.44772-1-1-1-1 .44772-1 1 .44772 1 1 1Zm0 1c1.10457 0 2-.89543 2-2s-.89543-2-2-2-2 .89543-2 2 .89543 2 2 2Z' fill='%23455065'/%3E%3C/svg%3E")` +const bullet = `url("data:image/svg+xml,%3Csvg fill='none' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='2' fill='%23455065'/%3E%3C/svg%3E");` + +const orderedContent = (indent: number) => { + const decimalIndentLevels = [3] + const lowerRomanIndentLevels = [2] + const lowerAlphaIndentLevels = [1, 4] + + if (decimalIndentLevels.includes(indent)) { + return `counter(list-${indent}, decimal) "."` + } + + if (lowerRomanIndentLevels.includes(indent)) { + return `counter(list-${indent}, lower-roman) "."` + } + + if (lowerAlphaIndentLevels.includes(indent)) { + return `counter(list-${indent}, lower-alpha) "."` + } +} + +const indentStyles = [1, 2, 3, 4].reduce( + (acc: { [key: string]: CSSProperties }, indent: number) => { + acc[`& .ql-indent-${indent}`] = { + paddingLeft: `${1.5 + 1.5 * indent}rem`, + } + + acc[`& .ql-indent-${indent}:before`] = { + left: `${1.5 * indent}rem`, + } + + acc[`& ol li.ql-indent-${indent}`] = { + counterIncrement: `list-${indent}`, + } + + acc[`& ol li.ql-indent-${indent}:before`] = { + content: orderedContent(indent), + } + + if (indent % 2 !== 0) { + acc[`& ul li.ql-indent-${indent}:before`] = { + backgroundImage: outlinedBullet, + } + } + + return acc + }, + {} +) + +const listStyles = { + '& p,& ol,& ul,& pre,& blockquote,& h1,& h2,& h3,& h4,& h5,& h6': { + counterReset: + 'list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9', + }, + '& li': { + listStyleType: 'none', + paddingLeft: '1.5rem', + position: 'relative', + '&:before': { + display: 'inline-block', + position: 'absolute', + left: 0, + whiteSpace: 'nowrap', + width: '1rem', + }, + }, + '& ol li': { + counterIncrement: 'list-0', + '&:before': { + content: 'counter(list-0, decimal) "."', + }, + }, + '& *:not(li)': { + counterReset: 'list-0', + }, + '& ul li': { + '&:before': { + content: '""', + backgroundImage: bullet, + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center', + height: rem('22px'), + width: '1rem', + }, + }, + ...indentStyles, +} + +const horizontalPadding = '0.5em' +const placeholder = ({ palette }: Theme) => ({ + '& .ql-blank:before': { + color: palette.grey.main2, + content: 'attr(data-placeholder)', + pointerEvents: 'none', + position: 'absolute', + left: horizontalPadding, + right: horizontalPadding, + }, +}) + +const hidden = { + '& .gl-hidden': { + display: 'none', + }, +} + +const editor = { + '& .ql-editor': { + height: '100%', + outline: 'none', + overflowY: 'auto', + padding: `1em ${horizontalPadding}`, + tabSize: '4', + textAlign: 'left', + whiteSpace: 'pre-wrap', + wordWrap: 'break-word', + }, + '& .ql-editor > *': { + cursor: 'text', + }, +} + +const clipboard = { + '& .ql-clipboard': { + left: '-100000px', + height: '1px', + overflowY: 'hidden', + position: 'absolute', + top: '50%', + }, +} + +const emojiIcon = { + '& .emoji-icon': { + verticalAlign: 'bottom', + width: '22px', + height: '22px', + }, +} + +const quillSpecificStyles = (theme: Theme) => ({ + ...placeholder(theme), + ...editor, + ...hidden, + ...clipboard, + ...emojiIcon, +}) + +export default (theme: Theme) => { + return createStyles({ + root: { + height: '12.5em', + overflowY: 'hidden', + resize: 'vertical', + position: 'relative', + ...listStyles, + ...margins, + ...quillSpecificStyles(theme), + }, + }) +} diff --git a/packages/picasso-rich-text-editor/src/RichText/RichText.tsx b/packages/picasso-rich-text-editor/src/RichText/RichText.tsx new file mode 100644 index 0000000000..b07098fdc9 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichText/RichText.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import type { BaseProps } from '@toptal/picasso-shared' +import Container from '@toptal/picasso/Container' + +import type { ASTType } from './types' +import useRichText from './hooks/useRichText' + +export interface Props extends BaseProps { + /** + * [hast](https://github.com/syntax-tree/hast) format + */ + value: ASTType +} + +export const RichText = ({ + value, + style, + className, + 'data-testid': dataTestId, +}: Props) => { + const richText = useRichText(value) + + return ( + + {richText} + + ) +} + +export default RichText diff --git a/packages/picasso-rich-text-editor/src/RichText/hooks/useRichText/index.ts b/packages/picasso-rich-text-editor/src/RichText/hooks/useRichText/index.ts new file mode 100644 index 0000000000..10d5526b34 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichText/hooks/useRichText/index.ts @@ -0,0 +1 @@ +export { default } from './useRichText' diff --git a/packages/picasso-rich-text-editor/src/RichText/hooks/useRichText/test.tsx b/packages/picasso-rich-text-editor/src/RichText/hooks/useRichText/test.tsx new file mode 100644 index 0000000000..db02953515 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichText/hooks/useRichText/test.tsx @@ -0,0 +1,182 @@ +import type { ReactElement } from 'react' +import { renderHook } from '@testing-library/react-hooks' + +import type { ASTType } from '../../types' +import useRichText from './useRichText' + +describe('useRichText', () => { + describe('not allowed tags', () => { + it('returns the unmapped tag', () => { + const tree = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'h1', + properties: {}, + children: [{ type: 'text', value: 'foobar' }], + }, + ], + } as unknown as ASTType + const { result } = renderHook(() => useRichText(tree)) + + const header = result.current as ReactElement + + expect(header.type).toBe('h1') + expect(header.props.children).toEqual(['foobar']) + }) + }) + describe('allowed tags', () => { + ;(['p', 'h3', 'strong', 'em', 'ul', 'ol', 'li'] as const).forEach(tag => { + it(`maps ${tag} with proper Picasso component`, () => { + const tree: ASTType = { + type: 'root', + children: [ + { + type: 'element', + tagName: tag, + properties: {}, + children: [{ type: 'text', value: 'foobar' }], + }, + ], + } + + const { result } = renderHook(() => useRichText(tree)) + + const element = result.current as ReactElement + const componentName = tag.charAt(0).toUpperCase() + tag.slice(1) + + expect((element.type as Function).name).toEqual(componentName) + expect(element.props.children).toEqual(['foobar']) + }) + }) + }) + + it('handles mulltiple children', () => { + const tree: ASTType = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'h3', + properties: {}, + children: [{ type: 'text', value: 'foo' }], + }, + { + type: 'element', + tagName: 'p', + properties: {}, + children: [{ type: 'text', value: 'bar' }], + }, + ], + } + + const { result } = renderHook(() => useRichText(tree)) + + const [headerElement, paragraphElement] = result.current as [ + ReactElement, + ReactElement + ] + + expect((headerElement.type as Function).name).toBe('H3') + expect(headerElement.props.children).toEqual(['foo']) + expect((paragraphElement.type as Function).name).toBe('P') + expect(paragraphElement.props.children).toEqual(['bar']) + }) + it('handles children recursively', () => { + const tree: ASTType = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'ul', + properties: {}, + children: [ + { + type: 'element', + tagName: 'li', + properties: {}, + children: [{ type: 'text', value: 'foo' }], + }, + { + type: 'element', + tagName: 'li', + properties: {}, + children: [{ type: 'text', value: 'bar' }], + }, + ], + }, + ], + } + + const { result } = renderHook(() => useRichText(tree)) + + const ulElement = result.current as ReactElement + const [liElementFirst, liElementSecond] = ulElement.props.children as [ + ReactElement, + ReactElement + ] + + expect((ulElement.type as Function).name).toBe('Ul') + expect((liElementFirst.type as Function).name).toBe('Li') + expect(liElementFirst.props.children).toEqual(['foo']) + expect((liElementSecond.type as Function).name).toBe('Li') + expect(liElementSecond.props.children).toEqual(['bar']) + }) + describe('when children are empty', () => { + it('returns null', () => { + const { result } = renderHook(() => + useRichText({ + type: 'root', + children: [], + }) + ) + + expect(result.current).toBeNull() + }) + }) + + describe('when children of child are empty', () => { + it('returns correct node', () => { + const tree: ASTType = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'h3', + properties: {}, + children: [{ type: 'text', value: 'foobar' }], + }, + { + type: 'element', + tagName: 'br', + properties: {}, + children: [], + }, + ], + } + + const { result } = renderHook(() => useRichText(tree)) + + const [headingElement, brElement] = result.current as [ + ReactElement, + ReactElement + ] + + expect((headingElement.type as Function).name).toBe('H3') + expect(brElement.type).toBe('br') + }) + }) + + describe('when children are undefined', () => { + it('returns null', () => { + const { result } = renderHook(() => + useRichText({ + type: 'root', + }) + ) + + expect(result.current).toBeNull() + }) + }) +}) 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 new file mode 100644 index 0000000000..a50a81bbe3 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichText/hooks/useRichText/useRichText.tsx @@ -0,0 +1,111 @@ +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' + +type Props = { + children?: React.ReactNode +} + +// List internaly passes another props to ListItem +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} +) +const Strong = ({ children }: Props) => ( + + {children} + +) +const Em = ({ children }: Props) => ( + + {children} + +) +const H3 = ({ children }: Props) => ( + + + {children} + + +) +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 componentMap: Record = { + p: P, + strong: Strong, + em: Em, + h3: H3, + li: Li, + ol: Ol, + ul: Ul, + a: A, + img: Emoji, +} as const + +const picassoMapper = (child: ReactNode): ReactNode => { + if (!isValidElement(child)) { + return child + } + + const type = + componentMap[child.type as keyof typeof componentMap] || child.type + + const mappedChildren = child.props.children?.map(picassoMapper) ?? null + + return createElement(type, { ...child.props, key: child.key }, mappedChildren) +} + +const useRichText = (value: ASTType): ReactNode[] | ReactNode => { + const mappedTextNodes = useMemo(() => { + const { children: astChildren } = value + + if (!astChildren?.length) { + return null + } + + const isSingleChild = astChildren.length === 1 + const reactElement = toH(createElement, value) as ReactElement + + if (isSingleChild) { + return picassoMapper(reactElement) + } + + // first node of tree is always "root", + // which is transformed to wrapping div when children.length > 1 + // when single children, there is no wrapping div and it returns textNode right away + const textNodes = reactElement.props.children + + return textNodes.map(picassoMapper) + }, [value]) + + return mappedTextNodes +} + +export default useRichText diff --git a/packages/picasso-rich-text-editor/src/RichText/index.ts b/packages/picasso-rich-text-editor/src/RichText/index.ts new file mode 100644 index 0000000000..bf0611bf5b --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichText/index.ts @@ -0,0 +1,8 @@ +import type { OmitInternalProps } from '@toptal/picasso-shared' + +import type { Props } from './RichText' + +export { default } from './RichText' + +export type RichTextProps = OmitInternalProps +export type { ASTType } from './types' 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 new file mode 100644 index 0000000000..501e15e2c3 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichText/story/Default.example.tsx @@ -0,0 +1,133 @@ +import React from 'react' +import { RichText } from '@toptal/picasso-rich-text-editor' + +import type { ASTType } from '../types' + +const ast: ASTType = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'h3', + properties: {}, + children: [{ type: 'text', value: 'Position Description' }], + }, + { + type: 'element', + tagName: 'p', + properties: {}, + children: [ + { + type: 'text', + value: + 'We’re looking for hardworking, self-starting Designers for our ', + }, + { + type: 'element', + tagName: 'strong', + properties: {}, + children: [{ type: 'text', value: 'Product Design' }], + }, + { + type: 'text', + value: ' team to help us define how talent interacts with ', + }, + { + type: 'element', + tagName: 'a', + children: [{ type: 'text', value: 'Toptal' }], + properties: { href: 'https://toptal.com' }, + }, + ], + }, + { + type: 'element', + tagName: 'p', + properties: {}, + children: [ + { + type: 'text', + value: + 'You’ll build beautiful and inspiring design experiences that help users discover and connect with resources they need in truly innovative ways.', + }, + ], + }, + { + type: 'element', + tagName: 'h3', + properties: {}, + children: [{ type: 'text', value: 'Requirements' }], + }, + { + type: 'element', + tagName: 'ol', + properties: {}, + children: [ + { + type: 'element', + tagName: 'li', + properties: {}, + children: [ + { + type: 'text', + value: + 'Collaborate with PMs and other designers to ship your first product features.', + }, + ], + }, + { + type: 'element', + tagName: 'li', + properties: {}, + children: [{ type: 'text', value: 'Learn about our design system.' }], + }, + ], + }, + { + type: 'element', + tagName: 'h3', + properties: {}, + children: [{ type: 'text', value: 'Requirements' }], + }, + + { + type: 'element', + tagName: 'ul', + properties: {}, + children: [ + { + type: 'element', + tagName: 'li', + properties: {}, + children: [ + { + type: 'text', + value: + 'Proficiency with various design and prototyping tools (such as Sketch, Abstract, Marvel, Principle, Figma), as well as knowledge of HTML and CSS.', + }, + ], + }, + { + type: 'element', + tagName: 'li', + properties: {}, + children: [ + { + type: 'text', + value: + 'An understanding that phenomenal experiences come from collaborative decision-making with front-end developers, engineers, researchers, content strategists, and other disciplines.', + }, + ], + }, + ], + }, + ], +} + +const style = { maxWidth: '500px' } + +const Example = () => { + return +} + +export default Example 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 new file mode 100644 index 0000000000..6d65a9cf67 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichText/story/HTML.example.tsx @@ -0,0 +1,177 @@ +import React, { useState } from 'react' +import { Grid } from '@toptal/picasso' +import { RichText, RichTextEditor } from '@toptal/picasso-rich-text-editor' +import { htmlToHast } from '@toptal/picasso-rich-text-editor/utils' + +import type { ASTType } from '../types' + +const Example = () => { + const [html, setHtml] = useState('') + + return ( + + + + + + + + + ) +} + +const defaultValue: ASTType = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'h3', + properties: {}, + children: [{ type: 'text', value: 'Position Description' }], + }, + { + type: 'element', + tagName: 'p', + properties: {}, + children: [ + { + type: 'text', + value: + 'We’re looking for hardworking, self-starting Designers for our ', + }, + { + type: 'element', + tagName: 'strong', + properties: {}, + children: [{ type: 'text', value: 'Product Design' }], + }, + { + type: 'text', + value: ' team to help us define how talent interacts with ', + }, + { + type: 'element', + tagName: 'a', + children: [{ type: 'text', value: 'Toptal' }], + properties: { href: 'https://toptal.com' }, + }, + ], + }, + { + type: 'element', + tagName: 'p', + properties: {}, + children: [ + { + type: 'text', + value: + 'You’ll build beautiful and inspiring design experiences that help users discover and connect with resources they need in truly innovative ways.', + }, + ], + }, + { + type: 'element', + tagName: 'h3', + properties: {}, + children: [{ type: 'text', value: 'Requirements' }], + }, + { + type: 'element', + tagName: 'ol', + properties: {}, + children: [ + { + type: 'element', + tagName: 'li', + properties: {}, + children: [ + { + type: 'text', + value: + 'Collaborate with PMs and other designers to ship your first product features.', + }, + { + type: 'element', + tagName: 'ol', + properties: {}, + children: [ + { + type: 'element', + tagName: 'li', + properties: {}, + children: [ + { type: 'text', value: 'Learn about our design system.' }, + ], + }, + ], + }, + ], + }, + ], + }, + { + type: 'element', + tagName: 'h3', + properties: {}, + children: [{ type: 'text', value: 'Requirements' }], + }, + + { + type: 'element', + tagName: 'ul', + properties: {}, + children: [ + { + type: 'element', + tagName: 'li', + properties: {}, + children: [ + { + type: 'text', + value: + 'Proficiency with various design and prototyping tools (such as Sketch, Abstract, Marvel, Principle, Figma), as well as knowledge of HTML and CSS.', + }, + ], + }, + { + type: 'element', + tagName: 'li', + properties: {}, + children: [ + { + type: 'text', + value: + 'An understanding that phenomenal experiences come from collaborative decision-making with front-end developers, engineers, researchers, content strategists, and other disciplines.', + }, + { + type: 'element', + tagName: 'ul', + properties: {}, + children: [ + { + type: 'element', + tagName: 'li', + properties: {}, + children: [ + { + type: 'text', + value: + 'Lorem itaque assumenda id accusamus omnis! Vel veritatis voluptatibus possimus eum aspernatur Facilis nobis iste iste reprehenderit nihil. Fugiat ipsam', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], +} + +export default Example diff --git a/packages/picasso-rich-text-editor/src/RichText/story/index.jsx b/packages/picasso-rich-text-editor/src/RichText/story/index.jsx new file mode 100644 index 0000000000..6b36242663 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichText/story/index.jsx @@ -0,0 +1,35 @@ +import { RichText } from '../RichText' +import PicassoBook from '~/.storybook/components/PicassoBook' + +const page = PicassoBook.section('Components').createPage( + 'RichText', + ` + RichText showcases the output of RichTextEditor. + + By default we should provide AST format as value from BE + to prevent unnecessary parsing on FE side. In some cases + we need to showcase preview before sending data to BE. + For this case we provide util function \`htmlToHast\`. + Please use carefully! + + ${PicassoBook.createBaseDocsLink( + 'https://app.abstract.com/projects/1b06c884-06af-482a-bf12-a82f521a19a1/branches/master/commits/4f1f6493dfac89015cc6c71ea348807e931fe3bc/files/13531207-e094-44ec-ae1f-f27628c1aea5/layers/5AFC1310-BBF4-4601-BA95-9FB38248733B?mode=design' + )} + + ${PicassoBook.createSourceLink(__filename)} + ` +) + +page + .createTabChapter('Props') + .addComponentDocs({ component: RichText, name: 'RichText' }) + +page + .createChapter() + .addExample('RichText/story/Default.example.tsx', { + title: 'AST from BE for normal view', + takeScreenshot: false, + }) + .addExample('RichText/story/HTML.example.tsx', { + title: 'HTML from FE for live-editing preview', + }) diff --git a/packages/picasso-rich-text-editor/src/RichText/types.ts b/packages/picasso-rich-text-editor/src/RichText/types.ts new file mode 100644 index 0000000000..075a91e509 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichText/types.ts @@ -0,0 +1,18 @@ +export type TextType = { + type: 'text' + value: string +} + +export type ElementType = { + type: 'element' + tagName: 'p' | 'h3' | 'strong' | 'em' | 'ul' | 'ol' | 'li' | 'br' | 'a' + properties: {} + children: ASTChildType[] +} + +export type ASTChildType = ElementType | TextType + +export type ASTType = { + type: 'root' + children?: ASTChildType[] +} diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/RichTextEditor.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/RichTextEditor.tsx new file mode 100644 index 0000000000..53339b247d --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/RichTextEditor.tsx @@ -0,0 +1,290 @@ +import React, { forwardRef, useMemo, useRef, useState } from 'react' +import type { Theme } from '@material-ui/core/styles' +import { makeStyles } from '@material-ui/core/styles' +import type { BaseProps } from '@toptal/picasso-shared' +import { useHasMultilineCounter } from '@toptal/picasso-shared' +import cx from 'classnames' +import hastUtilToHtml from 'hast-util-to-html' +import hastSanitize from 'hast-util-sanitize' +import noop from '@toptal/picasso/utils/noop' +import Container from '@toptal/picasso/Container' +import InputMultilineAdornment from '@toptal/picasso/InputMultilineAdornment' +import { usePropDeprecationWarning } from '@toptal/picasso/utils/use-deprecation-warnings' +import type { Status } from '@toptal/picasso/OutlinedInput' + +import type { CustomEmojiGroup, EditorPlugin } from '../QuillEditor' +import QuillEditor from '../QuillEditor' +import Toolbar from '../RichTextEditorToolbar' +import styles from './styles' +import { + useTextEditorState, + useOnSelectionChange, + useOnTextFormat, + useOnFocus, + useToolbarHandlers, + useCounter, +} from './hooks' +import type { ASTType } from '../RichText' +import type { CounterMessageSetter } from './types' + +export interface Props extends BaseProps { + /** Indicates that an element is to be focused on page load */ + autoFocus?: boolean + /** Default value in [HAST](https://github.com/syntax-tree/hast) format */ + defaultValue?: ASTType + /** + * This Boolean attribute indicates that the user cannot interact with the control. + */ + disabled?: boolean + /** unique identifier */ + id: string + /** + * @deprecated Use the `status` prop instead to both support success and error states + * Indicate whether `RichTextEditor` is in error state + */ + error?: boolean + /** Indicate `RichTextEditor` is in `error` or `default` state */ + status?: Extract + /** Used inside Form with combination of Label to enable forHtml functionality */ + hiddenInputId?: string + /** + * The maximum number of characters that the user can enter. + * If this value isn't specified, the user can enter an unlimited + * number of characters. + */ + maxLength?: number + /** + * The minimum number of characters required that the user should enter. + */ + minLength?: number + /** Name attribute of the input element */ + name?: string + /** + * Custom counter message for minLength + */ + minLengthMessage?: CounterMessageSetter + /** + * Custom counter message for maxLength + */ + maxLengthMessage?: CounterMessageSetter + /** + * Callback on text change + */ + onChange?: (value: string) => void + /** + * Callback for blur event + */ + onBlur?: () => void + /** + * Callback for focus event + */ + onFocus?: () => void + /** The placeholder attribute specifies a short hint that describes the expected value of a text editor. */ + placeholder?: string + /** List of plugins to enable on the editor */ + plugins?: EditorPlugin[] + setHasMultilineCounter?: (name: string, hasCounter: boolean) => void + testIds?: { + wrapper?: string + editor?: string + headerSelect?: string + boldButton?: string + italicButton?: string + unorderedListButton?: string + orderedListButton?: string + } + highlight?: 'autofill' + customEmojis?: CustomEmojiGroup[] +} + +const useStyles = makeStyles(styles, { + name: 'RichTextEditor', +}) + +export const RichTextEditor = forwardRef( + function RichTextEditor(props, ref) { + const { + 'data-testid': dataTestId, + plugins, + autoFocus = false, + className, + defaultValue, + disabled, + id, + onChange = noop, + onFocus = noop, + onBlur = noop, + placeholder, + minLength, + maxLength, + minLengthMessage, + maxLengthMessage, + style, + status, + testIds, + hiddenInputId, + setHasMultilineCounter, + name, + highlight, + customEmojis, + } = props + + const classes = useStyles() + const toolbarRef = useRef(null) + const editorRef = useRef(null) + const wrapperRef = useRef(null) + const { dispatch, state } = useTextEditorState() + + usePropDeprecationWarning({ + props, + name: 'error', + componentName: 'RichTextEditor', + description: + 'Use the `status` prop instead. `error` is deprecated and will be removed in the next major release.', + }) + + const { handleSelectionChange } = useOnSelectionChange({ dispatch }) + const { handleTextFormat } = useOnTextFormat({ dispatch }) + const { + handleBold, + handleItalic, + handleHeader, + handleOrdered, + handleUnordered, + handleLink, + insertEmoji, + } = useToolbarHandlers({ + editorRef, + handleTextFormat, + format: state.toolbar.format, + }) + + const { isEditorFocused, handleFocus, handleBlur } = useOnFocus({ + autoFocus, + editorRef, + toolbarRef, + wrapperRef, + onFocus, + onBlur, + dispatch, + }) + + const [defaultValueInHtml] = useState(() => + defaultValue ? hastUtilToHtml(hastSanitize(defaultValue)) : defaultValue + ) + + const { counterMessage, counterError, handleCounterMessage } = useCounter({ + minLength, + maxLength, + minLengthMessage, + maxLengthMessage, + }) + + // Disabled the exhaustive deps rule to allow users to + // declare prop like "plugins={[]}" instead of having to + // declare the array outside the component level + // eslint-disable-next-line react-hooks/exhaustive-deps + const memoizedPlugins = useMemo(() => plugins, []) + // eslint-disable-next-line react-hooks/exhaustive-deps + const memoizedCustomEmojis = useMemo(() => customEmojis, []) + + useHasMultilineCounter(name, !!counterMessage, setHasMultilineCounter) + + return ( + <> + { + if (typeof ref === 'function') { + ref(node) + } else if (ref != null) { + ref.current = node + } + wrapperRef.current = node + }} + data-testid={testIds?.wrapper || dataTestId} + onFocus={handleFocus} + onBlur={handleBlur} + > + + + {hiddenInputId && enableFocusOnLabelClick(hiddenInputId)} + + {counterMessage && ( + + {counterMessage} + + )} + + ) + } +) + +const hiddenInputStyle: React.CSSProperties = { + position: 'absolute', + opacity: 0, + zIndex: -1, +} + +// Native `for` attribute on label does not work for div target +const enableFocusOnLabelClick = (hiddenInputId: string) => ( + +) + +RichTextEditor.defaultProps = { + autoFocus: false, + onChange: noop, + onFocus: noop, + onBlur: noop, + disabled: false, + status: 'default', +} + +RichTextEditor.displayName = 'RichTextEditor' + +export default RichTextEditor diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/index.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/index.ts new file mode 100644 index 0000000000..4bbcaa6f0d --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/index.ts @@ -0,0 +1,6 @@ +export { default as useOnFocus } from './useOnFocus' +export { default as useOnSelectionChange } from './useOnSelectionChange' +export { default as useOnTextFormat } from './useOnTextFormat' +export { default as useTextEditorState } from './useTextEditorState' +export { default as useToolbarHandlers } from './useToolbarHandlers' +export { default as useCounter } from './useCounter' diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useCounter/index.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useCounter/index.ts new file mode 100644 index 0000000000..0d7fa90f7b --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useCounter/index.ts @@ -0,0 +1 @@ +export { default } from './useCounter' diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useCounter/test.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useCounter/test.ts new file mode 100644 index 0000000000..235f89a632 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useCounter/test.ts @@ -0,0 +1,96 @@ +import { act, renderHook } from '@testing-library/react-hooks' + +import useCounter from './useCounter' +import RichTextEditor from '../../RichTextEditor' + +const minLengthMessage = RichTextEditor.defaultProps?.minLengthMessage +const maxLengthMessage = RichTextEditor.defaultProps?.maxLengthMessage + +describe('useCounter', () => { + it('returns empty message when minLength and maxLength are undefined', () => { + const hookOptions = { + minLengthMessage, + maxLengthMessage, + } + + const { result } = renderHook(() => useCounter(hookOptions)) + + const { counterMessage } = result.current + + expect(counterMessage).toBe('') + }) + + it('returns default message when only minLength is provided', () => { + const hookOptions = { + minLength: 4, + minLengthMessage, + } + + const { result } = renderHook(() => useCounter(hookOptions)) + + const { counterMessage } = result.current + + expect(counterMessage).toContain('4') + }) + + it('returns default message when only maxLength is provided', () => { + const hookOptions = { + maxLength: 4, + maxLengthMessage, + } + + const { result } = renderHook(() => useCounter(hookOptions)) + + const { counterMessage } = result.current + + expect(counterMessage).toContain('4') + }) + + it('returns custom minLength message when minLengthMessage is provided', () => { + const hookOptions = { + minLength: 4, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + minLengthMessage: (_minLength: number, _currLength: number) => + 'custom message', + } + + const { result } = renderHook(() => useCounter(hookOptions)) + + const { counterMessage } = result.current + + expect(counterMessage).toBe('custom message') + }) + + it('returns custom maxLength message when maxLengthMessage is provided', () => { + const hookOptions = { + maxLength: 8, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + maxLengthMessage: () => 'custom message', + } + + const { result } = renderHook(() => useCounter(hookOptions)) + + const { counterMessage } = result.current + + expect(counterMessage).toBe('custom message') + }) + + it('returns respective message when both minLength and maxLength are provided', () => { + const hookOptions = { + minLength: 4, + maxLength: 8, + minLengthMessage: jest.fn(), + maxLengthMessage: jest.fn(), + } + + const { result } = renderHook(() => useCounter(hookOptions)) + + const { handleCounterMessage } = result.current + + expect(hookOptions.minLengthMessage).toHaveBeenCalledTimes(1) + + act(() => handleCounterMessage(6)) + + expect(hookOptions.maxLengthMessage).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useCounter/useCounter.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useCounter/useCounter.tsx new file mode 100644 index 0000000000..498969f841 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useCounter/useCounter.tsx @@ -0,0 +1,119 @@ +import { useCallback, useState } from 'react' + +import type { TextLengthChangeHandler } from '../../../QuillEditor' +import type { CounterMessageSetter } from '../../types' + +type Props = { + minLength?: number + maxLength?: number + minLengthMessage?: CounterMessageSetter + maxLengthMessage?: CounterMessageSetter +} + +const CLOSE_TO_LIMIT = 10 +const defaultMinLengthMessage: CounterMessageSetter = ( + minLength, + currLength, + isError +) => { + if (isError) { + return `${minLength} characters required, current count is ${currLength}` + } + + return `${currLength} characters entered` +} + +const defaultMaxLengthMessage: CounterMessageSetter = ( + maxLength, + currLength, + isError +) => { + if (isError) { + return `${currLength - maxLength} over the limit` + } + + return `${maxLength - currLength} characters left` +} + +const getInitialCounterMessage = ({ + minLength, + maxLength, + minLengthMessage = defaultMinLengthMessage, + maxLengthMessage = defaultMaxLengthMessage, +}: Props) => { + if (minLength) { + return minLengthMessage(minLength, 0, true) + } + + if (maxLength) { + return maxLengthMessage(maxLength, 0, false) + } + + return '' +} + +const useCounter = ({ + minLength, + maxLength, + minLengthMessage = defaultMinLengthMessage, + maxLengthMessage = defaultMaxLengthMessage, +}: Props) => { + const [message, setMesssage] = useState(() => + getInitialCounterMessage({ + minLength, + maxLength, + minLengthMessage, + maxLengthMessage, + }) + ) + const [isError, setIsError] = useState(!!minLength) + + const handleCounterMessage: TextLengthChangeHandler = useCallback( + currLength => { + if (minLength) { + if (currLength < minLength) { + setMesssage(minLengthMessage(minLength, currLength, true)) + setIsError(true) + + return + } else if (!maxLength) { + setMesssage(minLengthMessage(minLength, currLength, false)) + setIsError(false) + + return + } + } + + if (maxLength) { + if ( + maxLength - currLength <= CLOSE_TO_LIMIT && + maxLength - currLength >= 0 + ) { + setMesssage(maxLengthMessage(maxLength, currLength, false)) + setIsError(true) + + return + } + + if (currLength < maxLength) { + setMesssage(maxLengthMessage(maxLength, currLength, false)) + setIsError(false) + + return + } + + setMesssage(maxLengthMessage(maxLength, currLength, true)) + setIsError(true) + } + }, + [minLength, maxLength, minLengthMessage, maxLengthMessage] + ) + + return { + counterMessage: message, + counterError: isError, + handleCounterMessage, + } +} + +export default useCounter diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnFocus/index.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnFocus/index.ts new file mode 100644 index 0000000000..cf7aa185a1 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnFocus/index.ts @@ -0,0 +1 @@ +export { default } from './useOnFocus' diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnFocus/test.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnFocus/test.ts new file mode 100644 index 0000000000..1aee114a35 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnFocus/test.ts @@ -0,0 +1,223 @@ +import { renderHook } from '@testing-library/react-hooks' +import { act } from '@toptal/picasso/test-utils' +import type React from 'react' + +import { actionTypes as toolbarActionTypes } from '../../store/toolbar' +import useOnFocus from './useOnFocus' + +const emptyRef = { current: null } +const mockEvent = { + target: {}, + relatedTarget: {}, +} as React.FocusEvent + +describe('useOnFocus', () => { + describe('handleFocus', () => { + it('does nothing when editor or toolbar are not rendered', () => { + const hookOptions = { + autoFocus: false, + editorRef: emptyRef, + toolbarRef: emptyRef, + wrapperRef: emptyRef, + onFocus: jest.fn(), + onBlur: jest.fn(), + dispatch: jest.fn(), + } + + const { result } = renderHook(() => useOnFocus(hookOptions)) + + const handleFocus = result.current.handleFocus + + act(() => { + handleFocus(mockEvent) + }) + + expect(hookOptions.dispatch).not.toHaveBeenCalled() + expect(hookOptions.onFocus).not.toHaveBeenCalled() + expect(hookOptions.onBlur).not.toHaveBeenCalled() + }) + + it('enables toolbar when clicked inside toolbar', () => { + const hookOptions = { + autoFocus: false, + editorRef: { current: {} } as React.RefObject, + toolbarRef: { + current: { contains: jest.fn().mockImplementation(() => true) }, + } as unknown as React.RefObject, + wrapperRef: { + current: { contains: jest.fn().mockImplementation(() => false) }, + } as unknown as React.RefObject, + onFocus: jest.fn(), + onBlur: jest.fn(), + dispatch: jest.fn(), + } + + const { result } = renderHook(() => useOnFocus(hookOptions)) + + const handleFocus = result.current.handleFocus + + act(() => { + handleFocus(mockEvent) + }) + + expect(hookOptions.dispatch).toHaveBeenCalledTimes(1) + expect(hookOptions.dispatch).toHaveBeenCalledWith({ + payload: false, + type: toolbarActionTypes.disabled, + }) + expect(hookOptions.onFocus).not.toHaveBeenCalled() + expect(hookOptions.onBlur).not.toHaveBeenCalled() + }) + it('enables toolbar and calls onFocus when clicked inside editor', () => { + const hookOptions = { + autoFocus: false, + editorRef: { current: {} } as React.RefObject, + toolbarRef: { + current: { contains: jest.fn().mockImplementation(() => false) }, + } as unknown as React.RefObject, + wrapperRef: { + current: { contains: jest.fn().mockImplementation(() => false) }, + } as unknown as React.RefObject, + onFocus: jest.fn(), + onBlur: jest.fn(), + dispatch: jest.fn(), + } + + const { result } = renderHook(() => useOnFocus(hookOptions)) + + const handleFocus = result.current.handleFocus + + act(() => { + handleFocus(mockEvent) + }) + + expect(hookOptions.dispatch).toHaveBeenCalledTimes(1) + expect(hookOptions.dispatch).toHaveBeenCalledWith({ + payload: false, + type: toolbarActionTypes.disabled, + }) + expect(hookOptions.onFocus).toHaveBeenCalledTimes(1) + expect(hookOptions.onBlur).not.toHaveBeenCalled() + }) + }) + describe('handleBlur', () => { + it('does nothing when editor or toolbar are not rendered', () => { + const hookOptions = { + autoFocus: false, + editorRef: emptyRef, + toolbarRef: emptyRef, + wrapperRef: emptyRef, + onFocus: jest.fn(), + onBlur: jest.fn(), + dispatch: jest.fn(), + } + + const { result } = renderHook(() => useOnFocus(hookOptions)) + + const handleBlur = result.current.handleBlur + + act(() => { + handleBlur(mockEvent) + }) + + expect(hookOptions.dispatch).not.toHaveBeenCalled() + expect(hookOptions.onBlur).not.toHaveBeenCalled() + expect(hookOptions.onFocus).not.toHaveBeenCalled() + }) + + it('does nothing when focusElement is in toolbar', () => { + const hookOptions = { + autoFocus: false, + editorRef: { + contains: jest.fn().mockImplementation(() => false), + } as unknown as React.RefObject, + toolbarRef: { + current: { contains: jest.fn().mockImplementation(() => true) }, + } as unknown as React.RefObject, + wrapperRef: { + current: { contains: jest.fn().mockImplementation(() => false) }, + } as unknown as React.RefObject, + onFocus: jest.fn(), + onBlur: jest.fn(), + dispatch: jest.fn(), + } + + const { result } = renderHook(() => useOnFocus(hookOptions)) + + const handleBlur = result.current.handleBlur + + act(() => { + handleBlur(mockEvent) + }) + + expect(hookOptions.dispatch).not.toHaveBeenCalled() + expect(hookOptions.onBlur).not.toHaveBeenCalled() + expect(hookOptions.onFocus).not.toHaveBeenCalled() + }) + it('does nothing when focusElement is in editor', () => { + const hookOptions = { + autoFocus: false, + editorRef: { + contains: jest.fn().mockImplementation(() => true), + } as unknown as React.RefObject, + toolbarRef: { + current: { contains: jest.fn().mockImplementation(() => false) }, + } as unknown as React.RefObject, + wrapperRef: { + current: { contains: jest.fn().mockImplementation(() => false) }, + } as unknown as React.RefObject, + onFocus: jest.fn(), + onBlur: jest.fn(), + dispatch: jest.fn(), + } + + const { result } = renderHook(() => useOnFocus(hookOptions)) + + const handleBlur = result.current.handleBlur + + act(() => { + handleBlur(mockEvent) + }) + + expect(hookOptions.dispatch).not.toHaveBeenCalled() + expect(hookOptions.onBlur).not.toHaveBeenCalled() + expect(hookOptions.onFocus).not.toHaveBeenCalled() + }) + it('disables toolbar and calls onBlur when clicked outside the editor or toolbar', () => { + const hookOptions = { + autoFocus: false, + editorRef: { + current: { contains: jest.fn().mockImplementation(() => false) }, + } as unknown as React.RefObject, + toolbarRef: { + current: { contains: jest.fn().mockImplementation(() => false) }, + } as unknown as React.RefObject, + wrapperRef: { + current: { contains: jest.fn().mockImplementation(() => false) }, + } as unknown as React.RefObject, + onFocus: jest.fn(), + onBlur: jest.fn(), + dispatch: jest.fn(), + } + + const { result } = renderHook(() => useOnFocus(hookOptions)) + + const handleBlur = result.current.handleBlur + + act(() => { + handleBlur(mockEvent) + }) + + expect(hookOptions.dispatch).toHaveBeenCalledTimes(2) + expect(hookOptions.dispatch).toHaveBeenCalledWith({ + payload: true, + type: toolbarActionTypes.disabled, + }) + expect(hookOptions.dispatch).toHaveBeenCalledWith({ + type: toolbarActionTypes.resetFormat, + }) + expect(hookOptions.onBlur).toHaveBeenCalledTimes(1) + expect(hookOptions.onFocus).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnFocus/useOnFocus.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnFocus/useOnFocus.tsx new file mode 100644 index 0000000000..d82e3ddee7 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnFocus/useOnFocus.tsx @@ -0,0 +1,87 @@ +import { useCallback, useState } from 'react' + +import { actions as toolbarActions } from '../../store/toolbar' +import type { ActionsType } from '../../store' + +type Props = { + autoFocus: boolean + editorRef: React.RefObject + toolbarRef: React.RefObject + wrapperRef: React.RefObject + onFocus: () => void + onBlur: () => void + dispatch: React.Dispatch +} + +const useOnFocus = ({ + autoFocus, + editorRef, + toolbarRef, + wrapperRef, + onFocus, + onBlur, + dispatch, +}: Props) => { + const [isEditorFocused, setIsEditorFocused] = useState(autoFocus) + + const handleFocus = useCallback( + (e: React.FocusEvent) => { + if (!editorRef.current || !toolbarRef.current || !wrapperRef) { + return + } + + toolbarActions.setDisabled(dispatch)(false) + + const focusElement = e.target as Node + const isFocusElementInToolbar = toolbarRef.current.contains(focusElement) + + if (isFocusElementInToolbar) { + return + } + + setIsEditorFocused(true) + + onFocus() + }, + [dispatch, onFocus, editorRef, toolbarRef, wrapperRef] + ) + + const handleBlur = useCallback( + (e: React.FocusEvent) => { + if (!toolbarRef.current || !editorRef.current) { + return + } + + const focusElement = e.relatedTarget as Node + + const isFocusElementInToolbar = toolbarRef.current.contains(focusElement) + const isFocusElementInEditor = editorRef.current.contains(focusElement) + const isFocusElementWrapper = wrapperRef.current === focusElement + + if ( + isFocusElementInToolbar || + isFocusElementInEditor || + isFocusElementWrapper + ) { + return + } + + toolbarActions.setDisabled(dispatch)(true) + + toolbarActions.resetFormat(dispatch)() + + setIsEditorFocused(false) + + onBlur() + }, + [dispatch, onBlur, toolbarRef, editorRef, wrapperRef] + ) + + return { + isEditorFocused, + handleFocus, + handleBlur, + } +} + +export default useOnFocus diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnSelectionChange/index.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnSelectionChange/index.ts new file mode 100644 index 0000000000..4cc815f005 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnSelectionChange/index.ts @@ -0,0 +1 @@ +export { default } from './useOnSelectionChange' diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnSelectionChange/test.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnSelectionChange/test.ts new file mode 100644 index 0000000000..e7a6eab3e4 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnSelectionChange/test.ts @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react-hooks' +import { act } from '@toptal/picasso/test-utils' + +import { actionTypes } from '../../store/toolbar' +import useOnSelectionChange from './useOnSelectionChange' + +describe('useOnSelectionChange', () => { + it('calls dispatch with correct actions', () => { + const dispatch = jest.fn() + + const { result } = renderHook(() => useOnSelectionChange({ dispatch })) + + act(() => + result.current.handleSelectionChange({ + bold: true, + italic: false, + header: 3, + list: 'ordered', + link: 'https://toptal.com', + }) + ) + + expect(dispatch).toHaveBeenCalledTimes(5) + expect(dispatch).toHaveBeenCalledWith({ + type: actionTypes.bold, + payload: true, + }) + expect(dispatch).toHaveBeenCalledWith({ + type: actionTypes.italic, + payload: false, + }) + expect(dispatch).toHaveBeenCalledWith({ + type: actionTypes.header, + payload: '3', + }) + expect(dispatch).toHaveBeenCalledWith({ + type: actionTypes.list, + payload: 'ordered', + }) + expect(dispatch).toHaveBeenCalledWith({ + type: actionTypes.link, + payload: 'https://toptal.com', + }) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnSelectionChange/useOnSelectionChange.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnSelectionChange/useOnSelectionChange.tsx new file mode 100644 index 0000000000..ca76a37cab --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnSelectionChange/useOnSelectionChange.tsx @@ -0,0 +1,31 @@ +import type { Dispatch } from 'react' +import { useCallback } from 'react' + +import type { FormatType as EditorFormatType } from '../../../QuillEditor' +import { actions as toolbarActions } from '../../store/toolbar' +import type { ActionsType } from '../../store' +import { getToolbarFormatFromEditorFormat } from '../../utils/convertFormat' + +type Props = { + dispatch: Dispatch +} + +const useOnSelectionChange = ({ dispatch }: Props) => { + const handleSelectionChange = useCallback( + (editorFormat: EditorFormatType) => { + const { bold, italic, header, list, link } = + getToolbarFormatFromEditorFormat(editorFormat) + + toolbarActions.setBold(dispatch)(bold) + toolbarActions.setItalic(dispatch)(italic) + toolbarActions.setHeader(dispatch)(header) + toolbarActions.setList(dispatch)(list) + toolbarActions.setLink(dispatch)(link) + }, + [dispatch] + ) + + return { handleSelectionChange } +} + +export default useOnSelectionChange diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnTextFormat/index.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnTextFormat/index.ts new file mode 100644 index 0000000000..6606d22627 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnTextFormat/index.ts @@ -0,0 +1 @@ +export { default } from './useOnTextFormat' diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnTextFormat/test.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnTextFormat/test.ts new file mode 100644 index 0000000000..277df767fe --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnTextFormat/test.ts @@ -0,0 +1,132 @@ +import { renderHook } from '@testing-library/react-hooks' +import { act } from '@toptal/picasso/test-utils' + +import { actionTypes } from '../../store/toolbar' +import useOnTextFormat from './useOnTextFormat' + +describe('useOnTextFormat', () => { + it('handles bold action', () => { + const dispatch = jest.fn() + + const { result } = renderHook(() => useOnTextFormat({ dispatch })) + + const handleTextFormat = result.current.handleTextFormat + + act(() => + handleTextFormat({ + formatName: 'bold', + value: true, + }) + ) + + expect(dispatch).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledWith({ + type: actionTypes.bold, + payload: true, + }) + + act(() => + handleTextFormat({ + formatName: 'bold', + value: false, + }) + ) + expect(dispatch).toHaveBeenCalledWith({ + type: actionTypes.bold, + payload: false, + }) + }) + it('handles italic action', () => { + const dispatch = jest.fn() + + const { result } = renderHook(() => useOnTextFormat({ dispatch })) + + const handleTextFormat = result.current.handleTextFormat + + act(() => + handleTextFormat({ + formatName: 'italic', + value: true, + }) + ) + + expect(dispatch).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledWith({ + type: actionTypes.italic, + payload: true, + }) + + act(() => + handleTextFormat({ + formatName: 'italic', + value: false, + }) + ) + expect(dispatch).toHaveBeenCalledWith({ + type: actionTypes.italic, + payload: false, + }) + }) + it('handles list action', () => { + const dispatch = jest.fn() + + const { result } = renderHook(() => useOnTextFormat({ dispatch })) + + const handleTextFormat = result.current.handleTextFormat + + act(() => + handleTextFormat({ + formatName: 'list', + value: 'bullet', + }) + ) + + expect(dispatch).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledWith({ + type: actionTypes.list, + payload: 'bullet', + }) + + act(() => + handleTextFormat({ + formatName: 'list', + value: undefined, + }) + ) + expect(dispatch).toHaveBeenCalledWith({ + type: actionTypes.list, + payload: false, + }) + }) + it('handles header action', () => { + const dispatch = jest.fn() + + const { result } = renderHook(() => useOnTextFormat({ dispatch })) + + const handleTextFormat = result.current.handleTextFormat + + act(() => + handleTextFormat({ + formatName: 'header', + value: 3, + }) + ) + + expect(dispatch).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledWith({ + type: actionTypes.header, + payload: '3', + }) + + act(() => + handleTextFormat({ + formatName: 'header', + value: undefined, + }) + ) + expect(dispatch).toHaveBeenCalledWith({ + type: actionTypes.header, + payload: '', + }) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnTextFormat/useOnTextFormat.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnTextFormat/useOnTextFormat.tsx new file mode 100644 index 0000000000..32278c7323 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useOnTextFormat/useOnTextFormat.tsx @@ -0,0 +1,60 @@ +import type { Dispatch } from 'react' +import { useCallback } from 'react' + +import type { ActionsType } from '../../store' +import { actions as toolbarActions } from '../../store/toolbar' +import type { TextFormatHandlerEvent } from '../../../QuillEditor' +import { + convertBoldFromEditorValue, + convertItalicFromEditorValue, + convertListFromEditorValue, + convertHeaderFromEditorValue, + convertLinkFromEditorValue, +} from '../../utils/convertFormat' + +type Props = { + dispatch: Dispatch +} + +const useOnTextFormat = ({ dispatch }: Props) => { + const handleTextFormat = useCallback( + (e: TextFormatHandlerEvent) => { + switch (e.formatName) { + case 'bold': { + const boldValue = convertBoldFromEditorValue(e.value) + + return toolbarActions.setBold(dispatch)(boldValue) + } + case 'italic': { + const italicValue = convertItalicFromEditorValue(e.value) + + return toolbarActions.setItalic(dispatch)(italicValue) + } + case 'list': { + const listValue = convertListFromEditorValue(e.value) + + return toolbarActions.setList(dispatch)(listValue) + } + case 'header': { + const headerValue = convertHeaderFromEditorValue(e.value) + + return toolbarActions.setHeader(dispatch)(headerValue) + } + case 'link': { + const linkValue = convertLinkFromEditorValue(e.value) + + return toolbarActions.setLink(dispatch)(linkValue) + } + default: + throw Error( + `TextEditor - useOnTextFormat is not implemented for ${e}` + ) + } + }, + [dispatch] + ) + + return { handleTextFormat } +} + +export default useOnTextFormat diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useTextEditorState/index.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useTextEditorState/index.ts new file mode 100644 index 0000000000..9c86cd7b0b --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useTextEditorState/index.ts @@ -0,0 +1 @@ +export { default } from './useTextEditorState' diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useTextEditorState/test.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useTextEditorState/test.ts new file mode 100644 index 0000000000..d38bb66779 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useTextEditorState/test.ts @@ -0,0 +1,102 @@ +import { renderHook } from '@testing-library/react-hooks' +import { act } from '@toptal/picasso/test-utils' + +import useTextEditorState from './useTextEditorState' +import { initialState } from '../../store' +import { actionTypes } from '../../store/toolbar' + +describe('useTextEditorState', () => { + it('returns initial state', () => { + const { result } = renderHook(() => useTextEditorState()) + + expect(result.current.state).toEqual(initialState) + expect(result.current.dispatch).toBeInstanceOf(Function) + }) + + describe('toolbar', () => { + describe('format', () => { + it('updates bold', () => { + const { result } = renderHook(() => useTextEditorState()) + const dispatch = result.current.dispatch + + expect(result.current.state).toEqual(initialState) + act(() => dispatch({ type: actionTypes.bold, payload: true })) + expect(result.current.state).toEqual({ + ...initialState, + toolbar: { + ...initialState.toolbar, + format: { + ...initialState.toolbar.format, + bold: true, + }, + }, + }) + }) + it('updates italic', () => { + const { result } = renderHook(() => useTextEditorState()) + const dispatch = result.current.dispatch + + expect(result.current.state).toEqual(initialState) + act(() => dispatch({ type: actionTypes.italic, payload: true })) + expect(result.current.state).toEqual({ + ...initialState, + toolbar: { + ...initialState.toolbar, + format: { + ...initialState.toolbar.format, + italic: true, + }, + }, + }) + }) + it('updates header', () => { + const { result } = renderHook(() => useTextEditorState()) + const dispatch = result.current.dispatch + + expect(result.current.state).toEqual(initialState) + act(() => dispatch({ type: actionTypes.header, payload: '3' })) + expect(result.current.state).toEqual({ + ...initialState, + toolbar: { + ...initialState.toolbar, + format: { + ...initialState.toolbar.format, + header: '3', + }, + }, + }) + }) + it('updates list', () => { + const { result } = renderHook(() => useTextEditorState()) + const dispatch = result.current.dispatch + + expect(result.current.state).toEqual(initialState) + act(() => dispatch({ type: actionTypes.list, payload: 'ordered' })) + expect(result.current.state).toEqual({ + ...initialState, + toolbar: { + ...initialState.toolbar, + format: { + ...initialState.toolbar.format, + list: 'ordered', + }, + }, + }) + }) + }) + it('updates disabled', () => { + const { result } = renderHook(() => useTextEditorState()) + const dispatch = result.current.dispatch + + expect(result.current.state).toEqual(initialState) + act(() => dispatch({ type: actionTypes.disabled, payload: true })) + expect(result.current.state).toEqual({ + ...initialState, + toolbar: { + ...initialState.toolbar, + disabled: true, + }, + }) + }) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useTextEditorState/useTextEditorState.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useTextEditorState/useTextEditorState.tsx new file mode 100644 index 0000000000..07f6a97ba8 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useTextEditorState/useTextEditorState.tsx @@ -0,0 +1,11 @@ +import { useReducer } from 'react' + +import { combinedReducers, initialState } from '../../store' + +const useTextEditorState = () => { + const [state, dispatch] = useReducer(combinedReducers, initialState) + + return { state, dispatch } +} + +export default useTextEditorState diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useToolbarHandlers/index.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useToolbarHandlers/index.ts new file mode 100644 index 0000000000..49078a89cc --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useToolbarHandlers/index.ts @@ -0,0 +1 @@ +export { default } from './useToolbarHandlers' diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useToolbarHandlers/test.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useToolbarHandlers/test.ts new file mode 100644 index 0000000000..e7b3aeac24 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useToolbarHandlers/test.ts @@ -0,0 +1,194 @@ +import type React from 'react' +import { act } from '@toptal/picasso/test-utils' +import { renderHook } from '@testing-library/react-hooks' + +import useToolbarHandlers from './useToolbarHandlers' +import { initialState } from '../../store' + +const initialFormatState = initialState.toolbar.format +const mockEvent = {} as React.MouseEvent + +const editorRef = { + current: null, +} + +describe('useToolbarHandlers', () => { + describe('calls handleTextFormat with proper arguments', () => { + it('checks handleBold', () => { + const handleTextFormat = jest.fn() + const initFormat = initialFormatState + + const { rerender, result } = renderHook( + ({ format }) => + useToolbarHandlers({ + editorRef, + handleTextFormat, + format, + }), + { + initialProps: { + format: initFormat, + }, + } + ) + + act(() => result.current.handleBold(mockEvent)) + + expect(handleTextFormat).toHaveBeenCalledTimes(1) + expect(handleTextFormat).toHaveBeenCalledWith({ + formatName: 'bold', + value: !initFormat.bold, + }) + + const newFormat = { ...initFormat, bold: true } + + rerender({ format: newFormat }) + act(() => result.current.handleBold(mockEvent)) + + expect(handleTextFormat).toHaveBeenCalledTimes(2) + expect(handleTextFormat).toHaveBeenCalledWith({ + formatName: 'bold', + value: !newFormat.bold, + }) + }) + it('checks handleItalic', () => { + const handleTextFormat = jest.fn() + const initFormat = initialFormatState + + const { rerender, result } = renderHook( + ({ format }) => + useToolbarHandlers({ + editorRef, + handleTextFormat, + format, + }), + { + initialProps: { + format: initFormat, + }, + } + ) + + act(() => result.current.handleItalic(mockEvent)) + + expect(handleTextFormat).toHaveBeenCalledTimes(1) + expect(handleTextFormat).toHaveBeenCalledWith({ + formatName: 'italic', + value: !initFormat.italic, + }) + + const newFormat = { ...initFormat, italic: true } + + rerender({ format: newFormat }) + act(() => result.current.handleItalic(mockEvent)) + + expect(handleTextFormat).toHaveBeenCalledTimes(2) + expect(handleTextFormat).toHaveBeenCalledWith({ + formatName: 'italic', + value: !newFormat.italic, + }) + }) + it('checks handleHeader', () => { + const handleTextFormat = jest.fn() + const initFormat = initialFormatState + + const { result } = renderHook(() => + useToolbarHandlers({ + editorRef, + handleTextFormat, + format: initFormat, + }) + ) + + act(() => result.current.handleHeader({ target: { value: '3' } } as any)) + + expect(handleTextFormat).toHaveBeenCalledTimes(1) + expect(handleTextFormat).toHaveBeenCalledWith({ + formatName: 'header', + value: 3, + }) + + act(() => result.current.handleHeader({ target: { value: '' } } as any)) + + expect(handleTextFormat).toHaveBeenCalledTimes(2) + expect(handleTextFormat).toHaveBeenCalledWith({ + formatName: 'header', + value: undefined, + }) + }) + it('checks handleUnordered', () => { + const handleTextFormat = jest.fn() + const initFormat = initialFormatState + + const { rerender, result } = renderHook( + ({ format }) => + useToolbarHandlers({ + editorRef, + handleTextFormat, + format, + }), + { + initialProps: { + format: initFormat, + }, + } + ) + + act(() => result.current.handleUnordered(mockEvent)) + + expect(handleTextFormat).toHaveBeenCalledTimes(1) + expect(handleTextFormat).toHaveBeenCalledWith({ + formatName: 'list', + value: 'bullet', + }) + + const newFormat = { ...initFormat, list: 'bullet' } as const + + rerender({ format: newFormat }) + act(() => result.current.handleUnordered(mockEvent)) + + expect(handleTextFormat).toHaveBeenCalledTimes(2) + expect(handleTextFormat).toHaveBeenCalledWith({ + formatName: 'list', + value: undefined, + }) + }) + it('checks handleOrdered', () => { + const handleTextFormat = jest.fn() + const initFormat = initialFormatState + + const { rerender, result } = renderHook( + ({ format }) => + useToolbarHandlers({ + editorRef, + handleTextFormat, + format, + }), + { + initialProps: { + format: initFormat, + }, + } + ) + + act(() => result.current.handleOrdered(mockEvent)) + + expect(handleTextFormat).toHaveBeenCalledTimes(1) + expect(handleTextFormat).toHaveBeenCalledWith({ + formatName: 'list', + value: 'ordered', + }) + + const newFormat = { ...initFormat, list: 'ordered' } as const + + rerender({ format: newFormat }) + act(() => result.current.handleOrdered(mockEvent)) + + expect(handleTextFormat).toHaveBeenCalledTimes(2) + expect(handleTextFormat).toHaveBeenCalledWith({ + formatName: 'list', + value: undefined, + }) + }) + }) +}) diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useToolbarHandlers/useToolbarHandlers.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useToolbarHandlers/useToolbarHandlers.tsx new file mode 100644 index 0000000000..51a22ac7fd --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/hooks/useToolbarHandlers/useToolbarHandlers.tsx @@ -0,0 +1,148 @@ +import { useCallback } from 'react' + +import type { + TextFormatHandler, + FormatType as EditorFormatType, +} from '../../../QuillEditor' +import { CUSTOM_QUILL_EDITOR_FORMAT_EVENT } from '../../../QuillEditor' +import { + INSERT_DEFAULT_LINK_TEXT, + INSERT_EMOJI, +} from '../../../QuillEditor/constants' +import type { + SelectOnChangeHandler, + ButtonHandlerType, +} from '../../../RichTextEditorToolbar' +import type { FormatType } from '../../store/toolbar' +import { convertHeaderToEditorValue } from '../../utils/convertFormat' + +type Props = { + editorRef: React.RefObject + handleTextFormat: TextFormatHandler + format: FormatType +} + +const useToolbarHandlers = ({ editorRef, handleTextFormat, format }: Props) => { + const sendFormatEvent = useCallback( + (detail: Partial) => { + const formatEvent = new CustomEvent(CUSTOM_QUILL_EDITOR_FORMAT_EVENT, { + detail, + }) + + editorRef.current?.dispatchEvent(formatEvent) + }, + [editorRef] + ) + + const sendDefaultLinkTextEvent = useCallback( + detail => { + const defaultLinkTextEvent = new CustomEvent(INSERT_DEFAULT_LINK_TEXT, { + detail, + }) + + editorRef.current?.dispatchEvent(defaultLinkTextEvent) + }, + [editorRef] + ) + + const sendInsertEmojiEvent = useCallback( + detail => { + const insertEmojiEvent = new CustomEvent(INSERT_EMOJI, { + detail, + }) + + editorRef.current?.dispatchEvent(insertEmojiEvent) + }, + [editorRef] + ) + + const handleBold: ButtonHandlerType = () => { + const bold = !format.bold + + sendFormatEvent({ bold }) + handleTextFormat({ + formatName: 'bold', + value: bold, + }) + } + + const handleItalic: ButtonHandlerType = () => { + const italic = !format.italic + + sendFormatEvent({ italic }) + handleTextFormat({ + formatName: 'italic', + value: italic, + }) + } + + const handleOrdered: ButtonHandlerType = () => { + const list = format.list === 'ordered' ? undefined : 'ordered' + + sendFormatEvent({ list }) + handleTextFormat({ + formatName: 'list', + value: list, + }) + } + + const handleUnordered: ButtonHandlerType = () => { + const list = format.list === 'bullet' ? undefined : 'bullet' + + sendFormatEvent({ list }) + handleTextFormat({ + formatName: 'list', + value: list, + }) + } + + const handleHeader: SelectOnChangeHandler = event => { + const header = convertHeaderToEditorValue(event.target.value) + + sendFormatEvent( + header ? { header, bold: false, italic: false } : { header } + ) + handleTextFormat({ + formatName: 'header', + value: header, + }) + } + + const handleLink: ButtonHandlerType = () => { + const link = window.prompt('URL') + + const URLRegexp = new RegExp( + /[(http(s)?)://(www.)?a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/gi + ) + + if (!link || !URLRegexp.test(link)) { + window.alert('Not valid URL') + + return + } + + sendDefaultLinkTextEvent({ link }) + + sendFormatEvent({ link }) + handleTextFormat({ + formatName: 'link', + value: link, + }) + } + + const insertEmoji = (emoji: any) => { + sendInsertEmojiEvent(emoji) + } + + return { + handleBold, + handleItalic, + handleOrdered, + handleUnordered, + handleHeader, + handleLink, + insertEmoji, + } +} + +export default useToolbarHandlers diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/index.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/index.ts new file mode 100644 index 0000000000..cc31144c96 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/index.ts @@ -0,0 +1,8 @@ +import type { OmitInternalProps } from '@toptal/picasso-shared' + +import type { Props } from './RichTextEditor' +export type { CustomEmojiGroup } from './types' + +export { default } from './RichTextEditor' +export type { RichTextEditorChangeHandler } from './types' +export type RichTextEditorProps = OmitInternalProps diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/store/index.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/store/index.ts new file mode 100644 index 0000000000..4422942500 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/store/index.ts @@ -0,0 +1,14 @@ +import * as toolbarStore from './toolbar' +import type { StateType, ActionsType } from './types' + +export * from './types' + +export const combinedReducers = (state: StateType, action: ActionsType) => { + return { + toolbar: toolbarStore.reducer(state.toolbar, action), + } +} + +export const initialState = { + toolbar: toolbarStore.initialState, +} diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/actionTypes.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/actionTypes.ts new file mode 100644 index 0000000000..fd224a1c07 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/actionTypes.ts @@ -0,0 +1,11 @@ +const actionTypes = { + bold: 'TOOLBAR/SET_BOLD', + header: 'TOOLBAR/SET_HEADER', + italic: 'TOOLBAR/SET_ITALIC', + list: 'TOOLBAR/SET_LIST', + disabled: 'TOOLBAR/SET_DISABLED', + resetFormat: 'TOOLBAR/RESET_FORMAT', + link: 'TOOLBAR/LINK', +} as const + +export default actionTypes diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/actions.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/actions.ts new file mode 100644 index 0000000000..1a6ae93e82 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/actions.ts @@ -0,0 +1,55 @@ +import type { Dispatch } from 'react' + +import actionTypes from './actionTypes' +import type { + SetBoldActionType, + SetHeaderActionType, + SetItalicActionType, + SetListActionType, + SetDisabled, + ResetFormatType, + SetLinkActionType, +} from './types' + +const setBold = + (dispatch: Dispatch) => + (payload: SetBoldActionType['payload']) => + dispatch({ type: actionTypes.bold, payload }) + +const setItalic = + (dispatch: Dispatch) => + (payload: SetItalicActionType['payload']) => + dispatch({ type: actionTypes.italic, payload }) + +const setHeader = + (dispatch: Dispatch) => + (payload: SetHeaderActionType['payload']) => + dispatch({ type: actionTypes.header, payload }) + +const setList = + (dispatch: Dispatch) => + (payload: SetListActionType['payload']) => + dispatch({ type: actionTypes.list, payload }) + +const setLink = + (dispatch: Dispatch) => + (payload: SetLinkActionType['payload']) => + dispatch({ type: actionTypes.link, payload }) + +const setDisabled = (dispatch: Dispatch) => (payload: boolean) => + dispatch({ type: actionTypes.disabled, payload }) + +const resetFormat = (dispatch: Dispatch) => () => + dispatch({ type: actionTypes.resetFormat }) + +const actions = { + setBold, + setItalic, + setHeader, + setList, + setLink, + setDisabled, + resetFormat, +} + +export default actions diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/index.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/index.ts new file mode 100644 index 0000000000..c7cecf8d34 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/index.ts @@ -0,0 +1,7 @@ +import actions from './actions' +import reducer from './reducer' +import initialState from './initialState' +import actionTypes from './actionTypes' + +export * from './types' +export { actions, actionTypes, reducer, initialState } diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/initialState.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/initialState.ts new file mode 100644 index 0000000000..b9811dbfd3 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/initialState.ts @@ -0,0 +1,14 @@ +import type { ToolbarStateType } from './types' + +const initialState: ToolbarStateType = { + format: { + bold: false, + header: '', + italic: false, + list: false, + link: '', + }, + disabled: true, +} + +export default initialState diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/reducer.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/reducer.ts new file mode 100644 index 0000000000..3f15fb0284 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/reducer.ts @@ -0,0 +1,49 @@ +import actionTypes from './actionTypes' +import type { ToolbarReducerType } from './types' +import initialState from './initialState' + +const reducer: ToolbarReducerType = (state = initialState, action) => { + switch (action.type) { + case actionTypes.bold: + return { + ...state, + format: { ...state.format, bold: action.payload }, + } + case actionTypes.italic: + return { + ...state, + format: { ...state.format, italic: action.payload }, + } + case actionTypes.header: + return { + ...state, + format: { ...state.format, header: action.payload }, + } + case actionTypes.list: + return { + ...state, + format: { ...state.format, list: action.payload }, + } + case actionTypes.link: + return { + ...state, + format: { ...state.format, link: action.payload }, + } + case actionTypes.disabled: + return { + ...state, + disabled: action.payload, + } + + case actionTypes.resetFormat: + return { + ...state, + format: initialState.format, + } + + default: + return state + } +} + +export default reducer diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/types.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/types.ts new file mode 100644 index 0000000000..8b9f69f802 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/store/toolbar/types.ts @@ -0,0 +1,68 @@ +import type actionTypes from './actionTypes' + +export type HeaderValue = '3' | '' +export type BoldValue = boolean +export type ItalicValue = boolean +export type ListValue = 'bullet' | 'ordered' | false +export type LinkValue = string + +export type FormatType = { + bold: BoldValue + italic: ItalicValue + list: ListValue + header: HeaderValue + link: LinkValue +} + +export type ToolbarStateType = { + format: FormatType + disabled: boolean +} + +export type SetBoldActionType = { + type: typeof actionTypes.bold + payload: ToolbarStateType['format']['bold'] +} + +export type SetItalicActionType = { + type: typeof actionTypes.italic + payload: ToolbarStateType['format']['italic'] +} + +export type SetListActionType = { + type: typeof actionTypes.list + payload: ToolbarStateType['format']['list'] +} + +export type SetHeaderActionType = { + type: typeof actionTypes.header + payload: ToolbarStateType['format']['header'] +} + +export type SetLinkActionType = { + type: typeof actionTypes.link + payload: ToolbarStateType['format']['link'] +} + +export type SetDisabled = { + type: typeof actionTypes.disabled + payload: boolean +} + +export type ResetFormatType = { + type: typeof actionTypes.resetFormat +} + +export type ToolbarActionsType = + | SetBoldActionType + | SetItalicActionType + | SetListActionType + | SetHeaderActionType + | SetDisabled + | ResetFormatType + | SetLinkActionType + +export type ToolbarReducerType = ( + state: ToolbarStateType | undefined, + action: ToolbarActionsType +) => ToolbarStateType diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/store/types.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/store/types.ts new file mode 100644 index 0000000000..e128c876a7 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/store/types.ts @@ -0,0 +1,7 @@ +import type { ToolbarStateType, ToolbarActionsType } from './toolbar/types' + +export type StateType = { + toolbar: ToolbarStateType +} + +export type ActionsType = ToolbarActionsType diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/story/Default.example.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/story/Default.example.tsx new file mode 100644 index 0000000000..dd39072c16 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/story/Default.example.tsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react' +import { Container } from '@toptal/picasso' +import { RichTextEditor } from '@toptal/picasso-rich-text-editor' + +import type { RichTextEditorChangeHandler } from '../types' + +const Example = () => { + const [value, setValue] = useState() + + const handleChange: RichTextEditorChangeHandler = newValue => + setValue(newValue) + + return ( + <> + + + {value} + + + ) +} + +export default Example diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/story/DefaultValue.example.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/story/DefaultValue.example.tsx new file mode 100644 index 0000000000..95d353b500 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/story/DefaultValue.example.tsx @@ -0,0 +1,152 @@ +import React, { useState } from 'react' +import { Container } from '@toptal/picasso' +import type { + RichTextEditorChangeHandler, + ASTType, +} from '@toptal/picasso-rich-text-editor' +import { RichTextEditor } from '@toptal/picasso-rich-text-editor' + +const ast: ASTType = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'h3', + properties: {}, + children: [{ type: 'text', value: 'Position Description' }], + }, + { + type: 'element', + tagName: 'p', + properties: {}, + children: [ + { + type: 'text', + value: + 'We’re looking for hardworking, self-starting Designers for our ', + }, + { + type: 'element', + tagName: 'strong', + properties: {}, + children: [{ type: 'text', value: 'Product Design' }], + }, + { + type: 'text', + value: ' team to help us define how talent interacts with Toptal.', + }, + ], + }, + { + type: 'element', + tagName: 'p', + properties: {}, + children: [ + { + type: 'text', + value: + 'You’ll build beautiful and inspiring design experiences that help users discover and connect with resources they need in truly innovative ways.', + }, + ], + }, + { + type: 'element', + tagName: 'h3', + properties: {}, + children: [{ type: 'text', value: 'Requirements' }], + }, + { + type: 'element', + tagName: 'ol', + properties: {}, + children: [ + { + type: 'element', + tagName: 'li', + properties: {}, + children: [ + { + type: 'text', + value: + 'Collaborate with PMs and other designers to ship your first product features.', + }, + ], + }, + { + type: 'element', + tagName: 'li', + properties: {}, + children: [{ type: 'text', value: 'Learn about our design system.' }], + }, + ], + }, + { + type: 'element', + tagName: 'h3', + properties: {}, + children: [{ type: 'text', value: 'Requirements' }], + }, + + { + type: 'element', + tagName: 'ul', + properties: {}, + children: [ + { + type: 'element', + tagName: 'li', + properties: {}, + children: [ + { + type: 'text', + value: + 'Proficiency with various design and prototyping tools (such as Sketch, Abstract, Marvel, Principle, Figma), as well as knowledge of HTML and CSS.', + }, + ], + }, + { + type: 'element', + tagName: 'li', + properties: {}, + children: [ + { + type: 'text', + value: + 'An understanding that phenomenal experiences come from collaborative decision-making with front-end developers, engineers, researchers, content strategists, and other disciplines.', + }, + ], + }, + ], + }, + ], +} + +const Example = () => { + const [value, setValue] = useState() + + const handleChange: RichTextEditorChangeHandler = newValue => + setValue(newValue) + + return ( + <> + + + {value} + + + ) +} + +export default Example diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/story/Disabled.example.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/story/Disabled.example.tsx new file mode 100644 index 0000000000..2ce9e9d19a --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/story/Disabled.example.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { FormNonCompound, RichTextEditor } from '@toptal/picasso-forms' +import { noop } from '@toptal/picasso/utils' +import type { ASTType } from '@toptal/picasso-rich-text-editor' + +const ast: ASTType = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'h3', + properties: {}, + children: [ + { type: 'text', value: 'Values inside disabled RichTextEditor' }, + ], + }, + ], +} + +const Example = () => { + return ( + {}}> + {' '} + + + + ) +} + +export default Example diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/story/Emoji.example.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/story/Emoji.example.tsx new file mode 100644 index 0000000000..9fd91db987 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/story/Emoji.example.tsx @@ -0,0 +1,55 @@ +import React, { useState } from 'react' +import { Container } from '@toptal/picasso' +import { RichTextEditor } from '@toptal/picasso-rich-text-editor' + +import type { RichTextEditorChangeHandler, CustomEmojiGroup } from '../types' + +const customEmojis = [ + { + id: 'talent-community', + name: 'Talent Community', + emojis: [ + { + id: 'talent-community', + name: 'Talent Community', + keywords: ['Toptal', 'Talent Community', 'Community'], + skins: [ + { + src: 'https://emoji.slack-edge.com/T01HSMSV622/talent-community/3937b2735bdea8c3.png', + }, + ], + }, + ], + }, +] as CustomEmojiGroup[] + +const Example = () => { + const [value, setValue] = useState() + + const handleChange: RichTextEditorChangeHandler = newValue => + setValue(newValue) + + return ( + <> + + + {value} + + + ) +} + +export default Example diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/story/Limit.example.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/story/Limit.example.tsx new file mode 100644 index 0000000000..cc010870c9 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/story/Limit.example.tsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react' +import { Container } from '@toptal/picasso' +import { RichTextEditor } from '@toptal/picasso-rich-text-editor' + +import type { RichTextEditorChangeHandler } from '../types' + +const Example = () => { + const [value, setValue] = useState() + + const handleChange: RichTextEditorChangeHandler = newValue => + setValue(newValue) + + return ( + <> + + + {value} + + + ) +} + +export default Example diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/story/Links.example.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/story/Links.example.tsx new file mode 100644 index 0000000000..9e3d4bd7c4 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/story/Links.example.tsx @@ -0,0 +1,35 @@ +import React, { useState } from 'react' +import { Container } from '@toptal/picasso' +import { RichTextEditor } from '@toptal/picasso-rich-text-editor' + +import type { RichTextEditorChangeHandler } from '../types' + +const Example = () => { + const [value, setValue] = useState() + + const handleChange: RichTextEditorChangeHandler = newValue => + setValue(newValue) + + return ( + <> + + + {value} + + + ) +} + +export default Example diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/story/Status.example.tsx b/packages/picasso-rich-text-editor/src/RichTextEditor/story/Status.example.tsx new file mode 100644 index 0000000000..2b4418fcd7 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/story/Status.example.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { FormNonCompound, RichTextEditor } from '@toptal/picasso-forms' + +const Example = () => { + return ( + {}}> + + + + ) +} + +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 new file mode 100644 index 0000000000..0c778b5662 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/story/index.jsx @@ -0,0 +1,39 @@ +import PicassoBook from '~/.storybook/components/PicassoBook' +import RichTextEditor from '../RichTextEditor' + +const page = PicassoBook.section('Forms').createPage( + 'RichTextEditor', + ` + ${PicassoBook.createBaseDocsLink( + 'https://share.goabstract.com/e4c79c6c-4bcd-4411-97b7-09e821925e8e?mode=build&sha=e93949b90e728478fecb60bd7ba1efc06803315b' + )} + + ${PicassoBook.createSourceLink(__filename)} + ` +) + +page + .createTabChapter('Props') + .addComponentDocs({ component: RichTextEditor, name: 'RichTextEditor' }) + +page + .createChapter() + .addExample('RichTextEditor/story/Default.example.tsx', 'Default') + .addExample('RichTextEditor/story/DefaultValue.example.tsx', { + title: 'Default value', + takeScreenshot: false, + }) + .addExample('RichTextEditor/story/Disabled.example.tsx', 'Disabled') + .addExample('RichTextEditor/story/Limit.example.tsx', { + title: 'Limit Length', + takeScreenshot: false, + }) + .addExample('RichTextEditor/story/Status.example.tsx', 'Status') + .addExample('RichTextEditor/story/Links.example.tsx', { + title: 'Links', + takeScreenshot: false, + }) + .addExample('RichTextEditor/story/Emoji.example.tsx', { + title: 'Emojis', + takeScreenshot: false, + }) diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/styles.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/styles.ts new file mode 100644 index 0000000000..591d9106b0 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/styles.ts @@ -0,0 +1,42 @@ +import { outline } from '@toptal/picasso-shared' +import type { Theme } from '@material-ui/core/styles' +import { createStyles } from '@material-ui/core/styles' +import highlightAutofillStyles from '@toptal/picasso/InputBase/highlightStyles' + +export default (theme: Theme) => { + const { palette, sizes } = theme + + return createStyles({ + editorWrapper: { + position: 'relative', + borderRadius: sizes.borderRadius.small, + border: `1px solid ${palette.grey.light2}`, + padding: '0.5em', + + '&:hover:not($disabled):not($error)': { + borderColor: palette.grey.main2, + }, + }, + + disabled: { + pointerEvents: 'none', + background: palette.grey.lighter, + border: `1px solid ${palette.grey.lighter2}`, + }, + + error: { + borderColor: palette.red.main, + '&$focused': { + borderColor: palette.red.main, + ...outline(palette.red.main), + }, + }, + + focused: { + borderColor: palette.grey.main2, + ...outline(palette.primary.main), + }, + + ...highlightAutofillStyles(theme), + }) +} diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/types.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/types.ts new file mode 100644 index 0000000000..df2f0d3712 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/types.ts @@ -0,0 +1,11 @@ +import type { ChangeHandler } from '../QuillEditor' + +export type RichTextEditorChangeHandler = ChangeHandler + +export type CounterMessageSetter = ( + limit: number, + currLength: number, + isError: boolean +) => string + +export type { CustomEmojiGroup, CustomEmoji } from '../QuillEditor' diff --git a/packages/picasso-rich-text-editor/src/RichTextEditor/utils/convertFormat.ts b/packages/picasso-rich-text-editor/src/RichTextEditor/utils/convertFormat.ts new file mode 100644 index 0000000000..6efd8da710 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditor/utils/convertFormat.ts @@ -0,0 +1,44 @@ +import type { + FormatType as EditorFormatType, + BoldValue as EditorBoldValue, + ItalicValue as EditorItalicValue, + ListValue as EditorListValue, + HeaderValue as EditorHeaderValue, + LinkValue as EditorLinkValue, +} from '../../QuillEditor' +import type { + FormatType as ToolbarFormatType, + HeaderValue, +} from '../store/toolbar' + +export const convertBoldFromEditorValue = (bold: EditorBoldValue) => + bold || false +export const convertItalicFromEditorValue = (italic: EditorItalicValue) => + italic || false +export const convertListFromEditorValue = (list: EditorListValue) => + list || false +export const convertHeaderFromEditorValue = (header: EditorHeaderValue) => + header ? '3' : '' +export const convertLinkFromEditorValue = (link: EditorLinkValue) => link || '' + +export const getToolbarFormatFromEditorFormat = ( + format: EditorFormatType +): ToolbarFormatType => { + return { + bold: convertBoldFromEditorValue(format.bold), + italic: convertItalicFromEditorValue(format.italic), + list: convertListFromEditorValue(format.list), + header: convertHeaderFromEditorValue(format.header), + link: convertLinkFromEditorValue(format.link), + } +} + +export const convertHeaderToEditorValue = ( + header: HeaderValue +): EditorHeaderValue => { + if (header === '') { + return undefined + } + + return 3 +} diff --git a/packages/picasso-rich-text-editor/src/RichTextEditorButton/RichTextEditorButton.tsx b/packages/picasso-rich-text-editor/src/RichTextEditorButton/RichTextEditorButton.tsx new file mode 100644 index 0000000000..3daed5f880 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditorButton/RichTextEditorButton.tsx @@ -0,0 +1,58 @@ +import type { ReactElement } from 'react' +import React from 'react' +import cx from 'classnames' +import type { Theme } from '@material-ui/core' +import { makeStyles } from '@material-ui/core' +import type { BaseProps } from '@toptal/picasso-shared' +import { Button } from '@toptal/picasso' + +import styles from './styles' + +type Props = BaseProps & { + active: boolean + disabled: boolean + icon: ReactElement + onClick: (e: React.MouseEvent) => void + id?: string +} + +// Using { index: 10 } to inject CSS generated classes after the button's classes +// in order to prevent Button's styles to override custom TextEditorButton styles +// Related Jira issue: https://toptal-core.atlassian.net/browse/FX-1520 +const useStyles = makeStyles(styles, { + name: 'TextEditorButton', + index: 10, +}) + +const RichTextEditorButton = (props: Props) => { + const { icon, onClick, active, className, style, disabled, ...rest } = props + const classes = useStyles(props) + + return ( + + ) +} + +RichTextEditorButton.defaultProps = { + active: false, + disabled: false, + onClick: () => {}, +} + +RichTextEditorButton.displayName = 'RichTextEditorButton' + +export default RichTextEditorButton diff --git a/packages/picasso-rich-text-editor/src/RichTextEditorButton/index.ts b/packages/picasso-rich-text-editor/src/RichTextEditorButton/index.ts new file mode 100644 index 0000000000..25746ce3b9 --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditorButton/index.ts @@ -0,0 +1 @@ +export { default } from './RichTextEditorButton' diff --git a/packages/picasso-rich-text-editor/src/RichTextEditorButton/styles.ts b/packages/picasso-rich-text-editor/src/RichTextEditorButton/styles.ts new file mode 100644 index 0000000000..0b97d5121c --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditorButton/styles.ts @@ -0,0 +1,21 @@ +import type { Theme } from '@material-ui/core/styles' +import { createStyles } from '@material-ui/core/styles' + +export default ({ palette, sizes }: Theme) => + createStyles({ + button: { + borderRadius: sizes.borderRadius.small, + + '&+&': { + marginLeft: '0.5em', + }, + }, + + activeButton: { + backgroundColor: palette.grey.dark, + + '&:not(:hover) svg': { + fill: palette.common.white, + }, + }, + }) diff --git a/packages/picasso-rich-text-editor/src/RichTextEditorEmojiPicker/RichTextEditorEmojiPicker.tsx b/packages/picasso-rich-text-editor/src/RichTextEditorEmojiPicker/RichTextEditorEmojiPicker.tsx new file mode 100644 index 0000000000..4bdd7052ef --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditorEmojiPicker/RichTextEditorEmojiPicker.tsx @@ -0,0 +1,110 @@ +/* eslint-disable no-inline-styles/no-inline-styles */ +import React, { useEffect } from 'react' +import data from '@emoji-mart/data' +import Picker from '@emoji-mart/react' +import type { Theme } from '@material-ui/core' +import { makeStyles } from '@material-ui/core' +import cx from 'classnames' +import { Container } from '@toptal/picasso' + +import TextEditorButton from '../RichTextEditorButton' +import type { CustomEmojiGroup } from '../QuillEditor' + +interface Props { + richEditorId: string + customEmojis?: CustomEmojiGroup[] + onInsertEmoji: (emoji: string) => void +} + +const TRIGGER_EMOJI_PICKER_ID = 'trigger-emoji-picker' + +interface StyleProps { + showEmojiPicker: boolean +} + +const useStyles = makeStyles({ + emojiPicker: { + position: 'absolute', + top: 34, + left: 0, + zIndex: 10, + opacity: 0, + pointerEvents: 'none', + }, + activeOpacity: { opacity: 1 }, + activePointers: { pointerEvents: 'all' }, +}) + +const handleEmojiPickerEscBehaviour = ( + event: KeyboardEvent, + setShowEmojiPicker: React.Dispatch> +) => { + if (event.key === 'Escape') { + setShowEmojiPicker(false) + } +} + +export const RichtTextEditorEmojiPicker = ({ + richEditorId, + customEmojis, + onInsertEmoji, +}: Props) => { + const [showEmojiPicker, setShowEmojiPicker] = React.useState(false) + + const classes = useStyles({ showEmojiPicker }) + + const handleEmojiPickerClick = () => { + setShowEmojiPicker(!showEmojiPicker) + } + + const closePicker = () => { + setShowEmojiPicker(false) + } + + const handleEmojiInsert = (emoji: string) => { + onInsertEmoji(emoji) + setShowEmojiPicker(false) + } + + useEffect(() => { + if (!showEmojiPicker) { + return + } + + document.body.addEventListener('keyup', event => { + handleEmojiPickerEscBehaviour(event, setShowEmojiPicker) + }) + + return () => { + document.body.removeEventListener('keyup', event => { + handleEmojiPickerEscBehaviour(event, setShowEmojiPicker) + }) + } + }, [showEmojiPicker, setShowEmojiPicker]) + + return ( + + 🙂} + id={TRIGGER_EMOJI_PICKER_ID} + /> + + + + + ) +} diff --git a/packages/picasso-rich-text-editor/src/RichTextEditorToolbar/RichTextEditorToolbar.tsx b/packages/picasso-rich-text-editor/src/RichTextEditorToolbar/RichTextEditorToolbar.tsx new file mode 100644 index 0000000000..0a1b9eb52c --- /dev/null +++ b/packages/picasso-rich-text-editor/src/RichTextEditorToolbar/RichTextEditorToolbar.tsx @@ -0,0 +1,171 @@ +import React, { forwardRef } from 'react' +import type { Theme } from '@material-ui/core' +import { makeStyles } from '@material-ui/core' +import cx from 'classnames' +import { + Bold16, + Italic16, + Link16, + ListOrdered16, + ListUnordered16, + Container, + Select, +} from '@toptal/picasso' + +import styles from './styles' +import TextEditorButton from '../RichTextEditorButton' +import type { + ButtonHandlerType, + SelectOnChangeHandler, + FormatType, +} from './types' +import type { CustomEmojiGroup, EditorPlugin } from '../QuillEditor' +import { RichtTextEditorEmojiPicker } from '../RichTextEditorEmojiPicker/RichTextEditorEmojiPicker' + +type Props = { + disabled: boolean + id: string + format: FormatType + testIds?: { + headerSelect?: string + boldButton?: string + italicButton?: string + unorderedListButton?: string + orderedListButton?: string + linkButton?: string + emojiButton?: string + } + onBoldClick: ButtonHandlerType + onItalicClick: ButtonHandlerType + onLinkClick: ButtonHandlerType + onInsertEmoji: (emoji: string) => void + onHeaderChange: SelectOnChangeHandler + onUnorderedClick: ButtonHandlerType + onOrderedClick: ButtonHandlerType + plugins?: EditorPlugin[] + customEmojis?: CustomEmojiGroup[] +} + +const useStyles = makeStyles(styles, { + name: 'RichTextEditorToolbar', +}) + +export const RichTextEditorToolbar = forwardRef( + function RichTextEditorToolbar(props: Props, ref) { + const { + disabled, + id, + format, + onBoldClick, + onItalicClick, + onLinkClick, + onInsertEmoji, + onHeaderChange, + onUnorderedClick, + onOrderedClick, + testIds, + plugins, + customEmojis, + } = props + + const classes = useStyles(props) + const isHeadingFormat = format.header === '3' + + const allowLinks = plugins?.includes('link') + const allowEmojis = plugins?.includes('emoji') + + return ( + + +