import React, { useCallback, useState, useRef, useEffect, } from 'react'; import { UserPicture } from '@growi/ui'; import { Button, TabContent, TabPane, } from 'reactstrap'; import * as toastr from 'toastr'; import AppContainer from '~/client/services/AppContainer'; import CommentContainer from '~/client/services/CommentContainer'; import EditorContainer from '~/client/services/EditorContainer'; import PageContainer from '~/client/services/PageContainer'; import GrowiRenderer from '~/client/util/GrowiRenderer'; import { apiPostForm } from '~/client/util/apiv1-client'; import { CustomWindow } from '~/interfaces/global'; import { IInterceptorManager } from '~/interfaces/interceptor-manager'; import { useCurrentPagePath, useCurrentPageId, useCurrentUser } from '~/stores/context'; import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor'; import { useIsMobile } from '~/stores/ui'; import { CustomNavTab } from '../CustomNavigation/CustomNav'; import NotAvailableForGuest from '../NotAvailableForGuest'; import Editor from '../PageEditor/Editor'; import { SlackNotification } from '../SlackNotification'; import { withUnstatedContainers } from '../UnstatedUtils'; import CommentPreview from './CommentPreview'; const navTabMapping = { comment_editor: { Icon: () => , i18n: 'Write', index: 0, }, comment_preview: { Icon: () => , i18n: 'Preview', index: 1, }, }; type PropsType = { appContainer: AppContainer, commentContainer: CommentContainer, growiRenderer: GrowiRenderer, isForNewComment?: boolean, replyTo?: string, currentCommentId?: string, commentBody?: string, commentCreator?: string, onCancelButtonClicked?: () => void, onCommentButtonClicked?: () => void, } type EditorRef = { setValue: (value: string) => void, insertText: (text: string) => void, terminateUploadingState: () => void, } const CommentEditor = (props: PropsType): JSX.Element => { const { appContainer, commentContainer, growiRenderer, isForNewComment, replyTo, currentCommentId, commentBody, commentCreator, onCancelButtonClicked, onCommentButtonClicked, } = props; const { data: currentUser } = useCurrentUser(); const { data: currentPagePath } = useCurrentPagePath(); const { data: currentPageId } = useCurrentPageId(); const { data: isMobile } = useIsMobile(); const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled(); const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath); const config = appContainer.getConfig(); const isUploadable = config.upload.image || config.upload.file; const isUploadableFile = config.upload.file; const isSlackConfigured = config.isSlackConfigured; const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment); const [comment, setComment] = useState(commentBody ?? ''); const [html, setHtml] = useState(''); const [activeTab, setActiveTab] = useState('comment_editor'); const [error, setError] = useState(); const [slackChannels, setSlackChannels] = useState(slackChannelsData?.toString()); const editorRef = useRef(null); const renderHtml = useCallback((markdown: string) => { const context = { markdown, parsedHTML: '', }; const interceptorManager: IInterceptorManager = (window as CustomWindow).interceptorManager; interceptorManager.process('preRenderCommnetPreview', context) .then(() => { return interceptorManager.process('prePreProcess', context) }) .then(() => { context.markdown = growiRenderer.preProcess(context.markdown, context); }) .then(() => { return interceptorManager.process('postPreProcess', context) }) .then(() => { const parsedHTML = growiRenderer.process(context.markdown, context); context.parsedHTML = parsedHTML; }) .then(() => { return interceptorManager.process('prePostProcess', context) }) .then(() => { context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context); }) .then(() => { return interceptorManager.process('postPostProcess', context) }) .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) }) .then(() => { setHtml(context.parsedHTML); }) // process interceptors for post rendering .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) }); }, [growiRenderer]); const handleSelect = useCallback((activeTab: string) => { setActiveTab(activeTab); renderHtml(comment); }, [comment, renderHtml]); useEffect(() => { if (slackChannels === undefined) { return } setSlackChannels(slackChannelsData?.toString()); }, [slackChannelsData, slackChannels]); const initializeEditor = useCallback(() => { setComment(''); setHtml(''); setActiveTab('comment_editor'); setError(undefined); // reset value if (editorRef.current == null) { return } editorRef.current.setValue(''); }, []); const cancelButtonClickedHandler = useCallback(() => { // change state to not ready // when this editor is for the new comment mode if (isForNewComment) { setIsReadyToUse(false); } if (onCancelButtonClicked != null) { onCancelButtonClicked(); } }, [isForNewComment, onCancelButtonClicked]); const postComment = useCallback(async() => { try { if (currentCommentId != null) { await commentContainer.putComment( comment, currentCommentId, commentCreator, ); } else { await commentContainer.postComment( comment, replyTo, isSlackEnabled, slackChannels, ); } initializeEditor(); if (onCommentButtonClicked != null) { onCommentButtonClicked(); } } catch (err) { const errorMessage = err.message || 'An unknown error occured when posting comment'; setError(errorMessage); } }, [ comment, commentContainer, currentCommentId, commentCreator, initializeEditor, isSlackEnabled, onCommentButtonClicked, replyTo, slackChannels, ]); const ctrlEnterHandler = useCallback((event) => { if (event != null) { event.preventDefault(); } postComment(); }, [postComment]); const apiErrorHandler = useCallback((error: Error) => { toastr.error(error.message, 'Error occured', { closeButton: true, progressBar: true, newestOnTop: false, showDuration: '100', hideDuration: '100', timeOut: '3000', }); }, []); const uploadHandler = useCallback(async(file) => { if (editorRef.current == null) { return } const pagePath = currentPagePath; const pageId = currentPageId; const endpoint = '/attachments.add'; const formData = new FormData(); formData.append('file', file); formData.append('path', pagePath ?? ''); formData.append('page_id', pageId ?? ''); try { // TODO: typescriptize res const res = await apiPostForm(endpoint, formData) as any; const attachment = res.attachment; const fileName = attachment.originalName; let insertText = `[${fileName}](${attachment.filePathProxied})`; // when image if (attachment.fileFormat.startsWith('image/')) { // modify to "" syntax insertText = `!${insertText}`; } editorRef.current.insertText(insertText); } catch (err) { apiErrorHandler(err); } finally { editorRef.current.terminateUploadingState(); } }, [apiErrorHandler, currentPageId, currentPagePath]); const getCommentHtml = useCallback(() => { return ( ); }, [html]); const renderBeforeReady = useCallback((): JSX.Element => { return ( setIsReadyToUse(true)} > Add Comment ); }, []); const renderReady = () => { const commentPreview = getCommentHtml(); const errorMessage = {error}; const cancelButton = ( Cancel ); const submitButton = ( Comment ); // TODO: typescriptize Editor const AnyEditor = Editor as any; return ( <> {/* Note: is not optimized for ComentEditor in terms of responsive design. See a review comment in https://github.com/weseek/growi/pull/3473 */} {commentPreview} { errorMessage && errorMessage } { isSlackConfigured && ( mutateIsSlackEnabled(isSlackEnabled, false)} onChannelChange={setSlackChannels} id="idForComment" /> ) } {cancelButton}{submitButton} { error && errorMessage } {cancelButton}{submitButton} > ); }; return ( { isReadyToUse ? renderReady() : renderBeforeReady() } ); }; /** * Wrapper component for using unstated */ const CommentEditorWrapper = withUnstatedContainers>( CommentEditor, [AppContainer, PageContainer, EditorContainer, CommentContainer], ); export default CommentEditorWrapper;