Перейти к содержимому

Скины ​

Скин — это один внешний вид оболочки редактора: панель инструментов, кнопки и группы в ней, bubble-меню, выпадающие меню, диалоги, рамка вокруг содержимого и оформление выделения изображения. Выбирает его prop skin:

vue
<Tiptapify skin="pastel" />

Скин состоит из двух частей: токенов (цвета и размеры в виде CSS-переменных) и формы (структурные варианты, которые токены выразить не могут). Два встроенных скина: default — классический вид, приподнятая панель инструментов с приподнятыми группами кнопок, и pastel — спокойный вид. Собственные скины полноправны: registerSkin() и встроенные в код объекты скинов обрабатываются тем же механизмом, а встроенные скины — это обычные зарегистрированные скины, а не особые случаи.

Терминология: скин и тема Vuetify ​

  • skin (скин) — пользовательский термин для одного внешнего вида оболочки редактора: токены + форма. Prop называется skin, типы — TiptapifySkin*, реестр — registerSkin(), корневой класс — tiptapify-skin-{name}.
  • theme (тема) — зарезервировано за светлой/тёмной темами Vuetify. Компоненты Tiptapify получают свои props theme из имени текущей темы Vuetify; система скинов их не трогает.

Они сочетаются: большинство цветов встроенных токенов ссылаются на переменные темы Vuetify (rgb(var(--v-theme-surface)), …), поэтому скины автоматически следуют светлой/тёмной теме. Фиксированно тёмные ячейки в колонке classic — маркер изменения размера (rgba(0, 0, 0, .5/.8)), обычный контур изображения, тень bubble-меню и тени elevation у меню и диалогов — сделаны намеренно: классический вид рисует нейтральную тёмную оболочку в обеих темах, а замеренная эталонная версия это фиксирует. Пользовательский скин может свободно ссылаться на переменные темы — см. таблицу токенов.

Концепция ​

Токены ​

Токен — это CSS custom property с именем --tf-*. Ключ каталога — это camelCase суффикса CSS: toolbarBg → --tf-toolbar-bg, dialogTitleSize → --tf-dialog-title-size. Значения — обычные строки: цвета, полные сокращённые записи границ, тени, отступы, радиусы.

Каждое правило скина в библиотеке существует один раз и читает свой токен с запасным значением, равным классическому (базовому):

css
.tiptapify-toolbar-group {
  border-radius: var(--tf-group-radius, 4px);
}

Разрешённый скин выводит в виде переменных только свои переопределения; базовые значения несут CSS-фолбэки. Переменные попадают в корневой элемент редактора — а так как меню и диалоги телепортируются из него, то и в обёртку SkinScope каждого меню и в саму карточку диалога, поэтому токены действуют на каждой поверхности.

Форма ​

Шесть флагов определяют, какие наборы правил применяются; токены их окрашивают:

флагзначениячто переключает
toolbarraised · flatVToolbar с elevation и без внутренней тонкой линии или плоская тонированная панель
groupsbox · flatсекции панели инструментов как приподнятые блоки VBtnGroup (разметка 0.3.2: соединённые кнопки, блок окрашивают токены группы) или обычные flex-ряды div
buttonsvuetify · quietнативные кнопки Vuetify или «призрачные» кнопки с нейтральным состоянием нажатия
bubblecard · flatоставлен для совместимости: bubble-меню — это один ряд кнопок-иконок, окрашенный токенами, в обеих формах (их кнопки следуют buttons)
dialogclassic · softразделитель под заголовком и приподнятая кнопка закрытия или мягкая карточка и текстовая кнопка закрытия
menuvuetify · softвыпадающие меню: список Vuetify или светлая карточка с отступами

Разрешённая форма кодируется в корневом элементе редактора шестью классами .tiptapify-shape-{flag}-{value} и доступна как shape: ComputedRef<ResolvedShape> в useTiptapifyConfig() для пользовательских компонентов панели инструментов.

Extends ​

Скин разрешается поверх классической базы, затем по цепочке extends от корня, затем по собственным членам:

classic (base tokens + classic shape) → extends chain → own colors/metrics/flags

Отсутствующие звенья разрешаются в накопленную цепочку (с предупреждением в dev-режиме); цикл разрешается в classic (также с предупреждением в dev-режиме). Зарегистрированный скин указывается по имени; инлайн-объект скина может делать extends от зарегистрированного имени и никогда не попадает в глобальный реестр — пользовательские скины никогда не «утекают» между редакторами. Неизвестные ключи токенов игнорируются с предупреждением в dev-режиме.

Живые демо всех трёх форм есть на странице примеров скинов.

Каталог токенов ​

Таблицы ниже — это контракт v1: ключи каталога (TiptapifySkinColors / TiptapifySkinMetrics) — это имена, которые можно задавать. Ячейки classic — это CSS-фолбэки: значение, которое рисуют базовые правила, когда скин не переопределяет токен.

Панель инструментов / полоса ​

токенCSS-переменнаяclassicpastel
toolbarBg--tf-toolbar-bgrgb(var(--v-theme-surface))rgba(var(--v-theme-on-surface), 0.03)
toolbarBorderBottom--tf-toolbar-border-bottomnone1px solid rgba(var(--v-border-color), var(--v-border-opacity))
toolbarFg--tf-toolbar-fginheritrgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))

Элементы / группы / разделители ​

токенCSS-переменнаяclassicpastel
itemsPadding--tf-items-padding12px 16px6px 8px
itemsGap--tf-items-gapnormal ¹6px
itemsRowGap--tf-items-row-gap5px4px
groupGap--tf-group-gap0px2px
groupHeight--tf-group-height32pxauto
groupBg--tf-group-bgrgb(var(--v-theme-surface))transparent
groupRadius--tf-group-radius4px0px
groupShadow--tf-group-shadowVuetify elevation 2 ²none
dividerWidth--tf-divider-width0px (распорка: margin 0 4px)1px
dividerHeight--tf-divider-height0px20px
dividerColor--tf-divider-colortransparentrgba(var(--v-border-color), var(--v-border-opacity))

¹ Замеренное значение classic — normal, нативное flex-значение Vuetify, которое и несёт CSS-фолбэк. Базовое значение каталога 0px — это равнозначная явная длина для переопределений скина.

² Ячейки «Vuetify elevation n» носят описательный характер: точная строка из трёх теней — это замеренное значение в зафиксированном эталонном файле (src/skins/__fixtures__/skin-baseline.json), и оно же служит CSS-фолбэком. Для groupShadow это elevation 2 (0 3px 1px -2px …, 0 2px 2px 0 …, 0 1px 5px 0 …) — при groups: 'box' секция является VBtnGroup (elevation="4"), и правило токена окрашивает блок этой тройной тенью поверх собственного elevation группы: блок владеет elevation, а соединённые дочерние кнопки его не имеют.

Кнопки ​

Эти токены используются только при buttons: 'quiet' — см. Достижимость.

токенCSS-переменнаязначение quiet (classic: нативный Vuetify)
buttonFg--tf-button-fgrgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity))
buttonHoverBg--tf-button-hover-bgrgba(var(--v-theme-on-surface), 0.07)
buttonHoverFg--tf-button-hover-fgrgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))
buttonActiveBg--tf-button-active-bgrgba(var(--v-theme-on-surface), 0.1)
buttonActiveHoverBg--tf-button-active-hover-bgrgba(var(--v-theme-on-surface), 0.14)
buttonRadius--tf-button-radius6px

Bubble-меню (выделение, пустая строка, изображение) ​

токенCSS-переменнаяclassicpastel
bubbleBg--tf-bubble-bgrgb(var(--v-theme-surface))то же
bubbleBorder--tf-bubble-bordernone1px solid rgba(var(--v-border-color), var(--v-border-opacity))
bubbleShadow--tf-bubble-shadow0 4px 14px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08)0 6px 20px rgba(0, 0, 0, 0.12)
bubbleRadius--tf-bubble-radius6px10px
bubblePadding--tf-bubble-padding4pxто же
bubbleGap--tf-bubble-gap2pxто же
токенCSS-переменнаяclassicpastel
menuListPadding--tf-menu-list-padding8px 0px4px
menuRadius--tf-menu-radius4px8px
menuBorder--tf-menu-bordernone1px solid rgba(var(--v-border-color), var(--v-border-opacity))
menuShadow--tf-menu-shadowVuetify elevation 8 ²0 4px 16px rgba(0, 0, 0, 0.08)
menuItemRadius--tf-menu-item-radius0px6px
menuItemPadding--tf-menu-item-paddingVuetify ³4px 8px
menuItemMinHeight--tf-menu-item-min-heightVuetify ³34px
menuItemActiveBg--tf-menu-item-active-bgVuetify ³rgba(var(--v-theme-on-surface), 0.06)

³ Блок списка (радиус, тень, отступы списка) классического меню сегодня является правилом скина: обёртка токенов SkinScope стоит между собственным дочерним правилом Vuetify и списком, поэтому библиотека восстанавливает эти пиксели как правила скина, читающие токены выше. Пиксели элементов остаются нативными для Vuetify (эталонный файл фиксирует там значения Vuetify по умолчанию): четыре токена элементов не действуют, пока menu: 'vuetify'.

Диалоги ​

токенCSS-переменнаяclassicpastel
dialogShadow--tf-dialog-shadowVCard elevation ²0 20px 48px rgba(0, 0, 0, 0.16), 0 2px 6px rgba(0, 0, 0, 0.06)
dialogRadius--tf-dialog-radius4px (VCard)12px
dialogHeaderPadding--tf-dialog-header-padding8px 16px16px 12px 12px 20px
dialogContentPadding--tf-dialog-content-padding16px 24px 24px4px 20px
dialogActionsPadding--tf-dialog-actions-padding8px16px 20px 20px
dialogTitleSize--tf-dialog-title-size1.25rem1.125rem
dialogTitleWeight--tf-dialog-title-weight400600
dialogFieldRadius--tf-dialog-field-radiusзначение поля по умолчанию (4px)8px (outlined)
dialogActionBtnMinHeight--tf-dialog-action-btn-min-heightVuetify (auto)38px
dialogActionBtnRadius--tf-dialog-action-btn-radiusVuetify (4px)8px
dialogGridGap--tf-dialog-grid-gap24px16px

Рамка (обёртка границы редактора) ​

токенCSS-переменнаяclassicpastel
frameBorder--tf-frame-border1px solid rgba(var(--v-border-color), var(--v-border-opacity)), без верхней границыполная граница
frameBg--tf-frame-bgtransparentrgb(var(--v-theme-surface))
frameShadow--tf-frame-shadownone0 1px 2px rgba(0, 0, 0, 0.05)

При плоской форме панели инструментов рамка также рисует дельту по сторонам (верхняя граница и нижняя граница с 1.6× прозрачности границы). Эта дельта принадлежит форме, а не токену: через токен до неё не добраться.

Изображения и маркеры изменения размера ​

токенCSS-переменнаяclassicpastel
imageRadius--tf-image-radius0.125rem4px
imageBg--tf-image-bgtransparentrgba(var(--v-theme-on-surface), 0.04)
imageOutline--tf-image-outline1px solid rgba(0, 0, 0, 0.25) (выделено)2px solid rgb(var(--v-theme-primary))
imageOutlineHover--tf-image-outline-hovertransparentrgba(var(--v-theme-primary), 0.35)
handleBg--tf-handle-bgrgba(0, 0, 0, 0.5)rgb(var(--v-theme-primary))
handleBgHover--tf-handle-bg-hoverrgba(0, 0, 0, 0.8) (при наведении)rgb(var(--v-theme-primary))
handleBorder--tf-handle-bordernone2px solid rgb(var(--v-theme-surface))
handleShadow--tf-handle-shadownone0 0 0 1px rgba(0, 0, 0, 0.15)
handleRadius--tf-handle-radius2px50%

Панель инструментов изображения следует флагу buttons: при тихой форме она получает тихую поверхность — её отступы 2px/4px читают --tf-bubble-gap / --tf-bubble-padding с фолбэками меню изображения; отдельного токена панели инструментов изображения нет.

Нет в каталоге (территория запасного выхода) ​

Для некоторых размеров, упомянутых в обзоре дизайна, в v1 нет токена каталога: высота панели инструментов, ширина рамки, радиус рамки, ширина кнопки, отступы кнопки, ширина диалога. Таблицы каталога выше — это контракт; для этих пикселей откройте запасной выход и оформляйте стабильные классы напрямую.

Достижимость ​

Контракт, сформулированный один раз и соблюдаемый каждой группой выше:

Токен окрашивает поверхность, только если форма эту поверхность активирует; нативный внешний вид Vuetify оформляется цветами темы Vuetify.

По группам:

  • Панель инструментов, рамка, оформление изображений и маркеров — активны в любой форме; токены их окрашивают, ячейки classic — это нативный вид, к которому токены возвращаются как к фолбэку.
  • Блок групп (groupBg, groupRadius, groupHeight, groupShadow) — при groups: 'box' секция является VBtnGroup (структура 0.3.2: кнопки соединены, блок несёт elevation); правила блока окрашивают его токенами группы, а --tf-group-height удерживает контракт в 32px с помощью !important (правило плотности Vuetify с ним конфликтует, как и в 0.3.2). При groups: 'flat' секции — обычные ряды div (прозрачные, без тени, радиус 0 — база этой формы).
  • Тонкие линии разделителей (dividerWidth/Height/Color) — видимая тонкая линия доступна при groups: 'flat'; в форме box разделитель — это распорка нулевого размера (margin: 0 4px из базового правила).
  • Отступы и промежутки элементов — компактный ряд 6px 8px включается только на плоской полосе (toolbar: 'flat'), промежутки групп — на плоских секциях (groups: 'flat'); приподнятая полоса сохраняет классические метрики.
  • Кнопки (buttonFg, buttonHover*, buttonActive*, buttonRadius) — не действуют при buttons: 'vuetify': нативные кнопки Vuetify сами рисуют свои состояния. Оформляйте их цветами темы Vuetify (primary, surface, …) — классические формы намеренно их наследуют.
  • Метрики элементов меню (menuItemRadius/Padding/MinHeight/ActiveBg) — не действуют при menu: 'vuetify'; пиксели этих элементов принадлежат Vuetify. Блок списка меню (радиус/тень/отступы) окрашивается скином в обеих формах (см. примечание о меню).
  • Мягкое оформление диалога (dialogActionBtn*, dialogFieldRadius, мягкие отступы, отсутствующий разделитель) — рисуется при dialog: 'soft'; при dialog: 'classic' диалог — это VCard с классическими отступами.
  • Оформление bubble-меню — меню выделения, пустой строки и изображения представляют собой один ряд кнопок-иконок в обеих формах, окрашенный токенами bubble; bubble больше не меняет их разметку.

Переопределение, равное базовому значению каталога, не выводится — при не классической форме базу формы рисует CSS-фолбэк, поэтому «восстановление» классического значения (например, возврат buttonActiveBg к rgb(var(--v-theme-primary)) при buttons: 'quiet') молча ничего не делает. Чтобы принудительно задать классическое значение при не классической форме, задайте переопределение, отличающееся от базы каталога: равнозначный литерал вроде rgba(var(--v-theme-primary), 1) рисуется так же и проходит проверку на пропуск.

Токены намеренно не подключены к нативным путям — именно это сохраняет default пиксель-в-пиксель идентичным классическому виду, пока всё проходит через каталог.

Рецепты ​

Расширение pastel ​

Унаследуйте тихую форму и перекрасьте несколько поверхностей. Зарегистрированные имена работают в любом редакторе:

ts
// main.ts — call once, before any editor mounts
import { registerSkin } from 'tiptapify'

registerSkin('mint', {
  extends: 'pastel',
  colors: {
    toolbarBg: 'rgba(0, 128, 0, 0.05)',   // mint tint over the pastel wash
    handleBg: 'rgb(0, 100, 0)',           // deep green handles
    handleBgHover: 'rgb(0, 128, 0)',
    imageOutline: '2px solid rgb(0, 100, 0)',
    imageOutlineHover: 'rgba(0, 100, 0, 0.35)',
  },
})
vue
<Tiptapify skin="mint" />

Форма (flat/quiet/soft) берётся из pastel; нетронутые токены сохраняют значения pastel — тихая заливка кнопок остаётся нейтральной, меняется только то, что вы переопределили. Это скин, показанный вживую на странице примеров.

Расширение default ​

Сохраните классическую структуру, настройте оформление:

ts
registerSkin('paper', {
  extends: 'default',
  colors: { toolbarBg: 'rgb(var(--v-theme-background))' },
  metrics: { groupRadius: '8px', itemsPadding: '10px 14px' },
})

extends: 'default' — неявный корень, поэтому ключ можно опустить: { toolbar: 'flat', … } тоже начинается с classic.

Полностью свой скин ​

Без extends и без регистрации: передайте объект в prop skin и свободно задавайте флаги формы и токены. name ставит вашу собственную метку на корневой класс (см. ниже); без него класс — tiptapify-skin-inline.

vue
<script setup lang="ts">
import { defineSkin } from 'tiptapify'

const slate = defineSkin({
  name: 'slate',
  toolbar: 'flat',
  groups: 'flat',
  buttons: 'quiet',
  bubble: 'flat',
  dialog: 'soft',
  menu: 'soft',
  colors: { toolbarBg: 'rgb(30, 41, 59)', toolbarFg: 'rgb(226, 232, 240)' },
  metrics: { buttonRadius: '8px' },
})
</script>

<template>
  <Tiptapify :skin="slate" />
</template>

defineSkin — вспомогательная identity-функция для типизированных const-определений (в реестр она ничего не добавляет). Флаги формы можно задавать по отдельности — каждый пропущенный флаг остаётся классическим.

Запасной выход: корневой класс ​

Корневой элемент каждого редактора несёт tiptapify-skin-{name} (tiptapify-skin-inline для инлайн-объекта без имени). Библиотека никогда не привязывает правила к имени скина — этот класс ваш:

css
/* Style the frame of one skin, dimensions the catalog does not cover */
.tiptapify-skin-mint .tiptapify-frame {
  max-width: 720px;
  margin-inline: auto;
}

Стабильные хуки, на которые можно опираться:

классгде
tiptapify-skin-{name}корневой элемент редактора
tiptapify-shape-{flag}-{value} ×6корневой элемент редактора — разрешённая форма
tiptapify-toolbar · tiptapify-toolbar-items · tiptapify-toolbar-group · menu-dividerпанель инструментов и её секции
tiptapify-quiet-buttonsкорневые элементы панели инструментов и bubble-меню при buttons: 'quiet'
tiptapify-bubble-row · tiptapify-bubble-dividerряд каждого bubble-меню и тонкая линия между его группами
tiptapify-bubble-menu · floating-menu · tiptapify-image-menuряды меню выделения, пустой строки и изображения
tiptapify-btn--activeнажатая кнопка bubble-меню (активная метка, обтекание изображения)
tiptapify-menu · tiptapify-menu--softсодержимое оверлея выпадающего меню, носитель правил мягкого меню
tiptapify-skin-scopediv области токенов внутри телепортированных меню
tiptapify-dialog · tiptapify-dialog--softкарточка диалога и её мягкий носитель
tiptapify-frameобёртка границы редактора

Защита от тем потребителя. Слой скинов рассчитан на то, чтобы выживать в админских темах, которые добавляют отступы и перекрашивают внутренние элементы Vuetify: правила привязаны к классам формы/носителя, используют удвоенные классы (.v-list.v-list, .v-btn.v-btn.v-btn) для специфичности и сохраняют !important только там, где это оказалось необходимым в случае админской темы в базовом коммите (набор защиты menu.css). Соглашения, которые сохраняют работоспособность ваших собственных переопределений на том же поле боя:

  • ограничивайте свой CSS областью .tiptapify-skin-{name} (или класса формы) — никогда не пишите правила на голые внутренние элементы Vuetify;
  • для размеров из каталога предпочитайте токены (--tf-*), для остального — классы;
  • повторяйте удвоение при переопределении компонентов Vuetify (одиночный .v-list-item проигрывает правилам скина).

Визуальный контракт (заметка для контрибьюторов) ​

Вычисляемые стили встроенных скинов зафиксированы эталонным файлом src/skins/__fixtures__/skin-baseline.json (13 секций × светлая/тёмная): CSS-слой готов, когда замеренная матрица с ним совпадает. Примечания к чтению файла:

  • Размеры при наведении/нажатии нельзя принудительно вызвать со страницы, поэтому при записи читается текст объявления первого найденного в CSSOM правила для каждого hover-селектора и разрешается через probe-элемент внутри корня скина (поле resolved) — это контракт победившего правила, а не живой снимок :hover.
  • _meta.selectors.menu хранит текст селектора до появления SkinScope. Файл заморожен (нормативные значения), поэтому поле устарело намеренно; пакет записи фиксирует текущий селектор.
  • Файл несёт свои санкционированные изменения в _meta.notes: восстановление 2026-10-18 разметки VBtnGroup при groups: 'box' заново измерило поля дочерних кнопок classic groupBtn/buttonsActive (соединённые углы, блок владеет elevation). Поля pastel не тронуты — намеренное визуальное изменение обновляет только затронутые ячейки classic, записывает заметку и сопровождается строкой в CHANGELOG.

См. также ​

Выпущено под лицензией MIT.