PageEditor.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. import type { CSSProperties, JSX } from 'react';
  2. import React, {
  3. useCallback,
  4. useEffect,
  5. useLayoutEffect,
  6. useMemo,
  7. useRef,
  8. useState,
  9. } from 'react';
  10. import { Origin } from '@growi/core';
  11. import type { IPageHasId } from '@growi/core/dist/interfaces';
  12. import { globalEventTarget, pathUtils } from '@growi/core/dist/utils';
  13. import { GlobalCodeMirrorEditorKey, useSetResolvedTheme } from '@growi/editor';
  14. import { CodeMirrorEditorMain } from '@growi/editor/dist/client/components/CodeMirrorEditorMain';
  15. import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
  16. import { useRect } from '@growi/ui/dist/utils';
  17. import detectIndent from 'detect-indent';
  18. import { useAtomValue } from 'jotai';
  19. import { useTranslation } from 'next-i18next';
  20. import nodePath from 'path';
  21. import { debounce, throttle } from 'throttle-debounce';
  22. import { useUpdateStateAfterSave } from '~/client/services/page-operation';
  23. import {
  24. extractRemoteRevisionDataFromErrorObj,
  25. useUpdatePage,
  26. } from '~/client/services/update-page';
  27. import { uploadAttachments } from '~/client/services/upload-attachments';
  28. import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
  29. import { useIsEnableUnifiedMergeView } from '~/features/openai/client/states';
  30. import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
  31. import { useCurrentPathname, useCurrentUser } from '~/states/global';
  32. import {
  33. useCurrentPageData,
  34. useCurrentPageId,
  35. useCurrentPagePath,
  36. useIsEditable,
  37. useIsUntitledPage,
  38. usePageNotFound,
  39. } from '~/states/page';
  40. import { useTemplateBody } from '~/states/page/hooks';
  41. import {
  42. defaultIndentSizeAtom,
  43. isEnabledAttachTitleHeaderAtom,
  44. isIndentSizeForcedAtom,
  45. useAcceptedUploadFileType,
  46. } from '~/states/server-configurations';
  47. import {
  48. EditorMode,
  49. useCurrentIndentSize,
  50. useCurrentIndentSizeActions,
  51. useEditingMarkdown,
  52. useEditorMode,
  53. useReservedNextCaretLineValue,
  54. useSelectedGrant,
  55. useSetReservedNextCaretLine,
  56. useWaitingSaveProcessingActions,
  57. } from '~/states/ui/editor';
  58. import { useSetEditingClients } from '~/states/ui/editor/editing-clients';
  59. import { useEditorSettings } from '~/stores/editor';
  60. import { useSWRxCurrentGrantData } from '~/stores/page';
  61. import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
  62. import { usePreviewOptions } from '~/stores/renderer';
  63. import { useNextThemes } from '~/stores-universal/use-next-themes';
  64. import loggerFactory from '~/utils/logger';
  65. import {
  66. type ConflictHandler,
  67. useConflictEffect,
  68. useConflictResolver,
  69. } from './conflict';
  70. import { EditorNavbar } from './EditorNavbar';
  71. import { EditorNavbarBottom } from './EditorNavbarBottom';
  72. import Preview from './Preview';
  73. import { useScrollSync } from './ScrollSyncHelper';
  74. import '../GrowiEditor.vendor-styles.prebuilt';
  75. const logger = loggerFactory('growi:PageEditor');
  76. export type SaveOptions = {
  77. wip: boolean;
  78. slackChannels: string;
  79. isSlackEnabled: boolean;
  80. overwriteScopesOfDescendants?: boolean;
  81. };
  82. export type Save = (
  83. revisionId?: string,
  84. requestMarkdown?: string,
  85. opts?: SaveOptions,
  86. onConflict?: ConflictHandler,
  87. ) => Promise<IPageHasId | null>;
  88. type Props = {
  89. visibility?: boolean;
  90. };
  91. export const PageEditorSubstance = (props: Props): JSX.Element => {
  92. const { t } = useTranslation();
  93. const previewRef = useRef<HTMLDivElement>(null);
  94. const [previewRect] = useRect(previewRef);
  95. const isNotFound = usePageNotFound();
  96. const pageId = useCurrentPageId();
  97. const currentPagePath = useCurrentPagePath();
  98. const currentPathname = useCurrentPathname();
  99. const currentPage = useCurrentPageData();
  100. const [selectedGrant] = useSelectedGrant();
  101. const editingMarkdown = useEditingMarkdown();
  102. const isEnabledAttachTitleHeader = useAtomValue(
  103. isEnabledAttachTitleHeaderAtom,
  104. );
  105. const templateBody = useTemplateBody();
  106. const isEditable = useIsEditable();
  107. const { mutate: mutateWaitingSaveProcessing } =
  108. useWaitingSaveProcessingActions();
  109. const { editorMode, setEditorMode } = useEditorMode();
  110. const isUntitledPage = useIsUntitledPage();
  111. const isIndentSizeForced = useAtomValue(isIndentSizeForcedAtom);
  112. const currentIndentSize = useCurrentIndentSize();
  113. const { mutate: mutateCurrentIndentSize } = useCurrentIndentSizeActions();
  114. const defaultIndentSize = useAtomValue(defaultIndentSizeAtom);
  115. const acceptedUploadFileType = useAcceptedUploadFileType();
  116. const { data: editorSettings } = useEditorSettings();
  117. const { mutate: mutateIsGrantNormalized } = useSWRxCurrentGrantData(
  118. currentPage?._id,
  119. );
  120. const user = useCurrentUser();
  121. const setEditingClients = useSetEditingClients();
  122. const onConflict = useConflictResolver();
  123. const reservedNextCaretLine = useReservedNextCaretLineValue();
  124. const setReservedNextCaretLine = useSetReservedNextCaretLine();
  125. const isEnableUnifiedMergeView = useIsEnableUnifiedMergeView();
  126. const { data: rendererOptions } = usePreviewOptions();
  127. const shouldExpandContent = useShouldExpandContent(currentPage);
  128. const updatePage = useUpdatePage();
  129. const updateStateAfterSave = useUpdateStateAfterSave(pageId, {
  130. supressEditingMarkdownMutation: true,
  131. });
  132. useConflictEffect();
  133. const setResolvedTheme = useSetResolvedTheme();
  134. const { resolvedTheme } = useNextThemes();
  135. useEffect(() => {
  136. setResolvedTheme(resolvedTheme);
  137. }, [resolvedTheme, setResolvedTheme]);
  138. const currentRevisionId = currentPage?.revision?._id;
  139. // There are cases where "revisionId" is not required for revision updates
  140. // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
  141. const isRevisionIdRequiredForPageUpdate =
  142. currentPage?.revision?.origin === undefined;
  143. const initialValueRef = useRef('');
  144. const initialValue = useMemo(() => {
  145. if (!isNotFound) {
  146. return editingMarkdown ?? '';
  147. }
  148. let initialValue = '';
  149. if (isEnabledAttachTitleHeader && currentPathname != null) {
  150. const pageTitle = nodePath.basename(currentPathname);
  151. initialValue += `${pathUtils.attachTitleHeader(pageTitle)}\n`;
  152. }
  153. if (templateBody != null) {
  154. initialValue += `${templateBody}\n`;
  155. }
  156. return initialValue;
  157. }, [
  158. isNotFound,
  159. currentPathname,
  160. editingMarkdown,
  161. isEnabledAttachTitleHeader,
  162. templateBody,
  163. ]);
  164. useEffect(() => {
  165. // set to ref
  166. initialValueRef.current = initialValue;
  167. }, [initialValue]);
  168. const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(
  169. GlobalCodeMirrorEditorKey.MAIN,
  170. );
  171. const [markdownToPreview, setMarkdownToPreview] = useState<string>(
  172. codeMirrorEditor?.getDocString() ?? '',
  173. );
  174. const setMarkdownPreviewWithDebounce = useMemo(
  175. () =>
  176. debounce(
  177. 100,
  178. throttle(150, (value: string) => {
  179. setMarkdownToPreview(value);
  180. }),
  181. ),
  182. [],
  183. );
  184. const { scrollEditorHandler, scrollPreviewHandler } = useScrollSync(
  185. GlobalCodeMirrorEditorKey.MAIN,
  186. previewRef,
  187. );
  188. const scrollEditorHandlerThrottle = useMemo(
  189. () => throttle(25, scrollEditorHandler),
  190. [scrollEditorHandler],
  191. );
  192. const scrollPreviewHandlerThrottle = useMemo(
  193. () => throttle(25, scrollPreviewHandler),
  194. [scrollPreviewHandler],
  195. );
  196. const save: Save = useCallback(
  197. async (revisionId, markdown, opts, onConflict) => {
  198. if (pageId == null || selectedGrant == null) {
  199. logger.error(
  200. { pageId, selectedGrant },
  201. 'Some materials to save are invalid',
  202. );
  203. throw new Error('Some materials to save are invalid');
  204. }
  205. try {
  206. mutateWaitingSaveProcessing(true);
  207. const { page } = await updatePage({
  208. pageId,
  209. revisionId,
  210. wip: opts?.wip,
  211. body: markdown ?? '',
  212. grant: selectedGrant?.grant,
  213. origin: Origin.Editor,
  214. userRelatedGrantUserGroupIds: selectedGrant?.userRelatedGrantedGroups,
  215. ...(opts ?? {}),
  216. });
  217. // to sync revision id with page tree: https://github.com/growilabs/growi/pull/7227
  218. mutatePageTree();
  219. mutateRecentlyUpdated();
  220. // sync current grant data after update
  221. mutateIsGrantNormalized();
  222. return page;
  223. } catch (error) {
  224. logger.error({ err: error }, 'failed to save');
  225. const remoteRevisionData = extractRemoteRevisionDataFromErrorObj(error);
  226. if (remoteRevisionData != null) {
  227. onConflict?.(remoteRevisionData, markdown ?? '', save, opts);
  228. toastWarning(
  229. t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'),
  230. );
  231. return null;
  232. }
  233. toastError(error);
  234. return null;
  235. } finally {
  236. mutateWaitingSaveProcessing(false);
  237. }
  238. },
  239. [
  240. pageId,
  241. selectedGrant,
  242. mutateWaitingSaveProcessing,
  243. updatePage,
  244. mutateIsGrantNormalized,
  245. t,
  246. ],
  247. );
  248. const saveAndReturnToViewHandler = useCallback(
  249. async (evt: CustomEvent<SaveOptions>) => {
  250. const markdown = codeMirrorEditor?.getDocString();
  251. const revisionId = isRevisionIdRequiredForPageUpdate
  252. ? currentRevisionId
  253. : undefined;
  254. const page = await save(revisionId, markdown, evt.detail, onConflict);
  255. if (page == null) {
  256. return;
  257. }
  258. setEditorMode(EditorMode.View);
  259. updateStateAfterSave?.();
  260. },
  261. [
  262. codeMirrorEditor,
  263. currentRevisionId,
  264. isRevisionIdRequiredForPageUpdate,
  265. setEditorMode,
  266. onConflict,
  267. save,
  268. updateStateAfterSave,
  269. ],
  270. );
  271. const saveWithShortcut = useCallback(async () => {
  272. const markdown = codeMirrorEditor?.getDocString();
  273. const revisionId = isRevisionIdRequiredForPageUpdate
  274. ? currentRevisionId
  275. : undefined;
  276. const page = await save(revisionId, markdown, undefined, onConflict);
  277. if (page == null) {
  278. return;
  279. }
  280. toastSuccess(t('toaster.save_succeeded'));
  281. updateStateAfterSave?.();
  282. }, [
  283. codeMirrorEditor,
  284. currentRevisionId,
  285. isRevisionIdRequiredForPageUpdate,
  286. onConflict,
  287. save,
  288. t,
  289. updateStateAfterSave,
  290. ]);
  291. // the upload event handler
  292. const uploadHandler = useCallback(
  293. (files: File[]) => {
  294. if (pageId == null) {
  295. logger.error({ pageId }, 'pageId is invalid');
  296. throw new Error('pageId is invalid');
  297. }
  298. uploadAttachments(pageId, files, {
  299. onUploaded: (attachment) => {
  300. const fileName = attachment.originalName;
  301. const prefix = attachment.fileFormat.startsWith('image/')
  302. ? '!' // use "![fileName](url)" syntax when image
  303. : '';
  304. const insertText = `${prefix}[${fileName}](${attachment.filePathProxied})\n`;
  305. codeMirrorEditor?.insertText(insertText);
  306. },
  307. onError: (error) => {
  308. toastError(error);
  309. },
  310. });
  311. },
  312. [codeMirrorEditor, pageId],
  313. );
  314. const onChangeHandler = useCallback(
  315. (value: string) => {
  316. setMarkdownPreviewWithDebounce(value);
  317. },
  318. [setMarkdownPreviewWithDebounce],
  319. );
  320. const cmProps = useMemo(
  321. () => ({
  322. onChange: onChangeHandler,
  323. }),
  324. [onChangeHandler],
  325. );
  326. // set handler to save and return to View
  327. useEffect(() => {
  328. globalEventTarget.addEventListener(
  329. 'saveAndReturnToView',
  330. saveAndReturnToViewHandler,
  331. );
  332. return function cleanup() {
  333. globalEventTarget.removeEventListener(
  334. 'saveAndReturnToView',
  335. saveAndReturnToViewHandler,
  336. );
  337. };
  338. }, [saveAndReturnToViewHandler]);
  339. // set handler to focus
  340. useLayoutEffect(() => {
  341. if (editorMode === EditorMode.Editor && isUntitledPage === false) {
  342. codeMirrorEditor?.focus();
  343. }
  344. }, [codeMirrorEditor, editorMode, isUntitledPage]);
  345. // Detect indent size from contents (only when users are allowed to change it)
  346. useEffect(() => {
  347. // do nothing if the indent size fixed
  348. if (isIndentSizeForced == null || isIndentSizeForced) {
  349. mutateCurrentIndentSize(undefined);
  350. return;
  351. }
  352. // detect from markdown
  353. if (initialValue != null) {
  354. const detectedIndent = detectIndent(initialValue);
  355. if (
  356. detectedIndent.type === 'space' &&
  357. new Set([2, 4]).has(detectedIndent.amount)
  358. ) {
  359. mutateCurrentIndentSize(detectedIndent.amount);
  360. }
  361. }
  362. }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
  363. // set caret line if the edit button next to Header is clicked.
  364. useEffect(() => {
  365. if (codeMirrorEditor?.setCaretLine == null) {
  366. return;
  367. }
  368. if (editorMode === EditorMode.Editor) {
  369. codeMirrorEditor.setCaretLine(reservedNextCaretLine ?? 0, true);
  370. }
  371. }, [codeMirrorEditor, editorMode, reservedNextCaretLine]);
  372. // reset caret line if returning to the View.
  373. useEffect(() => {
  374. if (editorMode === EditorMode.View) {
  375. setReservedNextCaretLine(0);
  376. }
  377. }, [editorMode, setReservedNextCaretLine]);
  378. // TODO: Check the reproduction conditions that made this code necessary and confirm reproduction
  379. // // when transitioning to a different page, if the initialValue is the same,
  380. // // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
  381. // const onRouterChangeComplete = useCallback(() => {
  382. // codeMirrorEditor?.initDoc(ydoc?.getText('codemirror').toString());
  383. // codeMirrorEditor?.setCaretLine();
  384. // }, [codeMirrorEditor, ydoc]);
  385. // useEffect(() => {
  386. // router.events.on('routeChangeComplete', onRouterChangeComplete);
  387. // return () => {
  388. // router.events.off('routeChangeComplete', onRouterChangeComplete);
  389. // };
  390. // }, [onRouterChangeComplete, router.events]);
  391. const pastEndStyle: CSSProperties | undefined = useMemo(() => {
  392. if (previewRect == null) {
  393. return undefined;
  394. }
  395. const previewRectHeight = previewRect.height;
  396. // containerHeight - 1.5 line height
  397. return { paddingBottom: `calc(${previewRectHeight}px - 2em)` };
  398. }, [previewRect]);
  399. if (!isEditable) {
  400. return <></>;
  401. }
  402. if (rendererOptions == null) {
  403. return <></>;
  404. }
  405. return (
  406. <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
  407. <div className="page-editor-editor-container flex-expand-vert border-end">
  408. <CodeMirrorEditorMain
  409. enableUnifiedMergeView={isEnableUnifiedMergeView}
  410. enableCollaboration={editorMode === EditorMode.Editor}
  411. onSave={saveWithShortcut}
  412. onUpload={uploadHandler}
  413. acceptedUploadFileType={acceptedUploadFileType}
  414. onScroll={scrollEditorHandlerThrottle}
  415. indentSize={currentIndentSize ?? defaultIndentSize}
  416. user={user ?? undefined}
  417. pageId={pageId ?? undefined}
  418. editorSettings={editorSettings}
  419. onEditorsUpdated={setEditingClients}
  420. cmProps={cmProps}
  421. />
  422. </div>
  423. <div
  424. ref={previewRef}
  425. onScroll={scrollPreviewHandlerThrottle}
  426. className="page-editor-preview-container flex-expand-vert overflow-y-auto d-none d-lg-flex"
  427. >
  428. <Preview
  429. rendererOptions={rendererOptions}
  430. markdown={markdownToPreview}
  431. pagePath={currentPagePath}
  432. expandContentWidth={shouldExpandContent}
  433. style={pastEndStyle}
  434. />
  435. </div>
  436. </div>
  437. );
  438. };
  439. export const PageEditor = React.memo((props: Props): JSX.Element => {
  440. return (
  441. <div
  442. data-testid="page-editor"
  443. id="page-editor"
  444. className={`flex-expand-vert ${props.visibility ? '' : 'd-none'}`}
  445. >
  446. <EditorNavbar />
  447. <PageEditorSubstance visibility={props.visibility} />
  448. <EditorNavbarBottom />
  449. </div>
  450. );
  451. });
  452. PageEditor.displayName = 'PageEditor';