diff --git a/moon/apps/web/components/MrView/MRComment.tsx b/moon/apps/web/components/MrView/MRComment.tsx index 9aece062b..439cc558a 100644 --- a/moon/apps/web/components/MrView/MRComment.tsx +++ b/moon/apps/web/components/MrView/MRComment.tsx @@ -3,13 +3,12 @@ import { NotePlusIcon } from '@gitmono/ui/Icons' import type { MenuProps } from 'antd' import { Card, Dropdown } from 'antd/lib' import { formatDistance, fromUnixTime } from 'date-fns' - +import { getMarkdownExtensions } from '@gitmono/editor' import { useDeleteIssueComment } from '@/hooks/issues/useDeleteIssueComment' import { useDeleteMrCommentDelete } from '@/hooks/useDeleteMrCommentDelete' import { Conversation } from '@/pages/[org]/mr/[id]' - -import LexicalContent from './rich-editor/LexicalContent' - +import { RichTextRenderer } from '@/components/RichTextRenderer' +import { useMemo } from 'react' interface CommentProps { conv: Conversation id: string @@ -64,6 +63,7 @@ const Comment = ({ conv, id, whoamI }: CommentProps) => { } const time = formatDistance(fromUnixTime(conv.created_at), new Date(), { addSuffix: true }) + const extensions = useMemo(() => getMarkdownExtensions({ linkUnfurl: {} }), []) return ( { } > - +
+ +
) } diff --git a/moon/apps/web/components/SimpleNoteEditor/SimpleNoteContent.tsx b/moon/apps/web/components/SimpleNoteEditor/SimpleNoteContent.tsx new file mode 100644 index 000000000..8b805de55 --- /dev/null +++ b/moon/apps/web/components/SimpleNoteEditor/SimpleNoteContent.tsx @@ -0,0 +1,190 @@ +import { + DragEvent, + forwardRef, + KeyboardEvent, + memo, + MouseEvent, + useImperativeHandle, + useRef, + useState +} from 'react' +import { Editor as TTEditor } from '@tiptap/core' +import { EditorContent } from '@tiptap/react' + +import { ActiveEditorComment, BlurAtTopOptions } from '@gitmono/editor' +import { LayeredHotkeys } from '@gitmono/ui' + +import { AttachmentLightbox } from '@/components/AttachmentLightbox' +import { MentionList } from '@/components/MarkdownEditor/MentionList' +import { ReactionList } from '@/components/MarkdownEditor/ReactionList' +import { ResourceMentionList } from '@/components/MarkdownEditor/ResourceMentionList' +import { ADD_ATTACHMENT_SHORTCUT, SlashCommand } from '@/components/Post/Notes/SlashCommand' +import { useAutoScroll } from '@/hooks/useAutoScroll' +import { EMPTY_HTML } from '@/atoms/markdown' +import { CodeBlockLanguagePicker } from '@/components/CodeBlockLanguagePicker' +import { EditorBubbleMenu } from '@/components/EditorBubbleMenu' +import { MentionInteractivity } from '@/components/InlinePost/MemberHovercard' +import { DropProps, useEditorFileHandlers } from '@/components/MarkdownEditor/useEditorFileHandlers' +import { HighlightCommentPopover } from '@/components/NoteComments/HighlightCommentPopover' +import { useUploadNoteAttachments } from '@/components/Post/Notes/Attachments/useUploadAttachments' +import { NoteCommentPreview } from '@/components/Post/Notes/CommentRenderer' +import { useSimpleNoteEditor } from '@/components/SimpleNoteEditor/useSimpleNoteEditor' + +interface Props { + commentId: string + editable?: 'all' | 'viewer' + autofocus?: boolean + content: string + onBlurAtTop?: BlurAtTopOptions['onBlur'] + onKeyDown?: (event: KeyboardEvent) => void +} + +export interface SimpleNoteContentRef { + focus(pos: 'start' | 'end' | 'restore' | 'start-newline' | MouseEvent): void + handleDrop(props: DropProps): void + handleDragOver(isOver: boolean, event: DragEvent): void + editor: TTEditor | null + clearAndBlur(): void +} + +export const SimpleNoteContent = memo( + forwardRef((props, ref) => { + const { commentId, editable = 'viewer', autofocus = false, onBlurAtTop, content } = props + + const [activeComment, setActiveComment] = useState(null) + const [hoverComment, setHoverComment] = useState(null) + const [openAttachmentId, setOpenAttachmentId] = useState() + + const canUploadAttachments = editable === 'all' + const upload = useUploadNoteAttachments({ noteId: commentId, enabled: canUploadAttachments }) + + const editor = useSimpleNoteEditor({ + content, + autofocus, + editable: editable, + onHoverComment: setHoverComment, + onActiveComment: setActiveComment, + onOpenAttachment: setOpenAttachmentId, + onBlurAtTop + }) + + const { onDrop, onPaste, imperativeHandlers } = useEditorFileHandlers({ + enabled: canUploadAttachments, + upload, + editor + }) + + // these functions allow us to call editorRef?.current?.handleDrop() etc. on the parent container + useImperativeHandle( + ref, + () => ({ + clearAndBlur: () => editor.chain().setContent(EMPTY_HTML).blur().run(), + focus: (pos) => { + if (pos === 'start') { + editor.commands.focus('start') + } else if (pos === 'start-newline') { + editor.commands.focus('start') + editor.commands.insertContent('\n') + } else if (pos === 'end') { + editor.commands.focus('end') + } else if (pos === 'restore') { + editor.commands.focus() + } else if ('clientX' in pos && 'clientY' in pos && 'target' in pos) { + if (editor.view.dom.contains(pos.target as Node)) { + return + } + + const { left, right, top } = editor.view.dom.getBoundingClientRect() + const isRight = pos.clientX > right + const editorPos = editor.view.posAtCoords({ + left: isRight ? right : left, + top: pos.clientY + }) + + if (editorPos) { + const posAdjustment = isRight && editor.view.coordsAtPos(editorPos.pos).left === left ? -1 : 0 + + editor.commands.focus(editorPos.pos + posAdjustment) + } else if (pos.clientY < top) { + editor.commands.focus('start') + } else { + editor.commands.focus('end') + } + } + }, + ...imperativeHandlers, + editor + }), + [editor, imperativeHandlers] + ) + + const containerRef = useRef(null) + + useAutoScroll({ + ref: containerRef, + enabled: true + }) + + return ( +
+ { + if (!editor.isFocused) return + + const input = document.createElement('input') + + input.type = 'file' + input.onchange = async () => { + if (input.files?.length) { + upload({ + files: Array.from(input.files), + editor + }) + } + } + input.click() + }} + options={{ enableOnContentEditable: true, enableOnFormTags: true }} + /> + + { + if (hoverComment) { + setHoverComment(null) + setActiveComment(hoverComment) + } + }} + previewComment={activeComment ? null : hoverComment} + editor={editor} + noteId={commentId} + /> + + + + + + + + setOpenAttachmentId(undefined)} + onSelectAttachment={({ id }) => setOpenAttachmentId(id)} + /> + + setActiveComment(null)} + /> + + + + +
+ ) + }) +) + +SimpleNoteContent.displayName = 'SimpleNoteContent' \ No newline at end of file diff --git a/moon/apps/web/components/SimpleNoteEditor/useSimpleNoteEditor.tsx b/moon/apps/web/components/SimpleNoteEditor/useSimpleNoteEditor.tsx new file mode 100644 index 000000000..501efba7b --- /dev/null +++ b/moon/apps/web/components/SimpleNoteEditor/useSimpleNoteEditor.tsx @@ -0,0 +1,134 @@ +import { useEffect, useMemo } from 'react' +import { EditorOptions, ReactNodeViewRenderer, useEditor } from '@tiptap/react' + +import { ActiveEditorComment, BlurAtTopOptions, getNoteExtensions, PostNoteAttachmentOptions } from '@gitmono/editor' + +import { InlineResourceMentionRenderer } from '@/components/InlineResourceMentionRenderer' +import { useControlClickLink } from '@/components/MarkdownEditor/ControlClickLink' +import { MediaGalleryRenderer } from '@/components/Post/MediaGalleryRenderer' +import { InlineRelativeTimeRenderer } from '@/components/RichTextRenderer/handlers/RelativeTime' +import { useCurrentUserOrOrganizationHasFeature } from '@/hooks/useCurrentUserOrOrganizationHasFeature' +import { notEmpty } from '@/utils/notEmpty' + +import { LinkUnfurlRenderer } from '@/components/Post/LinkUnfurlRenderer' +import { NoteAttachmentRenderer } from '@/components/Post/Notes/Attachments/NoteAttachmentRenderer' +import { DragAndDrop } from '@/components/Post/Notes/DragAndDrop' + +interface SimpleNoteEditorOptions { + content: string + onOpenAttachment?: PostNoteAttachmentOptions['onOpenAttachment'] + autofocus?: boolean + editable?: 'all' | 'viewer' | 'none' + editorProps?: EditorOptions['editorProps'] + onHoverComment?(comment: ActiveEditorComment | null): void + onActiveComment?(comment: ActiveEditorComment | null): void + onBlurAtTop?: BlurAtTopOptions['onBlur'] +} + +export function useSimpleNoteEditor({ + content, + autofocus, + editable, + editorProps, + onHoverComment, + onActiveComment, + onOpenAttachment, + onBlurAtTop, +}: SimpleNoteEditorOptions) { + const linkOptions = useControlClickLink() + const hasRelativeTime = useCurrentUserOrOrganizationHasFeature('relative_time') + + const extensions = useMemo(() => { + return [ + ...getNoteExtensions({ + history: { + enabled: true + }, + dropcursor: { + class: 'text-blue-500', + width: 2 + }, + link: linkOptions, + linkUnfurl: { + addNodeView() { + return ReactNodeViewRenderer(LinkUnfurlRenderer) + } + }, + taskItem: { + canEdit() { + return editable !== 'none' + }, + onReadOnlyChecked() { + return editable === 'viewer' + } + }, + postNoteAttachment: { + onOpenAttachment, + addNodeView() { + return ReactNodeViewRenderer(NoteAttachmentRenderer) + } + }, + mediaGallery: { + onOpenAttachment, + addNodeView() { + return ReactNodeViewRenderer(MediaGalleryRenderer) + } + }, + resourceMention: { + addNodeView() { + return ReactNodeViewRenderer(InlineResourceMentionRenderer, { contentDOMElementTag: 'span' }) + } + }, + comment: { + enabled: true, + onCommentHovered: onHoverComment, + onCommentActivated: onActiveComment + }, + codeBlockHighlighted: { + highlight: true + }, + blurAtTop: { + enabled: !!onBlurAtTop, + onBlur: onBlurAtTop + }, + relativeTime: { + disabled: !hasRelativeTime, + addNodeView() { + return ReactNodeViewRenderer(InlineRelativeTimeRenderer, { contentDOMElementTag: 'span' }) + } + } + }), + DragAndDrop + ].filter(notEmpty) + }, [editable, linkOptions, onActiveComment, onBlurAtTop, onHoverComment, onOpenAttachment, hasRelativeTime]) + + const allEditable = editable === 'all' + + const editor = useEditor( + { + immediatelyRender: true, + shouldRerenderOnTransaction: false, + editorProps: { + attributes: { + class: + 'new-posts prose select-text focus:outline-none w-full relative note min-w-full px-4', + style: "overflow-anchor: ''" + }, + ...editorProps + }, + extensions, + autofocus: !!autofocus, + content, + editable: allEditable + }, + [extensions] + ) + + useEffect(() => { + if (editor.isEditable !== allEditable) { + editor.setEditable(allEditable) + } + }, [editor, allEditable]) + + return editor +} \ No newline at end of file diff --git a/moon/apps/web/pages/[org]/mr/[id].tsx b/moon/apps/web/pages/[org]/mr/[id].tsx index 0da401103..b409cb6ae 100644 --- a/moon/apps/web/pages/[org]/mr/[id].tsx +++ b/moon/apps/web/pages/[org]/mr/[id].tsx @@ -1,18 +1,16 @@ 'use client' -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { Card, Tabs, TabsProps,Timeline,} from 'antd'; // import { CommentOutlined, MergeOutlined, CloseCircleOutlined, PullRequestOutlined } from '@ant-design/icons'; import { ChevronRightCircleIcon, ChevronSelectIcon,AlarmIcon,ClockIcon} from '@gitmono/ui/Icons' import { formatDistance, fromUnixTime } from 'date-fns'; -import RichEditor from '@/components/MrView/rich-editor/RichEditor'; import MRComment from '@/components/MrView/MRComment'; import { useRouter } from 'next/router'; import 'diff2html/bundles/css/diff2html.min.css'; import FileDiff from '@/components/DiffView/FileDiff'; import { Button } from '@gitmono/ui'; // import { ReloadIcon } from '@radix-ui/react-icons'; -import {DownloadIcon } from '@gitmono/ui' import { cn } from '@gitmono/ui/utils'; import AuthAppProviders from '@/components/Providers/AuthAppProviders'; import { AppLayout } from '@/components/Layout/AppLayout'; @@ -24,6 +22,11 @@ import { usePostMrMerge } from '@/hooks/usePostMrMerge' import { usePostMrReopen } from '@/hooks/usePostMrReopen'; import { usePostMrClose } from '@/hooks/usePostMrClose'; import { useScope } from '@/contexts/scope' +import { SimpleNoteContent, SimpleNoteContentRef } from '@/components/SimpleNoteEditor/SimpleNoteContent'; +import { EMPTY_HTML } from '@/atoms/markdown' +import { useHandleBottomScrollOffset } from '@/components/NoteEditor/useHandleBottomScrollOffset' +import { trimHtml } from '@/utils/trimHtml' +import { toast } from 'react-hot-toast' interface MRDetail { status: string, @@ -42,8 +45,6 @@ const MRDetailPage:PageWithLayout = () =>{ const router = useRouter(); const { id : tempId, title } = router.query; const { scope } = useScope() - const [editorState, setEditorState] = useState(""); - const [editorHasText, setEditorHasText] = useState(false); const [login, _setLogin] = useState(true); const id = typeof tempId === 'string' ? tempId : ''; @@ -85,14 +86,22 @@ const MRDetailPage:PageWithLayout = () =>{ } const { mutate: postMrComment, isPending : mrCommentIsPending } = usePostMrComment(id) - const save_comment = () => { - postMrComment( - { content: editorState }, + + const send_comment = () => { + const currentContentHTML = editorRef.current?.editor?.getHTML() ?? '

'; + + if (trimHtml(currentContentHTML) === '') { + toast.error('Please enter the content.', { position: 'top-center' }) + }else { + postMrComment( + { content: currentContentHTML }, { - onSuccess: () => { - setEditorState(""); - }, - }); + onSuccess: () =>{ + editorRef.current?.clearAndBlur() + } + } + ); + } } let conv_items = mrDetail?.conversations.map(conv => { @@ -116,16 +125,29 @@ const MRDetailPage:PageWithLayout = () =>{ }); const buttonClasses= 'cursor-pointer'; + const editorRef = useRef(null); + const onKeyDownScrollHandler = useHandleBottomScrollOffset({ + editor: editorRef.current?.editor + }) const tab_items: TabsProps['items'] = [ { key: '1', label: 'Conversation', children: -
+
-

Add a comment

- +

Add a comment

+
+ +
{mrDetail && mrDetail.status === "open" && } @@ -144,18 +166,18 @@ const MRDetailPage:PageWithLayout = () =>{ onClick={handleMrReopen} aria-label="Reopen Merge Request" className={cn(buttonClasses)} + loading={mrReopenIsPending} > - {mrReopenIsPending && } Reopen Merge Request }
@@ -178,9 +200,9 @@ const MRDetailPage:PageWithLayout = () =>{ onClick={handleMrApprove} aria-label="Merge MR" className={cn(buttonClasses)} + loading={mrMergeIsPending} > - {mrMergeIsPending && } - Merge MR + Merge MR }