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

Merge branch 'master' into support/150088-sidebar-text-size

satof3 1 год назад
Родитель
Сommit
94ee86b0e8
82 измененных файлов с 758 добавлено и 403 удалено
  1. 2 0
      apps/app/config/logger/config.dev.js
  2. 3 3
      apps/app/package.json
  3. BIN
      apps/app/public/favicon.ico
  4. 24 0
      apps/app/public/favicon.svg
  5. BIN
      apps/app/public/images/icons/favicon/android-icon-144x144.png
  6. BIN
      apps/app/public/images/icons/favicon/android-icon-192x192.png
  7. BIN
      apps/app/public/images/icons/favicon/android-icon-36x36.png
  8. BIN
      apps/app/public/images/icons/favicon/android-icon-48x48.png
  9. BIN
      apps/app/public/images/icons/favicon/android-icon-72x72.png
  10. BIN
      apps/app/public/images/icons/favicon/android-icon-96x96.png
  11. BIN
      apps/app/public/images/icons/favicon/apple-icon-114x114.png
  12. BIN
      apps/app/public/images/icons/favicon/apple-icon-120x120.png
  13. BIN
      apps/app/public/images/icons/favicon/apple-icon-144x144.png
  14. BIN
      apps/app/public/images/icons/favicon/apple-icon-152x152.png
  15. BIN
      apps/app/public/images/icons/favicon/apple-icon-180x180.png
  16. BIN
      apps/app/public/images/icons/favicon/apple-icon-57x57.png
  17. BIN
      apps/app/public/images/icons/favicon/apple-icon-60x60.png
  18. BIN
      apps/app/public/images/icons/favicon/apple-icon-72x72.png
  19. BIN
      apps/app/public/images/icons/favicon/apple-icon-76x76.png
  20. BIN
      apps/app/public/images/icons/favicon/apple-icon-precomposed.png
  21. BIN
      apps/app/public/images/icons/favicon/apple-icon.png
  22. 0 2
      apps/app/public/images/icons/favicon/browserconfig.xml
  23. BIN
      apps/app/public/images/icons/favicon/favicon-16x16.png
  24. BIN
      apps/app/public/images/icons/favicon/favicon-32x32.png
  25. BIN
      apps/app/public/images/icons/favicon/favicon-96x96.png
  26. BIN
      apps/app/public/images/icons/favicon/ms-icon-144x144.png
  27. BIN
      apps/app/public/images/icons/favicon/ms-icon-150x150.png
  28. BIN
      apps/app/public/images/icons/favicon/ms-icon-310x310.png
  29. BIN
      apps/app/public/images/icons/favicon/ms-icon-70x70.png
  30. 2 0
      apps/app/src/client/components/Page/DisplaySwitcher.tsx
  31. 18 10
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  32. 6 6
      apps/app/src/client/components/PageEditor/ScrollSyncHelper.tsx
  33. 9 2
      apps/app/src/client/components/ReactMarkdownComponents/Header.tsx
  34. 6 4
      apps/app/src/features/questionnaire/server/service/questionnaire.ts
  35. 1 1
      apps/app/src/migrations/20180927102719-init-serverurl.js
  36. 1 1
      apps/app/src/migrations/20190618055300-abolish-crowi-classic-auth.js
  37. 1 1
      apps/app/src/migrations/20190618104011-add-config-app-installed.js
  38. 1 1
      apps/app/src/migrations/20200420160390-remove-crowi-layout.js
  39. 1 1
      apps/app/src/migrations/20200512005851-remove-behavior-type.js
  40. 1 1
      apps/app/src/migrations/20200514001356-update-theme-color-for-dark.js
  41. 1 1
      apps/app/src/migrations/20200620203632-normalize-locale-id.js
  42. 1 1
      apps/app/src/migrations/20200827045151-remove-layout-setting.js
  43. 1 1
      apps/app/src/migrations/20200828024025-copy-aws-setting.js
  44. 1 1
      apps/app/src/migrations/20200901034313-update-mail-transmission.js
  45. 1 1
      apps/app/src/migrations/20200903080025-remove-timeline-type.js.js
  46. 1 1
      apps/app/src/migrations/20200915035234-rename-s3-config.js
  47. 1 1
      apps/app/src/migrations/20210830074539-update-configs-for-slackbot.js
  48. 1 1
      apps/app/src/migrations/20211005131430-config-without-proxy-command-permission-for-renaming.js
  49. 2 3
      apps/app/src/migrations/20220311011114-convert-page-delete-config.js
  50. 1 1
      apps/app/src/migrations/20221014130200-remove-customize-is-saved-states-of-tab-changes.js
  51. 1 1
      apps/app/src/migrations/20221219011829-remove-basic-auth-related-config.js
  52. 1 1
      apps/app/src/migrations/20230213090921-remove-presentation-configurations.js
  53. 1 3
      apps/app/src/migrations/20230731075753-add_installed_date_to_config.js
  54. 2 0
      apps/app/src/pages/_document.page.tsx
  55. 3 3
      apps/app/src/server/models/config.ts
  56. 4 1
      apps/app/src/server/models/page.ts
  57. 12 7
      apps/app/src/server/routes/apiv3/healthcheck.ts
  58. 4 2
      apps/app/src/server/routes/apiv3/page/index.ts
  59. 1 1
      apps/app/src/server/routes/apiv3/page/update-page.ts
  60. 4 3
      apps/app/src/server/service/config-loader.ts
  61. 5 5
      apps/app/src/server/service/config-manager.spec.ts
  62. 3 3
      apps/app/src/server/service/config-manager.ts
  63. 3 2
      apps/app/src/server/service/page/index.ts
  64. 0 247
      apps/app/src/server/service/yjs.ts
  65. 43 0
      apps/app/src/server/service/yjs/create-indexes.ts
  66. 55 0
      apps/app/src/server/service/yjs/create-mongodb-persistence.ts
  67. 19 0
      apps/app/src/server/service/yjs/extended/mongodb-persistence.ts
  68. 1 0
      apps/app/src/server/service/yjs/index.ts
  69. 81 0
      apps/app/src/server/service/yjs/sync-ydoc.ts
  70. 44 22
      apps/app/src/server/service/yjs/yjs.integ.ts
  71. 220 0
      apps/app/src/server/service/yjs/yjs.ts
  72. 8 0
      apps/app/src/stores-universal/context.tsx
  73. 32 10
      apps/app/src/stores/editor.tsx
  74. 12 3
      apps/app/src/stores/yjs.ts
  75. 1 0
      packages/core/src/consts/index.ts
  76. 15 0
      packages/core/src/consts/ydoc-status.ts
  77. 0 1
      packages/core/src/interfaces/index.ts
  78. 0 6
      packages/core/src/interfaces/websocket.ts
  79. 1 0
      packages/editor/package.json
  80. 56 9
      packages/editor/src/client/services/use-codemirror-editor/utils/set-caret-line.ts
  81. 12 11
      packages/editor/src/client/stores/use-collaborative-editor-mode.ts
  82. 27 17
      yarn.lock

+ 2 - 0
apps/app/config/logger/config.dev.js

@@ -17,6 +17,8 @@ module.exports = {
   'growi:middleware:safe-redirect': 'debug',
   'growi:service:PassportService': 'debug',
   'growi:service:s2s-messaging:*': 'debug',
+  'growi:service:yjs': 'debug',
+  'growi:service:yjs:*': 'debug',
   // 'growi:service:socket-io': 'debug',
   // 'growi:service:ConfigManager': 'debug',
   // 'growi:service:mail': 'debug',

+ 3 - 3
apps/app/package.json

@@ -148,9 +148,9 @@
     "next-i18next": "^15.2.0",
     "next-superjson": "^0.0.4",
     "next-themes": "^0.2.1",
-    "nocache": "^3.0.1",
+    "nocache": "^4.0.0",
     "node-cron": "^3.0.2",
-    "nodemailer": "^6.6.2",
+    "nodemailer": "^6.9.14",
     "nodemailer-ses-transport": "~1.5.0",
     "openid-client": "^5.4.0",
     "p-retry": "^4.0.0",
@@ -230,7 +230,7 @@
     "@swc/jest": "^0.2.36",
     "@testing-library/react": "^14.1.2",
     "@testing-library/user-event": "^14.5.2",
-    "@types/express": "^4.17.11",
+    "@types/express": "^4.17.21",
     "@types/jest": "^29.5.2",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",

BIN
apps/app/public/favicon.ico


+ 24 - 0
apps/app/public/favicon.svg

@@ -0,0 +1,24 @@
+
+<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <style>
+    .logo {
+      fill: #777570;
+    }
+    .bg {
+      fill: white;
+    }
+    @media (prefers-color-scheme: dark) {
+      .logo {
+        fill: #E6E5E3;
+      }
+      .bg {
+        fill: black;
+      }
+    }
+  </style>
+<path class="bg" d="M47.134 22.5C47.6699 23.4282 47.6699 24.5718 47.134 25.5L36.866 43.2846C36.3301 44.2128 35.3397 44.7846 34.2679 44.7846L13.7321 44.7846C12.6603 44.7846 11.6699 44.2128 11.134 43.2846L0.866028 25.5C0.33013 24.5718 0.33013 23.4282 0.866028 22.5L11.134 4.71539C11.6699 3.78719 12.6603 3.21539 13.7321 3.21539L34.268 3.21539C35.3397 3.21539 36.3301 3.78719 36.866 4.71539L47.134 22.5Z"/>
+<path class="logo" d="M16.0962 27.332L12.5626 33.4861C12.4547 33.6759 12.4547 33.9097 12.5626 34.0961L15.2836 38.8337C15.3847 39.0065 15.5904 39.1251 15.7995 39.1251H16.0962L19.4848 33.2286L16.0962 27.332Z"/>
+<path class="logo" d="M33.9938 24.8076L29.3307 32.9272C29.243 33.0763 29.0845 33.2322 28.8148 33.2322H19.4819L16.0933 39.1254H32.2135C32.4226 39.1254 32.5979 39.0203 32.7058 38.8339L40.7642 24.8042H33.9938V24.8076Z"/>
+<path class="logo" d="M40.9127 24.5569C41.024 24.3671 41.0307 24.1536 40.9228 23.9639L38.1985 19.2229C38.0906 19.0331 37.9051 18.9111 37.686 18.9111H21.2892C21.0701 18.9111 20.8712 19.0297 20.7599 19.2127L18.1873 23.686L21.5758 29.5893L24.0676 25.2516C24.226 24.9805 24.516 24.8111 24.8262 24.8111H40.7677L40.9127 24.5603V24.5569Z"/>
+<path class="logo" d="M19.1953 15.2715H35.9292L32.7193 9.68338C32.6114 9.49361 32.426 9.375 32.2068 9.375H15.8101C15.5909 9.375 15.392 9.48344 15.2807 9.67322L7.08068 23.9435C6.97278 24.1333 6.97278 24.3638 7.08068 24.5535L10.2973 30.1519L18.6659 15.5698C18.7738 15.38 18.9761 15.2682 19.1953 15.2682V15.2715Z"/>
+</svg>

BIN
apps/app/public/images/icons/favicon/android-icon-144x144.png


BIN
apps/app/public/images/icons/favicon/android-icon-192x192.png


BIN
apps/app/public/images/icons/favicon/android-icon-36x36.png


BIN
apps/app/public/images/icons/favicon/android-icon-48x48.png


BIN
apps/app/public/images/icons/favicon/android-icon-72x72.png


BIN
apps/app/public/images/icons/favicon/android-icon-96x96.png


BIN
apps/app/public/images/icons/favicon/apple-icon-114x114.png


BIN
apps/app/public/images/icons/favicon/apple-icon-120x120.png


BIN
apps/app/public/images/icons/favicon/apple-icon-144x144.png


BIN
apps/app/public/images/icons/favicon/apple-icon-152x152.png


BIN
apps/app/public/images/icons/favicon/apple-icon-180x180.png


BIN
apps/app/public/images/icons/favicon/apple-icon-57x57.png


BIN
apps/app/public/images/icons/favicon/apple-icon-60x60.png


BIN
apps/app/public/images/icons/favicon/apple-icon-72x72.png


BIN
apps/app/public/images/icons/favicon/apple-icon-76x76.png


BIN
apps/app/public/images/icons/favicon/apple-icon-precomposed.png


BIN
apps/app/public/images/icons/favicon/apple-icon.png


+ 0 - 2
apps/app/public/images/icons/favicon/browserconfig.xml

@@ -1,2 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>

BIN
apps/app/public/images/icons/favicon/favicon-16x16.png


BIN
apps/app/public/images/icons/favicon/favicon-32x32.png


BIN
apps/app/public/images/icons/favicon/favicon-96x96.png


BIN
apps/app/public/images/icons/favicon/ms-icon-144x144.png


BIN
apps/app/public/images/icons/favicon/ms-icon-150x150.png


BIN
apps/app/public/images/icons/favicon/ms-icon-310x310.png


BIN
apps/app/public/images/icons/favicon/ms-icon-70x70.png


+ 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 (
     <>

+ 6 - 4
apps/app/src/features/questionnaire/server/service/questionnaire.ts

@@ -3,19 +3,21 @@ import * as os from 'node:os';
 
 import type { IUserHasId } from '@growi/core';
 
-import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { aclService } from '~/server/service/acl';
 import loggerFactory from '~/utils/logger';
 
+import type { IGrowiInfo } from '../../interfaces/growi-info';
 import {
-  GrowiWikiType, GrowiExternalAuthProviderType, IGrowiInfo, GrowiServiceType, GrowiAttachmentType, GrowiDeploymentType,
+  GrowiWikiType, GrowiExternalAuthProviderType, GrowiServiceType, GrowiAttachmentType, GrowiDeploymentType,
 } from '../../interfaces/growi-info';
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
 import { type IUserInfo, UserType } from '../../interfaces/user-info';
 import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
-import QuestionnaireOrder, { QuestionnaireOrderDocument } from '../models/questionnaire-order';
+import type { QuestionnaireOrderDocument } from '../models/questionnaire-order';
+import QuestionnaireOrder from '../models/questionnaire-order';
 import { isShowableCondition } from '../util/condition';
 
 

+ 1 - 1
apps/app/src/migrations/20180927102719-init-serverurl.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20190618055300-abolish-crowi-classic-auth.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20190618104011-add-config-app-installed.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200420160390-remove-crowi-layout.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200512005851-remove-behavior-type.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200514001356-update-theme-color-for-dark.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200620203632-normalize-locale-id.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200827045151-remove-layout-setting.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200828024025-copy-aws-setting.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200901034313-update-mail-transmission.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200903080025-remove-timeline-type.js.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20200915035234-rename-s3-config.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20210830074539-update-configs-for-slackbot.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20211005131430-config-without-proxy-command-permission-for-renaming.js

@@ -1,7 +1,7 @@
 import mongoose from 'mongoose';
 
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 2 - 3
apps/app/src/migrations/20220311011114-convert-page-delete-config.js

@@ -3,8 +3,8 @@ import mongoose from 'mongoose';
 import {
   PageRecursiveDeleteConfigValue, PageRecursiveDeleteCompConfigValue,
 } from '~/interfaces/page-delete-config';
-import ConfigModel from '~/server/models/config';
-import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import { Config } from '~/server/models/config';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:migrate:convert-page-delete-config');
@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:migrate:convert-page-delete-config');
 module.exports = {
   async up(db, client) {
     mongoose.connect(getMongoUri(), mongoOptions);
-    const Config = getModelSafely('Config') || ConfigModel;
 
     const isNewConfigExists = await Config.count({
       ns: 'crowi',

+ 1 - 1
apps/app/src/migrations/20221014130200-remove-customize-is-saved-states-of-tab-changes.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20221219011829-remove-basic-auth-related-config.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
apps/app/src/migrations/20230213090921-remove-presentation-configurations.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
-import Config from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 3
apps/app/src/migrations/20230731075753-add_installed_date_to_config.js

@@ -1,5 +1,5 @@
 // eslint-disable-next-line import/no-named-as-default
-import ConfigModel from '~/server/models/config';
+import { Config } from '~/server/models/config';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
@@ -12,7 +12,6 @@ module.exports = {
   async up() {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
-    const Config = getModelSafely('Config') || ConfigModel;
     const User = getModelSafely('User') || require('~/server/models/user')();
 
     const appInstalled = await Config.findOne({ key: 'app:installed' });
@@ -39,7 +38,6 @@ module.exports = {
   async down() {
     logger.info('Rollback migration');
     mongoose.connect(getMongoUri(), mongoOptions);
-    const Config = getModelSafely('Config') || ConfigModel;
 
     const appInstalled = await Config.findOne({ key: 'app:installed' });
     if (appInstalled != null) {

+ 2 - 0
apps/app/src/pages/_document.page.tsx

@@ -121,6 +121,8 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
         <Head>
           {this.renderCustomScript(customScript)}
           <link rel="stylesheet" key="link-theme" href={themeHref} />
+          <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+          <link rel="alternate icon" href="/favicon.ico" />
           <HeadersForGrowiPlugin pluginResourceEntries={pluginResourceEntries} />
           {this.renderCustomCss(customCss)}
         </Head>

+ 3 - 3
apps/app/src/server/models/config.ts

@@ -7,7 +7,7 @@ import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
-export interface Config {
+export interface IConfig {
   _id: Types.ObjectId;
   ns: string;
   key: string;
@@ -21,7 +21,7 @@ export interface Config {
 interface ModelMethods { any }
 
 
-const schema = new Schema<Config>({
+const schema = new Schema<IConfig>({
   ns: { type: String, required: true },
   key: { type: String, required: true },
   value: { type: String, required: true },
@@ -176,4 +176,4 @@ export const defaultNotificationConfigs: { [key: string]: any } = {
   'slack:token': undefined,
 };
 
-export default getOrCreateModel<Config, ModelMethods>('Config', schema);
+export const Config = getOrCreateModel<IConfig, ModelMethods>('Config', schema);

+ 4 - 1
apps/app/src/server/models/page.ts

@@ -67,7 +67,10 @@ export type CreateMethod = (path: string, body: string, user, options: IOptionsF
 
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete static methods
-  findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean): Promise<PageDocument[]>
+  findByIdAndViewer(pageId: ObjectIdLike, user, userGroups?, includeEmpty?: boolean): Promise<PageDocument & HasObjectId>
+  findByIdsAndViewer(
+    pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean,
+  ): Promise<(PageDocument & HasObjectId)[]>
   findByPath(path: string, includeEmpty?: boolean): Promise<PageDocument | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<PageDocument & HasObjectId | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<(PageDocument & HasObjectId)[]>

+ 12 - 7
apps/app/src/server/routes/apiv3/healthcheck.js → apps/app/src/server/routes/apiv3/healthcheck.ts

@@ -1,14 +1,17 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import express from 'express';
+import nocache from 'nocache';
 
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:routes:apiv3:healthcheck'); // eslint-disable-line no-unused-vars
+import { Config } from '../../models/config';
 
-const express = require('express');
+import type { ApiV3Response } from './interfaces/apiv3-response';
 
-const router = express.Router();
 
-const noCache = require('nocache');
+const logger = loggerFactory('growi:routes:apiv3:healthcheck');
+
+const router = express.Router();
 
 /**
  * @swagger
@@ -52,7 +55,6 @@ module.exports = (crowi) => {
 
   async function checkMongo(errors, info) {
     try {
-      const Config = crowi.models.Config;
       await Config.findOne({});
 
       info.mongo = 'OK';
@@ -123,8 +125,11 @@ module.exports = (crowi) => {
    *                  info:
    *                    $ref: '#/components/schemas/HealthcheckInfo'
    */
-  router.get('/', noCache(), async(req, res) => {
-    let checkServices = req.query.checkServices || [];
+  router.get('/', nocache(), async(req, res: ApiV3Response) => {
+    let checkServices = (() => {
+      if (req.query.checkServices == null) return [];
+      return Array.isArray(req.query.checkServices) ? req.query.checkServices : [req.query.checkServices];
+    })();
     let isStrictly = req.query.strictly != null;
 
     // for backward compatibility

+ 4 - 2
apps/app/src/server/routes/apiv3/page/index.ts

@@ -3,6 +3,7 @@ import path from 'path';
 import type { IPage } from '@growi/core';
 import {
   AllSubscriptionStatusType, PageGrant, SubscriptionStatusType,
+  getIdForRef,
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
@@ -592,7 +593,8 @@ module.exports = (crowi) => {
     } = page;
     let isGrantNormalized = false;
     try {
-      isGrantNormalized = await pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsers, grantedGroups, false, false);
+      const grantedUsersId = grantedUsers.map(ref => getIdForRef(ref));
+      isGrantNormalized = await pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsersId, grantedGroups, false, false);
     }
     catch (err) {
       logger.error('Error occurred while processing isGrantNormalized.', err);
@@ -615,7 +617,7 @@ module.exports = (crowi) => {
       return res.apiv3({ isGrantNormalized, grantData });
     }
 
-    const parentPage = await Page.findByIdAndViewer(page.parent, req.user, null, false);
+    const parentPage = await Page.findByIdAndViewer(getIdForRef(page.parent), req.user, null, false);
 
     // user isn't allowed to see parent's grant
     if (parentPage == null) {

+ 1 - 1
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -68,7 +68,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     const origin = req.body.origin;
     if (origin === Origin.View || origin === undefined) {
       const yjsService = getYjsService();
-      await yjsService.handleYDocUpdate(req.body.pageId, req.body.body);
+      await yjsService.syncWithTheLatestRevisionForce(req.body.pageId);
     }
 
     // persist activity

+ 4 - 3
apps/app/src/server/service/config-loader.ts

@@ -4,8 +4,9 @@ import { parseISO } from 'date-fns/parseISO';
 import { GrowiServiceType } from '~/features/questionnaire/interfaces/growi-info';
 import loggerFactory from '~/utils/logger';
 
-import type { Config } from '../models/config';
-import ConfigModel, { defaultCrowiConfigs, defaultMarkdownConfigs, defaultNotificationConfigs } from '../models/config';
+import {
+  Config, defaultCrowiConfigs, defaultMarkdownConfigs, defaultNotificationConfigs,
+} from '../models/config';
 
 
 const logger = loggerFactory('growi:service:ConfigLoader');
@@ -790,7 +791,7 @@ export default class ConfigLoader {
 
   async loadFromDB(): Promise<any> {
     const config = {};
-    const docs: Config[] = await ConfigModel.find().exec();
+    const docs = await Config.find().exec();
 
     for (const doc of docs) {
       if (!config[doc.ns]) {

+ 5 - 5
apps/app/src/server/service/config-manager.spec.ts

@@ -1,6 +1,6 @@
 import { mock } from 'vitest-mock-extended';
 
-import ConfigModel from '../models/config';
+import { Config } from '../models/config';
 
 import { configManager } from './config-manager';
 import type { S2sMessagingService } from './s2s-messaging/base';
@@ -19,7 +19,7 @@ describe('ConfigManager test', () => {
 
     test('invoke publishUpdateMessage()', async() => {
       // setup
-      ConfigModel.bulkWrite = vi.fn();
+      Config.bulkWrite = vi.fn();
       configManager.loadConfigs = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
 
@@ -28,14 +28,14 @@ describe('ConfigManager test', () => {
       await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig);
 
       // then
-      expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(Config.bulkWrite).toHaveBeenCalledTimes(1);
       expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
 
     test('does not invoke publishUpdateMessage()', async() => {
       // setup
-      ConfigModel.bulkWrite = vi.fn();
+      Config.bulkWrite = vi.fn();
       configManager.loadConfigs = vi.fn();
       configManager.publishUpdateMessage = vi.fn();
 
@@ -44,7 +44,7 @@ describe('ConfigManager test', () => {
       await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig, true);
 
       // then
-      expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(Config.bulkWrite).toHaveBeenCalledTimes(1);
       expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
     });

+ 3 - 3
apps/app/src/server/service/config-manager.ts

@@ -2,7 +2,7 @@ import { parseISO } from 'date-fns/parseISO';
 
 import loggerFactory from '~/utils/logger';
 
-import ConfigModel from '../models/config';
+import { Config } from '../models/config';
 import S2sMessage from '../models/vo/s2s-message';
 
 import type { ConfigObject } from './config-loader';
@@ -204,7 +204,7 @@ class ConfigManagerImpl implements ConfigManager, S2sMessageHandlable {
         },
       });
     }
-    await ConfigModel.bulkWrite(queries);
+    await Config.bulkWrite(queries);
 
     await this.loadConfigs();
 
@@ -223,7 +223,7 @@ class ConfigManagerImpl implements ConfigManager, S2sMessageHandlable {
         },
       });
     }
-    await ConfigModel.bulkWrite(queries);
+    await Config.bulkWrite(queries);
 
     await this.loadConfigs();
 

+ 3 - 2
apps/app/src/server/service/page/index.ts

@@ -7,7 +7,7 @@ import type {
   IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup, IRevisionHasId,
 } from '@growi/core';
 import {
-  PageGrant, PageStatus, getIdForRef,
+  PageGrant, PageStatus, YDocStatus, getIdForRef,
 } from '@growi/core';
 import {
   pagePathUtils, pathUtils,
@@ -4438,7 +4438,8 @@ class PageService implements IPageService {
     const yjsService = getYjsService();
 
     const currentYdoc = yjsService.getCurrentYdoc(pageId);
-    const hasYdocsNewerThanLatestRevision = await yjsService.hasYdocsNewerThanLatestRevision(pageId);
+    const ydocStatus = await yjsService.getYDocStatus(pageId);
+    const hasYdocsNewerThanLatestRevision = ydocStatus === YDocStatus.DRAFT;
 
     return {
       hasYdocsNewerThanLatestRevision,

+ 0 - 247
apps/app/src/server/service/yjs.ts

@@ -1,247 +0,0 @@
-import type { IRevisionHasId } from '@growi/core';
-import { GlobalSocketEventName } from '@growi/core';
-import mongoose from 'mongoose';
-import type { Server } from 'socket.io';
-import { MongodbPersistence } from 'y-mongodb-provider';
-import type { Document } from 'y-socket.io/dist/server';
-import { YSocketIO, type Document as Ydoc } from 'y-socket.io/dist/server';
-import * as Y from 'yjs';
-
-import { SocketEventName } from '~/interfaces/websocket';
-import loggerFactory from '~/utils/logger';
-
-import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
-
-
-const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
-const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
-
-
-const logger = loggerFactory('growi:service:yjs');
-
-
-export const extractPageIdFromYdocId = (ydocId: string): string | undefined => {
-  const result = ydocId.match(/yjs\/(.*)/);
-  return result?.[1];
-};
-
-export interface IYjsService {
-  hasYdocsNewerThanLatestRevision(pageId: string): Promise<boolean>;
-  handleYDocSync(pageId: string, initialValue: string): Promise<void>;
-  handleYDocUpdate(pageId: string, newValue: string): Promise<void>;
-  getCurrentYdoc(pageId: string): Ydoc | undefined;
-  getPersistedYdoc(pageId: string): Promise<Y.Doc>;
-}
-
-class YjsService implements IYjsService {
-
-  private ysocketio: YSocketIO;
-
-  private mdb: MongodbPersistence;
-
-  constructor(io: Server) {
-    const ysocketio = new YSocketIO(io);
-    ysocketio.initialize();
-    this.ysocketio = ysocketio;
-
-    this.mdb = new MongodbPersistence(
-      // ignore TS2345: Argument of type '{ client: any; db: any; }' is not assignable to parameter of type 'string'.
-      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-      // @ts-ignore
-      {
-        // TODO: Required upgrading mongoose and unifying the versions of mongodb to omit 'as any'
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        client: mongoose.connection.getClient() as any,
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        db: mongoose.connection.db as any,
-      },
-      {
-        collectionName: MONGODB_PERSISTENCE_COLLECTION_NAME,
-        flushSize: MONGODB_PERSISTENCE_FLUSH_SIZE,
-      },
-    );
-
-    this.createIndexes();
-
-    io.on('connection', (socket) => {
-
-      ysocketio.on('awareness-update', async(doc: Document) => {
-        const pageId = extractPageIdFromYdocId(doc.name);
-
-        if (pageId == null) return;
-
-        const awarenessStateSize = doc.awareness.states.size;
-
-        // Triggered when awareness changes
-        io
-          .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
-          .emit(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSize);
-
-        // Triggered when the last user leaves the editor
-        if (awarenessStateSize === 0) {
-          const hasYdocsNewerThanLatestRevision = await this.hasYdocsNewerThanLatestRevision(pageId);
-          io
-            .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
-            .emit(SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated, hasYdocsNewerThanLatestRevision);
-        }
-      });
-
-      socket.on(GlobalSocketEventName.YDocSync, async({ pageId, initialValue }) => {
-        try {
-          await this.handleYDocSync(pageId, initialValue);
-        }
-        catch (error) {
-          logger.warn(error.message);
-          socket.emit(GlobalSocketEventName.YDocSyncError, 'An error occurred during YDoc synchronization.');
-        }
-      });
-    });
-  }
-
-  private async createIndexes(): Promise<void> {
-
-    const collection = mongoose.connection.collection(MONGODB_PERSISTENCE_COLLECTION_NAME);
-
-    try {
-      await collection.createIndexes([
-        {
-          key: {
-            version: 1,
-            docName: 1,
-            action: 1,
-            clock: 1,
-            part: 1,
-          },
-        },
-        // for metaKey
-        {
-          key: {
-            version: 1,
-            docName: 1,
-            metaKey: 1,
-          },
-        },
-        // for flushDocument / clearDocument
-        {
-          key: {
-            docName: 1,
-            clock: 1,
-          },
-        },
-      ]);
-    }
-    catch (err) {
-      logger.error('Failed to create Index', err);
-      throw err;
-    }
-  }
-
-  public async hasYdocsNewerThanLatestRevision(pageId: string): Promise<boolean> {
-    // get the latest revision createdAt
-    const Revision = mongoose.model<IRevisionHasId>('Revision');
-    const result = await Revision
-      .findOne(
-        // filter
-        { pageId },
-        // projection
-        { createdAt: 1 },
-        { sort: { createdAt: -1 } },
-      );
-
-    const lastRevisionCreatedAt = (result == null)
-      ? 0
-      : result.createdAt.getTime();
-
-    // count yjs-writings documents with updatedAt > latestRevision.updatedAt
-    const ydocUpdatedAt: number | undefined = await this.mdb.getMeta(pageId, 'updatedAt');
-
-    return ydocUpdatedAt == null
-      ? false
-      : ydocUpdatedAt > lastRevisionCreatedAt;
-  }
-
-  public async handleYDocSync(pageId: string, initialValue: string): Promise<void> {
-    const currentYdoc = this.getCurrentYdoc(pageId);
-    if (currentYdoc == null) {
-      return;
-    }
-
-    const persistedYdoc = await this.getPersistedYdoc(pageId);
-    const persistedStateVector = Y.encodeStateVector(persistedYdoc);
-
-    await this.mdb.flushDocument(pageId);
-
-    // If no write operation has been performed, insert initial value
-    const clientsSize = persistedYdoc.store.clients.size;
-    if (clientsSize === 0) {
-      currentYdoc.getText('codemirror').insert(0, initialValue);
-    }
-
-    const diff = Y.encodeStateAsUpdate(currentYdoc, persistedStateVector);
-
-    if (diff.reduce((prev, curr) => prev + curr, 0) > 0) {
-      this.mdb.storeUpdate(pageId, diff);
-      this.mdb.setMeta(pageId, 'updatedAt', Date.now());
-    }
-
-    Y.applyUpdate(currentYdoc, Y.encodeStateAsUpdate(persistedYdoc));
-
-    currentYdoc.on('update', async(update) => {
-      this.mdb.storeUpdate(pageId, update);
-      this.mdb.setMeta(pageId, 'updatedAt', Date.now());
-    });
-
-    currentYdoc.on('destroy', async() => {
-      this.mdb.flushDocument(pageId);
-    });
-
-    persistedYdoc.destroy();
-  }
-
-  public async handleYDocUpdate(pageId: string, newValue: string): Promise<void> {
-    // TODO: https://redmine.weseek.co.jp/issues/132775
-    // It's necessary to confirm that the user is not editing the target page in the Editor
-    const currentYdoc = this.getCurrentYdoc(pageId);
-    if (currentYdoc == null) {
-      return;
-    }
-
-    const currentMarkdownLength = currentYdoc.getText('codemirror').length;
-    currentYdoc.getText('codemirror').delete(0, currentMarkdownLength);
-    currentYdoc.getText('codemirror').insert(0, newValue);
-    Y.encodeStateAsUpdate(currentYdoc);
-  }
-
-  public getCurrentYdoc(pageId: string): Ydoc | undefined {
-    const currentYdoc = this.ysocketio.documents.get(`yjs/${pageId}`);
-    return currentYdoc;
-  }
-
-  public async getPersistedYdoc(pageId: string): Promise<Y.Doc> {
-    const persistedYdoc = await this.mdb.getYDoc(pageId);
-    return persistedYdoc;
-  }
-
-}
-
-let _instance: YjsService;
-
-export const initializeYjsService = (io: Server): void => {
-  if (_instance != null) {
-    throw new Error('YjsService is already initialized');
-  }
-
-  if (io == null) {
-    throw new Error("'io' is required if initialize YjsService");
-  }
-
-  _instance = new YjsService(io);
-};
-
-export const getYjsService = (): YjsService => {
-  if (_instance == null) {
-    throw new Error('YjsService is not initialized yet');
-  }
-
-  return _instance;
-};

+ 43 - 0
apps/app/src/server/service/yjs/create-indexes.ts

@@ -0,0 +1,43 @@
+import mongoose from 'mongoose';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:yjs:create-indexes');
+
+export const createIndexes = async(collectionName: string): Promise<void> => {
+
+  const collection = mongoose.connection.collection(collectionName);
+
+  try {
+    await collection.createIndexes([
+      {
+        key: {
+          version: 1,
+          docName: 1,
+          action: 1,
+          clock: 1,
+          part: 1,
+        },
+      },
+      // for metaKey
+      {
+        key: {
+          version: 1,
+          docName: 1,
+          metaKey: 1,
+        },
+      },
+      // for flushDocument / clearDocument
+      {
+        key: {
+          docName: 1,
+          clock: 1,
+        },
+      },
+    ]);
+  }
+  catch (err) {
+    logger.error('Failed to create Index', err);
+    throw err;
+  }
+};

+ 55 - 0
apps/app/src/server/service/yjs/create-mongodb-persistence.ts

@@ -0,0 +1,55 @@
+import type { Persistence } from 'y-socket.io/dist/server';
+import * as Y from 'yjs';
+
+import loggerFactory from '~/utils/logger';
+
+import type { MongodbPersistence } from './extended/mongodb-persistence';
+
+const logger = loggerFactory('growi:service:yjs:create-mongodb-persistence');
+
+/**
+ * Based on the example by https://github.com/MaxNoetzold/y-mongodb-provider?tab=readme-ov-file#an-other-example
+ * @param mdb
+ * @returns
+ */
+export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence => {
+  const persistece: Persistence = {
+    provider: mdb,
+    bindState: async(docName, ydoc) => {
+      logger.debug('bindState', { docName });
+
+      const persistedYdoc = await mdb.getYDoc(docName);
+
+      // get the state vector so we can just store the diffs between client and server
+      const persistedStateVector = Y.encodeStateVector(persistedYdoc);
+      const diff = Y.encodeStateAsUpdate(ydoc, persistedStateVector);
+
+      // store the new data in db (if there is any: empty update is an array of 0s)
+      if (diff.reduce((previousValue, currentValue) => previousValue + currentValue, 0) > 0) {
+        mdb.storeUpdate(docName, diff);
+        mdb.setTypedMeta(docName, 'updatedAt', Date.now());
+      }
+
+      // send the persisted data to clients
+      Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc));
+
+      // store updates of the document in db
+      ydoc.on('update', async(update) => {
+        mdb.storeUpdate(docName, update);
+        mdb.setTypedMeta(docName, 'updatedAt', Date.now());
+      });
+
+      // cleanup some memory
+      persistedYdoc.destroy();
+    },
+    writeState: async(docName) => {
+      logger.debug('writeState', { docName });
+      // This is called when all connections to the document are closed.
+
+      // flush document on close to have the smallest possible database
+      await mdb.flushDocument(docName);
+    },
+  };
+
+  return persistece;
+};

+ 19 - 0
apps/app/src/server/service/yjs/extended/mongodb-persistence.ts

@@ -0,0 +1,19 @@
+import { MongodbPersistence as Original } from 'y-mongodb-provider';
+
+export type MetadataTypesMap = {
+  updatedAt: number,
+}
+type MetadataKeys = keyof MetadataTypesMap;
+
+
+export class MongodbPersistence extends Original {
+
+  async setTypedMeta<K extends MetadataKeys>(docName: string, key: K, value: MetadataTypesMap[K]): Promise<void> {
+    return this.setMeta(docName, key, value);
+  }
+
+  async getTypedMeta<K extends MetadataKeys>(docName: string, key: K): Promise<MetadataTypesMap[K] | undefined> {
+    return await this.getMeta(docName, key) as MetadataTypesMap[K] | undefined;
+  }
+
+}

+ 1 - 0
apps/app/src/server/service/yjs/index.ts

@@ -0,0 +1 @@
+export * from './yjs';

+ 81 - 0
apps/app/src/server/service/yjs/sync-ydoc.ts

@@ -0,0 +1,81 @@
+import { Origin, YDocStatus } from '@growi/core';
+import type { Document } from 'y-socket.io/dist/server';
+
+import loggerFactory from '~/utils/logger';
+
+import { Revision } from '../../models/revision';
+
+import type { MongodbPersistence } from './extended/mongodb-persistence';
+
+const logger = loggerFactory('growi:service:yjs:sync-ydoc');
+
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type Delta = Array<{insert?:Array<any>|string, delete?:number, retain?:number}>;
+
+type Context = {
+  ydocStatus: YDocStatus,
+}
+
+/**
+ * Sync the text and the meta data with the latest revision body
+ * @param mdb
+ * @param doc
+ * @param context true to force sync
+ */
+export const syncYDoc = async(mdb: MongodbPersistence, doc: Document, context: true | Context): Promise<void> => {
+  const pageId = doc.name;
+
+  const revision = await Revision
+    .findOne(
+      // filter
+      { pageId },
+      // projection
+      { body: 1, createdAt: 1, origin: 1 },
+      // options
+      { sort: { createdAt: -1 } },
+    )
+    .lean();
+
+  if (revision == null) {
+    logger.warn(`Synchronization has been canceled since the revision of the page ('${pageId}') could not be found`);
+    return;
+  }
+
+  const shouldSync = context === true
+    || (() => {
+      switch (context.ydocStatus) {
+        case YDocStatus.NEW:
+          return true;
+        case YDocStatus.OUTDATED:
+          // should skip when the YDoc is outdated and the latest revision is created by the editor
+          return revision.origin !== Origin.Editor;
+        default:
+          return false;
+      }
+    })();
+
+  if (shouldSync) {
+    logger.debug(`YDoc for the page ('${pageId}') is synced with the latest revision body`);
+
+    const ytext = doc.getText('codemirror');
+    const delta: Delta = [];
+
+    if (ytext.length > 0) {
+      delta.push({ delete: ytext.length });
+    }
+    if (revision.body != null) {
+      delta.push({ insert: revision.body });
+    }
+
+    ytext.applyDelta(delta, { sanitize: false });
+  }
+
+  const shouldSyncMeta = context === true
+    || context.ydocStatus === YDocStatus.NEW
+    || context.ydocStatus === YDocStatus.OUTDATED;
+
+  if (shouldSyncMeta) {
+    mdb.setMeta(doc.name, 'updatedAt', revision.createdAt.getTime() ?? Date.now());
+  }
+};

+ 44 - 22
apps/app/src/server/service/yjs.integ.ts → apps/app/src/server/service/yjs/yjs.integ.ts

@@ -1,16 +1,18 @@
+import { YDocStatus } from '@growi/core/dist/consts';
 import { Types } from 'mongoose';
 import type { Server } from 'socket.io';
 import { mock } from 'vitest-mock-extended';
-import type { MongodbPersistence } from 'y-mongodb-provider';
 
-import { Revision } from '../models/revision';
+import { Revision } from '../../models/revision';
 
+import type { MongodbPersistence } from './extended/mongodb-persistence';
 import type { IYjsService } from './yjs';
 import { getYjsService, initializeYjsService } from './yjs';
 
 
 vi.mock('y-socket.io/dist/server', () => {
   const YSocketIO = vi.fn();
+  YSocketIO.prototype.on = vi.fn();
   YSocketIO.prototype.initialize = vi.fn();
   return { YSocketIO };
 });
@@ -26,7 +28,7 @@ const getPrivateMdbInstance = (yjsService: IYjsService): MongodbPersistence => {
 
 describe('YjsService', () => {
 
-  describe('hasYdocsNewerThanLatestRevision()', () => {
+  describe('getYDocStatus()', () => {
 
     beforeAll(async() => {
       const ioMock = mock<Server>();
@@ -45,36 +47,53 @@ describe('YjsService', () => {
       await privateMdb.flushDB();
     });
 
-    it('returns false when neither revisions nor YDocs exists', async() => {
+    it('returns ISOLATED when neither revisions nor YDocs exists', async() => {
       // arrange
       const yjsService = getYjsService();
 
       const pageId = new ObjectId();
 
       // act
-      const result = await yjsService.hasYdocsNewerThanLatestRevision(pageId.toString());
+      const result = await yjsService.getYDocStatus(pageId.toString());
 
       // assert
-      expect(result).toBe(false);
+      expect(result).toBe(YDocStatus.ISOLATED);
     });
 
-    it('returns true when no revisions exist', async() => {
+    it('returns ISOLATED when no revisions exist', async() => {
       // arrange
       const yjsService = getYjsService();
 
       const pageId = new ObjectId();
 
       const privateMdb = getPrivateMdbInstance(yjsService);
-      await privateMdb.setMeta(pageId.toString(), 'updatedAt', 1000);
+      await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', 1000);
 
       // act
-      const result = await yjsService.hasYdocsNewerThanLatestRevision(pageId.toString());
+      const result = await yjsService.getYDocStatus(pageId.toString());
 
       // assert
-      expect(result).toBe(true);
+      expect(result).toBe(YDocStatus.ISOLATED);
     });
 
-    it('returns false when the latest revision is newer than meta data', async() => {
+    it('returns NEW when no YDocs exist', async() => {
+      // arrange
+      const yjsService = getYjsService();
+
+      const pageId = new ObjectId();
+
+      await Revision.insertMany([
+        { pageId, body: '' },
+      ]);
+
+      // act
+      const result = await yjsService.getYDocStatus(pageId.toString());
+
+      // assert
+      expect(result).toBe(YDocStatus.NEW);
+    });
+
+    it('returns DRAFT when the newer YDocs exist', async() => {
       // arrange
       const yjsService = getYjsService();
 
@@ -85,33 +104,36 @@ describe('YjsService', () => {
       ]);
 
       const privateMdb = getPrivateMdbInstance(yjsService);
-      await privateMdb.setMeta(pageId.toString(), 'updatedAt', (new Date(2024, 1, 1)).getTime());
+      await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2034, 1, 1)).getTime());
 
       // act
-      const result = await yjsService.hasYdocsNewerThanLatestRevision(pageId.toString());
+      const result = await yjsService.getYDocStatus(pageId.toString());
 
       // assert
-      expect(result).toBe(false);
+      expect(result).toBe(YDocStatus.DRAFT);
     });
 
-    it('returns false when no YDocs exist', async() => {
+    it('returns SYNCED', async() => {
       // arrange
       const yjsService = getYjsService();
 
       const pageId = new ObjectId();
 
       await Revision.insertMany([
-        { pageId, body: '' },
+        { pageId, body: '', createdAt: new Date(2025, 1, 1) },
       ]);
 
+      const privateMdb = getPrivateMdbInstance(yjsService);
+      await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2025, 1, 1)).getTime());
+
       // act
-      const result = await yjsService.hasYdocsNewerThanLatestRevision(pageId.toString());
+      const result = await yjsService.getYDocStatus(pageId.toString());
 
       // assert
-      expect(result).toBe(false);
+      expect(result).toBe(YDocStatus.SYNCED);
     });
 
-    it('returns true when the newer YDocs exist', async() => {
+    it('returns OUTDATED when the latest revision is newer than meta data', async() => {
       // arrange
       const yjsService = getYjsService();
 
@@ -122,13 +144,13 @@ describe('YjsService', () => {
       ]);
 
       const privateMdb = getPrivateMdbInstance(yjsService);
-      await privateMdb.setMeta(pageId.toString(), 'updatedAt', (new Date(2034, 1, 1)).getTime());
+      await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2024, 1, 1)).getTime());
 
       // act
-      const result = await yjsService.hasYdocsNewerThanLatestRevision(pageId.toString());
+      const result = await yjsService.getYDocStatus(pageId.toString());
 
       // assert
-      expect(result).toBe(true);
+      expect(result).toBe(YDocStatus.OUTDATED);
     });
 
   });

+ 220 - 0
apps/app/src/server/service/yjs/yjs.ts

@@ -0,0 +1,220 @@
+import type { IncomingMessage } from 'http';
+
+import type { IPage, IUserHasId } from '@growi/core';
+import { YDocStatus } from '@growi/core/dist/consts';
+import mongoose from 'mongoose';
+import type { Server } from 'socket.io';
+import type { Document } from 'y-socket.io/dist/server';
+import { YSocketIO, type Document as Ydoc } from 'y-socket.io/dist/server';
+
+import { SocketEventName } from '~/interfaces/websocket';
+import { RoomPrefix, getRoomNameWithId } from '~/server/util/socket-io-helpers';
+import loggerFactory from '~/utils/logger';
+
+import type { PageModel } from '../../models/page';
+import { Revision } from '../../models/revision';
+
+import { createIndexes } from './create-indexes';
+import { createMongoDBPersistence } from './create-mongodb-persistence';
+import { MongodbPersistence } from './extended/mongodb-persistence';
+import { syncYDoc } from './sync-ydoc';
+
+
+const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
+const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
+
+
+const logger = loggerFactory('growi:service:yjs');
+
+
+type RequestWithUser = IncomingMessage & { user: IUserHasId };
+
+
+export interface IYjsService {
+  getYDocStatus(pageId: string): Promise<YDocStatus>;
+  syncWithTheLatestRevisionForce(pageId: string): Promise<void>;
+  getCurrentYdoc(pageId: string): Ydoc | undefined;
+}
+
+
+class YjsService implements IYjsService {
+
+  private ysocketio: YSocketIO;
+
+  private mdb: MongodbPersistence;
+
+  constructor(io: Server) {
+
+    const mdb = new MongodbPersistence(
+      // ignore TS2345: Argument of type '{ client: any; db: any; }' is not assignable to parameter of type 'string'.
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      {
+        // TODO: Required upgrading mongoose and unifying the versions of mongodb to omit 'as any'
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        client: mongoose.connection.getClient() as any,
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        db: mongoose.connection.db as any,
+      },
+      {
+        collectionName: MONGODB_PERSISTENCE_COLLECTION_NAME,
+        flushSize: MONGODB_PERSISTENCE_FLUSH_SIZE,
+      },
+    );
+    this.mdb = mdb;
+
+    // initialize YSocketIO
+    const ysocketio = new YSocketIO(io);
+    this.injectPersistence(ysocketio, mdb);
+    ysocketio.initialize();
+    this.ysocketio = ysocketio;
+
+    // create indexes
+    createIndexes(MONGODB_PERSISTENCE_COLLECTION_NAME);
+
+    // register middlewares
+    this.registerAccessiblePageChecker(ysocketio);
+
+    ysocketio.on('document-loaded', async(doc: Document) => {
+      const pageId = doc.name;
+
+      const ydocStatus = await this.getYDocStatus(pageId);
+
+      syncYDoc(mdb, doc, { ydocStatus });
+    });
+
+    ysocketio.on('awareness-update', async(doc: Document) => {
+      const pageId = doc.name;
+
+      if (pageId == null) return;
+
+      const awarenessStateSize = doc.awareness.states.size;
+
+      // Triggered when awareness changes
+      io
+        .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
+        .emit(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSize);
+
+      // Triggered when the last user leaves the editor
+      if (awarenessStateSize === 0) {
+        const ydocStatus = await this.getYDocStatus(pageId);
+        const hasYdocsNewerThanLatestRevision = ydocStatus === YDocStatus.DRAFT || ydocStatus === YDocStatus.ISOLATED;
+
+        io
+          .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
+          .emit(SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated, hasYdocsNewerThanLatestRevision);
+      }
+    });
+
+  }
+
+  private injectPersistence(ysocketio: YSocketIO, mdb: MongodbPersistence): void {
+    const persistece = createMongoDBPersistence(mdb);
+
+    // foce set to private property
+    // eslint-disable-next-line dot-notation
+    ysocketio['persistence'] = persistece;
+  }
+
+  private registerAccessiblePageChecker(ysocketio: YSocketIO): void {
+    // check accessible page
+    ysocketio.nsp?.use(async(socket, next) => {
+      // extract page id from namespace
+      const pageId = socket.nsp.name.replace(/\/yjs\|/, '');
+      const user = (socket.request as RequestWithUser).user; // should be injected by SocketIOService
+
+      const Page = mongoose.model<IPage, PageModel>('Page');
+      const isAccessible = await Page.isAccessiblePageByViewer(pageId, user);
+
+      if (!isAccessible) {
+        return next(new Error('Forbidden'));
+      }
+
+      return next();
+    });
+  }
+
+  public async getYDocStatus(pageId: string): Promise<YDocStatus> {
+    const dumpLog = (status: YDocStatus, args?: { [key: string]: number }) => {
+      logger.debug(`getYDocStatus('${pageId}') detected '${status}'`, args ?? {});
+    };
+
+    // get the latest revision createdAt
+    const result = await Revision
+      .findOne(
+        // filter
+        { pageId },
+        // projection
+        { createdAt: 1 },
+        { sort: { createdAt: -1 } },
+      )
+      .lean();
+
+    if (result == null) {
+      dumpLog(YDocStatus.ISOLATED);
+      return YDocStatus.ISOLATED;
+    }
+
+    // count yjs-writings documents with updatedAt > latestRevision.updatedAt
+    const ydocUpdatedAt = await this.mdb.getTypedMeta(pageId, 'updatedAt');
+
+    if (ydocUpdatedAt == null) {
+      dumpLog(YDocStatus.NEW);
+      return YDocStatus.NEW;
+    }
+
+    const { createdAt } = result;
+    const lastRevisionCreatedAt = createdAt.getTime();
+
+    if (lastRevisionCreatedAt < ydocUpdatedAt) {
+      dumpLog(YDocStatus.DRAFT, { lastRevisionCreatedAt, ydocUpdatedAt });
+      return YDocStatus.DRAFT;
+    }
+
+    if (lastRevisionCreatedAt === ydocUpdatedAt) {
+      dumpLog(YDocStatus.SYNCED, { lastRevisionCreatedAt, ydocUpdatedAt });
+      return YDocStatus.SYNCED;
+    }
+
+    dumpLog(YDocStatus.OUTDATED, { lastRevisionCreatedAt, ydocUpdatedAt });
+    return YDocStatus.OUTDATED;
+  }
+
+  public async syncWithTheLatestRevisionForce(pageId: string): Promise<void> {
+    const doc = this.ysocketio.documents.get(pageId);
+
+    if (doc == null) {
+      return;
+    }
+
+    syncYDoc(this.mdb, doc, true);
+  }
+
+  public getCurrentYdoc(pageId: string): Ydoc | undefined {
+    const currentYdoc = this.ysocketio.documents.get(pageId);
+    return currentYdoc;
+  }
+
+}
+
+let _instance: YjsService;
+
+export const initializeYjsService = (io: Server): void => {
+  if (_instance != null) {
+    throw new Error('YjsService is already initialized');
+  }
+
+  if (io == null) {
+    throw new Error("'io' is required if initialize YjsService");
+  }
+
+  _instance = new YjsService(io);
+};
+
+export const getYjsService = (): YjsService => {
+  if (_instance == null) {
+    throw new Error('YjsService is not initialized yet');
+  }
+
+  return _instance;
+};

+ 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,
+  };
 };

+ 12 - 3
apps/app/src/stores/yjs.ts

@@ -15,7 +15,13 @@ type CurrentPageYjsDataUtils = {
 }
 
 export const useCurrentPageYjsData = (): SWRResponse<CurrentPageYjsData, Error> & CurrentPageYjsDataUtils => {
-  const swrResponse = useSWRStatic<CurrentPageYjsData, Error>('currentPageYjsData', undefined);
+  const { data: currentPageId } = useCurrentPageId();
+
+  const key = currentPageId != null
+    ? `/page/${currentPageId}/yjs-data`
+    : null;
+
+  const swrResponse = useSWRStatic<CurrentPageYjsData, Error>(key, undefined);
 
   const updateHasYdocsNewerThanLatestRevision = useCallback((hasYdocsNewerThanLatestRevision: boolean) => {
     swrResponse.mutate({ ...swrResponse.data, hasYdocsNewerThanLatestRevision });
@@ -29,12 +35,15 @@ export const useCurrentPageYjsData = (): SWRResponse<CurrentPageYjsData, Error>
 };
 
 export const useSWRMUTxCurrentPageYjsData = (): SWRMutationResponse<CurrentPageYjsData, Error> => {
-  const key = 'currentPageYjsData';
   const { data: currentPageId } = useCurrentPageId();
 
+  const key = currentPageId != null
+    ? `/page/${currentPageId}/yjs-data`
+    : null;
+
   return useSWRMutation(
     key,
-    () => apiv3Get<{ yjsData: CurrentPageYjsData }>(`/page/${currentPageId}/yjs-data`).then(result => result.data.yjsData),
+    ([endpoint]) => apiv3Get<{ yjsData: CurrentPageYjsData }>(endpoint).then(result => result.data.yjsData),
     { populateCache: true, revalidate: false },
   );
 };

+ 1 - 0
packages/core/src/consts/index.ts

@@ -1,2 +1,3 @@
 export * from './accepted-upload-file-type';
 export * from './growi-plugin';
+export * from './ydoc-status';

+ 15 - 0
packages/core/src/consts/ydoc-status.ts

@@ -0,0 +1,15 @@
+/**
+ * NEW: The document is newly created and not yet synced with the latest revision.
+ * SYNCED: The document is synced with the latest revision.
+ * DRAFT: The document advances as a draft compared to the latest revision
+ * OUTDATED: The document is outdated and needs to be synced with the latest revision.
+ * ISOLATED: The latest revision does not exist and the document is isolated from the page.
+ */
+export const YDocStatus = {
+  NEW: 'new',
+  SYNCED: 'synced',
+  DRAFT: 'draft',
+  OUTDATED: 'outdated',
+  ISOLATED: 'isolated',
+} as const;
+export type YDocStatus = typeof YDocStatus[keyof typeof YDocStatus]

+ 0 - 1
packages/core/src/interfaces/index.ts

@@ -13,4 +13,3 @@ export * from './subscription';
 export * from './tag';
 export * from './user';
 export * from './vite';
-export * from './websocket';

+ 0 - 6
packages/core/src/interfaces/websocket.ts

@@ -1,6 +0,0 @@
-export const GlobalSocketEventName = {
-  // YDoc
-  YDocSync: 'ydoc:sync',
-  YDocSyncError: 'ydoc:sync:error',
-} as const;
-export type GlobalSocketEventName = typeof GlobalSocketEventName[keyof typeof GlobalSocketEventName];

+ 1 - 0
packages/editor/package.json

@@ -18,6 +18,7 @@
     "lint": "npm-run-all -p lint:*"
   },
   "dependencies": {
+    "lib0": "^0.2.94",
     "markdown-table": "^3.0.3",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"

+ 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]);
 
 };

+ 12 - 11
packages/editor/src/client/stores/use-collaborative-editor-mode.ts

@@ -1,8 +1,8 @@
 import { useEffect, useState } from 'react';
 
 import { keymap } from '@codemirror/view';
-import { GlobalSocketEventName, type IUserHasId } from '@growi/core/dist/interfaces';
-import { useGlobalSocket, GLOBAL_SOCKET_NS } from '@growi/core/dist/swr';
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { useGlobalSocket } from '@growi/core/dist/swr';
 import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next';
 import { SocketIOProvider } from 'y-socket.io';
 import * as Y from 'yjs';
@@ -44,9 +44,6 @@ export const useCollaborativeEditorMode = (
     // so only awareness is destroyed here
     provider?.awareness.destroy();
 
-    // TODO: catch ydoc:sync:error GlobalSocketEventName.YDocSyncError
-    socket?.off(GlobalSocketEventName.YDocSync);
-
     setCPageId(pageId);
 
     // reset editors
@@ -70,15 +67,18 @@ export const useCollaborativeEditorMode = (
 
   // Setup provider
   useEffect(() => {
-    if (provider != null || ydoc == null || socket == null || onEditorsUpdated == null) {
+    if (provider != null || pageId == null || ydoc == null || socket == null || onEditorsUpdated == null) {
       return;
     }
 
     const socketIOProvider = new SocketIOProvider(
-      GLOBAL_SOCKET_NS,
-      `yjs/${pageId}`,
+      '/',
+      pageId,
       ydoc,
-      { autoConnect: true },
+      {
+        autoConnect: true,
+        resyncInterval: 3000,
+      },
     );
 
     const userLocalState: UserLocalState = {
@@ -92,14 +92,13 @@ export const useCollaborativeEditorMode = (
 
     socketIOProvider.on('sync', (isSync: boolean) => {
       if (isSync) {
-        socket.emit(GlobalSocketEventName.YDocSync, { pageId, initialValue });
         const userList: IUserHasId[] = Array.from(socketIOProvider.awareness.states.values(), value => value.user.user && value.user.user);
         onEditorsUpdated(userList);
       }
     });
 
     // update args type see: SocketIOProvider.Awareness.awarenessUpdate
-    socketIOProvider.awareness.on('update', (update: any) => {
+    socketIOProvider.awareness.on('update', (update: { added: unknown[]; removed: unknown[]; }) => {
       const { added, removed } = update;
       if (added.length > 0 || removed.length > 0) {
         const userList: IUserHasId[] = Array.from(socketIOProvider.awareness.states.values(), value => value.user.user && value.user.user);
@@ -131,6 +130,8 @@ export const useCollaborativeEditorMode = (
     return () => {
       cleanupYUndoManagerKeymap?.();
       cleanupYCollab?.();
+      // clean up editor
+      codeMirrorEditor.initDoc('');
     };
   }, [codeMirrorEditor, provider, ydoc]);
 };

+ 27 - 17
yarn.lock

@@ -2148,6 +2148,7 @@
 "@growi/editor@link:packages/editor":
   version "1.0.0"
   dependencies:
+    lib0 "^0.2.94"
     markdown-table "^3.0.3"
     react "^18.2.0"
     react-dom "^18.2.0"
@@ -4322,22 +4323,23 @@
   resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
   integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
 
-"@types/express-serve-static-core@^4.17.18":
-  version "4.17.19"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.19.tgz#00acfc1632e729acac4f1530e9e16f6dd1508a1d"
-  integrity sha512-DJOSHzX7pCiSElWaGR8kCprwibCB/3yW6vcT8VG3P0SJjnv19gnWG/AZMfM60Xj/YJIp/YCaDHyvzsFVeniARA==
+"@types/express-serve-static-core@^4.17.33":
+  version "4.19.5"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz#218064e321126fcf9048d1ca25dd2465da55d9c6"
+  integrity sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
     "@types/range-parser" "*"
+    "@types/send" "*"
 
-"@types/express@*", "@types/express@^4.17.11":
-  version "4.17.11"
-  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.11.tgz#debe3caa6f8e5fcda96b47bd54e2f40c4ee59545"
-  integrity sha512-no+R6rW60JEc59977wIxreQVsIEOAYwgCqldrA/vkpCnbD7MqTefO97lmoBe4WE0F156bC4uLSP1XHDOySnChg==
+"@types/express@*", "@types/express@^4.17.11", "@types/express@^4.17.21":
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d"
+  integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==
   dependencies:
     "@types/body-parser" "*"
-    "@types/express-serve-static-core" "^4.17.18"
+    "@types/express-serve-static-core" "^4.17.33"
     "@types/qs" "*"
     "@types/serve-static" "*"
 
@@ -4656,6 +4658,14 @@
   resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e"
   integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==
 
+"@types/send@*":
+  version "0.17.4"
+  resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a"
+  integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==
+  dependencies:
+    "@types/mime" "^1"
+    "@types/node" "*"
+
 "@types/serve-static@*":
   version "1.13.9"
   resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.9.tgz#aacf28a85a05ee29a11fb7c3ead935ac56f33e4e"
@@ -13900,10 +13910,10 @@ no-case@^3.0.4:
     lower-case "^2.0.2"
     tslib "^2.0.3"
 
-nocache@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/nocache/-/nocache-3.0.1.tgz#54d8b53a7e0a0aa1a288cfceab8a3cefbcde67d4"
-  integrity sha512-Gh39xwJwBKy0OvFmWfBs/vDO4Nl7JhnJtkqNP76OUinQz7BiMoszHYrIDHHAaqVl/QKVxCEy4ZxC/XZninu7nQ==
+nocache@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/nocache/-/nocache-4.0.0.tgz#d6c6f34bee4600f63f852dccfeb29c9a96cf85c0"
+  integrity sha512-AntnTbmKZvNYIsTVPPwv7dfZdAfo/6H/2ZlZACK66NAOQtIApxkB/6pf/c+s+ACW8vemGJzUCyVTssrzNUK6yQ==
 
 node-cron@^3.0.2:
   version "3.0.2"
@@ -13983,10 +13993,10 @@ nodemailer-ses-transport@~1.5.0:
   dependencies:
     aws-sdk "^2.2.36"
 
-nodemailer@^6.6.2:
-  version "6.6.2"
-  resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.6.2.tgz#e184c9ed5bee245a3e0bcabc7255866385757114"
-  integrity sha512-YSzu7TLbI+bsjCis/TZlAXBoM4y93HhlIgo0P5oiA2ua9Z4k+E2Fod//ybIzdJxOlXGRcHIh/WaeCBehvxZb/Q==
+nodemailer@^6.9.14:
+  version "6.9.14"
+  resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.9.14.tgz#845fda981f9fd5ac264f4446af908a7c78027f75"
+  integrity sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==
 
 nodemon@^3.1.3:
   version "3.1.3"