PageEditor.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  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 '@growi/editor/dist/style.css';
  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('Some materials to save are invalid', {
  200. pageId,
  201. selectedGrant,
  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('failed to save', error);
  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 is invalid', {
  296. pageId,
  297. });
  298. throw new Error('pageId is invalid');
  299. }
  300. uploadAttachments(pageId, files, {
  301. onUploaded: (attachment) => {
  302. const fileName = attachment.originalName;
  303. const prefix = attachment.fileFormat.startsWith('image/')
  304. ? '!' // use "![fileName](url)" syntax when image
  305. : '';
  306. const insertText = `${prefix}[${fileName}](${attachment.filePathProxied})\n`;
  307. codeMirrorEditor?.insertText(insertText);
  308. },
  309. onError: (error) => {
  310. toastError(error);
  311. },
  312. });
  313. },
  314. [codeMirrorEditor, pageId],
  315. );
  316. const onChangeHandler = useCallback(
  317. (value: string) => {
  318. setMarkdownPreviewWithDebounce(value);
  319. },
  320. [setMarkdownPreviewWithDebounce],
  321. );
  322. const cmProps = useMemo(
  323. () => ({
  324. onChange: onChangeHandler,
  325. }),
  326. [onChangeHandler],
  327. );
  328. // set handler to save and return to View
  329. useEffect(() => {
  330. globalEventTarget.addEventListener(
  331. 'saveAndReturnToView',
  332. saveAndReturnToViewHandler,
  333. );
  334. return function cleanup() {
  335. globalEventTarget.removeEventListener(
  336. 'saveAndReturnToView',
  337. saveAndReturnToViewHandler,
  338. );
  339. };
  340. }, [saveAndReturnToViewHandler]);
  341. // set handler to focus
  342. useLayoutEffect(() => {
  343. if (editorMode === EditorMode.Editor && isUntitledPage === false) {
  344. codeMirrorEditor?.focus();
  345. }
  346. }, [codeMirrorEditor, editorMode, isUntitledPage]);
  347. // Detect indent size from contents (only when users are allowed to change it)
  348. useEffect(() => {
  349. // do nothing if the indent size fixed
  350. if (isIndentSizeForced == null || isIndentSizeForced) {
  351. mutateCurrentIndentSize(undefined);
  352. return;
  353. }
  354. // detect from markdown
  355. if (initialValue != null) {
  356. const detectedIndent = detectIndent(initialValue);
  357. if (
  358. detectedIndent.type === 'space' &&
  359. new Set([2, 4]).has(detectedIndent.amount)
  360. ) {
  361. mutateCurrentIndentSize(detectedIndent.amount);
  362. }
  363. }
  364. }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
  365. // set caret line if the edit button next to Header is clicked.
  366. useEffect(() => {
  367. if (codeMirrorEditor?.setCaretLine == null) {
  368. return;
  369. }
  370. if (editorMode === EditorMode.Editor) {
  371. codeMirrorEditor.setCaretLine(reservedNextCaretLine ?? 0, true);
  372. }
  373. }, [codeMirrorEditor, editorMode, reservedNextCaretLine]);
  374. // reset caret line if returning to the View.
  375. useEffect(() => {
  376. if (editorMode === EditorMode.View) {
  377. setReservedNextCaretLine(0);
  378. }
  379. }, [editorMode, setReservedNextCaretLine]);
  380. // TODO: Check the reproduction conditions that made this code necessary and confirm reproduction
  381. // // when transitioning to a different page, if the initialValue is the same,
  382. // // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
  383. // const onRouterChangeComplete = useCallback(() => {
  384. // codeMirrorEditor?.initDoc(ydoc?.getText('codemirror').toString());
  385. // codeMirrorEditor?.setCaretLine();
  386. // }, [codeMirrorEditor, ydoc]);
  387. // useEffect(() => {
  388. // router.events.on('routeChangeComplete', onRouterChangeComplete);
  389. // return () => {
  390. // router.events.off('routeChangeComplete', onRouterChangeComplete);
  391. // };
  392. // }, [onRouterChangeComplete, router.events]);
  393. const pastEndStyle: CSSProperties | undefined = useMemo(() => {
  394. if (previewRect == null) {
  395. return undefined;
  396. }
  397. const previewRectHeight = previewRect.height;
  398. // containerHeight - 1.5 line height
  399. return { paddingBottom: `calc(${previewRectHeight}px - 2em)` };
  400. }, [previewRect]);
  401. if (!isEditable) {
  402. return <></>;
  403. }
  404. if (rendererOptions == null) {
  405. return <></>;
  406. }
  407. return (
  408. <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
  409. <div className="page-editor-editor-container flex-expand-vert border-end">
  410. <CodeMirrorEditorMain
  411. enableUnifiedMergeView={isEnableUnifiedMergeView}
  412. enableCollaboration={editorMode === EditorMode.Editor}
  413. onSave={saveWithShortcut}
  414. onUpload={uploadHandler}
  415. acceptedUploadFileType={acceptedUploadFileType}
  416. onScroll={scrollEditorHandlerThrottle}
  417. indentSize={currentIndentSize ?? defaultIndentSize}
  418. user={user ?? undefined}
  419. pageId={pageId ?? undefined}
  420. editorSettings={editorSettings}
  421. onEditorsUpdated={setEditingClients}
  422. cmProps={cmProps}
  423. />
  424. </div>
  425. <div
  426. ref={previewRef}
  427. onScroll={scrollPreviewHandlerThrottle}
  428. className="page-editor-preview-container flex-expand-vert overflow-y-auto d-none d-lg-flex"
  429. >
  430. <Preview
  431. rendererOptions={rendererOptions}
  432. markdown={markdownToPreview}
  433. pagePath={currentPagePath}
  434. expandContentWidth={shouldExpandContent}
  435. style={pastEndStyle}
  436. />
  437. </div>
  438. </div>
  439. );
  440. };
  441. export const PageEditor = React.memo((props: Props): JSX.Element => {
  442. return (
  443. <div
  444. data-testid="page-editor"
  445. id="page-editor"
  446. className={`flex-expand-vert ${props.visibility ? '' : 'd-none'}`}
  447. >
  448. <EditorNavbar />
  449. <PageEditorSubstance visibility={props.visibility} />
  450. <EditorNavbarBottom />
  451. </div>
  452. );
  453. });
  454. PageEditor.displayName = 'PageEditor';