Создайте редактор форматированного текста на основе slate.js (не полагаясь на React)

JavaScript

Предыстория и текущая ситуация

wangEditorРазрабатывается новая версия, стремящаяся стать более стабильным и лаконичным текстовым редактором с открытым исходным кодом.

  • устарелdocument.execCommand, отдельный вид и модель
  • Поддержка совместного редактирования в будущем
  • Полностью учитывайте расширяемость и механизм подключаемых модулей, чтобы упростить расширение более сложных функций.
  • Технические поиски программистов всегда приводили к тому, что они бросали себя~

Я проделал некоторую работу раньше

  • Разработайте демонстрацию с 0, исследуйте slate quill proseMirror в соответствии с практическими задачами, она была записанаэта статья
  • Попробуйте использовать сланец (не полагаясь на React), чтобы сделатьdemo
  • Обязательно используйте планшет в качестве ядра (не полагаясь на React) и начните пытаться разработать новую версию (исходный код WIP закрыт)

Несмотря на то, что некоторые функции были реализованы, все еще находится в процессе проектирования технического решения, а API и структура кода будут продолжать корректироваться.

image.png

Почему стоит использовать slate.js?

Сначала я тоже хотел разрабатывать собственное ядро ​​с 0, но потом потихоньку передумал. Особенно при взгляде на груду кода proseMirror.

image.png

Почему не саморазработанное ядро?

  • Наша основная цель — сделать стабильный, простой в использовании и хорошо масштабируемый продукт с открытым исходным кодом, а не заниматься гиковским духом и строить колеса.
  • Стоимость самостоятельной разработки очень высока, требует много времени и имеет много ошибок. И если я захочу это сделать, это будет в основном на мне - и моя личная энергия не гарантирована, я сейчас занят, может быть, я буду занят через два дня
  • PS: Если у вас есть глубокие технические интересы, вы можете начать с интерпретации исходного кода и написания демонстраций, а также посмотреть на свою личную энергию и способности.

По сравнению с другими продуктами с открытым исходным кодом

перо не подходит

  • Это уже зрелый редактор, ядро, UI, плагины и т. д. все сформировано, экология тоже очень большая, она готова к использованию, и мы мало что можем сделать.
  • Последняя версия quill была выпущена 2 года назад.
  • дельта пера требует больших затрат на обучение

прозаЗеркало не подходит

  • Это не оптимизированное ядро, оно включает в себя много контента, большой объем кода и большой размер пакета.
  • Дизайн относительно абстрактен, код сложен, и его нелегко интерпретировать.

сланец больше подходит

  • Дизайн прост и понятен, объем кода небольшой, размер пакета небольшой (всего 60кб gzip 17кб), а исходный код легко читается
  • На основе механизма плагинов все можно расширить
  • По умолчанию используется React, но вы можете настроить уровень представления с помощью вторичной разработки (уже сделано).

То, что вы основаны на slate.js, не означает, что это легко

  • Перепишите View, не полагаясь на React
  • Разработайте различные рабочие функции, такие как панель инструментов, всплывающая подсказка и т. д.
  • Расширяемость дизайна, полностью подключаемый модуль
  • Разрабатывайте различные функции редактора форматированного текста, особенно сложные функции, такие как таблицы и подсветка кода.

Это похоже на то, что я проектирую полноценный автомобиль на базе стандартного двигателя, что непросто.

общий дизайн

Разделить несколько пакетов на основе lerna

image.png

основные зависимости

  • 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 и модель (или выбор) в синхронизации
  • Определите механизм расширения (у него нет реальной функции) и реализуйте определенные функции, расширив модуль.

image.png

Перехват пользовательского ввода с помощью beforeinput

beforeinput — это относительно новое событие DOM, которое вообще не поддерживалось в прошлом году. В настоящее время кажется, что основные браузеры поддерживают его, особенно после выпуска FireFox 87, см.caniuse.
Для браузеров, которые не поддерживаются, вы можете использовать KeyDown / KeyPress для совместимости, хотя это будет иметь некоторое влияние, но хорошо учитывать многое. (PS: новая версия больше не поддерживает IE11)

image.png

Прослушайте событие 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мероприятие. Таким образом, они могут быть синхронизированы друг с другом.

image.png

синхронизированное представление 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
  
  // 后续还可能继续扩展其他能力,但尽量保证简洁、易读
}

Благодаря приведенному выше определению могут поддерживаться следующие формы меню. Другие все еще находятся в разработке.

image.png

API редактора и плагины

Ссылаться наslate-reactИсходный код, определяющий некоторые глобальные команды, полезен при рендеринге DOM.

image.png

Инкапсулирует плагин планшета для добавления/перезаписи API

image.png

Этот плагин поставляется с ядром. Вы также можете продолжать расширять другие плагины, то есть в модулях.

конструкция модуля

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

такие как жирный шрифт, подчеркивание и т. д.

image.png

Кратко объясните:

  • getValueФункция определяет текущее состояние, например выделение жирным шрифтом или нет.
  • isDisabledОпределить, доступно ли текущее меню, например, в блоке кода полужирный шрифт недоступен.
  • execТо есть метод, выполняемый при нажатии кнопки меню. Уведомлениеtag = 'button'кнопка типа.

select menu

При установке заголовка необходимо определить параметры.

image.png

showDropPanel

Если вы устанавливаете цвет, вам нужен dropPanel. Затем меню требуется getPanelContentElem для определения DOM содержимого dropPanel.

image.png

В настоящее время существуют только эти три типа, а другие типы могут быть расширены в будущем.

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+ воронок.