GrCodeEditor
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- A config in an admin area that lies in the database as text and is edited by hand in a
<textarea>with no line numbers and no chance of noticing a missing comma. - A letter template: the same case, but the text is longer and is edited more often.
- Any form field whose value is code rather than prose.
When to take something else
| Task | Component |
|---|---|
| Show code without editing | GrCodeBlock |
| Compare two versions | GrDiff |
| Ordinary multiline text | GrTextarea (the core) |
| A full IDE with an LSP and a file tree | nothing: the package does not do that and will not |
`Tab` moves the focus away — and that is a decision, not an omission
A code editor in a form that cannot be left from the keyboard is a trap: the user will not reach the “Save” button, and not everyone will manage to get around it with a mouse.
Indentation with Tab is switched on with the tabIndents prop. The standard CodeMirror device then
works — Esc returns to Tab the role of moving on — and a hint about that stands under the field:
changing the behaviour of a key silently is not allowed.
The language comes from the consumer
In CodeMirror every language is a separate npm package. A built-in set would either drag into the bundle languages you do not need, or turn into an optional peer with a dynamic import that breaks the build for anyone who has not installed the package.
<script setup>
import { json } from '@codemirror/lang-json'
</script>
<GrCodeEditor v-model="config" :language="json()" />
The tick is a working mode rather than an ornament: the editor is drawn as text at once and gets highlighted when the language has arrived.
<GrCodeEditor :language="() => import('@codemirror/lang-yaml').then(m => m.yaml())" />
If there is no grammar, the built-in parsing works: language="json" is coloured by the same
tokeniser as GrCodeBlock, through a bridge into CodeMirror decorations (docs/highlight.md). A
config in a form therefore looks the same before mounting and after, and a block standing next to it
does not turn out to be the only coloured thing on the page.
For any other language the string in language is only a name: there is no built-in parsing behind
it, and the text stays one colour until a grammar arrives.
The colour in both cases comes from the same --gr-code-block-* tokens: the themes of CodeMirror are
not taken in any form, otherwise the editor would become the only element of the page that does not
obey the theme of the application. There are ten roles, and the correspondence with the Lezer tags is
in docs/highlight.md.
Validation is a contract rather than a linter
@codemirror/lint is not connected: it brings a tooltip and a panel of its own that will not match
the design system. Instead of a dependency there is a prop:
<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) }]
}
}
The prop works more broadly than a linter: the same contract gives away a YAML schema or the answer
of server-side validation — a scenario a ready linter does not have at all. The remarks are linked
to the field through aria-describedby, so an error stays available without sight rather than only
as a coloured wave under the text.
`v-model` does not reset the cursor
A naive wrapper over CodeMirror replaces the document as a whole and thereby resets the cursor, the
selection and the undo history — on every round trip of v-model, that is, on every letter if
the parent puts the value into a ref and returns it back.
Here an incoming change is applied as a transaction with a minimal replacement: the common prefix and suffix are computed, and only the middle is changed. A change born in the editor itself is not applied back — the transaction is marked, and no echo loop arises.
The name of the field lives inside the editor
CodeMirror hangs the textbox role and contenteditable not on the node the component mounts but on
the .cm-content inside it. A name left on the wrapper does not reach the accessibility tree — the
widget reads as nameless, and axe reports aria-input-field-name.
The component therefore puts aria-label, aria-labelledby, aria-describedby, aria-invalid and
aria-required onto the editable node through EditorView.contentAttributes and updates them by
reconfiguration, without recreating the state. From the outside that is invisible: the label is still
taken from GrFormField, and ariaLabel overrides it outside a field.
The practical conclusion for the consumer: hanging ARIA attributes of your own on the root of the component is pointless — they will not reach the widget. Everything that has to be heard is passed with props.
CodeMirror is an optional peer
A package taken for the sake of GrCodeBlock or GrDiff is not obliged to install CodeMirror. The
editor without it will say so honestly in dev mode and will show the code as text rather than bring
the application down.
If the editor is needed, four packages are installed:
yarn add @codemirror/state @codemirror/view @codemirror/language @codemirror/commands
@codemirror/commands is not optional: it holds history, and an editor without undo is broken.
@codemirror/search is not in the set — a search over a config of forty lines is not needed, and the
search panel brings markup of its own; whoever needs it adds it through extensions.
Limits
Not an IDE: no LSP, no multiple cursors, no file tree, no tabs. Everything the props do not cover is
available through getView() — a live EditorView, declared an escape hatch with no contract.
Install
npm i @feugene/granularity-codeImport
import { GrCodeEditor } from '@feugene/granularity-code/components/GrCodeEditor'API
The API for this component has not been generated yet: the showcase generator only covers the core so far. Until it does, the reference lives in the package documentation.
Examples 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>Accessibility
- APG pattern
редактируемая область