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 Wuselect 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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
language | GrCodeEditorLanguage | undefined | "text" | Язык: строка — имя для встроенного разбора (`json`, `text`), объект или массив — грамматика CodeMirror, функция — её ленивая загрузка. По умолчанию `text`. |
maxHeight | string | number | undefined | undefined | Наибольшая высота поля: строка CSS или число в пикселях. |
wrap | boolean | undefined | undefined | Переносить длинные строки вместо горизонтальной прокрутки. Не задан — из `GrConfigProvider`, иначе `false`. |
lineNumbers | boolean | undefined | undefined | Номера строк в жёлобе. Не задан — из `GrConfigProvider`, иначе `true`. |
ariaLabel | string | undefined | undefined | Доступное имя редактора; перекрывает подпись `GrFormField`. Без того и другого — из локали («Code editor»). |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Ступень размера: кегль кода. По умолчанию `md`; берётся из `GrConfigProvider`. |
required | boolean | undefined | false | Обязательное поле (`aria-required`). Наследуется от `GrFormField`. |
modelValuev-model | string | undefined | "" | Текст в редакторе (`v-model`). По умолчанию пустая строка. |
placeholder | string | undefined | undefined | Подсказка в пустом редакторе. |
tabIndents | boolean | undefined | undefined | `Tab` вставляет отступ вместо перехода фокуса. По умолчанию **выключено**: редактор в форме, из которого нельзя выйти клавиатурой, — ловушка, и до кнопки «Сохранить» пользователь не доберётся. Включён — освободить фокус можно `Esc`, и об этом сказано под полем. |
id | string | undefined | undefined | Собственный `id` редактора. Не задан — берётся из `GrFormField`, иначе генерируется. |
disabled | boolean | undefined | false | Редактор недоступен: текст не правится, поле приглушено. Наследуется от `GrFormField`. |
readonly | boolean | undefined | false | Текст виден и выделяется, но не правится. Наследуется от `GrFormField`. |
invalid | boolean | undefined | false | Визуальное и ARIA-состояние ошибки (`aria-invalid`). Наследуется от `GrFormField`. |
extensions | readonly unknown[] | undefined | — | Сырые расширения CodeMirror — escape hatch для всего, чего нет в пропах. |
validate | ((value: string) => GrCodeIssue[] | Promise<GrCodeIssue[]>) | undefined | — | Замечания к коду: подчёркивание в месте и метка в жёлобе. Контракт, а не линтер: тем же пропом отдаётся результат `JSON.parse`, схема YAML или ответ серверной валидации — сценарий, которого у готового линтера нет вовсе. |
События
| 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
Компонентных зависимостей нет: редактор рисует себя сам, а CodeMirror —
внешняя библиотека, а не компонент дизайн-системы. Общая с блоком карта ролей
приходит .ts-модулем, и её классы granum извлекает по графу бандла.
const grCodeEditorConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrCodeEditor">;Файлdist/types/components/GrCodeEditor/GrCodeEditor.vue.d.ts
Язык редактора. Строка — имя языка для серверной разметки и встроенного разбора. Объект или массив — грамматика CodeMirror. Тик — ленивая её загрузка: редактор рисуется сразу как текст и подсвечивается, когда язык приехал. Тип шире, чем у блока и диффа, и это следствие устройства: до монтирования редактор рисует разметку блока, и ей нужна **строка**, а после монтирования работает CodeMirror, которому нужна **грамматика**.
type GrCodeEditorLanguage = string | object | readonly unknown[] | (() => Promise<unknown>);
/**
* Редактор кода: JSON и YAML-конфиги в админке, шаблоны писем.
*
* Не IDE: без LSP, без множественных курсоров, без файлового дерева. Показ без
* правки — `GrCodeBlock`, сравнение — `GrDiff`.
*/
export interface GrCodeEditorProps {Редактор кода: 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[]>;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
Минимальная замена: что и на что поменять, чтобы from стал to.
interface MinimalChange {
from: number;
to: number;
insert: string;
}Наименьший участок, которым один текст превращается в другой.
Наивная обёртка заменяет документ целиком ({ from: 0, to: doc.length }) и
сбрасывает этим курсор, выделение и историю undo — на **каждом** раунд-трипе
v-model, то есть на каждой букве, если родитель кладёт значение в ref и
возвращает обратно.
Возвращает null, когда менять нечего: пустая транзакция всё равно двигала бы
историю.
function minimalChange(current: string, next: string): MinimalChange | null;Замечание к коду: позиция, важность и текст. Даёт потребитель.
interface GrCodeIssue {
from: number;
to: number;
severity: 'error' | 'warning' | 'info';
message: string;
}Замечания, приведённые к границам документа.
Валидатор считает позиции по строке, которую ему дали, а документ к моменту
отрисовки мог стать короче: ответ асинхронного validate приходит на текст,
которого уже нет. Замечание за границей уронило бы CodeMirror исключением при
построении декорации, поэтому обрезается здесь, а не там.
function clampIssues(issues: readonly GrCodeIssue[], length: number): GrCodeIssue[];Файлdist/types/components/GrCodeEditor/defaults.d.ts
Пропы GrCodeEditor, настраиваемые глобально через
<GrConfigProvider :component-defaults="{ GrCodeEditor: { … } }">.
Только оформление и поведение клавиатуры: текст, язык и валидация
принадлежат экземпляру. tabIndents здесь не случайно — решение про
ловушку Tab принимают один раз на приложение, а не у каждого поля.
interface GrCodeEditorConfigurableProps {
size: GrComponentSize;
wrap: boolean;
lineNumbers: boolean;
tabIndents: boolean;
}Файлdist/types/components/GrCodeEditor/safelist.d.ts
const grCodeEditorSafelist: string[];Доступность
Паттерн APG
редактируемая область
| Клавиша | Действие |
|---|---|
| — | всё, что даёт defaultKeymap CodeMirror: перемещение, выделение |
| — | Ctrl/Cmd+Z и Shift+Ctrl/Cmd+Z из history. Tab **уводит фокус** |
Полный контракт пакета: docs/keyboard.md ↗
Использование из прозы библиотеки
Tab уводит фокус — и это решение, а не недоделка
Редактор кода в форме, из которого нельзя выйти клавиатурой, — ловушка: до кнопки «Сохранить» пользователь не доберётся, а обойти её мышью получится не у всех.
Отступ по Tab включается пропом tabIndents. Тогда работает штатный приём
CodeMirror — Esc возвращает Tab роль перехода, — и подсказка об этом стоит
под полем: молча менять поведение клавиши нельзя.
Язык приходит от потребителя
У CodeMirror каждый язык — отдельный npm-пакет. Встроенный набор либо тащил бы в бандл языки, которых вам не нужно, либо превращался в optional peer с динамическим импортом, ломающим сборку у того, кто пакет не поставил.
<script setup>
import { json } from '@codemirror/lang-json'
</script>
<GrCodeEditor v-model="config" :language="json()" />Тик — рабочий режим, а не украшение: редактор рисуется сразу как текст и подсвечивается, когда язык приехал.
<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 не подключается: он приносит свои тултип и панель, которые не
совпадут с дизайн-системой. Вместо зависимости — проп:
<GrCodeEditor v-model="config" :validate="checkJson" />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-режиме и покажет код текстом, а не
уронит приложение.
Нужен редактор — ставится четыре пакета:
yarn add @codemirror/state @codemirror/view @codemirror/language @codemirror/commands@codemirror/commands не факультативен: в нём history, а редактор без undo
сломан. @codemirror/search в набор не входит — поиск по конфигу на сорок строк
не нужен, а панель поиска приносит свою вёрстку; кому нужен, добавляет через
extensions.
Границы
Не IDE: без LSP, без множественных курсоров, без файлового дерева, без вкладок.
Всё, чего не покрывают пропы, доступно через getView() — живой EditorView,
объявленный escape hatch’ем без контракта.
Пропы этого примера