GrCodeEditor

  • code
  • Код
  • API · 16 props
  • ⌨ клавиши

Берут, когда Конфиг в админке.

import { GrCodeEditor } from '@feugene/granularity-code/components/GrCodeEditor'

Обзор

Когда брать

  • Конфиг в админке, который в базе лежит текстом, а руками правится в <textarea> без номеров строк и без шанса заметить пропущенную запятую.
  • Шаблон письма: тот же случай, но текст длиннее и правится чаще.
  • Любое поле формы, где значение — код, а не проза.

Когда взять другое

ЗадачаКомпонент
Показать код без правкиGrCodeBlock
Сравнить две версииGrDiff
Обычный многострочный текстGrTextarea (ядро)
Полноценная IDE с LSP и файловым деревомничего: пакет этого не делает и не будет

Примеры 3 у каждого — живое демо и его код

  • JSON-конфиг с проверкой на месте

    validate возвращает замечания с позициями, а редактор перечисляет их под текстом — каждое сообщение называет строку, потому что «неверный JSON» сам по себе оставляет поиск читателю. Функция может быть асинхронной: синтаксис проверяется на месте, правила — «на сервере», и оба приходят одним контрактом. tab-indents отдаёт Tab под отступ, а редактор сам подсказывает, как выйти.

    Feature flags · productionStored as JSON, applied on save
    {  "new-invoice-editor": { "enabled": true, "rollout": 25 },  "sepa-instant": { "enabled": true, "rollout": 140 },  "dark-pdf-export": { "enabled": false, "rolout": 0 }}

    Нажмите Escape, затем Tab, чтобы выйти из редактора

  • Шаблон письма с плейсхолдерами

    Прозу с плейсхолдерами удобнее читать с переносами. validate возвращает warning на неизвестном {{paylink}}, с номером строки и списком известных полей в сообщении, а placeholder объясняет пустой шаблон.

    Payment reminderSent 3 days after the due date
    Hi {{customer.name}},invoice {{invoice.number}} for {{invoice.amount}} was due on {{invoice.due}}. You can pay it in a minute here: {{paylink}}Thanks,The Acme Billing team
    • Строка 3, столбец 112 — Предупреждение: Line 3: «paylink» is not a known field. Known: customer.name, invoice.number, invoice.amount, invoice.due, pay_link.
  • Опубликованный запрос и его черновик

    readonly оставляет текст выделяемым и копируемым, но запрещает правку — опубликованный запрос копируют чаще, чем меняют. Строка в language — только имя: без пакета грамматики «sql» остаётся одноцветным, и ради просмотра ничего тяжёлого не грузится.

    Overdue by customerPublished by Grace Wu
    select c.name, count(*) as invoices, sum(i.amount) as overduefrom invoices ijoin customers c on c.id = i.customer_idwhere i.status = 'sent' and i.due < current_dategroup by c.nameorder by overdue desc

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
languageGrCodeEditorLanguage | undefined"text"Язык: строка — имя для встроенного разбора (`json`, `text`), объект или массив — грамматика CodeMirror, функция — её ленивая загрузка. По умолчанию `text`.
maxHeightstring | number | undefinedundefinedНаибольшая высота поля: строка CSS или число в пикселях.
wrapboolean | undefinedundefinedПереносить длинные строки вместо горизонтальной прокрутки. Не задан — из `GrConfigProvider`, иначе `false`.
lineNumbersboolean | undefinedundefinedНомера строк в жёлобе. Не задан — из `GrConfigProvider`, иначе `true`.
ariaLabelstring | undefinedundefinedДоступное имя редактора; перекрывает подпись `GrFormField`. Без того и другого — из локали («Code editor»).
size"xs" | "sm" | "md" | "lg" | undefinedundefinedСтупень размера: кегль кода. По умолчанию `md`; берётся из `GrConfigProvider`.
requiredboolean | undefinedfalseОбязательное поле (`aria-required`). Наследуется от `GrFormField`.
modelValuev-modelstring | undefined""Текст в редакторе (`v-model`). По умолчанию пустая строка.
placeholderstring | undefinedundefinedПодсказка в пустом редакторе.
tabIndentsboolean | undefinedundefined`Tab` вставляет отступ вместо перехода фокуса. По умолчанию **выключено**: редактор в форме, из которого нельзя выйти клавиатурой, — ловушка, и до кнопки «Сохранить» пользователь не доберётся. Включён — освободить фокус можно `Esc`, и об этом сказано под полем.
idstring | undefinedundefinedСобственный `id` редактора. Не задан — берётся из `GrFormField`, иначе генерируется.
disabledboolean | undefinedfalseРедактор недоступен: текст не правится, поле приглушено. Наследуется от `GrFormField`.
readonlyboolean | undefinedfalseТекст виден и выделяется, но не правится. Наследуется от `GrFormField`.
invalidboolean | undefinedfalseВизуальное и ARIA-состояние ошибки (`aria-invalid`). Наследуется от `GrFormField`.

События

EventСигнатураОписание
update:modelValue(event: "update:modelValue", value: string): void—
change(event: "change", value: string): void—
focus(event: "focus", event: FocusEvent): void—
blur(event: "blur", event: FocusEvent): void—

Методы

MethodsСигнатураОписание
focus() => Promise<void>Поставить курсор в редактор. Пока CodeMirror не смонтирован, ничего не делает.
blur() => Promise<void>Убрать фокус из редактора.
getView() => unknownЖивой `EditorView` — **escape hatch без контракта**. Всё, чего пакет не покрыл пропами, делается через него; на этот случай гарантий у нас нет.

Типы

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

Импорт
import type { GrCodeEditorLanguage, GrCodeEditorProps, GrCodeEditorEmits } from '@feugene/granularity-code/components/GrCodeEditor'

Файлdist/types/components/GrCodeEditor/config.d.ts

grCodeEditorConfigconst

Компонентных зависимостей нет: редактор рисует себя сам, а CodeMirror — внешняя библиотека, а не компонент дизайн-системы. Общая с блоком карта ролей приходит .ts-модулем, и её классы granum извлекает по графу бандла.

const grCodeEditorConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrCodeEditor">;

Файлdist/types/components/GrCodeEditor/GrCodeEditor.vue.d.ts

GrCodeEditorLanguagetype

Язык редактора. Строка — имя языка для серверной разметки и встроенного разбора. Объект или массив — грамматика CodeMirror. Тик — ленивая её загрузка: редактор рисуется сразу как текст и подсвечивается, когда язык приехал. Тип шире, чем у блока и диффа, и это следствие устройства: до монтирования редактор рисует разметку блока, и ей нужна **строка**, а после монтирования работает CodeMirror, которому нужна **грамматика**.

type GrCodeEditorLanguage = string | object | readonly unknown[] | (() => Promise<unknown>);
/**
 * Редактор кода: JSON и YAML-конфиги в админке, шаблоны писем.
 *
 * Не IDE: без LSP, без множественных курсоров, без файлового дерева. Показ без
 * правки — `GrCodeBlock`, сравнение — `GrDiff`.
 */
export interface GrCodeEditorProps {
GrCodeEditorPropsinterface

Редактор кода: JSON и YAML-конфиги в админке, шаблоны писем. Не IDE: без LSP, без множественных курсоров, без файлового дерева. Показ без правки — GrCodeBlock, сравнение — GrDiff.

interface GrCodeEditorProps {
    /** Текст в редакторе (`v-model`). По умолчанию пустая строка. */
    modelValue?: string;
    /**
     * Язык: строка — имя для встроенного разбора (`json`, `text`), объект или массив — грамматика
     * CodeMirror, функция — её ленивая загрузка. По умолчанию `text`.
     */
    language?: GrCodeEditorLanguage;
    /** Сырые расширения CodeMirror — escape hatch для всего, чего нет в пропах. */
    extensions?: readonly unknown[];
    /** Подсказка в пустом редакторе. */
    placeholder?: string;
    /**
     * Замечания к коду: подчёркивание в месте и метка в жёлобе.
     *
     * Контракт, а не линтер: тем же пропом отдаётся результат `JSON.parse`, схема
     * YAML или ответ серверной валидации — сценарий, которого у готового линтера
     * нет вовсе.
     */
    validate?: (value: string) => GrCodeIssue[] | Promise<GrCodeIssue[]>;
GrCodeEditorEmitsinterface
interface GrCodeEditorEmits {
    /** Текст изменился (`v-model`) — на каждой правке в редакторе. */
    (e: 'update:modelValue', value: string): void;
    /** Правка завершена — на потере фокуса. Для форм, где сохранять на букву не надо. */
    (e: 'change', value: string): void;
    /** Фокус вошёл в редактор. */
    (e: 'focus', event: FocusEvent): void;
    /** Фокус ушёл из редактора; следом приходит `change`. */
    (e: 'blur', event: FocusEvent): void;
}

Файлdist/types/components/GrCodeEditor/editorState.d.ts

MinimalChangeinterface

Минимальная замена: что и на что поменять, чтобы from стал to.

interface MinimalChange {
    from: number;
    to: number;
    insert: string;
}
minimalChangefunction

Наименьший участок, которым один текст превращается в другой. Наивная обёртка заменяет документ целиком ({ from: 0, to: doc.length }) и сбрасывает этим курсор, выделение и историю undo — на **каждом** раунд-трипе v-model, то есть на каждой букве, если родитель кладёт значение в ref и возвращает обратно. Возвращает null, когда менять нечего: пустая транзакция всё равно двигала бы историю.

function minimalChange(current: string, next: string): MinimalChange | null;
GrCodeIssueinterface

Замечание к коду: позиция, важность и текст. Даёт потребитель.

interface GrCodeIssue {
    from: number;
    to: number;
    severity: 'error' | 'warning' | 'info';
    message: string;
}
clampIssuesfunction

Замечания, приведённые к границам документа. Валидатор считает позиции по строке, которую ему дали, а документ к моменту отрисовки мог стать короче: ответ асинхронного validate приходит на текст, которого уже нет. Замечание за границей уронило бы CodeMirror исключением при построении декорации, поэтому обрезается здесь, а не там.

function clampIssues(issues: readonly GrCodeIssue[], length: number): GrCodeIssue[];

Файлdist/types/components/GrCodeEditor/defaults.d.ts

GrCodeEditorConfigurablePropsinterface

Пропы GrCodeEditor, настраиваемые глобально через <GrConfigProvider :component-defaults="{ GrCodeEditor: { … } }">. Только оформление и поведение клавиатуры: текст, язык и валидация принадлежат экземпляру. tabIndents здесь не случайно — решение про ловушку Tab принимают один раз на приложение, а не у каждого поля.

interface GrCodeEditorConfigurableProps {
    size: GrComponentSize;
    wrap: boolean;
    lineNumbers: boolean;
    tabIndents: boolean;
}

Файлdist/types/components/GrCodeEditor/safelist.d.ts

grCodeEditorSafelistconst
const grCodeEditorSafelist: string[];

Доступность

Паттерн APG

редактируемая область

КлавишаДействие
—всё, что даёт defaultKeymap CodeMirror: перемещение, выделение
—Ctrl/Cmd+Z и Shift+Ctrl/Cmd+Z из history. Tab **уводит фокус**

Использование из прозы библиотеки

Tab уводит фокус — и это решение, а не недоделка

Редактор кода в форме, из которого нельзя выйти клавиатурой, — ловушка: до кнопки «Сохранить» пользователь не доберётся, а обойти её мышью получится не у всех.

Отступ по Tab включается пропом tabIndents. Тогда работает штатный приём CodeMirror — Esc возвращает Tab роль перехода, — и подсказка об этом стоит под полем: молча менять поведение клавиши нельзя.

Язык приходит от потребителя

У CodeMirror каждый язык — отдельный npm-пакет. Встроенный набор либо тащил бы в бандл языки, которых вам не нужно, либо превращался в optional peer с динамическим импортом, ломающим сборку у того, кто пакет не поставил.

vue
<script setup>
import { json } from '@codemirror/lang-json'
</script>

<GrCodeEditor v-model="config" :language="json()" />

Тик — рабочий режим, а не украшение: редактор рисуется сразу как текст и подсвечивается, когда язык приехал.

vue
<GrCodeEditor :language="() => import('@codemirror/lang-yaml').then(m => m.yaml())" />

Грамматики нет — работает встроенный разбор: language="json" красится тем же токенизатором, что и GrCodeBlock, через мост в декорации CodeMirror (docs/highlight.md). Поэтому конфиг в форме выглядит одинаково до монтирования и после, а рядом стоящий блок не оказывается единственным цветным на странице.

Для любого другого языка строка в language — только имя: встроенного разбора за ней нет, и текст остаётся одноцветным, пока не приедет грамматика.

Цвет в обоих случаях приходит из одних токенов --gr-code-block-*: темы CodeMirror не берутся ни в каком виде, иначе редактор стал бы единственным элементом страницы, не слушающимся темы приложения. Ролей десять, соответствие тегам Lezer — в docs/highlight.md.

Валидация — контракт, а не линтер

@codemirror/lint не подключается: он приносит свои тултип и панель, которые не совпадут с дизайн-системой. Вместо зависимости — проп:

vue
<GrCodeEditor v-model="config" :validate="checkJson" />
ts
function checkJson(value: string) {
  try {
    JSON.parse(value)
    return []
  }
  catch (error) {
    return [{ from: 0, to: value.length, severity: 'error' as const, message: String(error) }]
  }
}

Проп работает шире линтера: тем же контрактом отдаётся схема YAML или ответ серверной валидации — сценарий, которого у готового линтера нет вовсе. Замечания связываются с полем через aria-describedby, поэтому ошибка остаётся доступной без зрения, а не только цветной волной под текстом.

Каждое замечание видно в трёх местах, и все три совпадают:

  • волнистое подчёркивание на диапазоне [from, to) в тоне важности (error, warning, info); точечное замечание (from === to) расширяется до одного символа — иначе подчёркивать нечего;
  • метка в жёлобе на строке замечания, с текстом замечаний строки в title. Колонка меток стоит, пока задан validate, и держит ширину и без замечаний: иначе текст прыгал бы вбок на каждой смене вердикта;
  • пункт списка под полем начинается со строки и столбца — «Line 3, column 11 — Error: …», — чтобы список и подчёркивание сопоставлялись.

Ответ асинхронного validate доезжает и до подчёркивания, и до жёлоба: замечания уходят в живой редактор эффектом, без пересоздания — курсор и история целы.

v-model не сбрасывает курсор

Наивная обёртка над CodeMirror заменяет документ целиком и сбрасывает этим курсор, выделение и историю undo — на каждом раунд-трипе v-model, то есть на каждой букве, если родитель кладёт значение в ref и возвращает обратно.

Здесь входящее изменение применяется транзакцией с минимальной заменой: считается общий префикс и суффикс, меняется только середина. Изменение, рождённое самим редактором, обратно не применяется — транзакция помечена, и эхо-петли не возникает.

Имя поля живёт внутри редактора

Роль textbox и contenteditable CodeMirror вешает не на тот узел, который монтирует компонент, а на .cm-content внутри него. Имя, оставленное на обёртке, до доступного дерева не доходит — виджет читается безымянным, и axe сообщает aria-input-field-name.

Поэтому aria-label, aria-labelledby, aria-describedby, aria-invalid и aria-required компонент кладёт на редактируемый узел через EditorView.contentAttributes и обновляет их реконфигурацией, без пересоздания состояния. Снаружи это незаметно: подпись по-прежнему берётся из GrFormField, а ariaLabel перекрывает её вне поля.

Практический вывод для потребителя: собственные ARIA-атрибуты вешать на корень компонента бессмысленно — они не доедут до виджета. Всё, что должно быть услышано, передаётся пропами.

CodeMirror — опциональный peer

Пакет, взятый ради GrCodeBlock или GrDiff, ставить CodeMirror не обязан. Редактор без него честно скажет об этом в dev-режиме и покажет код текстом, а не уронит приложение.

Нужен редактор — ставится четыре пакета:

bash
yarn add @codemirror/state @codemirror/view @codemirror/language @codemirror/commands

@codemirror/commands не факультативен: в нём history, а редактор без undo сломан. @codemirror/search в набор не входит — поиск по конфигу на сорок строк не нужен, а панель поиска приносит свою вёрстку; кому нужен, добавляет через extensions.

Границы

Не IDE: без LSP, без множественных курсоров, без файлового дерева, без вкладок. Всё, чего не покрывают пропы, доступно через getView() — живой EditorView, объявленный escape hatch’ем без контракта.