Files
openchamber/packages/ui/src/lib/codemirror/shikiHighlight.ts
T
Bohdan Triapitsyn 782982cdd3 feat(editor): Shiki syntax highlighting in the file editor (CodeMirror)
Bring the CodeMirror file editor up to the same rich highlighting as the Shiki
file view, so toggling edit <-> view is visually consistent. lezer collapses far
more tokens than TextMate (import/from/const are all "keyword"), so a theme
remap can't reach parity — instead, project real Shiki tokens onto decorations.

- Worker: add highlightTokens — tokenize with an arbitrary registered TextMate
  theme and return per-line styled runs with offsets. The theme object ships to
  the worker once per name; later calls send only the name.
- New shikiHighlight CodeMirror extension: a StateField of mark decorations
  built from worker tokens. Re-tokenizes on a short idle (off the keystroke
  path) and maps decorations through edits so colors persist while typing.
- flexokiTheme: add { syntaxColors: false } to keep only the editor UI theme,
  so the lezer highlighter doesn't compete with the Shiki decorations.
- FilesView: enable Shiki highlighting (same language resolver as the file view
  → identical language) and drop lezer token colors when it's active. lezer
  language stays on for indentation/folding/brackets.
2026-06-16 01:08:54 +03:00

136 lines
4.9 KiB
TypeScript

import type { Extension } from '@codemirror/state';
import { RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
import { highlightTokensInWorker } from '@/components/chat/markdown/markdown-worker';
import type { MarkdownTokenRun } from '@/components/chat/markdown/markdown-worker-protocol';
// Shiki-powered syntax highlighting for CodeMirror, matching the Shiki file
// view exactly: tokenize the whole document in the markdown Shiki worker (off
// the main thread) and project the tokens onto mark decorations.
//
// While typing, existing decorations are mapped through edits so colors stay put
// (no flash); the document is re-tokenized on a short idle, never on the hot
// keystroke path. Decoration inline styles override the lezer HighlightStyle, so
// the lezer language extension can stay on for indentation/folding/brackets.
const RETOKENIZE_IDLE_MS = 180;
const setShikiDecorations = StateEffect.define<DecorationSet>();
// Shiki FontStyle bitmask.
const FONT_STYLE_ITALIC = 1;
const FONT_STYLE_BOLD = 2;
const FONT_STYLE_UNDERLINE = 4;
// Mark decorations are interned by color+style so repeated tokens reuse one spec.
const markCache = new Map<string, Decoration>();
const markFor = (color: string, fontStyle: number): Decoration => {
const key = `${color}|${fontStyle}`;
const cached = markCache.get(key);
if (cached) return cached;
let style = '';
if (color) style += `color:${color};`;
if (fontStyle & FONT_STYLE_ITALIC) style += 'font-style:italic;';
if (fontStyle & FONT_STYLE_BOLD) style += 'font-weight:bold;';
if (fontStyle & FONT_STYLE_UNDERLINE) style += 'text-decoration:underline;';
const decoration = Decoration.mark({ attributes: { style } });
markCache.set(key, decoration);
return decoration;
};
const buildDecorations = (view: EditorView, lines: MarkdownTokenRun[][]): DecorationSet => {
const builder = new RangeSetBuilder<Decoration>();
const doc = view.state.doc;
const count = Math.min(lines.length, doc.lines);
for (let i = 0; i < count; i += 1) {
const runs = lines[i];
if (!runs || runs.length === 0) continue;
const line = doc.line(i + 1);
let pos = line.from;
for (const [length, color, fontStyle] of runs) {
if (length <= 0) continue;
const from = pos;
const to = Math.min(pos + length, line.to);
pos += length;
if (to <= from) continue;
if (!color && !fontStyle) continue;
builder.add(from, to, markFor(color, fontStyle));
}
}
return builder.finish();
};
const shikiDecorationsField = StateField.define<DecorationSet>({
create() {
return Decoration.none;
},
update(decorations, tr) {
// Map existing colors through edits so they track the text while typing.
let next = decorations.map(tr.changes);
for (const effect of tr.effects) {
if (effect.is(setShikiDecorations)) next = effect.value;
}
return next;
},
provide: (field) => EditorView.decorations.from(field),
});
type ShikiHighlightOptions = {
/** Shiki language id (e.g. 'typescript'). */
language: string;
/** App theme id — the registered TextMate theme name to tokenize with. */
themeName: string;
/** Resolved TextMate theme object (shipped to the worker once per name). */
theme: unknown;
};
const shikiHighlightPlugin = (options: ShikiHighlightOptions) =>
ViewPlugin.fromClass(
class {
private timer: ReturnType<typeof setTimeout> | undefined;
private generation = 0;
constructor(view: EditorView) {
void this.tokenize(view);
}
update(update: ViewUpdate) {
if (update.docChanged) this.schedule(update.view);
}
private schedule(view: EditorView) {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = undefined;
void this.tokenize(view);
}, RETOKENIZE_IDLE_MS);
}
private async tokenize(view: EditorView) {
const generation = ++this.generation;
const text = view.state.doc.toString();
const lines = await highlightTokensInWorker(text, options.language, options.themeName, options.theme);
if (!lines) return;
// Drop if a newer tokenization started or the doc length changed since.
if (generation !== this.generation) return;
if (view.state.doc.length !== text.length) return;
view.dispatch({ effects: setShikiDecorations.of(buildDecorations(view, lines)) });
}
destroy() {
if (this.timer) clearTimeout(this.timer);
}
},
);
/**
* CodeMirror extension that colors the document with Shiki tokens via the
* worker. Recreate it (new instance) when the language or theme changes.
*/
export const shikiHighlightExtension = (options: ShikiHighlightOptions): Extension => [
shikiDecorationsField,
shikiHighlightPlugin(options),
];