Предыстория и текущая ситуация
wangEditorРазрабатывается новая версия, стремящаяся стать более стабильным и лаконичным текстовым редактором с открытым исходным кодом.
- устарел
document.execCommand, отдельный вид и модель - Поддержка совместного редактирования в будущем
- Полностью учитывайте расширяемость и механизм подключаемых модулей, чтобы упростить расширение более сложных функций.
- Технические поиски программистов всегда приводили к тому, что они бросали себя~
Я проделал некоторую работу раньше
- Разработайте демонстрацию с 0, исследуйте slate quill proseMirror в соответствии с практическими задачами, она была записанаэта статья
- Попробуйте использовать сланец (не полагаясь на React), чтобы сделатьdemo
- Обязательно используйте планшет в качестве ядра (не полагаясь на React) и начните пытаться разработать новую версию (исходный код WIP закрыт)
Несмотря на то, что некоторые функции были реализованы, все еще находится в процессе проектирования технического решения, а API и структура кода будут продолжать корректироваться.
Почему стоит использовать slate.js?
Сначала я тоже хотел разрабатывать собственное ядро с 0, но потом потихоньку передумал. Особенно при взгляде на груду кода proseMirror.
Почему не саморазработанное ядро?
- Наша основная цель — сделать стабильный, простой в использовании и хорошо масштабируемый продукт с открытым исходным кодом, а не заниматься гиковским духом и строить колеса.
- Стоимость самостоятельной разработки очень высока, требует много времени и имеет много ошибок. И если я захочу это сделать, это будет в основном на мне - и моя личная энергия не гарантирована, я сейчас занят, может быть, я буду занят через два дня
- PS: Если у вас есть глубокие технические интересы, вы можете начать с интерпретации исходного кода и написания демонстраций, а также посмотреть на свою личную энергию и способности.
По сравнению с другими продуктами с открытым исходным кодом
перо не подходит
- Это уже зрелый редактор, ядро, UI, плагины и т. д. все сформировано, экология тоже очень большая, она готова к использованию, и мы мало что можем сделать.
- Последняя версия quill была выпущена 2 года назад.
- дельта пера требует больших затрат на обучение
прозаЗеркало не подходит
- Это не оптимизированное ядро, оно включает в себя много контента, большой объем кода и большой размер пакета.
- Дизайн относительно абстрактен, код сложен, и его нелегко интерпретировать.
сланец больше подходит
- Дизайн прост и понятен, объем кода небольшой, размер пакета небольшой (всего 60кб gzip 17кб), а исходный код легко читается
- На основе механизма плагинов все можно расширить
- По умолчанию используется React, но вы можете настроить уровень представления с помощью вторичной разработки (уже сделано).
То, что вы основаны на slate.js, не означает, что это легко
- Перепишите View, не полагаясь на React
- Разработайте различные рабочие функции, такие как панель инструментов, всплывающая подсказка и т. д.
- Расширяемость дизайна, полностью подключаемый модуль
- Разрабатывайте различные функции редактора форматированного текста, особенно сложные функции, такие как таблицы и подсветка кода.
Это похоже на то, что я проектирую полноценный автомобиль на базе стандартного двигателя, что непросто.
общий дизайн
Разделить несколько пакетов на основе lerna
основные зависимости
- slate.js — ядро редактора
- snbbdom - разделение моделей представлений, использование vdom для рендеринга представлений
- [Примечание] Для общедоступных зависимостей разумно используйте peerDependencies, чтобы избежать повторной упаковки! ! !
core
Строго говоря, это следует называться «View Core». Он основан на ядре Slate.js и завершает часть Ui часть редактора.
- Редактор — некоторые API для Slate для пользовательского интерфейса DOM.
- text-area - рендеринг DOM области ввода, события DOM, синхронизация выделения,
- форматы — правила рендеринга для различных данных в области ввода, например, как отображать полужирный шрифт, цвет, изображение, список и т. д. Расширяемая регистрация.
- меню — меню, включая панель инструментов, плавающее меню, всплывающую подсказку, контекстное меню, DropPanel, Modal и т. д. Расширяемая регистрация.
ядро само по себе не имеет никакой реальной функциональности. Необходимо расширять форматы, меню, плагины и т. д. с помощью модулей для определения конкретных функций.
Большие и сложные табличные функции, маленькие и простые жирные функции — все они должны обрабатываться таким образом.
basic modules
Некоторые распространенные, простые и базовые модули суммированы. Например:
- Простой стиль - смелый, курсивый, подчеркивающий, страдающий, встроенный код
- color - цвет текста, цвет фона
- заголовок - установить заголовок
- разное...
Некоторые более сложные модули необходимо разбивать в пакет отдельно, например, таблицу, кодовый блок и т. д. В любом случае, он основан на lerna, и его относительно просто расширить.
editor
Представьте ядро, представьте каждый модуль и, наконец, сгенерируйте редактор в соответствии с пользовательской конфигурацией.
основной дизайн
Как упоминалось выше, core следует строго называть view-core. Его основная роль:
- Перехватывайте пользовательский ввод и модификацию, используйте API редактора, чтобы инициировать модификацию модели (или модификацию выбора)
- изменение редактора обновляется в DOM в режиме реального времени, сохраняя DOM и модель (или выбор) в синхронизации
- Определите механизм расширения (у него нет реальной функции) и реализуйте определенные функции, расширив модуль.
Перехват пользовательского ввода с помощью beforeinput
beforeinput — это относительно новое событие DOM, которое вообще не поддерживалось в прошлом году. В настоящее время кажется, что основные браузеры поддерживают его, особенно после выпуска FireFox 87, см.caniuse.
Для браузеров, которые не поддерживаются, вы можете использовать KeyDown / KeyPress для совместимости, хотя это будет иметь некоторое влияние, но хорошо учитывать многое.
(PS: новая версия больше не поддерживает IE11)
Прослушайте событие beforeinput, а затем выполните разные API-интерфейсы редактора в соответствии с разными типами ввода.
// 阻止默认行为,劫持所有的富文本输入
event.preventDefault()
// 根据 beforeInput 的 event.inputType
switch (type) {
case 'deleteByComposition':
case 'deleteByCut':
case 'deleteByDrag': {
Editor.deleteFragment(editor)
break
}
case 'deleteContent':
case 'deleteContentForward': {
Editor.deleteForward(editor)
break
}
case 'deleteContentBackward': {
Editor.deleteBackward(editor)
break
}
case 'deleteEntireSoftLine': {
Editor.deleteBackward(editor, { unit: 'line' })
Editor.deleteForward(editor, { unit: 'line' })
break
}
case 'deleteHardLineBackward': {
Editor.deleteBackward(editor, { unit: 'block' })
break
}
case 'deleteSoftLineBackward': {
Editor.deleteBackward(editor, { unit: 'line' })
break
}
case 'deleteHardLineForward': {
Editor.deleteForward(editor, { unit: 'block' })
break
}
case 'deleteSoftLineForward': {
Editor.deleteForward(editor, { unit: 'line' })
break
}
case 'deleteWordBackward': {
Editor.deleteBackward(editor, { unit: 'word' })
break
}
case 'deleteWordForward': {
Editor.deleteForward(editor, { unit: 'word' })
break
}
case 'insertLineBreak':
case 'insertParagraph': {
Editor.insertBreak(editor)
break
}
case 'insertFromComposition':
case 'insertFromDrop':
case 'insertFromPaste':
case 'insertFromYank':
case 'insertReplacementText':
case 'insertText': {
if (data instanceof DataTransfer) {
// 这里处理非纯文本(如 html 图片文件等)的粘贴。对于纯文本的粘贴,使用 paste 事件
DomEditor.insertData(editor, data)
} else if (typeof data === 'string') {
Editor.insertText(editor, data)
}
break
}
}
синхронизация выбора
Изменения выбора DOM вызовутdocument.addEvenListener('selectionchange', fn)
изменения выбора редактора вызовутeditor.onChangeмероприятие. Таким образом, они могут быть синхронизированы друг с другом.
синхронизированное представление updateView
Представление обновления запускается, когда при изменении редактора необходимо обеспечить одновременную синхронизацию представления и модели. Делится на два этапа:
- Создать vnode на основе модели
- patch vnode
Второй шаг очень прост, мы используемsnabbdom.jsделать vdom рендеринг. Библиотека, используемая vue 2.x, старая и более стабильная. Более того, он может поддерживать jsx через простую настройку, что очень удобно писать.
Ключ лежит в первом шаге, создании vnode.
Код ниже упрощен для удобства чтения, и мы разделили логику на две части:renderElementа такжеrenderText
/**
* 根据 slate node 生成 snabbdom vnode
* @param node slate node
* @param index node index in parent.children
* @param parent parent slate node
* @param editor editor
*/
function node2Vnode(node: SlateNode, index: number, parent: SlateAncestor, editor: IDomEditor): VNode {
if (node.type && node.text) {
throw new Error(`
no node can not have both 'type' and 'text' prop!
一个节点不能同时拥有 type 和 text 两个属性!
${JSON.stringify(node)}
`)
}
let vnode: VNode
if (Element.isElement(node)) {
// element
vnode = renderElement(node as Element, editor)
} else {
// text
vnode = renderText(node as Text, parent, editor)
}
return vnode
}
renderElment
Упрощенный код renderElement выглядит следующим образом.
// renderElement 简化代码
function renderElement(elemNode: SlateElement, editor: IDomEditor): VNode {
// 根据 type 生成 vnode 的函数
const { type, children = [] } = elemNode
let genVnodeFn = getRenderFn(type)
const childrenVnode = isVoid
? null // void 节点 render elem 时不传入 children
: children.map((child: Node, index: number) => {
return node2Vnode(child, index, elemNode, editor)
})
// 创建 vnode
let vnode = genVnodeFn(elemNode, childrenVnode, editor)
return vnode
}
В коде genVnodeFn получается через node.type, то есть функцию генерации vnode из текущего узла.
Код функции следующий, то есть он будет использоваться по умолчанию<div>или<span>для визуализации узла.
/**
* 默认的 render elem
* @param elemNode elem
* @param editor editor
* @param children children vnode
* @returns vnode
*/
function defaultRender(
elemNode: SlateElement,
children: VNode[] | null,
editor: IDomEditor
): VNode {
const Tag = editor.isInline(elemNode) ? 'span' : 'div'
const vnode = <Tag>{children}</Tag>
return vnode
}
/**
* 根据 elemNode.type 获取 renderElement 函数
* @param type elemNode.type
*/
function getRenderFn(type: string): RenderElemFnType {
const fn = RENDER_ELEM_CONF[type]
return fn || defaultRender
}
Конечно, есть дефолт, естьпользовательское расширение (важно). Например, самый простой тип'paragraph'можно расширить следующим образом:
function renderParagraph(
elemNode: SlateElement,
children: VNode[] | null,
editor: IDomEditor
): VNode {
const vnode = <p>{children}</p>
return vnode
}
export const renderParagraphConf = {
type: 'paragraph',
renderFn: renderParagraph,
}
Наконец, в соответствии с genVnodeFn может быть сгенерирован vnode текущего узла. Независимо от того, является ли дочерний узел element или text , он передается вышеприведенномуnode2Vnodeфункция унифицированной обработки.
renderText
Упрощенный код renderText выглядит следующим образом.
// renderText 简化代码
function renderText(textNode: SlateText, parent: Ancestor, editor: IDomEditor): VNode {
// 生成 leaves vnode - 每个 text 节点都可拆分为若干个 leaf 节点
const leavesVnode = leaves.map((leafNode, index) => {
// 文字和样式
const isLast = index === leaves.length - 1
let strVnode = genTextVnode(leafNode, isLast, textNode, parent, editor)
strVnode = addTextVnodeStyle(leafNode, strVnode)
// 生成每一个 leaf 节点
return <span data-slate-leaf>{strVnode}</span>
})
// 生成 text vnode
const textId = `w-e-text-${key.id}`
const vnode = (
<span data-slate-node="text" id={textId} key={key.id}>
{leavesVnode /* 一个 text 可能包含多个 leaf */}
</span>
)
return vnode
}
Наиболее важным из них является рендеринг стилей текста, а именноaddTextVnodeStyleфункция, которая такжеМасштабируемостьиз. Например
function addTextStyle(node: SlateText | SlateElement, vnode: VNode): VNode {
const { bold, italic, underline, code, through } = node
let styleVnode: VNode = vnode
if (bold) {
styleVnode = <strong>{styleVnode}</strong>
}
if (code) {
styleVnode = <code>{styleVnode}</code>
}
if (italic) {
styleVnode = <em>{styleVnode}</em>
}
if (underline) {
styleVnode = <u>{styleVnode}</u>
}
if (through) {
styleVnode = <s>{styleVnode}</s>
}
return styleVnode
}
Короче говоря, будь то элемент рендеринга или текст, он поддерживает расширение через модуль.
Это не только гарантирует поддержку множества форматов и функций, но и распределяет логику кода по каждому модулю для хорошей изоляции.
меню поддерживает различные меню
menu должно быть абстракцией, на основе которой генерируются различные типы меню:
- традиционная панель инструментов
- Плавающее меню после выбора текста или элемента
- Правильные меню
- Также поддерживаются различные типы: кнопка выбора и т. д.
Текущее определение меню выглядит следующим образом:
interface IOption {
value: string
text: string
selected?: boolean
styleForRenderMenuList?: { [key: string]: string } // 渲染菜单 list 时的样式
}
export interface IMenuItem {
title: string
iconSvg: string
tag: string // 'button' / 'select'
showDropPanel?: boolean // 点击 'button' 显示 dropPanel
options?: IOption[] // select -> options
width?: number // 设置 button 宽度
getValue: (editor: IDomEditor) => string | boolean
isDisabled: (editor: IDomEditor) => boolean
exec?: (editor: IDomEditor, value: string | boolean) => void // button click 或 select change 时触发
getPanelContentElem?: (editor: IDomEditor) => Dom7Array // showDropPanel 情况下,获取 content elem
// 后续还可能继续扩展其他能力,但尽量保证简洁、易读
}
Благодаря приведенному выше определению могут поддерживаться следующие формы меню. Другие все еще находятся в разработке.
API редактора и плагины
Ссылаться наslate-reactИсходный код, определяющий некоторые глобальные команды, полезен при рендеринге DOM.
Инкапсулирует плагин планшета для добавления/перезаписи API
Этот плагин поставляется с ядром. Вы также можете продолжать расширять другие плагины, то есть в модулях.
конструкция модуля
core не имеет базовых функций, все функции являются модулями для расширения реализации. модуль может быть дополнен:
- menu
- formats
- renderElement
- addTextStyle
- плагин (т.е. сланцевый плагин)
Наконец, каждый модуль может выводить такой формат данных для регистрации в ядре.
interface IRenderElemConf {
type: string
renderFn: RenderElemFnType
}
interface IMenuConf {
key: string
factory: () => IMenuItem
config?: { [key: string]: any }
}
// module 数据格式
export interface IModuleConf {
addTextStyle?: TextStyleFnType
renderElems?: Array<IRenderElemConf>
menus?: Array<IMenuConf>
editorPlugin?: <T extends Editor>(editor: T) => T
// 后续可能会对格式做一些调整,但整体范围不会大变
}
расширяет addTextStyle
Как упоминалось выше, это стилизация текстового узла. Код для выделения полужирным шрифтом, курсивом, подчеркиванием и т. д. был написан выше.
Вот код для цвета шрифта и цвета фона:
/**
* 文字样式 - 字体颜色/背景色
* @param node slate node
* @param vnode vnode
* @returns vnode
*/
export function addTextStyle(node: SlateText | SlateElement, vnode: VNode): VNode {
const { color, bgColor } = node
let styleVnode: VNode = vnode
if (color) {
addVnodeStyle(styleVnode, { color }) // 给 vnode 添加样式
}
if (bgColor) {
addVnodeStyle(styleVnode, { backgroundColor: bgColor }) // 给 vnode 添加样式
}
return styleVnode
}
PS: Больше проблем здесь вызывает подсветка кода.
расширяет элемент рендеринга
Определите функцию для node.type , входного узла slate , выходного vnode .
// render h1
function renderHeader1(
elemNode: SlateElement,
children: VNode[] | null,
editor: IDomEditor
): VNode {
const vnode = <h1>{children}</h1>
return vnode
}
export const renderHeader1Conf = {
type: 'header1',
renderFn: renderHeader1,
}
Развернуть меню
меню — это абстракция, формат интерфейса которой определен вышеIMenuItem.
button menu
такие как жирный шрифт, подчеркивание и т. д.
Кратко объясните:
-
getValueФункция определяет текущее состояние, например выделение жирным шрифтом или нет. -
isDisabledОпределить, доступно ли текущее меню, например, в блоке кода полужирный шрифт недоступен. -
execТо есть метод, выполняемый при нажатии кнопки меню. Уведомлениеtag = 'button'кнопка типа.
select menu
При установке заголовка необходимо определить параметры.
showDropPanel
Если вы устанавливаете цвет, вам нужен dropPanel. Затем меню требуется getPanelContentElem для определения DOM содержимого dropPanel.
В настоящее время существуют только эти три типа, а другие типы могут быть расширены в будущем.
menu config
Некоторые меню требуют определенной настройки, такой как цвет, шрифт, высота строки и многое другое.
В V4 вся конфигурация централизована в глобальном файле editor.config. В новой версии будут сплиты:
- Определить конфигурацию по умолчанию при расширении меню, а не глобально
- Конфигурация хранится единообразно в
editor.getConfig().menuConf[key], поддержка модификации пользователя
// module 中扩展 menu 时,定义默认配置
// menu 代码中,可以通过 editor.getConfig().menuConf[key] 拿到
// 用户可以通过 editor.getConfig().menuConf[key] = {...} 修改某个 menu 的配置
{
key: 'color',
factory() {
return new ColorMenu('color', '文字颜色', '<svg>...</svg>')
},
config: {
colors: ['#000000', '#262626', '#595959', '#8c8c8c', '#bfbfbf', '#d9d9d9'],
},
}
плагин расширения
Многие функции требуют использования дополнительных функций для перезаписи API редактора, например:
- заголовок - когда новая строка заканчивается в конце, вставляется следующая строка
<p>, вместо заголовка по умолчанию - список - два последовательных перевода строки в конце, выпрыгнуть из списка, вставить
<p> - code-block — два последовательных разрыва строки в конце, выход из code-block, вставка
<p> - таблица - разрывы строк внутри ячеек, если две таблицы находятся рядом друг с другом, между ними вставляется пустая строка. Ждать
- Вставить — обрабатывает вставленный текст перед вставкой текста.
- Есть еще много... (чем сложнее функция, тем больше требуется благословления плагина)
Ниже приведен плагин в модуле заголовка, который относительно прост.
import { Editor, Transforms } from 'slate'
function withHeader<T extends Editor>(editor: T): T {
const { insertBreak } = editor
const newEditor = editor
// 重写 insertBreak - header 末尾回车时要插入 paragraph
newEditor.insertBreak = () => {
const [match] = Editor.nodes(newEditor, {
match: n => {
const { type = '' } = n
return type.startsWith('header') // 匹配 node.type 是 header 开头的 node
},
universal: true,
})
if (!match) {
// 未匹配到
insertBreak()
return
}
// 插入一个空 p
const p = { type: 'paragraph', children: [{ text: '' }] }
Transforms.insertNodes(newEditor, p, { mode: 'highest' })
}
// 返回 editor ,重要!
return newEditor
}
план дальнейших действий
Еще многое предстоит сделать и интегрировать в дизайн. Например:
- Другие основные функции
- вставить
- загрузить
- Плавающее меню, всплывающая подсказка, контекстное меню, модальное
- Разобраться с пользовательской конфигурацией
- Уход за API
- модульное тестирование / тестирование e2e
- CI/CD
- i18n
- Написание документации по разработке, пользовательской документации
- ...
В последующем потребуется около 3 недель, чтобы завершить основные функции, доработать технический план и форму расширения.
Остальные будут добавляться постепенно.
Кроме того, поскольку предстоит провести много испытаний, я планирую поставить текущийgithub issues3000+ проблем, накопившихся в новой версии, снова тестируются. Эти проблемы являются накопленным богатством.
Редактор форматированного текста — это признанная воронка, поэтому я сначала наступлю на эти 3000+ воронок.