feat: [code-1028]: add support for image upload (#751)

This commit is contained in:
Calvin Lee 2023-11-01 19:58:36 +00:00 committed by Harness
parent 3a7617a2e6
commit 76e5a32b79
10 changed files with 318 additions and 8 deletions

View File

@ -30,6 +30,17 @@
outline: none; 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 { .cm-scroller {
overflow: auto; overflow: auto;
padding: var(--spacing-small); padding: var(--spacing-small);
@ -44,3 +55,14 @@
} }
} }
} }
.editorTest {
:global {
.cm-editor {
&.cm-focused {
border-color: var(--primary-7);
outline: none;
}
}
}
}

View File

@ -17,3 +17,4 @@
/* eslint-disable */ /* eslint-disable */
// This is an auto-generated file // This is an auto-generated file
export declare const editor: string export declare const editor: string
export declare const editorTest: string

View File

@ -14,8 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
import React, { useEffect, useMemo, useRef } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Container } from '@harnessio/uicore' import { Container, useToaster } from '@harnessio/uicore'
import { LanguageDescription } from '@codemirror/language' import { LanguageDescription } from '@codemirror/language'
import { indentWithTab } from '@codemirror/commands' import { indentWithTab } from '@codemirror/commands'
import cx from 'classnames' import cx from 'classnames'
@ -28,6 +28,10 @@ import { Compartment, EditorState, Extension } from '@codemirror/state'
import { color } from '@uiw/codemirror-extensions-color' import { color } from '@uiw/codemirror-extensions-color'
import { hyperLink } from '@uiw/codemirror-extensions-hyper-link' import { hyperLink } from '@uiw/codemirror-extensions-hyper-link'
import { githubLight, githubDark } from '@uiw/codemirror-themes-all' 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' import css from './Editor.module.scss'
export interface EditorProps { export interface EditorProps {
@ -63,9 +67,14 @@ export const Editor = React.memo(function CodeMirrorReactEditor({
onViewUpdate, onViewUpdate,
darkTheme darkTheme
}: EditorProps) { }: EditorProps) {
const { showError } = useToaster()
const { getString } = useStrings()
const view = useRef<EditorView>() const view = useRef<EditorView>()
const ref = useRef<HTMLDivElement>() const ref = useRef<HTMLDivElement>()
const { repoMetadata } = useGetRepositoryMetadata()
const languageConfig = useMemo(() => new Compartment(), []) const languageConfig = useMemo(() => new Compartment(), [])
const [markdownContent, setMarkdownContent] = useState('')
const markdownLanguageSupport = useMemo(() => markdown({ codeLanguages: languages }), []) const markdownLanguageSupport = useMemo(() => markdown({ codeLanguages: languages }), [])
const style = useMemo(() => { const style = useMemo(() => {
if (maxHeight) { if (maxHeight) {
@ -78,7 +87,22 @@ export const Editor = React.memo(function CodeMirrorReactEditor({
useEffect(() => { useEffect(() => {
onChangeRef.current = onChange 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(() => { useEffect(() => {
const editorView = new EditorView({ const editorView = new EditorView({
@ -129,8 +153,13 @@ export const Editor = React.memo(function CodeMirrorReactEditor({
if (autoFocus) { if (autoFocus) {
editorView.focus() 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 () => { return () => {
messageElement.remove()
editorView.destroy() editorView.destroy()
} }
}, []) // eslint-disable-line react-hooks/exhaustive-deps }, []) // eslint-disable-line react-hooks/exhaustive-deps
@ -153,6 +182,28 @@ export const Editor = React.memo(function CodeMirrorReactEditor({
}) })
} }
}, [filename, forMarkdown, view, languageConfig, markdownLanguageSupport]) }, [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 <Container ref={ref} className={cx(css.editor, className)} style={style} /> return (
<Container
onDragOver={event => {
event.preventDefault()
}}
onDrop={handleDropForUpload}
onPaste={handlePasteForUpload}
ref={ref}
className={cx(css.editor, className, css.editorTest)}
style={style}
/>
)
}) })

View File

@ -262,3 +262,11 @@
display: none; display: none;
} }
} }
.dialog {
width: 610px !important;
.uploadContainer {
border-radius: 4px;
border: 1px dashed var(--grey-200);
background: var(--grey-50);
}
}

View File

@ -18,6 +18,7 @@
// This is an auto-generated file // This is an auto-generated file
export declare const buttonsBar: string export declare const buttonsBar: string
export declare const container: string export declare const container: string
export declare const dialog: string
export declare const hidden: string export declare const hidden: string
export declare const main: string export declare const main: string
export declare const markdownEditor: string export declare const markdownEditor: string
@ -26,4 +27,5 @@ export declare const preview: string
export declare const tabContent: string export declare const tabContent: string
export declare const tabs: string export declare const tabs: string
export declare const toolbar: string export declare const toolbar: string
export declare const uploadContainer: string
export declare const withPreview: string export declare const withPreview: string

View File

@ -15,14 +15,28 @@
*/ */
import React, { useCallback, useEffect, useRef, useState } from 'react' 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 type { IconName } from '@harnessio/icons'
import { Color } from '@harnessio/design-system' import { Color, FontVariation } from '@harnessio/design-system'
import cx from 'classnames' import cx from 'classnames'
import type { EditorView } from '@codemirror/view' import type { EditorView } from '@codemirror/view'
import { EditorSelection } from '@codemirror/state' import { EditorSelection } from '@codemirror/state'
import { Editor } from 'components/Editor/Editor' import { Editor } from 'components/Editor/Editor'
import { MarkdownViewer } from 'components/MarkdownViewer/MarkdownViewer' 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' import css from './MarkdownEditorWithPreview.module.scss'
enum MarkdownEditorTab { enum MarkdownEditorTab {
@ -34,6 +48,7 @@ enum ToolbarAction {
HEADER = 'HEADER', HEADER = 'HEADER',
BOLD = 'BOLD', BOLD = 'BOLD',
ITALIC = 'ITALIC', ITALIC = 'ITALIC',
UPLOAD = 'UPLOAD',
UNORDER_LIST = 'UNORDER_LIST', UNORDER_LIST = 'UNORDER_LIST',
CHECK_LIST = 'CHECK_LIST', CHECK_LIST = 'CHECK_LIST',
CODE_BLOCK = 'CODE_BLOCK' CODE_BLOCK = 'CODE_BLOCK'
@ -48,6 +63,8 @@ const toolbar: ToolbarItem[] = [
{ icon: 'header', action: ToolbarAction.HEADER }, { icon: 'header', action: ToolbarAction.HEADER },
{ icon: 'bold', action: ToolbarAction.BOLD }, { icon: 'bold', action: ToolbarAction.BOLD },
{ icon: 'italic', action: ToolbarAction.ITALIC }, { icon: 'italic', action: ToolbarAction.ITALIC },
{ icon: 'paperclip', action: ToolbarAction.UPLOAD },
{ icon: 'properties', action: ToolbarAction.UNORDER_LIST }, { icon: 'properties', action: ToolbarAction.UNORDER_LIST },
{ icon: 'form', action: ToolbarAction.CHECK_LIST }, { icon: 'form', action: ToolbarAction.CHECK_LIST },
{ icon: 'main-code-yaml', action: ToolbarAction.CODE_BLOCK } { icon: 'main-code-yaml', action: ToolbarAction.CODE_BLOCK }
@ -95,10 +112,17 @@ export function MarkdownEditorWithPreview({
autoFocusAndPosition, autoFocusAndPosition,
secondarySaveButton: SecondarySaveButton secondarySaveButton: SecondarySaveButton
}: MarkdownEditorWithPreviewProps) { }: MarkdownEditorWithPreviewProps) {
const { getString } = useStrings()
const fileInputRef = useRef<HTMLInputElement>(null)
const { repoMetadata } = useGetRepositoryMetadata()
const [selectedTab, setSelectedTab] = useState(MarkdownEditorTab.WRITE) const [selectedTab, setSelectedTab] = useState(MarkdownEditorTab.WRITE)
const viewRef = useRef<EditorView>() const viewRef = useRef<EditorView>()
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [dirty, setDirty] = useState(false) const [dirty, setDirty] = useState(false)
const [open, setOpen] = useState(false)
const [file, setFile] = useState<File>()
const { showError } = useToaster()
const [markdownContent, setMarkdownContent] = useState('')
const onToolbarAction = useCallback((action: ToolbarAction) => { const onToolbarAction = useCallback((action: ToolbarAction) => {
const view = viewRef.current const view = viewRef.current
@ -136,6 +160,12 @@ export function MarkdownEditorWithPreview({
break break
} }
case ToolbarAction.UPLOAD: {
setOpen(true)
break
}
case ToolbarAction.BOLD: { case ToolbarAction.BOLD: {
view.dispatch( view.dispatch(
view.state.changeByRange(range => ({ view.state.changeByRange(range => ({
@ -233,11 +263,121 @@ export function MarkdownEditorWithPreview({
useEffect(() => { useEffect(() => {
if (autoFocusAndPosition && !dirty) { if (autoFocusAndPosition && !dirty) {
scrollToAndSetCursorToEnd(containerRef, viewRef, true) scrollToAndSetCursorToEnd(containerRef, viewRef, true)
} } // eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoFocusAndPosition, viewRef, containerRef, scrollToAndSetCursorToEnd, dirty]) }, [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 ( return (
<Container ref={containerRef} className={cx(css.container, { [css.noBorder]: noBorder }, className)}> <Container ref={containerRef} className={cx(css.container, { [css.noBorder]: noBorder }, className)}>
<Dialog
onClose={() => {
setFile(undefined)
setOpen(false)
}}
className={css.dialog}
isOpen={open}>
<Text font={{ variation: FontVariation.H4 }}>{getString('imageUpload.title')}</Text>
<Container
margin={{ top: 'small' }}
onDragOver={event => {
event.preventDefault()
}}
onDrop={handleDropForSetFile}
onPaste={handlePasteForSetFile}
flex={{ alignItems: 'center' }}
className={css.uploadContainer}
width={500}
height={81}>
{file ? (
<Layout.Horizontal
width={`100%`}
padding={{ left: 'medium', right: 'medium' }}
flex={{ justifyContent: 'space-between' }}>
<Layout.Horizontal spacing="small">
<Text lineClamp={1} width={200}>
{file.name}
</Text>
<Text>{formatBytes(file.size)}</Text>
</Layout.Horizontal>
<FlexExpander />
<Text icon={'tick'} iconProps={{ color: Color.GREEN_800 }} color={Color.GREEN_800}>
{getString('imageUpload.readyToUpload')}
</Text>
</Layout.Horizontal>
) : (
<Text padding={{ left: 'medium' }} color={Color.GREY_400}>
{getString('imageUpload.text')}
<input type="file" ref={fileInputRef} onChange={handleFileChange} style={{ display: 'none' }} />
<Button
margin={{ left: 'small' }}
text={getString('browse')}
onClick={handleButtonClick}
variation={ButtonVariation.SECONDARY}
/>
</Text>
)}
</Container>
<Container padding={{ top: 'large' }}>
<Layout.Horizontal spacing="small">
<Button
type="submit"
text={getString('imageUpload.upload')}
variation={ButtonVariation.PRIMARY}
disabled={false}
onClick={() => {
handleUpload(file as File, setMarkdownContent, repoMetadata, showError)
setOpen(false)
setFile(undefined)
}}
/>
<Button
text={getString('cancel')}
variation={ButtonVariation.TERTIARY}
onClick={() => {
setOpen(false)
setFile(undefined)
}}
/>
</Layout.Horizontal>
</Container>
</Dialog>
<ul className={css.tabs}> <ul className={css.tabs}>
<li> <li>
<a <a

View File

@ -30,6 +30,7 @@ export interface StringsMap {
approve: string approve: string
ascending: string ascending: string
assignPeople: string assignPeople: string
attachText: string
basedOn: string basedOn: string
blame: string blame: string
blameCommitLine: string blameCommitLine: string
@ -304,6 +305,10 @@ export interface StringsMap {
'homepage.selectSpaceContent': string 'homepage.selectSpaceContent': string
'homepage.selectSpaceTitle': string 'homepage.selectSpaceTitle': string
'homepage.welcomeText': string 'homepage.welcomeText': string
'imageUpload.readyToUpload': string
'imageUpload.text': string
'imageUpload.title': string
'imageUpload.upload': string
importGitRepo: string importGitRepo: string
importProgress: string importProgress: string
'importRepo.failedToImportRepo': string 'importRepo.failedToImportRepo': string

View File

@ -842,12 +842,18 @@ enterBitbucketPlaceholder: https://bitbucket.org/
changeRepoVis: Change repository visibility changeRepoVis: Change repository visibility
changeRepoVisContent: Are you sure you want to make this repository {repoVis}? {repoText} changeRepoVisContent: Are you sure you want to make this repository {repoVis}? {repoText}
repoVisibility: Repository Visibility repoVisibility: Repository Visibility
attachText: Attach files by dragging & dropping, selecting or pasting them.
key: Key key: Key
setting: Setting setting: Setting
mergeCommit: Merge commit mergeCommit: Merge commit
squashMerge: Squash and merge squashMerge: Squash and merge
rebaseMerge: Rebase and merge rebaseMerge: Rebase and merge
Enable: Enable Enable: Enable
imageUpload:
title: Upload attachment
readyToUpload: Ready for upload
upload: Upload
text: Drag and drop a file here or click browse to select a file.
branchProtection: branchProtection:
namePlaceholder: Enter the rule name here namePlaceholder: Enter the rule name here
descPlaceholder: Enter the description here descPlaceholder: Enter the description here

View File

@ -28,6 +28,7 @@ import type {
TypesPullReq, TypesPullReq,
TypesRepository TypesRepository
} from 'services/code' } from 'services/code'
import { getErrorMessage } from './Utils'
export interface GitInfoProps { export interface GitInfoProps {
repoMetadata: TypesRepository repoMetadata: TypesRepository
@ -242,6 +243,50 @@ export function formatTriggers(triggers: EnumWebhookTrigger[]) {
}) })
} }
export const handleUpload = (
blob: File,
setMarkdownContent: (data: string) => void,
repoMetadata: TypesRepository | undefined,
showError: (message: React.ReactNode, timeout?: number | undefined, key?: string | undefined) => void
) => {
const reader = new FileReader()
// Set up a function to be called when the load event is triggered
reader.onload = async function () {
const markdown = await uploadImage(reader.result, showError, repoMetadata)
setMarkdownContent(markdown) // Set the markdown content
}
reader.readAsArrayBuffer(blob) // This will trigger the onload function when the reading is complete
}
export const uploadImage = async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fileBlob: any,
showError: (message: React.ReactNode, timeout?: number | undefined, key?: string | undefined) => void,
repoMetadata: TypesRepository | undefined
) => {
try {
const response = await fetch(`${window.location.origin}/api/v1/repos/${repoMetadata?.path}/+/uploads/`, {
method: 'POST',
headers: {
Accept: 'application/json',
'content-type': 'application/octet-stream'
},
body: fileBlob,
redirect: 'follow'
})
const result = await response.json()
if (!response.ok && result) {
showError(getErrorMessage(result))
return ''
}
const filePath = result.file_path
return window.location.origin + '/' + 'api/v1/repos/' + repoMetadata?.path + '/+/uploads/' + filePath
} catch (exception) {
showError(getErrorMessage(exception))
return ''
}
}
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
const BAD_GIT_REF_REGREX = /(^|[/.])([/.]|$)|^@$|@{|[\x00-\x20\x7f~^:?*[\\]|\.lock(\/|$)/ const BAD_GIT_REF_REGREX = /(^|[/.])([/.]|$)|^@$|@{|[\x00-\x20\x7f~^:?*[\\]|\.lock(\/|$)/
const BAD_GIT_BRANCH_REGREX = /^(-|HEAD$)/ const BAD_GIT_BRANCH_REGREX = /^(-|HEAD$)/

View File

@ -177,6 +177,36 @@ export function formatTime(timestamp: number | string, timeStyle = 'short'): str
: '' : ''
} }
type FileDropCallback = (file: File) => void
//handle file drop in image upload
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const handleFileDrop = (event: any, callback: FileDropCallback): void => {
event.preventDefault()
const file = event?.dataTransfer?.files[0]
if (file) {
callback(file)
}
}
type PasteCallback = (file: File) => void
// handle file paste in image upload
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const handlePaste = (event: { preventDefault: () => void; clipboardData: any }, callback: PasteCallback) => {
event.preventDefault()
const clipboardData = event.clipboardData
const items = clipboardData.items
if (items.length > 0) {
const firstItem = items[0]
if (firstItem.type.startsWith('image/')) {
const blob = firstItem.getAsFile()
callback(blob)
}
}
}
/** /**
* Format a timestamp to medium format date (i.e: Jan 1, 2021) * Format a timestamp to medium format date (i.e: Jan 1, 2021)
* @param timestamp Timestamp * @param timestamp Timestamp