GrCodeEditor
Берут, когда Конфиг в админке.
Когда брать
- Конфиг в админке, который в базе лежит текстом, а руками правится в
<textarea>без номеров строк и без шанса заметить пропущенную запятую. - Шаблон письма: тот же случай, но текст длиннее и правится чаще.
- Любое поле формы, где значение — код, а не проза.
Когда взять другое
| Задача | Компонент |
|---|---|
| Показать код без правки | GrCodeBlock |
| Сравнить две версии | GrDiff |
| Обычный многострочный текст | GrTextarea (ядро) |
| Полноценная IDE с LSP и файловым деревом | ничего: пакет этого не делает и не будет |
`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, поэтому ошибка остаётся
доступной без зрения, а не только цветной волной под текстом.
`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’ем без контракта.
Установка
npm i @feugene/granularity-codeИмпорт
import { GrCodeEditor } from '@feugene/granularity-code/components/GrCodeEditor'API
API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.
Примеры 5
Config
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrButton, GrFormField, GrSwitch } from '@feugene/granularity'
import type { GrCodeIssue } from '@feugene/granularity-code'
const config = ref(`{
"retries": 3,
"timeoutMs": 3000,
"features": ["billing", "reports"]
}`)
const tabIndents = ref(false)
/** Подпись говорит, что клавиша делает **сейчас**, а не одно из двух. */
const tabLabel = computed(() => tabIndents.value ? 'Tab делает отступ' : 'Tab уводит фокус')
/**
* Валидация — обычный проп, а не линтер CodeMirror: тем же контрактом сюда
* отдаётся схема YAML или ответ серверной проверки.
*/
function validateJson(value: string): GrCodeIssue[] {
try {
JSON.parse(value)
return []
}
catch (error) {
const message = error instanceof Error ? error.message : String(error)
const position = /position (\d+)/.exec(message)
const from = position ? Number(position[1]) : 0
return [{ from, to: Math.min(from + 1, value.length), severity: 'error', message }]
}
}
const saved = ref<string | null>(null)
</script>
<template>
<div class="grid gap-4">
<GrSwitch v-model="tabIndents" size="sm">
{{ tabLabel }}
</GrSwitch>
<GrFormField label="Конфигурация сервиса" hint="JSON: проверяется на лету">
<GrCodeEditor
v-model="config"
language="json"
:validate="validateJson"
:tab-indents="tabIndents"
max-height="16rem"
/>
</GrFormField>
<div class="flex items-center gap-3">
<GrButton size="sm" @click="saved = config">
Сохранить
</GrButton>
<span v-if="saved" class="showcase-demo-text text-sm">Сохранено {{ saved.length }} символов</span>
</div>
</div>
</template>Languages
<script setup lang="ts">
import { computed, ref, shallowRef, watch } from 'vue'
import { GrSegmented } from '@feugene/granularity'
/**
* Три языка — три отдельных npm-пакета CodeMirror, и грузит их **приложение**.
*
* Встроенный набор либо тащил бы в бандл языки, которых приложению не нужно,
* либо превращался в optional peer с динамическим импортом, ломающим сборку у
* того, кто пакет не поставил. Поэтому проп `language` принимает тик — функцию с
* промисом, — и границей импорта владеет тот, кто знает свои языки.
*/
const SNIPPETS = {
ts: `interface Order {
id: string
total: number
paid: boolean
}
export function unpaid(orders: Order[]): Order[] {
return orders.filter(order => !order.paid)
}`,
php: `<?php
final class OrderRepository
{
public function unpaid(int $limit = 20): array
{
return $this->query
->where('paid', false)
->limit($limit)
->get();
}
}`,
// Отступы табами — как их и пишет `gofmt`; в исходнике демо они экранированы,
// потому что литеральная табуляция в репозитории запрещена линтером.
go: [
'package orders',
'',
'import "context"',
'',
'func Unpaid(ctx context.Context, db *DB) ([]Order, error) {',
'\trows, err := db.QueryContext(ctx, "select * from orders where paid = false")',
'\tif err != nil {',
'\t\treturn nil, err',
'\t}',
'',
'\treturn scan(rows)',
'}',
].join('\n'),
}
type Language = keyof typeof SNIPPETS
/**
* Грамматики — тиком, а не готовым расширением: пакет языка приезжает только
* когда его выбрали. Переключение между вкладками не грузит два остальных.
*/
const GRAMMARS: Record<Language, () => Promise<unknown>> = {
ts: () => import('@codemirror/lang-javascript').then(m => m.javascript({ typescript: true })),
php: () => import('@codemirror/lang-php').then(m => m.php()),
go: () => import('@codemirror/lang-go').then(m => m.go()),
}
const language = ref<Language>('ts')
const code = ref(SNIPPETS.ts)
const loaded = shallowRef(new Set<Language>())
watch(language, (next) => {
code.value = SNIPPETS[next]
loaded.value = new Set([...loaded.value, next])
}, { immediate: true })
const grammar = computed(() => GRAMMARS[language.value])
const loadedNote = computed(() => loaded.value.size === 3
? 'все три уже в памяти'
: 'остальные приедут по выбору')
</script>
<template>
<div class="grid gap-4">
<GrSegmented
v-model="language"
size="sm"
:options="[
{ value: 'ts', label: 'TypeScript' },
{ value: 'php', label: 'PHP' },
{ value: 'go', label: 'Go' },
]"
/>
<GrCodeEditor
v-model="code"
:language="grammar"
:aria-label="`Код на ${language}`"
line-numbers
max-height="18rem"
/>
<p class="showcase-demo-text text-sm">
Загружено грамматик: <b>{{ loaded.size }}</b> из 3 — {{ loadedNote }}
</p>
</div>
</template>Lazy Lang
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue'
import { GrButton } from '@feugene/granularity'
const template = ref(`Здравствуйте, {{ name }}!
Заказ {{ order.id }} отправлен. Трек-номер: {{ order.track }}.
Ожидаемая доставка — {{ order.eta }}.`)
/**
* Язык подключает **потребитель**: у CodeMirror каждый язык — отдельный
* npm-пакет, и встроенный набор тащил бы в бандл языки, которых приложению не
* нужно.
*
* Здесь грамматика собирается на месте — подсветка подстановок `{{ … }}` в
* шаблоне письма. Важен не сам разбор, а граница: динамический `import`
* принадлежит приложению, и до его разрешения редактор уже работает.
*/
const language = shallowRef<string | (() => Promise<unknown>)>('text')
const loading = ref(false)
const loaded = ref(false)
async function loadLanguage() {
loading.value = true
const { StreamLanguage } = await import('@codemirror/language')
language.value = () => Promise.resolve(StreamLanguage.define({
token(stream) {
if (stream.match('{{')) {
while (!stream.eol() && !stream.match('}}', false))
stream.next()
stream.match('}}')
// Имена токенов у `StreamLanguage` — старые, из CodeMirror 5:
// таблица переводит их в теги Lezer, а современные имена в ней не
// значатся и остались бы без цвета. `property` — это `propertyName`,
// то есть роль `key`: подстановка в шаблоне и есть обращение к полю.
return 'property'
}
stream.next()
return null
},
}))
loading.value = false
loaded.value = true
}
const status = computed(() => loaded.value
? 'Грамматика приехала — подстановки подсвечены'
: 'Редактор рисуется сразу, подсветка приезжает следом')
</script>
<template>
<div class="grid gap-4">
<div class="flex items-center gap-3">
<GrButton size="sm" :loading="loading" :disabled="loaded" @click="loadLanguage">
{{ loaded ? 'Грамматика подключена' : 'Подключить грамматику' }}
</GrButton>
<span class="showcase-demo-text text-sm">{{ status }}</span>
</div>
<GrCodeEditor v-model="template" :language="language" max-height="14rem" />
</div>
</template>States
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue'
import { GrButton, GrFormField, GrSegmented } from '@feugene/granularity'
type State = 'edit' | 'readonly' | 'disabled' | 'invalid'
const state = ref<State>('edit')
const value = ref('')
const log = ref<string[]>([])
const editor = useTemplateRef<{ focus: () => void, getView: () => unknown }>('editor')
const readonly = computed(() => state.value === 'readonly')
const disabled = computed(() => state.value === 'disabled')
const invalid = computed(() => state.value === 'invalid')
const error = computed(() => invalid.value ? 'Сервер не принял конфигурацию' : undefined)
function note(event: string): void {
log.value = [event, ...log.value].slice(0, 4)
}
/** `getView()` — escape hatch без контракта: тут им считают длину документа. */
function measure(): void {
const view = editor.value?.getView() as { state: { doc: { lines: number } } } | null
note(view ? `getView(): строк ${view.state.doc.lines}` : 'getView(): редактор ещё не поднят')
}
</script>
<template>
<div class="grid gap-4">
<GrSegmented
v-model="state"
size="sm"
:options="[
{ value: 'edit', label: 'Обычное' },
{ value: 'readonly', label: 'readonly' },
{ value: 'disabled', label: 'disabled' },
{ value: 'invalid', label: 'invalid' },
]"
/>
<GrFormField label="Переопределение конфига" :error="error" hint="Пусто — показывается placeholder">
<GrCodeEditor
ref="editor"
v-model="value"
language="json"
placeholder="{ }"
:readonly="readonly"
:disabled="disabled"
:invalid="invalid"
line-numbers
size="sm"
max-height="10rem"
@change="note('change')"
@focus="note('focus')"
@blur="note('blur')"
/>
</GrFormField>
<div class="flex flex-wrap items-center gap-2">
<GrButton size="sm" variant="secondary" @click="editor?.focus()">
focus()
</GrButton>
<GrButton size="sm" variant="secondary" @click="measure()">
getView()
</GrButton>
<span class="showcase-demo-text text-sm">{{ log.length ? log.join(' · ') : 'событий пока не было' }}</span>
</div>
</div>
</template>Theme
<script setup lang="ts">
import { ref, shallowRef, watch } from 'vue'
import { GrSegmented } from '@feugene/granularity'
/**
* Тему подключает **потребитель**, а не пакет.
*
* Свою палитру редактор строит из токенов `--gr-code-block-*` — тогда код
* слушается темы приложения и меняется вместе с ней. Но проп `extensions` берёт
* любое расширение CodeMirror, а тема там и есть расширение: готовая из npm или
* собранная на месте. Ни та ни другая в зависимостях пакета не значится.
*/
type Palette = 'tokens' | 'one-dark' | 'custom'
const CODE = `import { defineStore } from './store'
export const useCart = defineStore('cart', {
state: () => ({ items: [], coupon: null }),
getters: {
total: state => state.items.reduce((sum, item) => sum + item.price, 0),
},
})`
const palette = ref<Palette>('tokens')
const code = ref(CODE)
const language = shallowRef(() => import('@codemirror/lang-javascript').then(m => m.javascript({ typescript: true })))
const extensions = shallowRef<unknown[]>([])
/**
* Тема на месте: `EditorView.theme` рисует хром, `HighlightStyle` — токены.
*
* Собрана здесь целиком, чтобы было видно: «любая тема» это не список
* поддерживаемых пакетов, а обычное расширение CodeMirror.
*/
async function customTheme(): Promise<unknown[]> {
const { EditorView } = await import('@codemirror/view')
const { HighlightStyle, syntaxHighlighting } = await import('@codemirror/language')
const { tags } = await import('@lezer/highlight')
return [
EditorView.theme({
'&': { backgroundColor: '#1d1f21', color: '#c5c8c6' },
'.cm-gutters': { backgroundColor: '#1d1f21', color: '#5c6370', border: 'none' },
'.cm-cursor': { borderLeftColor: '#f0c674' },
}, { dark: true }),
syntaxHighlighting(HighlightStyle.define([
{ tag: tags.keyword, color: '#b294bb' },
{ tag: tags.string, color: '#b5bd68' },
{ tag: tags.number, color: '#de935f' },
{ tag: tags.comment, color: '#707880', fontStyle: 'italic' },
{ tag: tags.propertyName, color: '#81a2be' },
{ tag: tags.function(tags.variableName), color: '#81a2be' },
])),
]
}
watch(palette, async (next) => {
if (next === 'tokens') {
extensions.value = []
return
}
extensions.value = next === 'one-dark'
? [await import('@codemirror/theme-one-dark').then(m => m.oneDark)]
: await customTheme()
}, { immediate: true })
</script>
<template>
<div class="grid gap-4">
<GrSegmented
v-model="palette"
size="sm"
:options="[
{ value: 'tokens', label: 'Токены приложения' },
{ value: 'one-dark', label: 'One Dark из npm' },
{ value: 'custom', label: 'Своя, на месте' },
]"
/>
<GrCodeEditor
v-model="code"
:language="language"
:extensions="extensions"
aria-label="Хранилище корзины"
line-numbers
max-height="16rem"
/>
<p class="showcase-demo-text text-sm">
<template v-if="palette === 'tokens'">
Своя палитра: цвета из <code>--gr-code-block-*</code>, поэтому редактор меняется вместе с темой страницы
</template>
<template v-else>
Тема потребителя сильнее нашей — переключите тему страницы в шапке: этот блок не изменится
</template>
</p>
</div>
</template>Доступность
- Паттерн APG
редактируемая область- Клавиши
- всё, что даёт
defaultKeymapCodeMirror: перемещение, выделение,Ctrl/Cmd+ZиShift+Ctrl/Cmd+Zизhistory.Tab**уводит фокус**