GrCodeEditor

Пакет: @feugene/granularity-codeспутникГруппа: Прочее

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

Когда брать

  • Конфиг в админке, который в базе лежит текстом, а руками правится в <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

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

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

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

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

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
редактируемая область
Клавиши
всё, что даёт defaultKeymap CodeMirror: перемещение, выделение, Ctrl/Cmd+Z и Shift+Ctrl/Cmd+Z из history. Tab **уводит фокус**

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты