import React, { useCallback, useEffect, useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; import AppContainer from '~/client/services/AppContainer'; import PageContainer from '~/client/services/PageContainer'; import { apiPost } from '~/client/util/apiv1-client'; import { getOptionsToSave } from '~/client/util/editor'; import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd'; import { useCurrentPagePath, useCurrentPageId } from '~/stores/context'; import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor'; import { useEditorMode, useSelectedGrant, } from '~/stores/ui'; import loggerFactory from '~/utils/logger'; import HackmdEditor from './PageEditorByHackmd/HackmdEditor'; import { withUnstatedContainers } from './UnstatedUtils'; const logger = loggerFactory('growi:PageEditorByHackmd'); type PageEditorByHackmdProps = { appContainer: AppContainer, pageContainer: PageContainer, }; type HackEditorRef = { getValue: () => string }; const PageEditorByHackmd = (props: PageEditorByHackmdProps) => { const { appContainer, pageContainer } = props; // wip const { t } = useTranslation(); const { data: editorMode } = useEditorMode(); const { data: currentPagePath } = useCurrentPagePath(); const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath); const { data: isSlackEnabled } = useIsSlackEnabled(); const { data: pageId } = useCurrentPageId(); const { data: pageTags, mutate: updatePageTagsForEditors } = usePageTagsForEditors(pageId); const { data: grant } = useSelectedGrant(); const slackChannels = slackChannelsData?.toString(); const [isInitialized, setIsInitialized] = useState(false); const [isInitializing, setIsInitializing] = useState(false); // for error const [hasError, setHasError] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [errorReason, setErrorReason] = useState(''); const hackmdEditorRef = useRef(null); useEffect(() => { const pageEditorByHackmdInstance = { getMarkdown: () => { if (!isInitialized) { return Promise.reject(new Error(t('hackmd.not_initialized'))); } if (hackmdEditorRef.current == null) { return } return hackmdEditorRef.current.getValue(); }, reset: () => { setIsInitialized(false); }, }; // appContainer.registerComponentInstance('PageEditorByHackmd', pageEditorByHackmdInstance); }, [appContainer, isInitialized, t]); const getHackmdUri = useCallback(() => { const envVars = appContainer.getConfig().env; return envVars.HACKMD_URI; }, [appContainer]); const isResume = useCallback(() => { const { pageIdOnHackmd, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime, } = pageContainer.state; const isPageExistsOnHackmd = (pageIdOnHackmd != null); return (isPageExistsOnHackmd && hasDraftOnHackmd) || isHackmdDraftUpdatingInRealtime; }, [pageContainer.state]); const startToEdit = useCallback(async() => { const hackmdUri = getHackmdUri(); if (hackmdUri == null) { // do nothing return; } setIsInitialized(false); setIsInitializing(true); try { const res = await apiPost('/hackmd.integrate', { pageId }); if (!res.ok) { throw new Error(res.error); } await pageContainer.setState({ pageIdOnHackmd: res.pageIdOnHackmd, revisionIdHackmdSynced: res.revisionIdHackmdSynced, }); } catch (err) { pageContainer.showErrorToastr(err); setHasError(true); setErrorMessage('GROWI server failed to connect to HackMD.'); setErrorReason(err.toString()); } setIsInitialized(true); setIsInitializing(false); }, [getHackmdUri, pageContainer, pageId]); /** * Start to edit w/o any api request */ const resumeToEdit = useCallback(() => { setIsInitialized(true); }, []); const discardChanges = useCallback(async() => { const { pageId } = pageContainer.state; try { const res = await apiPost('/hackmd.discard', { pageId }); if (!res.ok) { throw new Error(res.error); } pageContainer.setState({ isHackmdDraftUpdatingInRealtime: false, hasDraftOnHackmd: false, pageIdOnHackmd: res.pageIdOnHackmd, remoteRevisionId: res.revisionIdHackmdSynced, revisionIdHackmdSynced: res.revisionIdHackmdSynced, }); } catch (err) { logger.error(err); pageContainer.showErrorToastr(err); } }, [pageContainer]); /** * save and update state of containers * @param {string} markdown */ const onSaveWithShortcut = useCallback(async(markdown) => { if (isSlackEnabled == null || grant == null || slackChannels == null) { return } const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? []); try { // disable unsaved warning // editorContainer.disableUnsavedWarning(); commentout because disableUnsavedWarning doesn't exitst // eslint-disable-next-line no-unused-vars const { page, tags } = await pageContainer.save(markdown, editorMode, optionsToSave); logger.debug('success to save'); pageContainer.showSuccessToastr(); updatePageTagsForEditors(tags); } catch (error) { logger.error('failed to save', error); pageContainer.showErrorToastr(error); } }, [editorMode, grant, isSlackEnabled, pageContainer, pageTags, slackChannels, updatePageTagsForEditors]); /** * onChange event of HackmdEditor handler */ const hackmdEditorChangeHandler = useCallback(async(body) => { const hackmdUri = getHackmdUri(); if (hackmdUri == null) { // do nothing return; } // do nothing if contents are same if (pageContainer.state.markdown === body) { return; } // enable unsaved warning // editorContainer.enableUnsavedWarning(); commentout because enableUnsavedWarning doesn't exitst const params = { pageId: pageContainer.state.pageId, }; try { await apiPost('/hackmd.saveOnHackmd', params); } catch (err) { logger.error(err); } }, [getHackmdUri, pageContainer.state.markdown, pageContainer.state.pageId]); const penpalErrorOccuredHandler = useCallback((error) => { pageContainer.showErrorToastr(error); setHasError(true); setErrorMessage(t('hackmd.fail_to_connect')); setErrorReason(error.toString()); }, [pageContainer, t]); const renderPreInitContent = useCallback(() => { const hackmdUri = getHackmdUri(); const { revisionId, revisionIdHackmdSynced, remoteRevisionId, pageId, } = pageContainer.state; const isPageNotFound = pageId == null; let content; /* * HackMD is not setup */ if (hackmdUri == null) { content = (

{ t('hackmd.not_set_up')}

{/* eslint-disable-next-line react/no-danger */}

); } /* * used HackMD from NotFound Page */ else if (isPageNotFound) { content = (

{ t('hackmd.used_for_not_found') }

{/* eslint-disable-next-line react/no-danger */}

); } /* * Resume to edit or discard changes */ else if (isResume()) { const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId; content = (

HackMD is READY!

{t('hackmd.unsaved_draft')}

{ isHackmdDocumentOutdated && (
{t('hackmd.draft_outdated')}
{t('hackmd.based_on_revision')}  {revisionIdHackmdSynced?.substr(-8)}
) } { !isHackmdDocumentOutdated && (
) }
); } /* * Start to edit */ else { const isRevisionOutdated = revisionId !== remoteRevisionId; content = (

HackMD is READY!

{t('hackmd.clone_page_content')}

); } return (
{content}
); }, [discardChanges, getHackmdUri, isInitializing, isResume, pageContainer.state, resumeToEdit, startToEdit, t]); if (editorMode == null) { return null; } const hackmdUri = getHackmdUri(); const { markdown, pageIdOnHackmd, } = pageContainer.state; let content; // TODO: typescriptize // using any because ref cann't used between FC and class conponent with type safe const AnyEditor = HackmdEditor as any; if (isInitialized) { content = ( { onSaveWithShortcut(document); }} onPenpalErrorOccured={penpalErrorOccuredHandler} > ); } else { content = renderPreInitContent(); } return (
{content} { hasError && (

{t('hackmd.integration_failed')}

{errorMessage}

{errorReason}

{/* eslint-disable-next-line react/no-danger */}

) }
); }; const PageEditorByHackmdWrapper = withUnstatedContainers(PageEditorByHackmd, [AppContainer, PageContainer]); export default PageEditorByHackmdWrapper;