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

Merge branch 'dev/7.0.x' into imprv/133910-140441-replace-spinner

Tatsuya Ise 2 лет назад
Родитель
Сommit
bc4a33cf38
21 измененных файлов с 513 добавлено и 65 удалено
  1. 1 1
      apps/app/src/components/PageEditor/HandsontableModal.tsx
  2. 1 0
      apps/app/src/components/PageEditor/Preview.module.scss
  3. 10 3
      apps/app/src/features/search/client/components/SearchForm.tsx
  4. 10 10
      apps/app/src/features/search/client/components/SearchHelp.tsx
  5. 1 1
      apps/app/src/features/search/client/components/SearchMenuItem.tsx
  6. 22 16
      apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx
  7. 4 3
      apps/app/src/features/search/client/components/SearchModal.tsx
  8. 5 6
      apps/app/src/features/search/client/components/SearchResultMenuItem.tsx
  9. 6 1
      apps/app/src/server/routes/apiv3/page/create-page.ts
  10. 4 0
      packages/editor/package.json
  11. 1 1
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  12. 7 1
      packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropdownItem.tsx
  13. 16 2
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts
  14. 7 5
      packages/editor/src/services/file-dropzone/use-file-dropzone/use-file-dropzone.ts
  15. 13 14
      packages/editor/src/services/list-util/insert-newline-continue-markup.ts
  16. 0 0
      packages/editor/src/services/paste-util/paste-markdown-util.ts
  17. 1 0
      packages/editor/src/services/table-util/index.ts
  18. 202 0
      packages/editor/src/services/table-util/insert-new-row-to-table-markdown.ts
  19. 24 0
      packages/editor/src/services/table-util/markdown-table.d.ts
  20. 147 0
      packages/editor/src/services/table-util/markdown-table.js
  21. 31 1
      yarn.lock

+ 1 - 1
apps/app/src/components/PageEditor/HandsontableModal.tsx

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
 
 import { useHandsontableModalForEditor } from '@growi/editor/src/stores/use-handsontable';
 import { HotTable } from '@handsontable/react';
-import Handsontable from 'handsontable';
+import type Handsontable from 'handsontable';
 import { useTranslation } from 'next-i18next';
 import {
   Collapse,

+ 1 - 0
apps/app/src/components/PageEditor/Preview.module.scss

@@ -3,6 +3,7 @@
 .page-editor-preview-body :global {
   .wiki {
     max-width: 980px;
+    padding: 0px 15px;
     margin: 0 auto;
   }
 }

+ 10 - 3
apps/app/src/features/search/client/components/SearchForm.tsx

@@ -2,7 +2,7 @@ import React, {
   useCallback, useRef, useEffect, useMemo,
 } from 'react';
 
-import { GetInputProps } from '../interfaces/downshift';
+import type { GetInputProps } from '../interfaces/downshift';
 
 type Props = {
   searchKeyword: string,
@@ -35,7 +35,7 @@ export const SearchForm = (props: Props): JSX.Element => {
 
   const inputOptions = useMemo(() => {
     return getInputProps({
-      type: 'search',
+      type: 'text',
       placeholder: 'Search...',
       className: 'form-control',
       ref: inputRef,
@@ -52,11 +52,18 @@ export const SearchForm = (props: Props): JSX.Element => {
 
   return (
     <form
-      className="w-100"
+      className="w-100 position-relative"
       onSubmit={submitHandler}
       data-testid="search-form"
     >
       <input {...inputOptions} />
+      <button
+        type="button"
+        className="btn btn-neutral-secondary text-muted position-absolute bottom-0 end-0 w-auto h-100 border-0"
+        onClick={() => { onChange?.('') }}
+      >
+        <span className="material-symbols-outlined p-0">cancel</span>
+      </button>
     </form>
   );
 };

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

@@ -11,40 +11,40 @@ export const SearchHelp = (): JSX.Element => {
   return (
     <>
       <button type="button" className="btn border-0 text-muted d-flex justify-content-center align-items-center ms-1 p-0" onClick={() => setIsOpen(!isOpen)}>
-        <span className="material-symbols-outlined me-2">help</span>
-        { t('search_help.title') }
-        <span className="material-symbols-outlined ms-2">{isOpen ? 'expand_less' : 'expand_more'}</span>
+        <span className="material-symbols-outlined me-2 p-0">help</span>
+        <span>{t('search_help.title')}</span>
+        <span className="material-symbols-outlined ms-2 p-0">{isOpen ? 'expand_less' : 'expand_more'}</span>
       </button>
       <Collapse isOpen={isOpen}>
-        <table className="table m-0">
+        <table className="table table-borderless m-0">
           <tbody>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2">
                 <code>word1</code> <code>word2</code><br />
                 <small className="text-muted">({ t('search_help.and.syntax help') })</small>
               </th>
               <td><h6 className="m-0 text-muted">{ t('search_help.and.desc', { word1: 'word1', word2: 'word2' }) }</h6></td>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2">
                 <code>&quot;This is GROWI&quot;</code><br />
                 <small className="text-muted">({ t('search_help.phrase.syntax help') })</small>
               </th>
               <td><h6 className="m-0 text-muted">{ t('search_help.phrase.desc', { phrase: 'This is GROWI' }) }</h6></td>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2"><code>-keyword</code></th>
               <td><h6 className="m-0 text-muted">{ t('search_help.exclude.desc', { word: 'keyword' }) }</h6></td>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2"><code>prefix:/user/</code></th>
               <td><h6 className="m-0 text-muted">{ t('search_help.prefix.desc', { path: '/user/' }) }</h6></td>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2"><code>-prefix:/user/</code></th>
               <td><h6 className="m-0 text-muted">{ t('search_help.exclude_prefix.desc', { path: '/user/' }) }</h6></td>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2"><code>tag:wiki</code></th>
               <td><h6 className="m-0 text-muted">{ t('search_help.tag.desc', { tag: 'wiki' }) }</h6></td>
             </tr>

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

@@ -21,7 +21,7 @@ export const SearchMenuItem = (props: Props): JSX.Element => {
     getItemProps({
       index,
       item: { url },
-      className: `d-flex p-1 text-muted ${isActive ? 'active' : ''}`,
+      className: `d-flex align-items-center px-2 py-1 text-muted ${isActive ? 'active' : ''}`,
     })
   );
 

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

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { DevidedPagePath } from '@growi/core/dist/models';
 import { useTranslation } from 'next-i18next';
 
 import { useCurrentPagePath } from '~/stores/page';
@@ -23,10 +24,15 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
 
   const { data: currentPagePath } = useCurrentPagePath();
 
+  const dPagePath = (new DevidedPagePath(currentPagePath ?? '', true, true));
+  const currentPageName = `
+  ${(!(dPagePath.isRoot || dPagePath.isFormerRoot) ? '...' : '')}/${(dPagePath.isRoot ? '' : `${dPagePath.latter}/`)}
+  `;
+
   const shouldShowMenuItem = searchKeyword.trim().length > 0;
 
   return (
-    <>
+    <div>
       { shouldShowMenuItem && (
         <div data-testid="search-all-menu-item">
           <SearchMenuItem
@@ -35,15 +41,14 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
             getItemProps={getItemProps}
             url={`/_search?q=${searchKeyword}`}
           >
-            <span className="material-symbols-outlined fs-4 me-3">search</span>
-            <span>{searchKeyword}</span>
-            <div className="ms-auto">
-              <span>{t('search_method_menu_item.search_in_all')}</span>
+            <span className="material-symbols-outlined fs-4 me-3 p-0">search</span>
+            <div className="w-100 d-flex align-items-md-center flex-md-row align-items-start flex-column">
+              <span className="text-break me-auto">{searchKeyword}</span>
+              <span className="small text-body-tertiary">{t('search_method_menu_item.search_in_all')}</span>
             </div>
           </SearchMenuItem>
         </div>
       )}
-
       <div data-testid="search-prefix-menu-item">
         <SearchMenuItem
           index={shouldShowMenuItem ? 1 : 0}
@@ -51,11 +56,11 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
           getItemProps={getItemProps}
           url={`/_search?q=prefix:${currentPagePath} ${searchKeyword}`}
         >
-          <span className="material-symbols-outlined fs-4 me-3">search</span>
-          <code>prefix: {currentPagePath}</code>
-          <span className="ms-2">{searchKeyword}</span>
-          <div className="ms-auto">
-            <span>{t('search_method_menu_item.only_children_of_this_tree')}</span>
+          <span className="material-symbols-outlined fs-4 me-3 p-0">search</span>
+          <div className="w-100 d-flex align-items-md-center flex-md-row align-items-start flex-column">
+            <code className="text-break">{currentPageName}</code>
+            <span className="ms-md-2 text-break me-auto">{searchKeyword}</span>
+            <span className="small text-body-tertiary">{t('search_method_menu_item.only_children_of_this_tree')}</span>
           </div>
         </SearchMenuItem>
       </div>
@@ -67,13 +72,14 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
           getItemProps={getItemProps}
           url={`/_search?q="${searchKeyword}"`}
         >
-          <span className="material-symbols-outlined fs-4 me-3">search</span>
-          <span>{`"${searchKeyword}"`}</span>
-          <div className="ms-auto">
-            <span>{t('search_method_menu_item.exact_mutch')}</span>
+          <span className="material-symbols-outlined fs-4 me-3 p-0">search</span>
+          <div className="w-100 d-flex align-items-md-center flex-md-row align-items-start flex-column">
+            <span className="text-break me-auto">{`"${searchKeyword}"`}</span>
+            <span className="small text-body-tertiary">{t('search_method_menu_item.exact_mutch')}</span>
           </div>
         </SearchMenuItem>
       ) }
-    </>
+    </div>
+
   );
 };

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

@@ -56,7 +56,7 @@ const SearchModal = (): JSX.Element => {
 
   return (
     <Modal size="lg" isOpen={searchModalData?.isOpened ?? false} toggle={closeSearchModal} data-testid="search-modal">
-      <ModalBody>
+      <ModalBody className="pb-2">
         <Downshift
           onSelect={selectSearchMenuItemHandler}
           stateReducer={stateReducer}
@@ -83,11 +83,11 @@ const SearchModal = (): JSX.Element => {
                   className="btn border-0 d-flex justify-content-center p-0"
                   onClick={closeSearchModal}
                 >
-                  <span className="material-symbols-outlined fs-4 ms-3">close</span>
+                  <span className="material-symbols-outlined fs-4 ms-3 py-0">close</span>
                 </button>
               </div>
 
-              <ul {...getMenuProps()} className="list-unstyled">
+              <ul {...getMenuProps()} className="list-unstyled m-0">
                 <div className="border-top mt-3 mb-2" />
                 <SearchMethodMenuItem
                   activeIndex={highlightedIndex}
@@ -100,6 +100,7 @@ const SearchModal = (): JSX.Element => {
                   searchKeyword={searchKeyword}
                   getItemProps={getItemProps}
                 />
+                <div className="border-top mt-2 mb-2" />
               </ul>
             </div>
           )}

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

@@ -46,7 +46,7 @@ export const SearchResultMenuItem = (props: Props): JSX.Element => {
   }
 
   return (
-    <>
+    <div>
       {searchResult?.data
         .map((item, index) => (
           <SearchMenuItem
@@ -62,14 +62,13 @@ export const SearchResultMenuItem = (props: Props): JSX.Element => {
               <PagePathLabel path={item.data.path} />
             </span>
 
-            <span className="ms-2 d-flex justify-content-center align-items-center">
-              <span className="material-symbols-outlined fs-5">footprint</span>
-              <span>{item.data.seenUsers.length}</span>
+            <span className="text-body-tertiary ms-2 d-flex justify-content-center align-items-center">
+              <span className="material-symbols-outlined fs-6 p-0">footprint</span>
+              <span className="fs-6">{item.data.seenUsers.length}</span>
             </span>
           </SearchMenuItem>
         ))
       }
-      <div className="border-top mt-2 mb-2" />
-    </>
+    </div>
   );
 };

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

@@ -2,7 +2,7 @@ import type {
   IPage, IUser, IUserHasId,
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
-import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
+import { isCreatablePage, isUserPage, isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import { attachTitleHeader, normalizePath } from '@growi/core/dist/utils/path-utils';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
@@ -63,6 +63,11 @@ async function determinePath(_parentPath?: string, _path?: string, optionalParen
   if (_parentPath != null) {
     const parentPath = normalizePath(_parentPath);
 
+    // when parentPath is user's homepage
+    if (isUsersHomepage(parentPath)) {
+      return generateUntitledPath(parentPath, basePathname);
+    }
+
     // when parentPath is valid
     if (isCreatablePage(parentPath)) {
       return generateUntitledPath(parentPath, basePathname);

+ 4 - 0
packages/editor/package.json

@@ -17,6 +17,7 @@
     "version": "yarn version --no-git-tag-version --preid=RC"
   },
   "dependencies": {
+    "markdown-table": "^3.0.3",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   },
@@ -41,12 +42,15 @@
     "cm6-theme-material-dark": "^0.2.0",
     "cm6-theme-nord": "^0.2.0",
     "codemirror": "^6.0.1",
+    "csv-to-markdown-table": "1.4.1",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-react-refresh": "^0.4.1",
+    "markdown-table": "3.0.3",
     "react-dropzone": "^14.2.3",
     "react-hook-form": "^7.45.4",
     "react-toastify": "^9.1.3",
     "reactstrap": "^9.2.0",
+    "string-width": "^7.1.0",
     "swr": "^2.2.2",
     "ts-deepmerge": "^6.2.0",
     "y-codemirror.next": "^0.3.2",

+ 1 - 1
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -14,7 +14,7 @@ import {
 } from '../../services';
 import {
   adjustPasteData, getStrFromBol,
-} from '../../services/list-util/markdown-list-util';
+} from '../../services/paste-util/paste-markdown-util';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 
 import { Toolbar } from './Toolbar';

+ 7 - 1
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropdownItem.tsx

@@ -25,7 +25,13 @@ export const AttachmentsDropdownItem = (props: Props): JSX.Element => {
     getRootProps,
     getInputProps,
     open,
-  } = useFileDropzone({ onUpload, acceptedUploadFileType });
+  } = useFileDropzone({
+    onUpload,
+    acceptedUploadFileType,
+    dropzoneOpts: {
+      noClick: true, noDrag: true, noKeyboard: true,
+    },
+  });
 
   return (
     <div {...getRootProps()} className="dropzone">

+ 16 - 2
packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts

@@ -10,6 +10,7 @@ import {
   EditorState, Prec, type Extension,
 } from '@codemirror/state';
 import { keymap, EditorView } from '@codemirror/view';
+import type { Command } from '@codemirror/view';
 import { tags } from '@lezer/highlight';
 import { useCodeMirror, type UseCodeMirror } from '@uiw/react-codemirror';
 import deepmerge from 'ts-deepmerge';
@@ -19,6 +20,8 @@ import deepmerge from 'ts-deepmerge';
 import { yUndoManagerKeymap } from 'y-codemirror.next';
 
 import { emojiAutocompletionSettings } from '../../extensions/emojiAutocompletionSettings';
+import { insertNewlineContinueMarkup } from '../../list-util/insert-newline-continue-markup';
+import { insertNewRowToMarkdownTable, isInTable } from '../../table-util/insert-new-row-to-table-markdown';
 
 import { useAppendExtensions, type AppendExtensions } from './utils/append-extensions';
 import { useFocus, type Focus } from './utils/focus';
@@ -26,18 +29,29 @@ import { FoldDrawio, useFoldDrawio } from './utils/fold-drawio';
 import { useGetDoc, type GetDoc } from './utils/get-doc';
 import { useInitDoc, type InitDoc } from './utils/init-doc';
 import { useInsertMarkdownElements, type InsertMarkdowElements } from './utils/insert-markdown-elements';
-import { insertNewlineContinueMarkup } from './utils/insert-newline-continue-markup';
 import { useInsertPrefix, type InsertPrefix } from './utils/insert-prefix';
 import { useInsertText, type InsertText } from './utils/insert-text';
 import { useReplaceText, type ReplaceText } from './utils/replace-text';
 import { useSetCaretLine, type SetCaretLine } from './utils/set-caret-line';
 
 
+const onPressEnter: Command = (editor) => {
+
+  if (isInTable(editor)) {
+    insertNewRowToMarkdownTable(editor);
+    return true;
+  }
+
+  insertNewlineContinueMarkup(editor);
+
+  return true;
+};
+
 // set new markdownKeymap instead of default one
 // https://github.com/codemirror/lang-markdown/blob/main/src/index.ts#L17
 const markdownKeymap = [
   { key: 'Backspace', run: deleteCharBackward },
-  { key: 'Enter', run: insertNewlineContinueMarkup },
+  { key: 'Enter', run: onPressEnter },
 ];
 
 const markdownHighlighting = HighlightStyle.define([

+ 7 - 5
packages/editor/src/services/file-dropzone/use-file-dropzone/use-file-dropzone.ts

@@ -35,11 +35,13 @@ export const useFileDropzone = (props: Props): FileDropzoneState => {
 
   }, [onUpload, setIsUploading, acceptedUploadFileType]);
 
-  const accept: Accept | undefined = acceptedUploadFileType === AcceptedUploadFileType.IMAGE
-    ? {
-      'image/*': [],
-    }
-    : undefined;
+  let accept: Accept | undefined;
+  if (acceptedUploadFileType === AcceptedUploadFileType.ALL) {
+    accept = { 'application/*': [] };
+  }
+  else if (acceptedUploadFileType === AcceptedUploadFileType.IMAGE) {
+    accept = { 'image/*': [] };
+  }
 
   const dzState = useDropzone({
     onDrop: dropHandler,

+ 13 - 14
packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/insert-newline-continue-markup.ts → packages/editor/src/services/list-util/insert-newline-continue-markup.ts

@@ -1,25 +1,26 @@
-import type { ChangeSpec, StateCommand } from '@codemirror/state';
+import type { ChangeSpec } from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
 
 // https://regex101.com/r/7BN2fR/5
 const indentAndMarkRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/;
 const indentAndMarkOnlyRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s*)$/;
 
-export const insertNewlineContinueMarkup: StateCommand = ({ state, dispatch }) => {
+export const insertNewlineContinueMarkup = (editor: EditorView): void => {
 
   const changes: ChangeSpec[] = [];
 
   let selection;
 
-  const curPos = state.selection.main.head;
+  const curPos = editor.state.selection.main.head;
 
-  const aboveLine = state.doc.lineAt(curPos).number;
-  const bolPos = state.doc.line(aboveLine).from;
+  const aboveLine = editor.state.doc.lineAt(curPos).number;
+  const bolPos = editor.state.doc.line(aboveLine).from;
 
-  const strFromBol = state.sliceDoc(bolPos, curPos);
+  const strFromBol = editor.state.sliceDoc(bolPos, curPos);
 
   // If the text before the cursor is only markdown symbols
   if (indentAndMarkOnlyRE.test(strFromBol)) {
-    const insert = state.lineBreak;
+    const insert = editor.state.lineBreak;
 
     changes.push({
       from: bolPos,
@@ -33,10 +34,10 @@ export const insertNewlineContinueMarkup: StateCommand = ({ state, dispatch }) =
     const indentAndMark = strFromBol.match(indentAndMarkRE)?.[0];
 
     if (indentAndMark == null) {
-      return false;
+      return;
     }
 
-    const insert = state.lineBreak + indentAndMark;
+    const insert = editor.state.lineBreak + indentAndMark;
     const nextCurPos = curPos + insert.length;
 
     selection = { anchor: nextCurPos };
@@ -49,7 +50,7 @@ export const insertNewlineContinueMarkup: StateCommand = ({ state, dispatch }) =
 
   // If the text before the cursor is regular text
   else {
-    const insert = state.lineBreak;
+    const insert = editor.state.lineBreak;
     const nextCurPos = curPos + insert.length;
 
     selection = { anchor: nextCurPos };
@@ -60,11 +61,9 @@ export const insertNewlineContinueMarkup: StateCommand = ({ state, dispatch }) =
     });
   }
 
-  dispatch(state.update({
+  editor.dispatch({
     changes,
     selection,
     userEvent: 'input',
-  }));
-
-  return true;
+  });
 };

+ 0 - 0
packages/editor/src/services/list-util/markdown-list-util.ts → packages/editor/src/services/paste-util/paste-markdown-util.ts


+ 1 - 0
packages/editor/src/services/table-util/index.ts

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

+ 202 - 0
packages/editor/src/services/table-util/insert-new-row-to-table-markdown.ts

@@ -0,0 +1,202 @@
+import { EditorView } from '@codemirror/view';
+
+import { MarkdownTable } from './markdown-table';
+
+// https://regex101.com/r/7BN2fR/10
+const linePartOfTableRE = /^([^\r\n|]*)\|(([^\r\n|]*\|)+)$/;
+// https://regex101.com/r/1UuWBJ/3
+export const emptyLineOfTableRE = /^([^\r\n|]*)\|((\s*\|)+)$/;
+
+const getCurPos = (editor: EditorView): number => {
+  return editor.state.selection.main.head;
+};
+
+export const isInTable = (editor: EditorView): boolean => {
+  const curPos = getCurPos(editor);
+  const lineText = editor.state.doc.lineAt(curPos).text;
+  return linePartOfTableRE.test(lineText);
+};
+
+const getBot = (editor: EditorView): number => {
+  if (!isInTable(editor)) {
+    return getCurPos(editor);
+  }
+
+  const doc = editor.state.doc;
+  const firstLine = 1;
+  let line = doc.lineAt(getCurPos(editor)).number - 1;
+  for (; line >= firstLine; line--) {
+    const strLine = doc.line(line).text;
+    if (!linePartOfTableRE.test(strLine)) {
+      break;
+    }
+  }
+  const botLine = Math.max(firstLine, line + 1);
+  return doc.line(botLine).from;
+};
+
+const getEot = (editor: EditorView): number => {
+  if (!isInTable(editor)) {
+    return getCurPos(editor);
+  }
+
+  const doc = editor.state.doc;
+  const lastLine = doc.lines;
+
+  let line = doc.lineAt(getCurPos(editor)).number + 1;
+
+  for (; line <= lastLine; line++) {
+    const strLine = doc.line(line).text;
+    if (!linePartOfTableRE.test(strLine)) {
+      break;
+    }
+  }
+
+  const eotLine = line - 1;
+
+  return doc.line(eotLine).to;
+};
+
+const getStrFromBot = (editor: EditorView): string => {
+  return editor.state.sliceDoc(getBot(editor), getCurPos(editor));
+};
+
+const getStrToEot = (editor: EditorView): string => {
+  return editor.state.sliceDoc(getCurPos(editor), getEot(editor));
+};
+
+const addRowToMarkdownTable = (mdtable: MarkdownTable): any => {
+  const numCol = mdtable.table.length > 0 ? mdtable.table[0].length : 1;
+  const newRow: string[] = new Array(numCol);
+
+  newRow.fill('');
+
+  mdtable.table.push(newRow);
+};
+
+export const mergeMarkdownTable = (mdtableList: MarkdownTable[]): MarkdownTable => {
+  let newTable: any[] = [];
+  const options = mdtableList[0].options;
+  mdtableList.forEach((mdtable) => {
+    newTable = newTable.concat(mdtable.table);
+  });
+  return (new MarkdownTable(newTable, options));
+};
+
+const addRow = (editor: EditorView) => {
+  const strFromBot = getStrFromBot(editor);
+
+  let table = MarkdownTable.fromMarkdownString(strFromBot);
+
+  addRowToMarkdownTable(table);
+
+  const strToEot = getStrToEot(editor);
+
+  const tableBottom = MarkdownTable.fromMarkdownString(strToEot);
+
+  if (tableBottom.table.length > 0) {
+    table = mergeMarkdownTable([table, tableBottom]);
+  }
+
+  const curPos = getCurPos(editor);
+
+  const curLine = editor.state.doc.lineAt(curPos).number;
+  const nextLine = curLine + 1;
+
+  const botPos = getBot(editor);
+  const eotPos = getEot(editor);
+
+  editor.dispatch({
+    changes: {
+      from: botPos,
+      to: eotPos,
+      insert: table.toString(),
+    },
+  });
+
+  const nextCurPos = editor.state.doc.line(nextLine).from + 2;
+
+  editor.dispatch({
+    selection: { anchor: nextCurPos },
+  });
+};
+
+const removeRow = (editor: EditorView) => {
+
+  const curPos = getCurPos(editor);
+
+  const curLine = editor.state.doc.lineAt(curPos).number;
+
+  const bolPos = editor.state.doc.line(curLine).from;
+  const eolPos = editor.state.doc.line(curLine).to;
+
+  const nextCurPos = editor.state.doc.lineAt(getCurPos(editor)).to + 1;
+
+  editor.dispatch({
+    changes: {
+      from: bolPos,
+      to: eolPos,
+    },
+  });
+
+  editor.dispatch({
+    selection: { anchor: nextCurPos },
+  });
+};
+
+const reformTable = (editor: EditorView) => {
+  const tableStr = getStrFromBot(editor) + getStrToEot(editor);
+  const table = MarkdownTable.fromMarkdownString(tableStr);
+
+  const curPos = getCurPos(editor);
+  const botPos = getBot(editor);
+  const eotPos = getEot(editor);
+
+  const curLine = editor.state.doc.lineAt(curPos).number;
+  const nextLine = curLine + 1;
+
+  const eolPos = editor.state.doc.line(curLine).to;
+  const strToEol = editor.state.sliceDoc(curPos, eolPos);
+
+  const isLastRow = getStrToEot(editor) === strToEol;
+
+  editor.dispatch({
+    changes: {
+      from: botPos,
+      to: eotPos,
+      insert: table.toString(),
+    },
+  });
+
+  const nextCurPos = isLastRow ? editor.state.doc.line(curLine).to : editor.state.doc.line(nextLine).from + 2;
+
+  editor.dispatch({
+    selection: { anchor: nextCurPos },
+  });
+};
+
+export const insertNewRowToMarkdownTable = (editor: EditorView): void => {
+
+  const curPos = getCurPos(editor);
+
+  const curLine = editor.state.doc.lineAt(curPos).number;
+
+  const bolPos = editor.state.doc.line(curLine).from;
+  const eolPos = editor.state.doc.line(curLine).to;
+
+  const strFromBol = editor.state.sliceDoc(bolPos, curPos);
+  const strToEol = editor.state.sliceDoc(curPos, eolPos);
+
+  const isLastRow = getStrToEot(editor) === strToEol;
+  const isEndOfLine = curPos === eolPos;
+
+  if (isEndOfLine) {
+    addRow(editor);
+  }
+  else if (isLastRow && emptyLineOfTableRE.test(strFromBol + strToEol)) {
+    removeRow(editor);
+  }
+  else {
+    reformTable(editor);
+  }
+};

+ 24 - 0
packages/editor/src/services/table-util/markdown-table.d.ts

@@ -0,0 +1,24 @@
+export declare class MarkdownTable {
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  static fromHTMLTableTag(str: any): MarkdownTable;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  static fromDSV(str: any, delimiter: any): MarkdownTable;
+
+  static fromMarkdownString(str: string): MarkdownTable;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(table: any, options: any);
+
+  table: any;
+
+  options: any;
+
+  toString(): any;
+
+  clone(): MarkdownTable;
+
+  normalizeCells(): MarkdownTable;
+
+}

+ 147 - 0
packages/editor/src/services/table-util/markdown-table.js

@@ -0,0 +1,147 @@
+import csvToMarkdown from 'csv-to-markdown-table';
+import { markdownTable } from 'markdown-table';
+import stringWidth from 'string-width';
+
+// https://github.com/markdown-it/markdown-it/blob/d29f421927e93e88daf75f22089a3e732e195bd2/lib/rules_block/table.js#L83
+// https://regex101.com/r/7BN2fR/7
+const tableAlignmentLineRE = /^[-:|][-:|\s]*$/;
+const tableAlignmentLineNegRE = /^[^-:]*$/; // it is need to check to ignore empty row which is matched above RE
+const linePartOfTableRE = /^\|[^\r\n]*|[^\r\n]*\|$|([^|\r\n]+\|[^|\r\n]*)+/; // own idea
+
+const defaultOptions = { stringLength: stringWidth };
+
+/**
+ * markdown table class for markdown-table module
+ *   ref. https://github.com/wooorm/markdown-table
+ */
+export class MarkdownTable {
+
+  constructor(table, options) {
+    this.table = table || [];
+    this.options = Object.assign(options || {}, defaultOptions);
+
+    this.toString = this.toString.bind(this);
+  }
+
+  toString() {
+    return markdownTable(this.table, this.options);
+  }
+
+  /**
+   * returns cloned Markdowntable instance
+   * (This method clones only the table field.)
+   */
+  clone() {
+    const newTable = [];
+    for (let i = 0; i < this.table.length; i++) {
+      newTable.push([].concat(this.table[i]));
+    }
+    return new MarkdownTable(newTable, this.options);
+  }
+
+  /**
+   * normalize all cell data(trim & convert the newline character to space or pad '' if cell data is null)
+   */
+  normalizeCells() {
+    for (let i = 0; i < this.table.length; i++) {
+      for (let j = 0; j < this.table[i].length; j++) {
+        if (this.table[i][j] != null) {
+          this.table[i][j] = this.table[i][j].trim().replace(/\r?\n/g, ' ');
+        }
+        else {
+          this.table[i][j] = '';
+        }
+      }
+    }
+
+    return this;
+  }
+
+  /**
+   * return a MarkdownTable instance made from a string of HTML table tag
+   *
+   * If a parser error occurs, an error object with an error message is thrown.
+   * The error message is a innerHTML, so must not assign it into element.innerHTML because it can lead to Mutation-based XSS
+   */
+  static fromHTMLTableTag(str) {
+    // set up DOMParser
+    const domParser = new (window.DOMParser)();
+
+    // use DOMParser to prevent DOM based XSS (https://developer.mozilla.org/en-US/docs/Web/API/DOMParser)
+    const dom = domParser.parseFromString(str, 'application/xml');
+
+    if (dom.querySelector('parsererror')) {
+      throw new Error(dom.documentElement.innerHTML);
+    }
+
+    const tableElement = dom.querySelector('table');
+    const trElements = tableElement.querySelectorAll('tr');
+
+    const table = [];
+    let maxRowSize = 0;
+    for (let i = 0; i < trElements.length; i++) {
+      const row = [];
+      const cellElements = trElements[i].querySelectorAll('th,td');
+      for (let j = 0; j < cellElements.length; j++) {
+        row.push(cellElements[j].innerHTML);
+      }
+      table.push(row);
+
+      if (maxRowSize < row.length) maxRowSize = row.length;
+    }
+
+    const align = [];
+    for (let i = 0; i < maxRowSize; i++) {
+      align.push('');
+    }
+
+    return new MarkdownTable(table, { align });
+  }
+
+  /**
+   * return a MarkdownTable instance made from a string of delimiter-separated values
+   */
+  static fromDSV(str, delimiter) {
+    return MarkdownTable.fromMarkdownString(csvToMarkdown(str, delimiter, true));
+  }
+
+  /**
+   * return a MarkdownTable instance
+   *   ref. https://github.com/wooorm/markdown-table
+   * @param {string} str markdown string
+   */
+  static fromMarkdownString(str) {
+    const arrMDTableLines = str.split(/(\r\n|\r|\n)/);
+    const contents = [];
+    let aligns = [];
+    for (let n = 0; n < arrMDTableLines.length; n++) {
+      const line = arrMDTableLines[n];
+
+      if (tableAlignmentLineRE.test(line) && !tableAlignmentLineNegRE.test(line)) {
+        // parse line which described alignment
+        const alignRuleRE = [
+          { align: 'c', regex: /^:-+:$/ },
+          { align: 'l', regex: /^:-+$/ },
+          { align: 'r', regex: /^-+:$/ },
+        ];
+        let lineText = '';
+        lineText = line.replace(/^\||\|$/g, ''); // strip off pipe charactor which is placed head of line and last of line.
+        lineText = lineText.replace(/\s*/g, '');
+        aligns = lineText.split(/\|/).map((col) => {
+          const rule = alignRuleRE.find((rule) => { return col.match(rule.regex) });
+          return (rule != null) ? rule.align : '';
+        });
+      }
+      else if (linePartOfTableRE.test(line)) {
+        // parse line whether header or body
+        let lineText = '';
+        lineText = line.replace(/\s*\|\s*/g, '|');
+        lineText = lineText.replace(/^\||\|$/g, ''); // strip off pipe charactor which is placed head of line and last of line.
+        const row = lineText.split(/\|/);
+        contents.push(row);
+      }
+    }
+    return (new MarkdownTable(contents, { align: aligns }));
+  }
+
+}

+ 31 - 1
yarn.lock

@@ -1844,6 +1844,7 @@
 "@growi/editor@link:packages/editor":
   version "6.2.0-RC.0"
   dependencies:
+    markdown-table "^3.0.3"
     react "^18.2.0"
     react-dom "^18.2.0"
 
@@ -6760,6 +6761,11 @@ csurf@^1.11.0:
     csrf "3.1.0"
     http-errors "~1.7.3"
 
+csv-to-markdown-table@1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/csv-to-markdown-table/-/csv-to-markdown-table-1.4.1.tgz#7167eb16cf76da45abd54e13993e99f029c05754"
+  integrity sha512-jhLkfM7LXGQCuhxCwIw0QmpHCbMXy8ouC+T8KKoKaZ43DQAezpHCxNl74j2S9Sb4SEnVgMK8/RqJfNUk6xMHRQ==
+
 csv-to-markdown-table@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/csv-to-markdown-table/-/csv-to-markdown-table-1.1.0.tgz#1c4546b4a6d7265d7715df51825c1852a7286247"
@@ -7657,6 +7663,11 @@ emittery@^0.13.1:
     "@babel/runtime" "^7.0.0"
     prop-types "^15.6.0"
 
+emoji-regex@^10.3.0:
+  version "10.3.0"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.3.0.tgz#76998b9268409eb3dae3de989254d456e70cfe23"
+  integrity sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==
+
 emoji-regex@^8.0.0:
   version "8.0.0"
   resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
@@ -8999,6 +9010,11 @@ get-caller-file@^2.0.5:
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
   integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
+get-east-asian-width@^1.0.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz#5e6ebd9baee6fb8b7b6bd505221065f0cd91f64e"
+  integrity sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==
+
 get-func-name@^2.0.0, get-func-name@^2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41"
@@ -11973,6 +11989,11 @@ markdown-table@^3.0.0:
   resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.2.tgz#9b59eb2c1b22fe71954a65ff512887065a7bb57c"
   integrity sha512-y8j3a5/DkJCmS5x4dMCQL+OR0+2EAq3DOtio1COSHsmW2BGXnNCK3v12hJt1LrUz5iZH5g0LmuYOjDdI+czghA==
 
+markdown-table@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.3.tgz#e6331d30e493127e031dd385488b5bd326e4a6bd"
+  integrity sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==
+
 material-icons@^1.11.3:
   version "1.13.12"
   resolved "https://registry.yarnpkg.com/material-icons/-/material-icons-1.13.12.tgz#eed4082bf0426642edeb027e75397e3064adc536"
@@ -16547,6 +16568,15 @@ string-width@^5.0.1, string-width@^5.1.2:
     emoji-regex "^9.2.2"
     strip-ansi "^7.0.1"
 
+string-width@^7.1.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.1.0.tgz#d994252935224729ea3719c49f7206dc9c46550a"
+  integrity sha512-SEIJCWiX7Kg4c129n48aDRwLbFb2LJmXXFrWBG4NGaRtMQ3myKPKbwrD1BKqQn74oCoNMBVrfDEr5M9YxCsrkw==
+  dependencies:
+    emoji-regex "^10.3.0"
+    get-east-asian-width "^1.0.0"
+    strip-ansi "^7.1.0"
+
 string.prototype.matchall@^4.0.7:
   version "4.0.7"
   resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz#8e6ecb0d8a1fb1fda470d81acecb2dba057a481d"
@@ -16634,7 +16664,7 @@ strip-ansi@^3.0.0:
   dependencies:
     ansi-regex "^2.0.0"
 
-strip-ansi@^7.0.1:
+strip-ansi@^7.0.1, strip-ansi@^7.1.0:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
   integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==