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; type Props = { visibility?: boolean; }; export const PageEditorSubstance = (props: Props): JSX.Element => { const { t } = useTranslation(); const previewRef = useRef(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( 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) => { 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 "![fileName](url)" 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 (
); }; export const PageEditor = React.memo((props: Props): JSX.Element => { return (
); }); PageEditor.displayName = 'PageEditor';