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, '.');
+ });