From 2ea347589919915979a14ec95ba14b4c85f7eab7 Mon Sep 17 00:00:00 2001
From: pftom <1043269994@qq.com>
Date: Sun, 16 May 2021 23:36:12 +0800
Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E5=88=9D=E6=AD=A5=E5=AE=8C?=
 =?UTF-8?q?=E6=88=90=E7=AC=AC=E4=B8=80=E7=89=88=E4=BE=A7=E8=BE=B9=E6=A0=8F?=
 =?UTF-8?q?=E8=8F=9C=E5=8D=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
 package.json                       |   2 +
 src/App.vue                        | 212 ++---------
 src/components/BlockMenu.vue       | 577 +++++++++++++++++++++++++++++
 src/components/BlockMenuItem.vue   |  82 ++++
 src/components/LinkEditor.vue      |   3 +-
 src/components/LinkToolbar.vue     |  11 +-
 src/extensions/BlockMenuTrigger.js | 180 +++++++++
 src/extensions/index.js            |   1 +
 src/main.js                        |  22 +-
 src/menus/block.js                 | 112 ++++++
 src/nodes/Notice.js                |   2 +
 src/utils/dictionary.js            |   3 +
 src/utils/environment.js           |   7 +
 yarn.lock                          |  23 +-
 14 files changed, 1058 insertions(+), 179 deletions(-)
 create mode 100644 src/components/BlockMenu.vue
 create mode 100644 src/components/BlockMenuItem.vue
 create mode 100644 src/extensions/BlockMenuTrigger.js
 create mode 100644 src/menus/block.js
 create mode 100644 src/utils/environment.js
diff --git a/package.json b/package.json
index 9ec6dc0..a04691e 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
     "core-js": "^3.6.5",
     "element-ui": "^2.15.1",
     "highlight.js": "^10.6.0",
+    "lodash": "^4.17.21",
     "lodash.some": "^4.6.0",
     "medium-zoom": "^1.0.6",
     "monaco-editor": "^0.23.0",
@@ -29,6 +30,7 @@
     "prosemirror-tables": "^1.1.1",
     "prosemirror-utils": "0.9.6",
     "prosemirror-view": "^1.18.0",
+    "smooth-scroll-into-view-if-needed": "^1.1.32",
     "tiptap": "^1.32.1",
     "tiptap-extensions": "^1.35.1",
     "vue": "^2.6.11"
diff --git a/src/App.vue b/src/App.vue
index d7ba87f..206bc7b 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,171 +1,6 @@
 
   
 
 
 
+
+
diff --git a/src/components/BlockMenuItem.vue b/src/components/BlockMenuItem.vue
new file mode 100644
index 0000000..5b4015f
--- /dev/null
+++ b/src/components/BlockMenuItem.vue
@@ -0,0 +1,82 @@
+
+  
+
+
+
+
+
diff --git a/src/components/LinkEditor.vue b/src/components/LinkEditor.vue
index 128cd3f..cdd5969 100644
--- a/src/components/LinkEditor.vue
+++ b/src/components/LinkEditor.vue
@@ -46,6 +46,7 @@ export default {
     "onSearchLink",
     "onClickLink",
     "onRemoveLink",
+    "onShowToast",
   ],
   components: {
     CustomTooltip,
@@ -80,8 +81,6 @@ export default {
     },
     showCreateLink() {
       const value = this.value;
-      const results =
-        this.results[value.trim()] || this.results[this.previousValue] || [];
 
       const looksLikeUrl = value.match(/^https?:\/\//i);
 
diff --git a/src/components/LinkToolbar.vue b/src/components/LinkToolbar.vue
index 7400472..8955013 100644
--- a/src/components/LinkToolbar.vue
+++ b/src/components/LinkToolbar.vue
@@ -4,9 +4,10 @@
       v-if="active"
       :from="view.state.selection.from"
       :to="view.state.selection.to"
-      :on-create-link="onCreateLink"
-      :on-select-link="onSelectLink"
-      :on-remove-link="onClose"
+      :view="view"
+      :onCreateLink="onCreateLink"
+      :onSelectLink="handleOnSelectLink"
+      :onRemoveLink="onClose"
     >
   
 
@@ -35,6 +36,9 @@ export default {
     tooltip: Object,
     dictionary: Object,
     onCreateLink: Function,
+    onSearchLink: Function,
+    onClickLink: Function,
+    onShowToast: Function,
     onClose: Function,
   },
   components: {
@@ -51,6 +55,7 @@ export default {
     active() {
       return isActive({
         view: this.view,
+        isActive: this.isActive,
       });
     },
   },
diff --git a/src/extensions/BlockMenuTrigger.js b/src/extensions/BlockMenuTrigger.js
new file mode 100644
index 0000000..14f843b
--- /dev/null
+++ b/src/extensions/BlockMenuTrigger.js
@@ -0,0 +1,180 @@
+import { InputRule } from "prosemirror-inputrules";
+import { Plugin } from "prosemirror-state";
+import { isInTable } from "prosemirror-tables";
+import { Decoration, DecorationSet } from "prosemirror-view";
+import { Extension } from "tiptap";
+import { findParentNode } from "prosemirror-utils";
+
+const MAX_MATCH = 500;
+const OPEN_REGEX = /^\/(\w+)?$/;
+const CLOSE_REGEX = /(^(?!\/(\w+)?)(.*)$|^\/((\w+)\s.*|\s)$)/;
+
+// based on the input rules code in Prosemirror, here:
+// https://github.com/ProseMirror/prosemirror-inputrules/blob/master/src/inputrules.js
+function run(view, from, to, regex, handler) {
+  if (view.composing) {
+    return false;
+  }
+  const state = view.state;
+  const $from = state.doc.resolve(from);
+  if ($from.parent.type.spec.code) {
+    return false;
+  }
+
+  const textBefore = $from.parent.textBetween(
+    Math.max(0, $from.parentOffset - MAX_MATCH),
+    $from.parentOffset,
+    null,
+    "\ufffc"
+  );
+
+  const match = regex.exec(textBefore);
+  const tr = handler(state, match, match ? from - match[0].length : from, to);
+  if (!tr) return false;
+  return true;
+}
+
+export default class BlockMenuTrigger extends Extension {
+  get name() {
+    return "blockmenu";
+  }
+
+  get plugins() {
+    const button = document.createElement("button");
+    button.className = "block-menu-trigger";
+    const icon = document.createElement("span");
+    icon.innerHTML = "+";
+    button.appendChild(icon);
+
+    return [
+      new Plugin({
+        props: {
+          handleClick: () => {
+            this.options.onClose();
+            return false;
+          },
+          handleKeyDown: (view, event) => {
+            // Prosemirror input rules are not triggered on backspace, however
+            // we need them to be evaluted for the filter trigger to work
+            // correctly. This additional handler adds inputrules-like handling.
+            if (event.key === "Backspace") {
+              // timeout ensures that the delete has been handled by prosemirror
+              // and any characters removed, before we evaluate the rule.
+              setTimeout(() => {
+                const { pos } = view.state.selection.$from;
+                return run(view, pos, pos, OPEN_REGEX, (state, match) => {
+                  if (match) {
+                    this.options.onOpen(match[1]);
+                  } else {
+                    this.options.onClose();
+                  }
+                  return null;
+                });
+              });
+            }
+
+            // If the query is active and we're navigating the block menu then
+            // just ignore the key events in the editor itself until we're done
+            if (
+              event.key === "Enter" ||
+              event.key === "ArrowUp" ||
+              event.key === "ArrowDown" ||
+              event.key === "Tab"
+            ) {
+              const { pos } = view.state.selection.$from;
+
+              return run(view, pos, pos, OPEN_REGEX, (state, match) => {
+                // just tell Prosemirror we handled it and not to do anything
+                return match ? true : null;
+              });
+            }
+
+            return false;
+          },
+          decorations: (state) => {
+            const parent = findParentNode(
+              (node) => node.type.name === "paragraph"
+            )(state.selection);
+
+            if (!parent) {
+              return;
+            }
+
+            const decorations = [];
+            const isEmpty = parent && parent.node.content.size === 0;
+            const isSlash = parent && parent.node.textContent === "/";
+            const isTopLevel = state.selection.$from.depth === 1;
+
+            if (isTopLevel) {
+              if (isEmpty) {
+                decorations.push(
+                  Decoration.widget(parent.pos, () => {
+                    button.addEventListener("click", () => {
+                      this.options.onOpen("");
+                    });
+                    return button;
+                  })
+                );
+
+                decorations.push(
+                  Decoration.node(
+                    parent.pos,
+                    parent.pos + parent.node.nodeSize,
+                    {
+                      class: "placeholder",
+                      "data-empty-text": this.options.dictionary.newLineEmpty,
+                    }
+                  )
+                );
+              }
+
+              if (isSlash) {
+                decorations.push(
+                  Decoration.node(
+                    parent.pos,
+                    parent.pos + parent.node.nodeSize,
+                    {
+                      class: "placeholder",
+                      "data-empty-text": `  ${this.options.dictionary.newLineWithSlash}`,
+                    }
+                  )
+                );
+              }
+
+              return DecorationSet.create(state.doc, decorations);
+            }
+
+            return;
+          },
+        },
+      }),
+    ];
+  }
+
+  inputRules() {
+    return [
+      // main regex should match only:
+      // /word
+      new InputRule(OPEN_REGEX, (state, match) => {
+        if (
+          match &&
+          state.selection.$from.parent.type.name === "paragraph" &&
+          !isInTable(state)
+        ) {
+          this.options.onOpen(match[1]);
+        }
+        return null;
+      }),
+      // invert regex should match some of these scenarios:
+      // /word
+      // /
+      // /word
+      new InputRule(CLOSE_REGEX, (state, match) => {
+        if (match) {
+          this.options.onClose();
+        }
+        return null;
+      }),
+    ];
+  }
+}
diff --git a/src/extensions/index.js b/src/extensions/index.js
index 7b8937b..46eb3d9 100644
--- a/src/extensions/index.js
+++ b/src/extensions/index.js
@@ -1,2 +1,3 @@
 export { default as Doc } from "./Doc";
 export { default as Title } from "./Title";
+export { default as BlockMenuTrigger } from "./BlockMenuTrigger";
diff --git a/src/main.js b/src/main.js
index 4279ed9..964b87b 100644
--- a/src/main.js
+++ b/src/main.js
@@ -16,6 +16,16 @@ import {
   faAlignCenter,
   faAlignRight,
   faAlignLeft,
+  faHeading,
+  faListOl,
+  faListUl,
+  faCheck,
+  faTable,
+  faQuoteLeft,
+  faFileCode,
+  faGripHorizontal,
+  faImage,
+  faTint,
 } from "@fortawesome/free-solid-svg-icons";
 import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
 import App from "./App.vue";
@@ -34,7 +44,17 @@ library.add(
   faChevronUp,
   faAlignCenter,
   faAlignRight,
-  faAlignLeft
+  faAlignLeft,
+  faListOl,
+  faListUl,
+  faHeading,
+  faCheck,
+  faTable,
+  faQuoteLeft,
+  faFileCode,
+  faGripHorizontal,
+  faImage,
+  faTint
 );
 
 Vue.component("font-awesome-icon", FontAwesomeIcon);
diff --git a/src/menus/block.js b/src/menus/block.js
new file mode 100644
index 0000000..6dd9afb
--- /dev/null
+++ b/src/menus/block.js
@@ -0,0 +1,112 @@
+import { IS_MAC } from "../utils/environment";
+
+const mod = IS_MAC ? "⌘" : "ctrl";
+
+export default function blockMenuItems(dictionary) {
+  return [
+    {
+      name: "heading",
+      title: dictionary.h1,
+      keywords: "h1 heading1 title",
+      icon: "heading",
+      shortcut: "^ ⇧ 1",
+      attrs: { level: 1 },
+    },
+    {
+      name: "heading",
+      title: dictionary.h2,
+      keywords: "h2 heading2",
+      icon: "heading",
+      shortcut: "^ ⇧ 2",
+      attrs: { level: 2 },
+    },
+    {
+      name: "heading",
+      title: dictionary.h3,
+      keywords: "h3 heading3",
+      icon: "heading",
+      shortcut: "^ ⇧ 3",
+      attrs: { level: 3 },
+    },
+    {
+      name: "separator",
+    },
+    {
+      name: "todo_list",
+      title: dictionary.checkboxList,
+      icon: "check",
+      keywords: "checklist checkbox task",
+      shortcut: "^ ⇧ 7",
+    },
+    {
+      name: "bullet_list",
+      title: dictionary.bulletList,
+      icon: "list-ul",
+      shortcut: "^ ⇧ 8",
+    },
+    {
+      name: "ordered_list",
+      title: dictionary.orderedList,
+      icon: "list-ol",
+      shortcut: "^ ⇧ 9",
+    },
+    {
+      name: "separator",
+    },
+    {
+      name: "table",
+      title: dictionary.table,
+      icon: "table",
+      attrs: { rowsCount: 3, colsCount: 3 },
+    },
+    {
+      name: "blockquote",
+      title: dictionary.quote,
+      icon: "quote-left",
+      shortcut: `${mod} ]`,
+    },
+    {
+      name: "code_block",
+      title: dictionary.codeBlock,
+      icon: "file-code",
+      shortcut: "^ ⇧ \\",
+      keywords: "script",
+    },
+    {
+      name: "horizontal_rule",
+      title: dictionary.hr,
+      icon: "grip-horizontal",
+      shortcut: `${mod} _`,
+      keywords: "horizontal rule break line",
+    },
+    {
+      name: "image",
+      title: dictionary.image,
+      icon: "image",
+      keywords: "picture photo",
+    },
+    {
+      name: "link",
+      title: dictionary.link,
+      icon: "link",
+      shortcut: `${mod} k`,
+      keywords: "link url uri href",
+    },
+    {
+      name: "separator",
+    },
+    {
+      name: "diff_block",
+      title: dictionary.diffBlock,
+      icon: "tint",
+      keywords: "container_notice card information",
+    },
+    {
+      name: "notice",
+      title: dictionary.default,
+      icon: "tint",
+      keywords: "container_notice card information",
+      attrs: { style: "default" },
+    },
+  ];
+}
diff --git a/src/nodes/Notice.js b/src/nodes/Notice.js
index 7ad76e8..49b387c 100644
--- a/src/nodes/Notice.js
+++ b/src/nodes/Notice.js
@@ -24,6 +24,7 @@ function getStyleFromRawMatch(rawMatch) {
 
 export default class Notice extends Node {
   get styleOptions() {
+    console.log("options", this.options);
     return Object.entries({
       default: this.options.dictionary.default,
       primary: this.options.dictionary.primary,
@@ -82,6 +83,7 @@ export default class Notice extends Node {
   }
 
   commands({ type }) {
+    console.log("type", type);
     return (attrs) => toggleWrap(type, attrs);
   }
 
diff --git a/src/utils/dictionary.js b/src/utils/dictionary.js
index d1f739c..51a99d9 100644
--- a/src/utils/dictionary.js
+++ b/src/utils/dictionary.js
@@ -8,6 +8,7 @@ const base = {
   danger: "Danger",
 
   // Table
+  table: "Table",
   addColumnAfter: "Insert column after",
   addColumnBefore: "Insert column before",
   addRowAfter: "Insert row after",
@@ -57,6 +58,8 @@ const base = {
   strikethrough: "Strikethrough",
   strong: "Bold",
   subheading: "Subheading",
+
+  diffBlock: "DiffBlock",
 };
 
 export default base;
diff --git a/src/utils/environment.js b/src/utils/environment.js
new file mode 100644
index 0000000..313f7cf
--- /dev/null
+++ b/src/utils/environment.js
@@ -0,0 +1,7 @@
+export const IS_MAC =
+  typeof window != "undefined" &&
+  /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
+
+export const IS_FIREFOX =
+  typeof navigator !== "undefined" &&
+  /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent);
diff --git a/yarn.lock b/yarn.lock
index f9202f8..8178188 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3045,6 +3045,11 @@ compression@^1.7.4:
     safe-buffer "5.1.2"
     vary "~1.1.2"
 
+compute-scroll-into-view@^1.0.17:
+  version "1.0.17"
+  resolved "https://registry.npm.taobao.org/compute-scroll-into-view/download/compute-scroll-into-view-1.0.17.tgz?cache=0&sync_timestamp=1614042424875&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcompute-scroll-into-view%2Fdownload%2Fcompute-scroll-into-view-1.0.17.tgz#6a88f18acd9d42e9cf4baa6bec7e0522607ab7ab"
+  integrity sha1-aojxis2dQunPS6pr7H4FImB6t6s=
+
 concat-map@0.0.1:
   version "0.0.1"
   resolved "https://registry.npm.taobao.org/concat-map/download/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@@ -6669,9 +6674,9 @@ lodash@4.17.14:
   resolved "https://registry.npm.taobao.org/lodash/download/lodash-4.17.14.tgz?cache=0&sync_timestamp=1613835817439&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba"
   integrity sha1-nOSHrmbJYlT+ILWZ8htoFgKAeLo=
 
-lodash@^4.0.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.3, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.2.1, lodash@~4.17.10:
+lodash@^4.0.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.3, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.2.1, lodash@~4.17.10:
   version "4.17.21"
-  resolved "https://registry.npm.taobao.org/lodash/download/lodash-4.17.21.tgz?cache=0&sync_timestamp=1613835817439&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Flodash%2Fdownload%2Flodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+  resolved "https://registry.npm.taobao.org/lodash/download/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha1-Z5WRxWTDv/quhFTPCz3zcMPWkRw=
 
 log-symbols@^1.0.2:
@@ -9242,6 +9247,13 @@ schema-utils@^3.0.0:
     ajv "^6.12.5"
     ajv-keywords "^3.5.2"
 
+scroll-into-view-if-needed@^2.2.28:
+  version "2.2.28"
+  resolved "https://registry.npm.taobao.org/scroll-into-view-if-needed/download/scroll-into-view-if-needed-2.2.28.tgz#5a15b2f58a52642c88c8eca584644e01703d645a"
+  integrity sha1-WhWy9YpSZCyIyOylhGROAXA9ZFo=
+  dependencies:
+    compute-scroll-into-view "^1.0.17"
+
 scss-tokenizer@^0.2.3:
   version "0.2.3"
   resolved "https://registry.npm.taobao.org/scss-tokenizer/download/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1"
@@ -9464,6 +9476,13 @@ slice-ansi@^2.1.0:
     astral-regex "^1.0.0"
     is-fullwidth-code-point "^2.0.0"
 
+smooth-scroll-into-view-if-needed@^1.1.32:
+  version "1.1.32"
+  resolved "https://registry.nlark.com/smooth-scroll-into-view-if-needed/download/smooth-scroll-into-view-if-needed-1.1.32.tgz#57718cb2caa5265ade3e96006dfcf28b2fdcfca0"
+  integrity sha1-V3GMssqlJlrePpYAbfzyiy/c/KA=
+  dependencies:
+    scroll-into-view-if-needed "^2.2.28"
+
 snapdragon-node@^2.0.1:
   version "2.1.1"
   resolved "https://registry.npm.taobao.org/snapdragon-node/download/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"