GrCodeEditor

Package: @feugene/granularity-codecompanionGroup: misc

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

TaskComponent
Show code without editingGrCodeBlock
Compare two versionsGrDiff
Ordinary multiline textGrTextarea (the core)
A full IDE with an LSP and a file treenothing: 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-code

Import

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

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>

Accessibility

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

Full keyboard contract of the package

Component documentationAll components