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

Merge branch 'master' into support/use-jotai

Yuki Takei 5 месяцев назад
Родитель
Сommit
2e4fa42794
100 измененных файлов с 861 добавлено и 475 удалено
  1. 4 0
      .vscode/settings.json
  2. 0 4
      apps/app/.eslintrc.js
  3. 3 1
      apps/app/bin/print-memory-consumption.ts
  4. 1 1
      apps/app/src/client/services/user-ui-settings.ts
  5. 1 1
      apps/app/src/client/util/apiv3-client.ts
  6. 0 12
      apps/app/src/components/.eslintrc.js
  7. 1 1
      apps/app/src/features/collaborative-editor/states/current-page-yjs-data.ts
  8. 4 2
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx
  9. 1 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx
  10. 3 1
      apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginCard.tsx
  11. 2 2
      apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginDeleteModal.tsx
  12. 1 1
      apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginInstallerForm.tsx
  13. 1 1
      apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  14. 1 1
      apps/app/src/features/growi-plugin/client/Admin/states/modal/plugin-delete.ts
  15. 1 1
      apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts
  16. 1 1
      apps/app/src/features/mermaid/components/MermaidViewer.tsx
  17. 1 1
      apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx
  18. 1 1
      apps/app/src/features/rate-limiter/utils/config-generator.ts
  19. 2 2
      apps/app/src/features/search/client/components/PrivateLegacyPages.tsx
  20. 1 1
      apps/app/src/features/search/client/components/SearchHelp.tsx
  21. 1 1
      apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx
  22. 2 2
      apps/app/src/features/search/client/components/SearchModal.tsx
  23. 2 4
      apps/app/src/features/search/client/components/SearchPage/SearchControl.tsx
  24. 1 1
      apps/app/src/features/search/client/components/SearchPage/SearchOptionModal.tsx
  25. 2 3
      apps/app/src/features/search/client/components/SearchPage/SearchPage.tsx
  26. 8 8
      apps/app/src/features/search/client/components/SearchPage/SearchPageBase.tsx
  27. 3 3
      apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx
  28. 8 4
      apps/app/src/features/search/client/components/SearchPage/SearchResultList.tsx
  29. 1 1
      apps/app/src/features/search/client/components/SearchPage/SortControl.tsx
  30. 1 1
      apps/app/src/features/search/client/components/SearchResultMenuItem.tsx
  31. 1 1
      apps/app/src/features/search/client/states/modal/search.ts
  32. 1 1
      apps/app/src/interfaces/ui.ts
  33. 0 12
      apps/app/src/models/.eslintrc.js
  34. 0 12
      apps/app/src/pages/.eslintrc.js
  35. 0 12
      apps/app/src/server/.eslintrc.js
  36. 6 3
      apps/app/src/server/crowi/express-init.js
  37. 42 0
      apps/app/src/server/middlewares/certify-origin.ts
  38. 2 1
      apps/app/src/server/routes/index.js
  39. 1 1
      apps/app/src/server/routes/ogp.ts
  40. 1 1
      apps/app/src/server/service/file-uploader/gcs/multipart-uploader.ts
  41. 1 2
      apps/app/src/server/service/g2g-transfer.ts
  42. 1 1
      apps/app/src/server/service/s2s-messaging/nchan.ts
  43. 12 0
      apps/app/src/server/util/createApiRouter.ts
  44. 200 0
      apps/app/src/server/util/is-simple-request.spec.ts
  45. 65 0
      apps/app/src/server/util/is-simple-request.ts
  46. 0 12
      apps/app/src/services/.eslintrc.js
  47. 1 1
      apps/app/src/states/page/hooks.ts
  48. 3 4
      apps/app/src/states/page/use-fetch-current-page.spec.tsx
  49. 1 1
      apps/app/src/states/page/use-fetch-current-page.ts
  50. 1 1
      apps/app/src/states/page/use-set-remote-latest-page-data.ts
  51. 2 2
      apps/app/src/states/search/keyword-manager.ts
  52. 1 1
      apps/app/src/states/socket-io/global-socket.ts
  53. 1 1
      apps/app/src/states/ui/device.ts
  54. 1 1
      apps/app/src/states/ui/editor/editor-mode.ts
  55. 1 1
      apps/app/src/states/ui/editor/reserved-next-caret-line.ts
  56. 1 1
      apps/app/src/states/ui/modal/delete-attachment.ts
  57. 1 1
      apps/app/src/states/ui/modal/delete-bookmark-folder.ts
  58. 1 1
      apps/app/src/states/ui/modal/empty-trash.ts
  59. 1 1
      apps/app/src/states/ui/modal/page-accessories.ts
  60. 1 1
      apps/app/src/states/ui/modal/page-create.ts
  61. 1 1
      apps/app/src/states/ui/modal/page-delete.ts
  62. 1 1
      apps/app/src/states/ui/modal/page-duplicate.ts
  63. 1 1
      apps/app/src/states/ui/modal/page-presentation.ts
  64. 1 1
      apps/app/src/states/ui/modal/page-rename.ts
  65. 1 1
      apps/app/src/states/ui/modal/page-select.ts
  66. 1 1
      apps/app/src/states/ui/modal/page-status-alert.ts
  67. 1 1
      apps/app/src/states/ui/modal/put-back-page.ts
  68. 1 1
      apps/app/src/states/ui/modal/shortcuts.ts
  69. 1 1
      apps/app/src/states/ui/modal/tag-edit.ts
  70. 1 1
      apps/app/src/states/ui/modal/update-user-group-confirm.ts
  71. 1 1
      apps/app/src/states/ui/page-tree-desc-count-map.ts
  72. 1 1
      apps/app/src/states/ui/sidebar/sidebar.ts
  73. 1 1
      apps/app/src/states/ui/toc.ts
  74. 2 2
      apps/app/src/states/ui/unsaved-warning.ts
  75. 0 12
      apps/app/src/stores-universal/.eslintrc.js
  76. 0 12
      apps/app/src/utils/.eslintrc.js
  77. 1 0
      apps/app/src/utils/axios/create-custom-axios.ts
  78. 1 0
      apps/app/src/utils/axios/index.spec.ts
  79. 2 0
      apps/app/src/utils/axios/index.ts
  80. 2 3
      apps/app/src/utils/logger/index.ts
  81. 80 68
      biome.json
  82. 2 1
      package.json
  83. 1 2
      packages/editor/.eslintignore
  84. 0 13
      packages/editor/.eslintrc.cjs
  85. 1 1
      packages/editor/package.json
  86. 59 45
      packages/editor/src/client/components-internal/CodeMirrorEditor/CodeMirrorEditor.tsx
  87. 18 24
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/AttachmentsDropdownItem.tsx
  88. 35 20
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/AttachmentsDropup.tsx
  89. 8 4
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/DiagramButton.tsx
  90. 20 20
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/EmojiButton.tsx
  91. 7 5
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/LinkEditButton.tsx
  92. 8 5
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/TableButton.tsx
  93. 11 5
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/TemplateButton.tsx
  94. 75 25
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/TextFormatTools.tsx
  95. 25 14
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/Toolbar.tsx
  96. 56 22
      packages/editor/src/client/components-internal/playground/Playground.tsx
  97. 10 8
      packages/editor/src/client/components-internal/playground/PlaygroundController.tsx
  98. 2 2
      packages/editor/src/client/components-internal/playground/Preview.tsx
  99. 7 3
      packages/editor/src/client/components-internal/playground/controller/KeymapControl.tsx
  100. 6 6
      packages/editor/src/client/components-internal/playground/controller/OutlineSecondaryButtons.tsx

+ 4 - 0
.vscode/settings.json

@@ -11,6 +11,10 @@
     "editor.defaultFormatter": "biomejs.biome"
   },
 
+  "[json]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
   // use vscode-stylelint
   // see https://github.com/stylelint/vscode-stylelint
   "stylelint.validate": ["css", "less", "scss"],

+ 0 - 4
apps/app/.eslintrc.js

@@ -61,10 +61,6 @@ module.exports = {
   },
   rules: {
     'space-before-function-paren': 'off',
-    'no-restricted-imports': ['error', {
-      name: 'axios',
-      message: 'Please use src/utils/axios instead.',
-    }],
     '@typescript-eslint/no-var-requires': 'off',
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei

+ 3 - 1
apps/app/bin/print-memory-consumption.ts

@@ -296,7 +296,9 @@ class NodeMemoryConsumptionChecker {
     // Memory Flags
     if (info.memoryFlags.length > 0) {
       console.log('\n🔸 Memory Flags:');
-      info.memoryFlags.forEach((flag) => console.log(`  ${flag}`));
+      info.memoryFlags.forEach((flag) => {
+        console.log(`  ${flag}`);
+      });
     }
 
     // Summary

+ 1 - 1
apps/app/src/client/services/user-ui-settings.ts

@@ -1,4 +1,4 @@
-// eslint-disable-next-line no-restricted-imports
+// biome-ignore lint/style/noRestrictedImports: Type-only import from axios
 import type { AxiosResponse } from 'axios';
 import { debounce } from 'throttle-debounce';
 

+ 1 - 1
apps/app/src/client/util/apiv3-client.ts

@@ -1,4 +1,4 @@
-// eslint-disable-next-line no-restricted-imports
+// biome-ignore lint/style/noRestrictedImports: Type-only import from axios
 import type { AxiosResponse } from 'axios';
 import urljoin from 'url-join';
 

+ 0 - 12
apps/app/src/components/.eslintrc.js

@@ -1,12 +0,0 @@
-module.exports = {
-  extends: '../../.eslintrc.js',
-  rules: {
-    // restrict importing from client/ and components/ directories
-    'no-restricted-imports': ['error', {
-      patterns: [
-        '~/client/',
-        'client/',
-      ],
-    }],
-  },
-};

+ 1 - 1
apps/app/src/features/collaborative-editor/states/current-page-yjs-data.ts

@@ -1,5 +1,5 @@
-import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 import { apiv3Get } from '../../../client/util/apiv3-client';
 import type { CurrentPageYjsData } from '../../../interfaces/yjs';

+ 4 - 2
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx

@@ -1,8 +1,8 @@
-import type { FC } from 'react';
-import { useCallback, useMemo, useState } from 'react';
 import type { IGrantedGroup } from '@growi/core';
 import { GroupType, getIdForRef } from '@growi/core';
 import { useAtomValue } from 'jotai';
+import type { FC } from 'react';
+import { useCallback, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { TabContent, TabPane } from 'reactstrap';
 
@@ -168,12 +168,14 @@ export const ExternalGroupManagement: FC = () => {
   const navTabMapping = useMemo(() => {
     return {
       ldap: {
+        // biome-ignore lint/correctness/noNestedComponentDefinitions: ignore
         Icon: () => (
           <span className="material-symbols-outlined">network_node</span>
         ),
         i18n: 'LDAP',
       },
       keycloak: {
+        // biome-ignore lint/correctness/noNestedComponentDefinitions: ignore
         Icon: () => <span className="material-symbols-outlined">key</span>,
         i18n: 'Keycloak',
       },

+ 1 - 0
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx

@@ -38,6 +38,7 @@ export const LdapGroupManagement: FC = () => {
     [isUserBind],
   );
 
+  // biome-ignore lint/correctness/noNestedComponentDefinitions: ignore
   const AdditionalForm = (): JSX.Element => {
     return isUserBind ? (
       <div className="row form-group">

+ 3 - 1
apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginCard.tsx

@@ -1,6 +1,6 @@
-import { type JSX, useState } from 'react';
 import Link from 'next/link';
 import { useTranslation } from 'next-i18next';
+import { type JSX, useState } from 'react';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
@@ -21,6 +21,7 @@ export const PluginCard = (props: Props): JSX.Element => {
 
   const { t } = useTranslation('admin');
 
+  // biome-ignore lint/correctness/noNestedComponentDefinitions: ignore
   const PluginCardButton = (): JSX.Element => {
     const [_isEnabled, setIsEnabled] = useState<boolean>(isEnabled);
 
@@ -62,6 +63,7 @@ export const PluginCard = (props: Props): JSX.Element => {
     );
   };
 
+  // biome-ignore lint/correctness/noNestedComponentDefinitions: ignore
   const PluginDeleteButton = (): JSX.Element => {
     return (
       <div>

+ 2 - 2
apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginDeleteModal.tsx

@@ -1,7 +1,7 @@
-import type React from 'react';
-import { useCallback } from 'react';
 import Link from 'next/link';
 import { useTranslation } from 'next-i18next';
+import type React from 'react';
+import { useCallback } from 'react';
 import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { apiv3Delete } from '~/client/util/apiv3-client';

+ 1 - 1
apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginInstallerForm.tsx

@@ -1,5 +1,5 @@
-import { type JSX, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
+import { type JSX, useCallback } from 'react';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';

+ 1 - 1
apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx

@@ -1,6 +1,6 @@
-import type { JSX } from 'react';
 import dynamic from 'next/dynamic';
 import { useTranslation } from 'next-i18next';
+import type { JSX } from 'react';
 import { Spinner } from 'reactstrap';
 
 import { usePluginDeleteModalActions } from '../../states/modal/plugin-delete';

+ 1 - 1
apps/app/src/features/growi-plugin/client/Admin/states/modal/plugin-delete.ts

@@ -1,5 +1,5 @@
-import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 import type { IGrowiPluginHasId } from '../../../../interfaces';
 

+ 1 - 1
apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts

@@ -5,7 +5,7 @@ import {
   importPackageJson,
   validateGrowiDirective,
 } from '@growi/pluginkit/dist/v4/server/index.cjs';
-// eslint-disable-next-line no-restricted-imports
+// biome-ignore lint/style/noRestrictedImports: Direct axios usage for plugin downloading
 import axios from 'axios';
 import fs, { readFileSync } from 'fs';
 import type mongoose from 'mongoose';

+ 1 - 1
apps/app/src/features/mermaid/components/MermaidViewer.tsx

@@ -1,5 +1,5 @@
-import React, { type JSX, useEffect, useRef } from 'react';
 import mermaid from 'mermaid';
+import React, { type JSX, useEffect, useRef } from 'react';
 import { v7 as uuidV7 } from 'uuid';
 
 import { useNextThemes } from '~/stores-universal/use-next-themes';

+ 1 - 1
apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx

@@ -1,7 +1,7 @@
-import { type JSX, useCallback, useMemo, useState } from 'react';
 import { format } from 'date-fns/format';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
+import { type JSX, useCallback, useMemo, useState } from 'react';
 import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
 import { apiv3Post } from '~/client/util/apiv3-client';

+ 1 - 1
apps/app/src/features/rate-limiter/utils/config-generator.ts

@@ -57,7 +57,7 @@ export const generateApiRateLimitConfig = (): ApiRateLimitConfigResult => {
     const result = key.match(regExp);
 
     if (result == null) {
-      return null;
+      return;
     }
 
     const target = result[1];

+ 2 - 2
apps/app/src/features/search/client/components/PrivateLegacyPages.tsx

@@ -1,3 +1,5 @@
+import { LoadingSpinner } from '@growi/ui/dist/components';
+import { useTranslation } from 'next-i18next';
 import React, {
   type JSX,
   useCallback,
@@ -6,8 +8,6 @@ import React, {
   useRef,
   useState,
 } from 'react';
-import { LoadingSpinner } from '@growi/ui/dist/components';
-import { useTranslation } from 'next-i18next';
 import {
   DropdownItem,
   DropdownMenu,

+ 1 - 1
apps/app/src/features/search/client/components/SearchHelp.tsx

@@ -1,5 +1,5 @@
-import { type JSX, useState } from 'react';
 import { useTranslation } from 'next-i18next';
+import { type JSX, useState } from 'react';
 import { Collapse } from 'reactstrap';
 
 export const SearchHelp = (): JSX.Element => {

+ 1 - 1
apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx

@@ -1,6 +1,6 @@
-import type { JSX } from 'react';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { useTranslation } from 'next-i18next';
+import type { JSX } from 'react';
 
 import { useCurrentPagePath } from '~/states/page';
 

+ 2 - 2
apps/app/src/features/search/client/components/SearchModal.tsx

@@ -1,9 +1,9 @@
-import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
-import { useRouter } from 'next/router';
 import Downshift, {
   type DownshiftState,
   type StateChangeOptions,
 } from 'downshift';
+import { useRouter } from 'next/router';
+import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
 import { Modal, ModalBody } from 'reactstrap';
 
 import { useSetSearchKeyword } from '~/states/search';

+ 2 - 4
apps/app/src/features/search/client/components/SearchPage/SearchControl.tsx

@@ -1,16 +1,14 @@
-import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'next-i18next';
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import { Collapse } from 'reactstrap';
 
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import type { ISearchConditions, ISearchConfigurations } from '~/stores/search';
-
+import styles from './SearchControl.module.scss';
 import { SearchModalTriggerinput } from './SearchModalTriggerinput';
 import SearchOptionModal from './SearchOptionModal';
 import SortControl from './SortControl';
 
-import styles from './SearchControl.module.scss';
-
 type Props = {
   isEnableSort: boolean;
   isEnableFilter: boolean;

+ 1 - 1
apps/app/src/features/search/client/components/SearchPage/SearchOptionModal.tsx

@@ -1,5 +1,5 @@
-import { type FC, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
+import { type FC, useCallback } from 'react';
 import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
 type Props = {

+ 2 - 3
apps/app/src/features/search/client/components/SearchPage/SearchPage.tsx

@@ -1,6 +1,6 @@
-import React, { type JSX, useCallback, useMemo, useRef, useState } from 'react';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
+import React, { type JSX, useCallback, useMemo, useRef, useState } from 'react';
 
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
 import { NotAvailableForReadOnlyUser } from '~/client/components/NotAvailableForReadOnlyUser';
@@ -20,14 +20,13 @@ import {
 
 import { OperateAllControl } from './OperateAllControl';
 import SearchControl from './SearchControl';
+import styles from './SearchPage.module.scss';
 import type { IReturnSelectedPageIds } from './SearchPageBase';
 import {
   SearchPageBase,
   usePageDeleteModalForBulkDeletion,
 } from './SearchPageBase';
 
-import styles from './SearchPage.module.scss';
-
 // TODO: replace with "customize:showPageLimitationS"
 const INITIAL_PAGIONG_SIZE = 20;
 

+ 8 - 8
apps/app/src/features/search/client/components/SearchPage/SearchPageBase.tsx

@@ -1,3 +1,7 @@
+import { LoadingSpinner } from '@growi/ui/dist/components';
+import { useAtomValue } from 'jotai';
+import dynamic from 'next/dynamic';
+import { useTranslation } from 'next-i18next';
 import type React from 'react';
 import type { ForwardRefRenderFunction, JSX } from 'react';
 import {
@@ -7,10 +11,6 @@ import {
   useRef,
   useState,
 } from 'react';
-import dynamic from 'next/dynamic';
-import { LoadingSpinner } from '@growi/ui/dist/components';
-import { useAtomValue } from 'jotai';
-import { useTranslation } from 'next-i18next';
 
 import type { ForceHideMenuItems } from '~/client/components/Common/Dropdown/PageItemControl';
 import type { ISelectableAll } from '~/client/interfaces/selectable-all';
@@ -28,13 +28,11 @@ import {
 } from '~/states/server-configurations';
 import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
 import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
-
+import styles from './SearchPageBase.module.scss';
 // Do not import with next/dynamic
 // see: https://github.com/growilabs/growi/pull/7923
 import { SearchResultList } from './SearchResultList';
 
-import styles from './SearchPageBase.module.scss';
-
 // https://regex101.com/r/brrkBu/1
 const highlightKeywordsSplitter = /"[^"]+"|[^\u{20}\u{3000}]+/gu;
 
@@ -107,7 +105,9 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<
       }
 
       if (pages != null) {
-        pages.forEach((page) => selectedPageIdsByCheckboxes.add(page.data._id));
+        pages.forEach((page) => {
+          selectedPageIdsByCheckboxes.add(page.data._id);
+        });
       }
     },
     deselectAll: () => {

+ 3 - 3
apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx

@@ -1,9 +1,9 @@
-import type { FC, JSX } from 'react';
-import { useCallback, useEffect, useRef } from 'react';
-import dynamic from 'next/dynamic';
 import type { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
+import dynamic from 'next/dynamic';
 import { useTranslation } from 'next-i18next';
+import type { FC, JSX } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
 import { animateScroll } from 'react-scroll';
 import { DropdownItem } from 'reactstrap';
 import { debounce } from 'throttle-debounce';

+ 8 - 4
apps/app/src/features/search/client/components/SearchPage/SearchResultList.tsx

@@ -1,11 +1,11 @@
-import type { ForwardRefRenderFunction } from 'react';
-import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
 import {
   type IPageInfoForListing,
   type IPageWithMeta,
   isIPageInfoForListing,
 } from '@growi/core/dist/interfaces';
 import { useTranslation } from 'next-i18next';
+import type { ForwardRefRenderFunction } from 'react';
+import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
 
 import type { ForceHideMenuItems } from '~/client/components/Common/Dropdown/PageItemControl';
 import { PageListItemL } from '~/client/components/PageList/PageListItemL';
@@ -61,13 +61,17 @@ const SearchResultListSubstance: ForwardRefRenderFunction<
     selectAll: () => {
       const items = itemsRef.current;
       if (items != null) {
-        items.forEach((item) => item != null && item.select());
+        items.forEach((item) => {
+          item?.select();
+        });
       }
     },
     deselectAll: () => {
       const items = itemsRef.current;
       if (items != null) {
-        items.forEach((item) => item != null && item.deselect());
+        items.forEach((item) => {
+          item?.deselect();
+        });
       }
     },
   }));

+ 1 - 1
apps/app/src/features/search/client/components/SearchPage/SortControl.tsx

@@ -1,5 +1,5 @@
-import type { FC } from 'react';
 import { useTranslation } from 'next-i18next';
+import type { FC } from 'react';
 
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 

+ 1 - 1
apps/app/src/features/search/client/components/SearchResultMenuItem.tsx

@@ -1,5 +1,5 @@
-import { type JSX, useCallback } from 'react';
 import { PagePathLabel, UserPicture } from '@growi/ui/dist/components';
+import { type JSX, useCallback } from 'react';
 import { useDebounce } from 'usehooks-ts';
 
 import { useSWRxSearch } from '~/stores/search';

+ 1 - 1
apps/app/src/features/search/client/states/modal/search.ts

@@ -1,5 +1,5 @@
-import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 type OnSearch = (keyword: string) => void;
 type OpenSearchModal = (

+ 1 - 1
apps/app/src/interfaces/ui.ts

@@ -1,5 +1,5 @@
-import type { JSX } from 'react';
 import type { Nullable } from '@growi/core';
+import type { JSX } from 'react';
 
 import type { IPageForItem } from '~/interfaces/page';
 

+ 0 - 12
apps/app/src/models/.eslintrc.js

@@ -1,12 +0,0 @@
-module.exports = {
-  extends: '../../.eslintrc.js',
-  rules: {
-    // restrict importing from client/ and components/ directories
-    'no-restricted-imports': ['error', {
-      patterns: [
-        '~/client/',
-        'client/',
-      ],
-    }],
-  },
-};

+ 0 - 12
apps/app/src/pages/.eslintrc.js

@@ -1,12 +0,0 @@
-module.exports = {
-  extends: '../../.eslintrc.js',
-  rules: {
-    // restrict importing from client/ and components/ directories
-    'no-restricted-imports': ['error', {
-      patterns: [
-        '~/client/',
-        'client/',
-      ],
-    }],
-  },
-};

+ 0 - 12
apps/app/src/server/.eslintrc.js

@@ -1,12 +0,0 @@
-module.exports = {
-  extends: '../../.eslintrc.js',
-  rules: {
-    // restrict importing from client/ and components/ directories
-    'no-restricted-imports': ['error', {
-      patterns: [
-        '~/client/',
-        'client/',
-      ],
-    }],
-  },
-};

+ 6 - 3
apps/app/src/server/crowi/express-init.js

@@ -2,9 +2,11 @@ import { themesRootPath as presetThemesRootPath } from '@growi/preset-themes';
 import csrf from 'csurf';
 import qs from 'qs';
 
-import { PLUGIN_EXPRESS_STATIC_DIR, PLUGIN_STORING_PATH } from '~/features/growi-plugin/server/consts';
+
+import { PLUGIN_EXPRESS_STATIC_DIR, PLUGIN_STORING_PATH } from '../../features/growi-plugin/server/consts';
+import loggerFactory from '../../utils/logger';
 import { resolveFromRoot } from '~/server/util/project-dir-utils';
-import loggerFactory from '~/utils/logger';
+import CertifyOrigin from '../middlewares/certify-origin';
 
 import registerSafeRedirectFactory from '../middlewares/safe-redirect';
 
@@ -26,7 +28,6 @@ module.exports = function(crowi, app) {
   const registerSafeRedirect = registerSafeRedirectFactory();
   const injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
   const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
-
   const avoidSessionRoutes = require('../routes/avoid-session-routes');
 
   const env = crowi.node_env;
@@ -123,6 +124,8 @@ module.exports = function(crowi, app) {
   // default methods + PUT. See: https://expressjs.com/en/resources/middleware/csurf.html#ignoremethods
   app.use(csrf({ ignoreMethods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'DELETE'], cookie: false }));
 
+  app.use('/_api', CertifyOrigin);
+
   // passport
   logger.debug('initialize Passport');
   app.use(passport.initialize());

+ 42 - 0
apps/app/src/server/middlewares/certify-origin.ts

@@ -0,0 +1,42 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { NextFunction, Response } from 'express';
+
+import loggerFactory from '../../utils/logger';
+import { configManager } from '../service/config-manager';
+import isSimpleRequest from '../util/is-simple-request';
+
+import type { AccessTokenParserReq } from './access-token-parser/interfaces';
+
+
+const logger = loggerFactory('growi:middleware:certify-origin');
+
+type Apiv3ErrFunction = (error: ErrorV3) => void;
+
+const certifyOrigin = (req: AccessTokenParserReq, res: Response & { apiv3Err: Apiv3ErrFunction }, next: NextFunction): void => {
+
+  const appSiteUrl = configManager.getConfig('app:siteUrl');
+  const configuredOrigin = appSiteUrl ? new URL(appSiteUrl).origin : null;
+  const requestOrigin = req.headers.origin;
+  const runtimeOrigin = `${req.protocol}://${req.get('host')}`;
+
+  const isSameOriginReq = requestOrigin == null
+  || requestOrigin === configuredOrigin
+  || requestOrigin === runtimeOrigin;
+
+  const accessToken = req.query.access_token ?? req.body.access_token;
+
+  if (!isSameOriginReq && req.headers.origin != null && isSimpleRequest(req)) {
+    const message = 'Invalid request (origin check failed but simple request)';
+    logger.error(message);
+    return res.apiv3Err(new ErrorV3(message));
+  }
+
+  if (!isSameOriginReq && accessToken == null && !isSimpleRequest(req)) {
+    const message = 'Invalid request (origin check failed and no access token)';
+    logger.error(message);
+    return res.apiv3Err(new ErrorV3(message));
+  }
+
+  next();
+};
+export default certifyOrigin;

+ 2 - 1
apps/app/src/server/routes/index.js

@@ -49,6 +49,7 @@ module.exports = function(crowi, app) {
   const tag = require('./tag')(crowi, app);
   const search = require('./search')(crowi, app);
   const ogp = require('./ogp')(crowi);
+  const { createApiRouter } = require('~/server/util/createApiRouter');
 
   const next = nextFactory(crowi);
 
@@ -121,7 +122,7 @@ module.exports = function(crowi, app) {
   // API v3
   app.use('/_api/v3', unavailableWhenMaintenanceModeForApi, apiV3Router);
 
-  const apiV1Router = express.Router();
+  const apiV1Router = createApiRouter();
 
   apiV1Router.get('/search'              , accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }) , loginRequired , search.api.search);
 

+ 1 - 1
apps/app/src/server/routes/ogp.ts

@@ -3,7 +3,7 @@ import path from 'path';
 
 import { getIdStringForRef, type IUser } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
-// eslint-disable-next-line no-restricted-imports
+// biome-ignore lint/style/noRestrictedImports: Direct axios usage for OGP image fetching
 import axios from 'axios';
 import type {
   Request, Response, NextFunction,

+ 1 - 1
apps/app/src/server/service/file-uploader/gcs/multipart-uploader.ts

@@ -1,5 +1,5 @@
 import type { Bucket, File } from '@google-cloud/storage';
-// eslint-disable-next-line no-restricted-imports
+// biome-ignore lint/style/noRestrictedImports: Direct axios usage for GCS multipart upload
 import axios from 'axios';
 import urljoin from 'url-join';
 

+ 1 - 2
apps/app/src/server/service/g2g-transfer.ts

@@ -3,9 +3,8 @@ import { createReadStream } from 'fs';
 import { basename } from 'path';
 import type { Readable } from 'stream';
 
-// eslint-disable-next-line no-restricted-imports
-import type { IUser } from '@growi/core';
 import { ConfigSource } from '@growi/core';
+import type { IUser } from '@growi/core/dist/interfaces';
 import rawAxios, { type AxiosRequestConfig } from 'axios';
 import FormData from 'form-data';
 import mongoose, { Types as MongooseTypes } from 'mongoose';

+ 1 - 1
apps/app/src/server/service/s2s-messaging/nchan.ts

@@ -1,6 +1,6 @@
 import path from 'path';
 
-// eslint-disable-next-line no-restricted-imports
+// biome-ignore lint/style/noRestrictedImports: Direct axios usage for external S2S messaging
 import axios from 'axios';
 import ReconnectingWebSocket from 'reconnecting-websocket';
 import WebSocket from 'ws';

+ 12 - 0
apps/app/src/server/util/createApiRouter.ts

@@ -0,0 +1,12 @@
+import express, { type Router } from 'express';
+import CertifyOrigin from '~/server/middlewares/certify-origin';
+
+function createApiRouter(): Router {
+  const router = express.Router();
+  router.use(CertifyOrigin);
+  return router;
+}
+
+export {
+  createApiRouter,
+};

+ 200 - 0
apps/app/src/server/util/is-simple-request.spec.ts

@@ -0,0 +1,200 @@
+import type { Request } from 'express';
+import { mock } from 'vitest-mock-extended';
+
+import isSimpleRequest from './is-simple-request';
+
+describe('isSimpleRequest', () => {
+
+
+  // method
+  describe('When request method is checked', () => {
+
+    // allow
+    describe('When allowed method is given', () => {
+      const allowedMethods = ['GET', 'HEAD', 'POST'];
+      it.each(allowedMethods)('returns true for %s method', (method) => {
+        const reqMock = mock<Request>();
+        reqMock.method = method;
+        reqMock.headers = { 'content-type': 'text/plain' };
+        expect(isSimpleRequest(reqMock)).toBe(true);
+      });
+    });
+
+    // disallow
+    describe('When disallowed method is given', () => {
+      const disallowedMethods = ['PUT', 'DELETE', 'PATCH', 'OPTIONS', 'TRACE'];
+      it.each(disallowedMethods)('returns false for %s method', (method) => {
+        const reqMock = mock<Request>();
+        reqMock.method = method;
+        reqMock.headers = {};
+        expect(isSimpleRequest(reqMock)).toBe(false);
+      });
+    });
+
+  });
+
+
+  // headers
+  describe('When request headers are checked', () => {
+
+    // allow(Other than content-type)
+    describe('When only safe headers are given', () => {
+      const safeHeaders = [
+        'accept',
+        'accept-language',
+        'content-language',
+        'range',
+        'referer',
+        'dpr',
+        'downlink',
+        'save-data',
+        'viewport-width',
+        'width',
+      ];
+      it.each(safeHeaders)('returns true for safe header: %s', (headerName) => {
+        const reqMock = mock<Request>();
+        reqMock.method = 'POST';
+        reqMock.headers = {
+          [headerName]: 'test-value',
+        };
+        expect(isSimpleRequest(reqMock)).toBe(true);
+      });
+      // content-type
+      it('returns true for valid content-type values', () => {
+        const validContentTypes = [
+          'application/x-www-form-urlencoded',
+          'multipart/form-data',
+          'text/plain',
+        ];
+        validContentTypes.forEach((contentType) => {
+          const reqMock = mock<Request>();
+          reqMock.method = 'POST';
+          reqMock.headers = { 'content-type': contentType };
+          expect(isSimpleRequest(reqMock)).toBe(true);
+        });
+      });
+      // combination
+      it('returns true for combination of safe headers', () => {
+        const reqMock = mock<Request>();
+        reqMock.method = 'POST';
+        reqMock.headers = {
+          Accept: 'application/json',
+          'content-Type': 'text/plain',
+          'Accept-Language': 'en-US',
+        };
+        expect(isSimpleRequest(reqMock)).toBe(true);
+      });
+    });
+
+    // disallow
+    describe('When unsafe headers are given', () => {
+      const unsafeHeaders = [
+        'X-Custom-Header',
+        'Authorization',
+        'X-Requested-With',
+        'X-CSRF-Token',
+      ];
+      it.each(unsafeHeaders)('returns false for unsafe header: %s', (headerName) => {
+        const reqMock = mock<Request>({
+          method: 'POST',
+          headers: { [headerName]: 'test-value' },
+        });
+        expect(isSimpleRequest(reqMock)).toBe(false);
+      });
+      // combination
+      it('returns false when safe and unsafe headers are mixed', () => {
+        const reqMock = mock<Request>();
+        reqMock.method = 'POST';
+        reqMock.headers = {
+          Accept: 'application/json', // Safe
+          'X-Custom-Header': 'custom-value', // Unsafe
+        };
+        expect(isSimpleRequest(reqMock)).toBe(false);
+      });
+    });
+
+  });
+
+
+  // content-type
+  describe('When content-type is checked', () => {
+
+    // allow
+    describe('When a safe content-type is given', () => {
+      const safeContentTypes = [
+        'application/x-www-form-urlencoded',
+        'multipart/form-data',
+        'text/plain',
+        // parameters
+        'application/x-www-form-urlencoded; charset=UTF-8',
+        'multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW',
+        'text/plain; charset=iso-8859-1',
+      ];
+      it.each(safeContentTypes)('returns true for %s', (contentType) => {
+        const reqMock = mock<Request>();
+        reqMock.method = 'POST';
+        reqMock.headers = {
+          'content-type': contentType,
+        };
+        expect(isSimpleRequest(reqMock)).toBe(true);
+      });
+    });
+    // absent
+    describe('When content-type is absent', () => {
+      it('returns true when no content-type header is set', () => {
+        const reqMock = mock<Request>();
+        reqMock.method = 'POST';
+        reqMock.headers = {};
+        expect(isSimpleRequest(reqMock)).toBe(true);
+      });
+    });
+
+    // disallow
+    describe('When disallowed content-type is given', () => {
+      const disallowedContentTypes = [
+        'application/json',
+        'application/xml',
+        'text/html',
+        'application/octet-stream',
+      ];
+      it.each(disallowedContentTypes)('returns false for %s', (contentType) => {
+        const reqMock = mock<Request>();
+        reqMock.method = 'POST';
+        reqMock.headers = { 'content-type': contentType };
+        expect(isSimpleRequest(reqMock)).toBe(false);
+      });
+    });
+
+  });
+
+  // integration
+  describe('When multiple conditions are checked', () => {
+
+    describe('When all conditions are met', () => {
+      it('returns true', () => {
+        const reqMock = mock<Request>();
+        reqMock.method = 'POST';
+        reqMock.headers = { 'content-type': 'application/x-www-form-urlencoded' };
+        expect(isSimpleRequest(reqMock)).toBe(true);
+      });
+    });
+
+    describe('When method is disallowed but headers are safe', () => {
+      it('returns false', () => {
+        const reqMock = mock<Request>();
+        reqMock.method = 'PUT';
+        reqMock.headers = { 'content-type': 'text/plain' };
+        expect(isSimpleRequest(reqMock)).toBe(false);
+      });
+    });
+
+    describe('When method is allowed but headers are non-safe', () => {
+      it('returns false', () => {
+        const reqMock = mock<Request>();
+        reqMock.method = 'POST';
+        reqMock.headers = { 'X-Custom-Header': 'custom-value' };
+        expect(isSimpleRequest(reqMock)).toBe(false);
+      });
+    });
+  });
+});

+ 65 - 0
apps/app/src/server/util/is-simple-request.ts

@@ -0,0 +1,65 @@
+import type { Request } from 'express';
+
+import type { AccessTokenParserReq } from '~/server/middlewares/access-token-parser/interfaces';
+
+const allowedMethods = ['GET', 'HEAD', 'POST'] as const;
+type AllowedMethod = typeof allowedMethods[number];
+function isAllowedMethod(method: string): method is AllowedMethod {
+  return allowedMethods.includes(method as AllowedMethod);
+}
+
+const safeRequestHeaders = [
+  'accept',
+  'accept-language',
+  'content-language',
+  'content-type',
+  'range',
+  'referer',
+  'dpr',
+  'downlink',
+  'save-data',
+  'viewport-width',
+  'width',
+] as const;
+type SafeRequestHeader = typeof safeRequestHeaders[number];
+
+function isSafeRequestHeader(header: string): header is SafeRequestHeader {
+  return safeRequestHeaders.includes(header.toLowerCase() as SafeRequestHeader);
+}
+
+const allowedContentTypes = [
+  'application/x-www-form-urlencoded',
+  'multipart/form-data',
+  'text/plain',
+] as const;
+type AllowedContentType = typeof allowedContentTypes[number];
+
+function isAllowedContentType(contentType: string): contentType is AllowedContentType {
+  return allowedContentTypes.some(allowed => contentType.toLowerCase().startsWith(allowed));
+}
+
+const isSimpleRequest = (req: Request | AccessTokenParserReq): boolean => {
+  // 1. Check if the request method is allowed
+  if (!isAllowedMethod(req.method)) {
+    return false;
+  }
+
+  // 2. Check if the request headers are safe
+  const nonSafeHeaders = Object.keys(req.headers).filter((header) => {
+    return !isSafeRequestHeader(header);
+  });
+  if (nonSafeHeaders.length > 0) {
+    return false;
+  }
+
+  // 3. Content-Type is
+  const contentType = req.headers['content-type'];
+  if (contentType != null && !isAllowedContentType(contentType)) {
+    return false;
+  }
+
+  // Return true if all conditions are met
+  return true;
+};
+
+export default isSimpleRequest;

+ 0 - 12
apps/app/src/services/.eslintrc.js

@@ -1,12 +0,0 @@
-module.exports = {
-  extends: '../../.eslintrc.js',
-  rules: {
-    // restrict importing from client/ and components/ directories
-    'no-restricted-imports': ['error', {
-      patterns: [
-        '~/client/',
-        'client/',
-      ],
-    }],
-  },
-};

+ 1 - 1
apps/app/src/states/page/hooks.ts

@@ -1,10 +1,10 @@
-import { useCallback, useMemo } from 'react';
 import {
   isCreatablePage,
   isPermalink,
 } from '@growi/core/dist/utils/page-path-utils';
 import { useAtomValue, useSetAtom } from 'jotai';
 import { useAtomCallback } from 'jotai/utils';
+import { useCallback, useMemo } from 'react';
 
 import { useIsGuestUser, useIsReadOnlyUser } from '../context';
 import { useCurrentPathname } from '../global';

+ 3 - 4
apps/app/src/states/page/use-fetch-current-page.spec.tsx

@@ -1,5 +1,3 @@
-import type { NextRouter } from 'next/router';
-import { useRouter } from 'next/router';
 import type {
   IPagePopulatedToShowRevision,
   IRevisionHasId,
@@ -9,13 +7,14 @@ import type {
   PageStatus,
 } from '@growi/core';
 import { renderHook, waitFor } from '@testing-library/react';
-// eslint-disable-next-line no-restricted-imports
+// biome-ignore lint/style/noRestrictedImports: import only types
 import type { AxiosResponse } from 'axios';
 import { createStore, Provider } from 'jotai';
+import type { NextRouter } from 'next/router';
+import { useRouter } from 'next/router';
 import { vi } from 'vitest';
 import { mockDeep } from 'vitest-mock-extended';
 
-// eslint-disable-next-line no-restricted-imports
 import * as apiv3Client from '~/client/util/apiv3-client';
 import { useFetchCurrentPage } from '~/states/page';
 import {

+ 1 - 1
apps/app/src/states/page/use-fetch-current-page.ts

@@ -1,4 +1,3 @@
-import { useCallback } from 'react';
 import {
   type IPagePopulatedToShowRevision,
   isIPageNotFoundInfo,
@@ -9,6 +8,7 @@ import { isPermalink } from '@growi/core/dist/utils/page-path-utils';
 import { removeHeadingSlash } from '@growi/core/dist/utils/path-utils';
 import { useAtomValue } from 'jotai';
 import { useAtomCallback } from 'jotai/utils';
+import { useCallback } from 'react';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import loggerFactory from '~/utils/logger';

+ 1 - 1
apps/app/src/states/page/use-set-remote-latest-page-data.ts

@@ -1,6 +1,6 @@
-import { useCallback } from 'react';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { useSetAtom } from 'jotai/react';
+import { useCallback } from 'react';
 
 import {
   remoteRevisionBodyAtom,

+ 2 - 2
apps/app/src/states/search/keyword-manager.ts

@@ -1,6 +1,6 @@
-import { useCallback, useEffect, useRef } from 'react';
-import { useRouter } from 'next/router';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useRouter } from 'next/router';
+import { useCallback, useEffect, useRef } from 'react';
 
 /**
  * Atom for managing search keyword state

+ 1 - 1
apps/app/src/states/socket-io/global-socket.ts

@@ -1,5 +1,5 @@
-import { useCallback, useEffect } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback, useEffect } from 'react';
 import type { Socket } from 'socket.io-client';
 
 import { SocketEventName } from '~/interfaces/websocket';

+ 1 - 1
apps/app/src/states/ui/device.ts

@@ -1,10 +1,10 @@
-import { useEffect } from 'react';
 import { Breakpoint } from '@growi/ui/dist/interfaces';
 import {
   addBreakpointListener,
   cleanupBreakpointListener,
 } from '@growi/ui/dist/utils';
 import { atom, useAtom } from 'jotai';
+import { useEffect } from 'react';
 
 // Device state atoms
 export const isDeviceLargerThanXlAtom = atom(false);

+ 1 - 1
apps/app/src/states/ui/editor/editor-mode.ts

@@ -1,6 +1,6 @@
-import { useCallback } from 'react';
 import { isServer } from '@growi/core/dist/utils';
 import { atom, useAtom } from 'jotai';
+import { useCallback } from 'react';
 
 import { useIsEditable, usePageNotFound } from '~/states/page';
 

+ 1 - 1
apps/app/src/states/ui/editor/reserved-next-caret-line.ts

@@ -1,6 +1,6 @@
-import { useEffect } from 'react';
 import type EventEmitter from 'node:events';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useEffect } from 'react';
 
 declare global {
   // eslint-disable-next-line vars-on-top, no-var

+ 1 - 1
apps/app/src/states/ui/modal/delete-attachment.ts

@@ -1,6 +1,6 @@
-import { useCallback } from 'react';
 import type { IAttachmentHasId } from '@growi/core';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 type Remove = (body: { attachment_id: string }) => Promise<void>;
 

+ 1 - 1
apps/app/src/states/ui/modal/delete-bookmark-folder.ts

@@ -1,5 +1,5 @@
-import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 import type { BookmarkFolderItems } from '../../../interfaces/bookmark-info';
 import type { onDeletedBookmarkFolderFunction } from '../../../interfaces/ui';

+ 1 - 1
apps/app/src/states/ui/modal/empty-trash.ts

@@ -1,6 +1,6 @@
-import { useCallback } from 'react';
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 type IEmptyTrashModalOption = {
   onEmptiedTrash?: () => void;

+ 1 - 1
apps/app/src/states/ui/modal/page-accessories.ts

@@ -1,5 +1,5 @@
-import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 // Constants to match the original implementation
 export const PageAccessoriesModalContents = {

+ 1 - 1
apps/app/src/states/ui/modal/page-create.ts

@@ -1,5 +1,5 @@
-import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 // Type definitions
 export type CreateModalStatus = {

+ 1 - 1
apps/app/src/states/ui/modal/page-delete.ts

@@ -1,6 +1,6 @@
-import { useCallback } from 'react';
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 import type { OnDeletedFunction } from '../../../interfaces/ui';
 

+ 1 - 1
apps/app/src/states/ui/modal/page-duplicate.ts

@@ -1,5 +1,5 @@
-import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 import type { OnDuplicatedFunction } from '../../../interfaces/ui';
 

+ 1 - 1
apps/app/src/states/ui/modal/page-presentation.ts

@@ -1,5 +1,5 @@
-import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 export type PresentationModalStatus = {
   isOpened: boolean;

+ 1 - 1
apps/app/src/states/ui/modal/page-rename.ts

@@ -1,6 +1,6 @@
-import { useCallback } from 'react';
 import type { IPageToRenameWithMeta } from '@growi/core';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 import type { OnRenamedFunction } from '../../../interfaces/ui';
 

+ 1 - 1
apps/app/src/states/ui/modal/page-select.ts

@@ -1,5 +1,5 @@
-import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 import type { OnSelectedFunction } from '../../../interfaces/ui';
 

+ 1 - 1
apps/app/src/states/ui/modal/page-status-alert.ts

@@ -1,5 +1,5 @@
-import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 import type { EditorMode } from '../editor';
 

+ 1 - 1
apps/app/src/states/ui/modal/put-back-page.ts

@@ -1,5 +1,5 @@
-import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 import type { OnPutBackedFunction } from '../../../interfaces/ui';
 

+ 1 - 1
apps/app/src/states/ui/modal/shortcuts.ts

@@ -1,5 +1,5 @@
-import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 // Type definitions
 export type ShortcutsModalStatus = {

+ 1 - 1
apps/app/src/states/ui/modal/tag-edit.ts

@@ -1,5 +1,5 @@
-import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 // Type definitions to match the original implementation
 export type TagEditModalStatus = {

+ 1 - 1
apps/app/src/states/ui/modal/update-user-group-confirm.ts

@@ -1,6 +1,6 @@
-import { useCallback } from 'react';
 import type { IUserGroupHasId } from '@growi/core';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 export type UpdateUserGroupConfirmModalStatus = {
   isOpened: boolean;

+ 1 - 1
apps/app/src/states/ui/page-tree-desc-count-map.ts

@@ -1,5 +1,5 @@
-import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
 
 // Type definitions
 export type UpdateDescCountData = Map<string, number>;

+ 1 - 1
apps/app/src/states/ui/sidebar/sidebar.ts

@@ -1,5 +1,5 @@
-import { type RefObject, useCallback, useMemo } from 'react';
 import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
+import { type RefObject, useCallback, useMemo } from 'react';
 
 import { scheduleToPut } from '~/client/services/user-ui-settings';
 import { SidebarContentsType, SidebarMode } from '~/interfaces/ui';

+ 1 - 1
apps/app/src/states/ui/toc.ts

@@ -1,5 +1,5 @@
-import { type RefObject, useCallback, useEffect, useState } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { type RefObject, useCallback, useEffect, useState } from 'react';
 import type { HtmlElementNode } from 'rehype-toc';
 
 import type { generateTocOptions } from '~/client/services/renderer/renderer';

+ 2 - 2
apps/app/src/states/ui/unsaved-warning.ts

@@ -1,6 +1,6 @@
-import { useCallback, useLayoutEffect } from 'react';
-import { useRouter } from 'next/router';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useRouter } from 'next/router';
+import { useCallback, useLayoutEffect } from 'react';
 
 // Type definitions
 type CommentEditorDirtyMapData = Map<string, boolean>;

+ 0 - 12
apps/app/src/stores-universal/.eslintrc.js

@@ -1,12 +0,0 @@
-module.exports = {
-  extends: '../../.eslintrc.js',
-  rules: {
-    // restrict importing from client/ and components/ directories
-    'no-restricted-imports': ['error', {
-      patterns: [
-        '~/client/',
-        'client/',
-      ],
-    }],
-  },
-};

+ 0 - 12
apps/app/src/utils/.eslintrc.js

@@ -1,12 +0,0 @@
-module.exports = {
-  extends: '../../.eslintrc.js',
-  rules: {
-    // restrict importing from client/ and components/ directories
-    'no-restricted-imports': ['error', {
-      patterns: [
-        '~/client/',
-        'client/',
-      ],
-    }],
-  },
-};

+ 1 - 0
apps/app/src/utils/axios/create-custom-axios.ts

@@ -1,3 +1,4 @@
+/** biome-ignore-all lint/style/noRestrictedImports: This file provides a factory method for custom axios instance */
 import type { AxiosRequestConfig } from 'axios';
 import axios from 'axios';
 import { formatISO } from 'date-fns';

+ 1 - 0
apps/app/src/utils/axios/index.spec.ts

@@ -1,3 +1,4 @@
+/** biome-ignore-all lint/style/noRestrictedImports: This file is a test file for an adapter for axios */
 import axios from 'axios';
 import { describe, expect, it } from 'vitest';
 

+ 2 - 0
apps/app/src/utils/axios/index.ts

@@ -1,3 +1,5 @@
+/** biome-ignore-all lint/style/noRestrictedImports: This file is an adapter for axios */
+
 import type { AxiosStatic } from 'axios';
 import axios from 'axios';
 

+ 2 - 3
apps/app/src/utils/logger/index.ts

@@ -1,8 +1,7 @@
-import type Logger from 'bunyan';
-import { createLogger, type UniversalBunyanConfig } from 'universal-bunyan';
-
 import configForDev from '^/config/logger/config.dev';
 import configForProd from '^/config/logger/config.prod';
+import type Logger from 'bunyan';
+import { createLogger, type UniversalBunyanConfig } from 'universal-bunyan';
 
 const isProduction = process.env.NODE_ENV === 'production';
 const config = (

+ 80 - 68
biome.json

@@ -3,42 +3,42 @@
   "files": {
     "includes": [
       "**",
-      "!**/dist/**",
-      "!**/node_modules/**",
-      "!**/.pnpm-store/**",
-      "!**/coverage/**",
+      "!**/dist",
+      "!**/node_modules",
+      "!**/.pnpm-store",
+      "!**/coverage",
       "!**/vite.config.ts.timestamp-*",
       "!**/vite.server.config.ts.timestamp-*",
       "!**/vite.client.config.ts.timestamp-*",
-      "!**/.turbo/**",
-      "!**/.vscode/**",
+      "!**/.turbo",
+      "!**/.vscode",
       "!**/turbo.json",
-      "!**/.claude/**",
-      "!**/.next/**",
-      "!**/.terraform/**",
-      "!bin/**",
+      "!**/.claude",
+      "!**/.next",
+      "!**/.terraform",
+      "!bin",
       "!tsconfig.base.json",
-      "!**/.devcontainer/**",
+      "!**/.devcontainer",
       "!**/.eslintrc.js",
       "!**/.stylelintrc.json",
       "!**/package.json",
-      "!apps/app/src/styles/prebuilt/**",
-      "!apps/app/tmp/**",
-      "!apps/pdf-converter/specs/**",
-      "!apps/slackbot-proxy/src/public/bootstrap/**",
+      "!apps/app/src/styles/prebuilt",
+      "!apps/app/tmp",
+      "!apps/pdf-converter/specs",
+      "!apps/slackbot-proxy/src/public/bootstrap",
       "!packages/pdf-converter-client/src/index.ts",
-      "!packages/pdf-converter-client/specs/**",
-      "!apps/app/playwright/**",
-      "!apps/app/src/client/**",
-      "!apps/app/src/components/**",
-      "!apps/app/src/features/openai/**",
-      "!apps/app/src/pages/**",
-      "!apps/app/src/server/**",
-      "!apps/app/src/services/**",
-      "!apps/app/src/stores/**",
-      "!apps/app/src/styles/**",
-      "!apps/app/test-with-vite/**",
-      "!apps/app/tmp/**"
+      "!packages/pdf-converter-client/specs",
+      "!apps/app/playwright",
+      "!apps/app/src/client",
+      "!apps/app/src/components",
+      "!apps/app/src/features/openai",
+      "!apps/app/src/pages",
+      "!apps/app/src/server",
+      "!apps/app/src/services",
+      "!apps/app/src/stores",
+      "!apps/app/src/styles",
+      "!apps/app/test-with-vite",
+      "!apps/app/tmp"
     ]
   },
   "formatter": {
@@ -48,50 +48,17 @@
   "assist": {
     "actions": {
       "source": {
-        "organizeImports": {
-          "level": "on",
-          "options": {
-            "groups": [
-              [
-                "react",
-                "react/**"
-              ],
-              [
-                "next",
-                "next/**"
-              ],
-              [
-                ":NODE:",
-                ":PACKAGE:"
-              ],
-              ":BLANK_LINE:",
-              [
-                "@/**",
-                "^/**"
-              ],
-              ":BLANK_LINE:",
-              "~/**",
-              ":BLANK_LINE:",
-              [
-                ":PATH:",
-                "!**/*.css",
-                "!**/*.scss"
-              ],
-              ":BLANK_LINE:",
-              [
-                "**/*.css",
-                "**/*.scss"
-              ]
-            ]
-          }
-        }
+        "organizeImports": "on"
       }
     }
   },
   "linter": {
     "enabled": true,
     "rules": {
-      "recommended": true
+      "recommended": true,
+      "correctness": {
+        "useUniqueElementIds": "warn"
+      }
     }
   },
   "javascript": {
@@ -103,15 +70,60 @@
     }
   },
   "overrides": [
+    {
+      "includes": ["apps/pdf-converter/**", "./apps/slackbot-proxy/**"],
+      "linter": {
+        "rules": {
+          "style": {
+            "useImportType": "off"
+          }
+        }
+      }
+    },
+    {
+      "includes": ["apps/app/**"],
+      "linter": {
+        "rules": {
+          "style": {
+            "noRestrictedImports": {
+              "level": "error",
+              "options": {
+                "paths": {
+                  "axios": "Please use src/utils/axios instead."
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     {
       "includes": [
-        "apps/pdf-converter/**",
-        "./apps/slackbot-proxy/**"
+        "apps/app/src/stores-universal/**",
+        "apps/app/src/utils/**",
+        "apps/app/src/models/**",
+        "apps/app/src/services/**",
+        "apps/app/src/pages/**",
+        "apps/app/src/server/**",
+        "apps/app/src/components/**"
       ],
       "linter": {
         "rules": {
           "style": {
-            "useImportType": "off"
+            "noRestrictedImports": {
+              "level": "error",
+              "options": {
+                "paths": {
+                  "axios": "Please use src/utils/axios instead."
+                },
+                "patterns": [
+                  {
+                    "group": ["~/client/*", "client/*"],
+                    "message": "Importing from client/ directories is restricted."
+                  }
+                ]
+              }
+            }
           }
         }
       }

+ 2 - 1
package.json

@@ -42,7 +42,7 @@
     "vite-plugin-dts": "v4.2.1 causes the unexpected error 'Cannot find package 'vue-tsc''"
   },
   "devDependencies": {
-    "@biomejs/biome": "2.0.6",
+    "@biomejs/biome": "^2.2.6",
     "@changesets/changelog-github": "^0.5.0",
     "@changesets/cli": "^2.27.3",
     "@faker-js/faker": "^9.0.1",
@@ -56,6 +56,7 @@
     "@types/css-modules": "^1.0.2",
     "@types/eslint": "^8.37.0",
     "@types/estree": "^1.0.1",
+    "@types/glob": "^8.1.0",
     "@types/node": "^20.18.3",
     "@types/path-browserify": "^1.0.0",
     "@typescript-eslint/eslint-plugin": "^5.59.7",

+ 1 - 2
packages/editor/.eslintignore

@@ -1,2 +1 @@
-/dist/**
-vite-env.d.ts
+*

+ 0 - 13
packages/editor/.eslintrc.cjs

@@ -1,13 +0,0 @@
-module.exports = {
-  env: { browser: true, es2020: true },
-  extends: [
-    'weseek/react',
-  ],
-  plugins: ['react-refresh'],
-  rules: {
-    'react-refresh/only-export-components': [
-      'warn',
-      { allowConstantExport: true },
-    ],
-  },
-};

+ 1 - 1
packages/editor/package.json

@@ -13,7 +13,7 @@
     "dev": "vite build --mode dev",
     "watch": "pnpm run dev -w --emptyOutDir=false",
     "serve": "vite",
-    "lint:js": "eslint **/*.{js,ts}",
+    "lint:js": "biome check",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint": "npm-run-all -p lint:*"
   },

+ 59 - 45
packages/editor/src/client/components-internal/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -1,64 +1,68 @@
 import type { DetailedHTMLProps, JSX } from 'react';
-import {
-  forwardRef, useMemo, useRef, useEffect,
-} from 'react';
-
+import { forwardRef, useEffect, useMemo, useRef } from 'react';
 import { indentUnit } from '@codemirror/language';
-import {
-  EditorView,
-} from '@codemirror/view';
+import { EditorView } from '@codemirror/view';
 import { AcceptedUploadFileType } from '@growi/core';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
-import { PasteMode, type EditorSettings, type GlobalCodeMirrorEditorKey } from '../../../consts';
 import {
-  useFileDropzone, FileDropzoneOverlay, useShowTableIcon, getStrFromBol, adjustPasteData,
+  type EditorSettings,
+  type GlobalCodeMirrorEditorKey,
+  PasteMode,
+} from '../../../consts';
+import {
+  adjustPasteData,
+  FileDropzoneOverlay,
+  getStrFromBol,
+  useFileDropzone,
+  useShowTableIcon,
 } from '../../services-internal';
 import { useCodeMirrorEditorIsolated } from '../../stores/codemirror-editor';
 import { useDefaultExtensions } from '../../stores/use-default-extensions';
 import { useEditorSettings } from '../../stores/use-editor-settings';
-
 import { Toolbar } from './Toolbar';
 
-
 import style from './CodeMirrorEditor.module.scss';
 
 const moduleClass = style['codemirror-editor'];
 
-
 // Fix IME cursor position issue by EditContext
 // ref: https://github.com/growilabs/growi/pull/9267
 // ref: https://discuss.codemirror.net/t/issue-with-google-japanese-ime-cursor-position-in-v6/8810/3
 (EditorView as unknown as { EDIT_CONTEXT: boolean }).EDIT_CONTEXT = false;
 
-
-const CodeMirrorEditorContainer = forwardRef<HTMLDivElement, DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>>(
-  (props, ref) => {
-    const { className = '', ...rest } = props;
-    return (
-      <div className={`${className} flex-expand-vert ${style['codemirror-editor-container']}`} ref={ref} {...rest} />
-    );
-  },
-);
+const CodeMirrorEditorContainer = forwardRef<
+  HTMLDivElement,
+  DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
+>((props, ref) => {
+  const { className = '', ...rest } = props;
+  return (
+    <div
+      className={`${className} flex-expand-vert ${style['codemirror-editor-container']}`}
+      ref={ref}
+      {...rest}
+    />
+  );
+});
 
 export type CodeMirrorEditorProps = {
   /**
    * Specity the props for the react-codemirror component. **This must be a memolized object.**
    */
-  cmProps?: ReactCodeMirrorProps,
-  acceptedUploadFileType?: AcceptedUploadFileType,
-  indentSize?: number,
-  editorSettings?: EditorSettings,
-  onSave?: () => void,
-  onUpload?: (files: File[]) => void,
-  onScroll?: () => void,
-}
+  cmProps?: ReactCodeMirrorProps;
+  acceptedUploadFileType?: AcceptedUploadFileType;
+  indentSize?: number;
+  editorSettings?: EditorSettings;
+  onSave?: () => void;
+  onUpload?: (files: File[]) => void;
+  onScroll?: () => void;
+};
 
 type Props = CodeMirrorEditorProps & {
-  editorKey: string | GlobalCodeMirrorEditorKey,
-  className?: string,
-  hideToolbar?: boolean,
-}
+  editorKey: string | GlobalCodeMirrorEditorKey;
+  className?: string;
+  hideToolbar?: boolean;
+};
 
 export const CodeMirrorEditor = (props: Props): JSX.Element => {
   const {
@@ -77,7 +81,11 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
   const containerRef = useRef(null);
 
-  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey, containerRef.current, cmProps);
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(
+    editorKey,
+    containerRef.current,
+    cmProps,
+  );
 
   useDefaultExtensions(codeMirrorEditor);
   useEditorSettings(codeMirrorEditor, editorSettings, onSave);
@@ -92,7 +100,6 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
     const cleanupFunction = codeMirrorEditor?.appendExtensions?.(extension);
     return cleanupFunction;
-
   }, [codeMirrorEditor, indentSize]);
 
   const pasteMode = editorSettings?.pasteMode;
@@ -109,7 +116,11 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
       if (event.clipboardData.types.includes('text/plain')) {
         if (codeMirrorEditor == null) return;
 
-        if (pasteMode == null || pasteMode === PasteMode.both || pasteMode === PasteMode.text) {
+        if (
+          pasteMode == null ||
+          pasteMode === PasteMode.both ||
+          pasteMode === PasteMode.text
+        ) {
           const textData = event.clipboardData.getData('text/plain');
 
           const strFromBol = getStrFromBol(editor);
@@ -122,7 +133,11 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
       if (event.clipboardData.types.includes('Files')) {
         if (onUpload == null) return;
 
-        if (pasteMode == null || pasteMode === PasteMode.both || pasteMode === PasteMode.file) {
+        if (
+          pasteMode == null ||
+          pasteMode === PasteMode.both ||
+          pasteMode === PasteMode.file
+        ) {
           onUpload(Array.from(event.clipboardData.files));
         }
       }
@@ -134,11 +149,9 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
     const cleanupFunction = codeMirrorEditor?.appendExtensions(extension);
     return cleanupFunction;
-
   }, [codeMirrorEditor, pasteMode, onUpload]);
 
   useEffect(() => {
-
     const handleDrop = (event: DragEvent) => {
       // prevents conflicts between codemirror and react-dropzone during file drops.
       event.preventDefault();
@@ -150,11 +163,9 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
     const cleanupFunction = codeMirrorEditor?.appendExtensions(extension);
     return cleanupFunction;
-
   }, [codeMirrorEditor]);
 
   useEffect(() => {
-
     const handleScroll = (event: Event) => {
       event.preventDefault();
       if (onScroll != null) {
@@ -168,7 +179,6 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
     const cleanupFunction = codeMirrorEditor?.appendExtensions(extension);
     return cleanupFunction;
-
   }, [onScroll, codeMirrorEditor]);
 
   const {
@@ -189,7 +199,6 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
   });
 
   const fileUploadState = useMemo(() => {
-
     if (isUploading) {
       return 'dropzone-uploading';
     }
@@ -221,8 +230,13 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
   }, [isUploading, isDragAccept, isDragReject, acceptedUploadFileType]);
 
   return (
-    <div className={`${className} ${moduleClass} flex-expand-vert overflow-y-hidden`}>
-      <div {...getRootProps()} className={`dropzone  ${fileUploadState} flex-expand-vert`}>
+    <div
+      className={`${className} ${moduleClass} flex-expand-vert overflow-y-hidden`}
+    >
+      <div
+        {...getRootProps()}
+        className={`dropzone  ${fileUploadState} flex-expand-vert`}
+      >
         <input {...getInputProps()} />
         <FileDropzoneOverlay isEnabled={isDragActive} />
         <CodeMirrorEditorContainer ref={containerRef} />

+ 18 - 24
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/AttachmentsDropdownItem.tsx

@@ -1,36 +1,26 @@
-import type { ReactNode, JSX } from 'react';
-
+import type { JSX, ReactNode } from 'react';
 import type { AcceptedUploadFileType } from '@growi/core';
-import {
-  DropdownItem,
-} from 'reactstrap';
+import { DropdownItem } from 'reactstrap';
 
 import { useFileDropzone } from '../../../services-internal';
 
 type Props = {
-  acceptedUploadFileType: AcceptedUploadFileType,
-  children?: ReactNode,
-  onUpload?: (files: File[]) => void,
-  onClose?: () => void,
-}
+  acceptedUploadFileType: AcceptedUploadFileType;
+  children?: ReactNode;
+  onUpload?: (files: File[]) => void;
+  onClose?: () => void;
+};
 
 export const AttachmentsDropdownItem = (props: Props): JSX.Element => {
+  const { acceptedUploadFileType, children, onUpload, onClose } = props;
 
-  const {
-    acceptedUploadFileType,
-    children,
-    onUpload,
-    onClose,
-  } = props;
-
-  const {
-    getRootProps,
-    getInputProps,
-    open,
-  } = useFileDropzone({
+  const { getRootProps, getInputProps, open } = useFileDropzone({
     // close after uploading
     // https://github.com/growilabs/growi/pull/8564
-    onUpload: (files: File[]) => { onUpload?.(files); onClose?.() },
+    onUpload: (files: File[]) => {
+      onUpload?.(files);
+      onClose?.();
+    },
     acceptedUploadFileType,
     dropzoneOpts: {
       noClick: true,
@@ -45,7 +35,11 @@ export const AttachmentsDropdownItem = (props: Props): JSX.Element => {
   return (
     <div {...getRootProps()} className="dropzone">
       <input {...getInputProps()} />
-      <DropdownItem toggle={false} className="d-flex gap-2 align-items-center" onClick={open}>
+      <DropdownItem
+        toggle={false}
+        className="d-flex gap-2 align-items-center"
+        onClick={open}
+      >
         {children}
       </DropdownItem>
     </div>

+ 35 - 20
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/AttachmentsDropup.tsx

@@ -1,15 +1,13 @@
-import { useState, type JSX } from 'react';
-
+import { type JSX, useState } from 'react';
 import { AcceptedUploadFileType } from '@growi/core';
 import {
-  DropdownToggle,
-  DropdownMenu,
-  DropdownItem,
   Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
 } from 'reactstrap';
 
 import type { GlobalCodeMirrorEditorKey } from '../../../../consts';
-
 import { AttachmentsDropdownItem } from './AttachmentsDropdownItem';
 import { LinkEditButton } from './LinkEditButton';
 
@@ -17,12 +15,11 @@ import styles from './AttachmentsDropup.module.scss';
 
 const btnAttachmentToggleClass = styles['btn-attachment-toggle'];
 
-
 type Props = {
-  editorKey: string | GlobalCodeMirrorEditorKey,
-  acceptedUploadFileType: AcceptedUploadFileType,
-  onUpload?: (files: File[]) => void,
-}
+  editorKey: string | GlobalCodeMirrorEditorKey;
+  acceptedUploadFileType: AcceptedUploadFileType;
+  onUpload?: (files: File[]) => void;
+};
 
 export const AttachmentsDropup = (props: Props): JSX.Element => {
   const { acceptedUploadFileType, editorKey, onUpload } = props;
@@ -31,8 +28,16 @@ export const AttachmentsDropup = (props: Props): JSX.Element => {
 
   return (
     <>
-      <Dropdown isOpen={isOpen} toggle={() => setOpen(!isOpen)} direction="up" className="lh-1">
-        <DropdownToggle className={`${btnAttachmentToggleClass} btn-toolbar-button rounded-circle`} color="unset">
+      <Dropdown
+        isOpen={isOpen}
+        toggle={() => setOpen(!isOpen)}
+        direction="up"
+        className="lh-1"
+      >
+        <DropdownToggle
+          className={`${btnAttachmentToggleClass} btn-toolbar-button rounded-circle`}
+          color="unset"
+        >
           <span className="material-symbols-outlined fs-6">add</span>
         </DropdownToggle>
         <DropdownMenu>
@@ -42,19 +47,29 @@ export const AttachmentsDropup = (props: Props): JSX.Element => {
 
           <DropdownItem divider />
 
-          { acceptedUploadFileType === AcceptedUploadFileType.ALL && (
-            <AttachmentsDropdownItem acceptedUploadFileType={AcceptedUploadFileType.ALL} onUpload={onUpload} onClose={() => setOpen(false)}>
-              <span className="material-symbols-outlined fs-5">attach_file</span>
+          {acceptedUploadFileType === AcceptedUploadFileType.ALL && (
+            <AttachmentsDropdownItem
+              acceptedUploadFileType={AcceptedUploadFileType.ALL}
+              onUpload={onUpload}
+              onClose={() => setOpen(false)}
+            >
+              <span className="material-symbols-outlined fs-5">
+                attach_file
+              </span>
               Files
             </AttachmentsDropdownItem>
-          ) }
+          )}
 
-          { acceptedUploadFileType !== AcceptedUploadFileType.NONE && (
-            <AttachmentsDropdownItem acceptedUploadFileType={AcceptedUploadFileType.IMAGE} onUpload={onUpload} onClose={() => setOpen(false)}>
+          {acceptedUploadFileType !== AcceptedUploadFileType.NONE && (
+            <AttachmentsDropdownItem
+              acceptedUploadFileType={AcceptedUploadFileType.IMAGE}
+              onUpload={onUpload}
+              onClose={() => setOpen(false)}
+            >
               <span className="material-symbols-outlined fs-5">image</span>
               Images
             </AttachmentsDropdownItem>
-          ) }
+          )}
 
           <LinkEditButton editorKey={editorKey} />
         </DropdownMenu>

+ 8 - 4
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/DiagramButton.tsx

@@ -1,10 +1,10 @@
-import { useCallback, type JSX } from 'react';
+import { type JSX, useCallback } from 'react';
 
 import { useDrawioModalForEditorActions } from '../../../../states/modal/drawio-for-editor';
 
 type Props = {
-  editorKey: string,
-}
+  editorKey: string;
+};
 
 export const DiagramButton = (props: Props): JSX.Element => {
   const { editorKey } = props;
@@ -13,7 +13,11 @@ export const DiagramButton = (props: Props): JSX.Element => {
     openDrawioModal(editorKey);
   }, [editorKey, openDrawioModal]);
   return (
-    <button type="button" className="btn btn-toolbar-button" onClick={onClickDiagramButton}>
+    <button
+      type="button"
+      className="btn btn-toolbar-button"
+      onClick={onClickDiagramButton}
+    >
       {/* TODO: chack and fix font-size. see: https://redmine.weseek.co.jp/issues/143015 */}
       <span className="growi-custom-icons fs-6">drawer_io</span>
     </button>

+ 20 - 20
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/EmojiButton.tsx

@@ -1,8 +1,4 @@
-import {
-  useState, useCallback,
-  type CSSProperties, type JSX,
-} from 'react';
-
+import { type CSSProperties, type JSX, useCallback, useState } from 'react';
 import emojiData from '@emoji-mart/data';
 import Picker from '@emoji-mart/react';
 import { Modal } from 'reactstrap';
@@ -11,8 +7,8 @@ import { useResolvedTheme } from '../../../../states/ui/resolved-theme';
 import { useCodeMirrorEditorIsolated } from '../../../stores/codemirror-editor';
 
 type Props = {
-  editorKey: string,
-}
+  editorKey: string;
+};
 
 export const EmojiButton = (props: Props): JSX.Element => {
   const { editorKey } = props;
@@ -23,20 +19,20 @@ export const EmojiButton = (props: Props): JSX.Element => {
   const resolvedTheme = useResolvedTheme();
   const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen]);
 
-  const selectEmoji = useCallback((emoji: { shortcodes: string }): void => {
-
-    if (!isOpen) {
-      return;
-    }
+  const selectEmoji = useCallback(
+    (emoji: { shortcodes: string }): void => {
+      if (!isOpen) {
+        return;
+      }
 
-    codeMirrorEditor?.insertText(emoji.shortcodes);
-
-    toggle();
-  }, [isOpen, toggle, codeMirrorEditor]);
+      codeMirrorEditor?.insertText(emoji.shortcodes);
 
+      toggle();
+    },
+    [isOpen, toggle, codeMirrorEditor],
+  );
 
   const setStyle = useCallback((): CSSProperties => {
-
     const view = codeMirrorEditor?.view;
     const cursorIndex = view?.state.selection.main.head;
 
@@ -73,10 +69,14 @@ export const EmojiButton = (props: Props): JSX.Element => {
       <button type="button" className="btn btn-toolbar-button" onClick={toggle}>
         <span className="material-symbols-outlined fs-5">emoji_emotions</span>
       </button>
-      { isOpen
-      && (
+      {isOpen && (
         <div className="mb-2 d-none d-md-block">
-          <Modal isOpen={isOpen} toggle={toggle} backdropClassName="emoji-picker-modal" fade={false}>
+          <Modal
+            isOpen={isOpen}
+            toggle={toggle}
+            backdropClassName="emoji-picker-modal"
+            fade={false}
+          >
             <span style={setStyle()}>
               <Picker
                 onEmojiSelect={selectEmoji}

+ 7 - 5
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/LinkEditButton.tsx

@@ -1,5 +1,4 @@
-import { useCallback, type JSX } from 'react';
-
+import { type JSX, useCallback } from 'react';
 import { DropdownItem } from 'reactstrap';
 
 import type { GlobalCodeMirrorEditorKey } from '../../../../consts';
@@ -9,8 +8,8 @@ import { useCodeMirrorEditorIsolated } from '../../../stores/codemirror-editor';
 
 
 type Props = {
-  editorKey: string | GlobalCodeMirrorEditorKey,
-}
+  editorKey: string | GlobalCodeMirrorEditorKey;
+};
 
 export const LinkEditButton = (props: Props): JSX.Element => {
   const { editorKey } = props;
@@ -33,7 +32,10 @@ export const LinkEditButton = (props: Props): JSX.Element => {
   }, [codeMirrorEditor?.view, openLinkEditModal]);
 
   return (
-    <DropdownItem className="d-flex gap-2 align-items-center" onClick={onClickOpenLinkEditModal}>
+    <DropdownItem
+      className="d-flex gap-2 align-items-center"
+      onClick={onClickOpenLinkEditModal}
+    >
       <span className="material-symbols-outlined fs-5">link</span>Link
     </DropdownItem>
   );

+ 8 - 5
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/TableButton.tsx

@@ -1,12 +1,11 @@
-import { useCallback, type JSX } from 'react';
+import { type JSX, useCallback } from 'react';
 
 import { useHandsontableModalForEditorActions } from '../../../../states/modal/handsontable';
 import { useCodeMirrorEditorIsolated } from '../../../stores/codemirror-editor';
 
-
 type Props = {
-  editorKey: string,
-}
+  editorKey: string;
+};
 
 export const TableButton = (props: Props): JSX.Element => {
   const { editorKey } = props;
@@ -18,7 +17,11 @@ export const TableButton = (props: Props): JSX.Element => {
   }, [editor, openTableModal]);
 
   return (
-    <button type="button" className="btn btn-toolbar-button" onClick={onClickTableButton}>
+    <button
+      type="button"
+      className="btn btn-toolbar-button"
+      onClick={onClickTableButton}
+    >
       <span className="material-symbols-outlined fs-5">table_chart</span>
     </button>
   );

+ 11 - 5
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/TemplateButton.tsx

@@ -1,11 +1,11 @@
-import { useCallback, type JSX } from 'react';
+import { type JSX, useCallback } from 'react';
 
 import { useTemplateModalActions } from '../../../../states/modal/template';
 import { useCodeMirrorEditorIsolated } from '../../../stores/codemirror-editor';
 
 type Props = {
-  editorKey: string,
-}
+  editorKey: string;
+};
 
 export const TemplateButton = (props: Props): JSX.Element => {
   const { editorKey } = props;
@@ -15,14 +15,20 @@ export const TemplateButton = (props: Props): JSX.Element => {
   const onClickTempleteButton = useCallback(() => {
     const editor = codeMirrorEditor?.view;
     if (editor != null) {
-      const insertText = (text: string) => editor.dispatch(editor.state.replaceSelection(text));
+      const insertText = (text: string) =>
+        editor.dispatch(editor.state.replaceSelection(text));
       const onSubmit = (templateText: string) => insertText(templateText);
       openTemplateModal({ onSubmit });
     }
   }, [codeMirrorEditor?.view, openTemplateModal]);
 
   return (
-    <button type="button" className="btn btn-toolbar-button" onClick={onClickTempleteButton} data-testid="open-template-button">
+    <button
+      type="button"
+      className="btn btn-toolbar-button"
+      onClick={onClickTempleteButton}
+      data-testid="open-template-button"
+    >
       <span className="material-symbols-outlined fs-5">file_copy</span>
     </button>
   );

+ 75 - 25
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/TextFormatTools.tsx

@@ -1,5 +1,4 @@
-import { useCallback, useState, type JSX } from 'react';
-
+import { type JSX, useCallback, useState } from 'react';
 import { Collapse } from 'reactstrap';
 
 import type { GlobalCodeMirrorEditorKey } from '../../../../consts';
@@ -10,12 +9,11 @@ import styles from './TextFormatTools.module.scss';
 const btnTextFormatToolsTogglerClass = styles['btn-text-format-tools-toggler'];
 
 type TogglarProps = {
-  isOpen: boolean,
-  onClick?: () => void,
-}
+  isOpen: boolean;
+  onClick?: () => void;
+};
 
 const TextFormatToolsToggler = (props: TogglarProps): JSX.Element => {
-
   const { isOpen, onClick } = props;
 
   const activeClass = isOpen ? 'active' : '';
@@ -32,9 +30,9 @@ const TextFormatToolsToggler = (props: TogglarProps): JSX.Element => {
 };
 
 type TextFormatToolsType = {
-  editorKey: string | GlobalCodeMirrorEditorKey,
-  onTextFormatToolsCollapseChange: () => void,
-}
+  editorKey: string | GlobalCodeMirrorEditorKey;
+  onTextFormatToolsCollapseChange: () => void;
+};
 
 export const TextFormatTools = (props: TextFormatToolsType): JSX.Element => {
   const { editorKey, onTextFormatToolsCollapseChange } = props;
@@ -42,14 +40,17 @@ export const TextFormatTools = (props: TextFormatToolsType): JSX.Element => {
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
 
   const toggle = useCallback(() => {
-    setOpen(bool => !bool);
+    setOpen((bool) => !bool);
   }, []);
 
   const onClickInsertMarkdownElements = (prefix: string, suffix: string) => {
     codeMirrorEditor?.insertMarkdownElements(prefix, suffix);
   };
 
-  const onClickInsertPrefix = (prefix: string, noSpaceIfPrefixExists?: boolean) => {
+  const onClickInsertPrefix = (
+    prefix: string,
+    noSpaceIfPrefixExists?: boolean,
+  ) => {
     codeMirrorEditor?.insertPrefix(prefix, noSpaceIfPrefixExists);
   };
 
@@ -57,35 +58,84 @@ export const TextFormatTools = (props: TextFormatToolsType): JSX.Element => {
     <div className="d-flex">
       <TextFormatToolsToggler isOpen={isOpen} onClick={toggle} />
 
-      <Collapse isOpen={isOpen} horizontal onEntered={onTextFormatToolsCollapseChange} onExited={onTextFormatToolsCollapseChange}>
+      <Collapse
+        isOpen={isOpen}
+        horizontal
+        onEntered={onTextFormatToolsCollapseChange}
+        onExited={onTextFormatToolsCollapseChange}
+      >
         <div className="d-flex px-1 gap-1" style={{ width: '220px' }}>
-          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertMarkdownElements('**', '**')}>
+          <button
+            type="button"
+            className="btn btn-toolbar-button"
+            onClick={() => onClickInsertMarkdownElements('**', '**')}
+          >
             <span className="material-symbols-outlined fs-5">format_bold</span>
           </button>
-          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertMarkdownElements('*', '*')}>
-            <span className="material-symbols-outlined fs-5">format_italic</span>
+          <button
+            type="button"
+            className="btn btn-toolbar-button"
+            onClick={() => onClickInsertMarkdownElements('*', '*')}
+          >
+            <span className="material-symbols-outlined fs-5">
+              format_italic
+            </span>
           </button>
-          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertMarkdownElements('~', '~')}>
-            <span className="material-symbols-outlined fs-5">format_strikethrough</span>
+          <button
+            type="button"
+            className="btn btn-toolbar-button"
+            onClick={() => onClickInsertMarkdownElements('~', '~')}
+          >
+            <span className="material-symbols-outlined fs-5">
+              format_strikethrough
+            </span>
           </button>
-          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertPrefix('#', true)}>
+          <button
+            type="button"
+            className="btn btn-toolbar-button"
+            onClick={() => onClickInsertPrefix('#', true)}
+          >
             {/* TODO: chack and fix font-size. see: https://redmine.weseek.co.jp/issues/143015 */}
             <span className="growi-custom-icons">header</span>
           </button>
-          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertMarkdownElements('`', '`')}>
+          <button
+            type="button"
+            className="btn btn-toolbar-button"
+            onClick={() => onClickInsertMarkdownElements('`', '`')}
+          >
             <span className="material-symbols-outlined fs-5">code</span>
           </button>
-          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertPrefix('-')}>
-            <span className="material-symbols-outlined fs-5">format_list_bulleted</span>
+          <button
+            type="button"
+            className="btn btn-toolbar-button"
+            onClick={() => onClickInsertPrefix('-')}
+          >
+            <span className="material-symbols-outlined fs-5">
+              format_list_bulleted
+            </span>
           </button>
-          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertPrefix('1.')}>
-            <span className="material-symbols-outlined fs-5">format_list_numbered</span>
+          <button
+            type="button"
+            className="btn btn-toolbar-button"
+            onClick={() => onClickInsertPrefix('1.')}
+          >
+            <span className="material-symbols-outlined fs-5">
+              format_list_numbered
+            </span>
           </button>
-          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertPrefix('>')}>
+          <button
+            type="button"
+            className="btn btn-toolbar-button"
+            onClick={() => onClickInsertPrefix('>')}
+          >
             {/* TODO: chack and fix font-size. see: https://redmine.weseek.co.jp/issues/143015 */}
             <span className="growi-custom-icons">format_quote</span>
           </button>
-          <button type="button" className="btn btn-toolbar-button" onClick={() => onClickInsertPrefix('- [ ]')}>
+          <button
+            type="button"
+            className="btn btn-toolbar-button"
+            onClick={() => onClickInsertPrefix('- [ ]')}
+          >
             <span className="material-symbols-outlined fs-5">checklist</span>
           </button>
         </div>

+ 25 - 14
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/Toolbar.tsx

@@ -1,12 +1,8 @@
-import {
-  memo, useCallback, useRef, type JSX,
-} from 'react';
-
+import { type JSX, memo, useCallback, useRef } from 'react';
 import type { AcceptedUploadFileType } from '@growi/core';
 import SimpleBar from 'simplebar-react';
 
 import type { GlobalCodeMirrorEditorKey } from '../../../../consts';
-
 import { AttachmentsDropup } from './AttachmentsDropup';
 import { DiagramButton } from './DiagramButton';
 import { EmojiButton } from './EmojiButton';
@@ -17,10 +13,10 @@ import { TextFormatTools } from './TextFormatTools';
 import styles from './Toolbar.module.scss';
 
 type Props = {
-  editorKey: string | GlobalCodeMirrorEditorKey,
-  acceptedUploadFileType: AcceptedUploadFileType,
-  onUpload?: (files: File[]) => void,
-}
+  editorKey: string | GlobalCodeMirrorEditorKey;
+  acceptedUploadFileType: AcceptedUploadFileType;
+  onUpload?: (files: File[]) => void;
+};
 
 export const Toolbar = memo((props: Props): JSX.Element => {
   const { editorKey, acceptedUploadFileType, onUpload } = props;
@@ -30,16 +26,31 @@ export const Toolbar = memo((props: Props): JSX.Element => {
     if (simpleBarRef.current) {
       simpleBarRef.current.recalculate();
     }
-  }, [simpleBarRef]);
+  }, []);
 
   return (
     <>
-      <div className={`d-flex gap-2 py-1 px-2 px-md-3 border-top ${styles['codemirror-editor-toolbar']} align-items-center`}>
-        <AttachmentsDropup editorKey={editorKey} onUpload={onUpload} acceptedUploadFileType={acceptedUploadFileType} />
+      <div
+        className={`d-flex gap-2 py-1 px-2 px-md-3 border-top ${styles['codemirror-editor-toolbar']} align-items-center`}
+      >
+        <AttachmentsDropup
+          editorKey={editorKey}
+          onUpload={onUpload}
+          acceptedUploadFileType={acceptedUploadFileType}
+        />
         <div className="flex-grow-1">
-          <SimpleBar ref={simpleBarRef} autoHide style={{ overflowY: 'hidden' }}>
+          <SimpleBar
+            ref={simpleBarRef}
+            autoHide
+            style={{ overflowY: 'hidden' }}
+          >
             <div className="d-flex gap-2">
-              <TextFormatTools editorKey={editorKey} onTextFormatToolsCollapseChange={onTextFormatToolsCollapseChange} />
+              <TextFormatTools
+                editorKey={editorKey}
+                onTextFormatToolsCollapseChange={
+                  onTextFormatToolsCollapseChange
+                }
+              />
               <EmojiButton editorKey={editorKey} />
               <TableButton editorKey={editorKey} />
               <DiagramButton editorKey={editorKey} />

+ 56 - 22
packages/editor/src/client/components-internal/playground/Playground.tsx

@@ -1,24 +1,22 @@
-import {
-  useCallback, useEffect, useMemo, useState, type JSX,
-} from 'react';
-
 import { AcceptedUploadFileType } from '@growi/core';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
+import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
 import { toast } from 'react-toastify';
 
-import { GlobalCodeMirrorEditorKey } from '../../../consts';
 import type {
-  EditorSettings, EditorTheme, KeyMapMode, PasteMode,
+  EditorSettings,
+  EditorTheme,
+  KeyMapMode,
+  PasteMode,
 } from '../../../consts';
+import { GlobalCodeMirrorEditorKey } from '../../../consts';
 import { CodeMirrorEditorMain } from '../../components/CodeMirrorEditorMain';
 import { useCodeMirrorEditorIsolated } from '../../stores/codemirror-editor';
-
 import { PlaygroundController } from './PlaygroundController';
 import { Preview } from './Preview';
 import { useSetupPlaygroundSocket } from './states/socket';
 
 export const Playground = (): JSX.Element => {
-
   const [markdownToPreview, setMarkdownToPreview] = useState('');
   const [editorTheme, setEditorTheme] = useState<EditorTheme>('defaultlight');
   const [editorKeymap, setEditorKeymap] = useState<KeyMapMode>('default');
@@ -26,7 +24,9 @@ export const Playground = (): JSX.Element => {
   const [enableUnifiedMergeView, setUnifiedMergeViewEnabled] = useState(false);
   const [editorSettings, setEditorSettings] = useState<EditorSettings>();
 
-  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(
+    GlobalCodeMirrorEditorKey.MAIN,
+  );
 
   // Initialize playground socket
   useSetupPlaygroundSocket();
@@ -46,6 +46,29 @@ export const Playground = (): JSX.Element => {
     });
   }, [setEditorSettings, editorKeymap, editorTheme, editorPaste]);
 
+  // initialize global socket
+  useEffect(() => {
+    const setUpSocket = async () => {
+      const { io } = await import('socket.io-client');
+      const socket = io(GLOBAL_SOCKET_NS, {
+        transports: ['websocket'],
+      });
+
+      // eslint-disable-next-line no-console
+      socket.on('error', (err) => {
+        console.error(err);
+      });
+      // eslint-disable-next-line no-console
+      socket.on('connect_error', (err) => {
+        console.error('Failed to connect with websocket.', err);
+      });
+
+      mutate(socket);
+    };
+
+    setUpSocket();
+  }, [mutate]);
+
   // set handler to save with shortcut key
   const saveHandler = useCallback(() => {
     // eslint-disable-next-line no-console
@@ -55,22 +78,30 @@ export const Playground = (): JSX.Element => {
 
   // the upload event handler
   // demo of uploading a file.
-  const uploadHandler = useCallback((files: File[]) => {
-    files.forEach((file) => {
-      // set dummy file name.
-      const insertText = `[${file.name}](/attachment/aaaabbbbccccdddd)\n`;
-      codeMirrorEditor?.insertText(insertText);
-    });
-
-  }, [codeMirrorEditor]);
+  const uploadHandler = useCallback(
+    (files: File[]) => {
+      files.forEach((file) => {
+        // set dummy file name.
+        const insertText = `[${file.name}](/attachment/aaaabbbbccccdddd)\n`;
+        codeMirrorEditor?.insertText(insertText);
+      });
+    },
+    [codeMirrorEditor],
+  );
 
-  const cmProps = useMemo<ReactCodeMirrorProps>(() => ({
-    onChange: setMarkdownToPreview,
-  }), []);
+  const cmProps = useMemo<ReactCodeMirrorProps>(
+    () => ({
+      onChange: setMarkdownToPreview,
+    }),
+    [],
+  );
 
   return (
     <div className="d-flex flex-column vw-100 flex-expand-vh-100">
-      <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '83px' }}>
+      <div
+        className="flex-expand-vert justify-content-center align-items-center bg-dark"
+        style={{ minHeight: '83px' }}
+      >
         <div className="text-white">GrowiSubNavigation</div>
       </div>
       <div className="flex-expand-horiz">
@@ -98,7 +129,10 @@ export const Playground = (): JSX.Element => {
           />
         </div>
       </div>
-      <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '50px' }}>
+      <div
+        className="flex-expand-vert justify-content-center align-items-center bg-dark"
+        style={{ minHeight: '50px' }}
+      >
         <div className="text-white">EditorNavbarBottom</div>
       </div>
     </div>

+ 10 - 8
packages/editor/src/client/components-internal/playground/PlaygroundController.tsx

@@ -1,6 +1,4 @@
 import type { EditorTheme, KeyMapMode, PasteMode } from '../../../consts';
-
-
 import { InitEditorValueRow } from './controller/InitEditorValueRow';
 import { KeymapControl } from './controller/KeymapControl';
 import { PasteModeControl } from './controller/PasteModeControl';
@@ -9,18 +7,22 @@ import { ThemeControl } from './controller/ThemeControl';
 import { UnifiedMergeViewControl } from './controller/UnifiedMergeViewControl';
 
 type PlaygroundControllerProps = {
-  setEditorTheme: (value: EditorTheme) => void
-  setEditorKeymap: (value: KeyMapMode) => void
-  setEditorPaste: (value: PasteMode) => void
-  setUnifiedMergeView: (value: boolean) => void
+  setEditorTheme: (value: EditorTheme) => void;
+  setEditorKeymap: (value: KeyMapMode) => void;
+  setEditorPaste: (value: PasteMode) => void;
+  setUnifiedMergeView: (value: boolean) => void;
 };
 
-export const PlaygroundController = (props: PlaygroundControllerProps): JSX.Element => {
+export const PlaygroundController = (
+  props: PlaygroundControllerProps,
+): JSX.Element => {
   return (
     <div className="container">
       <InitEditorValueRow />
       <SetCaretLineRow />
-      <UnifiedMergeViewControl onChange={bool => props.setUnifiedMergeView(bool)} />
+      <UnifiedMergeViewControl
+        onChange={(bool) => props.setUnifiedMergeView(bool)}
+      />
       <ThemeControl setEditorTheme={props.setEditorTheme} />
       <KeymapControl setEditorKeymap={props.setEditorKeymap} />
       <PasteModeControl setEditorPaste={props.setEditorPaste} />

+ 2 - 2
packages/editor/src/client/components-internal/playground/Preview.tsx

@@ -1,8 +1,8 @@
 import type { JSX } from 'react';
 
 type Props = {
-  markdown?: string,
-}
+  markdown?: string;
+};
 
 export const Preview = (props: Props): JSX.Element => {
   return (

+ 7 - 3
packages/editor/src/client/components-internal/playground/controller/KeymapControl.tsx

@@ -1,18 +1,22 @@
 import type { KeyMapMode } from '../../../../consts';
 import { AllKeyMap } from '../../../../consts';
-
 import { OutlineSecondaryButtons } from './OutlineSecondaryButtons';
 
 type KeymapControlProps = {
   setEditorKeymap: (value: KeyMapMode) => void;
 };
 
-export const KeymapControl = ({ setEditorKeymap }: KeymapControlProps): JSX.Element => {
+export const KeymapControl = ({
+  setEditorKeymap,
+}: KeymapControlProps): JSX.Element => {
   return (
     <div className="row mt-5">
       <h2>Keymaps</h2>
       <div className="col">
-        <OutlineSecondaryButtons<KeyMapMode> update={setEditorKeymap} items={AllKeyMap} />
+        <OutlineSecondaryButtons<KeyMapMode>
+          update={setEditorKeymap}
+          items={AllKeyMap}
+        />
       </div>
     </div>
   );

+ 6 - 6
packages/editor/src/client/components-internal/playground/controller/OutlineSecondaryButtons.tsx

@@ -1,15 +1,15 @@
 type OutlineSecondaryButtonsProps<V> = {
-  update: (value: V) => void,
-  items: V[],
-}
+  update: (value: V) => void;
+  items: V[];
+};
 
-export const OutlineSecondaryButtons = <V extends { toString: () => string }, >(
+export const OutlineSecondaryButtons = <V extends { toString: () => string }>(
   props: OutlineSecondaryButtonsProps<V>,
 ): JSX.Element => {
   const { update, items } = props;
   return (
     <div className="d-flex flex-wrap gap-1">
-      { items.map(item => (
+      {items.map((item) => (
         <button
           key={item.toString()}
           type="button"
@@ -18,7 +18,7 @@ export const OutlineSecondaryButtons = <V extends { toString: () => string }, >(
         >
           {item.toString()}
         </button>
-      )) }
+      ))}
     </div>
   );
 };

Некоторые файлы не были показаны из-за большого количества измененных файлов