From 76e5a32b7966c235bab0824a5f490693422859bd Mon Sep 17 00:00:00 2001 From: Calvin Lee Date: Wed, 1 Nov 2023 19:58:36 +0000 Subject: [PATCH] feat: [code-1028]: add support for image upload (#751) --- web/src/components/Editor/Editor.module.scss | 22 +++ .../components/Editor/Editor.module.scss.d.ts | 1 + web/src/components/Editor/Editor.tsx | 61 +++++++- .../MarkdownEditorWithPreview.module.scss | 8 + ...MarkdownEditorWithPreview.module.scss.d.ts | 2 + .../MarkdownEditorWithPreview.tsx | 146 +++++++++++++++++- web/src/framework/strings/stringTypes.ts | 5 + web/src/i18n/strings.en.yaml | 6 + web/src/utils/GitUtils.ts | 45 ++++++ web/src/utils/Utils.ts | 30 ++++ 10 files changed, 318 insertions(+), 8 deletions(-) diff --git a/web/src/components/Editor/Editor.module.scss b/web/src/components/Editor/Editor.module.scss index f098cc2d3..6467a8386 100644 --- a/web/src/components/Editor/Editor.module.scss +++ b/web/src/components/Editor/Editor.module.scss @@ -30,6 +30,17 @@ outline: none; } + .attachDiv { + padding-top: var(--spacing-xsmall); + padding-bottom: var(--spacing-xsmall); + padding-left: var(--spacing-medium); + color: var(--grey-400); + outline: none; + font-size: 13px; + font-weight: 400; + border-top: 1px dashed var(--grey-200); + } + .cm-scroller { overflow: auto; padding: var(--spacing-small); @@ -44,3 +55,14 @@ } } } + +.editorTest { + :global { + .cm-editor { + &.cm-focused { + border-color: var(--primary-7); + outline: none; + } + } + } +} diff --git a/web/src/components/Editor/Editor.module.scss.d.ts b/web/src/components/Editor/Editor.module.scss.d.ts index c17c82e4f..32b012e81 100644 --- a/web/src/components/Editor/Editor.module.scss.d.ts +++ b/web/src/components/Editor/Editor.module.scss.d.ts @@ -17,3 +17,4 @@ /* eslint-disable */ // This is an auto-generated file export declare const editor: string +export declare const editorTest: string diff --git a/web/src/components/Editor/Editor.tsx b/web/src/components/Editor/Editor.tsx index 2e0b9d027..580973c61 100644 --- a/web/src/components/Editor/Editor.tsx +++ b/web/src/components/Editor/Editor.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import React, { useEffect, useMemo, useRef } from 'react' -import { Container } from '@harnessio/uicore' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { Container, useToaster } from '@harnessio/uicore' import { LanguageDescription } from '@codemirror/language' import { indentWithTab } from '@codemirror/commands' import cx from 'classnames' @@ -28,6 +28,10 @@ import { Compartment, EditorState, Extension } from '@codemirror/state' import { color } from '@uiw/codemirror-extensions-color' import { hyperLink } from '@uiw/codemirror-extensions-hyper-link' import { githubLight, githubDark } from '@uiw/codemirror-themes-all' +import { useStrings } from 'framework/strings' +import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata' +import { handleUpload } from 'utils/GitUtils' +import { handleFileDrop, handlePaste } from 'utils/Utils' import css from './Editor.module.scss' export interface EditorProps { @@ -63,9 +67,14 @@ export const Editor = React.memo(function CodeMirrorReactEditor({ onViewUpdate, darkTheme }: EditorProps) { + const { showError } = useToaster() + const { getString } = useStrings() const view = useRef() const ref = useRef() + const { repoMetadata } = useGetRepositoryMetadata() + const languageConfig = useMemo(() => new Compartment(), []) + const [markdownContent, setMarkdownContent] = useState('') const markdownLanguageSupport = useMemo(() => markdown({ codeLanguages: languages }), []) const style = useMemo(() => { if (maxHeight) { @@ -78,7 +87,22 @@ export const Editor = React.memo(function CodeMirrorReactEditor({ useEffect(() => { onChangeRef.current = onChange - }, [onChange]) + }, [onChange, markdownContent]) + + useEffect(() => { + appendMarkdownContent() + }, [markdownContent]) // eslint-disable-line react-hooks/exhaustive-deps + + const appendMarkdownContent = () => { + if (view.current && markdownContent) { + const currentContent = view.current.state.doc.toString() + const updatedContent = currentContent + `![image](${markdownContent})` + view.current.setState(EditorState.create({ doc: updatedContent })) + setUploading(false) + } + } + + const [uploading, setUploading] = useState(false) useEffect(() => { const editorView = new EditorView({ @@ -129,8 +153,13 @@ export const Editor = React.memo(function CodeMirrorReactEditor({ if (autoFocus) { editorView.focus() } - + // Create a new DOM element for the message + const messageElement = document.createElement('div') + messageElement.className = 'attachDiv' + messageElement.textContent = uploading ? 'Uploading your files ...' : getString('attachText') + editorView.dom.appendChild(messageElement) return () => { + messageElement.remove() editorView.destroy() } }, []) // eslint-disable-line react-hooks/exhaustive-deps @@ -153,6 +182,28 @@ export const Editor = React.memo(function CodeMirrorReactEditor({ }) } }, [filename, forMarkdown, view, languageConfig, markdownLanguageSupport]) + const handleUploadCallback = (file: File) => { + handleUpload(file, setMarkdownContent, repoMetadata, showError) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleDropForUpload = async (event: any) => { + handleFileDrop(event, handleUploadCallback) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handlePasteForUpload = (event: { preventDefault: () => void; clipboardData: any }) => { + handlePaste(event, handleUploadCallback) + } - return + return ( + { + event.preventDefault() + }} + onDrop={handleDropForUpload} + onPaste={handlePasteForUpload} + ref={ref} + className={cx(css.editor, className, css.editorTest)} + style={style} + /> + ) }) diff --git a/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.module.scss b/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.module.scss index feb6e0d18..c680c96f0 100644 --- a/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.module.scss +++ b/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.module.scss @@ -262,3 +262,11 @@ display: none; } } +.dialog { + width: 610px !important; + .uploadContainer { + border-radius: 4px; + border: 1px dashed var(--grey-200); + background: var(--grey-50); + } +} diff --git a/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.module.scss.d.ts b/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.module.scss.d.ts index b00dc46ee..d86522f88 100644 --- a/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.module.scss.d.ts +++ b/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.module.scss.d.ts @@ -18,6 +18,7 @@ // This is an auto-generated file export declare const buttonsBar: string export declare const container: string +export declare const dialog: string export declare const hidden: string export declare const main: string export declare const markdownEditor: string @@ -26,4 +27,5 @@ export declare const preview: string export declare const tabContent: string export declare const tabs: string export declare const toolbar: string +export declare const uploadContainer: string export declare const withPreview: string diff --git a/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.tsx b/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.tsx index 3ad74dd6d..51620c104 100644 --- a/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.tsx +++ b/web/src/components/MarkdownEditorWithPreview/MarkdownEditorWithPreview.tsx @@ -15,14 +15,28 @@ */ import React, { useCallback, useEffect, useRef, useState } from 'react' -import { Button, Container, ButtonVariation, Layout, ButtonSize } from '@harnessio/uicore' +import { + Text, + Button, + Container, + ButtonVariation, + Layout, + ButtonSize, + Dialog, + FlexExpander, + useToaster +} from '@harnessio/uicore' import type { IconName } from '@harnessio/icons' -import { Color } from '@harnessio/design-system' +import { Color, FontVariation } from '@harnessio/design-system' import cx from 'classnames' import type { EditorView } from '@codemirror/view' import { EditorSelection } from '@codemirror/state' import { Editor } from 'components/Editor/Editor' import { MarkdownViewer } from 'components/MarkdownViewer/MarkdownViewer' +import { useStrings } from 'framework/strings' +import { formatBytes, handleFileDrop, handlePaste } from 'utils/Utils' +import { handleUpload } from 'utils/GitUtils' +import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata' import css from './MarkdownEditorWithPreview.module.scss' enum MarkdownEditorTab { @@ -34,6 +48,7 @@ enum ToolbarAction { HEADER = 'HEADER', BOLD = 'BOLD', ITALIC = 'ITALIC', + UPLOAD = 'UPLOAD', UNORDER_LIST = 'UNORDER_LIST', CHECK_LIST = 'CHECK_LIST', CODE_BLOCK = 'CODE_BLOCK' @@ -48,6 +63,8 @@ const toolbar: ToolbarItem[] = [ { icon: 'header', action: ToolbarAction.HEADER }, { icon: 'bold', action: ToolbarAction.BOLD }, { icon: 'italic', action: ToolbarAction.ITALIC }, + { icon: 'paperclip', action: ToolbarAction.UPLOAD }, + { icon: 'properties', action: ToolbarAction.UNORDER_LIST }, { icon: 'form', action: ToolbarAction.CHECK_LIST }, { icon: 'main-code-yaml', action: ToolbarAction.CODE_BLOCK } @@ -95,10 +112,17 @@ export function MarkdownEditorWithPreview({ autoFocusAndPosition, secondarySaveButton: SecondarySaveButton }: MarkdownEditorWithPreviewProps) { + const { getString } = useStrings() + const fileInputRef = useRef(null) + const { repoMetadata } = useGetRepositoryMetadata() const [selectedTab, setSelectedTab] = useState(MarkdownEditorTab.WRITE) const viewRef = useRef() const containerRef = useRef(null) const [dirty, setDirty] = useState(false) + const [open, setOpen] = useState(false) + const [file, setFile] = useState() + const { showError } = useToaster() + const [markdownContent, setMarkdownContent] = useState('') const onToolbarAction = useCallback((action: ToolbarAction) => { const view = viewRef.current @@ -136,6 +160,12 @@ export function MarkdownEditorWithPreview({ break } + case ToolbarAction.UPLOAD: { + setOpen(true) + + break + } + case ToolbarAction.BOLD: { view.dispatch( view.state.changeByRange(range => ({ @@ -233,11 +263,121 @@ export function MarkdownEditorWithPreview({ useEffect(() => { if (autoFocusAndPosition && !dirty) { scrollToAndSetCursorToEnd(containerRef, viewRef, true) - } + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoFocusAndPosition, viewRef, containerRef, scrollToAndSetCursorToEnd, dirty]) + const setFileCallback = (newFile: File) => { + setFile(newFile) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handlePasteForSetFile = (event: { preventDefault: () => void; clipboardData: any }) => { + handlePaste(event, setFileCallback) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleDropForSetFile = async (event: any) => { + handleFileDrop(event, setFileCallback) + } + + useEffect(() => { + const view = viewRef.current + if (markdownContent && view) { + const insertText = `![image](${markdownContent})` + view.dispatch( + view.state.changeByRange(range => ({ + changes: [{ from: range.from, insert: insertText }], + range: EditorSelection.range(range.from + insertText.length, range.from + insertText.length) + })) + ) + } + }, [markdownContent]) + + const handleButtonClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click() + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleFileChange = (event: any) => { + setFile(event?.target?.files[0]) + } + return ( + { + setFile(undefined) + setOpen(false) + }} + className={css.dialog} + isOpen={open}> + {getString('imageUpload.title')} + + { + event.preventDefault() + }} + onDrop={handleDropForSetFile} + onPaste={handlePasteForSetFile} + flex={{ alignItems: 'center' }} + className={css.uploadContainer} + width={500} + height={81}> + {file ? ( + + + + {file.name} + + {formatBytes(file.size)} + + + + {getString('imageUpload.readyToUpload')} + + + ) : ( + + {getString('imageUpload.text')} + +