/* * Copyright 2023 Harness, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import React, { useCallback, useEffect, useRef, useState } from 'react' import { Text, Button, Container, ButtonVariation, Layout, ButtonSize, Dialog, FlexExpander, useToaster } from '@harnessio/uicore' import type { IconName } from '@harnessio/icons' import { Color, FontVariation } from '@harnessio/design-system' import cx from 'classnames' import type { EditorView } from '@codemirror/view' import { keymap } from '@codemirror/view' import { undo, redo, history } from '@codemirror/commands' import { EditorSelection } from '@codemirror/state' import { isEmpty } from 'lodash-es' import { useMutate } from 'restful-react' import { Editor } from 'components/Editor/Editor' import { MarkdownViewer } from 'components/MarkdownViewer/MarkdownViewer' import { useStrings } from 'framework/strings' import { CommentBoxOutletPosition, formatBytes, getErrorMessage, handleFileDrop, handlePaste } from 'utils/Utils' import { decodeGitContent, handleUpload, normalizeGitRef } from 'utils/GitUtils' import type { TypesRepository } from 'services/code' import css from './MarkdownEditorWithPreview.module.scss' enum MarkdownEditorTab { WRITE = 'write', PREVIEW = 'preview' } enum ToolbarAction { HEADER = 'HEADER', BOLD = 'BOLD', ITALIC = 'ITALIC', UPLOAD = 'UPLOAD', UNORDER_LIST = 'UNORDER_LIST', CHECK_LIST = 'CHECK_LIST', CODE_BLOCK = 'CODE_BLOCK' } interface ToolbarItem { icon: IconName action: ToolbarAction } 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 } ] interface MarkdownEditorWithPreviewProps { className?: string value?: string templateData?: string onChange?: (value: string) => void onSave?: (value: string) => void onCancel?: () => void setDirty?: (dirty: boolean) => void i18n: { placeHolder: string tabEdit: string tabPreview: string cancel: string save: string } hideButtons?: boolean hideCancel?: boolean editorHeight?: string noBorder?: boolean viewRef?: React.MutableRefObject secondarySaveButton?: typeof Button // When set to true, the editor will be scrolled to center of screen // and cursor is set to the end of the document autoFocusAndPosition?: boolean outlets?: Partial> handleCopilotClick?: () => void flag?: boolean sourceGitRef?: string targetGitRef?: string setFlag?: React.Dispatch> repoMetadata: TypesRepository | undefined standalone: boolean routingId: string } export function MarkdownEditorWithPreview({ className, value = '', templateData = '', onChange, onSave, onCancel, setDirty: setDirtyProp, i18n, hideButtons, hideCancel, editorHeight, noBorder, viewRef: viewRefProp, autoFocusAndPosition, secondarySaveButton: SecondarySaveButton, repoMetadata, standalone, routingId, handleCopilotClick, outlets = {}, setFlag, flag, sourceGitRef, targetGitRef }: MarkdownEditorWithPreviewProps) { const { getString } = useStrings() const fileInputRef = useRef(null) 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 { mutate } = useMutate({ verb: 'POST', path: `/api/v1/repos/${repoMetadata?.path}/+/genai/change-summary` }) const isDirty = useRef(dirty) useEffect( function setDirtyRef() { isDirty.current = dirty }, [dirty] ) const myKeymap = keymap.of([ { key: 'Mod-z', run: undo, preventDefault: true }, { key: 'Mod-Shift-z', run: redo, preventDefault: true }, { key: 'Mod-Enter', run: () => { if (isDirty.current) onSaveHandler() return true }, preventDefault: true } ]) const dispatchContent = (content: string, userEvent: boolean) => { const view = viewRef.current const currentContent = view?.state.doc.toString() view?.dispatch({ changes: { from: 0, to: currentContent?.length, insert: content }, userEvent: userEvent ? 'input' : 'ignore' // Marking this transaction as an input event makes it part of the undo history }) } const [data, setData] = useState({}) useEffect(() => { if (flag) { if (handleCopilotClick) { dispatchContent(getString('aidaGenSummary'), false) mutate({ head_ref: normalizeGitRef(sourceGitRef), base_ref: normalizeGitRef(targetGitRef) }) .then(res => { setData(res.summary || '') }) .catch(err => { showError(getErrorMessage(err)) }) } setFlag?.(false) } }, [handleCopilotClick]) useEffect(() => { if (!isEmpty(data)) { dispatchContent(`${data}`, true) } }, [data]) const onToolbarAction = useCallback((action: ToolbarAction) => { const view = viewRef.current if (!view?.state) { return } // Note: Part of this code is copied from @uiwjs/react-markdown-editor // MIT License, Copyright (c) 2020 uiw // @see https://github.dev/uiwjs/react-markdown-editor/blob/2d3f45079c79616b867ef03681a8ba9799169921/src/commands/header.tsx switch (action) { case ToolbarAction.HEADER: { const lineInfo = view.state.doc.lineAt(view.state.selection.main.from) let mark = '#' const matchMark = lineInfo.text.match(/^#+/) if (matchMark && matchMark[0]) { const txt = matchMark[0] if (txt.length < 6) { mark = txt + '#' } } if (mark.length > 6) { mark = '#' } const title = lineInfo.text.replace(/^#+/, '') view.dispatch({ changes: { from: lineInfo.from, to: lineInfo.to, insert: `${mark} ${title}` }, // selection: EditorSelection.range(lineInfo.from + mark.length, lineInfo.to), selection: { anchor: lineInfo.from + mark.length + 1 } }) break } case ToolbarAction.UPLOAD: { setFile(undefined) setOpen(true) break } case ToolbarAction.BOLD: { view.dispatch( view.state.changeByRange(range => ({ changes: [ { from: range.from, insert: '**' }, { from: range.to, insert: '**' } ], range: EditorSelection.range(range.from + 2, range.to + 2) })) ) break } case ToolbarAction.ITALIC: { view.dispatch( view.state.changeByRange(range => ({ changes: [ { from: range.from, insert: '*' }, { from: range.to, insert: '*' } ], range: EditorSelection.range(range.from + 1, range.to + 1) })) ) break } case ToolbarAction.UNORDER_LIST: { const lineInfo = view.state.doc.lineAt(view.state.selection.main.from) let mark = '- ' const matchMark = lineInfo.text.match(/^-/) if (matchMark && matchMark[0]) { mark = '' } view.dispatch({ changes: { from: lineInfo.from, to: lineInfo.to, insert: `${mark}${lineInfo.text}` }, // selection: EditorSelection.range(lineInfo.from + mark.length, lineInfo.to), selection: { anchor: view.state.selection.main.from + mark.length } }) break } case ToolbarAction.CHECK_LIST: { const lineInfo = view.state.doc.lineAt(view.state.selection.main.from) let mark = '- [ ] ' const matchMark = lineInfo.text.match(/^-\s\[\s\]\s/) if (matchMark && matchMark[0]) { mark = '' } view.dispatch({ changes: { from: lineInfo.from, to: lineInfo.to, insert: `${mark}${lineInfo.text}` }, // selection: EditorSelection.range(lineInfo.from + mark.length, lineInfo.to), selection: { anchor: view.state.selection.main.from + mark.length } }) break } case ToolbarAction.CODE_BLOCK: { const main = view.state.selection.main const txt = view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to) view.dispatch({ changes: { from: main.from, to: main.to, insert: `\`\`\`tsx\n${txt}\n\`\`\`` }, selection: EditorSelection.range(main.from + 3, main.from + 6) }) break } } }, []) useEffect(() => { setDirtyProp?.(dirty) return () => { setDirtyProp?.(false) } }, [dirty]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (viewRefProp) { viewRefProp.current = viewRef.current } }, [viewRefProp, viewRef.current]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (autoFocusAndPosition && !dirty) { scrollToAndSetCursorToEnd(containerRef, viewRef, true) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoFocusAndPosition, viewRef, containerRef, scrollToAndSetCursorToEnd, dirty]) useEffect(() => { if (!isEmpty(templateData)) { const currentContent = viewRef.current?.state.doc.toString() ?? '' const newContent = decodeGitContent(templateData) // If empty dispatch template data, if content same as old content then dispatch previous content if (currentContent !== newContent) { viewRef.current?.dispatch({ changes: { from: 0, to: currentContent.length, // You might need to adjust this based on how your content is structured insert: currentContent } }) } if (isEmpty(currentContent)) { viewRef.current?.dispatch({ changes: { from: 0, to: 0, insert: newContent } }) } } }, [templateData]) 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 = file?.type.startsWith('image/') ? `![image](${markdownContent})` : `${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]) } const onSaveHandler = useCallback(() => onSave?.(viewRef.current?.state.doc.toString() || ''), [onSave]) 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')} {outlets[CommentBoxOutletPosition.START_OF_MARKDOWN_EDITOR_TOOLBAR]} {toolbar.map((item, index) => { return (