From 3a836115d67aabc2c18080cac584a754ccc97f37 Mon Sep 17 00:00:00 2001 From: reactoholic Date: Fri, 3 Jan 2025 12:20:21 +0200 Subject: [PATCH 1/8] implement image pasting in md editor --- .../MarkdownInput/FormikMarkdownField.tsx | 126 +++++++++++++++--- .../ui/forms/MarkdownInput/MarkdownInput.tsx | 18 +-- .../MarkdownInputControls.tsx | 1 + 3 files changed, 120 insertions(+), 25 deletions(-) diff --git a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx index 2751ea331d..62f4c8303a 100644 --- a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx +++ b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx @@ -1,6 +1,25 @@ -import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { FormControl, FormHelperText, InputLabel, InputProps, OutlinedInput, useFormControl } from '@mui/material'; +import { + ChangeEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + ClipboardEvent, + PropsWithChildren, +} from 'react'; +import { + FormControl, + FormHelperText, + InputBaseComponentProps, + InputLabel, + InputProps, + OutlinedInput, + useFormControl, +} from '@mui/material'; import { useField } from 'formik'; +import { Editor } from '@tiptap/react'; import CharacterCounter from '../characterCounter/CharacterCounter'; import TranslationKey from '@/core/i18n/utils/TranslationKey'; import { useValidationMessageTranslation } from '@/domain/shared/i18n/ValidationMessageTranslation'; @@ -11,6 +30,9 @@ import { MarkdownTextMaxLength } from '../field-length.constants'; import { error as logError } from '@/core/logging/sentry/log'; import { isMarkdownMaxLengthError } from './MarkdownValidator'; import { useTranslation } from 'react-i18next'; +import { useUploadFileMutation } from '@/core/apollo/generated/apollo-hooks'; +import { useNotification } from '../../notifications/useNotification'; +import { useStorageConfigContext } from '@/domain/storage/StorageBucket/StorageConfigContext'; interface MarkdownFieldProps extends InputProps { title: string; @@ -60,8 +82,10 @@ export const FormikMarkdownField = ({ temporaryLocation = false, controlsVisible = 'always', }: MarkdownFieldProps) => { - const tErr = useValidationMessageTranslation(); - const { t } = useTranslation(); + const [editor, setEditor] = useState(); + + const notify = useNotification(); + const validate = () => { const characterCount = inputElementRef.current?.value?.length ?? 0; const isAboveCharacterLimit = maxLength && characterCount > maxLength; @@ -70,8 +94,22 @@ export const FormikMarkdownField = ({ } }; + const storageConfig = useStorageConfigContext(); + const [field, meta, helper] = useField({ name, validate }); + const [uploadFile] = useUploadFileMutation({ + onCompleted: data => { + notify(t('components.file-upload.file-upload-success'), 'success'); + + editor?.commands.setImage({ src: data.uploadFileOnStorageBucket, alt: 'pasted-image' }); + }, + }); + + const { t } = useTranslation(); + + const tErr = useValidationMessageTranslation(); + const isError = Boolean(meta.error) && meta.touched; useEffect(() => { @@ -88,7 +126,7 @@ export const FormikMarkdownField = ({ return tErr(meta.error as TranslationKey, { field: title }); }, [isError, meta.error, validInputHelperText, tErr, title]); - const handleChange = useCallback( + const handleOnChange = useCallback( (event: ChangeEvent) => { const trimmedValue = event.target.value.trim(); const newValue = trimmedValue === '
' ? '' : event.target.value; @@ -97,6 +135,53 @@ export const FormikMarkdownField = ({ [helper] ); + const handleOnPaste = useCallback( + (event: ClipboardEvent) => { + const clipboardData = event.clipboardData; + const items = clipboardData.items; + + if (!items) { + return; + } + + const storageBucketId = storageConfig?.storageBucketId; + + if (storageBucketId) { + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + + if (file) { + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) { + const reader = new FileReader(); + reader.onload = () => { + uploadFile({ + variables: { + file, + uploadData: { + storageBucketId, + temporaryLocation: true, + }, + }, + }); + }; + reader.readAsDataURL(file); + } + event.preventDefault(); + } + } + } + event.preventDefault(); + } + } + } + }, + [storageConfig?.storageBucketId, uploadFile] + ); + const handleBlur = useCallback(() => { helper.setTouched(true); }, [helper]); @@ -115,12 +200,20 @@ export const FormikMarkdownField = ({ inputElement?.focus(); }; + const handlePassEditor = useCallback((edtr: Editor) => setEditor(edtr), []); + + const inputComponent = useCallback( + (props: PropsWithChildren) => , + [handlePassEditor] + ); + const labelOffset = inputElement?.getLabelOffset(); return ( + {labelOffset && ( )} + + {({ characterCount }) => ( diff --git a/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx b/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx index 719dddc7bf..531f6b5d7e 100644 --- a/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx +++ b/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx @@ -31,6 +31,7 @@ interface MarkdownInputProps extends InputBaseComponentProps { maxLength?: number; hideImageOptions?: boolean; temporaryLocation?: boolean; + passEditor?: (editor: Editor) => void; } type Offset = { @@ -71,6 +72,7 @@ export const MarkdownInput = memo( onFocus, onBlur, temporaryLocation = false, + passEditor, }, ref ) => { @@ -95,6 +97,12 @@ export const MarkdownInput = memo( // Currently used to highlight overflow but can be reused for other similar features as well const shadowEditor = useEditor({ ...editorOptions, content: '', editable: false }); + useEffect(() => { + if (editor) { + passEditor?.(editor); + } + }, [editor, passEditor]); + useLayoutEffect(() => { if (!editor || !isInteractingWithInput || editor.getText() === '') { updateHtmlContent(); @@ -275,16 +283,10 @@ export const MarkdownInput = memo( onDialogClose={handleDialogClose} temporaryLocation={temporaryLocation} /> - + + {({ characterCount }) => typeof maxLength === 'undefined' || characterCount <= maxLength ? null : ( diff --git a/src/core/ui/forms/MarkdownInputControls/MarkdownInputControls.tsx b/src/core/ui/forms/MarkdownInputControls/MarkdownInputControls.tsx index eb3da7614f..0881c1b082 100644 --- a/src/core/ui/forms/MarkdownInputControls/MarkdownInputControls.tsx +++ b/src/core/ui/forms/MarkdownInputControls/MarkdownInputControls.tsx @@ -43,6 +43,7 @@ const sanitizeUrl = (url: string): string => { const parsedUrl = new URL(url); const allowedProtocols = ['http:', 'https:']; // 'javascript:' is used to prevent XSS attacks by blocking dangerous protocols + // eslint-disable-next-line no-script-url const dangerousProtocols = ['javascript:', 'data:', 'vbscript:']; if (!allowedProtocols.includes(parsedUrl.protocol) || dangerousProtocols.some(p => url.toLowerCase().includes(p))) { From 619399d5df0b701e0ef0fad5fb592a85fd967001 Mon Sep 17 00:00:00 2001 From: reactoholic Date: Fri, 3 Jan 2025 17:20:30 +0200 Subject: [PATCH 2/8] resolve bunny comment --- .../MarkdownInput/FormikMarkdownField.tsx | 39 ++++++++----------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx index 62f4c8303a..8396531f27 100644 --- a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx +++ b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx @@ -150,31 +150,24 @@ export const FormikMarkdownField = ({ for (const item of items) { if (item.type.startsWith('image/')) { const file = item.getAsFile(); - if (file) { - for (const item of items) { - if (item.type.startsWith('image/')) { - const file = item.getAsFile(); - if (file) { - const reader = new FileReader(); - reader.onload = () => { - uploadFile({ - variables: { - file, - uploadData: { - storageBucketId, - temporaryLocation: true, - }, - }, - }); - }; - reader.readAsDataURL(file); - } - event.preventDefault(); - } - } + const reader = new FileReader(); + + reader.onload = () => { + uploadFile({ + variables: { + file, + uploadData: { + storageBucketId, + temporaryLocation: true, + }, + }, + }); + }; + + reader.readAsDataURL(file); + event.preventDefault(); } - event.preventDefault(); } } } From 1911a9c83c78220cebacc4c501b39a407615a29f Mon Sep 17 00:00:00 2001 From: reactoholic Date: Mon, 6 Jan 2025 09:36:28 +0200 Subject: [PATCH 3/8] resolve pr comments --- .../ui/forms/MarkdownInput/FormikMarkdownField.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx index 8396531f27..c971bf734d 100644 --- a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx +++ b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx @@ -140,13 +140,13 @@ export const FormikMarkdownField = ({ const clipboardData = event.clipboardData; const items = clipboardData.items; - if (!items) { - return; - } + if (!items) return; const storageBucketId = storageConfig?.storageBucketId; if (storageBucketId) { + let hasImage = false; + for (const item of items) { if (item.type.startsWith('image/')) { const file = item.getAsFile(); @@ -165,11 +165,15 @@ export const FormikMarkdownField = ({ }); }; - reader.readAsDataURL(file); - event.preventDefault(); + reader.readAsDataURL(file); // Read to trigger onLoad. + hasImage = true; } } } + + if (hasImage) { + event.preventDefault(); // Prevent default only if there's an image. + } } }, [storageConfig?.storageBucketId, uploadFile] From 3359bb3910183a2cab65f95d754ff6de8de009f5 Mon Sep 17 00:00:00 2001 From: reactoholic Date: Mon, 6 Jan 2025 10:23:20 +0200 Subject: [PATCH 4/8] resolve bunny comment --- src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx index c971bf734d..b8565f3cfa 100644 --- a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx +++ b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx @@ -104,6 +104,9 @@ export const FormikMarkdownField = ({ editor?.commands.setImage({ src: data.uploadFileOnStorageBucket, alt: 'pasted-image' }); }, + onError: error => { + logError(error); + }, }); const { t } = useTranslation(); From 237a0de704cba1536abf0d7c10d4d2f426be0178 Mon Sep 17 00:00:00 2001 From: reactoholic Date: Mon, 6 Jan 2025 13:31:00 +0200 Subject: [PATCH 5/8] fix temporaryStorage flag when uploading pasted image --- .../ui/forms/MarkdownInput/FormikMarkdownField.tsx | 10 ++++++---- src/core/ui/forms/MarkdownInput/MarkdownInput.tsx | 8 ++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx index b8565f3cfa..f039d8990c 100644 --- a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx +++ b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx @@ -162,7 +162,7 @@ export const FormikMarkdownField = ({ file, uploadData: { storageBucketId, - temporaryLocation: true, + temporaryLocation, }, }, }); @@ -200,11 +200,13 @@ export const FormikMarkdownField = ({ inputElement?.focus(); }; - const handlePassEditor = useCallback((edtr: Editor) => setEditor(edtr), []); + const handleImagePaste = useCallback((edtr: Editor) => setEditor(edtr), []); const inputComponent = useCallback( - (props: PropsWithChildren) => , - [handlePassEditor] + (props: PropsWithChildren) => ( + + ), + [handleImagePaste] ); const labelOffset = inputElement?.getLabelOffset(); diff --git a/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx b/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx index 531f6b5d7e..480ace9c9c 100644 --- a/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx +++ b/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx @@ -31,7 +31,7 @@ interface MarkdownInputProps extends InputBaseComponentProps { maxLength?: number; hideImageOptions?: boolean; temporaryLocation?: boolean; - passEditor?: (editor: Editor) => void; + pasteImageHandler?: (editor: Editor) => void; } type Offset = { @@ -72,7 +72,7 @@ export const MarkdownInput = memo( onFocus, onBlur, temporaryLocation = false, - passEditor, + pasteImageHandler, }, ref ) => { @@ -99,9 +99,9 @@ export const MarkdownInput = memo( useEffect(() => { if (editor) { - passEditor?.(editor); + pasteImageHandler?.(editor); } - }, [editor, passEditor]); + }, [editor, pasteImageHandler]); useLayoutEffect(() => { if (!editor || !isInteractingWithInput || editor.getText() === '') { From 9a26fc748fbeba18527a9bc57d057d3be5e23fab Mon Sep 17 00:00:00 2001 From: reactoholic Date: Mon, 6 Jan 2025 16:06:58 +0200 Subject: [PATCH 6/8] fix image double pasting issue on initial paste --- .../MarkdownInput/FormikMarkdownField.tsx | 30 +++++++++++++------ .../ui/forms/MarkdownInput/MarkdownInput.tsx | 4 +++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx index f039d8990c..70518c0086 100644 --- a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx +++ b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx @@ -105,7 +105,7 @@ export const FormikMarkdownField = ({ editor?.commands.setImage({ src: data.uploadFileOnStorageBucket, alt: 'pasted-image' }); }, onError: error => { - logError(error); + console.error(error.message); }, }); @@ -141,17 +141,18 @@ export const FormikMarkdownField = ({ const handleOnPaste = useCallback( (event: ClipboardEvent) => { const clipboardData = event.clipboardData; - const items = clipboardData.items; + const items = clipboardData?.items; if (!items) return; const storageBucketId = storageConfig?.storageBucketId; if (storageBucketId) { - let hasImage = false; + let imageProcessed = false; for (const item of items) { - if (item.type.startsWith('image/')) { + // Process `image/png` first + if (item.kind === 'file' && item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) { const reader = new FileReader(); @@ -168,14 +169,25 @@ export const FormikMarkdownField = ({ }); }; - reader.readAsDataURL(file); // Read to trigger onLoad. - hasImage = true; + reader.readAsDataURL(file); // Read the file to trigger onLoad. + imageProcessed = true; + break; // Stop after processing the image. + } + } + + // Process `text/html` only if there is no image + if (!imageProcessed && item.kind === 'string' && item.type === 'text/html') { + const htmlContent = clipboardData.getData('text/html'); + if (htmlContent.includes('`, ignore it to avoid duplication. + imageProcessed = true; + break; } } } - if (hasImage) { - event.preventDefault(); // Prevent default only if there's an image. + if (imageProcessed) { + event.preventDefault(); } } }, @@ -206,7 +218,7 @@ export const FormikMarkdownField = ({ (props: PropsWithChildren) => ( ), - [handleImagePaste] + [handleImagePaste, handleOnPaste] ); const labelOffset = inputElement?.getLabelOffset(); diff --git a/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx b/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx index 480ace9c9c..a04ee298b4 100644 --- a/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx +++ b/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx @@ -58,6 +58,10 @@ const proseMirrorStyles = { const editorOptions: Partial = { extensions: [StarterKit, ImageExtension, Link, Highlight, Iframe], + editorProps: { + // Prevents automatic pasting + handlePaste: () => true, // Returns true to stop the default handling + }, }; export const MarkdownInput = memo( From 5c2373c9780fafff891271d8b6dc75d7ea36cf47 Mon Sep 17 00:00:00 2001 From: reactoholic Date: Tue, 7 Jan 2025 11:30:07 +0200 Subject: [PATCH 7/8] fix double pasting issue --- .../MarkdownInput/FormikMarkdownField.tsx | 111 +---------------- .../ui/forms/MarkdownInput/MarkdownInput.tsx | 113 +++++++++++++++--- 2 files changed, 100 insertions(+), 124 deletions(-) diff --git a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx index 70518c0086..7ff411365a 100644 --- a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx +++ b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx @@ -1,25 +1,6 @@ -import { - ChangeEvent, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - ClipboardEvent, - PropsWithChildren, -} from 'react'; -import { - FormControl, - FormHelperText, - InputBaseComponentProps, - InputLabel, - InputProps, - OutlinedInput, - useFormControl, -} from '@mui/material'; +import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { FormControl, FormHelperText, InputLabel, InputProps, OutlinedInput, useFormControl } from '@mui/material'; import { useField } from 'formik'; -import { Editor } from '@tiptap/react'; import CharacterCounter from '../characterCounter/CharacterCounter'; import TranslationKey from '@/core/i18n/utils/TranslationKey'; import { useValidationMessageTranslation } from '@/domain/shared/i18n/ValidationMessageTranslation'; @@ -30,9 +11,6 @@ import { MarkdownTextMaxLength } from '../field-length.constants'; import { error as logError } from '@/core/logging/sentry/log'; import { isMarkdownMaxLengthError } from './MarkdownValidator'; import { useTranslation } from 'react-i18next'; -import { useUploadFileMutation } from '@/core/apollo/generated/apollo-hooks'; -import { useNotification } from '../../notifications/useNotification'; -import { useStorageConfigContext } from '@/domain/storage/StorageBucket/StorageConfigContext'; interface MarkdownFieldProps extends InputProps { title: string; @@ -82,10 +60,6 @@ export const FormikMarkdownField = ({ temporaryLocation = false, controlsVisible = 'always', }: MarkdownFieldProps) => { - const [editor, setEditor] = useState(); - - const notify = useNotification(); - const validate = () => { const characterCount = inputElementRef.current?.value?.length ?? 0; const isAboveCharacterLimit = maxLength && characterCount > maxLength; @@ -94,21 +68,8 @@ export const FormikMarkdownField = ({ } }; - const storageConfig = useStorageConfigContext(); - const [field, meta, helper] = useField({ name, validate }); - const [uploadFile] = useUploadFileMutation({ - onCompleted: data => { - notify(t('components.file-upload.file-upload-success'), 'success'); - - editor?.commands.setImage({ src: data.uploadFileOnStorageBucket, alt: 'pasted-image' }); - }, - onError: error => { - console.error(error.message); - }, - }); - const { t } = useTranslation(); const tErr = useValidationMessageTranslation(); @@ -138,62 +99,6 @@ export const FormikMarkdownField = ({ [helper] ); - const handleOnPaste = useCallback( - (event: ClipboardEvent) => { - const clipboardData = event.clipboardData; - const items = clipboardData?.items; - - if (!items) return; - - const storageBucketId = storageConfig?.storageBucketId; - - if (storageBucketId) { - let imageProcessed = false; - - for (const item of items) { - // Process `image/png` first - if (item.kind === 'file' && item.type.startsWith('image/')) { - const file = item.getAsFile(); - if (file) { - const reader = new FileReader(); - - reader.onload = () => { - uploadFile({ - variables: { - file, - uploadData: { - storageBucketId, - temporaryLocation, - }, - }, - }); - }; - - reader.readAsDataURL(file); // Read the file to trigger onLoad. - imageProcessed = true; - break; // Stop after processing the image. - } - } - - // Process `text/html` only if there is no image - if (!imageProcessed && item.kind === 'string' && item.type === 'text/html') { - const htmlContent = clipboardData.getData('text/html'); - if (htmlContent.includes('`, ignore it to avoid duplication. - imageProcessed = true; - break; - } - } - } - - if (imageProcessed) { - event.preventDefault(); - } - } - }, - [storageConfig?.storageBucketId, uploadFile] - ); - const handleBlur = useCallback(() => { helper.setTouched(true); }, [helper]); @@ -212,15 +117,6 @@ export const FormikMarkdownField = ({ inputElement?.focus(); }; - const handleImagePaste = useCallback((edtr: Editor) => setEditor(edtr), []); - - const inputComponent = useCallback( - (props: PropsWithChildren) => ( - - ), - [handleImagePaste, handleOnPaste] - ); - const labelOffset = inputElement?.getLabelOffset(); return ( @@ -254,10 +150,9 @@ export const FormikMarkdownField = ({ temporaryLocation, }} placeholder={placeholder} - inputComponent={inputComponent} + inputComponent={MarkdownInput} sx={{ '&.MuiOutlinedInput-root': { padding: gutters(0.5) } }} onBlur={handleBlur} - onPaste={handleOnPaste} onChange={handleOnChange} /> diff --git a/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx b/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx index a04ee298b4..c496d86b0a 100644 --- a/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx +++ b/src/core/ui/forms/MarkdownInput/MarkdownInput.tsx @@ -8,8 +8,10 @@ import React, { useLayoutEffect, useRef, useState, + useMemo, } from 'react'; import { Box, useTheme } from '@mui/material'; +import { useTranslation } from 'react-i18next'; import { Editor, EditorContent, useEditor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import { InputBaseComponentProps } from '@mui/material/InputBase/InputBase'; @@ -25,13 +27,16 @@ import { Highlight } from '@tiptap/extension-highlight'; import { Selection } from 'prosemirror-state'; import { EditorOptions } from '@tiptap/core'; import { Iframe } from '../MarkdownInputControls/InsertEmbedCodeButton/Iframe'; +import { EditorView } from '@tiptap/pm/view'; +import { useStorageConfigContext } from '@/domain/storage/StorageBucket/StorageConfigContext'; +import { useUploadFileMutation } from '@/core/apollo/generated/apollo-hooks'; +import { useNotification } from '../../notifications/useNotification'; interface MarkdownInputProps extends InputBaseComponentProps { controlsVisible?: 'always' | 'focused'; maxLength?: number; hideImageOptions?: boolean; temporaryLocation?: boolean; - pasteImageHandler?: (editor: Editor) => void; } type Offset = { @@ -56,14 +61,6 @@ const proseMirrorStyles = { '& img': { maxWidth: '100%' }, } as const; -const editorOptions: Partial = { - extensions: [StarterKit, ImageExtension, Link, Highlight, Iframe], - editorProps: { - // Prevents automatic pasting - handlePaste: () => true, // Returns true to stop the default handling - }, -}; - export const MarkdownInput = memo( forwardRef( ( @@ -76,7 +73,6 @@ export const MarkdownInput = memo( onFocus, onBlur, temporaryLocation = false, - pasteImageHandler, }, ref ) => { @@ -96,17 +92,102 @@ export const MarkdownInput = memo( setHtmlContent(String(content)); }; + const { t } = useTranslation(); + + const notify = useNotification(); + + const [uploadFile] = useUploadFileMutation({ + onCompleted: data => { + notify(t('components.file-upload.file-upload-success'), 'success'); + + editor?.commands.setImage({ src: data.uploadFileOnStorageBucket, alt: 'pasted-image' }); + }, + + onError: error => { + console.error(error.message); + }, + }); + + const storageConfig = useStorageConfigContext(); + + const editorOptions: Partial = useMemo( + () => ({ + extensions: [StarterKit, ImageExtension, Link, Highlight, Iframe], + editorProps: { + handlePaste: (_view: EditorView, event: ClipboardEvent) => { + const clipboardData = event.clipboardData; + const items = clipboardData?.items; + + if (!items) { + return false; // Allow default behavior if no items are found. + } + + const storageBucketId = storageConfig?.storageBucketId; + + if (!storageBucketId) { + return false; // Stop custom handling if storageBucketId is missing. + } + + let imageProcessed = false; + + for (const item of items) { + // Handle images first + if (!imageProcessed && item.kind === 'file' && item.type.startsWith('image/')) { + const file = item.getAsFile(); + + if (file) { + const reader = new FileReader(); + + reader.onload = () => { + uploadFile({ + variables: { + file, + uploadData: { + storageBucketId, + temporaryLocation, + }, + }, + }); + }; + + reader.readAsDataURL(file); + imageProcessed = true; + } + } + + // Check for HTML content containing tags + if (!imageProcessed && item.kind === 'string' && item.type === 'text/html') { + const htmlContent = clipboardData.getData('text/html'); + + if (htmlContent.includes(' { - if (editor) { - pasteImageHandler?.(editor); - } - }, [editor, pasteImageHandler]); - useLayoutEffect(() => { if (!editor || !isInteractingWithInput || editor.getText() === '') { updateHtmlContent(); From fb8b8d6db34a6ce9d67bb2d3ff53807d5e0a4816 Mon Sep 17 00:00:00 2001 From: reactoholic Date: Wed, 8 Jan 2025 14:23:51 +0200 Subject: [PATCH 8/8] resolve pr comments --- .../MarkdownInput/FormikMarkdownField.tsx | 31 +++++++++---------- .../MarkdownInputControls.tsx | 1 - 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx index 7ff411365a..2751ea331d 100644 --- a/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx +++ b/src/core/ui/forms/MarkdownInput/FormikMarkdownField.tsx @@ -60,6 +60,8 @@ export const FormikMarkdownField = ({ temporaryLocation = false, controlsVisible = 'always', }: MarkdownFieldProps) => { + const tErr = useValidationMessageTranslation(); + const { t } = useTranslation(); const validate = () => { const characterCount = inputElementRef.current?.value?.length ?? 0; const isAboveCharacterLimit = maxLength && characterCount > maxLength; @@ -70,10 +72,6 @@ export const FormikMarkdownField = ({ const [field, meta, helper] = useField({ name, validate }); - const { t } = useTranslation(); - - const tErr = useValidationMessageTranslation(); - const isError = Boolean(meta.error) && meta.touched; useEffect(() => { @@ -90,7 +88,7 @@ export const FormikMarkdownField = ({ return tErr(meta.error as TranslationKey, { field: title }); }, [isError, meta.error, validInputHelperText, tErr, title]); - const handleOnChange = useCallback( + const handleChange = useCallback( (event: ChangeEvent) => { const trimmedValue = event.target.value.trim(); const newValue = trimmedValue === '
' ? '' : event.target.value; @@ -123,7 +121,6 @@ export const FormikMarkdownField = ({ - {labelOffset && ( )} - - {({ characterCount }) => ( diff --git a/src/core/ui/forms/MarkdownInputControls/MarkdownInputControls.tsx b/src/core/ui/forms/MarkdownInputControls/MarkdownInputControls.tsx index 0881c1b082..eb3da7614f 100644 --- a/src/core/ui/forms/MarkdownInputControls/MarkdownInputControls.tsx +++ b/src/core/ui/forms/MarkdownInputControls/MarkdownInputControls.tsx @@ -43,7 +43,6 @@ const sanitizeUrl = (url: string): string => { const parsedUrl = new URL(url); const allowedProtocols = ['http:', 'https:']; // 'javascript:' is used to prevent XSS attacks by blocking dangerous protocols - // eslint-disable-next-line no-script-url const dangerousProtocols = ['javascript:', 'data:', 'vbscript:']; if (!allowedProtocols.includes(parsedUrl.protocol) || dangerousProtocols.some(p => url.toLowerCase().includes(p))) {