diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 567eb0e..0f26961 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/README.md b/README.md index e5f7668..aa32c46 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A customizable and easy to use Tiptap styled W - [Get started](#get-started) - [Simple usage](#simple-usage) - [Using mentions](#using-mentions) + - [Image upload](#image-upload) - [Read only](#read-only) - [Customization](#customization) - [Toolbar](#toolbar) @@ -29,6 +30,7 @@ A customizable and easy to use Tiptap styled W - [Root styles](#root-styles) - [Each element styles](#each-element-styles) - [Props](#props) + - [Image upload props `ImageUploadOptions`](#image-upload-props-imageuploadoptions) - [Contributing](#contributing) @@ -106,6 +108,41 @@ function App() { ); } ``` +### Image upload + +![Gif](https://github.com/tiavina-mika/mui-tiptap-editor/blob/main/screenshots/image-upload.gif) + + + +```tsx +// example of API using axios +// note that the response should be directly the image url +// so it can be displayed in the editor +function uploadImage(file) { + const data = new FormData(); + data.append('file', file); + const response = axios.post('/documents/image/upload', data); + return response.data; +}; + +function App() { + return ( + + ); +} +``` See [`here`](https://github.com/tiavina-mika/mui-tiptap-editor/tree/main/example) for more examples that use `TextEditor`. @@ -200,7 +237,14 @@ import { TextEditorReadOnly } from 'mui-tiptap-editor'; enter: "Entrer le lien", height: "Hauteur", width: "Largeur" - } + }, + imageUpload: { + fileTooLarge: "Fichier trop volumineux", + maximumNumberOfFiles: "Nombre maximum de fichiers atteint", + enterValidAltText: "Entrez un texte alternatif valide", + addAltText: "Ajouter un texte alternatif", + invalidMimeType: "Type de fichier invalide", + }, }} /> ``` @@ -266,8 +310,17 @@ import './index.css'; |onChange|`(value: string) => void`|-| Function to call when the input change |userPathname|`string`|/user| URL pathname for the mentioned user (eg: /user/user_id) |labels|`ILabels`|null| Override labels, for example using `i18n` +|uploadImageOptions|`ImageUploadOptions`|null| Override image upload default options like max size, max file number, ... |...all tiptap features|[EditorOptions](https://github.com/ueberdosis/tiptap/blob/e73073c02069393d858ca7d8c44b56a651417080/packages/core/src/types.ts#L52)|empty| Can access to all tiptap `useEditor` props +## Image upload props `ImageUploadOptions` +|props |type | Default value | Description | +|----------------|-------------------------------|-----------------------------|-----------------------------| +|uploadImage|`function`|undefined|an API call to your server to handle and store the image +|maxSize|`number`|10|maximum size of the image in MB +|maxFilesNumber|`number`|5|maximum number of files to be uploaded at once +|allowedMimeTypes|`string[]`|all image types|allowed mime types to be uploaded + ## Contributing Get started [here](https://github.com/tiavina-mika/mui-tiptap-editor/blob/main/CONTRIBUTING.md). diff --git a/package.json b/package.json index 31c7432..4cdc13b 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@tiptap/extension-color": "^2.4.0", "@tiptap/extension-gapcursor": "^2.4.0", "@tiptap/extension-heading": "^2.4.0", + "@tiptap/extension-image": "^2.5.6", "@tiptap/extension-link": "^2.4.0", "@tiptap/extension-mention": "^2.4.0", "@tiptap/extension-placeholder": "^2.4.0", diff --git a/screenshots/image.upload.gif b/screenshots/image.upload.gif new file mode 100644 index 0000000..2383fa6 Binary files /dev/null and b/screenshots/image.upload.gif differ diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 5ca7f83..2736f9b 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -33,6 +33,7 @@ type Props = { onClick?: MouseEventHandler; loading?: boolean; withCloseButton?: boolean; + className?: string; buttonColor?: 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning'; } & DialogProps; @@ -48,6 +49,7 @@ const Dialog = ({ onClick, loading, children, + className, withCloseButton = false, ...dialogProps }: Props) => { @@ -79,7 +81,9 @@ const Dialog = ({ aria-describedby="alert-dialog-description" maxWidth={maxWidth} css={classes.dialog} - onClick={onClick}> + onClick={onClick} + className={className} + >
{title} diff --git a/src/components/TextEditor.tsx b/src/components/TextEditor.tsx index 5c74984..66a1e1b 100644 --- a/src/components/TextEditor.tsx +++ b/src/components/TextEditor.tsx @@ -94,6 +94,7 @@ const TextEditor = ({ mentions, userPathname, labels, + uploadImageOptions, editable = true, withFloatingMenu = false, withBubbleMenu = true, @@ -112,6 +113,8 @@ const TextEditor = ({ user, mentions, userPathname, + uploadImageOptions, + uploadImageLabels: labels?.imageUpload, ...editorOptions }) diff --git a/src/dev/App.tsx b/src/dev/App.tsx index e56a7ea..ee90137 100644 --- a/src/dev/App.tsx +++ b/src/dev/App.tsx @@ -34,7 +34,7 @@ const currentUser = mentions[0]; const theme = createTheme({ palette: { - mode: 'dark', + mode: 'light', }, }); @@ -51,6 +51,13 @@ const App = () => { placeholder="French here" mentions={mentions} user={currentUser} + bubbleMenuToolbar={['align']} + uploadImageOptions={{ + uploadImage: () => Promise.resolve('https://source.unsplash.com/random'), + maxSize: 5, + maxFilesNumber: 2, + allowedMimeTypes: ['image/jpeg', 'image/png', 'image/jpg'], + }} labels={{ editor: { editor: "Editeur", @@ -116,7 +123,14 @@ const App = () => { enter: "Entrer le lien", height: "Hauteur", width: "Largeur" - } + }, + imageUpload: { + fileTooLarge: "Fichier trop volumineux", + maximumNumberOfFiles: "Nombre maximum de fichiers atteint", + enterValidAltText: "Entrez un texte alternatif valide", + addAltText: "Ajouter un texte alternatif", + invalidMimeType: "Type de fichier invalide", + }, }} /> diff --git a/src/extensions/CustomImage.tsx b/src/extensions/CustomImage.tsx new file mode 100644 index 0000000..c7337de --- /dev/null +++ b/src/extensions/CustomImage.tsx @@ -0,0 +1,361 @@ +/** + * + * Custom Image Extension + * + * inspired by: + * https://github.com/angelikatyborska/tiptap-image-alt-text/tree/main + * https://angelika.me/2023/02/26/how-to-add-editing-image-alt-text-tiptap/ + * https://github.com/ueberdosis/tiptap/issues/2912 + * https://tiptap.dev/docs/editor/extensions/functionality/filehandler + * +*/ + +import TiptapImage from '@tiptap/extension-image' +import { Editor, NodeViewWrapper, NodeViewWrapperProps, ReactNodeViewRenderer } from '@tiptap/react'; +import Edit from '../icons/Edit'; +import { IconButton, Stack, TextField, Theme, Typography } from '@mui/material'; +import { ChangeEvent, ClipboardEvent, useEffect, useState } from 'react'; +import { checkAlt } from '../utils/app.utils'; +import Dialog from '../components/Dialog'; +import Add from '../icons/Add'; +import Close from '../icons/Close'; +import { EditorView } from '@tiptap/pm/view'; +import { Slice } from '@tiptap/pm/model'; +import { ILabels, ImageUploadOptions } from '../types'; +import { Plugin } from '@tiptap/pm/state'; + +// check if the image is from tiptap or not +const FROM_TIPTAP = true; + +/** + * function to handle image upload, on drop or paste + * @param options + * @param editor + * @param labels + * @returns + */ +export const onUpload = ( + { + // upload callback, should return the image src, used mainly for uploading to a server + uploadImage, + // max file size in MB + maxSize = 10, + // max number of files + maxFilesNumber = 5, + // drop or paste + type, + // allowed file types to upload + allowedMimeTypes = null, + }: ImageUploadOptions, + // tiptap editor instance + editor: Editor, + // custom labels + labels?: ILabels['imageUpload'], +) => (view: EditorView, event: DragEvent | ClipboardEvent, _: Slice, moved: boolean): boolean | void => { + // default labels + const { + maximumNumberOfFiles = `You can only upload ${maxFilesNumber} images at a time.`, + fileTooLarge = `Images need to be less than ${maxSize}mb in size.`, + invalidMimeType = 'Invalid file type', + } = labels || {}; + + // check if event has files + const hasFiles = type === 'drop' + ? (event as DragEvent).dataTransfer?.files?.length + : (event as ClipboardEvent).clipboardData?.files?.length; + + if (!hasFiles) return; + + if (type === 'drop' && moved) return; + + const images = (type === 'drop' ? (event as DragEvent).dataTransfer?.files : (event as ClipboardEvent).clipboardData?.files) || []; + + if (images.length > maxFilesNumber) { + window.alert(maximumNumberOfFiles); + return; + } + + if (images.length === 0) return; + + event.preventDefault(); + + // const { schema } = view.state; + + for (const image of images) { + // file size in MB + const fileSize = ((image.size / 1024) / 1024).toFixed(4); + + if (!/image/i.test(image.type)) { + window.alert(invalidMimeType); + return; + } + + if ( + (allowedMimeTypes && allowedMimeTypes.length && !allowedMimeTypes.includes(image.type)) + || Array.isArray(allowedMimeTypes) && allowedMimeTypes.length === 0 + ) { + window.alert(invalidMimeType); + return; + } + + // check valid image type under 10MB + if (+fileSize > maxSize) { + window.alert(fileTooLarge); + return; + } + + const reader = new FileReader(); + + reader.onload = (readerEvent: ProgressEvent) => { + if (!readerEvent.target) return; + // const node = schema.nodes.image.create({ + // src: readerEvent.target.result + // }); + + // --------------------------- // + // ---------- drop ---------- // + // --------------------------- // + if (type === 'drop') { + const dropEvent = event as DragEvent; + const coordinates = view.posAtCoords({ left: dropEvent.clientX, top: dropEvent.clientY }) as { pos: number; inside: number; }; + + // if using the plugin, use this commented code + // const transaction = view.state.tr.insert( + // coordinates.pos, + // node + // ); + // view.dispatch(transaction); + + editor.chain().insertContentAt(coordinates.pos, { + type: 'image', + attrs: { + src: readerEvent.target.result, + }, + }).focus().run(); + return; + } + + // --------------------------- // + // ---------- paste ---------- // + // --------------------------- // + // if using the plugin, use this commented code + // const transaction = view.state.tr.replaceSelectionWith(node); + // view.dispatch(transaction); + + editor.chain().insertContentAt(editor.state.selection.anchor, { + type: 'image', + attrs: { + src: readerEvent.target.result, + }, + }).focus().run(); + } + reader.readAsDataURL(image); + } +} + +const classes = { + tiptapImageRootStyle: ({ isRight, isLeft }: { isRight: boolean; isLeft: boolean; }) => { + const values = { + display: 'flex', + flex: 1, + justifyContent: 'center', // default + width: 'fit-content', + position: 'relative' as const, + '&.ProseMirror-selectednode .tiptap-image-content': { + outline: '2px solid blue', + }, + }; + + if (isRight) { + values.justifyContent = 'flex-end'; + } else if (isLeft) { + values.justifyContent = 'flex-start'; + } + + return values + }, + tiptapImageRoot: { + '&.ProseMirror-selectednode .tiptap-image-content': { + outline: '2px solid blue', + }, + }, + altContainer: (theme: Theme) => ({ + position: 'absolute' as const, + bottom: 10, + left: 10, + maxWidth: 'calc(100% - 20px)', + padding: '0 4px', + border: '1px solid ' + theme.palette.divider, + backgroundColor: theme.palette.background.paper, + overflow: 'hidden', + }), + buttonIconSx: { + '& svg': { width: 18 }, + }, + addButton: { + backgroundColor: 'transparent', + border: 'none', + cursor: 'pointer', + borderRadius: 4, + padding: '4px 4px 4px 12px !important', + fontSize: '14px !important' + } +} + +const getClassName = (selected: boolean): string => { + // this className is used in the css file + let className = 'tiptap-image'; + if (selected) { + className += ' ProseMirror-selectednode'; + } + return className +} + +type Props = ILabels['imageUpload'] & NodeViewWrapperProps; + +const ImageNode = ({ labels, node, updateAttributes, editor, ...props }: Props) => { + const [open, setOpen] = useState(false); + const [clear, setClear] = useState(false); + const [alt, setAlt] = useState(''); + const [error, setError] = useState(''); + + useEffect(() => { + setAlt(node.attrs.alt || ''); + }, [node.attrs.alt]) + + const altLabel = labels?.addAltText || 'Add alt text'; + + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + const toggleClear = () => setClear(!clear); + + const handleChange = (e: ChangeEvent) => { + const { value } = e.target; + if (checkAlt(value)) { + setAlt(value); + return; + } + + setError(labels?.enterValidAltText || 'Please enter a valid alt text'); + } + + const onConfirm = async () => { + await updateAttributes({ alt }); + setOpen(false); + // if delete this current node (each image) + // deleteNode(); + } + + const handleDelete = () => { + updateAttributes({ alt: '' }); + setAlt(''); + toggleClear(); + } + + return ( + +
+ {/* ------------ image ------------ */} + {alt} + {/* ------------ alt ------------ */} + {/* + * display only in editable mode + * NOTE: if displaying the html string outside of the editor, hide this by using css + */} + {(!clear && editor.options.editable) && ( + <> + + {alt && !error + ? ( + + {alt} + + + + + ) : ( + + )} + + + + + {error && ( + {error} + )} + + )} +
+ + + +
+ ) +} + +/** + * custom image extension to handle image upload + * it extends the tiptap image extension + * @NOTE if a callback is provided, it will override the default image upload handler + * @param options image upload options like file size, number of files, upload callback + * @param labels custom or override labels + * @returns + */ +const getCustomImage = (options?: Omit, labels?: ILabels['imageUpload']) => TiptapImage.extend({ + defaultOptions: { + ...TiptapImage.options, + sizes: ["inline", "block", "left", "right"] + }, + addNodeView() { + return ReactNodeViewRenderer( + (props: any) => , + { className: 'tiptap-image' } + ); + }, + addProseMirrorPlugins() { + const editor = this.editor as Editor; + return [ + new Plugin({ + props: { + handleDrop: onUpload({ ...options, type: 'drop' }, editor, labels), + handlePaste: onUpload({ ...options, type: 'paste' }, editor, labels), + } as any, + }), + ]; + } +}) + +export default getCustomImage; diff --git a/src/hooks/useTextEditor.ts b/src/hooks/useTextEditor.ts index 30c2419..85d4f2a 100644 --- a/src/hooks/useTextEditor.ts +++ b/src/hooks/useTextEditor.ts @@ -28,12 +28,13 @@ import { EditorEvents, } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; - +import TiptapImage from '@tiptap/extension-image'; import { useEffect } from 'react'; import Heading from '@tiptap/extension-heading'; import { Node } from '@tiptap/pm/model'; import getSuggestion from '../components/mention/suggestions'; -import { ITextEditorOption } from '../types.d'; +import { ILabels, ImageUploadOptions, ITextEditorOption } from '../types.d'; +import getCustomImage from '../extensions/CustomImage'; const extensions = [ Color.configure({ types: [TextStyle.name, ListItem.name] }), @@ -87,7 +88,7 @@ const extensions = [ Gapcursor, Youtube, TextAlign.configure({ - types: ["heading", "paragraph"] + types: ["heading", "paragraph", "table", "image"] }), CodeBlockLowlight.configure({ lowlight: createLowlight(common), @@ -145,6 +146,8 @@ export type TextEditorProps = { mentions?: ITextEditorOption[]; // url for user profile userPathname?: string; + uploadImageOptions?: Omit; + uploadImageLabels?: ILabels['imageUpload']; } & Partial; export const useTextEditor = ({ @@ -154,8 +157,10 @@ export const useTextEditor = ({ tab, user, mentions, - editable = true, + uploadImageOptions, + uploadImageLabels, userPathname, + editable = true, ...editorOptions }: TextEditorProps) => { const theme = useTheme(); @@ -172,16 +177,16 @@ export const useTextEditor = ({ HTMLAttributes: { class: "mention" }, - renderLabel({ options, node }: { - options: MentionOptions; - node: Node; - }) { + renderLabel({ options, node }: { options: MentionOptions; node: Node }) { return `${options.suggestion.char}${ node.attrs.label ?? node.attrs.id.label }`; }, suggestion: getSuggestion(mentions) }), + getCustomImage(uploadImageOptions, uploadImageLabels).configure({ + allowBase64: true + }), ...extensions, ] as AnyExtension[], /* The `onUpdate` function in the `useTextEditor` hook is a callback that is triggered whenever the diff --git a/src/icons/Add.tsx b/src/icons/Add.tsx new file mode 100644 index 0000000..8999790 --- /dev/null +++ b/src/icons/Add.tsx @@ -0,0 +1,14 @@ +import { SvgIcon } from "@mui/material"; + +const Add = () => { + return ( + + + + + + + ); +} + +export default Add; diff --git a/src/icons/Edit.tsx b/src/icons/Edit.tsx new file mode 100644 index 0000000..25900ad --- /dev/null +++ b/src/icons/Edit.tsx @@ -0,0 +1,13 @@ +import { SvgIcon } from "@mui/material"; + +const Edit = () => { + return ( + + + + + + ); +} + +export default Edit; diff --git a/src/index.css b/src/index.css index 5f3e33a..f6a1688 100644 --- a/src/index.css +++ b/src/index.css @@ -35,12 +35,6 @@ code { justify-content: center; } -.flexStretch { - display: flex; - flex-direction: column; - align-items: stretch; -} - .stretch { align-items: stretch; } @@ -57,17 +51,11 @@ code { align-items: flex-end; } -.flexBaseline { - align-items: baseline; -} .spaceBetween { justify-content: space-between; } -.spaceAround { - justify-content: space-around; -} .justifyStart { justify-content: flex-start; @@ -85,17 +73,6 @@ code { align-self: stretch; } -.justifySelf { - justify-self: stretch; -} - -.centerSelf { - align-self: center; -} -.endSelf { - align-self: flex-end; -} - .flex1 { flex: 1; } @@ -121,6 +98,11 @@ code { min-height: 150px; } +.mui-tiptap-input .tiptap-image { + margin-top: 12px; + margin-bottom: 12px; +} + .mui-tiptap-input p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; @@ -138,6 +120,7 @@ code { padding-left: 0; padding-right: 0; min-height: initial; + align-self: stretch; } .tiptap blockquote { @@ -309,3 +292,21 @@ code { cursor: ew-resize; cursor: col-resize; } + +/* --------------------------------------- */ +/* ---------------- Image ---------------- */ +/* --------------------------------------- */ +.tiptap-image .tiptap-alt-text .edit-button { + flex: 0 0 auto; + + border: 0; + padding: 0; + background-color: transparent; + appearance: none; + + text-decoration: none; +} + +.tiptap-image { + display: flex; +} diff --git a/src/types.d.ts b/src/types.d.ts index 6b86cb3..fe4cf8d 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -24,9 +24,46 @@ export enum EditorToolbarEnum { youtube = 'youtube', color = 'color', mention = 'mention', + // does not exist yet ai = 'ai' } +/** + * Image upload options from drop or paste event + * the image can be uploaded to the server via an API or saved inside as a base64 string + */ +export type ImageUploadOptions = { + /** + * callback function to upload the image + * it takes a file as an argument + * it should return directly the uploaded image url + * it is used to upload the image to the server + * @NOTE if not provided, the image will be uploaded as a base64 string and saved so + * @param file + * @returns + */ + uploadImage?: (file: File) => Promise; + /** + * maximum size of the image in MB (each image) + * @default 10mb + */ + maxSize?: number; + /** + * maximum number of files to be uploaded at once + * @default 5 + */ + maxFilesNumber?: number; + /** + * control which mime types are allowed to be uploaded (pasted or dropped) + * @default all image mime types + */ + allowedMimeTypes?: string[] | null; + /** + * type of the upload event + */ + type: 'drop' | 'paste'; +}; + export type IEditorToolbar = `${EditorToolbarEnum}`; export type IRequiredLabels = { @@ -96,6 +133,13 @@ export type IRequiredLabels = { height: string; width: string; }; + imageUpload: { + maximumNumberOfFiles: string; + fileTooLarge: string; + addAltText: string; + enterValidAltText: string; + invalidMimeType: string; + } }; export type ILabels = DeepPartial; @@ -247,4 +291,9 @@ export type TextEditorProps = { * it's useful for i18n or changing the default labels */ labels?: ILabels; + + /** + * upload image options + */ + uploadImageOptions?: Omit; } & Partial; diff --git a/src/utils/app.utils.ts b/src/utils/app.utils.ts index ffe43f6..716ba73 100644 --- a/src/utils/app.utils.ts +++ b/src/utils/app.utils.ts @@ -62,3 +62,10 @@ export const checkIsValidYoutubeUrl = (url: string): boolean => { url.includes("youtube") ); } + +export const checkAlt = (text: string): boolean => { + return ( + text.length > 0 && + typeof text === "string" + ); +} diff --git a/yarn.lock b/yarn.lock index d325de6..8b837f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2392,6 +2392,15 @@ __metadata: languageName: node linkType: hard +"@tiptap/extension-image@npm:^2.5.6": + version: 2.5.6 + resolution: "@tiptap/extension-image@npm:2.5.6" + peerDependencies: + "@tiptap/core": ^2.5.6 + checksum: 10c0/33830ac0372ae3c869d38282a159f3e6f161744b5f5119953b769259ba0ebf70b264730914512211120d5bc4974dd6460e973405e8f14111a654edf16e057afc + languageName: node + linkType: hard + "@tiptap/extension-italic@npm:^2.4.0": version: 2.4.0 resolution: "@tiptap/extension-italic@npm:2.4.0" @@ -7443,6 +7452,7 @@ __metadata: "@tiptap/extension-color": "npm:^2.4.0" "@tiptap/extension-gapcursor": "npm:^2.4.0" "@tiptap/extension-heading": "npm:^2.4.0" + "@tiptap/extension-image": "npm:^2.5.6" "@tiptap/extension-link": "npm:^2.4.0" "@tiptap/extension-mention": "npm:^2.4.0" "@tiptap/extension-placeholder": "npm:^2.4.0"