From 015ce259146fbe78d73929e43ba909b03f6b4c43 Mon Sep 17 00:00:00 2001 From: liuhongbo <916196375@qq.com> Date: Mon, 9 Oct 2023 18:46:24 +0800 Subject: [PATCH] feat: 40% --- src/extension/core/utils/index.js | 19 ++ src/extension/plugins/code-block/func.md | 4 + src/extension/plugins/code-block/helpers.js | 91 ++++++-- src/extension/plugins/code-block/index.js | 4 +- .../plugins/code-block/menu/index.js | 8 +- src/extension/plugins/code-block/plugin.js | 206 ++++++++++++++++-- .../code-block/render-elem/constant.js | 5 +- .../plugins/code-block/render-elem/index.js | 13 +- .../render-elem/languageSelector.js | 26 ++- src/extension/plugins/header/helper.js | 1 - src/extension/plugins/header/menu/index.js | 4 +- src/extension/plugins/header/plugin.js | 2 +- src/extension/render/render-element.js | 4 + 13 files changed, 335 insertions(+), 52 deletions(-) diff --git a/src/extension/core/utils/index.js b/src/extension/core/utils/index.js index 9225f3d2..a542671c 100644 --- a/src/extension/core/utils/index.js +++ b/src/extension/core/utils/index.js @@ -1,5 +1,6 @@ import slugid from 'slugid'; import { useTranslation } from 'react-i18next'; +import { Node } from 'slate'; export const match = (node, path, predicate) => { if (!predicate) return true; @@ -26,6 +27,24 @@ export const generateEmptyElement = (type) => { return { id: slugid.nice(), type, children: [generateDefaultText()] }; }; +export const generateTextInCustom = (text = '') => { + return { id: slugid.nice(), text: text }; +}; + +/** + * @param {String} type + * @param {Node[] | object | String} [children = LeafNode[]] If provide a string,that will be generate a text node as children automatically + * @param {object} [props = {}] + * @returns {Node} + */ +export const generateElementInCustom = (type, children = generateDefaultText(), props = {}) => { + if (typeof children === 'string') { + children = generateTextInCustom(children); + } + const nodeChildren = Array.isArray(children) ? children : [children]; + return { id: slugid.nice(), type, ...props, children: nodeChildren }; +}; + export const isEmptyParagraph = (node) => { if (node.type !== 'paragraph') return false; if (node.children.length !== 1) return false; diff --git a/src/extension/plugins/code-block/func.md b/src/extension/plugins/code-block/func.md index c03f2b28..a40e575d 100644 --- a/src/extension/plugins/code-block/func.md +++ b/src/extension/plugins/code-block/func.md @@ -18,6 +18,10 @@ * 问题与实现 * 在插入代码块时会检测下方是否有空行,如果没有则自动插入一行`Paragraph`,此操作为避免无法结束代码块,或无法在代码块后插入新的`Paragraph`。 +* 与seafile-editor的区别 + * code-line不会存在于code-block中 + * quote-block不会存在于code-block中 + Todo: * [ ] 加粗,斜体,代码块选中后光标没有自动归位 diff --git a/src/extension/plugins/code-block/helpers.js b/src/extension/plugins/code-block/helpers.js index 0109bc55..9bab984a 100644 --- a/src/extension/plugins/code-block/helpers.js +++ b/src/extension/plugins/code-block/helpers.js @@ -1,7 +1,8 @@ -import { Editor, Location, Node, Path, Point, Range, Transforms, next } from 'slate'; -import { CODE_BLOCK, PARAGRAPH } from '../../constants/element-types'; -import { focusEditor, generateEmptyElement, getNextNode, getSelectedElems, getSelectedNodeByType, getSelectedNodeEntryByType, isEndPoint } from '../../core'; -import { LANGUAGE_MAP } from './render-elem/constant'; +import { Editor, Node, Transforms } from 'slate'; +import { CODE_BLOCK, CODE_LINE, PARAGRAPH } from '../../constants/element-types'; +import { focusEditor, generateElementInCustom, getAboveBlockNode, getSelectedElems, getSelectedNodeEntryByType } from '../../core'; +// eslint-disable-next-line no-unused-vars +import { EXPLAIN_TEXT, LANGUAGE_MAP } from './render-elem/constant'; export const isMenuDisabled = (editor, readonly) => { if (readonly) return true; @@ -12,32 +13,82 @@ export const isMenuDisabled = (editor, readonly) => { if (isSelectedVoid) return true; // Disable the menu when selection is not in the paragraph or code block const isEnable = selectedElments.some(node => [CODE_BLOCK, PARAGRAPH].includes(node.type)); + console.log('isEnable', isEnable) return !isEnable; }; -export const isCodeBlockNode = (editor) => { - if (!editor.selection) return false; - const [node] = Editor.nodes(editor, { - match: node => node.type === CODE_BLOCK +export const getCodeBlockNodeEntry = (editor) => { + if (!editor.selection) return; + const [codeBlock] = Editor.nodes(editor, { + match: node => node.type === CODE_BLOCK, + mode: 'highest' }); - return !!node; + console.log('getSelectedElems(editor)',) + return codeBlock; }; +export const isInCodeBlock = (editor) => { + if (!editor.selection) return false; + const [codeBlock] = Editor.nodes(editor, { + match: node => node.type === CODE_BLOCK, + mode: 'highest' + }); + console.log('codeBlock', codeBlock) + if (!codeBlock) return false; + const selectedElments = getSelectedElems(editor) + console.log('selectedElments', selectedElments) + const isNotInCodeBlock = !selectedElments.find(element => ![CODE_BLOCK, CODE_LINE].includes(element.type)); + console.log('isNotInCodeBlock', isNotInCodeBlock) + return isNotInCodeBlock; +} + export const transformToCodeBlock = (editor) => { - const selectedNode = getSelectedNodeEntryByType(editor, PARAGRAPH); - const endPointOfSelectParagraph = Editor.end(editor, selectedNode[1]); - Transforms.select(editor, endPointOfSelectParagraph); - Transforms.setNodes(editor, { type: CODE_BLOCK }); - const nextNode = getNextNode(editor); - // If the next node is not empty, insert a new paragraph - const isInsertParagraph = !(nextNode && nextNode[0].children.length === 1 && nextNode[0].children[0].text === ''); - if (isInsertParagraph) { - Transforms.insertNodes(editor, generateEmptyElement(PARAGRAPH)); + const textList = []; + const nodeEntries = Editor.nodes(editor, { + match: node => editor.children.includes(node), // Match the highest level node that custom selected + universal: true, + }); + for (let nodeEntry of nodeEntries) { + const [node] = nodeEntry; + if (node) { + textList.push(Node.string(node)); + } } - focusEditor(editor, endPointOfSelectParagraph); + // Generate code block + const codeBlockChildren = textList.map(text => generateElementInCustom(CODE_LINE, text)); + const codeBlock = generateElementInCustom(CODE_BLOCK, codeBlockChildren, { lang: EXPLAIN_TEXT }); + + Transforms.removeNodes(editor, { at: editor.selection }); + Transforms.insertNodes(editor, codeBlock, { mode: 'highest' }); + focusEditor(editor); }; export const unwrapCodeBlock = (editor) => { - Transforms.setNodes(editor, { type: PARAGRAPH }); + const selectedCodeBlock = getSelectedNodeEntryByType(editor, CODE_BLOCK); + if (!selectedCodeBlock) return; + const selectedCodeBlockPath = selectedCodeBlock[1]; + const codeLineEntries = Editor.nodes(editor, { + at: selectedCodeBlockPath, + match: node => node.type === CODE_LINE, + }); + const paragraphNodes = []; + for (const codeLineEntry of codeLineEntries) { + console.log('codeLineEntry', codeLineEntry) + const [codeLineNode] = codeLineEntry; + const paragraph = generateElementInCustom(PARAGRAPH, Node.string(codeLineNode)); + paragraphNodes.push(paragraph); + } + console.log('paragraphNodes', paragraphNodes) + Transforms.removeNodes(editor, { at: selectedCodeBlockPath, match: node => node.type === CODE_LINE, mode: 'lowest' }); + Transforms.insertNodes(editor, paragraphNodes, { at: Editor.end(editor, editor.selection) }); focusEditor(editor); }; + +/** + * @param {Object} editor + * @param {keyof LANGUAGE_MAP} [language = EXPLAIN_TEXT] by default is 'none' + */ +export const setCodeBlockLanguage = (editor, language) => { + const selectedNode = getSelectedNodeEntryByType(editor, CODE_BLOCK); + Transforms.setNodes(editor, { lang: language }, { at: selectedNode[1] }); +}; diff --git a/src/extension/plugins/code-block/index.js b/src/extension/plugins/code-block/index.js index 3249a185..34eea967 100644 --- a/src/extension/plugins/code-block/index.js +++ b/src/extension/plugins/code-block/index.js @@ -1,14 +1,14 @@ import { CODE_BLOCK } from '../../constants/element-types'; import CodeBlockMenu from './menu'; import withCodeBlock from './plugin'; -import renderCodeBlock from './render-elem'; +import renderCodeBlock, { renderCodeLine } from './render-elem'; const CodeBlockPlugin = { type: CODE_BLOCK, nodeType: 'element', editorMenus: [CodeBlockMenu], editorPlugin: withCodeBlock, - renderElements: [renderCodeBlock], + renderElements: [renderCodeBlock,renderCodeLine], }; export default CodeBlockPlugin; diff --git a/src/extension/plugins/code-block/menu/index.js b/src/extension/plugins/code-block/menu/index.js index ecac35c4..f5cbd254 100644 --- a/src/extension/plugins/code-block/menu/index.js +++ b/src/extension/plugins/code-block/menu/index.js @@ -1,15 +1,17 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { MenuItem } from '../../../commons'; import { CODE_BLOCK } from '../../../constants/element-types'; -import { isCodeBlockNode, isMenuDisabled, transformToCodeBlock, unwrapCodeBlock } from '../helpers'; +import { getCodeBlockNodeEntry, isInCodeBlock, isMenuDisabled, transformToCodeBlock, unwrapCodeBlock } from '../helpers'; import { MENUS_CONFIG_MAP } from '../../../constants'; const menuConfig = MENUS_CONFIG_MAP[CODE_BLOCK]; const CodeBlockMenu = ({ isRichEditor, className, readonly, editor }) => { - const isActive = isCodeBlockNode(editor); + const isActive = useMemo(() => isInCodeBlock(editor), [editor.selection]); + isInCodeBlock(editor) const onMousedown = useCallback((e) => { e.stopPropagation(); + e.preventDefault(); isActive ? unwrapCodeBlock(editor) : transformToCodeBlock(editor); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isActive]); diff --git a/src/extension/plugins/code-block/plugin.js b/src/extension/plugins/code-block/plugin.js index ea1eaaeb..41ed8b7f 100644 --- a/src/extension/plugins/code-block/plugin.js +++ b/src/extension/plugins/code-block/plugin.js @@ -1,22 +1,200 @@ -import { Editor, Element, Point, Transforms, Node } from 'slate'; -import { ReactEditor } from 'slate-react'; -import { CODE_BLOCK, PARAGRAPH } from '../../constants/element-types'; -import { generateEmptyElement } from '../../core'; +import isHotkey from 'is-hotkey'; +import { Transforms, Node, Range, Editor } from 'slate'; +import { getNodeType, isLastNode, getSelectedNodeByType, generateEmptyElement, generateElementInCustom } from '../../core'; +import { getCodeBlockNodeEntry } from './helpers'; +import { CODE_BLOCK, CODE_LINE, PARAGRAPH } from '../../constants/element-types'; const withCodeBlock = (editor: Editor) => { - const { insertBreak, insertText, deleteBackward } = editor; + const { normalizeNode, insertFragment, insertText, insertBreak, insertData, insertNode, onHotKeyDown } = editor; const newEditor = editor; - newEditor.insertBreak = () => { - const { selection } = editor; - if (selection == null) return insertBreak(); + newEditor.insertData = (data) => { + if (data.types.includes('text/code-block') && !getSelectedNodeByType(editor, CODE_BLOCK)) { + const codeBlockNode = JSON.parse(data.getData('text/code-block')); + return insertNode(codeBlockNode); + } + insertData(data); + }; + + newEditor.insertFragment = (data) => { + // only selected code block content + if (data.length === 1 && data[0].type === CODE_BLOCK && !getSelectedNodeByType(editor, CODE_BLOCK)) { + data.forEach((node, index) => { + if (node.type === CODE_BLOCK) { + const newBlock = node.children.map(line => { + const text = Node.string(line); + const children = generateElementInCustom(PARAGRAPH, text); + return children; + }); + data.splice(index, 1, ...newBlock); + } + }); + return insertFragment(data); + } else { + if (getSelectedNodeByType(editor, CODE_BLOCK)) { + // Paste into code block + + // Pasted data is code block split with code-line + data.forEach((node, index) => { + if (node.type === CODE_BLOCK) { + const codeLineArr = node.children.map(line => line); + data.splice(index, 1, ...codeLineArr); + } + }); + const newData = data.map(node => { + const text = Node.string(node); + const codeLine = generateElementInCustom(CODE_LINE, text); + return codeLine; + }); + + // current focus code-line string not empty + const string = Editor.string(newEditor, newEditor.selection.focus.path); + if (string.length !== 0 && Range.isCollapsed(newEditor.selection)) { + const [node, ...restNode] = newData; + const text = Node.string(node); + insertText(text); + if (restNode.length !== 0) { + insertBreak(); + insertFragment(restNode); + } + return; + } + return insertFragment(newData); + } else { + // Paste into not a code block + return insertFragment(data); + } + } + }; + + // Rewrite normalizeNode + newEditor.normalizeNode = ([node, path]) => { + const type = getNodeType(node); + + if (type === CODE_LINE && path.length <= 1) { + Transforms.setNodes(newEditor, { type: PARAGRAPH }, { at: path }); + return; + } + + if (type === CODE_BLOCK) { + if (node.children.length === 0) { + Transforms.delete(newEditor, { at: path }); + return; + } + + // code-block is the last node in the editor and needs to be followed by a p node + const isLast = isLastNode(newEditor, node); + if (isLast) { + const paragraph = generateEmptyElement(PARAGRAPH); + Transforms.insertNodes(newEditor, paragraph, { at: [path[0] + 1] }); + } + + // Here must be a code node below code-block + if (getNodeType(node.children[0]) !== CODE_LINE) { + Transforms.unwrapNodes(newEditor); + Transforms.setNodes(newEditor, { type: PARAGRAPH }, { mode: 'highest' }); + } + + if (node.children.length > 1) { + node.children.forEach((child, index) => { + if (child.type !== CODE_LINE) { + Transforms.setNodes(newEditor, { type: CODE_LINE }, { at: [...path, index] }); + } + }); + } + } + + // Perform default behavior + return normalizeNode([node, path]); + }; + + newEditor.onHotKeyDown = (event) => { + const wrapperCodeBlock = getCodeBlockNodeEntry(newEditor); + if (!wrapperCodeBlock) return onHotKeyDown(event); + + if (isHotkey('mod+enter', event)) { + event.preventDefault(); + if (newEditor.selection && !Range.isExpanded(newEditor.selection)) { + const path = Editor.path(newEditor, newEditor.selection); + const newParagraphPath = [path[0] + 1]; + const newParagraph = generateEmptyElement(PARAGRAPH); + Transforms.insertNodes(newEditor, newParagraph, { at: newParagraphPath }); + Transforms.select(newEditor, newParagraphPath); + } + } + + if (isHotkey('tab', event)) { + event.stopPropagation(); + event.preventDefault(); + const nodeEntries = Editor.nodes(newEditor, { + mode: 'lowest', + match: node => node.type === CODE_LINE, + }); + const nodeEntryList = Array.from(nodeEntries); + for (const nodeEntry of nodeEntryList) { + const [node, path] = nodeEntry; + // Insert 4 spaces for easier remove space + Transforms.insertText(newEditor, ' '.repeat(4), { at: { path: [...path, 0], offset: 0 } }); + } + const newRange = Editor.range(newEditor, nodeEntryList[0][1].concat(0), nodeEntryList.at(-1)[1].concat(0)); + nodeEntryList.length > 1 ? Transforms.select(newEditor, newRange) : Transforms.select(newEditor); + } + + if (isHotkey('shift+tab', event)) { + event.preventDefault(); + // Match the beginning of the line space, delete up to 4 spaces at a time + const costomSelection = newEditor.selection; + const matchBeginSpace = /^\s*/; + const nodeEntries = Editor.nodes(newEditor, { + mode: 'lowest', + match: node => node.type === CODE_LINE, + }); + const nodeEntryList = Array.from(nodeEntries); + let removedSpaceCount = 0; + + for (const nodeEntry of nodeEntryList) { + const [node, path] = nodeEntry; + const spaceNum = Node.string(node).match(matchBeginSpace); + // skip empty line and no space begining line + if (!spaceNum || !spaceNum[0].length) continue; + const deleteNum = Math.min(spaceNum[0].length, 4); + removedSpaceCount += deleteNum; + for (let i = 0; i < deleteNum; i++) { + Transforms.select(newEditor, { path: [...path, 0], offset: 0 }); + Editor.deleteForward(newEditor, { unit: 'character' }); + } + } + // Select multiple rows when operating more then one line + // Keep cursor location when operating one line + if (nodeEntryList.length > 1) { + const selectLocation = Editor.range(newEditor, nodeEntryList[0][1].concat(0), nodeEntryList.at(-1)[1].concat(0)); + Transforms.select(newEditor, selectLocation); + } else { + const { anchor, focus } = costomSelection; + const isCollapsed = Range.isCollapsed(costomSelection); + if (isCollapsed) { + const selectLocation = { ...costomSelection.focus, offset: costomSelection.focus.offset - removedSpaceCount }; + Transforms.select(newEditor, selectLocation); + } else { + const selectLocation = { + anchor: { ...anchor, offset: anchor.offset - removedSpaceCount }, + focus: { ...focus, offset: focus.offset - removedSpaceCount } + }; + Transforms.select(newEditor, selectLocation); + } + } + } - const [nodeEntry] = Editor.nodes(editor, { - match: n => Element.isElement(n) && n.type === CODE_BLOCK, - universal: true, - }); - if (!nodeEntry) return insertBreak(); - insertText('\n'); + if (isHotkey('mod+a', event)) { + event.preventDefault(); + const codeBlockEntry = Editor.nodes(newEditor, { + mode: 'highest', + match: node => node.type === CODE_BLOCK, + }); + if (!codeBlockEntry) return; + const codeBlockEntryList = Array.from(...codeBlockEntry); + Transforms.select(newEditor, codeBlockEntryList[1]); + } }; return newEditor; diff --git a/src/extension/plugins/code-block/render-elem/constant.js b/src/extension/plugins/code-block/render-elem/constant.js index 0942fb84..a707d3ea 100644 --- a/src/extension/plugins/code-block/render-elem/constant.js +++ b/src/extension/plugins/code-block/render-elem/constant.js @@ -1,5 +1,8 @@ +// Default language key name +export const EXPLAIN_TEXT = 'none'; +// Language map export const LANGUAGE_MAP = { - none: ' Text', + [EXPLAIN_TEXT]: ' Text', html: ' HTML', css: ' CSS', javascript: ' Javascript', diff --git a/src/extension/plugins/code-block/render-elem/index.js b/src/extension/plugins/code-block/render-elem/index.js index 82cdc857..b7d60275 100644 --- a/src/extension/plugins/code-block/render-elem/index.js +++ b/src/extension/plugins/code-block/render-elem/index.js @@ -2,15 +2,24 @@ import React from 'react'; import LanguageSelector from './languageSelector'; const renderCodeBlock = ({ attributes, children, element }) => { - console.log('element', element) return (
{children}
-