PageEditor.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  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 { useSetScrollToRemoteCursor } from '~/states/ui/editor/scroll-to-remote-cursor';
  60. import { useEditorSettings } from '~/stores/editor';
  61. import { useSWRxCurrentGrantData } from '~/stores/page';
  62. import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
  63. import { usePreviewOptions } from '~/stores/renderer';
  64. import { useNextThemes } from '~/stores-universal/use-next-themes';
  65. import loggerFactory from '~/utils/logger';
  66. import {
  67. type ConflictHandler,
  68. useConflictEffect,
  69. useConflictResolver,
  70. } from './conflict';
  71. import { EditorNavbar } from './EditorNavbar';
  72. import { EditorNavbarBottom } from './EditorNavbarBottom';
  73. import Preview from './Preview';
  74. import { useScrollSync } from './ScrollSyncHelper';
  75. import '../GrowiEditor.vendor-styles.prebuilt';
  76. const logger = loggerFactory('growi:PageEditor');
  77. export type SaveOptions = {
  78. wip: boolean;
  79. slackChannels: string;
  80. isSlackEnabled: boolean;
  81. overwriteScopesOfDescendants?: boolean;
  82. };
  83. export type Save = (
  84. revisionId?: string,
  85. requestMarkdown?: string,
  86. opts?: SaveOptions,
  87. onConflict?: ConflictHandler,
  88. ) => Promise<IPageHasId | null>;
  89. type Props = {
  90. visibility?: boolean;
  91. };
  92. export const PageEditorSubstance = (props: Props): JSX.Element => {
  93. const { t } = useTranslation();
  94. const previewRef = useRef<HTMLDivElement>(null);
  95. const [previewRect] = useRect(previewRef);
  96. const isNotFound = usePageNotFound();
  97. const pageId = useCurrentPageId();
  98. const currentPagePath = useCurrentPagePath();
  99. const currentPathname = useCurrentPathname();
  100. const currentPage = useCurrentPageData();
  101. const [selectedGrant] = useSelectedGrant();
  102. const editingMarkdown = useEditingMarkdown();
  103. const isEnabledAttachTitleHeader = useAtomValue(
  104. isEnabledAttachTitleHeaderAtom,
  105. );
  106. const templateBody = useTemplateBody();
  107. const isEditable = useIsEditable();
  108. const { mutate: mutateWaitingSaveProcessing } =
  109. useWaitingSaveProcessingActions();
  110. const { editorMode, setEditorMode } = useEditorMode();
  111. const isUntitledPage = useIsUntitledPage();
  112. const isIndentSizeForced = useAtomValue(isIndentSizeForcedAtom);
  113. const currentIndentSize = useCurrentIndentSize();
  114. const { mutate: mutateCurrentIndentSize } = useCurrentIndentSizeActions();
  115. const defaultIndentSize = useAtomValue(defaultIndentSizeAtom);
  116. const acceptedUploadFileType = useAcceptedUploadFileType();
  117. const { data: editorSettings } = useEditorSettings();
  118. const { mutate: mutateIsGrantNormalized } = useSWRxCurrentGrantData(
  119. currentPage?._id,
  120. );
  121. const user = useCurrentUser();
  122. const setEditingClients = useSetEditingClients();
  123. const setScrollToRemoteCursor = useSetScrollToRemoteCursor();
  124. const onConflict = useConflictResolver();
  125. const reservedNextCaretLine = useReservedNextCaretLineValue();
  126. const setReservedNextCaretLine = useSetReservedNextCaretLine();
  127. const isEnableUnifiedMergeView = useIsEnableUnifiedMergeView();
  128. const { data: rendererOptions } = usePreviewOptions();
  129. const shouldExpandContent = useShouldExpandContent(currentPage);
  130. const updatePage = useUpdatePage();
  131. const updateStateAfterSave = useUpdateStateAfterSave(pageId, {
  132. supressEditingMarkdownMutation: true,
  133. });
  134. useConflictEffect();
  135. const setResolvedTheme = useSetResolvedTheme();
  136. const { resolvedTheme } = useNextThemes();
  137. useEffect(() => {
  138. setResolvedTheme(resolvedTheme);
  139. }, [resolvedTheme, setResolvedTheme]);
  140. const currentRevisionId = currentPage?.revision?._id;
  141. // There are cases where "revisionId" is not required for revision updates
  142. // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
  143. const isRevisionIdRequiredForPageUpdate =
  144. currentPage?.revision?.origin === undefined;
  145. const initialValueRef = useRef('');
  146. const initialValue = useMemo(() => {
  147. if (!isNotFound) {
  148. return editingMarkdown ?? '';
  149. }
  150. let initialValue = '';
  151. if (isEnabledAttachTitleHeader && currentPathname != null) {
  152. const pageTitle = nodePath.basename(currentPathname);
  153. initialValue += `${pathUtils.attachTitleHeader(pageTitle)}\n`;
  154. }
  155. if (templateBody != null) {
  156. initialValue += `${templateBody}\n`;
  157. }
  158. return initialValue;
  159. }, [
  160. isNotFound,
  161. currentPathname,
  162. editingMarkdown,
  163. isEnabledAttachTitleHeader,
  164. templateBody,
  165. ]);
  166. useEffect(() => {
  167. // set to ref
  168. initialValueRef.current = initialValue;
  169. }, [initialValue]);
  170. const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(
  171. GlobalCodeMirrorEditorKey.MAIN,
  172. );
  173. const [markdownToPreview, setMarkdownToPreview] = useState<string>(
  174. codeMirrorEditor?.getDocString() ?? '',
  175. );
  176. const setMarkdownPreviewWithDebounce = useMemo(
  177. () =>
  178. debounce(
  179. 100,
  180. throttle(150, (value: string) => {
  181. setMarkdownToPreview(value);
  182. }),
  183. ),
  184. [],
  185. );
  186. const { scrollEditorHandler, scrollPreviewHandler } = useScrollSync(
  187. GlobalCodeMirrorEditorKey.MAIN,
  188. previewRef,
  189. );
  190. const scrollEditorHandlerThrottle = useMemo(
  191. () => throttle(25, scrollEditorHandler),
  192. [scrollEditorHandler],
  193. );
  194. const scrollPreviewHandlerThrottle = useMemo(
  195. () => throttle(25, scrollPreviewHandler),
  196. [scrollPreviewHandler],
  197. );
  198. const save: Save = useCallback(
  199. async (revisionId, markdown, opts, onConflict) => {
  200. if (pageId == null || selectedGrant == null) {
  201. logger.error(
  202. { pageId, selectedGrant },
  203. 'Some materials to save are invalid',
  204. );
  205. throw new Error('Some materials to save are invalid');
  206. }
  207. try {
  208. mutateWaitingSaveProcessing(true);
  209. const { page } = await updatePage({
  210. pageId,
  211. revisionId,
  212. wip: opts?.wip,
  213. body: markdown ?? '',
  214. grant: selectedGrant?.grant,
  215. origin: Origin.Editor,
  216. userRelatedGrantUserGroupIds: selectedGrant?.userRelatedGrantedGroups,
  217. ...(opts ?? {}),
  218. });
  219. // to sync revision id with page tree: https://github.com/growilabs/growi/pull/7227
  220. mutatePageTree();
  221. mutateRecentlyUpdated();
  222. // sync current grant data after update
  223. mutateIsGrantNormalized();
  224. return page;
  225. } catch (error) {
  226. logger.error({ err: error }, 'failed to save');
  227. const remoteRevisionData = extractRemoteRevisionDataFromErrorObj(error);
  228. if (remoteRevisionData != null) {
  229. onConflict?.(remoteRevisionData, markdown ?? '', save, opts);
  230. toastWarning(
  231. t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'),
  232. );
  233. return null;
  234. }
  235. toastError(error);
  236. return null;
  237. } finally {
  238. mutateWaitingSaveProcessing(false);
  239. }
  240. },
  241. [
  242. pageId,
  243. selectedGrant,
  244. mutateWaitingSaveProcessing,
  245. updatePage,
  246. mutateIsGrantNormalized,
  247. t,
  248. ],
  249. );
  250. const saveAndReturnToViewHandler = useCallback(
  251. async (evt: CustomEvent<SaveOptions>) => {
  252. const markdown = codeMirrorEditor?.getDocString();
  253. const revisionId = isRevisionIdRequiredForPageUpdate
  254. ? currentRevisionId
  255. : undefined;
  256. const page = await save(revisionId, markdown, evt.detail, onConflict);
  257. if (page == null) {
  258. return;
  259. }
  260. setEditorMode(EditorMode.View);
  261. updateStateAfterSave?.();
  262. },
  263. [
  264. codeMirrorEditor,
  265. currentRevisionId,
  266. isRevisionIdRequiredForPageUpdate,
  267. setEditorMode,
  268. onConflict,
  269. save,
  270. updateStateAfterSave,
  271. ],
  272. );
  273. const saveWithShortcut = useCallback(async () => {
  274. const markdown = codeMirrorEditor?.getDocString();
  275. const revisionId = isRevisionIdRequiredForPageUpdate
  276. ? currentRevisionId
  277. : undefined;
  278. const page = await save(revisionId, markdown, undefined, onConflict);
  279. if (page == null) {
  280. return;
  281. }
  282. toastSuccess(t('toaster.save_succeeded'));
  283. updateStateAfterSave?.();
  284. }, [
  285. codeMirrorEditor,
  286. currentRevisionId,
  287. isRevisionIdRequiredForPageUpdate,
  288. onConflict,
  289. save,
  290. t,
  291. updateStateAfterSave,
  292. ]);
  293. // the upload event handler
  294. const uploadHandler = useCallback(
  295. (files: File[]) => {
  296. if (pageId == null) {
  297. logger.error({ pageId }, 'pageId is invalid');
  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. onScrollToRemoteCursorReady={setScrollToRemoteCursor}
  423. cmProps={cmProps}
  424. />
  425. </div>
  426. <div
  427. ref={previewRef}
  428. onScroll={scrollPreviewHandlerThrottle}
  429. className="page-editor-preview-container flex-expand-vert overflow-y-auto d-none d-lg-flex"
  430. >
  431. <Preview
  432. rendererOptions={rendererOptions}
  433. markdown={markdownToPreview}
  434. pagePath={currentPagePath}
  435. expandContentWidth={shouldExpandContent}
  436. style={pastEndStyle}
  437. />
  438. </div>
  439. </div>
  440. );
  441. };
  442. export const PageEditor = React.memo((props: Props): JSX.Element => {
  443. return (
  444. <div
  445. data-testid="page-editor"
  446. id="page-editor"
  447. className={`flex-expand-vert ${props.visibility ? '' : 'd-none'}`}
  448. >
  449. <EditorNavbar />
  450. <PageEditorSubstance visibility={props.visibility} />
  451. <EditorNavbarBottom />
  452. </div>
  453. );
  454. });
  455. PageEditor.displayName = 'PageEditor';