From 47770e8d7ba8f4770a1f64db648218c191862534 Mon Sep 17 00:00:00 2001 From: Ethan Xu <34962267+ufec@users.noreply.github.com> Date: Mon, 4 Sep 2023 19:53:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=89=E5=BA=8F=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E3=80=81=E6=97=A0=E5=BA=8F=E5=88=97=E8=A1=A8=E3=80=81checklist?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=89=80=E8=A7=81=E5=8D=B3=E6=89=80=E5=BE=97?= =?UTF-8?q?=E7=BC=96=E8=BE=91=20#543=20(#553)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 543 textarea 实现 * feat: 543 contenteditable 实现 * fix: getValueWithoutCode 统一,contenteditable 移动到 PreviewerBubble中处理 * feat(fireShortcutKey): 光标在列表行,tab调整层级,附带移除无用的样式 * feat(fireShortcutKey): 正则调整 --- examples/basic.html | 45 +++++++++ examples/scripts/basic.js | 6 ++ src/Cherry.js | 16 +++- src/Previewer.js | 9 +- src/toolbars/PreviewerBubble.js | 46 ++++++--- src/utils/listContentHandler.js | 162 ++++++++++++++++++++++++++++++++ src/utils/regexp.js | 19 ++++ 7 files changed, 282 insertions(+), 21 deletions(-) create mode 100644 examples/basic.html create mode 100644 examples/scripts/basic.js create mode 100644 src/utils/listContentHandler.js diff --git a/examples/basic.html b/examples/basic.html new file mode 100644 index 00000000..43aa44db --- /dev/null +++ b/examples/basic.html @@ -0,0 +1,45 @@ + + + + + + Cherry Editor - Markdown Editor + + + + + + + + +
+
+ + + + + diff --git a/examples/scripts/basic.js b/examples/scripts/basic.js new file mode 100644 index 00000000..38d6ba26 --- /dev/null +++ b/examples/scripts/basic.js @@ -0,0 +1,6 @@ +var basicConfig = { + id: 'markdown', +}; + +var config = Object.assign({}, basicConfig, { value: '- cherrymark' }); +window.cherry = new Cherry(config); diff --git a/src/Cherry.js b/src/Cherry.js index 36fc535d..0a27c525 100644 --- a/src/Cherry.js +++ b/src/Cherry.js @@ -34,6 +34,7 @@ import locales from '@/locales/index'; import { urlProcessorProxy } from './UrlCache'; import { CherryStatic } from './CherryStatic'; +import { LIST_CONTENT } from '@/utils/regexp'; /** @typedef {import('~types/cherry').CherryOptions} CherryOptions */ export default class Cherry extends CherryStatic { @@ -599,9 +600,22 @@ export default class Cherry extends CherryStatic { /** * @private - * @param {*} evt + * @param {KeyboardEvent} evt */ fireShortcutKey(evt) { + const cursor = this.editor.editor.getCursor(); + const lineContent = this.editor.editor.getLine(cursor.line); + // shift + tab 已经被绑定为缩进,所以这里不做处理 + if (!evt.shiftKey && evt.key === 'Tab' && LIST_CONTENT.test(lineContent)) { + // 每按一次Tab,如果当前光标在行首或者行尾,就在行首加一个\t + if (cursor.ch === 0 || cursor.ch === lineContent.length || cursor.ch === lineContent.length + 1) { + evt.preventDefault(); + this.editor.editor.setSelection({ line: cursor.line, ch: 0 }, { line: cursor.line, ch: lineContent.length }); + this.editor.editor.replaceSelection(`\t${lineContent}`, 'around'); + const newCursor = this.editor.editor.getCursor(); + this.editor.editor.setSelection(newCursor, newCursor); + } + } if (this.toolbar.matchShortcutKey(evt)) { // 快捷键 evt.preventDefault(); diff --git a/src/Previewer.js b/src/Previewer.js index 3437e0e2..63d8bba2 100644 --- a/src/Previewer.js +++ b/src/Previewer.js @@ -518,8 +518,9 @@ export default class Previewer { continue; } } - if (/^(class|id|href|rel|target|src|title|controls|align|width|height|style|open)$/i.test(name)) { + if (/^(class|id|href|rel|target|src|title|controls|align|width|height|style|open|contenteditable)$/i.test(name)) { name = name === 'class' ? 'className' : name; + name = name === 'contenteditable' ? 'contentEditable' : name; if (name === 'style') { ret.style = ret.style ? ret.style : []; ret.style.push(value); @@ -760,10 +761,8 @@ export default class Previewer { this.options.virtualDragLineDom.classList.remove('cherry-drag--hidden'); this.editor.options.editorDom.classList.remove('cherry-editor--full'); // 恢复现场 - if (this.options.previewerCache.layout !== {}) { - const { layout } = this.options.previewerCache; - this.setRealLayout(layout.editorPercentage, layout.previewerPercentage); - } + const { layout } = this.options.previewerCache; + this.setRealLayout(layout.editorPercentage, layout.previewerPercentage); if (this.options.previewerCache.htmlChanged) { this.update(this.options.previewerCache.html); } diff --git a/src/toolbars/PreviewerBubble.js b/src/toolbars/PreviewerBubble.js index 414ac975..f5ced418 100644 --- a/src/toolbars/PreviewerBubble.js +++ b/src/toolbars/PreviewerBubble.js @@ -20,10 +20,12 @@ import CodeHandler from '@/utils/codeBlockContentHandler'; import { drawioDialog } from '@/utils/dialog'; import Event from '@/Event'; import { copyToClip } from '@/utils/copy'; -import { imgDrawioReg, getCodeBlockRule } from '@/utils/regexp'; +import { imgDrawioReg, getValueWithoutCode } from '@/utils/regexp'; import { CODE_PREVIEWER_LANG_SELECT_CLASS_NAME } from '@/utils/code-preview-language-setting'; import debounce from 'lodash/debounce'; import FormulaHandler from '@/utils/formulaUtilsHandler'; +import ListHandler from '@/utils/listContentHandler'; + /** * 预览区域的响应式工具栏 */ @@ -157,7 +159,7 @@ export default class PreviewerBubble { this.checkboxIdx = list.indexOf(target); // 然后找到Editor中对应的`- []`或者`- [ ]`进行修改 - const contents = this.getValueWithoutCode().split('\n'); + const contents = getValueWithoutCode(this.editor.editor.getValue()).split('\n'); let editorCheckboxCount = 0; // [ ]中的空格,或者[x]中的x的位置 @@ -250,6 +252,22 @@ export default class PreviewerBubble { this.$showFormulaPreviewerBubbles('click', target, { x: e.pageX, y: e.pageY }); } break; + case 'A': + e.stopPropagation(); // 阻止冒泡,避免触发预览区域的点击事件 + break; + case 'P': + if (target instanceof HTMLParagraphElement && target.parentElement instanceof HTMLLIElement) { + if (target.children.length !== 0) { + // 富文本 + e.preventDefault(); + e.stopPropagation(); + } + // 鼠标点击在列表的某项上时,为target增加contenteditable属性,使其可编辑 + target.setAttribute('contenteditable', 'true'); + target.focus(); + this.$showListPreviewerBubbles('click', target); + } + break; } this.$dealCodeBlockEditorMode(e); } @@ -374,17 +392,15 @@ export default class PreviewerBubble { this.bubbleHandler[trigger] = formulaHandler; } - getValueWithoutCode() { - return this.editor.editor - .getValue() - .replace(getCodeBlockRule().reg, (whole) => { - // 把代码块里的内容干掉 - return whole.replace(/^.*$/gm, '/n'); - }) - .replace(/(`+)(.+?(?:\n.+?)*?)\1/g, (whole) => { - // 把行内代码的符号去掉 - return whole.replace(/[![\]()]/g, '.'); - }); + /** + * 为触发的列表增加操作工具栏 + * @param {string} trigger 触发方式 + * @param {HTMLParagraphElement} target 用户触发的列表dom + */ + $showListPreviewerBubbles(trigger, target, options = {}) { + this.$createPreviewerBubbles(trigger, 'list-hover-handler'); + const listHandler = new ListHandler(trigger, target, this.bubble[trigger], this.previewerDom, this.editor); + this.bubbleHandler[trigger] = listHandler; } /** @@ -396,7 +412,7 @@ export default class PreviewerBubble { const allDrawioImgs = Array.from(this.previewerDom.querySelectorAll('img[data-type="drawio"]')); const totalDrawioImgs = allDrawioImgs.length; const drawioImgIndex = allDrawioImgs.indexOf(htmlElement); - const content = this.getValueWithoutCode(); + const content = getValueWithoutCode(this.editor.editor.getValue()); const drawioImgsCode = content.match(imgDrawioReg); const testSrc = drawioImgsCode[drawioImgIndex] ? drawioImgsCode[drawioImgIndex].replace(/^!\[.*?\]\((.*?)\)/, '$1').trim() @@ -442,7 +458,7 @@ export default class PreviewerBubble { * @returns {boolean} */ beginChangeImgValue(htmlElement) { - const content = this.getValueWithoutCode(); + const content = getValueWithoutCode(this.editor.editor.getValue()); const src = htmlElement.getAttribute('src'); const imgReg = /(!\[[^\n]*?\]\([^)]+\))/g; const contentImgs = content.match(imgReg); diff --git a/src/utils/listContentHandler.js b/src/utils/listContentHandler.js new file mode 100644 index 00000000..b7232302 --- /dev/null +++ b/src/utils/listContentHandler.js @@ -0,0 +1,162 @@ +/** + * Copyright (C) 2021 THL A29 Limited, a Tencent company. + * + * 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. + */ + +import { getValueWithoutCode, LIST_CONTENT } from '@/utils/regexp'; + +export default class ListHandler { + /** @type{HTMLElement} */ + bubbleContainer = null; + + regList = LIST_CONTENT; + + /** @type{Array.} */ + range = []; + + /** @type{import('codemirror').Position} */ + position = { line: 0, ch: 0 }; + + /** + * @param {string} trigger 触发方式 + * @param {HTMLParagraphElement} target 目标dom + * @param {HTMLDivElement} container bubble容器 + * @param {HTMLDivElement} previewerDom 预览器dom + * @param {import('../Editor').default} editor 编辑器实例 + */ + constructor(trigger, target, container, previewerDom, editor, options = {}) { + this.trigger = trigger; + this.target = target; + this.container = container; + this.previewerDom = previewerDom; + this.editor = editor; + this.handleEditablesInputBinded = this.handleEditablesInput.bind(this); // 保证this指向正确以及能够正确移除事件 + this.handleEditablesUnfocusBinded = this.handleEditablesUnfocus.bind(this); + this.target.addEventListener('input', this.handleEditablesInputBinded, false); + this.target.addEventListener('focusout', this.handleEditablesUnfocusBinded, false); + this.setSelection(); + } + + /** + * 触发事件 + * @param {string} type 事件类型 + * @param {Event} event 事件对象 + */ + emit(type, event) { + switch (type) { + case 'remove': + return this.remove(); + } + } + + remove() { + if (this.bubbleContainer) { + this.bubbleContainer.style.display = 'none'; + if (this.bubbleContainer.children[0] instanceof HTMLTextAreaElement) { + this.bubbleContainer.children[0].value = ''; // 清空内容 + } + } + this.target.removeAttribute('contenteditable'); + this.target.removeEventListener('input', this.handleEditablesInputBinded, false); + this.target.removeEventListener('focusout', this.handleEditablesUnfocusBinded, false); + const cursor = this.editor.editor.getCursor(); // 获取光标位置 + this.editor.editor.setSelection(cursor, cursor); // 取消选中 + } + + setSelection() { + const allLi = Array.from(this.previewerDom.querySelectorAll('li')); // 预览区域内所有的li + const targetLiIdx = allLi.findIndex((li) => li === this.target.parentElement); + if (targetLiIdx === -1) { + return; // 没有找到li + } + const contents = getValueWithoutCode(this?.editor.editor.getValue())?.split('\n') ?? []; + let contentsLiCount = 0; // 编辑器中是列表的数量 + let targetLine = -1; // 行 + let targetCh = -1; // 列 + let targetContent = ''; // 当前点击的li的内容 + contents.forEach((lineContent, lineIdx) => { + // 匹配是否符合列表的正则 + const regRes = this.regList.exec(lineContent); + if (regRes !== null) { + if (contentsLiCount === targetLiIdx && regRes[1] !== undefined) { + targetLine = lineIdx; + // eslint-disable-next-line prefer-destructuring + targetContent = regRes[4]; // 这里只取一个没必要解构 + targetCh = lineContent.indexOf(targetContent); + } + contentsLiCount += 1; + } + }); + const from = { line: targetLine, ch: targetCh }; + const to = { line: targetLine, ch: targetCh + targetContent.length }; + this.editor.editor.setSelection(from, to); + this.range = [from, to]; + this.position = this.editor.editor.getCursor(); // 输入就获取光标位置,防止后面点到编辑器dom的时候光标位置不对 + } + + /** + * 处理contenteditable元素的输入事件 + * @param {InputEvent} event + */ + handleEditablesInput(event) { + event.stopPropagation(); + event.preventDefault(); + /** @typedef {'insertText'|'insertFromPaste'|'insertParagraph'|'insertLineBreak'|'deleteContentBackward'|'deleteContentForward'|'deleteByCut'|'deleteContentForward'|'deleteWordBackward'} InputType*/ + if (event.target instanceof HTMLParagraphElement) { + if (event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak') { + this.handleInsertLineBreak(); + } + } + } + + /** + * 处理contenteditable元素的失去焦点事件 + * @param {FocusEvent} event + */ + handleEditablesUnfocus(event) { + event.stopPropagation(); + event.preventDefault(); + if (event.target instanceof HTMLParagraphElement) { + console.log('event', event); + const md = this.editor.$cherry.engine.makeMarkdown(event.target.innerHTML); + console.log('md', md); + const [from, to] = this.range; + this.editor.editor.replaceRange(md, from, to); + this.remove(); + } + } + + handleInsertLineBreak() { + // 获取当前光标位置 + const cursor = this.editor.editor.getCursor(); + // 获取光标行的内容 + const lineContent = this.editor.editor.getLine(cursor.line); + const regRes = this.regList.exec(lineContent); + let insertContent = '\n- '; + if (regRes !== null) { + // 存在选中的checkbox则替换为未选中的checkbox,其他的保持原样 + insertContent = `\n${regRes[1]}${regRes[2]?.replace('[x]', '[ ] ')}`; + } + // 在当前行的末尾插入一个换行符,这会创建一个新行 + this.editor.editor.replaceRange(insertContent, { + line: cursor.line, + ch: this.editor.editor.getLine(cursor.line).length, + }); + // 将光标移动到新行 + this.editor.editor.setCursor({ line: cursor.line + 1, ch: insertContent.length + 1 }); + // 将光标聚焦到编辑器上 + this.editor.editor.focus(); + this.remove(); + } +} diff --git a/src/utils/regexp.js b/src/utils/regexp.js index ead96d99..d6ebdc00 100644 --- a/src/utils/regexp.js +++ b/src/utils/regexp.js @@ -111,6 +111,9 @@ export const URL_NO_SLASH = new RegExp(`^${URL_INLINE_NO_SLASH.source}$`); export const URL = new RegExp(`^${URL_INLINE.source}$`); +// 正则结果[全部, 判定符之前的空格或者tab, 判定符, checkbox内容(没有就是undefined), 列表内容] +export const LIST_CONTENT = /([ \t]*)([*+-][ ](\[[ x]\])?|[a-z0-9I一二三四五六七八九十零]+\.)([^\r\n]*)/; + export function getTableRule(merge = false) { // ^(\|[^\n]+\|\r?\n)((?:\|:?[-]+:?)+\|)(\n(?:\|[^\n]+\|\r?\n?)*)?$ // (\\|?[^\\n|]+\\|?\\n)(?:\\|?[\\s]*:?[-]{2,}:?[\\s]* @@ -261,3 +264,19 @@ export const imgDrawioXmlReg = /(!\[[^\n]*?\]\([^)]+\)\{[^}]* data-xml=)([^}]+)\ */ export const imgDrawioReg = /(!\[[^\n]*?\]\(data:image\/[a-z]{1,10};base64,[^)]+\)\{data-type=drawio data-xml=[^}]+\})/g; + +/** + * 从编辑器里的内容中获取没有代码块的内容 + * @param {string} value + * @returns {string} + */ +export const getValueWithoutCode = (value = '') => + value + .replace(getCodeBlockRule().reg, (whole) => { + // 把代码块里的内容干掉 + return whole.replace(/^.*$/gm, '/n'); + }) + .replace(/(`+)(.+?(?:\n.+?)*?)\1/g, (whole) => { + // 把行内代码的符号去掉 + return whole.replace(/[![\]()]/g, '.'); + });