| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512 |
- import type { CSSProperties, JSX } from 'react';
- import React, {
- useCallback,
- useEffect,
- useLayoutEffect,
- useMemo,
- useRef,
- useState,
- } from 'react';
- import { Origin } from '@growi/core';
- import type { IPageHasId } from '@growi/core/dist/interfaces';
- import { globalEventTarget, pathUtils } from '@growi/core/dist/utils';
- import { GlobalCodeMirrorEditorKey, useSetResolvedTheme } from '@growi/editor';
- import { CodeMirrorEditorMain } from '@growi/editor/dist/client/components/CodeMirrorEditorMain';
- import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
- import { useRect } from '@growi/ui/dist/utils';
- import detectIndent from 'detect-indent';
- import { useAtomValue } from 'jotai';
- import { useTranslation } from 'next-i18next';
- import nodePath from 'path';
- import { debounce, throttle } from 'throttle-debounce';
- import { useUpdateStateAfterSave } from '~/client/services/page-operation';
- import {
- extractRemoteRevisionDataFromErrorObj,
- useUpdatePage,
- } from '~/client/services/update-page';
- import { uploadAttachments } from '~/client/services/upload-attachments';
- import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
- import { useIsEnableUnifiedMergeView } from '~/features/openai/client/states';
- import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
- import { useCurrentPathname, useCurrentUser } from '~/states/global';
- import {
- useCurrentPageData,
- useCurrentPageId,
- useCurrentPagePath,
- useIsEditable,
- useIsUntitledPage,
- usePageNotFound,
- } from '~/states/page';
- import { useTemplateBody } from '~/states/page/hooks';
- import {
- defaultIndentSizeAtom,
- isEnabledAttachTitleHeaderAtom,
- isIndentSizeForcedAtom,
- useAcceptedUploadFileType,
- } from '~/states/server-configurations';
- import {
- EditorMode,
- useCurrentIndentSize,
- useCurrentIndentSizeActions,
- useEditingMarkdown,
- useEditorMode,
- useReservedNextCaretLineValue,
- useSelectedGrant,
- useSetReservedNextCaretLine,
- useWaitingSaveProcessingActions,
- } from '~/states/ui/editor';
- import { useSetEditingClients } from '~/states/ui/editor/editing-clients';
- import { useEditorSettings } from '~/stores/editor';
- import { useSWRxCurrentGrantData } from '~/stores/page';
- import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
- import { usePreviewOptions } from '~/stores/renderer';
- import { useNextThemes } from '~/stores-universal/use-next-themes';
- import loggerFactory from '~/utils/logger';
- import {
- type ConflictHandler,
- useConflictEffect,
- useConflictResolver,
- } from './conflict';
- import { EditorNavbar } from './EditorNavbar';
- import { EditorNavbarBottom } from './EditorNavbarBottom';
- import Preview from './Preview';
- import { useScrollSync } from './ScrollSyncHelper';
- import '../GrowiEditor.vendor-styles.prebuilt';
- const logger = loggerFactory('growi:PageEditor');
- export type SaveOptions = {
- wip: boolean;
- slackChannels: string;
- isSlackEnabled: boolean;
- overwriteScopesOfDescendants?: boolean;
- };
- export type Save = (
- revisionId?: string,
- requestMarkdown?: string,
- opts?: SaveOptions,
- onConflict?: ConflictHandler,
- ) => Promise<IPageHasId | null>;
- type Props = {
- visibility?: boolean;
- };
- export const PageEditorSubstance = (props: Props): JSX.Element => {
- const { t } = useTranslation();
- const previewRef = useRef<HTMLDivElement>(null);
- const [previewRect] = useRect(previewRef);
- const isNotFound = usePageNotFound();
- const pageId = useCurrentPageId();
- const currentPagePath = useCurrentPagePath();
- const currentPathname = useCurrentPathname();
- const currentPage = useCurrentPageData();
- const [selectedGrant] = useSelectedGrant();
- const editingMarkdown = useEditingMarkdown();
- const isEnabledAttachTitleHeader = useAtomValue(
- isEnabledAttachTitleHeaderAtom,
- );
- const templateBody = useTemplateBody();
- const isEditable = useIsEditable();
- const { mutate: mutateWaitingSaveProcessing } =
- useWaitingSaveProcessingActions();
- const { editorMode, setEditorMode } = useEditorMode();
- const isUntitledPage = useIsUntitledPage();
- const isIndentSizeForced = useAtomValue(isIndentSizeForcedAtom);
- const currentIndentSize = useCurrentIndentSize();
- const { mutate: mutateCurrentIndentSize } = useCurrentIndentSizeActions();
- const defaultIndentSize = useAtomValue(defaultIndentSizeAtom);
- const acceptedUploadFileType = useAcceptedUploadFileType();
- const { data: editorSettings } = useEditorSettings();
- const { mutate: mutateIsGrantNormalized } = useSWRxCurrentGrantData(
- currentPage?._id,
- );
- const user = useCurrentUser();
- const setEditingClients = useSetEditingClients();
- const onConflict = useConflictResolver();
- const reservedNextCaretLine = useReservedNextCaretLineValue();
- const setReservedNextCaretLine = useSetReservedNextCaretLine();
- const isEnableUnifiedMergeView = useIsEnableUnifiedMergeView();
- const { data: rendererOptions } = usePreviewOptions();
- const shouldExpandContent = useShouldExpandContent(currentPage);
- const updatePage = useUpdatePage();
- const updateStateAfterSave = useUpdateStateAfterSave(pageId, {
- supressEditingMarkdownMutation: true,
- });
- useConflictEffect();
- const setResolvedTheme = useSetResolvedTheme();
- const { resolvedTheme } = useNextThemes();
- useEffect(() => {
- setResolvedTheme(resolvedTheme);
- }, [resolvedTheme, setResolvedTheme]);
- const currentRevisionId = currentPage?.revision?._id;
- // There are cases where "revisionId" is not required for revision updates
- // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
- const isRevisionIdRequiredForPageUpdate =
- currentPage?.revision?.origin === undefined;
- const initialValueRef = useRef('');
- const initialValue = useMemo(() => {
- if (!isNotFound) {
- return editingMarkdown ?? '';
- }
- let initialValue = '';
- if (isEnabledAttachTitleHeader && currentPathname != null) {
- const pageTitle = nodePath.basename(currentPathname);
- initialValue += `${pathUtils.attachTitleHeader(pageTitle)}\n`;
- }
- if (templateBody != null) {
- initialValue += `${templateBody}\n`;
- }
- return initialValue;
- }, [
- isNotFound,
- currentPathname,
- editingMarkdown,
- isEnabledAttachTitleHeader,
- templateBody,
- ]);
- useEffect(() => {
- // set to ref
- initialValueRef.current = initialValue;
- }, [initialValue]);
- const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(
- GlobalCodeMirrorEditorKey.MAIN,
- );
- const [markdownToPreview, setMarkdownToPreview] = useState<string>(
- codeMirrorEditor?.getDocString() ?? '',
- );
- const setMarkdownPreviewWithDebounce = useMemo(
- () =>
- debounce(
- 100,
- throttle(150, (value: string) => {
- setMarkdownToPreview(value);
- }),
- ),
- [],
- );
- const { scrollEditorHandler, scrollPreviewHandler } = useScrollSync(
- GlobalCodeMirrorEditorKey.MAIN,
- previewRef,
- );
- const scrollEditorHandlerThrottle = useMemo(
- () => throttle(25, scrollEditorHandler),
- [scrollEditorHandler],
- );
- const scrollPreviewHandlerThrottle = useMemo(
- () => throttle(25, scrollPreviewHandler),
- [scrollPreviewHandler],
- );
- const save: Save = useCallback(
- async (revisionId, markdown, opts, onConflict) => {
- if (pageId == null || selectedGrant == null) {
- logger.error(
- { pageId, selectedGrant },
- 'Some materials to save are invalid',
- );
- throw new Error('Some materials to save are invalid');
- }
- try {
- mutateWaitingSaveProcessing(true);
- const { page } = await updatePage({
- pageId,
- revisionId,
- wip: opts?.wip,
- body: markdown ?? '',
- grant: selectedGrant?.grant,
- origin: Origin.Editor,
- userRelatedGrantUserGroupIds: selectedGrant?.userRelatedGrantedGroups,
- ...(opts ?? {}),
- });
- // to sync revision id with page tree: https://github.com/growilabs/growi/pull/7227
- mutatePageTree();
- mutateRecentlyUpdated();
- // sync current grant data after update
- mutateIsGrantNormalized();
- return page;
- } catch (error) {
- logger.error({ err: error }, 'failed to save');
- const remoteRevisionData = extractRemoteRevisionDataFromErrorObj(error);
- if (remoteRevisionData != null) {
- onConflict?.(remoteRevisionData, markdown ?? '', save, opts);
- toastWarning(
- t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'),
- );
- return null;
- }
- toastError(error);
- return null;
- } finally {
- mutateWaitingSaveProcessing(false);
- }
- },
- [
- pageId,
- selectedGrant,
- mutateWaitingSaveProcessing,
- updatePage,
- mutateIsGrantNormalized,
- t,
- ],
- );
- const saveAndReturnToViewHandler = useCallback(
- async (evt: CustomEvent<SaveOptions>) => {
- const markdown = codeMirrorEditor?.getDocString();
- const revisionId = isRevisionIdRequiredForPageUpdate
- ? currentRevisionId
- : undefined;
- const page = await save(revisionId, markdown, evt.detail, onConflict);
- if (page == null) {
- return;
- }
- setEditorMode(EditorMode.View);
- updateStateAfterSave?.();
- },
- [
- codeMirrorEditor,
- currentRevisionId,
- isRevisionIdRequiredForPageUpdate,
- setEditorMode,
- onConflict,
- save,
- updateStateAfterSave,
- ],
- );
- const saveWithShortcut = useCallback(async () => {
- const markdown = codeMirrorEditor?.getDocString();
- const revisionId = isRevisionIdRequiredForPageUpdate
- ? currentRevisionId
- : undefined;
- const page = await save(revisionId, markdown, undefined, onConflict);
- if (page == null) {
- return;
- }
- toastSuccess(t('toaster.save_succeeded'));
- updateStateAfterSave?.();
- }, [
- codeMirrorEditor,
- currentRevisionId,
- isRevisionIdRequiredForPageUpdate,
- onConflict,
- save,
- t,
- updateStateAfterSave,
- ]);
- // the upload event handler
- const uploadHandler = useCallback(
- (files: File[]) => {
- if (pageId == null) {
- logger.error({ pageId }, 'pageId is invalid');
- throw new Error('pageId is invalid');
- }
- uploadAttachments(pageId, files, {
- onUploaded: (attachment) => {
- const fileName = attachment.originalName;
- const prefix = attachment.fileFormat.startsWith('image/')
- ? '!' // use "" syntax when image
- : '';
- const insertText = `${prefix}[${fileName}](${attachment.filePathProxied})\n`;
- codeMirrorEditor?.insertText(insertText);
- },
- onError: (error) => {
- toastError(error);
- },
- });
- },
- [codeMirrorEditor, pageId],
- );
- const onChangeHandler = useCallback(
- (value: string) => {
- setMarkdownPreviewWithDebounce(value);
- },
- [setMarkdownPreviewWithDebounce],
- );
- const cmProps = useMemo(
- () => ({
- onChange: onChangeHandler,
- }),
- [onChangeHandler],
- );
- // set handler to save and return to View
- useEffect(() => {
- globalEventTarget.addEventListener(
- 'saveAndReturnToView',
- saveAndReturnToViewHandler,
- );
- return function cleanup() {
- globalEventTarget.removeEventListener(
- 'saveAndReturnToView',
- saveAndReturnToViewHandler,
- );
- };
- }, [saveAndReturnToViewHandler]);
- // set handler to focus
- useLayoutEffect(() => {
- if (editorMode === EditorMode.Editor && isUntitledPage === false) {
- codeMirrorEditor?.focus();
- }
- }, [codeMirrorEditor, editorMode, isUntitledPage]);
- // Detect indent size from contents (only when users are allowed to change it)
- useEffect(() => {
- // do nothing if the indent size fixed
- if (isIndentSizeForced == null || isIndentSizeForced) {
- mutateCurrentIndentSize(undefined);
- return;
- }
- // detect from markdown
- if (initialValue != null) {
- const detectedIndent = detectIndent(initialValue);
- if (
- detectedIndent.type === 'space' &&
- new Set([2, 4]).has(detectedIndent.amount)
- ) {
- mutateCurrentIndentSize(detectedIndent.amount);
- }
- }
- }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
- // set caret line if the edit button next to Header is clicked.
- useEffect(() => {
- if (codeMirrorEditor?.setCaretLine == null) {
- return;
- }
- if (editorMode === EditorMode.Editor) {
- codeMirrorEditor.setCaretLine(reservedNextCaretLine ?? 0, true);
- }
- }, [codeMirrorEditor, editorMode, reservedNextCaretLine]);
- // reset caret line if returning to the View.
- useEffect(() => {
- if (editorMode === EditorMode.View) {
- setReservedNextCaretLine(0);
- }
- }, [editorMode, setReservedNextCaretLine]);
- // TODO: Check the reproduction conditions that made this code necessary and confirm reproduction
- // // when transitioning to a different page, if the initialValue is the same,
- // // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
- // const onRouterChangeComplete = useCallback(() => {
- // codeMirrorEditor?.initDoc(ydoc?.getText('codemirror').toString());
- // codeMirrorEditor?.setCaretLine();
- // }, [codeMirrorEditor, ydoc]);
- // useEffect(() => {
- // router.events.on('routeChangeComplete', onRouterChangeComplete);
- // return () => {
- // router.events.off('routeChangeComplete', onRouterChangeComplete);
- // };
- // }, [onRouterChangeComplete, router.events]);
- const pastEndStyle: CSSProperties | undefined = useMemo(() => {
- if (previewRect == null) {
- return undefined;
- }
- const previewRectHeight = previewRect.height;
- // containerHeight - 1.5 line height
- return { paddingBottom: `calc(${previewRectHeight}px - 2em)` };
- }, [previewRect]);
- if (!isEditable) {
- return <></>;
- }
- if (rendererOptions == null) {
- return <></>;
- }
- return (
- <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
- <div className="page-editor-editor-container flex-expand-vert border-end">
- <CodeMirrorEditorMain
- enableUnifiedMergeView={isEnableUnifiedMergeView}
- enableCollaboration={editorMode === EditorMode.Editor}
- onSave={saveWithShortcut}
- onUpload={uploadHandler}
- acceptedUploadFileType={acceptedUploadFileType}
- onScroll={scrollEditorHandlerThrottle}
- indentSize={currentIndentSize ?? defaultIndentSize}
- user={user ?? undefined}
- pageId={pageId ?? undefined}
- editorSettings={editorSettings}
- onEditorsUpdated={setEditingClients}
- cmProps={cmProps}
- />
- </div>
- <div
- ref={previewRef}
- onScroll={scrollPreviewHandlerThrottle}
- className="page-editor-preview-container flex-expand-vert overflow-y-auto d-none d-lg-flex"
- >
- <Preview
- rendererOptions={rendererOptions}
- markdown={markdownToPreview}
- pagePath={currentPagePath}
- expandContentWidth={shouldExpandContent}
- style={pastEndStyle}
- />
- </div>
- </div>
- );
- };
- export const PageEditor = React.memo((props: Props): JSX.Element => {
- return (
- <div
- data-testid="page-editor"
- id="page-editor"
- className={`flex-expand-vert ${props.visibility ? '' : 'd-none'}`}
- >
- <EditorNavbar />
- <PageEditorSubstance visibility={props.visibility} />
- <EditorNavbarBottom />
- </div>
- );
- });
- PageEditor.displayName = 'PageEditor';
|