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

Merge branch 'fix/109932-icon-for-selecting-own-language' of https://github.com/weseek/growi into fix/109932-icon-for-selecting-own-language

kaori 3 лет назад
Родитель
Сommit
810f8c9d70
60 измененных файлов с 1164 добавлено и 849 удалено
  1. 2 0
      .github/workflows/ci-app-prod.yml
  2. 3 2
      .github/workflows/ci-app.yml
  3. 1 0
      packages/app/docker/Dockerfile
  4. 1 1
      packages/app/resource/locales/en_US/sandbox-diagrams.md
  5. 1 1
      packages/app/resource/locales/ja_JP/sandbox-diagrams.md
  6. 1 1
      packages/app/resource/locales/zh_CN/sandbox-diagrams.md
  7. 50 37
      packages/app/src/client/services/page-operation.ts
  8. 0 21
      packages/app/src/client/util/editor.ts
  9. 2 2
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  10. 0 117
      packages/app/src/components/Drawio.tsx
  11. 99 157
      packages/app/src/components/Page.tsx
  12. 24 30
      packages/app/src/components/PageEditor.tsx
  13. 17 11
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  14. 1 1
      packages/app/src/components/PageEditor/ConflictDiffModal.tsx
  15. 18 5
      packages/app/src/components/PageEditor/DrawioCommunicationHelper.ts
  16. 3 7
      packages/app/src/components/PageEditor/DrawioModal.tsx
  17. 7 7
      packages/app/src/components/PageEditor/MarkdownDrawioUtil.js
  18. 38 47
      packages/app/src/components/PageEditorByHackmd.tsx
  19. 1 0
      packages/app/src/components/PageRenameModal.tsx
  20. 19 0
      packages/app/src/components/ReactMarkdownComponents/DrawioViewerWithEditButton.module.scss
  21. 69 0
      packages/app/src/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx
  22. 5 1
      packages/app/src/components/ReactMarkdownComponents/Header.tsx
  23. 7 2
      packages/app/src/components/SavePageControls.tsx
  24. 38 0
      packages/app/src/components/Script/DrawioViewerScript.tsx
  25. 0 10
      packages/app/src/interfaces/editor-settings.ts
  26. 0 8
      packages/app/src/interfaces/global.ts
  27. 0 11
      packages/app/src/interfaces/graph-viewer.ts
  28. 10 0
      packages/app/src/interfaces/page-operation.ts
  29. 17 7
      packages/app/src/pages/[[...path]].page.tsx
  30. 10 1
      packages/app/src/pages/_private-legacy-pages.page.tsx
  31. 10 1
      packages/app/src/pages/_search.page.tsx
  32. 98 87
      packages/app/src/pages/share/[[...path]].page.tsx
  33. 1 1
      packages/app/src/server/service/config-loader.ts
  34. 0 38
      packages/app/src/server/views/widget/headers/drawio.html
  35. 0 156
      packages/app/src/services/renderer/interceptor/drawio-interceptor.js
  36. 12 1
      packages/app/src/services/renderer/renderer.tsx
  37. 2 6
      packages/app/src/stores/context.tsx
  38. 6 1
      packages/app/src/stores/editor.tsx
  39. 17 7
      packages/app/src/stores/modal.tsx
  40. 0 13
      packages/app/src/styles/_page.scss
  41. 7 0
      packages/app/src/styles/theme/_apply-colors-dark.scss
  42. 7 0
      packages/app/src/styles/theme/_apply-colors-light.scss
  43. 93 50
      packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts
  44. 1 0
      packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts
  45. 1 0
      packages/remark-drawio-plugin/.eslintignore
  46. 18 0
      packages/remark-drawio-plugin/.eslintrc.js
  47. 1 0
      packages/remark-drawio-plugin/.gitignore
  48. 6 0
      packages/remark-drawio-plugin/README.md
  49. 35 0
      packages/remark-drawio-plugin/package.json
  50. 12 0
      packages/remark-drawio-plugin/src/components/DrawioViewer.module.scss
  51. 162 0
      packages/remark-drawio-plugin/src/components/DrawioViewer.tsx
  52. 5 0
      packages/remark-drawio-plugin/src/index.ts
  53. 15 0
      packages/remark-drawio-plugin/src/interfaces/graph-viewer.ts
  54. 53 0
      packages/remark-drawio-plugin/src/services/renderer/remark-drawio-plugin.ts
  55. 102 0
      packages/remark-drawio-plugin/src/utils/embed.ts
  56. 5 0
      packages/remark-drawio-plugin/src/utils/global.ts
  57. 12 0
      packages/remark-drawio-plugin/tsconfig.base.json
  58. 16 0
      packages/remark-drawio-plugin/tsconfig.build.json
  59. 10 0
      packages/remark-drawio-plugin/tsconfig.json
  60. 13 1
      yarn.lock

+ 2 - 0
.github/workflows/ci-app-prod.yml

@@ -14,6 +14,7 @@ on:
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
+      - packages/remark-drawio-plugin/**
       - packages/remark-growi-plugin/**
       - packages/remark-growi-plugin/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**
@@ -32,6 +33,7 @@ on:
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
+      - packages/remark-drawio-plugin/**
       - packages/remark-growi-plugin/**
       - packages/remark-growi-plugin/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**

+ 3 - 2
.github/workflows/ci-app.yml

@@ -16,6 +16,7 @@ on:
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
+      - packages/remark-drawio-plugin/**
       - packages/remark-growi-plugin/**
       - packages/remark-growi-plugin/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**
@@ -55,7 +56,7 @@ jobs:
 
 
       - name: lerna run lint for plugins
       - name: lerna run lint for plugins
         run: |
         run: |
-          yarn lerna run lint --scope @growi/remark-growi-plugin --scope @growi/plugin-*
+          yarn lerna run lint --scope @growi/remark-* --scope @growi/plugin-*
       - name: lerna run lint for app
       - name: lerna run lint for app
         run: |
         run: |
           yarn lerna run lint --scope @growi/app --scope @growi/codemirror-textlint --scope @growi/core --scope @growi/slack --scope @growi/ui
           yarn lerna run lint --scope @growi/app --scope @growi/codemirror-textlint --scope @growi/core --scope @growi/slack --scope @growi/ui
@@ -109,7 +110,7 @@ jobs:
 
 
       - name: lerna run test for plugins
       - name: lerna run test for plugins
         run: |
         run: |
-          yarn lerna run test --scope @growi/remark-growi-plugin --scope @growi/plugin-*
+          yarn lerna run test --scope @growi/remark-* --scope @growi/plugin-*
 
 
       - name: Test app
       - name: Test app
         working-directory: ./packages/app
         working-directory: ./packages/app

+ 1 - 0
packages/app/docker/Dockerfile

@@ -109,6 +109,7 @@ COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
 COPY packages/plugin-lsx packages/plugin-lsx
 COPY packages/plugin-lsx packages/plugin-lsx
 COPY packages/slack packages/slack
 COPY packages/slack packages/slack
 COPY packages/ui packages/ui
 COPY packages/ui packages/ui
+COPY packages/remark-drawio-plugin packages/remark-drawio-plugin
 COPY packages/remark-growi-plugin packages/remark-growi-plugin
 COPY packages/remark-growi-plugin packages/remark-growi-plugin
 COPY packages/hackmd packages/hackmd
 COPY packages/hackmd packages/hackmd
 
 

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
packages/app/resource/locales/en_US/sandbox-diagrams.md


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
packages/app/resource/locales/ja_JP/sandbox-diagrams.md


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
packages/app/resource/locales/zh_CN/sandbox-diagrams.md


+ 50 - 37
packages/app/src/client/services/page-operation.ts

@@ -1,7 +1,8 @@
 import { SubscriptionStatusType, Nullable } from '@growi/core';
 import { SubscriptionStatusType, Nullable } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
-import { OptionsToSave } from '~/interfaces/editor-settings';
+import { OptionsToSave } from '~/interfaces/page-operation';
+import { useIsEnabledUnsavedWarning } from '~/stores/editor';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { toastError } from '../util/apiNotification';
 import { toastError } from '../util/apiNotification';
@@ -118,43 +119,55 @@ type PageInfo= {
   revisionId: Nullable<string>,
   revisionId: Nullable<string>,
 }
 }
 
 
+type SaveOrUpdateFunction = (markdown: string, pageInfo: PageInfo, optionsToSave?: OptionsToSave) => any;
+
 // TODO: define return type
 // TODO: define return type
-export const saveOrUpdate = async(optionsToSave: OptionsToSave, pageInfo: PageInfo, markdown: string) => {
-  const { path, pageId, revisionId } = pageInfo;
-
-  const options = Object.assign({}, optionsToSave);
-
-  /*
-  * Note: variable "markdown" will be received from params
-  * please delete the following code after implemating HackMD editor function
-  */
-  // let markdown;
-  // if (editorMode === EditorMode.HackMD) {
-  // const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
-  // markdown = await pageEditorByHackmd.getMarkdown();
-  // // set option to sync
-  // options.isSyncRevisionToHackmd = true;
-  // revisionId = this.state.revisionIdHackmdSynced;
-  // }
-  // else {
-  // const pageEditor = this.appContainer.getComponentInstance('PageEditor');
-  // const pageEditor = getComponentInstance('PageEditor');
-  // markdown = pageEditor.getMarkdown();
-  // }
-
-  const isNoRevisionPage = pageId != null && revisionId == null;
-
-  let res;
-  if (pageId == null || isNoRevisionPage) {
-    res = await createPage(path, markdown, options);
-  }
-  else {
-    if (revisionId == null) {
-      const msg = '\'revisionId\' is required to update page';
-      throw new Error(msg);
+export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
+  /* eslint-disable react-hooks/rules-of-hooks */
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  /* eslint-enable react-hooks/rules-of-hooks */
+
+  return async function(markdown: string, pageInfo: PageInfo, optionsToSave?: OptionsToSave) {
+    const { path, pageId, revisionId } = pageInfo;
+
+    const options: OptionsToSave = Object.assign({}, optionsToSave);
+    /*
+    * Note: variable "markdown" will be received from params
+    * please delete the following code after implemating HackMD editor function
+    */
+    // let markdown;
+    // if (editorMode === EditorMode.HackMD) {
+    // const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
+    // markdown = await pageEditorByHackmd.getMarkdown();
+    // // set option to sync
+    // options.isSyncRevisionToHackmd = true;
+    // revisionId = this.state.revisionIdHackmdSynced;
+    // }
+    // else {
+    // const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+    // const pageEditor = getComponentInstance('PageEditor');
+    // markdown = pageEditor.getMarkdown();
+    // }
+
+    const isNoRevisionPage = pageId != null && revisionId == null;
+
+    let res;
+    if (pageId == null || isNoRevisionPage) {
+      res = await createPage(path, markdown, options);
+    }
+    else {
+      if (revisionId == null) {
+        const msg = '\'revisionId\' is required to update page';
+        throw new Error(msg);
+      }
+      res = await updatePage(pageId, revisionId, markdown, options);
     }
     }
-    res = await updatePage(pageId, revisionId, markdown, options);
-  }
 
 
-  return res;
+    // The updateFn should be a promise or asynchronous function to handle the remote mutation
+    // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
+    // Moreover, `async() => false` does not work since it's too fast to be calculated.
+    await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
+
+    return res;
+  };
 };
 };

+ 0 - 21
packages/app/src/client/util/editor.ts

@@ -1,21 +0,0 @@
-import type { OptionsToSave } from '~/interfaces/editor-settings';
-
-export const getOptionsToSave = (
-    isSlackEnabled: boolean,
-    slackChannels: string,
-    grant: number,
-    grantUserGroupId: string | null | undefined,
-    grantUserGroupName: string | null | undefined,
-    pageTags: string[],
-    isSyncRevisionToHackmd?: boolean,
-): OptionsToSave => {
-  return {
-    pageTags,
-    isSlackEnabled,
-    slackChannels,
-    grant,
-    grantUserGroupId,
-    grantUserGroupName,
-    isSyncRevisionToHackmd,
-  };
-};

+ 2 - 2
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -126,7 +126,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
   }, [props.userGroupRelations, props.childUserGroups]);
   }, [props.userGroupRelations, props.childUserGroups]);
 
 
   return (
   return (
-    <>
+    <div data-testid="grw-user-group-table">
       <h2>{props.headerLabel}</h2>
       <h2>{props.headerLabel}</h2>
 
 
       <table className="table table-bordered table-user-list">
       <table className="table table-bordered table-user-list">
@@ -216,6 +216,6 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
           })}
           })}
         </tbody>
         </tbody>
       </table>
       </table>
-    </>
+    </div>
   );
   );
 };
 };

+ 0 - 117
packages/app/src/components/Drawio.tsx

@@ -1,117 +0,0 @@
-import React, {
-  useCallback, useEffect, useMemo, useRef, useState,
-} from 'react';
-
-import EventEmitter from 'events';
-
-import { useTranslation } from 'next-i18next';
-import { debounce } from 'throttle-debounce';
-
-import { CustomWindow } from '~/interfaces/global';
-import { IGraphViewer, isGraphViewer } from '~/interfaces/graph-viewer';
-
-import NotAvailableForGuest from './NotAvailableForGuest';
-
-type Props = {
-  GraphViewer: IGraphViewer,
-  drawioContent: string,
-  rangeLineNumberOfMarkdown: { beginLineNumber: number, endLineNumber: number },
-  isPreview?: boolean,
-}
-
-// It calls callback when GraphViewer is not null.
-// eslint-disable-next-line @typescript-eslint/ban-types
-const waitForGraphViewer = async(callback: Function) => {
-  const MAX_WAIT_COUNT = 10; // no reason for 10
-
-  for (let i = 0; i < MAX_WAIT_COUNT; i++) {
-    if (isGraphViewer((window as CustomWindow).GraphViewer)) {
-      callback((window as CustomWindow).GraphViewer);
-      break;
-    }
-    // Sleep 500 ms
-    // eslint-disable-next-line no-await-in-loop
-    await new Promise<void>(r => setTimeout(() => r(), 500));
-  }
-};
-
-const Drawio = (props: Props): JSX.Element => {
-
-  const { t } = useTranslation();
-
-  // Wrap with a function since GraphViewer is a function.
-  // This applies when call setGraphViewer as well.
-  const [GraphViewer, setGraphViewer] = useState<IGraphViewer | undefined>(() => (window as CustomWindow).GraphViewer);
-
-  const { drawioContent, rangeLineNumberOfMarkdown, isPreview } = props;
-
-  // const { open: openDrawioModal } = useDrawioModalForPage();
-
-  const drawioContainerRef = useRef<HTMLDivElement>(null);
-
-  const globalEmitter: EventEmitter = (window as CustomWindow).globalEmitter;
-
-  const editButtonClickHandler = useCallback(() => {
-    const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
-    globalEmitter.emit('launchDrawioModal', beginLineNumber, endLineNumber);
-  }, [rangeLineNumberOfMarkdown, globalEmitter]);
-
-  const renderDrawio = useCallback((GraphViewer: IGraphViewer) => {
-    if (drawioContainerRef.current == null) {
-      return;
-    }
-
-    const mxgraphs = drawioContainerRef.current.getElementsByClassName('mxgraph');
-    if (mxgraphs.length > 0) {
-      // GROWI では、mxgraph element は最初のものをレンダリングする前提とする
-      const div = mxgraphs[0];
-
-      if (div != null) {
-        div.innerHTML = '';
-        GraphViewer.createViewerForElement(div);
-      }
-    }
-  }, [drawioContainerRef]);
-
-  const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
-
-  useEffect(() => {
-    if (GraphViewer == null) {
-      waitForGraphViewer((gv: IGraphViewer) => {
-        setGraphViewer(() => gv);
-      });
-      return;
-    }
-
-    renderDrawioWithDebounce(GraphViewer);
-  }, [renderDrawioWithDebounce, GraphViewer]);
-
-  return (
-    <div className="editable-with-drawio position-relative">
-      { !isPreview && (
-        <NotAvailableForGuest>
-          <button type="button" className="drawio-iframe-trigger position-absolute btn btn-outline-secondary" onClick={editButtonClickHandler}>
-            <i className="icon-note mr-1"></i>{t('Edit')}
-          </button>
-        </NotAvailableForGuest>
-      ) }
-      <div
-        className="drawio"
-        style={
-          {
-            borderRadius: 3,
-            border: '1px solid #d7d7d7',
-            margin: '20px 0',
-          }
-        }
-        ref={drawioContainerRef}
-        // eslint-disable-next-line react/no-danger
-        dangerouslySetInnerHTML={{ __html: drawioContent }}
-      >
-      </div>
-    </div>
-  );
-
-};
-
-export default Drawio;

+ 99 - 157
packages/app/src/components/Page.tsx

@@ -1,37 +1,39 @@
 import React, {
 import React, {
   useCallback,
   useCallback,
-  useEffect, useRef, useState,
+  useEffect, useRef,
 } from 'react';
 } from 'react';
 
 
 import EventEmitter from 'events';
 import EventEmitter from 'events';
 
 
+import { DrawioEditByViewerProps } from '@growi/remark-drawio-plugin';
+import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
-// import { debounce } from 'throttle-debounce';
-
 import { HtmlElementNode } from 'rehype-toc';
 import { HtmlElementNode } from 'rehype-toc';
 
 
+import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { getOptionsToSave } from '~/client/util/editor';
+import { OptionsToSave } from '~/interfaces/page-operation';
 import {
 import {
   useIsGuestUser, useShareLinkId,
   useIsGuestUser, useShareLinkId,
 } from '~/stores/context';
 } from '~/stores/context';
-import {
-  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
-} from '~/stores/editor';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useEditingMarkdown } from '~/stores/editor';
+import { useDrawioModal } from '~/stores/modal';
+import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useViewOptions } from '~/stores/renderer';
 import { useViewOptions } from '~/stores/renderer';
 import {
 import {
   useCurrentPageTocNode,
   useCurrentPageTocNode,
-  useEditorMode, useIsMobile,
+  useIsMobile,
 } from '~/stores/ui';
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import RevisionRenderer from './Page/RevisionRenderer';
 import RevisionRenderer from './Page/RevisionRenderer';
-import { DrawioModal } from './PageEditor/DrawioModal';
 import mdu from './PageEditor/MarkdownDrawioUtil';
 import mdu from './PageEditor/MarkdownDrawioUtil';
 
 
 
 
-declare const globalEmitter: EventEmitter;
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
 
 
 // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
@@ -39,118 +41,10 @@ const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr:
 
 
 const logger = loggerFactory('growi:Page');
 const logger = loggerFactory('growi:Page');
 
 
-type PageSubstanceProps = {
-  rendererOptions: any,
-  page: any,
-  pageTags?: string[],
-  editorMode: string,
-  isGuestUser: boolean,
-  isMobile?: boolean,
-  isSlackEnabled: boolean,
-  slackChannels: string,
-};
-
-class PageSubstance extends React.Component<PageSubstanceProps> {
-
-  gridEditModal: any;
-
-  linkEditModal: any;
-
-  drawioModal: any;
-
-  constructor(props: PageSubstanceProps) {
-    super(props);
-
-    this.state = {
-      currentTargetTableArea: null,
-      currentTargetDrawioArea: null,
-    };
-
-    this.gridEditModal = React.createRef();
-    this.linkEditModal = React.createRef();
-    this.drawioModal = React.createRef();
-
-    this.saveHandlerForDrawioModal = this.saveHandlerForDrawioModal.bind(this);
-  }
-
-  /**
-   * launch DrawioModal with data specified by arguments
-   * @param beginLineNumber
-   * @param endLineNumber
-   */
-  launchDrawioModal(beginLineNumber, endLineNumber) {
-    // const markdown = this.props.pageContainer.state.markdown;
-    // const drawioMarkdownArray = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber);
-    // const drawioData = drawioMarkdownArray.slice(1, drawioMarkdownArray.length - 1).join('\n').trim();
-    // this.setState({ currentTargetDrawioArea: { beginLineNumber, endLineNumber } });
-    // this.drawioModal.current.show(drawioData);
-  }
-
-  async saveHandlerForDrawioModal(drawioData) {
-  //   const {
-  //     isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
-  //   } = this.props;
-  //   const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
-
-    //   const newMarkdown = mdu.replaceDrawioInMarkdown(
-    //     drawioData,
-    //     this.props.pageContainer.state.markdown,
-    //     this.state.currentTargetDrawioArea.beginLineNumber,
-    //     this.state.currentTargetDrawioArea.endLineNumber,
-    //   );
-
-    //   try {
-    //     // disable unsaved warning
-    //     mutateIsEnabledUnsavedWarning(false);
-
-    //     // eslint-disable-next-line no-unused-vars
-    //     const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
-    //     logger.debug('success to save');
-
-    // // Todo: add translation
-    //   toastSuccess(t(''));
-    //   }
-  //   catch (error) {
-  //     logger.error('failed to save', error);
-  //     toastError(error);
-  //   }
-  //   finally {
-  //     this.setState({ currentTargetDrawioArea: null });
-  //   }
-  }
-
-  override render() {
-    const {
-      rendererOptions, page, isMobile, isGuestUser,
-    } = this.props;
-    const { path } = page;
-    const { _id: revisionId, body: markdown } = page.revision;
-
-    return (
-      <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
-
-        { revisionId != null && (
-          <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
-        )}
-
-        { !isGuestUser && (
-          <>
-            <GridEditModal ref={this.gridEditModal} />
-            <LinkEditModal ref={this.linkEditModal} />
-            {/* TODO: use global DrawioModal https://redmine.weseek.co.jp/issues/105981 */}
-            {/* <DrawioModal
-              ref={this.drawioModal}
-              onSave={this.saveHandlerForDrawioModal}
-            /> */}
-          </>
-        )}
-      </div>
-    );
-  }
-
-}
 
 
 export const Page = (props) => {
 export const Page = (props) => {
+  const { t } = useTranslation();
+
   // Pass tocRef to generateViewOptions (=> rehypePlugin => customizeTOC) to call mutateCurrentPageTocNode when tocRef.current changes.
   // Pass tocRef to generateViewOptions (=> rehypePlugin => customizeTOC) to call mutateCurrentPageTocNode when tocRef.current changes.
   // The toc node passed by customizeTOC is assigned to tocRef.current.
   // The toc node passed by customizeTOC is assigned to tocRef.current.
   const tocRef = useRef<HtmlElementNode>();
   const tocRef = useRef<HtmlElementNode>();
@@ -160,41 +54,86 @@ export const Page = (props) => {
   }, []);
   }, []);
 
 
   const { data: shareLinkId } = useShareLinkId();
   const { data: shareLinkId } = useShareLinkId();
-  const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
-  const { data: editorMode } = useEditorMode();
+  const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
+  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
+  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
   const { data: isMobile } = useIsMobile();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPage?.path);
-  const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageTags } = usePageTagsForEditors(null); // TODO: pass pageId
   const { data: rendererOptions } = useViewOptions(storeTocNodeHandler);
   const { data: rendererOptions } = useViewOptions(storeTocNodeHandler);
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
   const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
+  const { open: openDrawioModal } = useDrawioModal();
+
+  const saveOrUpdate = useSaveOrUpdate();
 
 
-  const pageRef = useRef(null);
 
 
   useEffect(() => {
   useEffect(() => {
     mutateCurrentPageTocNode(tocRef.current);
     mutateCurrentPageTocNode(tocRef.current);
   // eslint-disable-next-line react-hooks/exhaustive-deps
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [mutateCurrentPageTocNode, tocRef.current]); // include tocRef.current to call mutateCurrentPageTocNode when tocRef.current changes
   }, [mutateCurrentPageTocNode, tocRef.current]); // include tocRef.current to call mutateCurrentPageTocNode when tocRef.current changes
 
 
-  // // set handler to open DrawioModal
-  // useEffect(() => {
-  //   const handler = (beginLineNumber, endLineNumber) => {
-  //     if (pageRef?.current != null) {
-  //       pageRef.current.launchDrawioModal(beginLineNumber, endLineNumber);
-  //     }
-  //   };
-  //   window.globalEmitter.on('launchDrawioModal', handler);
-
-  //   return function cleanup() {
-  //     window.globalEmitter.removeListener('launchDrawioModal', handler);
-  //   };
-  // }, []);
-
-  if (currentPage == null || editorMode == null || isGuestUser == null || rendererOptions == null) {
+
+  const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
+    if (currentPage == null || tagsInfo == null) {
+      return;
+    }
+
+    // disable if share link
+    if (shareLinkId != null) {
+      return;
+    }
+
+    const currentMarkdown = currentPage.revision.body;
+    const optionsToSave: OptionsToSave = {
+      isSlackEnabled: false,
+      slackChannels: '',
+      grant: currentPage.grant,
+      grantUserGroupId: currentPage.grantedGroup?._id,
+      grantUserGroupName: currentPage.grantedGroup?.name,
+      pageTags: tagsInfo.tags,
+    };
+
+    const newMarkdown = mdu.replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
+
+    try {
+      const currentRevisionId = currentPage.revision._id;
+      await saveOrUpdate(
+        newMarkdown,
+        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
+        optionsToSave,
+      );
+
+      toastSuccess(t('toaster.save_succeeded'));
+
+      // rerender
+      mutateCurrentPage();
+      mutateEditingMarkdown(newMarkdown);
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      toastError(error);
+    }
+  }, [currentPage, mutateCurrentPage, mutateEditingMarkdown, saveOrUpdate, shareLinkId, t, tagsInfo]);
+
+  // set handler to open DrawioModal
+  useEffect(() => {
+    // disable if share link
+    if (shareLinkId != null) {
+      return;
+    }
+
+    const handler = (data: DrawioEditByViewerProps) => {
+      openDrawioModal(data.drawioMxFile, drawioMxFile => saveByDrawioModal(drawioMxFile, data.bol, data.eol));
+    };
+    globalEmitter.on('launchDrawioModal', handler);
+
+    return function cleanup() {
+      globalEmitter.removeListener('launchDrawioModal', handler);
+    };
+  }, [openDrawioModal, saveByDrawioModal, shareLinkId]);
+
+  if (currentPage == null || isGuestUser == null || rendererOptions == null) {
     const entries = Object.entries({
     const entries = Object.entries({
-      currentPage, editorMode, isGuestUser, rendererOptions,
+      currentPage, isGuestUser, rendererOptions,
     })
     })
       .map(([key, value]) => [key, value == null ? 'null' : undefined])
       .map(([key, value]) => [key, value == null ? 'null' : undefined])
       .filter(([, value]) => value != null);
       .filter(([, value]) => value != null);
@@ -203,19 +142,22 @@ export const Page = (props) => {
     return null;
     return null;
   }
   }
 
 
+  const { _id: revisionId, body: markdown } = currentPage.revision;
+
   return (
   return (
-    <PageSubstance
-      {...props}
-      ref={pageRef}
-      rendererOptions={rendererOptions}
-      page={currentPage}
-      editorMode={editorMode}
-      isGuestUser={isGuestUser}
-      isMobile={isMobile}
-      isSlackEnabled={isSlackEnabled}
-      pageTags={pageTags}
-      slackChannels={slackChannelsData?.toString()}
-      mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
-    />
+    <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
+
+      { revisionId != null && (
+        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+      )}
+
+      { !isGuestUser && (
+        <>
+          <GridEditModal />
+          <LinkEditModal />
+        </>
+      )}
+    </div>
   );
   );
+
 };
 };

+ 24 - 30
packages/app/src/components/PageEditor.tsx

@@ -12,18 +12,19 @@ import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 import { throttle, debounce } from 'throttle-debounce';
 
 
-import { saveOrUpdate } from '~/client/services/page-operation';
+import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
-import { getOptionsToSave } from '~/client/util/editor';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { IEditorMethods } from '~/interfaces/editor-methods';
+import { OptionsToSave } from '~/interfaces/page-operation';
 import {
 import {
   useCurrentPathname, useCurrentPageId, useIsEnabledAttachTitleHeader, useTemplateBodyData,
   useCurrentPathname, useCurrentPageId, useIsEnabledAttachTitleHeader, useTemplateBodyData,
-  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage, useEditingMarkdown, useIsNotFound,
+  useIsEditable, useIsUploadableFile, useIsUploadableImage, useIsNotFound, useIsIndentSizeForced,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useIsEnabledUnsavedWarning,
   useIsEnabledUnsavedWarning,
+  useEditingMarkdown,
 } from '~/stores/editor';
 } from '~/stores/editor';
 import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
 import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
 import { usePreviewOptions } from '~/stores/renderer';
 import { usePreviewOptions } from '~/stores/renderer';
@@ -43,7 +44,10 @@ import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 const logger = loggerFactory('growi:PageEditor');
 const logger = loggerFactory('growi:PageEditor');
 
 
 
 
-declare const globalEmitter: EventEmitter;
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
 
 
 
 
 // for scrolling
 // for scrolling
@@ -73,11 +77,12 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
   const { data: isUploadableImage } = useIsUploadableImage();
 
 
   const { data: rendererOptions } = usePreviewOptions();
   const { data: rendererOptions } = usePreviewOptions();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const saveOrUpdate = useSaveOrUpdate();
 
 
   const currentRevisionId = currentPage?.revision?._id;
   const currentRevisionId = currentPage?.revision?._id;
 
 
@@ -105,19 +110,6 @@ const PageEditor = React.memo((): JSX.Element => {
   const editorRef = useRef<IEditorMethods>(null);
   const editorRef = useRef<IEditorMethods>(null);
   const previewRef = useRef<HTMLDivElement>(null);
   const previewRef = useRef<HTMLDivElement>(null);
 
 
-  // const optionsToSave = useMemo(() => {
-  //   if (grantData == null) {
-  //     return;
-  //   }
-  //   const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
-  //   const optionsToSave = getOptionsToSave(
-  //     isSlackEnabled ?? false, slackChannels,
-  //     grantData.grant, grantData.grantedGroup?.id, grantData.grantedGroup?.name,
-  //     pageTags || [],
-  //   );
-  //   return optionsToSave;
-  // }, [grantData, isSlackEnabled, pageTags, slackChannelsData]);
-
   const setMarkdownWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string, isClean: boolean) => {
   const setMarkdownWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string, isClean: boolean) => {
     markdownToSave.current = value;
     markdownToSave.current = value;
     setMarkdownToPreview(value);
     setMarkdownToPreview(value);
@@ -141,16 +133,21 @@ const PageEditor = React.memo((): JSX.Element => {
     const grant = grantData.grant || PageGrant.GRANT_PUBLIC;
     const grant = grantData.grant || PageGrant.GRANT_PUBLIC;
     const grantedGroup = grantData?.grantedGroup;
     const grantedGroup = grantData?.grantedGroup;
 
 
-    const optionsToSave = Object.assign(
-      getOptionsToSave(isSlackEnabled, slackChannels, grant || 1, grantedGroup?.id, grantedGroup?.name, pageTags || []),
-      { ...opts },
-    );
+    const optionsToSave: OptionsToSave = {
+      isSlackEnabled,
+      slackChannels,
+      grant: grant || 1,
+      pageTags: pageTags || [],
+      grantUserGroupId: grantedGroup?.id,
+      grantUserGroupName: grantedGroup?.name,
+      ...opts,
+    };
 
 
     try {
     try {
       const { page } = await saveOrUpdate(
       const { page } = await saveOrUpdate(
-        optionsToSave,
-        { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
         markdownToSave.current,
         markdownToSave.current,
+        { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
+        optionsToSave,
       );
       );
 
 
       return page;
       return page;
@@ -170,7 +167,7 @@ const PageEditor = React.memo((): JSX.Element => {
     }
     }
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, pageId, currentPagePath, currentRevisionId]);
+  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, saveOrUpdate, pageId, currentPagePath, currentRevisionId]);
 
 
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
     if (editorMode !== EditorMode.Editor) {
     if (editorMode !== EditorMode.Editor) {
@@ -181,10 +178,7 @@ const PageEditor = React.memo((): JSX.Element => {
     if (page == null) {
     if (page == null) {
       return;
       return;
     }
     }
-    // The updateFn should be a promise or asynchronous function to handle the remote mutation
-    // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
-    // Moreover, `async() => false` does not work since it's too fast to be calculated.
-    await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
+
     if (isNotFound) {
     if (isNotFound) {
       await router.push(`/${page._id}`);
       await router.push(`/${page._id}`);
     }
     }
@@ -193,7 +187,7 @@ const PageEditor = React.memo((): JSX.Element => {
       await mutateCurrentPage();
       await mutateCurrentPage();
     }
     }
     mutateEditorMode(EditorMode.View);
     mutateEditorMode(EditorMode.View);
-  }, [editorMode, save, mutateIsEnabledUnsavedWarning, isNotFound, mutateEditorMode, router, mutateCurrentPageId, mutateCurrentPage]);
+  }, [editorMode, save, isNotFound, mutateEditorMode, router, mutateCurrentPageId, mutateCurrentPage]);
 
 
   const saveWithShortcut = useCallback(async() => {
   const saveWithShortcut = useCallback(async() => {
     if (editorMode !== EditorMode.Editor) {
     if (editorMode !== EditorMode.Editor) {

+ 17 - 11
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -158,7 +158,7 @@ class CodeMirrorEditor extends AbstractEditor {
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
 
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
-    this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
+    this.clickDrawioIconHandler = this.clickDrawioIconHandler.bind(this);
 
 
   }
   }
 
 
@@ -868,7 +868,7 @@ class CodeMirrorEditor extends AbstractEditor {
     this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
     this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
   }
   }
 
 
-  // fold draw.io section (::: drawio ~ :::)
+  // fold draw.io section (``` drawio ~ ```)
   foldDrawioSection() {
   foldDrawioSection() {
     const editor = this.getCodeMirror();
     const editor = this.getCodeMirror();
     const lineNumbers = mdu.findAllDrawioSection(editor);
     const lineNumbers = mdu.findAllDrawioSection(editor);
@@ -877,14 +877,20 @@ class CodeMirrorEditor extends AbstractEditor {
     });
     });
   }
   }
 
 
-  onSaveForDrawio(drawioData) {
-    const range = mdu.replaceFocusedDrawioWithEditor(this.getCodeMirror(), drawioData);
-    // Fold the section after the drawio section (:::drawio) has been updated.
-    this.foldDrawioSection();
-    return range;
+  clickDrawioIconHandler() {
+    const drawioMxFile = mdu.getMarkdownDrawioMxfile(this.getCodeMirror());
+
+    this.props.onClickDrawioBtn(
+      drawioMxFile,
+      // onSave
+      (drawioMxFile) => {
+        mdu.replaceFocusedDrawioWithEditor(this.getCodeMirror(), drawioMxFile);
+        // Fold the section after the drawio section (```drawio) has been updated.
+        this.foldDrawioSection();
+      },
+    );
   }
   }
 
 
-
   getNavbarItems() {
   getNavbarItems() {
     return [
     return [
       <Button
       <Button
@@ -1025,7 +1031,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         color={null}
         bssize="small"
         bssize="small"
         title="draw.io"
         title="draw.io"
-        onClick={() => this.props.onClickDrawioBtn(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()))}
+        onClick={this.clickDrawioIconHandler}
       >
       >
         <EditorIcon icon="Drawio" />
         <EditorIcon icon="Drawio" />
       </Button>,
       </Button>,
@@ -1154,8 +1160,8 @@ const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
   const { open: openDrawioModal } = useDrawioModal();
   const { open: openDrawioModal } = useDrawioModal();
   const { open: openHandsontableModal } = useHandsontableModal();
   const { open: openHandsontableModal } = useHandsontableModal();
 
 
-  const openDrawioModalHandler = useCallback((drawioMxFile) => {
-    openDrawioModal(drawioMxFile);
+  const openDrawioModalHandler = useCallback((drawioMxFile, onSave) => {
+    openDrawioModal(drawioMxFile, onSave);
   }, [openDrawioModal]);
   }, [openDrawioModal]);
 
 
   const openTableModalHandler = useCallback((table, editor, autoFormatMarkdownTable) => {
   const openTableModalHandler = useCallback((table, editor, autoFormatMarkdownTable) => {

+ 1 - 1
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -11,7 +11,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import type { OptionsToSave } from '~/interfaces/editor-settings';
+import { OptionsToSave } from '~/interfaces/page-operation';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 import { useEditorMode } from '~/stores/ui';
 import { useEditorMode } from '~/stores/ui';
 
 

+ 18 - 5
packages/app/src/components/PageEditor/DrawioCommunicationHelper.ts

@@ -1,3 +1,5 @@
+import { extractCodeFromMxfile } from '@growi/remark-drawio-plugin';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 
 
@@ -41,11 +43,7 @@ export class DrawioCommunicationHelper {
       }
       }
     }
     }
 
 
-    if (event.data === 'ready') {
-      event.source?.postMessage(drawioMxFile, { targetOrigin: '*' });
-      return;
-    }
-
+    // configure
     if (event.data === '{"event":"configure"}') {
     if (event.data === '{"event":"configure"}') {
       if (event.source == null) {
       if (event.source == null) {
         return;
         return;
@@ -63,6 +61,21 @@ export class DrawioCommunicationHelper {
       return;
       return;
     }
     }
 
 
+    // restore diagram data
+    if (event.data === 'ready') {
+      let code = drawioMxFile;
+      try {
+        code = extractCodeFromMxfile(drawioMxFile);
+      }
+      // catch error if drawioMxFile is not XML
+      catch (err) {
+        // do nothing because drawioMxFile might be base64 code
+      }
+
+      event.source?.postMessage(code, { targetOrigin: '*' });
+      return;
+    }
+
     if (typeof event.data === 'string' && event.data.match(/mxfile/)) {
     if (typeof event.data === 'string' && event.data.match(/mxfile/)) {
       if (event.data.length > 0) {
       if (event.data.length > 0) {
         const parser = new DOMParser();
         const parser = new DOMParser();

+ 3 - 7
packages/app/src/components/PageEditor/DrawioModal.tsx

@@ -35,11 +35,7 @@ const drawioConfig = {
 };
 };
 
 
 
 
-type Props = {
-  // onSave: (drawioData) => void,
-};
-
-export const DrawioModal = (props: Props): JSX.Element => {
+export const DrawioModal = (): JSX.Element => {
   const { data: drawioUri } = useDrawioUri();
   const { data: drawioUri } = useDrawioUri();
   const { data: personalSettingsInfo } = usePersonalSettings();
   const { data: personalSettingsInfo } = usePersonalSettings();
 
 
@@ -71,9 +67,9 @@ export const DrawioModal = (props: Props): JSX.Element => {
     return new DrawioCommunicationHelper(
     return new DrawioCommunicationHelper(
       drawioUri,
       drawioUri,
       drawioConfig,
       drawioConfig,
-      { onClose: closeDrawioModal },
+      { onClose: closeDrawioModal, onSave: drawioModalData?.onSave },
     );
     );
-  }, [closeDrawioModal, drawioUri]);
+  }, [closeDrawioModal, drawioModalData?.onSave, drawioUri]);
 
 
   const receiveMessageHandler = useCallback((event: MessageEvent) => {
   const receiveMessageHandler = useCallback((event: MessageEvent) => {
     if (drawioModalData == null) {
     if (drawioModalData == null) {

+ 7 - 7
packages/app/src/components/PageEditor/MarkdownDrawioUtil.js

@@ -4,8 +4,8 @@
 class MarkdownDrawioUtil {
 class MarkdownDrawioUtil {
 
 
   constructor() {
   constructor() {
-    this.lineBeginPartOfDrawioRE = /^:::(\s.*)drawio$/;
-    this.lineEndPartOfDrawioRE = /^:::$/;
+    this.lineBeginPartOfDrawioRE = /^```(\s.*)drawio$/;
+    this.lineEndPartOfDrawioRE = /^```$/;
   }
   }
 
 
   /**
   /**
@@ -100,9 +100,9 @@ class MarkdownDrawioUtil {
       const bod = this.getBod(editor);
       const bod = this.getBod(editor);
       const eod = this.getEod(editor);
       const eod = this.getEod(editor);
 
 
-      // skip block begin sesion("::: drawio")
+      // skip block begin sesion("``` drawio")
       bod.line++;
       bod.line++;
-      // skip block end sesion(":::")
+      // skip block end sesion("```")
       eod.line--;
       eod.line--;
       eod.ch = editor.getDoc().getLine(eod.line).length;
       eod.ch = editor.getDoc().getLine(eod.line).length;
 
 
@@ -113,7 +113,7 @@ class MarkdownDrawioUtil {
 
 
   replaceFocusedDrawioWithEditor(editor, drawioData) {
   replaceFocusedDrawioWithEditor(editor, drawioData) {
     const curPos = editor.getCursor();
     const curPos = editor.getCursor();
-    const drawioBlock = ['::: drawio', drawioData.toString(), ':::'].join('\n');
+    const drawioBlock = ['``` drawio', drawioData.toString(), '```'].join('\n');
     let beginPos;
     let beginPos;
     let endPos;
     let endPos;
 
 
@@ -145,9 +145,9 @@ class MarkdownDrawioUtil {
     if (markdownBeforeDrawio.length > 0) {
     if (markdownBeforeDrawio.length > 0) {
       newMarkdown += `${markdownBeforeDrawio.join('\n')}\n`;
       newMarkdown += `${markdownBeforeDrawio.join('\n')}\n`;
     }
     }
-    newMarkdown += '::: drawio\n';
+    newMarkdown += '``` drawio\n';
     newMarkdown += drawioData;
     newMarkdown += drawioData;
-    newMarkdown += '\n:::';
+    newMarkdown += '\n```';
     if (markdownAfterDrawio.length > 0) {
     if (markdownAfterDrawio.length > 0) {
       newMarkdown += `\n${markdownAfterDrawio.join('\n')}`;
       newMarkdown += `\n${markdownAfterDrawio.join('\n')}`;
     }
     }

+ 38 - 47
packages/app/src/components/PageEditorByHackmd.tsx

@@ -10,16 +10,16 @@ import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
-import { saveOrUpdate } from '~/client/services/page-operation';
+import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
-import { getOptionsToSave } from '~/client/util/editor';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
+import { OptionsToSave } from '~/interfaces/page-operation';
 import {
 import {
   useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
   useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
-  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors,
 } from '~/stores/editor';
 } from '~/stores/editor';
 import {
 import {
   usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useRemoteRevisionId,
   usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useRemoteRevisionId,
@@ -35,7 +35,12 @@ import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 
 
 const logger = loggerFactory('growi:PageEditorByHackmd');
 const logger = loggerFactory('growi:PageEditorByHackmd');
 
 
-declare const globalEmitter: EventEmitter;
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
 
 
 type HackEditorRef = {
 type HackEditorRef = {
   getValue: () => Promise<string>
   getValue: () => Promise<string>
@@ -57,6 +62,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: grant } = useSelectedGrant();
   const { data: grant } = useSelectedGrant();
   const { data: hackmdUri } = useHackmdUri();
   const { data: hackmdUri } = useHackmdUri();
+  const saveOrUpdate = useSaveOrUpdate();
 
 
   const { returnPathForURL } = pathUtils;
   const { returnPathForURL } = pathUtils;
 
 
@@ -77,7 +83,6 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: pageIdOnHackmd, mutate: mutatePageIdOnHackmd } = usePageIdOnHackmd();
   const { data: pageIdOnHackmd, mutate: mutatePageIdOnHackmd } = usePageIdOnHackmd();
   const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
   const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const [isHackmdDraftUpdatingInRealtime, setIsHackmdDraftUpdatingInRealtime] = useState(false);
   const [isHackmdDraftUpdatingInRealtime, setIsHackmdDraftUpdatingInRealtime] = useState(false);
   const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId(revision?._id);
   const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId(revision?._id);
 
 
@@ -91,34 +96,26 @@ export const PageEditorByHackmd = (): JSX.Element => {
         throw new Error('Some materials to save are invalid');
         throw new Error('Some materials to save are invalid');
       }
       }
 
 
-      let optionsToSave;
-
-      const currentOptionsToSave = getOptionsToSave(
-        isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
-      );
-
-      if (opts != null) {
-        optionsToSave = Object.assign(currentOptionsToSave, {
-          ...opts,
-        });
-      }
-      else {
-        optionsToSave = currentOptionsToSave;
-      }
+      const optionsToSave: OptionsToSave = {
+        isSlackEnabled,
+        slackChannels,
+        grant: grant.grant,
+        grantUserGroupId: grant.grantedGroup?.id,
+        grantUserGroupName: grant.grantedGroup?.name,
+        pageTags: pageTags ?? [],
+        isSyncRevisionToHackmd: true,
+        ...opts,
+      };
 
 
       const markdown = await hackmdEditorRef.current.getValue();
       const markdown = await hackmdEditorRef.current.getValue();
 
 
-      const { page } = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
+      const { page } = await saveOrUpdate(markdown, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, optionsToSave);
       await mutatePageData();
       await mutatePageData();
       await mutateTagsInfo();
       await mutateTagsInfo();
 
 
       if (page == null) {
       if (page == null) {
         return;
         return;
       }
       }
-      // The updateFn should be a promise or asynchronous function to handle the remote mutation
-      // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
-      // Moreover, `async() => false` does not work since it's too fast to be calculated.
-      await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
       if (isNotFound) {
       if (isNotFound) {
         await router.push(`/${page._id}`);
         await router.push(`/${page._id}`);
       }
       }
@@ -132,8 +129,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       toastError(error.message);
       toastError(error.message);
     }
     }
-  }, [editorMode, isSlackEnabled, currentPathname, slackChannels, grant, revision, pageTags, pageId,
-      currentPagePath, mutatePageData, mutateTagsInfo, mutateIsEnabledUnsavedWarning, isNotFound, mutateEditorMode, router, mutateCurrentPageId]);
+  // eslint-disable-next-line max-len
+  }, [editorMode, isSlackEnabled, currentPathname, slackChannels, grant, revision, pageTags, saveOrUpdate, pageId, currentPagePath, mutatePageData, mutateTagsInfo, isNotFound, mutateEditorMode, router, mutateCurrentPageId]);
 
 
   // set handler to save and reload Page
   // set handler to save and reload Page
   useEffect(() => {
   useEffect(() => {
@@ -226,7 +223,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error(err);
       logger.error(err);
       toastError(err.message);
       toastError(err.message);
     }
     }
-  }, [setIsHackmdDraftUpdatingInRealtime, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced, pageId]);
+  }, [pageId, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRemoteRevisionId, mutateRevisionIdHackmdSynced]);
 
 
   /**
   /**
    * save and update state of containers
    * save and update state of containers
@@ -239,10 +236,16 @@ export const PageEditorByHackmd = (): JSX.Element => {
         isSlackEnabled == null || grant == null || slackChannels == null || pageId == null
         isSlackEnabled == null || grant == null || slackChannels == null || pageId == null
         || revisionIdHackmdSynced == null || currentPagePathOrPathname == null
         || revisionIdHackmdSynced == null || currentPagePathOrPathname == null
       ) { throw new Error('Some materials to save are invalid') }
       ) { throw new Error('Some materials to save are invalid') }
-      const optionsToSave = getOptionsToSave(
-        isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
-      );
-      const res = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, markdown);
+      const optionsToSave = {
+        isSlackEnabled,
+        slackChannels,
+        grant: grant.grant,
+        grantUserGroupId: grant.grantedGroup?.id,
+        grantUserGroupName: grant.grantedGroup?.name,
+        pageTags: pageTags ?? [],
+        isSyncRevisionToHackmd: true,
+      };
+      const res = await saveOrUpdate(markdown, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, optionsToSave);
 
 
       // update pageData
       // update pageData
       mutatePageData(res);
       mutatePageData(res);
@@ -252,7 +255,6 @@ export const PageEditorByHackmd = (): JSX.Element => {
       mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
       mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
       mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
       mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
       mutateTagsInfo();
       mutateTagsInfo();
-      mutateIsEnabledUnsavedWarning(false);
 
 
       logger.debug('success to save');
       logger.debug('success to save');
 
 
@@ -262,20 +264,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       toastError(error.message);
       toastError(error.message);
     }
     }
-  }, [isSlackEnabled,
-      grant,
-      slackChannels,
-      pageId,
-      revisionIdHackmdSynced,
-      currentPathname,
-      pageTags,
-      currentPagePath,
-      mutatePageData,
-      mutateRevisionIdHackmdSynced,
-      mutateHasDraftOnHackmd,
-      mutateTagsInfo,
-      mutateIsEnabledUnsavedWarning,
-      t]);
+  // eslint-disable-next-line max-len
+  }, [currentPagePath, currentPathname, isSlackEnabled, grant, slackChannels, pageId, revisionIdHackmdSynced, pageTags, saveOrUpdate, mutatePageData, mutateRemoteRevisionId, mutateRevisionIdHackmdSynced, mutateHasDraftOnHackmd, mutateTagsInfo, t]);
 
 
   /**
   /**
    * onChange event of HackmdEditor handler
    * onChange event of HackmdEditor handler
@@ -433,7 +423,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
         {content}
         {content}
       </div>
       </div>
     );
     );
-  }, [discardChanges, isInitializing, isResume, resumeToEdit, startToEdit, t, hackmdUri, pageId, remoteRevisionId, revisionIdHackmdSynced, revision?._id]);
+  // eslint-disable-next-line max-len
+  }, [pageId, hackmdUri, isResume, t, revisionIdHackmdSynced, remoteRevisionId, pageData, returnPathForURL, isInitializing, resumeToEdit, discardChanges, revision?._id, startToEdit]);
 
 
   if (editorMode == null || revision == null) {
   if (editorMode == null || revision == null) {
     return <></>;
     return <></>;

+ 1 - 0
packages/app/src/components/PageRenameModal.tsx

@@ -335,6 +335,7 @@ const PageRenameModal = (): JSX.Element => {
       <>
       <>
         <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
         <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
         <button
         <button
+          data-testid="grw-page-rename-button"
           type="button"
           type="button"
           className="btn btn-primary"
           className="btn btn-primary"
           onClick={rename}
           onClick={rename}

+ 19 - 0
packages/app/src/components/ReactMarkdownComponents/DrawioViewerWithEditButton.module.scss

@@ -0,0 +1,19 @@
+.drawio-viewer-with-edit-button :global {
+  position: relative;
+
+  .btn-edit-drawio {
+    position: absolute;
+    top: 11px;
+    right: 10px;
+    z-index: 14;
+    font-size: 12px;
+    line-height: 1;
+    opacity: 0;
+  }
+}
+
+.drawio-viewer-with-edit-button:hover :global {
+  .btn-edit-drawio {
+    opacity: 1;
+  }
+}

+ 69 - 0
packages/app/src/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx

@@ -0,0 +1,69 @@
+import React, { useCallback, useState } from 'react';
+
+import EventEmitter from 'events';
+
+import {
+  DrawioEditByViewerProps,
+  DrawioViewer, DrawioViewerProps, extractCodeFromMxfile,
+} from '@growi/remark-drawio-plugin';
+import { useTranslation } from 'next-i18next';
+
+import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
+
+import styles from './DrawioViewerWithEditButton.module.scss';
+
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
+
+export const DrawioViewerWithEditButton = React.memo((props: DrawioViewerProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { bol, eol } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const [isRendered, setRendered] = useState(false);
+  const [mxfile, setMxfile] = useState('');
+
+  const editButtonClickHandler = useCallback(() => {
+    const data: DrawioEditByViewerProps = {
+      bol, eol, drawioMxFile: extractCodeFromMxfile(mxfile),
+    };
+    globalEmitter.emit('launchDrawioModal', data);
+  }, [bol, eol, mxfile]);
+
+  const renderingStartHandler = useCallback(() => {
+    setRendered(false);
+  }, []);
+
+  const renderingUpdatedHandler = useCallback((mxfile: string | null) => {
+    setRendered(mxfile != null);
+
+    if (mxfile != null) {
+      setMxfile(mxfile);
+    }
+  }, []);
+
+  const showEditButton = isRendered && !isGuestUser && !isSharedUser;
+
+  return (
+    <div className={`drawio-viewer-with-edit-button ${styles['drawio-viewer-with-edit-button']}`}>
+      { showEditButton && (
+        <button
+          type="button"
+          className="btn btn-outline-secondary btn-edit-drawio"
+          onClick={editButtonClickHandler}
+        >
+          <i className="icon-note mr-1"></i>{t('Edit')}
+        </button>
+      ) }
+      <DrawioViewer {...props} onRenderingStart={renderingStartHandler} onRenderingUpdated={renderingUpdatedHandler} />
+    </div>
+  );
+});
+DrawioViewerWithEditButton.displayName = 'DrawioViewerWithEditButton';

+ 5 - 1
packages/app/src/components/ReactMarkdownComponents/Header.tsx

@@ -11,7 +11,11 @@ import { NextLink } from './NextLink';
 import styles from './Header.module.scss';
 import styles from './Header.module.scss';
 
 
 
 
-declare const globalEmitter: EventEmitter;
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
 
 
 function setCaretLine(line?: number): void {
 function setCaretLine(line?: number): void {
   if (line != null) {
   if (line != null) {

+ 7 - 2
packages/app/src/components/SavePageControls.tsx

@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
 
 
 import EventEmitter from 'events';
 import EventEmitter from 'events';
 
 
-import { pagePathUtils, PageGrant } from '@growi/core';
+import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   UncontrolledButtonDropdown, Button,
   UncontrolledButtonDropdown, Button,
@@ -19,7 +19,12 @@ import loggerFactory from '~/utils/logger';
 
 
 import GrantSelector from './SavePageControls/GrantSelector';
 import GrantSelector from './SavePageControls/GrantSelector';
 
 
-declare const globalEmitter: EventEmitter;
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
 
 
 const logger = loggerFactory('growi:SavePageControls');
 const logger = loggerFactory('growi:SavePageControls');
 
 

+ 38 - 0
packages/app/src/components/Script/DrawioViewerScript.tsx

@@ -0,0 +1,38 @@
+import { useCallback } from 'react';
+
+import type { IGraphViewerGlobal } from '@growi/remark-drawio-plugin';
+import Script from 'next/script';
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var GraphViewer: IGraphViewerGlobal;
+}
+
+export const DrawioViewerScript = (): JSX.Element => {
+  const loadedHandler = useCallback(() => {
+    // disable useResizeSensor and checkVisibleState
+    //   for preventing resize event by viewer.min.js
+    GraphViewer.useResizeSensor = false;
+    GraphViewer.prototype.checkVisibleState = false;
+
+    // Set responsive option.
+    // refs: https://github.com/jgraph/drawio/blob/v13.9.1/src/main/webapp/js/diagramly/GraphViewer.js#L89-L95
+    // GraphViewer.prototype.responsive = true;
+
+    // Set z-index ($zindex-dropdown + 200) for lightbox.
+    // 'lightbox' is like a modal dialog that appears when click on a drawio diagram.
+    // z-index refs: https://github.com/twbs/bootstrap/blob/v4.6.2/scss/_variables.scss#L681
+    GraphViewer.prototype.lightboxZIndex = 1200;
+    GraphViewer.prototype.toolbarZIndex = 1200;
+
+    GraphViewer.processElements();
+  }, []);
+
+  return (
+    <Script
+      type="text/javascript"
+      src="https://www.draw.io/js/viewer.min.js"
+      onLoad={loadedHandler}
+    />
+  );
+};

+ 0 - 10
packages/app/src/interfaces/editor-settings.ts

@@ -35,13 +35,3 @@ export type EditorConfig = {
     isUploadableImage: boolean,
     isUploadableImage: boolean,
   }
   }
 }
 }
-
-export type OptionsToSave = {
-  isSlackEnabled: boolean;
-  slackChannels: string;
-  grant: number;
-  pageTags: string[] | null;
-  grantUserGroupId?: string | null;
-  grantUserGroupName?: string | null;
-  isSyncRevisionToHackmd?: boolean;
-};

+ 0 - 8
packages/app/src/interfaces/global.ts

@@ -1,8 +0,0 @@
-import EventEmitter from 'events';
-
-import { IGraphViewer } from './graph-viewer';
-
-export type CustomWindow = Window
-                         & typeof globalThis
-                         & { globalEmitter: EventEmitter }
-                         & { GraphViewer: IGraphViewer };

+ 0 - 11
packages/app/src/interfaces/graph-viewer.ts

@@ -1,11 +0,0 @@
-export interface IGraphViewer {
-  createViewerForElement: (Element) => void,
-}
-
-export const isGraphViewer = (val: any): val is IGraphViewer => {
-  if (typeof val === 'function' && typeof val.createViewerForElement === 'function') {
-    return true;
-  }
-
-  return false;
-};

+ 10 - 0
packages/app/src/interfaces/page-operation.ts

@@ -26,3 +26,13 @@ export type IPageOperationProcessData = {
 export type IPageOperationProcessInfo = {
 export type IPageOperationProcessInfo = {
   [pageId: string]: IPageOperationProcessData,
   [pageId: string]: IPageOperationProcessData,
 }
 }
+
+export type OptionsToSave = {
+  isSlackEnabled: boolean;
+  slackChannels: string;
+  grant: number;
+  pageTags: string[] | null;
+  grantUserGroupId?: string | null;
+  grantUserGroupName?: string | null;
+  isSyncRevisionToHackmd?: boolean;
+};

+ 17 - 7
packages/app/src/pages/[[...path]].page.tsx

@@ -23,23 +23,23 @@ import { Comments } from '~/components/Comments';
 import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 // import { useTranslation } from '~/i18n';
 // import { useTranslation } from '~/i18n';
 import { CurrentPageContentFooter } from '~/components/PageContentFooter';
 import { CurrentPageContentFooter } from '~/components/PageContentFooter';
+import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { UsersHomePageFooterProps } from '~/components/UsersHomePageFooter';
 import { UsersHomePageFooterProps } from '~/components/UsersHomePageFooter';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 // import { renderScriptTagByName, renderHighlightJsStyleTag } from '~/service/cdn-resources-loader';
 // import { renderScriptTagByName, renderHighlightJsStyleTag } from '~/service/cdn-resources-loader';
 // import { useRendererSettings } from '~/stores/renderer';
 // import { useRendererSettings } from '~/stores/renderer';
 // import { EditorMode, useEditorMode, useIsMobile } from '~/stores/ui';
 // import { EditorMode, useEditorMode, useIsMobile } from '~/stores/ui';
 import type { EditorConfig } from '~/interfaces/editor-settings';
 import type { EditorConfig } from '~/interfaces/editor-settings';
-import type { CustomWindow } from '~/interfaces/global';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
-import { useSWRxCurrentPage, useSWRxIsGrantNormalized, useSWRxPageInfo } from '~/stores/page';
+import { useEditingMarkdown } from '~/stores/editor';
+import { useSWRxCurrentPage, useSWRxIsGrantNormalized } from '~/stores/page';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import {
 import {
-  EditorMode,
   useEditorMode, useSelectedGrant,
   useEditorMode, useSelectedGrant,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
 } from '~/stores/ui';
 } from '~/stores/ui';
@@ -64,7 +64,7 @@ import {
   useDrawioUri, useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
   useDrawioUri, useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
   useIsAclEnabled, useIsSearchPage, useTemplateTagData, useTemplateBodyData, useIsEnabledAttachTitleHeader,
   useIsAclEnabled, useIsSearchPage, useTemplateTagData, useTemplateBodyData, useIsEnabledAttachTitleHeader,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
-  useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
+  useIsSlackConfigured, useRendererConfig,
   useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useCustomizedLogoSrc, useIsContainerFluid,
   useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useCustomizedLogoSrc, useIsContainerFluid,
 } from '../stores/context';
 } from '../stores/context';
 
 
@@ -74,6 +74,12 @@ import {
 // import { useCurrentPageSWR } from '../stores/page';
 // import { useCurrentPageSWR } from '../stores/page';
 
 
 
 
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
+
 const NotCreatablePage = dynamic(() => import('../components/NotCreatablePage').then(mod => mod.NotCreatablePage), { ssr: false });
 const NotCreatablePage = dynamic(() => import('../components/NotCreatablePage').then(mod => mod.NotCreatablePage), { ssr: false });
 const ForbiddenPage = dynamic(() => import('../components/ForbiddenPage'), { ssr: false });
 const ForbiddenPage = dynamic(() => import('../components/ForbiddenPage'), { ssr: false });
 const UnsavedAlertDialog = dynamic(() => import('../components/UnsavedAlertDialog'), { ssr: false });
 const UnsavedAlertDialog = dynamic(() => import('../components/UnsavedAlertDialog'), { ssr: false });
@@ -154,7 +160,7 @@ type Props = CommonProps & {
   // isMailerSetup: boolean,
   // isMailerSetup: boolean,
   isAclEnabled: boolean,
   isAclEnabled: boolean,
   // hasSlackConfig: boolean,
   // hasSlackConfig: boolean,
-  drawioUri: string,
+  drawioUri: string | null,
   hackmdUri: string,
   hackmdUri: string,
   noCdn: string,
   noCdn: string,
   // highlightJsStyle: string,
   // highlightJsStyle: string,
@@ -184,8 +190,8 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   const { data: currentUser } = useCurrentUser(props.currentUser ?? null);
   const { data: currentUser } = useCurrentUser(props.currentUser ?? null);
 
 
   // register global EventEmitter
   // register global EventEmitter
-  if (isClient()) {
-    (window as CustomWindow).globalEmitter = new EventEmitter();
+  if (isClient() && window.globalEmitter == null) {
+    window.globalEmitter = new EventEmitter();
   }
   }
 
 
   // commons
   // commons
@@ -299,7 +305,11 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
         {renderHighlightJsStyleTag(props.highlightJsStyle)}
         {renderHighlightJsStyleTag(props.highlightJsStyle)}
         */}
         */}
       </Head>
       </Head>
+
+      <DrawioViewerScript />
+
       <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={isContainerFluid}>
       <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={isContainerFluid}>
+
         <div className="h-100 d-flex flex-column justify-content-between">
         <div className="h-100 d-flex flex-column justify-content-between">
           <header className="py-0 position-relative">
           <header className="py-0 position-relative">
             <div id="grw-subnav-container">
             <div id="grw-subnav-container">

+ 10 - 1
packages/app/src/pages/_private-legacy-pages.page.tsx

@@ -5,6 +5,7 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
 import Head from 'next/head';
 
 
+import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
@@ -12,7 +13,7 @@ import type { IUser, IUserHasId } from '~/interfaces/user';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import {
 import {
-  useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
+  useCsrfToken, useCurrentUser, useDrawioUri, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
@@ -33,6 +34,8 @@ type Props = CommonProps & {
   isSearchServiceReachable: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
   isSearchScopeChildrenAsDefault: boolean,
 
 
+  drawioUri: string | null,
+
   // UI
   // UI
   userUISettings?: IUserUISettings
   userUISettings?: IUserUISettings
   // Sidebar
   // Sidebar
@@ -59,6 +62,8 @@ const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
 
+  useDrawioUri(props.drawioUri);
+
   // UserUISettings
   // UserUISettings
   usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
   usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
   usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
   usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
@@ -78,6 +83,8 @@ const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
         */}
         */}
       </Head>
       </Head>
 
 
+      <DrawioViewerScript />
+
       <SearchResultLayout title={useCustomTitle(props, 'GROWI')}>
       <SearchResultLayout title={useCustomTitle(props, 'GROWI')}>
         <div id="private-regacy-pages">
         <div id="private-regacy-pages">
           <PrivateLegacyPages />
           <PrivateLegacyPages />
@@ -109,6 +116,8 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
 
 
+  props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
+
   props.sidebarConfig = {
   props.sidebarConfig = {
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),

+ 10 - 1
packages/app/src/pages/_search.page.tsx

@@ -5,6 +5,7 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
 import Head from 'next/head';
 
 
+import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
@@ -12,7 +13,7 @@ import type { IUser, IUserHasId } from '~/interfaces/user';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import {
 import {
-  useCsrfToken, useCurrentUser, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
+  useCsrfToken, useCurrentUser, useDrawioUri, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
@@ -35,6 +36,8 @@ type Props = CommonProps & {
   isSearchServiceReachable: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
   isSearchScopeChildrenAsDefault: boolean,
 
 
+  drawioUri: string | null,
+
   // UI
   // UI
   userUISettings?: IUserUISettings
   userUISettings?: IUserUISettings
   // Sidebar
   // Sidebar
@@ -64,6 +67,8 @@ const SearchResultPage: NextPage<Props> = (props: Props) => {
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
 
+  useDrawioUri(props.drawioUri);
+
   // UserUISettings
   // UserUISettings
   usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
   usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
   usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
   usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
@@ -96,6 +101,8 @@ const SearchResultPage: NextPage<Props> = (props: Props) => {
         */}
         */}
       </Head>
       </Head>
 
 
+      <DrawioViewerScript />
+
       <SearchResultLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
       <SearchResultLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
         <div id="search-page">
         <div id="search-page">
           <SearchPage />
           <SearchPage />
@@ -130,6 +137,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
   props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
   props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
 
 
+  props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
+
   props.sidebarConfig = {
   props.sidebarConfig = {
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),

+ 98 - 87
packages/app/src/pages/share/[[...path]].page.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import React from 'react';
 
 
-import { IUser, IUserHasId } from '@growi/core';
+import { IUserHasId } from '@growi/core';
 import {
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 } from 'next';
@@ -14,6 +14,7 @@ import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
 import GrowiContextualSubNavigation from '~/components/Navbar/GrowiContextualSubNavigation';
 import GrowiContextualSubNavigation from '~/components/Navbar/GrowiContextualSubNavigation';
 import { Page } from '~/components/Page';
 import { Page } from '~/components/Page';
 import styles from '~/components/Page/DisplaySwitcher.module.scss'; // for PageList toc style
 import styles from '~/components/Page/DisplaySwitcher.module.scss'; // for PageList toc style
+import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import TableOfContents from '~/components/TableOfContents';
 import TableOfContents from '~/components/TableOfContents';
 import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
 import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CrowiRequest } from '~/interfaces/crowi-request';
@@ -21,7 +22,7 @@ import { RendererConfig } from '~/interfaces/services/renderer';
 import { IShareLinkHasId } from '~/interfaces/share-link';
 import { IShareLinkHasId } from '~/interfaces/share-link';
 import {
 import {
   useCurrentUser, useCurrentPathname, useCurrentPageId, useRendererConfig, useIsSearchPage,
   useCurrentUser, useCurrentPathname, useCurrentPageId, useRendererConfig, useIsSearchPage,
-  useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault,
+  useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useDrawioUri,
 } from '~/stores/context';
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -42,6 +43,7 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
   isSearchScopeChildrenAsDefault: boolean,
+  drawioUri: string | null,
   rendererConfig: RendererConfig,
   rendererConfig: RendererConfig,
 };
 };
 
 
@@ -55,6 +57,8 @@ const SharedPage: NextPage<Props> = (props: Props) => {
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
+  useDrawioUri(props.drawioUri);
+
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -63,113 +67,120 @@ const SharedPage: NextPage<Props> = (props: Props) => {
   const shareLink = props.shareLink;
   const shareLink = props.shareLink;
 
 
   return (
   return (
-    <ShareLinkLayout title={useCustomTitle(props, 'GROWI')} expandContainer={props.isContainerFluid}>
-      <div className="h-100 d-flex flex-column justify-content-between">
-        <header className="py-0 position-relative">
-          {isShowSharedPage && <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />}
-        </header>
-
-        <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
-
-        <div className="flex-grow-1">
-          <div id="content-main" className="content-main">
-            <div className="grw-container-convertible">
-              { props.disableLinkSharing && (
-                <div className="mt-4">
-                  <ForbiddenPage isLinkSharingDisabled={props.disableLinkSharing} />
-                </div>
-              )}
-
-              { (isNotFound && !props.disableLinkSharing) && (
-                <div className="container-lg">
-                  <h2 className="text-muted mt-4">
-                    <i className="icon-ban" aria-hidden="true" />
-                    <span> Page is not found</span>
-                  </h2>
-                </div>
-              )}
-
-              { (props.isExpired && !props.disableLinkSharing && shareLink != null) && (
-                <div className="container-lg">
-                  <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
-                  <h2 className="text-muted mt-4">
-                    <i className="icon-ban" aria-hidden="true" />
-                    <span> Page is expired</span>
-                  </h2>
-                </div>
-              )}
-
-              {(isShowSharedPage && shareLink != null) && (
-                <>
-                  <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
-                  <div className="d-flex flex-column flex-lg-row-reverse">
-
-                    <div className="grw-side-contents-container">
-                      <div className="grw-side-contents-sticky-container">
-
-                        {/* Page list */}
-                        <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
-                          { shareLink.relatedPage.path != null && (
-                            <button
-                              type="button"
-                              className="btn btn-block btn-outline-secondary grw-btn-page-accessories
-                              rounded-pill d-flex justify-content-between align-items-center"
-                              onClick={() => openDescendantPageListModal(shareLink.relatedPage.path)}
-                              data-testid="pageListButton"
-                            >
-                              <div className="grw-page-accessories-control-icon">
-                                <PageListIcon />
-                              </div>
-                              {t('page_list')}
-                              <CountBadge count={shareLink.relatedPage.descendantCount} offset={1} />
-                            </button>
-                          ) }
-                        </div>
-
-                        <div className="d-none d-lg-block">
-                          <TableOfContents />
+    <>
+      <DrawioViewerScript />
+
+      <ShareLinkLayout title={useCustomTitle(props, 'GROWI')} expandContainer={props.isContainerFluid}>
+        <div className="h-100 d-flex flex-column justify-content-between">
+          <header className="py-0 position-relative">
+            {isShowSharedPage && <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />}
+          </header>
+
+          <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
+
+          <div className="flex-grow-1">
+            <div id="content-main" className="content-main">
+              <div className="grw-container-convertible">
+                { props.disableLinkSharing && (
+                  <div className="mt-4">
+                    <ForbiddenPage isLinkSharingDisabled={props.disableLinkSharing} />
+                  </div>
+                )}
+
+                { (isNotFound && !props.disableLinkSharing) && (
+                  <div className="container-lg">
+                    <h2 className="text-muted mt-4">
+                      <i className="icon-ban" aria-hidden="true" />
+                      <span> Page is not found</span>
+                    </h2>
+                  </div>
+                )}
+
+                { (props.isExpired && !props.disableLinkSharing && shareLink != null) && (
+                  <div className="container-lg">
+                    <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
+                    <h2 className="text-muted mt-4">
+                      <i className="icon-ban" aria-hidden="true" />
+                      <span> Page is expired</span>
+                    </h2>
+                  </div>
+                )}
+
+                {(isShowSharedPage && shareLink != null) && (
+                  <>
+                    <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
+                    <div className="d-flex flex-column flex-lg-row-reverse">
+
+                      <div className="grw-side-contents-container">
+                        <div className="grw-side-contents-sticky-container">
+
+                          {/* Page list */}
+                          <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
+                            { shareLink.relatedPage.path != null && (
+                              <button
+                                type="button"
+                                className="btn btn-block btn-outline-secondary grw-btn-page-accessories
+                                rounded-pill d-flex justify-content-between align-items-center"
+                                onClick={() => openDescendantPageListModal(shareLink.relatedPage.path)}
+                                data-testid="pageListButton"
+                              >
+                                <div className="grw-page-accessories-control-icon">
+                                  <PageListIcon />
+                                </div>
+                                {t('page_list')}
+                                <CountBadge count={shareLink.relatedPage.descendantCount} offset={1} />
+                              </button>
+                            ) }
+                          </div>
+
+                          <div className="d-none d-lg-block">
+                            <TableOfContents />
+                          </div>
                         </div>
                         </div>
                       </div>
                       </div>
-                    </div>
 
 
-                    <div className="flex-grow-1 flex-basis-0 mw-0">
-                      <Page />
+                      <div className="flex-grow-1 flex-basis-0 mw-0">
+                        <Page />
+                      </div>
                     </div>
                     </div>
-                  </div>
-                </>
-              )}
+                  </>
+                )}
+              </div>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
-      </div>
-    </ShareLinkLayout>
+      </ShareLinkLayout>
+    </>
   );
   );
 };
 };
 
 
 function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): void {
 function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): void {
   const req: CrowiRequest = context.req as CrowiRequest;
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
   const { crowi } = req;
+  const { configManager, searchService, xssService } = crowi;
+
+  props.disableLinkSharing = configManager.getConfig('crowi', 'security:disableLinkSharing');
 
 
-  props.disableLinkSharing = crowi.configManager.getConfig('crowi', 'security:disableLinkSharing');
+  props.isSearchServiceConfigured = searchService.isConfigured;
+  props.isSearchServiceReachable = searchService.isReachable;
+  props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
 
 
-  props.isSearchServiceConfigured = crowi.searchService.isConfigured;
-  props.isSearchServiceReachable = crowi.searchService.isReachable;
-  props.isSearchScopeChildrenAsDefault = crowi.configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+  props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
 
 
   props.rendererConfig = {
   props.rendererConfig = {
-    isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
-    isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
-    adminPreferredIndentSize: crowi.configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
-    isIndentSizeForced: crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
 
 
     plantumlUri: process.env.PLANTUML_URI ?? null,
     plantumlUri: process.env.PLANTUML_URI ?? null,
     blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
     blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
 
 
     // XSS Options
     // XSS Options
-    isEnabledXssPrevention: crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
-    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    attrWhiteList: xssService.getAttrWhiteList(),
+    tagWhiteList: xssService.getTagWhiteList(),
+    highlightJsStyleBorder: configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
   };
 }
 }
 
 

+ 1 - 1
packages/app/src/server/service/config-loader.ts

@@ -149,7 +149,7 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     ns:      'crowi',
     ns:      'crowi',
     key:     'app:drawioUri',
     key:     'app:drawioUri',
     type:    ValueType.STRING,
     type:    ValueType.STRING,
-    default: 'https://embed.diagrams.net/',
+    default: null,
   },
   },
   NCHAN_URI: {
   NCHAN_URI: {
     ns:      'crowi',
     ns:      'crowi',

+ 0 - 38
packages/app/src/server/views/widget/headers/drawio.html

@@ -1,38 +0,0 @@
-<!-- draw.io -->
-{% if getConfig('crowi', 'app:drawioUri') %}
-<script type="text/javascript">
-  // refs: https://github.com/jgraph/drawio/blob/v13.4.3/etc/build/build.xml#L35-L38
-  let url = new URL("{{ getConfig('crowi', 'app:drawioUri') }}");
-  let origin = url.origin;
-  window.DRAWIO_BASE_URL = origin;
-  window.DRAWIO_LIGHTBOX_URL = origin;
-  window.STENCIL_PATH = [origin, 'stencils'].join('/');
-  window.SHAPES_PATH = [origin, 'shapes'].join('/');
-  window.mxBasePath = [origin, 'mxgraph'].join('/');
-</script>
-{% endif %}
-
-<script type="text/javascript">
-  // define callback function invoked by viewer.min.js of draw.io
-  // refs: https://github.com/jgraph/drawio/blob/v12.9.1/etc/build/build.xml#L219-L232
-  window.onDrawioViewerLoad = function() {
-    const DrawioViewer = window.GraphViewer;
-
-    if (DrawioViewer != null) {
-      // disable useResizeSensor and checkVisibleState
-      //   for preventing resize event by viewer.min.js
-      DrawioViewer.useResizeSensor = false;
-      DrawioViewer.prototype.checkVisibleState = false;
-
-      // Set responsive option.
-      // refs: https://github.com/jgraph/drawio/blob/v13.9.1/src/main/webapp/js/diagramly/GraphViewer.js#L89-L95
-      DrawioViewer.prototype.responsive = true;
-
-      // Set z-index ($zindex-dropdown + 200) for lightbox.
-      // 'lightbox' is like a modal dialog that appears when click on a drawio diagram.
-      // z-index refs: https://github.com/twbs/bootstrap/blob/v4.6.2/scss/_variables.scss#L681
-      DrawioViewer.prototype.lightboxZIndex = 1200;
-      DrawioViewer.prototype.toolbarZIndex = 1200;
-    }
-  };
-</script>

+ 0 - 156
packages/app/src/services/renderer/interceptor/drawio-interceptor.js

@@ -1,156 +0,0 @@
-/* eslint-disable import/prefer-default-export */
-import React from 'react';
-
-import { BasicInterceptor } from '@growi/core';
-import ReactDOM from 'react-dom';
-
-import Drawio from '~/components/Drawio';
-
-/**
- * The interceptor for draw.io
- *
- *  replace draw.io tag (render by markdown-it-drawio-viewer) to a React target element
- */
-export class DrawioInterceptor extends BasicInterceptor {
-
-  constructor() {
-    super();
-
-    this.previousPreviewContext = null;
-  }
-
-  /**
-   * @inheritdoc
-   */
-  isInterceptWhen(contextName) {
-    return (
-      contextName === 'preRenderHtml'
-      || contextName === 'preRenderPreviewHtml'
-      || contextName === 'postRenderHtml'
-      || contextName === 'postRenderPreviewHtml'
-    );
-  }
-
-  /**
-   * @inheritdoc
-   */
-  isProcessableParallel() {
-    return false;
-  }
-
-  /**
-   * @inheritdoc
-   */
-  async process(contextName, ...args) {
-    const context = Object.assign(args[0]); // clone
-
-    if (contextName === 'preRenderHtml' || contextName === 'preRenderPreviewHtml') {
-      return this.drawioPreRender(contextName, context);
-    }
-
-    if (contextName === 'postRenderHtml' || contextName === 'postRenderPreviewHtml') {
-      this.drawioPostRender(contextName, context);
-      return;
-    }
-  }
-
-  /**
-   * @inheritdoc
-   */
-  createRandomStr(length) {
-    const bag = 'abcdefghijklmnopqrstuvwxyz0123456789';
-    let generated = '';
-    for (let i = 0; i < length; i++) {
-      generated += bag[Math.floor(Math.random() * bag.length)];
-    }
-    return generated;
-  }
-
-  /**
-   * @inheritdoc
-   */
-  drawioPreRender(contextName, context) {
-    const div = document.createElement('div');
-    div.innerHTML = context.parsedHTML;
-
-    context.DrawioMap = {};
-    Array.from(div.querySelectorAll('.mxgraph')).forEach((element) => {
-      const domId = `mxgraph-${this.createRandomStr(8)}`;
-
-      context.DrawioMap[domId] = {
-        rangeLineNumberOfMarkdown: {
-          beginLineNumber: element.parentNode.dataset.beginLineNumberOfMarkdown,
-          endLineNumber: element.parentNode.dataset.endLineNumberOfMarkdown,
-        },
-        contentHtml: element.outerHTML,
-      };
-      element.outerHTML = `<div id="${domId}"></div>`;
-    });
-    context.parsedHTML = div.innerHTML;
-
-    // unmount
-    if (contextName === 'preRenderPreviewHtml') {
-      this.unmountPreviousReactDOMs(context);
-    }
-
-    // resolve
-    return context;
-  }
-
-  /**
-   * @inheritdoc
-   */
-  drawioPostRender(contextName, context) {
-    const isPreview = (contextName === 'postRenderPreviewHtml');
-    const renderDrawioInRealtime = context.renderDrawioInRealtime;
-
-    Object.keys(context.DrawioMap).forEach((domId) => {
-      const elem = document.getElementById(domId);
-      if (elem) {
-        if (isPreview && !renderDrawioInRealtime) {
-          this.renderDisabledDrawioReactDOM(context.DrawioMap[domId], elem, isPreview);
-        }
-        else {
-          this.renderReactDOM(context.DrawioMap[domId], elem, isPreview);
-        }
-      }
-    });
-  }
-
-  /**
-   * @inheritdoc
-   */
-  renderReactDOM(drawioMapEntry, elem, isPreview) {
-    ReactDOM.render(
-      // eslint-disable-next-line react/jsx-filename-extension
-      <Drawio
-        drawioContent={drawioMapEntry.contentHtml}
-        isPreview={isPreview}
-        rangeLineNumberOfMarkdown={drawioMapEntry.rangeLineNumberOfMarkdown}
-      />,
-      elem,
-    );
-  }
-
-  renderDisabledDrawioReactDOM(drawioMapEntry, elem, isPreview) {
-    ReactDOM.render(
-      // eslint-disable-next-line react/jsx-filename-extension
-      <div className="alert alert-light text-dark">Rendering of draw.io is disabled.</div>,
-      elem,
-    );
-  }
-
-  /**
-   * @inheritdoc
-   */
-  unmountPreviousReactDOMs(newContext) {
-    if (this.previousPreviewContext != null) {
-      Array.from(document.querySelectorAll('.mxgraph')).forEach((element) => {
-        ReactDOM.unmountComponentAtNode(element);
-      });
-    }
-
-    this.previousPreviewContext = newContext;
-  }
-
-}

+ 12 - 1
packages/app/src/services/renderer/renderer.tsx

@@ -3,6 +3,7 @@ import { ComponentType } from 'react';
 
 
 import { Lsx } from '@growi/plugin-lsx/components';
 import { Lsx } from '@growi/plugin-lsx/components';
 import * as lsxGrowiPlugin from '@growi/plugin-lsx/services/renderer';
 import * as lsxGrowiPlugin from '@growi/plugin-lsx/services/renderer';
+import * as drawioPlugin from '@growi/remark-drawio-plugin';
 import growiPlugin from '@growi/remark-growi-plugin';
 import growiPlugin from '@growi/remark-growi-plugin';
 import { Schema as SanitizeOption } from 'hast-util-sanitize';
 import { Schema as SanitizeOption } from 'hast-util-sanitize';
 import { SpecialComponents } from 'react-markdown/lib/ast-to-react';
 import { SpecialComponents } from 'react-markdown/lib/ast-to-react';
@@ -22,6 +23,7 @@ import { PluggableList, Pluggable, PluginTuple } from 'unified';
 
 
 
 
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
+import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { RendererConfig } from '~/interfaces/services/renderer';
@@ -320,6 +322,7 @@ export const generateViewOptions = (
   remarkPlugins.push(
   remarkPlugins.push(
     math,
     math,
     [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
     [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
+    drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
   );
   );
@@ -333,6 +336,7 @@ export const generateViewOptions = (
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
     [sanitize, deepmerge(
     [sanitize, deepmerge(
       commonSanitizeOption,
       commonSanitizeOption,
+      drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
     )],
     )],
     katex,
     katex,
@@ -348,6 +352,7 @@ export const generateViewOptions = (
     components.h2 = Header;
     components.h2 = Header;
     components.h3 = Header;
     components.h3 = Header;
     components.lsx = props => <Lsx {...props} forceToFetchData />;
     components.lsx = props => <Lsx {...props} forceToFetchData />;
+    components.drawio = DrawioViewerWithEditButton;
   }
   }
 
 
   // // Add configurers for viewer
   // // Add configurers for viewer
@@ -397,6 +402,7 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
   remarkPlugins.push(
   remarkPlugins.push(
     math,
     math,
     [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
     [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
+    drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
   );
   );
@@ -410,6 +416,7 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
     [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     [sanitize, deepmerge(
     [sanitize, deepmerge(
       commonSanitizeOption,
       commonSanitizeOption,
+      drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
     )],
     )],
     katex,
     katex,
@@ -418,6 +425,7 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
   // add components
   // add components
   if (components != null) {
   if (components != null) {
     components.lsx = props => <Lsx {...props} />;
     components.lsx = props => <Lsx {...props} />;
+    components.drawio = drawioPlugin.DrawioViewer;
   }
   }
 
 
   verifySanitizePlugin(options, false);
   verifySanitizePlugin(options, false);
@@ -433,6 +441,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   remarkPlugins.push(
   remarkPlugins.push(
     math,
     math,
     [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
     [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
+    drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
   );
   );
@@ -447,6 +456,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     [sanitize, deepmerge(
     [sanitize, deepmerge(
       commonSanitizeOption,
       commonSanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
+      drawioPlugin.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
     )],
     )],
     katex,
     katex,
@@ -455,9 +465,10 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   // add components
   // add components
   if (components != null) {
   if (components != null) {
     components.lsx = props => <Lsx {...props} />;
     components.lsx = props => <Lsx {...props} />;
+    components.drawio = drawioPlugin.DrawioViewer;
   }
   }
 
 
-  verifySanitizePlugin(options, false);
+  // verifySanitizePlugin(options, false);
   return options;
   return options;
 };
 };
 
 

+ 2 - 6
packages/app/src/stores/context.tsx

@@ -89,8 +89,8 @@ export const useRegistrationWhiteList = (initialData?: Nullable<string[]>): SWRR
   return useContextSWR<Nullable<string[]>, Error>('registrationWhiteList', initialData);
   return useContextSWR<Nullable<string[]>, Error>('registrationWhiteList', initialData);
 };
 };
 
 
-export const useDrawioUri = (initialData?: string): SWRResponse<string, Error> => {
-  return useContextSWR('drawioUri', initialData, { fallbackData: 'https://embed.diagrams.net/' });
+export const useDrawioUri = (initialData?: Nullable<string>): SWRResponse<string, Error> => {
+  return useContextSWR('drawioUri', initialData ?? undefined, { fallbackData: 'https://embed.diagrams.net/' });
 };
 };
 
 
 export const useHackmdUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
 export const useHackmdUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
@@ -182,10 +182,6 @@ export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boo
   return useContextSWR('isBlinkedAtBoot', initialData, { fallbackData: false });
   return useContextSWR('isBlinkedAtBoot', initialData, { fallbackData: false });
 };
 };
 
 
-export const useEditingMarkdown = (initialData?: string): SWRResponse<string, Error> => {
-  return useContextSWR('currentMarkdown', initialData);
-};
-
 export const useIsUploadableImage = (initialData?: boolean): SWRResponse<boolean, Error> => {
 export const useIsUploadableImage = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useContextSWR('isUploadableImage', initialData);
   return useContextSWR('isUploadableImage', initialData);
 };
 };

+ 6 - 1
packages/app/src/stores/editor.tsx

@@ -1,5 +1,5 @@
 import { Nullable, withUtils, SWRResponseWithUtils } from '@growi/core';
 import { Nullable, withUtils, SWRResponseWithUtils } from '@growi/core';
-import useSWR, { MutatorOptions, SWRResponse, useSWRConfig } from 'swr';
+import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiGet } from '~/client/util/apiv1-client';
@@ -15,6 +15,11 @@ import { useSWRxTagsInfo } from './page';
 import { useStaticSWR } from './use-static-swr';
 import { useStaticSWR } from './use-static-swr';
 
 
 
 
+export const useEditingMarkdown = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR('editingMarkdown', initialData);
+};
+
+
 type EditorSettingsOperation = {
 type EditorSettingsOperation = {
   update: (updateData: Partial<IEditorSettings>) => Promise<void>,
   update: (updateData: Partial<IEditorSettings>) => Promise<void>,
   turnOffAskingBeforeDownloadLargeFiles: () => void,
   turnOffAskingBeforeDownloadLargeFiles: () => void,

+ 17 - 7
packages/app/src/stores/modal.tsx

@@ -1,3 +1,5 @@
+import { useCallback } from 'react';
+
 import { SWRResponse } from 'swr';
 import { SWRResponse } from 'swr';
 
 
 import MarkdownTable from '~/client/models/MarkdownTable';
 import MarkdownTable from '~/client/models/MarkdownTable';
@@ -444,13 +446,19 @@ export const useShortcutsModal = (): SWRResponse<ShortcutsModalStatus, Error> &
 * DrawioModal
 * DrawioModal
 */
 */
 
 
+type DrawioModalSaveHandler = (drawioMxFile: string) => void;
+
 type DrawioModalStatus = {
 type DrawioModalStatus = {
   isOpened: boolean,
   isOpened: boolean,
   drawioMxFile: string,
   drawioMxFile: string,
+  onSave?: DrawioModalSaveHandler,
 }
 }
 
 
 type DrawioModalStatusUtils = {
 type DrawioModalStatusUtils = {
-  open(drawioMxFile: string): void,
+  open(
+    drawioMxFile: string,
+    onSave?: DrawioModalSaveHandler,
+  ): void,
   close(): void,
   close(): void,
 }
 }
 
 
@@ -461,13 +469,15 @@ export const useDrawioModal = (status?: DrawioModalStatus): SWRResponse<DrawioMo
   };
   };
   const swrResponse = useStaticSWR<DrawioModalStatus, Error>('drawioModalStatus', status, { fallbackData: initialData });
   const swrResponse = useStaticSWR<DrawioModalStatus, Error>('drawioModalStatus', status, { fallbackData: initialData });
 
 
-  const open = (drawioMxFile: string): void => {
-    swrResponse.mutate({ isOpened: true, drawioMxFile });
-  };
+  const { mutate } = swrResponse;
 
 
-  const close = (): void => {
-    swrResponse.mutate({ isOpened: false, drawioMxFile: '' });
-  };
+  const open = useCallback((drawioMxFile: string, onSave?: DrawioModalSaveHandler): void => {
+    mutate({ isOpened: true, drawioMxFile, onSave });
+  }, [mutate]);
+
+  const close = useCallback((): void => {
+    mutate({ isOpened: false, drawioMxFile: '', onSave: undefined });
+  }, [mutate]);
 
 
   return {
   return {
     ...swrResponse,
     ...swrResponse,

+ 0 - 13
packages/app/src/styles/_page.scss

@@ -29,19 +29,6 @@
   }
   }
 }
 }
 
 
-/**
- * for drawio with drawio iframe button
- */
-.editable-with-drawio {
-  .drawio-iframe-trigger {
-    top: 11px;
-    right: 10px;
-    z-index: 14;
-    font-size: 12px;
-    line-height: 1;
-  }
-}
-
 .card.grw-page-status-alert {
 .card.grw-page-status-alert {
   $margin-bottom: $grw-navbar-bottom-height + 10px;
   $margin-bottom: $grw-navbar-bottom-height + 10px;
 
 

+ 7 - 0
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -516,6 +516,13 @@ ul.pagination {
   }
   }
 }
 }
 
 
+/*
+ * drawio
+ */
+.drawio-viewer {
+  border-color: $border-color-global;
+}
+
 /*
 /*
  * modal
  * modal
  */
  */

+ 7 - 0
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -401,6 +401,13 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
   }
   }
 }
 }
 
 
+/*
+ * drawio
+ */
+.drawio-viewer {
+  border-color: $border-color-global;
+}
+
 /*
 /*
  * admin settings
  * admin settings
  */
  */

+ 93 - 50
packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts

@@ -11,15 +11,15 @@ context('Switch Sidebar content', () => {
   it('PageTree is successfully shown', () => {
   it('PageTree is successfully shown', () => {
     cy.collapseSidebar(false);
     cy.collapseSidebar(false);
     cy.visit('/page');
     cy.visit('/page');
+    cy.waitUntilSkeletonDisappear();
+
     cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
     cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(1500);
     cy.wait(1500);
-    cy.screenshot(`${ssPrefix}-pagetree-after-load`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-pagetree-after-load`);
   });
   });
-
 });
 });
 
 
-
 context('Modal for page operation', () => {
 context('Modal for page operation', () => {
 
 
   const ssPrefix = 'modal-for-page-operation-';
   const ssPrefix = 'modal-for-page-operation-';
@@ -31,8 +31,11 @@ context('Modal for page operation', () => {
     });
     });
     cy.collapseSidebar(true);
     cy.collapseSidebar(true);
   });
   });
+
   it("PageCreateModal is shown and closed successfully", () => {
   it("PageCreateModal is shown and closed successfully", () => {
     cy.visit('/');
     cy.visit('/');
+    cy.waitUntilSkeletonDisappear();
+
     cy.getByTestid('newPageBtn').click();
     cy.getByTestid('newPageBtn').click();
 
 
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
@@ -40,11 +43,14 @@ context('Modal for page operation', () => {
       cy.get('button.close').click();
       cy.get('button.close').click();
 
 
     });
     });
-    cy.screenshot(`${ssPrefix}page-create-modal-closed`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}page-create-modal-closed`);
   });
   });
+
   it("Successfully Create Today's page", () => {
   it("Successfully Create Today's page", () => {
     const pageName = "Today's page";
     const pageName = "Today's page";
     cy.visit('/');
     cy.visit('/');
+    cy.waitUntilSkeletonDisappear();
+
     cy.getByTestid('newPageBtn').click();
     cy.getByTestid('newPageBtn').click();
 
 
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
@@ -57,12 +63,22 @@ context('Modal for page operation', () => {
     cy.get('.layout-root').should('not.have.class', 'editing');
     cy.get('.layout-root').should('not.have.class', 'editing');
 
 
     cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
     cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
+
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(300);
+
+    // Do not use "cy.waitUntilSkeletonDisappear()"
+    cy.get('.grw-skeleton').should('not.exist');
+
     cy.screenshot(`${ssPrefix}create-today-page`);
     cy.screenshot(`${ssPrefix}create-today-page`);
   });
   });
+
   it('Successfully create page under specific path', () => {
   it('Successfully create page under specific path', () => {
     const pageName = 'child';
     const pageName = 'child';
 
 
-    cy.visit('/SandBox');
+    cy.visit('/Sandbox');
+    cy.waitUntilSkeletonDisappear();
+
     cy.getByTestid('newPageBtn').click();
     cy.getByTestid('newPageBtn').click();
 
 
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
@@ -75,12 +91,18 @@ context('Modal for page operation', () => {
     cy.get('.layout-root').should('not.have.class', 'editing');
     cy.get('.layout-root').should('not.have.class', 'editing');
 
 
     cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
     cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
+
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(300);
+
+    // Do not use "cy.waitUntilSkeletonDisappear()"
+    cy.get('.grw-skeleton').should('not.exist');
+
     cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
     cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
   });
   });
 
 
   it('Trying to create template page under the root page fail', () => {
   it('Trying to create template page under the root page fail', () => {
     cy.visit('/');
     cy.visit('/');
-
     cy.waitUntilSkeletonDisappear();
     cy.waitUntilSkeletonDisappear();
 
 
     cy.getByTestid('newPageBtn').click();
     cy.getByTestid('newPageBtn').click();
@@ -94,7 +116,7 @@ context('Modal for page operation', () => {
       cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
       cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
     });
     });
     cy.get('.toast-error').should('be.visible').invoke('attr', 'style', 'opacity: 1');
     cy.get('.toast-error').should('be.visible').invoke('attr', 'style', 'opacity: 1');
-    cy.screenshot(`${ssPrefix}create-template-for-children-error`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}create-template-for-children-error`);
     cy.get('.toast-error').should('be.visible').click();
     cy.get('.toast-error').should('be.visible').click();
     cy.get('.toast-error').should('not.exist');
     cy.get('.toast-error').should('not.exist');
 
 
@@ -104,11 +126,12 @@ context('Modal for page operation', () => {
       cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
       cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
     });
     });
     cy.get('.toast-error').should('be.visible').invoke('attr', 'style', 'opacity: 1');
     cy.get('.toast-error').should('be.visible').invoke('attr', 'style', 'opacity: 1');
-    cy.screenshot(`${ssPrefix}create-template-for-descendants-error`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}create-template-for-descendants-error`);
   });
   });
 
 
   it('PageDeleteModal is shown successfully', () => {
   it('PageDeleteModal is shown successfully', () => {
     cy.visit('/Sandbox/Bootstrap4');
     cy.visit('/Sandbox/Bootstrap4');
+    cy.waitUntilSkeletonDisappear();
 
 
      cy.get('#grw-subnav-container').within(() => {
      cy.get('#grw-subnav-container').within(() => {
        cy.getByTestid('open-page-item-control-btn').click({force: true});
        cy.getByTestid('open-page-item-control-btn').click({force: true});
@@ -119,7 +142,8 @@ context('Modal for page operation', () => {
   });
   });
 
 
   it('PageDuplicateModal is shown successfully', () => {
   it('PageDuplicateModal is shown successfully', () => {
-    cy.visit('/Sandbox/Bootstrap4', {  });
+    cy.visit('/Sandbox/Bootstrap4');
+    cy.waitUntilSkeletonDisappear();
 
 
     cy.get('#grw-subnav-container').within(() => {
     cy.get('#grw-subnav-container').within(() => {
       cy.getByTestid('open-page-item-control-btn').click({force: true});
       cy.getByTestid('open-page-item-control-btn').click({force: true});
@@ -130,45 +154,49 @@ context('Modal for page operation', () => {
   });
   });
 
 
   it('PageMoveRenameModal is shown successfully', () => {
   it('PageMoveRenameModal is shown successfully', () => {
-    cy.visit('/Sandbox/Bootstrap4', {  });
+    cy.visit('/Sandbox/Bootstrap4');
+    cy.waitUntilSkeletonDisappear();
 
 
     cy.get('#grw-subnav-container').within(() => {
     cy.get('#grw-subnav-container').within(() => {
       cy.getByTestid('open-page-item-control-btn').click({force: true});
       cy.getByTestid('open-page-item-control-btn').click({force: true});
       cy.getByTestid('open-page-move-rename-modal-btn').click({force: true});
       cy.getByTestid('open-page-move-rename-modal-btn').click({force: true});
     });
     });
 
 
+    cy.getByTestid('grw-page-rename-button').should('be.disabled');
+
     cy.getByTestid('page-rename-modal').should('be.visible').screenshot(`${ssPrefix}-rename-bootstrap4`);
     cy.getByTestid('page-rename-modal').should('be.visible').screenshot(`${ssPrefix}-rename-bootstrap4`);
   });
   });
 
 
 });
 });
 
 
 
 
-context('Open presentation modal', () => {
+// TODO: Uncomment after https://redmine.weseek.co.jp/issues/103121 is resolved
+// context('Open presentation modal', () => {
 
 
-  const ssPrefix = 'access-to-presentation-modal-';
+//   const ssPrefix = 'access-to-presentation-modal-';
 
 
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-    cy.collapseSidebar(true);
-  });
+//   beforeEach(() => {
+//     // login
+//     cy.fixture("user-admin.json").then(user => {
+//       cy.login(user.username, user.password);
+//     });
+//     cy.collapseSidebar(true);
+//   });
 
 
-  it('PresentationModal for "/" is shown successfully', () => {
-    cy.visit('/');
+//   it('PresentationModal for "/" is shown successfully', () => {
+//     cy.visit('/');
 
 
-    cy.get('#grw-subnav-container').within(() => {
-      cy.getByTestid('open-page-item-control-btn').click({force: true});
-      cy.getByTestid('open-presentation-modal-btn').click({force: true});
-    });
+//     cy.get('#grw-subnav-container').within(() => {
+//       cy.getByTestid('open-page-item-control-btn').click({force: true});
+//       cy.getByTestid('open-presentation-modal-btn').click({force: true});
+//     });
 
 
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(1500);
-    cy.screenshot(`${ssPrefix}-open-top`);
-  });
+//     // eslint-disable-next-line cypress/no-unnecessary-waiting
+//     cy.wait(1500);
+//     cy.screenshot(`${ssPrefix}-open-top`);
+//   });
 
 
-});
+// });
 
 
 context('Page Accessories Modal', () => {
 context('Page Accessories Modal', () => {
 
 
@@ -184,6 +212,8 @@ context('Page Accessories Modal', () => {
 
 
   it('Page History is shown successfully', () => {
   it('Page History is shown successfully', () => {
      cy.visit('/Sandbox/Bootstrap4');
      cy.visit('/Sandbox/Bootstrap4');
+     cy.waitUntilSkeletonDisappear();
+
      cy.get('#grw-subnav-container').within(() => {
      cy.get('#grw-subnav-container').within(() => {
       cy.getByTestid('open-page-item-control-btn').within(() => {
       cy.getByTestid('open-page-item-control-btn').within(() => {
         cy.get('button.btn-page-item-control').click({force: true});
         cy.get('button.btn-page-item-control').click({force: true});
@@ -194,22 +224,29 @@ context('Page Accessories Modal', () => {
      cy.getByTestid('page-history').should('be.visible')
      cy.getByTestid('page-history').should('be.visible')
      cy.screenshot(`${ssPrefix}-open-page-history-bootstrap4`);
      cy.screenshot(`${ssPrefix}-open-page-history-bootstrap4`);
   });
   });
+
   it('Page Attachment Data is shown successfully', () => {
   it('Page Attachment Data is shown successfully', () => {
-     cy.visit('/Sandbox/Bootstrap4', {  });
+     cy.visit('/Sandbox/Bootstrap4');
+     cy.waitUntilSkeletonDisappear();
+
      cy.get('#grw-subnav-container').within(() => {
      cy.get('#grw-subnav-container').within(() => {
+      cy.getByTestid('open-page-item-control-btn').should('be.visible');
       cy.getByTestid('open-page-item-control-btn').within(() => {
       cy.getByTestid('open-page-item-control-btn').within(() => {
-        cy.getByTestid('open-page-item-control-btn').should('be.visible');
         cy.get('button.btn-page-item-control').click({force: true});
         cy.get('button.btn-page-item-control').click({force: true});
+        cy.getByTestid('page-item-control-menu').should('be.visible');
+        cy.getByTestid('open-page-accessories-modal-btn-with-attachment-data-tab').click();
       });
       });
-       cy.getByTestid('open-page-accessories-modal-btn-with-attachment-data-tab').click();
     });
     });
 
 
      cy.getByTestid('page-accessories-modal').should('be.visible')
      cy.getByTestid('page-accessories-modal').should('be.visible')
      cy.getByTestid('page-attachment').should('be.visible')
      cy.getByTestid('page-attachment').should('be.visible')
      cy.screenshot(`${ssPrefix}-open-page-attachment-data-bootstrap4`);
      cy.screenshot(`${ssPrefix}-open-page-attachment-data-bootstrap4`);
   });
   });
+
   it('Share Link Management is shown successfully', () => {
   it('Share Link Management is shown successfully', () => {
-    cy.visit('/Sandbox/Bootstrap4', { });
+    cy.visit('/Sandbox/Bootstrap4');
+    cy.waitUntilSkeletonDisappear();
+
     cy.get('#grw-subnav-container').within(() => {
     cy.get('#grw-subnav-container').within(() => {
       cy.getByTestid('open-page-item-control-btn').within(() => {
       cy.getByTestid('open-page-item-control-btn').within(() => {
         cy.get('button.btn-page-item-control').click({force: true});
         cy.get('button.btn-page-item-control').click({force: true});
@@ -222,10 +259,9 @@ context('Page Accessories Modal', () => {
    cy.getByTestid('share-link-management').should('be.visible');
    cy.getByTestid('share-link-management').should('be.visible');
    cy.screenshot(`${ssPrefix}-open-share-link-management-bootstrap4`);
    cy.screenshot(`${ssPrefix}-open-share-link-management-bootstrap4`);
   });
   });
-
 });
 });
 
 
-context('Tag Oprations', () =>{
+context('Tag Oprations', { scrollBehavior: false }, () =>{
 
 
   beforeEach(() => {
   beforeEach(() => {
     // login
     // login
@@ -238,6 +274,7 @@ context('Tag Oprations', () =>{
   it('Successfully add new tag', () => {
   it('Successfully add new tag', () => {
     const ssPrefix = 'tag-operations-add-new-tag-'
     const ssPrefix = 'tag-operations-add-new-tag-'
     const tag = 'we';
     const tag = 'we';
+
     cy.visit('/Sandbox');
     cy.visit('/Sandbox');
     cy.waitUntilSkeletonDisappear();
     cy.waitUntilSkeletonDisappear();
 
 
@@ -258,7 +295,7 @@ context('Tag Oprations', () =>{
       cy.get('#tag-typeahead-asynctypeahead').should('be.visible');
       cy.get('#tag-typeahead-asynctypeahead').should('be.visible');
       cy.get('#tag-typeahead-asynctypeahead-item-0').should('be.visible');
       cy.get('#tag-typeahead-asynctypeahead-item-0').should('be.visible');
       cy.get('a#tag-typeahead-asynctypeahead-item-0').click({force: true})
       cy.get('a#tag-typeahead-asynctypeahead-item-0').click({force: true})
-      cy.screenshot(`${ssPrefix}3-insert-tag-name`, {capture: 'viewport'});
+      cy.screenshot(`${ssPrefix}3-insert-tag-name`);
     });
     });
 
 
     cy.get('#edit-tag-modal').within(() => {
     cy.get('#edit-tag-modal').within(() => {
@@ -266,17 +303,17 @@ context('Tag Oprations', () =>{
     });
     });
 
 
     cy.get('.toast').should('be.visible').trigger('mouseover');
     cy.get('.toast').should('be.visible').trigger('mouseover');
-    cy.get('.grw-taglabels-container > .grw-tag-labels > a', { timeout: 10000 }).contains(tag).should('exist');
+    cy.get('.grw-taglabels-container > .grw-tag-labels > a').contains(tag).should('exist');
     /* eslint-disable cypress/no-unnecessary-waiting */
     /* eslint-disable cypress/no-unnecessary-waiting */
     cy.wait(150); // wait for toastr to change its color occured by mouseover
     cy.wait(150); // wait for toastr to change its color occured by mouseover
-    cy.screenshot(`${ssPrefix}4-click-done`, {capture: 'viewport'});
-
+    cy.screenshot(`${ssPrefix}4-click-done`);
   });
   });
 
 
   it('Successfully duplicate page by generated tag', () => {
   it('Successfully duplicate page by generated tag', () => {
     const ssPrefix = 'tag-operations-page-duplicate-';
     const ssPrefix = 'tag-operations-page-duplicate-';
     const tag = 'we';
     const tag = 'we';
     const newPageName = 'our';
     const newPageName = 'our';
+
     cy.visit('/Sandbox');
     cy.visit('/Sandbox');
     cy.waitUntilSkeletonDisappear();
     cy.waitUntilSkeletonDisappear();
 
 
@@ -287,13 +324,16 @@ context('Tag Oprations', () =>{
         });
         });
       });
       });
     });
     });
+
+    // Search result page
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    // cy.get('#wiki').should('be.visible');
+    cy.get('#revision-loader').should('be.visible');
+
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
-    cy.screenshot(`${ssPrefix}1-click-tag-name`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}1-click-tag-name`);
     cy.getByTestid('search-result-list').should('be.visible').then(($el)=>{
     cy.getByTestid('search-result-list').should('be.visible').then(($el)=>{
       cy.wrap($el).within(()=>{
       cy.wrap($el).within(()=>{
         cy.getByTestid('open-page-item-control-btn').first().click();
         cy.getByTestid('open-page-item-control-btn').first().click();
@@ -301,7 +341,7 @@ context('Tag Oprations', () =>{
 
 
       // eslint-disable-next-line cypress/no-unnecessary-waiting
       // eslint-disable-next-line cypress/no-unnecessary-waiting
       cy.wait(1500); // for wait rendering pagelist info
       cy.wait(1500); // for wait rendering pagelist info
-      cy.screenshot(`${ssPrefix}2-click-three-dots-menu`, {capture: 'viewport'});
+      cy.screenshot(`${ssPrefix}2-click-three-dots-menu`);
 
 
       cy.wrap($el).within(()=>{
       cy.wrap($el).within(()=>{
         cy.getByTestid('open-page-item-control-btn').first().within(()=>{
         cy.getByTestid('open-page-item-control-btn').first().within(()=>{
@@ -312,14 +352,15 @@ context('Tag Oprations', () =>{
 
 
     cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
     cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
       cy.get('.rbt-input-main').type(`-${newPageName}`, {force: true});
       cy.get('.rbt-input-main').type(`-${newPageName}`, {force: true});
-    }).screenshot(`${ssPrefix}3-duplicate-page`, {capture: 'viewport'});
+    }).screenshot(`${ssPrefix}3-duplicate-page`);
 
 
     cy.getByTestid('page-duplicate-modal').within(() => {
     cy.getByTestid('page-duplicate-modal').within(() => {
       cy.get('.modal-footer > button.btn').click();
       cy.get('.modal-footer > button.btn').click();
     });
     });
+
     cy.visit(`Sandbox-${newPageName}`);
     cy.visit(`Sandbox-${newPageName}`);
     cy.waitUntilSkeletonDisappear();
     cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}4-duplicated-page`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}4-duplicated-page`);
   });
   });
 
 
   it('Successfully rename page from generated tag', () => {
   it('Successfully rename page from generated tag', () => {
@@ -330,14 +371,17 @@ context('Tag Oprations', () =>{
 
 
     cy.visit('/Sandbox-our');
     cy.visit('/Sandbox-our');
     cy.waitUntilSkeletonDisappear();
     cy.waitUntilSkeletonDisappear();
+
+    // Search result page
     cy.get('.grw-tag-label').should('be.visible').contains(tag).click();
     cy.get('.grw-tag-label').should('be.visible').contains(tag).click();
-    cy.waitUntilSkeletonDisappear();
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
+    cy.get('#revision-loader').should('be.visible');
+
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(300);
     cy.wait(300);
-    cy.screenshot(`${ssPrefix}1-click-tag-name`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}1-click-tag-name`);
 
 
     cy.getByTestid('search-result-list').within(() => {
     cy.getByTestid('search-result-list').within(() => {
       cy.get('.list-group-item').each(($row) => {
       cy.get('.list-group-item').each(($row) => {
@@ -376,11 +420,10 @@ context('Tag Oprations', () =>{
       cy.get('.modal-footer > button').click();
       cy.get('.modal-footer > button').click();
     });
     });
 
 
-    cy.visit(`${newPageName}`);
+    cy.visit(newPageName);
     cy.waitUntilSkeletonDisappear();
     cy.waitUntilSkeletonDisappear();
 
 
     cy.getByTestid('grw-tag-labels').should('be.visible')
     cy.getByTestid('grw-tag-labels').should('be.visible')
-    cy.screenshot(`${ssPrefix}4-new-page-name-applied`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}4-new-page-name-applied`);
   });
   });
-
 });
 });

+ 1 - 0
packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts

@@ -106,6 +106,7 @@ context('Access to Admin page', () => {
   it('/admin/user-groups is successfully loaded', () => {
   it('/admin/user-groups is successfully loaded', () => {
     cy.visit('/admin/user-groups');
     cy.visit('/admin/user-groups');
     cy.getByTestid('admin-user-groups').should('be.visible');
     cy.getByTestid('admin-user-groups').should('be.visible');
+    cy.getByTestid('grw-user-group-table').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-user-groups`);
     cy.screenshot(`${ssPrefix}-admin-user-groups`);
   });
   });
 
 

+ 1 - 0
packages/remark-drawio-plugin/.eslintignore

@@ -0,0 +1 @@
+/dist/**

+ 18 - 0
packages/remark-drawio-plugin/.eslintrc.js

@@ -0,0 +1,18 @@
+module.exports = {
+  extends: [
+    'weseek/react',
+    'weseek/typescript',
+  ],
+  env: {
+  },
+  globals: {
+  },
+  settings: {
+    // resolve path aliases by eslint-import-resolver-typescript
+    'import/resolver': {
+      typescript: {},
+    },
+  },
+  rules: {
+  },
+};

+ 1 - 0
packages/remark-drawio-plugin/.gitignore

@@ -0,0 +1 @@
+/dist

+ 6 - 0
packages/remark-drawio-plugin/README.md

@@ -0,0 +1,6 @@
+# remark-drawio-plugin
+
+[GROWI][growi] remark plugin to draw diagrams with [draw.io (diagrams.net)](https://www.diagrams.net/)
+
+Usage
+------

+ 35 - 0
packages/remark-drawio-plugin/package.json

@@ -0,0 +1,35 @@
+{
+  "name": "@growi/remark-drawio-plugin",
+  "version": "6.0.0-RC.8",
+  "description": "remark plugin to draw diagrams with draw.io (diagrams.net)",
+  "license": "MIT",
+  "keywords": [
+    "unified",
+    "remark",
+    "remark-plugin",
+    "plugin",
+    "mdast",
+    "markdown"
+  ],
+  "main": "dist/index.js",
+  "typings": "dist/index.d.ts",
+  "scripts": {
+    "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
+    "tsc": "tsc -p tsconfig.build.json",
+    "tsc:w": "yarn tsc -w",
+    "clean": "npx -y shx rm -rf dist",
+    "lint:js": "eslint **/*.{js,jsx,ts,tsx}",
+    "lint:styles": "stylelint --allow-empty-input src/**/*.scss src/**/*.css",
+    "lint": "run-p lint:*",
+    "test": ""
+  },
+  "dependencies": {
+    "xmldoc": "^1.2.0"
+  },
+  "devDependencies": {
+    "eslint-plugin-regex": "^1.8.0",
+    "pako": "^2.1.0",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  }
+}

+ 12 - 0
packages/remark-drawio-plugin/src/components/DrawioViewer.module.scss

@@ -0,0 +1,12 @@
+.drawio-viewer :global {
+  margin: 20px 0;
+  border: 1px solid transparent;
+  border-radius: 4px;
+
+  .geDiagramContainer {
+    // centering
+    margin-right: auto;
+    margin-left: auto;
+  }
+
+}

+ 162 - 0
packages/remark-drawio-plugin/src/components/DrawioViewer.tsx

@@ -0,0 +1,162 @@
+import React, {
+  ReactNode, useCallback, useEffect, useMemo, useRef, useState,
+} from 'react';
+
+import { debounce } from 'throttle-debounce';
+
+import type { IGraphViewerGlobal } from '..';
+import { generateMxgraphData } from '../utils/embed';
+import { isGraphViewerGlobal } from '../utils/global';
+
+
+import styles from './DrawioViewer.module.scss';
+
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var GraphViewer: IGraphViewerGlobal;
+}
+
+
+export type DrawioViewerProps = {
+  diagramIndex: number,
+  bol: number,
+  eol: number,
+  children?: ReactNode,
+  onRenderingStart?: () => void,
+  onRenderingUpdated?: (mxfile: string | null) => void,
+}
+
+export type DrawioEditByViewerProps = {
+  bol: number,
+  eol: number,
+  drawioMxFile: string,
+}
+
+export const DrawioViewer = React.memo((props: DrawioViewerProps): JSX.Element => {
+  const {
+    diagramIndex, bol, eol, children,
+    onRenderingStart, onRenderingUpdated,
+  } = props;
+
+  const drawioContainerRef = useRef<HTMLDivElement>(null);
+
+  const [error, setError] = useState<Error>();
+
+  const renderDrawio = useCallback(() => {
+    if (drawioContainerRef.current == null) {
+      return;
+    }
+
+    if (!('GraphViewer' in window && isGraphViewerGlobal(GraphViewer))) {
+      // Do nothing if loading has not been terminated.
+      // Alternatively, GraphViewer.processElements() will be called in Script.onLoad.
+      // see DrawioViewerScript.tsx
+      return;
+    }
+
+    const mxgraphs = drawioContainerRef.current.getElementsByClassName('mxgraph');
+    if (mxgraphs.length > 0) {
+      // This component should have only one '.mxgraph' element
+      const div = mxgraphs[0];
+
+      if (div != null) {
+        div.innerHTML = '';
+
+        // render diagram with createViewerForElement
+        try {
+          GraphViewer.createViewerForElement(div);
+        }
+        catch (err) {
+          setError(err);
+        }
+      }
+    }
+  }, []);
+
+  const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
+
+  const mxgraphHtml = useMemo(() => {
+    setError(undefined);
+
+    if (children == null) {
+      return '';
+    }
+
+    const code = children instanceof Array
+      ? children.map(e => e?.toString()).join('')
+      : children.toString();
+
+    let mxgraphData;
+    try {
+      mxgraphData = generateMxgraphData(code);
+    }
+    catch (err) {
+      setError(err);
+    }
+
+    return `<div class="mxgraph" data-mxgraph="${mxgraphData}"></div>`;
+  }, [children]);
+
+  useEffect(() => {
+    if (mxgraphHtml.length > 0) {
+      onRenderingStart?.();
+      renderDrawioWithDebounce();
+    }
+  }, [mxgraphHtml, onRenderingStart, renderDrawioWithDebounce]);
+
+  useEffect(() => {
+    if (error != null) {
+      onRenderingUpdated?.(null);
+    }
+  }, [error, onRenderingUpdated]);
+
+  // ****************  detect data-mxgraph has rendered ****************
+  useEffect(() => {
+    const container = drawioContainerRef.current;
+    if (container == null) return;
+
+    const observerCallback = (mutationRecords:MutationRecord[]) => {
+      mutationRecords.forEach((record:MutationRecord) => {
+        const target = record.target as HTMLElement;
+
+        const mxgraphData = target.dataset.mxgraph;
+        if (mxgraphData != null) {
+          const mxgraph = JSON.parse(mxgraphData);
+          onRenderingUpdated?.(mxgraph.xml);
+        }
+      });
+    };
+
+    const observer = new MutationObserver(observerCallback);
+    observer.observe(container, { childList: true, subtree: true });
+    return () => {
+      observer.disconnect();
+    };
+  }, [onRenderingUpdated]);
+  // *******************************  end  *******************************
+
+  return (
+    <div
+      key={`drawio-viewer-${diagramIndex}`}
+      ref={drawioContainerRef}
+      className={`drawio-viewer ${styles['drawio-viewer']} p-2`}
+      data-begin-line-number-of-markdown={bol}
+      data-end-line-number-of-markdown={eol}
+    >
+      {/* show error */}
+      { error != null && (
+        <span className="text-muted"><i className="icon-fw icon-exclamation"></i>
+          {error.name && <strong>{error.name}: </strong>}
+          {error.message}
+        </span>
+      ) }
+
+      { error == null && (
+        // eslint-disable-next-line react/no-danger
+        <div dangerouslySetInnerHTML={{ __html: mxgraphHtml }} />
+      ) }
+    </div>
+  );
+});
+DrawioViewer.displayName = 'DrawioViewer';

+ 5 - 0
packages/remark-drawio-plugin/src/index.ts

@@ -0,0 +1,5 @@
+export * from './interfaces/graph-viewer';
+export * from './components/DrawioViewer';
+export * from './services/renderer/remark-drawio-plugin';
+export * from './utils/embed';
+export * from './utils/global';

+ 15 - 0
packages/remark-drawio-plugin/src/interfaces/graph-viewer.ts

@@ -0,0 +1,15 @@
+export interface IGraphViewer {
+  checkVisibleState: boolean,
+  responsive: boolean,
+  lightboxZIndex: number,
+  toolbarZIndex: number,
+  xml: string,
+}
+
+export interface IGraphViewerGlobal {
+  processElements: () => void,
+  createViewerForElement: (element: Element, callback?: (viewer: IGraphViewer) => void) => void,
+
+  useResizeSensor: boolean,
+  prototype: IGraphViewer,
+}

+ 53 - 0
packages/remark-drawio-plugin/src/services/renderer/remark-drawio-plugin.ts

@@ -0,0 +1,53 @@
+import { Schema as SanitizeOption } from 'hast-util-sanitize';
+import { Plugin } from 'unified';
+import { Node } from 'unist';
+import { visit } from 'unist-util-visit';
+
+const SUPPORTED_ATTRIBUTES = ['diagramIndex', 'bol', 'eol'];
+
+type Lang = 'drawio';
+
+function isDrawioBlock(lang: unknown): lang is Lang {
+  return /^drawio$/.test(lang as string);
+}
+
+function rewriteNode(node: Node, index: number) {
+  const data = node.data ?? (node.data = {});
+
+  node.type = 'paragraph';
+  node.children = [{ type: 'text', value: node.value }];
+  data.hName = 'drawio';
+  data.hProperties = {
+    diagramIndex: index,
+    bol: node.position?.start.line,
+    eol: node.position?.end.line,
+  };
+}
+
+export const remarkPlugin: Plugin = function() {
+  return (tree) => {
+    visit(tree, (node, index) => {
+      if (node.type === 'code') {
+        if (isDrawioBlock(node.lang)) {
+          rewriteNode(node, index ?? 0);
+
+          // omit position to fix the key regardless of its position
+          // see:
+          //   https://github.com/remarkjs/react-markdown/issues/703
+          //   https://github.com/remarkjs/react-markdown/issues/466
+          //
+          //   https://github.com/remarkjs/react-markdown/blob/a80dfdee2703d84ac2120d28b0e4998a5b417c85/lib/ast-to-react.js#L201-L204
+          //   https://github.com/remarkjs/react-markdown/blob/a80dfdee2703d84ac2120d28b0e4998a5b417c85/lib/ast-to-react.js#L217-L222
+          delete node.position;
+        }
+      }
+    });
+  };
+};
+
+export const sanitizeOption: SanitizeOption = {
+  tagNames: ['drawio'],
+  attributes: {
+    drawio: SUPPORTED_ATTRIBUTES,
+  },
+};

+ 102 - 0
packages/remark-drawio-plugin/src/utils/embed.ts

@@ -0,0 +1,102 @@
+// transplanted from https://github.com/jgraph/drawio-tools/blob/d46977060ffad70cae5a9059a2cbfcd8bcf420de/tools/convert.html
+import pako from 'pako';
+import xmldoc from 'xmldoc';
+
+export const extractCodeFromMxfile = (input: string): string => {
+  const doc = new xmldoc.XmlDocument(input);
+  return doc.valueWithPath('diagram');
+};
+
+const validateInputData = (input: string): boolean => {
+  let data = input;
+
+  try {
+    data = extractCodeFromMxfile(data);
+  }
+  catch (e) {
+    // ignore
+  }
+
+  try {
+    data = Buffer.from(data, 'base64').toString('binary');
+  }
+  catch (e) {
+    throw new Error(`Base64 to binary failed: ${e}`);
+  }
+
+  if (data.length > 0) {
+    try {
+      data = pako.inflateRaw(Uint8Array.from(data, c => c.charCodeAt(0)), { to: 'string' });
+    }
+    catch (e) {
+      throw new Error(`inflateRaw failed: ${e}`);
+    }
+  }
+
+  try {
+    data = decodeURIComponent(data);
+  }
+  catch (e) {
+    throw new Error(`decodeURIComponent failed: ${e}`);
+  }
+
+  return true;
+};
+
+const escapeHTML = (string): string => {
+  if (typeof string !== 'string') {
+    return string;
+  }
+  return string.replace(/[&'`"<>]/g, (match): string => {
+    return {
+      '&': '&amp;',
+      "'": '&#x27;',
+      '`': '&#x60;',
+      '"': '&quot;',
+      '<': '&lt;',
+      '>': '&gt;',
+    }[match] ?? match;
+  });
+};
+
+export const generateMxgraphData = (code: string): string => {
+  const trimedCode = code.trim();
+  if (!trimedCode) {
+    return '';
+  }
+
+  validateInputData(trimedCode);
+
+  let xml;
+  try {
+    // may be XML Format <mxfile><diagram> ... </diagram></mxfile>
+    const doc = new xmldoc.XmlDocument(trimedCode);
+    const diagram = doc.valueWithPath('diagram');
+    if (diagram) {
+      xml = trimedCode;
+    }
+  }
+  catch (e) {
+    // may be NOT XML Format
+    xml = `
+<mxfile version="6.8.9" editor="www.draw.io" type="atlas">
+  <mxAtlasLibraries/>
+  <diagram>${trimedCode}</diagram>
+</mxfile>
+`;
+  }
+
+  // see options: https://drawio.freshdesk.com/support/solutions/articles/16000042542-embed-html
+  const mxGraphData = {
+    editable: false,
+    highlight: '#0000ff',
+    nav: false,
+    toolbar: null,
+    edit: null,
+    resize: true,
+    lightbox: 'false',
+    xml,
+  };
+
+  return escapeHTML(JSON.stringify(mxGraphData));
+};

+ 5 - 0
packages/remark-drawio-plugin/src/utils/global.ts

@@ -0,0 +1,5 @@
+import { IGraphViewerGlobal } from '../interfaces/graph-viewer';
+
+export const isGraphViewerGlobal = (val: unknown): val is IGraphViewerGlobal => {
+  return (typeof val === 'function' && 'createViewerForElement' in val && 'processElements' in val);
+};

+ 12 - 0
packages/remark-drawio-plugin/tsconfig.base.json

@@ -0,0 +1,12 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "jsx": "preserve",
+  },
+  "include": [
+    "src"
+  ],
+  "exclude": [
+    "test"
+  ]
+}

+ 16 - 0
packages/remark-drawio-plugin/tsconfig.build.json

@@ -0,0 +1,16 @@
+{
+  "extends": "./tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "./src",
+    "outDir": "dist",
+    "declaration": true,
+    "noResolve": false,
+    "preserveConstEnums": true,
+    "sourceMap": false,
+    "noEmit": false,
+
+    "baseUrl": ".",
+    "paths": {
+    }
+  }
+}

+ 10 - 0
packages/remark-drawio-plugin/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "extends": "./tsconfig.base.json",
+  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+      "~/*": ["./src/*"],
+      "@growi/*": ["../*/src"]
+    }
+  }
+}

+ 13 - 1
yarn.lock

@@ -17364,6 +17364,11 @@ pacote@^11.2.6:
     ssri "^8.0.1"
     ssri "^8.0.1"
     tar "^6.1.0"
     tar "^6.1.0"
 
 
+pako@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86"
+  integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==
+
 param-case@^3.0.3, param-case@^3.0.4:
 param-case@^3.0.3, param-case@^3.0.4:
   version "3.0.4"
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5"
   resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5"
@@ -20994,7 +20999,7 @@ sax@1.2.1:
   version "1.2.1"
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a"
 
 
-sax@>=0.6.0:
+sax@>=0.6.0, sax@^1.2.4:
   version "1.2.4"
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
   integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
   integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
@@ -24835,6 +24840,13 @@ xmlbuilder@~9.0.1:
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
   integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=
   integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=
 
 
+xmldoc@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/xmldoc/-/xmldoc-1.2.0.tgz#7554371bfd8c138287cff01841ae4566d26e5541"
+  integrity sha512-2eN8QhjBsMW2uVj7JHLHkMytpvGHLHxKXBy4J3fAT/HujsEtM6yU84iGjpESYGHg6XwK0Vu4l+KgqQ2dv2cCqg==
+  dependencies:
+    sax "^1.2.4"
+
 xmldom@0.1.x:
 xmldom@0.1.x:
   version "0.1.31"
   version "0.1.31"
   resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
   resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"

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