Преглед изворни кода

Merge pull request #8461 from weseek/feat/136036-implement-to-add-table-when-doing-a-line-break-in-table-markdown

feat: implement to add table when doing a line break in table markdown
WNomunomu пре 2 година
родитељ
комит
f0bc95e1d3

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

+ 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';

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

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