diff --git a/src/Editor.js b/src/Editor.js index b2b1be74..59b6cb96 100644 --- a/src/Editor.js +++ b/src/Editor.js @@ -114,13 +114,15 @@ export default class Editor { } /** - * 处理draw.io的xml数据和图片的base64数据,对这种超大的数据增加省略号 + * 在onChange后处理draw.io的xml数据和图片的base64数据,对这种超大的数据增加省略号, + * 以及对全角符号进行特殊染色。 */ - dealBigData = () => { + dealSpecialWords = () => { if (this.noChange) { this.noChange = false; return; } + this.formatFullWidthMark(); this.formatBigData2Mark(imgBase64Reg, 'cm-url base64'); this.formatBigData2Mark(imgDrawioXmlReg, 'cm-url drawio'); }; @@ -157,6 +159,70 @@ export default class Editor { } }; + /** + * 高亮全角符号 ·|¥|、|:|“|”|【|】|(|)|《|》 + */ + formatFullWidthMark() { + const { editor } = this; + const regex = /[·¥、“”【】()《》]+/g; // 此处以仅匹配多个连续的全角符号 + const searcher = editor.getSearchCursor(regex); + let oneSearch = searcher.findNext(); + editor.getAllMarks().forEach(function (mark) { + // 重新加载cm-fullwidth的mark + if (mark.className === 'cm-fullwidth') { + mark.clear(); + } + }); + for (; oneSearch !== false; oneSearch = searcher.findNext()) { + const target = searcher.from(); + if (!target) { + continue; + } + const targetChFrom = target.ch; + const targetChTo = targetChFrom + oneSearch[0].length; + const targetLine = target.line; + const begin = { line: targetLine, ch: targetChFrom }; + const end = { line: targetLine, ch: targetChTo }; + editor.markText(begin, end, { + className: 'cm-fullwidth', + title: '按住Ctrl点击切换成半角(Hold down Ctrl and click to switch to half-width)', + attributes: { from: targetChFrom, to: targetChTo, line: targetLine }, + }); + } + } + + /** + * + * @param {CodeMirror.Editor} codemirror + * @param {MouseEvent} evt + */ + toHalfWidth(codemirror, evt) { + const { target } = evt; + if (evt.target.classList.contains('cm-fullwidth') && evt.ctrlKey && evt.buttons === 1) { + const begin = { line: target.getAttribute('line'), ch: target.getAttribute('from') }; + const end = { line: target.getAttribute('line'), ch: target.getAttribute('to') }; + codemirror.getDoc().setSelection(begin, end); + codemirror + .getDoc() + .replaceSelection( + target.innerHTML + .replaceAll('·', '`') + .replaceAll('¥', '$') + .replaceAll('、', '/') + .replaceAll(':', ':') + .replaceAll('“', '"') + .replaceAll('”', '"') + .replaceAll('【', '[') + .replaceAll('】', ']') + .replaceAll('(', '(') + .replaceAll(')', ')') + .replaceAll('《', '<') + .replaceAll('》', '>'), + 'around', + ); + codemirror.setCursor(end); + } + } /** * * @param {KeyboardEvent} e @@ -286,6 +352,7 @@ export default class Editor { const { line: targetLine } = codemirror.getCursor(); const top = Math.abs(evt.y - codemirror.getWrapperElement().getBoundingClientRect().y); this.previewer.scrollToLineNumWithOffset(targetLine + 1, top); + this.toHalfWidth(codemirror, evt); }; /** @@ -344,7 +411,7 @@ export default class Editor { editor.on('change', (codemirror, evt) => { this.options.onChange(evt, codemirror); - this.dealBigData(); + this.dealSpecialWords(); if (this.options.autoSave2Textarea) { // @ts-ignore // 将codemirror里的内容回写到textarea里 @@ -394,7 +461,7 @@ export default class Editor { // 当批量上传文件时,每个被插入的文件中间需要加个换行,但单个上传文件的时候不需要加换行 const insertValue = i > 0 ? `\n${mdStr} ` : `${mdStr} `; codemirror.replaceSelection(insertValue); - this.dealBigData(); + this.dealSpecialWords(); }); } }, 50); diff --git a/src/core/hooks/SuggestList.js b/src/core/hooks/SuggestList.js new file mode 100644 index 00000000..b7958451 --- /dev/null +++ b/src/core/hooks/SuggestList.js @@ -0,0 +1,288 @@ +/** + * Tencent is pleased to support the open source community by making CherryMarkdown available. + * + * Copyright (C) 2021 THL A29 Limited, a Tencent company. All rights reserved. + * The below software in this distribution may have been modified by THL A29 Limited ("Tencent Modifications"). + * + * All Tencent Modifications are Copyright (C) THL A29 Limited. + * + * CherryMarkdown is licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* + * 外加配置系统联想词 + */ +export const addonsKeywords = '#'; +/* + * 预留联想词 + */ +export const suggesterKeywords = '/·¥、:“”【】()《》'.concat(addonsKeywords); +/* + * 系统联想候选表,主要为'、'以及'、'的联想。 + */ +const SystemSuggestList = [ + { + icon: 'h1', + label: 'H1 Heading', + keyword: 'head1', + value: '# ', + }, + { + icon: 'h2', + label: 'H2 Heading', + keyword: 'head2', + value: '## ', + }, + { + icon: 'h3', + label: 'H3 Heading', + keyword: 'head3', + value: '### ', + }, + { + icon: 'table', + label: 'Table', + keyword: 'table', + value: '| Header | Header | Header |\n| --- | --- | --- |\n| Content | Content | Content |\n', + }, + { + icon: 'code', + label: 'Code', + keyword: 'code', + value: '```\n\n```\n', + }, + { + icon: 'link', + label: 'Link', + keyword: 'link', + value: `[title](https://url)`, + selection: { from: 'title](https://url)'.length, to: '](https://url)'.length }, + }, + { + icon: 'checklist', + label: 'Checklist', + keyword: 'checklist', + value: `- [ ] item\n- [x] item`, + }, + { + icon: 'tips', + label: 'Panel', + keyword: 'panel tips info warning danger success', + value: `::: primary title\ncontent\n:::\n`, + }, + { + icon: 'insertFlow', + label: 'Detail', + keyword: 'detail', + value: `+++ 点击展开更多\n内容\n++- 默认展开\n内容\n++ 默认收起\n内容\n+++\n`, + }, + // { + // icon: 'pen', + // label: '续写', + // keyword: 'xu xie chatgpt', + // value: () => { + // if (!this.$engine.$cherry.options.openai.apiKey) { + // return '请先配置openai apiKey'; + // } + // this.$engine.$cherry.toolbar.toolbarHandlers.chatgpt('complement'); + // return `\n`; + // }, + // }, + // { + // icon: 'pen', + // label: '总结', + // keyword: 'zong jie chatgpt', + // value: () => { + // if (!this.$engine.$cherry.options.openai.apiKey) { + // return '请先配置openai apiKey'; + // } + // this.$engine.$cherry.toolbar.toolbarHandlers.chatgpt('summary'); + // return `\n`; + // }, + // }, +]; +/* + * 全角联想候选表,用于将全角转为半角。 + */ +const HalfWidthSuggestList = [ + { + icon: 'FullWidth', + label: '`', + keyword: '···', + value: '`', + }, + { + icon: 'FullWidth', + label: '$', + keyword: '¥', + value: '$', + }, + { + icon: 'FullWidth', + label: '/', + keyword: '、', + value: '/', + }, + { + icon: 'FullWidth', + label: '\\', + keyword: '、', + value: '\\', + }, + { + icon: 'FullWidth', + label: ':', + keyword: ':', + value: ':', + }, + { + icon: 'FullWidth', + label: '"', + keyword: '“', + value: '"', + }, + { + icon: 'FullWidth', + label: '"', + keyword: '”', + value: '"', + }, + { + icon: 'FullWidth', + label: '[', + keyword: '【', + value: '[', + }, + { + icon: 'FullWidth', + label: ']', + keyword: '】', + value: ']', + }, + { + icon: 'FullWidth', + label: '(', + keyword: '(', + value: '(', + }, + { + icon: 'FullWidth', + label: ')', + keyword: ')', + value: ')', + }, + { + icon: 'FullWidth', + label: '<', + keyword: '《', + value: '<', + }, + { + icon: 'FullWidth', + label: '>', + keyword: '》', + value: '>', + }, +]; +/* + * 更多候选适配, + * goLeft用于在选中联想之后向左移动一定距离光标, + * selection用于选中光标,from-to即选中范围。 + */ +const MoreSuggestList = [ + { + icon: 'FullWidth', + label: '[]', + keyword: '【】', + value: `[]`, + goLeft: 1, + }, + { + icon: 'FullWidth', + label: '【】', + keyword: '【', + value: `【】`, + goLeft: 1, + }, + { + icon: 'link', + label: 'Link', + keyword: '【】', + value: `[title](https://url)`, + selection: { from: 'title](https://url)'.length, to: '](https://url)'.length }, + }, + { + icon: 'FullWidth', + label: '()', + keyword: '(', + value: `()`, + goLeft: 1, + }, + { + icon: 'FullWidth', + label: '()', + keyword: '(', + value: `()`, + goLeft: 1, + }, + { + icon: 'FullWidth', + label: '<>', + keyword: '《》', + value: `<>`, + goLeft: 1, + }, + { + icon: 'FullWidth', + label: '《》', + keyword: '《》', + value: `《》`, + goLeft: 1, + }, + { + icon: 'FullWidth', + label: '""', + keyword: '“”', + value: `""`, + goLeft: 1, + }, + { + icon: 'FullWidth', + label: '“”', + keyword: '“”', + value: `”“`, + goLeft: 1, + }, +]; +/* + * 除开系统联想候选表的其他所有表之和 + */ +const OtherSuggestList = HalfWidthSuggestList.concat(MoreSuggestList); +export function allSuggestList(keyword, locales) { + const systemSuggestList = [].concat(SystemSuggestList); + const otherSuggestList = [].concat(OtherSuggestList); + systemSuggestList.forEach((item) => { + item.label = locales ? locales[item.label] : item.label; + }); + otherSuggestList.forEach((item) => { + item.label = locales ? locales[item.label] : item.label; + }); + if (keyword[0] === '/' || keyword[0] === '、' || addonsKeywords.includes(keyword[0])) { + systemSuggestList.forEach((item) => { + item.keyword = ''.concat(keyword[0], item.keyword); + }); + } + // '、'除了返回系统候选表,还需要返回两个半角字符 + return otherSuggestList.concat(systemSuggestList).filter((item) => { + return item.keyword.startsWith(keyword[0]); + }); +} diff --git a/src/core/hooks/Suggester.js b/src/core/hooks/Suggester.js index 4a076a0d..8c2d08b3 100644 --- a/src/core/hooks/Suggester.js +++ b/src/core/hooks/Suggester.js @@ -21,6 +21,7 @@ */ import escapeRegExp from 'lodash/escapeRegExp'; import SyntaxBase from '@/core/SyntaxBase'; +import { allSuggestList, suggesterKeywords } from '@/core/hooks/SuggestList'; import { Pass } from 'codemirror/src/util/misc'; import { isLookbehindSupported } from '@/utils/regexp'; import { replaceLookbehind } from '@/utils/lookbehind-replace'; @@ -90,95 +91,6 @@ export default class Suggester extends SyntaxBase { this.initConfig(this.config); } - /** - * 获取系统默认的候选项列表 - * TODO:后面考虑增加层级机制,比如“公式”是一级,“集合、逻辑运算、方程式”是公式的二级候选值 - */ - getSystemSuggestList() { - const locales = this.$locale; - const suggestList = [ - { - icon: 'h1', - label: locales ? locales['H1 Heading'] : 'H1 Heading', - keyword: 'head1', - value: '# ', - }, - { - icon: 'h2', - label: locales ? locales['H2 Heading'] : 'H2 Heading', - keyword: 'head2', - value: '## ', - }, - { - icon: 'h3', - label: locales ? locales['H3 Heading'] : 'H3 Heading', - keyword: 'head3', - value: '### ', - }, - { - icon: 'table', - label: locales ? locales.table : 'Table', - keyword: 'table', - value: '| Header | Header | Header |\n| --- | --- | --- |\n| Content | Content | Content |\n', - }, - { - icon: 'code', - label: locales ? locales.code : 'Code', - keyword: 'code', - value: '```\n\n```\n', - }, - { - icon: 'link', - label: locales ? locales.link : 'Link', - keyword: 'link', - value: `[title](https://url)`, - }, - { - icon: 'checklist', - label: locales ? locales.checklist : 'Checklist', - keyword: 'checklist', - value: `- [ ] item\n- [x] item`, - }, - { - icon: 'tips', - label: locales ? locales.panel : 'Panel', - keyword: 'panel tips info warning danger success', - value: `::: primary title\ncontent\n:::\n`, - }, - { - icon: 'insertFlow', - label: locales ? locales.detail : 'Detail', - keyword: 'detail', - value: `+++ 点击展开更多\n内容\n++- 默认展开\n内容\n++ 默认收起\n内容\n+++\n`, - }, - // { - // icon: 'pen', - // label: '续写', - // keyword: 'xu xie chatgpt', - // value: () => { - // if (!this.$engine.$cherry.options.openai.apiKey) { - // return '请先配置openai apiKey'; - // } - // this.$engine.$cherry.toolbar.toolbarHandlers.chatgpt('complement'); - // return `\n`; - // }, - // }, - // { - // icon: 'pen', - // label: '总结', - // keyword: 'zong jie chatgpt', - // value: () => { - // if (!this.$engine.$cherry.options.openai.apiKey) { - // return '请先配置openai apiKey'; - // } - // this.$engine.$cherry.toolbar.toolbarHandlers.chatgpt('summary'); - // return `\n`; - // }, - // }, - ]; - return suggestList; - } - /** * 初始化配置 * @param {SuggesterConfig} config @@ -190,26 +102,32 @@ export default class Suggester extends SyntaxBase { if (!suggester) { suggester = []; } - const systemSuggestList = this.getSystemSuggestList(); // 默认的唤醒关键字 - suggester.unshift({ - keyword: '/', - suggestList(word, callback) { - const $word = word.replace(/^\//, ''); - // 加个空格就直接退出联想 - if (/^\s$/.test($word)) { - callback(false); - return; - } - const keyword = $word.replace(/\s+/g, '').split('').join('.*?'); - const test = new RegExp(`^.*?${keyword}.*?$`, 'i'); - const suggestList = systemSuggestList.filter((item) => { - // TODO: 首次联想的时候会把所有的候选项列出来,后续可以增加一些机制改成默认拉取一部分候选项 - return !$word || test.test(item.keyword); - }); - callback(suggestList); - }, - }); + for (const suggesterKeyword of suggesterKeywords) { + suggester.push({ + keyword: suggesterKeyword, + suggestList(_word, callback) { + // 将word全转成小写 + const word = _word.toLowerCase(); + const systemSuggestList = allSuggestList(suggesterKeyword, this.$locale); + // 加个空格就直接退出联想 + if (/^\s$/.test(word)) { + callback(false); + return; + } + // 删掉word当中suggesterKeywords出现的字符 + const keyword = word.replace(new RegExp(suggesterKeyword, 'g'), '').split('').join('.*?'); + const test = new RegExp(`^.*?${keyword}.*?$`, 'i'); + const suggestList = systemSuggestList.filter((item) => { + // TODO: 首次联想的时候会把所有的候选项列出来,后续可以增加一些机制改成默认拉取一部分候选项 + return !word || test.test(item.keyword); + }); + // 当没有候选项时直接推出联想 + callback(suggestList.length === 0 ? false : suggestList); + }, + }); + } + console.log(suggester); suggester.forEach((configItem) => { if (!configItem.suggestList) { console.warn('[cherry-suggester]: the suggestList of config is missing.'); @@ -389,10 +307,10 @@ class SuggesterPanel { this.relocatePanel(this.editor.editor); }); - this.onClickPancelItem(); + this.onClickPanelItem(); } - onClickPancelItem() { + onClickPanelItem() { this.tryCreatePanel(); this.$suggesterPanel.addEventListener( 'click', @@ -413,7 +331,7 @@ class SuggesterPanel { } } - showsuggesterPanel({ left, top, items }) { + showSuggesterPanel({ left, top, items }) { this.tryCreatePanel(); if (!this.$suggesterPanel && isBrowser()) { document.body.appendChild(this.createDom(this.panelWrap)); @@ -427,7 +345,7 @@ class SuggesterPanel { this.$suggesterPanel.style.zIndex = '100'; } - hidesuggesterPanel() { + hideSuggesterPanel() { this.tryCreatePanel(); // const $suggesterPanel = document.querySelector('.cherry-suggester-panel'); if (this.$suggesterPanel) { @@ -513,7 +431,7 @@ class SuggesterPanel { const rect = $cursor.getBoundingClientRect(); const top = rect.top + lineHeight; const { left } = rect; - this.showsuggesterPanel({ left, top, items: this.optionList }); + this.showSuggesterPanel({ left, top, items: this.optionList }); } /** @@ -542,7 +460,7 @@ class SuggesterPanel { // 关闭关联 stopRelate() { - this.hidesuggesterPanel(); + this.hideSuggesterPanel(); this.cursorFrom = null; this.cursorTo = null; this.keyword = ''; @@ -589,6 +507,19 @@ class SuggesterPanel { if (result) { this.editor.editor.replaceRange(result, cursorFrom, cursorTo); } + // 控制光标左移一位或者选中某个范围 + if (this.optionList[idx].goLeft) { + const cursor = this.editor.editor.getCursor(); + this.editor.editor.setCursor(cursor.line, cursor.ch - this.optionList[idx].goLeft); + } + if (this.optionList[idx].selection) { + const { line } = this.editor.editor.getCursor(); + const { ch } = this.editor.editor.getCursor(); + this.editor.editor.setSelection( + { line, ch: ch - this.optionList[idx].selection.from }, + { line, ch: ch - this.optionList[idx].selection.to }, + ); + } } } @@ -702,8 +633,8 @@ class SuggesterPanel { setTimeout(() => { this.stopRelate(); }, 0); - } else if (keyCode === 27) { - // 按下esc的时候退出联想 + } else if (keyCode === 27 || keyCode === 0x25 || keyCode === 0x27) { + // 按下esc或者←、→键的时候退出联想 evt.stopPropagation(); codemirror.focus(); setTimeout(() => { diff --git a/src/sass/cherry.scss b/src/sass/cherry.scss index d8a0cc16..e677bf81 100644 --- a/src/sass/cherry.scss +++ b/src/sass/cherry.scss @@ -549,6 +549,15 @@ .cm-s-default .cm-keyword { color: $editorKeywordColor; } + + .cm-s-default .cm-fullwidth { + color: $fullwidthColor; + z-index: 3; + } + + .cm-s-default .cm-fullwidth :hover { + cursor: pointer; + } } .cherry-drag {