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"