diff --git a/README.md b/README.md index 840eaf1..d6b2329 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,7 @@ redo: () => Promise selectAll: () => Promise isApple: () => boolean rerender: () => void +getEditorElement: () => HTMLElement ``` #### Queries diff --git a/example/package.json b/example/package.json index 1e78b6d..1af1941 100644 --- a/example/package.json +++ b/example/package.json @@ -10,23 +10,26 @@ "postinstall": "patch-package" }, "dependencies": { + "emotion": "^10.0.9", + "image-extensions": "^1.1.0", + "is-url": "^1.2.4", "slate": "^0.70.0", - "slate-react": "^0.70.0", "slate-history": "^0.66.0", "slate-hyperscript": "^0.67.0", - "emotion": "^10.0.9" + "slate-react": "^0.70.0" }, "devDependencies": { - "patch-package": "^6.4.7", - "@testing-library/react": "^12.1.2", "@testing-library/jest-dom": "^5.15.0", + "@testing-library/react": "^12.1.2", + "@types/is-url": "^1.2.30", "@types/jest": "^27.0.0", "@types/react": "^16.8.0", "@types/react-dom": "^16.8.0", "@vitejs/plugin-react": "^1.0.0", - "typescript": "^4.3.2", - "vite": "^2.6.4", "jest": "^27.3.1", - "ts-jest": "^27.0.7" + "patch-package": "^6.4.7", + "ts-jest": "^27.0.7", + "typescript": "^4.3.2", + "vite": "^2.6.4" } } diff --git a/example/src/Components.tsx b/example/src/Components.tsx index 84a4911..31528a6 100644 --- a/example/src/Components.tsx +++ b/example/src/Components.tsx @@ -7,6 +7,8 @@ import React, { PropsWithChildren } from 'react' import ReactDOM from 'react-dom' import { cx, css } from 'emotion' import { FC } from 'react' +import { useSelected, useFocused } from 'slate-react' +import { ImageElement } from './custom-types' interface BaseProps { className: string @@ -40,8 +42,8 @@ export const Button = React.forwardRef( ? 'white' : '#aaa' : active - ? 'black' - : '#ccc'}; + ? 'black' + : '#ccc'}; `, )} /> @@ -128,3 +130,34 @@ export const Toolbar = React.forwardRef( /> ), ) + +export const Image = React.forwardRef( + ( + { element, attributes, children }: { element: ImageElement, attributes: any, children: React.ReactNode }, + ref: any + ) => { + const selected = useSelected(); + const focused = useFocused(); + + return ( +
+
+ {element.caption} +
+ { + /* see https://github.com/ianstormtaylor/slate/issues/3930#issuecomment-723288696 */ + children + } +
+ + ) + } +); \ No newline at end of file diff --git a/example/src/Editor.tsx b/example/src/Editor.tsx index 7debd4c..373643d 100644 --- a/example/src/Editor.tsx +++ b/example/src/Editor.tsx @@ -15,8 +15,10 @@ import { } from 'slate' import { withHistory } from 'slate-history' -import { Button, Icon, Toolbar } from './Components' +import { Button, Icon, Toolbar, Image } from './Components' import { FC } from 'react' +import { withImages } from './plugins' +import { EditableProps } from 'slate-react/dist/components/editable' const HOTKEYS = { 'mod+b': 'bold', @@ -43,52 +45,73 @@ export const RichTextExample: FC<{ variant = 'wordProcessor', initialValue = emptyEditor, }) => { - const [value, setValue] = useState(initialValue) - const renderElement = useCallback((props) => , []) - const renderLeaf = useCallback((props) => , []) - const editor = useMemo( - () => withHistory(withReact(mockEditor ?? createEditor())), - [], - ) - - return ( - setValue(value)}> - - - - - - {variant === 'wordProcessor' && ( - <> - - - - - - - )} - - { - for (const hotkey in HOTKEYS) { - if (isHotkey(hotkey, event as any)) { - event.preventDefault() - // @ts-ignore - const mark = HOTKEYS[hotkey] - toggleMark(editor, mark) - } + const [value, setValue] = useState(initialValue) + const renderElement = useCallback((props) => , []) + const renderLeaf = useCallback((props) => , []) + const editor = useMemo( + () => withImages(withHistory(withReact(mockEditor ?? createEditor()))), + [], + ) + + + const editableProps: EditableProps = { + renderElement, + renderLeaf, + placeholder: "Enter some rich text…", + autoFocus: true, + onKeyDown: (event) => { + for (const hotkey in HOTKEYS) { + if (isHotkey(hotkey, event as any)) { + event.preventDefault() + // @ts-ignore + const mark = HOTKEYS[hotkey] + toggleMark(editor, mark) } - }} - /> - - ) -} + } + } + } + + /** + * Disable scrollSelectionIntoView when testing. + * We do this to fix `TypeError: Cannot read property 'bind' of undefined` + * that stems from https://github.com/ianstormtaylor/slate/blob/43ca2b56c8bd8bcc30dd38808dd191f804d53ae4/packages/slate-react/src/components/editable.tsx#L1369 + * and https://github.com/ianstormtaylor/slate/blob/43ca2b56c8bd8bcc30dd38808dd191f804d53ae4/packages/slate-react/src/components/editable.tsx#L234 + * + * This error was encountered when testing dropEvent for images + */ + // TODO: Maybe there is a better fix than this, please check the test file to figure if there is any + if (mockEditor) { + editableProps.scrollSelectionIntoView = () => { } + } + + return ( + setValue(value)}> + + + + + + {variant === 'wordProcessor' && ( + <> + + + + + + + )} + + { + console.log(dataTransfer.files[0].name) + }} + /> + + ) + } const toggleBlock = (editor: Editor, format: any) => { const isActive = isBlockActive(editor, format) @@ -157,6 +180,8 @@ const Element: FC = ({ attributes, children, element }) => { return
  • {children}
  • case 'numbered-list': return
      {children}
    + case 'image': + return {children} default: return

    {children}

    } diff --git a/example/src/custom-types.d.ts b/example/src/custom-types.d.ts index 3ef6c7e..cf29fcb 100644 --- a/example/src/custom-types.d.ts +++ b/example/src/custom-types.d.ts @@ -35,6 +35,7 @@ export type HeadingTwoElement = { type: 'heading-two'; children: Descendant[] } export type ImageElement = { type: 'image' url: string + caption: string children: EmptyText[] } diff --git a/example/src/hyperscript.d.ts b/example/src/hyperscript.d.ts index 428d905..1590985 100644 --- a/example/src/hyperscript.d.ts +++ b/example/src/hyperscript.d.ts @@ -9,6 +9,7 @@ declare namespace JSX { italic?: boolean children?: any } + himage: any hbulletedlist: any hlistitem: any cursor: any diff --git a/example/src/plugins.tsx b/example/src/plugins.tsx new file mode 100644 index 0000000..9bc44fd --- /dev/null +++ b/example/src/plugins.tsx @@ -0,0 +1,65 @@ +import imageExtensions from 'image-extensions'; +import isUrl from "is-url"; +import { Transforms } from "slate"; +import { CustomEditor, ImageElement } from "./custom-types"; + +type SlatePlugin = (editor: CustomEditor) => CustomEditor; + +export const withImages: SlatePlugin = editor => { + const { insertData, isVoid } = editor + + const insertImage = (editor: CustomEditor, url: string, caption: string) => { + + const text = { text: '' } + const image: ImageElement = { type: 'image', url, caption, children: [text] } + + Transforms.insertNodes(editor, image); + + } + + const isImageUrl = (url: string) => { + if (!url) return false + if (!isUrl(url)) return false + const ext = new URL(url).pathname.split('.').pop() + return imageExtensions.includes(ext as string) + }; + + editor.isVoid = element => { + return element.type === 'image' ? true : isVoid(element) + } + + editor.insertData = data => { + const text = data.getData('text/plain') + const { files } = data; + + if (files && files.length > 0) { + for (const file of files) { + const reader = new FileReader() + const [mime, extention] = file.type.split('/') + + if (mime === 'image') { + reader.addEventListener('load', () => { + const url = reader.result; + + let caption = file.name; + caption = caption.substring(0, caption.indexOf(`.${extention}`)); + + if (url) { + insertImage(editor, url.toString(), caption) + } else { + throw 'image url should not be null' + } + }) + + reader.readAsDataURL(file) + } + } + } else if (isImageUrl(text)) { + insertImage(editor, text, '') + } else { + insertData(data) + } + } + + return editor +} \ No newline at end of file diff --git a/example/src/test-utils.ts b/example/src/test-utils.ts index 7d69d4c..6357853 100644 --- a/example/src/test-utils.ts +++ b/example/src/test-utils.ts @@ -10,6 +10,7 @@ export const jsx = createHyperscript({ hp: { type: 'paragraph' }, hbulletedlist: { type: 'bulleted-list' }, hlistitem: { type: 'list-item' }, + himage: { type: 'image' }, inline: { inline: true }, block: {}, wrapper: {}, diff --git a/example/src/tests/image.test.tsx b/example/src/tests/image.test.tsx new file mode 100644 index 0000000..04adbbd --- /dev/null +++ b/example/src/tests/image.test.tsx @@ -0,0 +1,110 @@ +/** @jsx jsx */ + +import { assertOutput, buildTestHarness } from '../../../dist/esm' +import { RichTextExample } from '../Editor' +import { jsx } from '../test-utils' +import { createEvent, fireEvent } from '@testing-library/dom' +import { act } from 'react-dom/test-utils'; + +// https://drafts.csswg.org/cssom-view/#caretposition +interface CaretPosition { + readonly offsetNode: Node; + readonly offset: number; + getClientRect(): DOMRect | null; +} + +// https://stackoverflow.com/a/57272491/9936282 +const toBase64 = (file: Blob) => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result); + reader.onerror = error => reject(error); +}); + +async function editorBodySetUp(input: JSX.Element, debug = false) { + + const [editor, slateTestUtils, reactTestUtils] = await buildTestHarness(RichTextExample)({ + editor: input, + debug, + }); + + return { + editor, + slateTestUtils, + reactTestUtils + } + +} + +it('can drag and drop image to editor', async () => { + const fileName = 'chucknorris'; + const file = new File(['(⌐□_□)'], `${fileName}.png`, { type: 'image/png' }); + const fileDataUrl = await toBase64(file).catch((e) => { throw e }); + + const { + editor, + slateTestUtils: { getEditorElement } + } = await editorBodySetUp( + + + + + + + + ) + + const editorElement = getEditorElement(); + // @ts-ignore + editorElement.isContentEditable = true; + + // See https://github.com/testing-library/react-testing-library/issues/339#issuecomment-526241983 + const dropEvent = createEvent.drop(editorElement) + // Mocks + Object.defineProperties(dropEvent, { + clientX: { value: 0 }, + clientY: { value: 0 }, + dataTransfer: { + value: { + files: [new File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png' })], + getData: () => '' + } + } + }); + Object.defineProperty(window.document, 'caretPositionFromPoint', { + value: () => ({ + offset: 0, + // TODO: I am not sure if editorElement is the right Node to be here + offsetNode: editorElement as Node + } as CaretPosition) + }) + + await act(async () => { + /** + * We are having some errors because of this function: + * https://github.com/ianstormtaylor/slate/blob/43ca2b56c8bd8bcc30dd38808dd191f804d53ae4/packages/slate-react/src/plugin/react-editor.ts#L401 + * + * Directions to fix were these: + * https://github.com/testing-library/dom-testing-library/blob/90d420d12d21f4bab2ea2dc92ba1cc274f5bd1e4/src/events.js#L65 + * https://github.com/testing-library/react-testing-library/issues/339 + */ + fireEvent(editorElement, dropEvent) + }); + + await act(() => Promise.resolve()) + + assertOutput(editor, + + + + + + + + + + + + ) + +}); \ No newline at end of file diff --git a/example/yarn.lock b/example/yarn.lock index 91a82a4..6cc2170 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -718,6 +718,11 @@ resolved "https://registry.yarnpkg.com/@types/is-hotkey/-/is-hotkey-0.1.5.tgz#f3123ba21228c0408c10594abf378caddbb802f8" integrity sha512-pZTb6AsG7I56FJgYA8Cbit3cB3NGVwyHgwyUCENjXewTQChOtQaxaV+u6BO4hqtS1o9KT1wML+NRkGhQZ6swtA== +"@types/is-url@^1.2.30": + version "1.2.30" + resolved "https://registry.yarnpkg.com/@types/is-url/-/is-url-1.2.30.tgz#85567e8bee4fee69202bc3448f9fb34b0d56c50a" + integrity sha512-AnlNFwjzC8XLda5VjRl4ItSd8qp8pSNowvsut0WwQyBWHpOxjxRJm8iO6uETWqEyLdYdb9/1j+Qd9gQ4l5I4fw== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" @@ -1770,6 +1775,11 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +image-extensions@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/image-extensions/-/image-extensions-1.1.0.tgz#b8e6bf6039df0056e333502a00b6637a3105d894" + integrity sha1-uOa/YDnfAFbjM1AqALZjejEF2JQ= + immer@^9.0.6: version "9.0.6" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73" @@ -1878,6 +1888,11 @@ is-typedarray@^1.0.0: resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= +is-url@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/is-url/-/is-url-1.2.4.tgz#04a4df46d28c4cff3d73d01ff06abeb318a1aa52" + integrity sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww== + is-wsl@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" diff --git a/src/buildTestHarness.tsx b/src/buildTestHarness.tsx index b88075e..5b15be7 100644 --- a/src/buildTestHarness.tsx +++ b/src/buildTestHarness.tsx @@ -38,6 +38,7 @@ export type RenderEditorReturnTuple = [ selectAll: () => Promise isApple: () => boolean rerender: () => void + getEditorElement: () => HTMLElement }, ReturnType, ] @@ -343,6 +344,7 @@ export const buildTestHarness = selectAll, isApple, rerender: () => options.rerender(), + getEditorElement: () => element }, options, ]