From 0ccec1dd9f13ec29c0bcb8342b1d3d26fae7d4f3 Mon Sep 17 00:00:00 2001 From: snelusha Date: Sun, 5 Apr 2026 03:11:05 +0530 Subject: [PATCH 1/5] Refactor CodeEditor to CodeMirror with Shiki integration --- apps/web/package.json | 13 +- apps/web/src/components/code-editor.tsx | 254 +++-- apps/web/src/components/editor.tsx | 20 +- apps/web/src/components/shiki-editor.ts | 1219 +++++++++++++++++++++++ bun.lock | 103 +- package.json | 2 +- 6 files changed, 1439 insertions(+), 172 deletions(-) create mode 100644 apps/web/src/components/shiki-editor.ts diff --git a/apps/web/package.json b/apps/web/package.json index a2cd783c..78643c1b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,13 @@ }, "dependencies": { "@base-ui/react": "^1.3.0", + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", + "@codemirror/language": "^6.12.3", + "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.41.0", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@hugeicons/core-free-icons": "^4.1.1", "@hugeicons/react": "^1.1.6", @@ -18,11 +25,12 @@ "@tanstack/react-router": "^1.168.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "codemirror": "^6.0.2", "immer": "^11.1.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-hotkeys-hook": "^5.2.4", - "shadcn": "^4.1.2", + "shadcn": "^4.2.0", "shiki": "^4.0.2", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -37,7 +45,8 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "globals": "^17.4.0", + "style-mod": "^4.1.3", "typescript": "~6.0.2", - "vite": "^8.0.4" + "vite": "^8.0.7" } } diff --git a/apps/web/src/components/code-editor.tsx b/apps/web/src/components/code-editor.tsx index f40e04b5..caf2626e 100644 --- a/apps/web/src/components/code-editor.tsx +++ b/apps/web/src/components/code-editor.tsx @@ -1,164 +1,156 @@ import * as React from "react"; -import { createHighlighter } from "shiki"; +import { basicSetup } from "codemirror"; +import { Prec } from "@codemirror/state"; +import { indentUnit } from "@codemirror/language"; +import { autocompletion } from "@codemirror/autocomplete"; +import { EditorView, keymap } from "@codemirror/view"; +import { indentWithTab } from "@codemirror/commands"; + +import { ShikiEditor } from "@/components/shiki-editor"; import { cn } from "@/lib/utils"; -import type { BundledLanguage, BundledTheme, HighlighterGeneric } from "shiki"; +import type { KeyBinding } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; + +export type EditorLanguage = "ballerina" | "toml" | "text"; + +type HotkeyMap = Record void>; interface CodeEditorProps { value?: string; onChange?: (value: string) => void; - language?: string; + hotkeys?: HotkeyMap; + language?: EditorLanguage; className?: string; } -const sharedClasses = - "p-4 leading-[22.5px] font-sans whitespace-pre overflow-auto absolute inset-0 box-border [tab-size:2]"; +const INDENT = " "; + +function buildHotkeyExtension(hotkeysRef: React.RefObject) { + const bindings: KeyBinding[] = Object.keys(hotkeysRef.current ?? {}).map( + (key) => ({ + key, + run: () => { + hotkeysRef.current?.[key]?.(); + return true; + }, + }), + ); + + return Prec.highest(keymap.of(bindings)); +} -function escapeHtml(html: string) { - return html - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); +function baseExtensions(hotkeysRef: React.RefObject): Extension[] { + return [ + buildHotkeyExtension(hotkeysRef), + basicSetup, + indentUnit.of(INDENT), + keymap.of([indentWithTab]), + theme, + autocompletion({ + activateOnTyping: false, + override: [], + }), + ]; } +const theme = EditorView.theme({ + "&": { + fontSize: "12.5px", + height: "100%", + }, + ".cm-scroller": { + fontFamily: "var(--font-sans), ui-monospace, monospace", + overflow: "auto", + scrollbarWidth: "none", + msOverflowStyle: "none", + }, + ".cm-scroller::-webkit-scrollbar": { + display: "none", + }, + ".cm-content": { + paddingTop: "1rem", + lineHeight: "180%", + }, + ".cm-line": { + lineHeight: "inherit", + }, + ".cm-gutters": { + paddingLeft: "0.5rem", + backgroundColor: "transparent", + border: "none", + color: "var(--muted-foreground)", + userSelect: "none", + }, + ".cm-activeLineGutter": { + backgroundColor: "transparent", + }, + ".cm-activeLine": { + backgroundColor: "transparent", + }, + "&.cm-focused .cm-selectionBackground": { + backgroundColor: "rgba(59, 130, 246, 0.1) !important", + }, + ".cm-matchingBracket, .cm-nonmatchingBracket": { + outline: "none", + borderRadius: "0", + }, +}); + export function CodeEditor({ - value = "", + value, onChange, + hotkeys = {}, language = "ballerina", className, }: CodeEditorProps) { - const [highlighted, setHighlighted] = React.useState(""); - const highlighterRef = React.useRef | null>(null); - const textareaRef = React.useRef(null); - const preRef = React.useRef(null); - - const renderHighlight = React.useCallback( - ( - code: string, - hl?: HighlighterGeneric | null, - ) => { - const instance = hl || highlighterRef.current; - if (!instance) return; - - try { - const html = instance.codeToHtml(code || " ", { - lang: language, - theme: "github-light", - }); - const inner = html - .replace(/^]*>]*>/, "") - .replace(/<\/code><\/pre>$/, ""); - setHighlighted(inner); - } catch { - setHighlighted(escapeHtml(code)); - } - }, - [language], - ); + const parentRef = React.useRef(null); + const editorRef = React.useRef(null); + const onChangeRef = React.useRef(onChange); + onChangeRef.current = onChange; + + const hotkeysRef = React.useRef(hotkeys); + hotkeysRef.current = hotkeys; + + // biome-ignore lint/correctness/useExhaustiveDependencies: editor is recreated only on lang change; value is synced separately React.useEffect(() => { - let isMounted = true; - - async function initShiki() { - try { - const hl = await createHighlighter({ - themes: ["github-light"], - langs: ["ballerina", "toml"], - }); - - if (isMounted) { - highlighterRef.current = hl; - renderHighlight(textareaRef.current?.value ?? "", hl); - } - } catch { - if (isMounted) - setHighlighted(escapeHtml(textareaRef.current?.value ?? "")); - } - } - - initShiki(); + const parent = parentRef.current; + if (!parent) return; + + const editor = new ShikiEditor({ + parent, + doc: value, + lang: language, + themes: { + light: "github-light", + }, + defaultColor: "light", + themeStyle: "cm", + onUpdate: (update) => { + if (update.docChanged) + onChangeRef.current?.(update.state.doc.toString()); + }, + extensions: baseExtensions(hotkeysRef), + }); + + editorRef.current = editor; + return () => { - isMounted = false; - highlighterRef.current?.dispose(); - highlighterRef.current = null; + editorRef.current?.destroy(); + editorRef.current = null; }; - }, [renderHighlight]); - - React.useEffect(() => { - renderHighlight(value); - }, [value, renderHighlight]); - - const syncScroll = React.useCallback(() => { - if (textareaRef.current && preRef.current) { - preRef.current.scrollTop = textareaRef.current.scrollTop; - preRef.current.scrollLeft = textareaRef.current.scrollLeft; - } }, []); - const handleKeyDown = React.useCallback( - (e: React.KeyboardEvent) => { - if (e.key !== "Tab") return; - - e.preventDefault(); - const target = e.currentTarget; - const { selectionStart, selectionEnd, value: currentValue } = target; - - const newValue = - currentValue.slice(0, selectionStart) + - " " + - currentValue.slice(selectionEnd); - onChange?.(newValue); - - setTimeout(() => { - if (textareaRef.current) { - textareaRef.current.setSelectionRange( - selectionStart + 4, - selectionStart + 4, - ); - } - }, 0); - }, - [onChange], - ); - return (
-
-