Пользовательские wings ​

Пользовательская wing — не просто кнопка панели. Это декларативное расширение, объединяющее сохранённую структуру документа, команды, преобразование HTML и Markdown, правила импорта и поведение представления. Registry проверяет его ещё до появления редактора, не допуская неверные структуры в документ.

Начинайте с самого узкого factory ​

Для большинства форматирования не нужна полная декларация. Используйте simpleMark() для inline mark без значения, valueMark() для mark с ограниченным набором значений, boxObject() для блока без потомков и listFamily() для списка.

ts
import { createNabiWith, simpleMark, wings } from 'nabi-note'

const exStrong = simpleMark({
  w: 'exStrong',
  toHtml: (_node, children, ctx) => ctx.element('strong', children()),
})

const { nabi, registry } = createNabiWith(wings().allBasic().use(exStrong))

Реализация разных видов wing ​

У каждого примера ниже своя сохраняемая форма. Сначала зарегистрируйте один, затем проверьте getJson() и getHtml(). Добавляйте команды и кнопки только после того, как структура заработает.

1. Inline mark без значения: акцент ​

Когда возможность лишь оборачивает текст, используйте simpleMark(). В документе сохраняется exStrong, а HTML выводится как <strong>.

ts
import { simpleMark } from 'nabi-note'

export const exStrong = simpleMark({
  w: 'exStrong',
  clearable: true,
  toHtml: (_node, children, ctx) => ctx.element('strong', children()),
  styles: '.nabi-content strong { font-weight: 700; }',
})

При clearable: true команда очистки форматирования удаляет и этот mark. До добавления кнопки применяйте его через nabi.applyCommand() или другую пользовательскую команду. Один селектор .nabi-content strong оформляет редактор и опубликованный текст.

2. Inline mark со значением: тон состояния ​

Для цвета, размера или состояния из разрешённого набора используйте valueMark(). Значение хранится в a.v; значения вне списка удаляются при repair().

ts
import { valueMark } from 'nabi-note'

export const exTone = valueMark({
  w: 'exTone',
  key: 'v',
  values: ['quiet', 'loud'],
  clearable: true,
  toHtml: (node, children, ctx) =>
    ctx.element('span', children(), { 'data-ex-tone': String(node.a?.v ?? '') }),
  styles: `
    .nabi-content [data-ex-tone="quiet"] { opacity: .65; }
    .nabi-content [data-ex-tone="loud"] { color: var(--nabi-accent); font-weight: 700; }
  `,
})

Сохранённая форма: { "w": "exTone", "a": { "v": "loud" }, "ch": ["Important"] }. CSS обращается к сохранённому значению и меняет также опубликованный вид. Не удаляйте значения из существующего списка бездумно: прежние документы могут потерять их при чтении.

3. Блок без потомков: разделитель ​

Для самостоятельного объекта без потомков — изображения, видео или разделителя — используйте boxObject().

ts
import { boxObject } from 'nabi-note'

export const exDivider = boxObject({
  w: 'exDivider',
  toHtml: (_node, _children, ctx) => ctx.element('hr', ''),
  styles: '.nabi-content hr { border-color: var(--nabi-line); }',
})

Для объекта со значениями вроде URL или ширины объявите проверку в attrs, а обязательные значения поместите в requires. Непроверяемое значение отклоняйте через null, а не молча подставляйте значение по умолчанию.

4. Блок с несколькими абзацами: выноска ​

Для блока с содержимым документа объявите container. holds: 'blocks' разрешает абзацы, списки и объектные блоки.

ts
import type { Wing } from 'nabi-note'

export const exCallout: Wing = {
  w: 'exCallout',
  place: 'container',
  holds: 'blocks',
  isolating: true,
  scopedCommands: ['insertImage', 'insertTable', 'toggleBulletList', 'toggleQuote'],
  toHtml: (_node, children, ctx) =>
    ctx.element('aside', children(), { class: 'ex-callout' }),
  styles: `
    .nabi-content .ex-callout {
      border-inline-start: 4px solid var(--nabi-accent);
      background: var(--nabi-soft);
      padding: 1rem;
    }
  `,
}

Одна декларация ещё не умеет оборачивать выбранные абзацы. Перед публикацией возможности в UI добавьте чистую команду в commands и вызывающую её button.

isolating: true допускает несколько абзацев и не позволяет удалению пересечь границу врезки и объединить её с соседним содержимым. Команды из scopedCommands работают с документом внутри врезки, поэтому вставка изображений и таблиц, преобразование списков и цитат остаются внутри неё.

5. Связанная пара списка и элемента ​

Если список и его элемент должны всегда встречаться вместе, используйте listFamily(). Следующий пример — минимальный пользовательский список, создающий <ul> и <li>.

ts
import { listFamily } from 'nabi-note'

export const exList = listFamily({
  w: 'exList',
  item: 'exListItem',
  toHtml: (_node, children, ctx) => ctx.element('ul', children(), { class: 'ex-list' }),
  itemHtml: (_node, children, ctx) => ctx.element('li', children()),
  styles: '.nabi-content .ex-list { border-inline-start: 2px solid var(--nabi-line); }',
})

listFamily() исправляет блок внутри списка, оборачивая его в элемент. Для значения элемента, например отметки выполнения, добавьте itemDecl и repairItem.

Регистрация в одном упорядоченном наборе ​

На сервере и в браузере используйте одинаковые декларации в одном порядке.

ts
const selected = wings()
  .allBasic()
  .use(exStrong)
  .use(exTone)
  .use(exDivider)
  .use(exCallout)
  .use(exList)

const { nabi, registry } = createNabiWith(selected, { locale: 'ru' })

Имена и структура документа ​

Имена, попадающие в документ, должны соответствовать ex[A-Z0-9].... Имя вроде exCallout не позволит будущей официальной wing изменить смысл сохранённого содержимого.

place задаёт форму хранения: mark оборачивает inline-содержимое, void — блок без потомков, container содержит потомков, attr меняет свойства абзаца, а tool не создаёт узел документа. Для container нужны holds: 'blocks' | 'inline' и toHtml().

ts
const exNote = {
  w: 'exNote',
  place: 'container',
  holds: 'blocks',
  toHtml: (_node, children, ctx) => ctx.element('aside', children()),
} as const

attrs, boolAttrs, allows, requiresAnyOf и parts задают структурные ограничения. Для каждого объявленного parts также нужен соответствующий partHtml. Ограничивайте значения выбирающей wing через attrKey и attrValues.

Все параметры декларации ​

Объявляйте только необходимые поля: factory уже заполняет часть из них.

ОбластьПараметрыНазначение
Основаw, place, basic, stylesИмя, вид структуры, участие в базовом каталоге, CSS по умолчанию
Структураholds, singleParagraph, isolating, attrs, boolAttrsТип дочерних узлов, поведение Enter, защита границ редактирования, допустимые атрибуты, логические атрибуты
Структураparts, allows, noAlign, requiresAnyOfВнутренние части, разрешённые потомки, запрет выравнивания, зависимость wing
ЗначенияattrKey, attrValues, currentValueКлюч и список значения, определение текущего значения
Команды и вводcommands, scopedCommands, onKey, escapeKeys, doubleKeys, inputRulesКоманды, команды внутри вложенного документа, обработка клавиш, Escape/двойное нажатие, автопреобразование
Поведение поверхностиattachDOM-поведение и очистка поверхности
ПреобразованиеtoHtml, partHtml, toMd, partMdВывод HTML и Markdown
Импорт и исправлениеclaim, ioFilter, repair, partRepairИмпорт HTML, файлы, проверка и исправление JSON
UIbutton, buttons, contextЭлементы панели и контекстного UI
Очистка форматированияclearableУдаляется ли wing при очистке форматирования

w и place обязательны всегда. Создающим узлы wings mark, void и container также нужен toHtml(). Контейнеру нужен holds, а каждой объявленной части — соответствующий partHtml.

Держите HTML, Markdown и JSON вместе ​

toHtml() рендерит сохранённый узел в HTML, а toMd() экспортирует Markdown. Без Markdown builder созданный HTML сохраняется, чтобы не потерять данные. При импорте claim() должен распознавать только ваш элемент и проверенные атрибуты.

repair() запускается при загрузке JSON и после команд. Для неверного атрибута верните исправленный узел, а для неудержимого узла — null. Стройте HTML через ctx.element(), ctx.escape() и ctx.url(); не склеивайте теги, атрибуты или URL в обход этих проверок.

Отделяйте команды от поведения представления ​

Команда — чистая функция документа и выделения, возвращающая следующий документ и выделение внутри него. Она не читает и не меняет DOM и возвращает null, если корректное изменение невозможно. Называйте команды в lower camel case с глагола, например insertNote.

DOM-поведение, например перетаскивание выделения таблицы, помещайте в attach(host). Сразу регистрируйте очистку каждого listener и изменённого атрибута через host.onDispose(), чтобы она выполнилась даже при ошибке настройки. Не меняйте DOM композируемого текста и сопоставление выделения поверхности.

Панель и контекстные элементы объявляйте через button, buttons и context. Дублирование правил их команд в UI приложения может рассинхронизировать UI и модель документа.

Выполнение команд внутри блока ​

Объявляйте scopedCommands у контейнеров с holds: 'blocks' и у parts, содержащих блоки. Если оба конца выделения находятся в одной области, команда получает её дочерний документ и относительные пути выделения, а результат затем переносится во внешний документ. Выделения, пересекающие границы области, отклоняются; вне области команда сохраняет исходное поведение. Плавающие области и ячейки таблиц также используют этот механизм для ограничения вставки, преобразований и загрузки.

ts
const exMediaBox: Wing = {
  w: 'exMediaBox',
  place: 'container',
  holds: 'blocks',
  isolating: true,
  scopedCommands: ['insertImage', 'commitUpload', 'toggleCode'],
  toHtml: (_node, children, ctx) =>
    ctx.element('aside', children(), { class: 'ex-media-box' }),
}

Список допускает только команды крыльев. Незарегистрированные команды пропускаются, поэтому регистрировать вместе с ним крылья изображений или загрузки не обязательно. Выбирайте команды, работающие только с переданным внутренним документом и относительными путями. Не включайте команды, которым нужны абсолютные пути полного документа или которые заменяют его целиком. Команды ядра также недопустимы.

singleParagraph ограничивает содержимое одним абзацем, а isolating сохраняет несколько абзацев, не позволяя Backspace/Delete пересекать границу редактирования. У содержащих блоки parts также можно объявить isolating: true.

Обработка клавиш во вложенных блоках ​

Поле KeyIntent.shift, получаемое onKey, позволяет различать Enter и Shift+Enter. Если внутреннее крыло возвращает null в самостоятельной области редактирования, обработка последовательно передаётся родительским крыльям до этой области. Поэтому списки и код в ячейках сначала обрабатывают отступы, а необработанный Tab может переходить между ячейками. Одно крыло не вызывается дважды для одного нажатия.

CSS-стили ​

Обязательный базовый CSS wing помещайте в styles. Стили встроенных wings уже входят в nabi-note/nabi.css. В браузере для стилей выбранного registry можно использовать collectSheets() и injectSheets(); при SSR подключайте CSS-файл.

В редакторе и публикации используйте одинаковые классы и data-атрибуты, но не меняйте структуру [data-key], display и white-space редактора. CSS должен менять только вид, а не сопоставление каретки.

ts
const exCallout = {
  w: 'exCallout',
  place: 'container',
  holds: 'blocks',
  toHtml: (_node, children, ctx) =>
    ctx.element('aside', children(), { class: 'ex-callout' }),
  styles: `
    .nabi-content .ex-callout {
      padding: 1rem;
      border-inline-start: 4px solid var(--nabi-accent);
      background: var(--nabi-soft);
      border-radius: var(--nabi-radius);
    }
  `,
} as const

Обращайтесь только к классам и data-атрибутам, создаваемым toHtml(). Изменения сервиса сужайте, например до .article-body .ex-callout.

Проверяйте весь контракт ​

Убедитесь, что сохранённый JSON повторно загружается с той же структурой и HTML. Проверьте отклонение неверных имён, дублированных команд, отсутствующих builder и неудовлетворённых зависимостей. Покройте неверный импорт HTML и ввод repair(), обработку выделения командами, SSR-вывод и стилизованный опубликованный вид.

Полные типы и аргументы factory смотрите в установленных декларациях и английском справочнике API.