PageEditorByHackmd.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. import React, {
  2. useCallback, useRef, useState, useEffect,
  3. } from 'react';
  4. import EventEmitter from 'events';
  5. import { useTranslation } from 'react-i18next';
  6. import { saveOrUpdate } from '~/client/services/page-operation';
  7. import { toastError, toastSuccess } from '~/client/util/apiNotification';
  8. import { apiPost } from '~/client/util/apiv1-client';
  9. import { getOptionsToSave } from '~/client/util/editor';
  10. import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
  11. import {
  12. useCurrentPageId, useCurrentPathname, useHackmdUri,
  13. } from '~/stores/context';
  14. import {
  15. useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
  16. } from '~/stores/editor';
  17. import {
  18. usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useIsHackmdDraftUpdatingInRealtime,
  19. } from '~/stores/hackmd';
  20. import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
  21. import { useRemoteRevisionId } from '~/stores/remote-latest-page';
  22. import {
  23. EditorMode,
  24. useEditorMode, useSelectedGrant,
  25. } from '~/stores/ui';
  26. import loggerFactory from '~/utils/logger';
  27. import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
  28. const logger = loggerFactory('growi:PageEditorByHackmd');
  29. declare const globalEmitter: EventEmitter;
  30. type HackEditorRef = {
  31. getValue: () => Promise<string>
  32. };
  33. export const PageEditorByHackmd = (): JSX.Element => {
  34. const { t } = useTranslation();
  35. const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
  36. const { data: currentPagePath } = useCurrentPagePath();
  37. const { data: currentPathname } = useCurrentPathname();
  38. const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
  39. const { data: isSlackEnabled } = useIsSlackEnabled();
  40. const { data: pageId } = useCurrentPageId();
  41. const { data: pageTags } = usePageTagsForEditors(pageId);
  42. const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
  43. const { data: grant } = useSelectedGrant();
  44. const { data: hackmdUri } = useHackmdUri();
  45. // pageData
  46. const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
  47. const revision = pageData?.revision;
  48. const slackChannels = slackChannelsData?.toString();
  49. const [isInitialized, setIsInitialized] = useState(false);
  50. const [isInitializing, setIsInitializing] = useState(false);
  51. // for error
  52. const [hasError, setHasError] = useState(false);
  53. const [errorMessage, setErrorMessage] = useState('');
  54. const [errorReason, setErrorReason] = useState('');
  55. // state from pageContainer
  56. const { data: pageIdOnHackmd, mutate: mutatePageIdOnHackmd } = usePageIdOnHackmd();
  57. const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
  58. const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
  59. const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
  60. const { data: isHackmdDraftUpdatingInRealtime, mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime(false);
  61. const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId(revision?._id);
  62. const hackmdEditorRef = useRef<HackEditorRef>(null);
  63. const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
  64. if (editorMode !== EditorMode.HackMD) { return }
  65. try {
  66. if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null || revision == null || hackmdEditorRef.current == null) {
  67. throw new Error('Some materials to save are invalid');
  68. }
  69. let optionsToSave;
  70. const currentOptionsToSave = getOptionsToSave(
  71. isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
  72. );
  73. if (opts != null) {
  74. optionsToSave = Object.assign(currentOptionsToSave, {
  75. ...opts,
  76. });
  77. }
  78. else {
  79. optionsToSave = currentOptionsToSave;
  80. }
  81. const markdown = await hackmdEditorRef.current.getValue();
  82. await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
  83. await mutatePageData();
  84. await mutateTagsInfo();
  85. mutateEditorMode(EditorMode.View);
  86. mutateIsEnabledUnsavedWarning(false);
  87. }
  88. catch (error) {
  89. logger.error('failed to save', error);
  90. toastError(error.message);
  91. }
  92. }, [editorMode,
  93. isSlackEnabled,
  94. currentPathname,
  95. slackChannels,
  96. grant,
  97. revision,
  98. pageTags,
  99. pageId,
  100. currentPagePath,
  101. mutatePageData,
  102. mutateEditorMode,
  103. mutateTagsInfo,
  104. mutateIsEnabledUnsavedWarning,
  105. ]);
  106. // set handler to save and reload Page
  107. useEffect(() => {
  108. globalEmitter.on('saveAndReturnToView', saveAndReturnToViewHandler);
  109. return function cleanup() {
  110. globalEmitter.removeListener('saveAndReturnToView', saveAndReturnToViewHandler);
  111. };
  112. }, [saveAndReturnToViewHandler]);
  113. const resetInitializedStatusHandler = useCallback(() => {
  114. setIsInitialized(false);
  115. }, []);
  116. // set handler to save and reload Page
  117. useEffect(() => {
  118. globalEmitter.on('resetInitializedHackMdStatus', resetInitializedStatusHandler);
  119. return function cleanup() {
  120. globalEmitter.removeListener('resetInitializedHackMdStatus', resetInitializedStatusHandler);
  121. };
  122. }, [resetInitializedStatusHandler]);
  123. const isResume = useCallback(() => {
  124. const isPageExistsOnHackmd = (pageIdOnHackmd != null);
  125. return (isPageExistsOnHackmd && hasDraftOnHackmd) || isHackmdDraftUpdatingInRealtime;
  126. }, [hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime, pageIdOnHackmd]);
  127. const startToEdit = useCallback(async() => {
  128. if (hackmdUri == null) {
  129. // do nothing
  130. return;
  131. }
  132. setIsInitialized(false);
  133. setIsInitializing(true);
  134. try {
  135. const res = await apiPost<IResHackmdIntegrated>('/hackmd.integrate', { pageId });
  136. if (!res.ok) {
  137. throw new Error(res.error);
  138. }
  139. mutatePageIdOnHackmd(res.pageIdOnHackmd);
  140. mutateRevisionIdHackmdSynced(res.revisionIdHackmdSynced);
  141. }
  142. catch (err) {
  143. toastError(err.message);
  144. setHasError(true);
  145. setErrorMessage('GROWI server failed to connect to HackMD.');
  146. setErrorReason(err.toString());
  147. }
  148. setIsInitialized(true);
  149. setIsInitializing(false);
  150. }, [pageId, hackmdUri, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced]);
  151. /**
  152. * Start to edit w/o any api request
  153. */
  154. const resumeToEdit = useCallback(() => {
  155. setIsInitialized(true);
  156. }, []);
  157. const discardChanges = useCallback(async() => {
  158. if (pageId == null) { return }
  159. try {
  160. const res = await apiPost<IResHackmdDiscard>('/hackmd.discard', { pageId });
  161. if (!res.ok) {
  162. throw new Error(res.error);
  163. }
  164. mutateIsHackmdDraftUpdatingInRealtime(false);
  165. mutateHasDraftOnHackmd(false);
  166. mutatePageIdOnHackmd(res.pageIdOnHackmd);
  167. mutateRemoteRevisionId(res.revisionIdHackmdSynced);
  168. mutateRevisionIdHackmdSynced(res.revisionIdHackmdSynced);
  169. }
  170. catch (err) {
  171. logger.error(err);
  172. toastError(err.message);
  173. }
  174. }, [mutateIsHackmdDraftUpdatingInRealtime, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced, mutateRemoteRevisionId, pageId]);
  175. /**
  176. * save and update state of containers
  177. * @param {string} markdown
  178. */
  179. const onSaveWithShortcut = useCallback(async(markdown) => {
  180. try {
  181. const currentPagePathOrPathname = currentPagePath || currentPathname;
  182. if (
  183. isSlackEnabled == null || grant == null || slackChannels == null || pageId == null
  184. || revisionIdHackmdSynced == null || currentPagePathOrPathname == null
  185. ) { throw new Error('Some materials to save are invalid') }
  186. const optionsToSave = getOptionsToSave(
  187. isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
  188. );
  189. const res = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, markdown);
  190. // update pageData
  191. mutatePageData(res);
  192. // set updated data
  193. mutateRemoteRevisionId(res.revision._id);
  194. mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
  195. mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
  196. mutateTagsInfo();
  197. mutateIsEnabledUnsavedWarning(false);
  198. logger.debug('success to save');
  199. toastSuccess(t('successfully_saved_the_page'));
  200. }
  201. catch (error) {
  202. logger.error('failed to save', error);
  203. toastError(error.message);
  204. }
  205. }, [isSlackEnabled,
  206. grant,
  207. slackChannels,
  208. pageId,
  209. revisionIdHackmdSynced,
  210. currentPathname,
  211. pageTags,
  212. currentPagePath,
  213. mutatePageData,
  214. mutateRevisionIdHackmdSynced,
  215. mutateHasDraftOnHackmd,
  216. mutateTagsInfo,
  217. mutateIsEnabledUnsavedWarning,
  218. mutateRemoteRevisionId,
  219. t]);
  220. /**
  221. * onChange event of HackmdEditor handler
  222. */
  223. const hackmdEditorChangeHandler = useCallback(async(body) => {
  224. if (hackmdUri == null || pageId == null) {
  225. // do nothing
  226. return;
  227. }
  228. if (revision?.body === body) {
  229. return;
  230. }
  231. try {
  232. await apiPost('/hackmd.saveOnHackmd', { pageId });
  233. }
  234. catch (err) {
  235. logger.error(err);
  236. }
  237. }, [pageId, revision?.body, hackmdUri]);
  238. const penpalErrorOccuredHandler = useCallback((error) => {
  239. toastError(error.message);
  240. setHasError(true);
  241. setErrorMessage(t('hackmd.fail_to_connect'));
  242. setErrorReason(error.toString());
  243. }, [t]);
  244. const renderPreInitContent = useCallback(() => {
  245. const isPageNotFound = pageId == null;
  246. let content;
  247. /*
  248. * HackMD is not setup
  249. */
  250. if (hackmdUri == null) {
  251. content = (
  252. <div>
  253. <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> { t('hackmd.not_set_up')}</p>
  254. {/* eslint-disable-next-line react/no-danger */}
  255. <p dangerouslySetInnerHTML={{ __html: t('hackmd.need_to_associate_with_growi_to_use_hackmd_refer_to_this') }} />
  256. </div>
  257. );
  258. }
  259. /*
  260. * used HackMD from NotFound Page
  261. */
  262. else if (isPageNotFound) {
  263. content = (
  264. <div className="text-center">
  265. <p className="hackmd-status-label">
  266. <i className="fa fa-file-text mr-2" />
  267. { t('hackmd.used_for_not_found') }
  268. </p>
  269. {/* eslint-disable-next-line react/no-danger */}
  270. <p dangerouslySetInnerHTML={{ __html: t('hackmd.need_to_make_page') }} />
  271. </div>
  272. );
  273. }
  274. /*
  275. * Resume to edit or discard changes
  276. */
  277. else if (isResume()) {
  278. const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
  279. content = (
  280. <div>
  281. <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
  282. <p className="text-center"><strong>{t('hackmd.unsaved_draft')}</strong></p>
  283. { isHackmdDocumentOutdated && (
  284. <div className="card border-warning">
  285. <div className="card-header bg-warning"><i className="icon-fw icon-info"></i> {t('hackmd.draft_outdated')}</div>
  286. <div className="card-body text-center">
  287. {t('hackmd.based_on_revision')}&nbsp;
  288. <a href={`?revision=${revisionIdHackmdSynced}`}><span className="badge badge-secondary">{revisionIdHackmdSynced?.substr(-8)}</span></a>
  289. <div className="text-center mt-3">
  290. <button
  291. className="btn btn-link btn-view-outdated-draft p-0"
  292. type="button"
  293. disabled={isInitializing}
  294. onClick={resumeToEdit}
  295. >
  296. {t('hackmd.view_outdated_draft')}
  297. </button>
  298. </div>
  299. </div>
  300. </div>
  301. ) }
  302. { !isHackmdDocumentOutdated && (
  303. <div className="text-center hackmd-resume-button-container mb-3">
  304. <button
  305. className="btn btn-success btn-lg waves-effect waves-light"
  306. type="button"
  307. disabled={isInitializing}
  308. onClick={resumeToEdit}
  309. >
  310. <span className="btn-label"><i className="icon-fw icon-control-end"></i></span>
  311. <span className="btn-text">{t('hackmd.resume_to_edit')}</span>
  312. </button>
  313. </div>
  314. ) }
  315. <div className="text-center hackmd-discard-button-container mb-3">
  316. <button
  317. className="btn btn-outline-secondary btn-lg waves-effect waves-light"
  318. type="button"
  319. onClick={discardChanges}
  320. >
  321. <span className="btn-label"><i className="icon-fw icon-control-start"></i></span>
  322. <span className="btn-text">{t('hackmd.discard_changes')}</span>
  323. </button>
  324. </div>
  325. </div>
  326. );
  327. }
  328. /*
  329. * Start to edit
  330. */
  331. else {
  332. const isRevisionOutdated = revision?._id !== remoteRevisionId;
  333. content = (
  334. <div>
  335. <p className="text-muted text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
  336. <div className="text-center hackmd-start-button-container mb-3">
  337. <button
  338. className="btn btn-info btn-lg waves-effect waves-light"
  339. type="button"
  340. disabled={isRevisionOutdated || isInitializing}
  341. onClick={startToEdit}
  342. >
  343. <span className="btn-label"><i className="icon-fw icon-paper-plane"></i></span>
  344. {t('hackmd.start_to_edit')}
  345. </button>
  346. </div>
  347. <p className="text-center">{t('hackmd.clone_page_content')}</p>
  348. </div>
  349. );
  350. }
  351. return (
  352. <div className="hackmd-preinit d-flex justify-content-center align-items-center">
  353. {content}
  354. </div>
  355. );
  356. }, [discardChanges, isInitializing, isResume, resumeToEdit, startToEdit, t, hackmdUri, pageId, remoteRevisionId, revisionIdHackmdSynced, revision?._id]);
  357. if (editorMode == null || revision == null) {
  358. return <></>;
  359. }
  360. let content;
  361. // TODO: typescriptize
  362. // using any because ref cann't used between FC and class conponent with type safe
  363. const AnyEditor = HackmdEditor as any;
  364. if (isInitialized && hackmdUri != null) {
  365. content = (
  366. <AnyEditor
  367. ref={hackmdEditorRef}
  368. hackmdUri={hackmdUri}
  369. pageIdOnHackmd={pageIdOnHackmd}
  370. initializationMarkdown={isResume() ? null : revision.body}
  371. onChange={hackmdEditorChangeHandler}
  372. onSaveWithShortcut={(document) => {
  373. onSaveWithShortcut(document);
  374. }}
  375. onPenpalErrorOccured={penpalErrorOccuredHandler}
  376. >
  377. </AnyEditor>
  378. );
  379. }
  380. else {
  381. content = renderPreInitContent();
  382. }
  383. return (
  384. <div className="position-relative">
  385. {content}
  386. { hasError && (
  387. <div className="hackmd-error position-absolute d-flex flex-column justify-content-center align-items-center">
  388. <div className="bg-box p-5 text-center">
  389. <h2 className="text-warning"><i className="icon-fw icon-exclamation"></i> {t('hackmd.integration_failed')}</h2>
  390. <h4>{errorMessage}</h4>
  391. <p className="card well text-danger">
  392. {errorReason}
  393. </p>
  394. {/* eslint-disable-next-line react/no-danger */}
  395. <p dangerouslySetInnerHTML={{ __html: t('hackmd.check_configuration') }} />
  396. </div>
  397. </div>
  398. ) }
  399. </div>
  400. );
  401. };