mirror of
https://github.com/harness/drone.git
synced 2025-05-16 08:59:56 +08:00
feat: [code-1028]: add support for image upload (#751)
This commit is contained in:
parent
3a7617a2e6
commit
76e5a32b79
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -17,3 +17,4 @@
|
||||
/* eslint-disable */
|
||||
// This is an auto-generated file
|
||||
export declare const editor: string
|
||||
export declare const editorTest: string
|
||||
|
@ -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<EditorView>()
|
||||
const ref = useRef<HTMLDivElement>()
|
||||
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 + ``
|
||||
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 <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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
@ -262,3 +262,11 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.dialog {
|
||||
width: 610px !important;
|
||||
.uploadContainer {
|
||||
border-radius: 4px;
|
||||
border: 1px dashed var(--grey-200);
|
||||
background: var(--grey-50);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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<HTMLInputElement>(null)
|
||||
const { repoMetadata } = useGetRepositoryMetadata()
|
||||
const [selectedTab, setSelectedTab] = useState(MarkdownEditorTab.WRITE)
|
||||
const viewRef = useRef<EditorView>()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
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 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 = ``
|
||||
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 (
|
||||
<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}>
|
||||
<li>
|
||||
<a
|
||||
|
@ -30,6 +30,7 @@ export interface StringsMap {
|
||||
approve: string
|
||||
ascending: string
|
||||
assignPeople: string
|
||||
attachText: string
|
||||
basedOn: string
|
||||
blame: string
|
||||
blameCommitLine: string
|
||||
@ -304,6 +305,10 @@ export interface StringsMap {
|
||||
'homepage.selectSpaceContent': string
|
||||
'homepage.selectSpaceTitle': string
|
||||
'homepage.welcomeText': string
|
||||
'imageUpload.readyToUpload': string
|
||||
'imageUpload.text': string
|
||||
'imageUpload.title': string
|
||||
'imageUpload.upload': string
|
||||
importGitRepo: string
|
||||
importProgress: string
|
||||
'importRepo.failedToImportRepo': string
|
||||
|
@ -842,12 +842,18 @@ enterBitbucketPlaceholder: https://bitbucket.org/
|
||||
changeRepoVis: Change repository visibility
|
||||
changeRepoVisContent: Are you sure you want to make this repository {repoVis}? {repoText}
|
||||
repoVisibility: Repository Visibility
|
||||
attachText: Attach files by dragging & dropping, selecting or pasting them.
|
||||
key: Key
|
||||
setting: Setting
|
||||
mergeCommit: Merge commit
|
||||
squashMerge: Squash and merge
|
||||
rebaseMerge: Rebase and merge
|
||||
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:
|
||||
namePlaceholder: Enter the rule name here
|
||||
descPlaceholder: Enter the description here
|
||||
|
@ -28,6 +28,7 @@ import type {
|
||||
TypesPullReq,
|
||||
TypesRepository
|
||||
} from 'services/code'
|
||||
import { getErrorMessage } from './Utils'
|
||||
|
||||
export interface GitInfoProps {
|
||||
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
|
||||
const BAD_GIT_REF_REGREX = /(^|[/.])([/.]|$)|^@$|@{|[\x00-\x20\x7f~^:?*[\\]|\.lock(\/|$)/
|
||||
const BAD_GIT_BRANCH_REGREX = /^(-|HEAD$)/
|
||||
|
@ -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)
|
||||
* @param timestamp Timestamp
|
||||
|
Loading…
Reference in New Issue
Block a user