Skip to content

Commit

Permalink
feat: 有序列表、无序列表、checklist支持所见即所得编辑 #543 (#553)
Browse files Browse the repository at this point in the history
* feat: 543 textarea 实现

* feat: 543 contenteditable 实现

* fix: getValueWithoutCode 统一,contenteditable 移动到 PreviewerBubble中处理

* feat(fireShortcutKey): 光标在列表行,tab调整层级,附带移除无用的样式

* feat(fireShortcutKey): 正则调整
  • Loading branch information
ufec authored Sep 4, 2023
1 parent b8244f6 commit 47770e8
Show file tree
Hide file tree
Showing 7 changed files with 282 additions and 21 deletions.
45 changes: 45 additions & 0 deletions examples/basic.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<title>Cherry Editor - Markdown Editor</title>
<style>
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}

video {
max-width: 400px;
}

#demo-val {
display: none;
}

img {
max-width: 100%;
}
iframe.cherry-dialog-iframe {
width: 100%;
height: 100%;
}
</style>
<link rel="stylesheet" type="text/css" href="../dist/cherry-markdown.css">
<link rel="Shortcut Icon" href="./logo/favicon.ico">
<link rel="Shortcut Icon" href="../logo/favicon.ico">
<link rel="Bookmark" href="../logo/favicon.ico">
</head>

<body>
<div id="dom_mask" style="position: absolute; top: 40px; height: 20px; width: 100%;"></div>
<div id="markdown"></div>
<script src="../dist/cherry-markdown.js"></script>
<script src="./scripts/basic.js"></script>
</body>

</html>
6 changes: 6 additions & 0 deletions examples/scripts/basic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
var basicConfig = {
id: 'markdown',
};

var config = Object.assign({}, basicConfig, { value: '- cherrymark' });
window.cherry = new Cherry(config);
16 changes: 15 additions & 1 deletion src/Cherry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 4 additions & 5 deletions src/Previewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
46 changes: 31 additions & 15 deletions src/toolbars/PreviewerBubble.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
* 预览区域的响应式工具栏
*/
Expand Down Expand Up @@ -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的位置
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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()
Expand Down Expand Up @@ -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);
Expand Down
162 changes: 162 additions & 0 deletions src/utils/listContentHandler.js
Original file line number Diff line number Diff line change
@@ -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.<import('codemirror').Position>} */
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();
}
}
19 changes: 19 additions & 0 deletions src/utils/regexp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]*
Expand Down Expand Up @@ -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, '.');
});

0 comments on commit 47770e8

Please sign in to comment.