Просмотр исходного кода

Merge pull request #8826 from weseek/144136-145716-scroll-to-header-when-start-to-edit

fix: Enable  # next to headline in view
Yuki Takei 1 год назад
Родитель
Сommit
c0c7faf49a

+ 2 - 0
apps/app/src/client/components/Page/DisplaySwitcher.tsx

@@ -3,6 +3,7 @@ import dynamic from 'next/dynamic';
 import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed';
 import { useIsEditable } from '~/stores-universal/context';
 import { EditorMode, useEditorMode } from '~/stores-universal/ui';
+import { useReservedNextCaretLine } from '~/stores/editor';
 import { useIsLatestRevision } from '~/stores/page';
 
 import { LazyRenderer } from '../Common/LazyRenderer';
@@ -18,6 +19,7 @@ export const DisplaySwitcher = (): JSX.Element => {
   const { data: isLatestRevision } = useIsLatestRevision();
 
   useHashChangedEffect();
+  useReservedNextCaretLine();
 
   return (
     <LazyRenderer shouldRender={isEditable === true && editorMode === EditorMode.Editor}>

+ 18 - 10
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -32,6 +32,7 @@ import {
 import { EditorMode, useEditorMode } from '~/stores-universal/ui';
 import { useNextThemes } from '~/stores-universal/use-next-themes';
 import {
+  useReservedNextCaretLine,
   useEditorSettings,
   useCurrentIndentSize,
   useEditingMarkdown,
@@ -109,6 +110,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: user } = useCurrentUser();
   const { onEditorsUpdated } = useEditingUsers();
   const onConflict = useConflictResolver();
+  const { data: reservedNextCaretLine, mutate: mutateReservedNextCaretLine } = useReservedNextCaretLine();
 
   const { data: rendererOptions } = usePreviewOptions();
 
@@ -298,19 +300,25 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     }
   }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
 
-  // set handler to set caret line
+
+  // set caret line if the edit button next to Header is clicked.
   useEffect(() => {
-    const handler = (lineNumber?: number) => {
-      codeMirrorEditor?.setCaretLine(lineNumber);
+    if (codeMirrorEditor?.setCaretLine == null) {
+      return;
+    }
+    if (editorMode === EditorMode.Editor) {
+      codeMirrorEditor.setCaretLine(reservedNextCaretLine ?? 0, true);
+    }
 
-      // TODO: scroll to the caret line
-    };
-    globalEmitter.on('setCaretLine', handler);
+  }, [codeMirrorEditor, editorMode, reservedNextCaretLine]);
+
+  // reset caret line if returning to the View.
+  useEffect(() => {
+    if (editorMode === EditorMode.View) {
+      mutateReservedNextCaretLine(0);
+    }
+  }, [editorMode, mutateReservedNextCaretLine]);
 
-    return function cleanup() {
-      globalEmitter.removeListener('setCaretLine', handler);
-    };
-  }, [codeMirrorEditor]);
 
   // 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,

+ 6 - 6
apps/app/src/client/components/PageEditor/ScrollSyncHelper.tsx

@@ -121,12 +121,12 @@ const scrollEditor = (editorRootElement: HTMLElement, previewRootElement: HTMLEl
   newScrollTop += calcScrollElementToTop(previewElements[topPreviewElementIndex]);
   newScrollTop += calcScorllElementByRatio(
     {
-      start: editorElements[startEditorElementIndex].getBoundingClientRect(),
-      top: editorElements[topEditorElementIndex].getBoundingClientRect(),
+      start: editorElements[startEditorElementIndex]?.getBoundingClientRect(),
+      top: editorElements[topEditorElementIndex]?.getBoundingClientRect(),
       next: editorElements[nextEditorElementIndex]?.getBoundingClientRect(),
     },
     {
-      start: previewElements[topPreviewElementIndex].getBoundingClientRect(),
+      start: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
       next: previewElements[topPreviewElementIndex + 1]?.getBoundingClientRect(),
     },
   );
@@ -156,12 +156,12 @@ const scrollPreview = (editorRootElement: HTMLElement, previewRootElement: HTMLE
   newScrollTop += calcScrollElementToTop(editorElements[startEditorElementIndex]);
   newScrollTop += calcScorllElementByRatio(
     {
-      start: previewElements[topPreviewElementIndex].getBoundingClientRect(),
-      top: previewElements[topPreviewElementIndex].getBoundingClientRect(),
+      start: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
+      top: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
       next: previewElements[topPreviewElementIndex + 1]?.getBoundingClientRect(),
     },
     {
-      start: editorElements[startEditorElementIndex].getBoundingClientRect(),
+      start: editorElements[startEditorElementIndex]?.getBoundingClientRect(),
       next: editorElements[nextEditorElementIndex]?.getBoundingClientRect(),
     },
   );

+ 9 - 2
apps/app/src/client/components/ReactMarkdownComponents/Header.tsx

@@ -9,6 +9,7 @@ import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import {
   useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
 } from '~/stores-universal/context';
+import { useCurrentPageYjsData } from '~/stores/yjs';
 import loggerFactory from '~/utils/logger';
 
 
@@ -26,7 +27,7 @@ declare global {
 
 function setCaretLine(line?: number): void {
   if (line != null) {
-    globalEmitter.emit('setCaretLine', line);
+    globalEmitter.emit('reservedNextCaretLine', line);
   }
 }
 
@@ -66,6 +67,7 @@ export const Header = (props: HeaderProps): JSX.Element => {
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
+  const { data: currentPageYjsData } = useCurrentPageYjsData();
 
   const router = useRouter();
 
@@ -111,7 +113,12 @@ export const Header = (props: HeaderProps): JSX.Element => {
     };
   }, [activateByHash, router.events]);
 
-  const showEditButton = !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null;
+  // TODO: currentPageYjsData?.hasYdocsNewerThanLatestRevision === false make to hide the edit button when a Yjs draft exists
+  // This is because the current conditional logic cannot handle cases where the draft is an empty string.
+  // It will be possible to address this TODO ySyncAnnotation become available for import.
+  // Ref: https://github.com/yjs/y-codemirror.next/pull/30
+  const showEditButton = !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null
+                            && currentPageYjsData?.hasYdocsNewerThanLatestRevision === false;
 
   return (
     <>

+ 8 - 0
apps/app/src/stores-universal/context.tsx

@@ -1,3 +1,7 @@
+import { useCallback, useEffect } from 'react';
+
+import type EventEmitter from 'events';
+
 import { AcceptedUploadFileType } from '@growi/core';
 import type { ColorScheme, IUserHasId } from '@growi/core';
 import { useSWRStatic } from '@growi/core/dist/swr';
@@ -12,6 +16,10 @@ import type { TargetAndAncestors } from '../interfaces/page-listing-results';
 
 import { useContextSWR } from './use-context-swr';
 
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
 
 type Nullable<T> = T | null;
 

+ 32 - 10
apps/app/src/stores/editor.tsx

@@ -1,7 +1,7 @@
-import { useCallback } from 'react';
+import { useCallback, useEffect } from 'react';
 
 import { type Nullable } from '@growi/core';
-import { withUtils, type SWRResponseWithUtils } from '@growi/core/dist/swr';
+import { withUtils, type SWRResponseWithUtils, useSWRStatic } from '@growi/core/dist/swr';
 import type { EditorSettings } from '@growi/editor';
 import useSWR, { type SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
@@ -9,22 +9,21 @@ import useSWRImmutable from 'swr/immutable';
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import type { SlackChannels } from '~/interfaces/user-trigger-notification';
-
 import {
   useCurrentUser, useDefaultIndentSize, useIsGuestUser, useIsReadOnlyUser,
 } from '~/stores-universal/context';
+
 // import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useSWRxTagsInfo } from './page';
-import { useStaticSWR } from './use-static-swr';
 
 
 export const useWaitingSaveProcessing = (): SWRResponse<boolean, Error> => {
-  return useStaticSWR('waitingSaveProcessing', undefined, { fallbackData: false });
+  return useSWRStatic('waitingSaveProcessing', undefined, { fallbackData: false });
 };
 
 
 export const useEditingMarkdown = (initialData?: string): SWRResponse<string, Error> => {
-  return useStaticSWR('editingMarkdown', initialData);
+  return useSWRStatic('editingMarkdown', initialData);
 };
 
 
@@ -69,7 +68,7 @@ export const useEditorSettings = (): SWRResponseWithUtils<EditorSettingsOperatio
 
 export const useCurrentIndentSize = (): SWRResponse<number, Error> => {
   const { data: defaultIndentSize } = useDefaultIndentSize();
-  return useStaticSWR<number, Error>(
+  return useSWRStatic<number, Error>(
     defaultIndentSize == null ? null : 'currentIndentSize',
     undefined,
     { fallbackData: defaultIndentSize },
@@ -92,7 +91,7 @@ export const useSWRxSlackChannels = (currentPagePath: Nullable<string>): SWRResp
 };
 
 export const useIsSlackEnabled = (): SWRResponse<boolean, Error> => {
-  return useStaticSWR(
+  return useSWRStatic(
     'isSlackEnabled',
     undefined,
     { fallbackData: false },
@@ -105,7 +104,7 @@ export type IPageTagsForEditorsOption = {
 
 export const usePageTagsForEditors = (pageId: Nullable<string>): SWRResponse<string[], Error> & IPageTagsForEditorsOption => {
   const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
-  const swrResult = useStaticSWR<string[], Error>('pageTags', undefined);
+  const swrResult = useSWRStatic<string[], Error>('pageTags', undefined);
   const { mutate } = swrResult;
   const sync = useCallback((): void => {
     mutate(tagsInfoData?.tags || [], false);
@@ -118,5 +117,28 @@ export const usePageTagsForEditors = (pageId: Nullable<string>): SWRResponse<str
 };
 
 export const useIsEnabledUnsavedWarning = (): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isEnabledUnsavedWarning');
+  return useSWRStatic<boolean, Error>('isEnabledUnsavedWarning');
+};
+
+
+export const useReservedNextCaretLine = (initialData?: number): SWRResponse<number> => {
+
+  const swrResponse = useSWRStatic('saveNextCaretLine', initialData, { fallbackData: 0 });
+  const { mutate } = swrResponse;
+
+  useEffect(() => {
+    const handler = (lineNumber: number) => {
+      mutate(lineNumber);
+    };
+
+    globalEmitter.on('reservedNextCaretLine', handler);
+
+    return function cleanup() {
+      globalEmitter.removeListener('reservedNextCaretLine', handler);
+    };
+  }, [mutate]);
+
+  return {
+    ...swrResponse,
+  };
 };

+ 56 - 9
packages/editor/src/client/services/use-codemirror-editor/utils/set-caret-line.ts

@@ -1,27 +1,74 @@
 import { useCallback } from 'react';
 
-import type { EditorView } from '@codemirror/view';
+import { Compartment, StateEffect } from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
+import type { ViewUpdate } from '@codemirror/view';
 
-export type SetCaretLine = (lineNumber?: number) => void;
+export type SetCaretLine = (lineNumber?: number, schedule?: boolean) => void;
 
-export const useSetCaretLine = (view?: EditorView): SetCaretLine => {
-
-  return useCallback((lineNumber) => {
-    const doc = view?.state.doc;
+const setCaretLine = (view?: EditorView, lineNumber?: number): void => {
+  const doc = view?.state.doc;
 
-    if (doc == null) {
-      return;
-    }
+  if (doc == null) {
+    return;
+  }
 
+  try {
     const posOfLineEnd = doc.line(lineNumber ?? 1).to;
     view?.dispatch({
       selection: {
         anchor: posOfLineEnd,
         head: posOfLineEnd,
       },
+      scrollIntoView: true,
+      effects: EditorView.scrollIntoView(posOfLineEnd, { x: 'end', y: 'center' }),
     });
     // focus
     view?.focus();
+  }
+  catch (_: unknown) {
+    // if posOfLineEnd is not found.
+  }
+
+};
+
+const setCaretLineScheduleForYjs = (view?: EditorView, lineNumber?: number): void => {
+
+  const compartment = new Compartment();
+
+  const setCaretLineOnceExtension = EditorView.updateListener.of((v: ViewUpdate) => {
+
+    // TODO: use ySyncAnnotation for if statement and remove "currentPageYjsData?.hasRevisionBodyDiff === false" in Header.tsx
+    // Ref: https://github.com/yjs/y-codemirror.next/pull/30
+    if (v.docChanged && v.changes.desc.length === 0) {
+
+      setCaretLine(view, lineNumber);
+
+      // setCaretLineOnceExtension, which setCaretLineScheduleForYjs added, will remove itself from view.
+      view?.dispatch({
+        effects: compartment.reconfigure([]),
+      });
+    }
+  });
+
+  view?.dispatch({
+    effects: StateEffect.appendConfig.of(
+      compartment.of(setCaretLineOnceExtension),
+    ),
+  });
+};
+
+export const useSetCaretLine = (view?: EditorView): SetCaretLine => {
+
+  return useCallback((lineNumber?: number, schedule?: boolean) => {
+
+    if (schedule) {
+      setCaretLineScheduleForYjs(view, lineNumber);
+    }
+    else {
+      setCaretLine(view, lineNumber);
+    }
+
   }, [view]);
 
 };