Skip to content

Commit 070fa4b

Browse files
Merge pull request #35 from snelusha/feat/codemirror
Refactor CodeEditor to CodeMirror
2 parents 6436698 + c163e67 commit 070fa4b

7 files changed

Lines changed: 1552 additions & 184 deletions

File tree

apps/web/package.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,27 @@
1111
},
1212
"dependencies": {
1313
"@base-ui/react": "^1.3.0",
14+
"@codemirror/autocomplete": "^6.20.1",
15+
"@codemirror/commands": "^6.10.3",
16+
"@codemirror/language": "^6.12.3",
17+
"@codemirror/legacy-modes": "^6.5.2",
18+
"@codemirror/search": "^6.6.0",
19+
"@codemirror/state": "^6.6.0",
20+
"@codemirror/view": "^6.41.0",
1421
"@fontsource-variable/jetbrains-mono": "^5.2.8",
1522
"@hugeicons/core-free-icons": "^4.1.1",
1623
"@hugeicons/react": "^1.1.6",
24+
"@replit/codemirror-vim": "^6.3.0",
1725
"@tailwindcss/vite": "^4.2.1",
1826
"@tanstack/react-router": "^1.168.10",
1927
"class-variance-authority": "^0.7.1",
2028
"clsx": "^2.1.1",
29+
"codemirror": "^6.0.2",
2130
"immer": "^11.1.4",
2231
"react": "^19.2.4",
2332
"react-dom": "^19.2.4",
2433
"react-hotkeys-hook": "^5.2.4",
25-
"shadcn": "^4.1.2",
34+
"shadcn": "^4.2.0",
2635
"shiki": "^4.0.2",
2736
"sonner": "^2.0.7",
2837
"tailwind-merge": "^3.5.0",
@@ -37,7 +46,8 @@
3746
"@types/react-dom": "^19.2.3",
3847
"@vitejs/plugin-react": "^6.0.1",
3948
"globals": "^17.4.0",
49+
"style-mod": "^4.1.3",
4050
"typescript": "~6.0.2",
41-
"vite": "^8.0.4"
51+
"vite": "^8.0.7"
4252
}
4353
}
Lines changed: 179 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1,164 +1,214 @@
11
import * as React from "react";
22

3-
import { createHighlighter } from "shiki";
3+
import { basicSetup } from "codemirror";
4+
import { Compartment, Prec } from "@codemirror/state";
5+
import { StreamLanguage, indentUnit } from "@codemirror/language";
6+
import { autocompletion } from "@codemirror/autocomplete";
7+
import { EditorView, keymap } from "@codemirror/view";
8+
import { indentWithTab } from "@codemirror/commands";
9+
import { clike } from "@codemirror/legacy-modes/mode/clike";
10+
import { Vim, vim } from "@replit/codemirror-vim";
11+
12+
import { ShikiEditor } from "@/components/shiki-editor";
13+
14+
import { useEditorStore } from "@/stores/editor-store";
15+
import { useFileTreeActions } from "@/stores/file-tree-store";
416

517
import { cn } from "@/lib/utils";
618

7-
import type { BundledLanguage, BundledTheme, HighlighterGeneric } from "shiki";
19+
import type { KeyBinding } from "@codemirror/view";
20+
import type { Extension } from "@codemirror/state";
21+
22+
export type EditorLanguage = "ballerina" | "toml" | "text";
23+
24+
type HotkeyMap = Record<string, () => void>;
825

926
interface CodeEditorProps {
1027
value?: string;
1128
onChange?: (value: string) => void;
12-
language?: string;
29+
hotkeys?: HotkeyMap;
30+
language?: EditorLanguage;
1331
className?: string;
1432
}
1533

16-
const sharedClasses =
17-
"p-4 leading-[22.5px] font-sans whitespace-pre overflow-auto absolute inset-0 box-border [tab-size:2]";
34+
const INDENT = " ";
35+
36+
// This is a hack to bring smart indentation for Ballerina
37+
// since there's no official CodeMirror support
38+
const ballerinaMode = StreamLanguage.define(
39+
clike({
40+
name: "ballerina",
41+
}),
42+
);
43+
44+
function buildHotkeyExtension(hotkeysRef: React.RefObject<HotkeyMap>) {
45+
const bindings: KeyBinding[] = Object.keys(hotkeysRef.current ?? {}).map(
46+
(key) => ({
47+
key,
48+
run: () => {
49+
hotkeysRef.current?.[key]?.();
50+
return true;
51+
},
52+
}),
53+
);
54+
55+
return Prec.highest(keymap.of(bindings));
56+
}
1857

19-
function escapeHtml(html: string) {
20-
return html
21-
.replace(/&/g, "&amp;")
22-
.replace(/</g, "&lt;")
23-
.replace(/>/g, "&gt;")
24-
.replace(/"/g, "&quot;")
25-
.replace(/'/g, "&#039;");
58+
function baseExtensions(hotkeysRef: React.RefObject<HotkeyMap>): Extension[] {
59+
return [
60+
buildHotkeyExtension(hotkeysRef),
61+
basicSetup,
62+
indentUnit.of(INDENT),
63+
keymap.of([indentWithTab]),
64+
theme,
65+
autocompletion({
66+
activateOnTyping: false,
67+
override: [],
68+
}),
69+
];
2670
}
2771

72+
const theme = EditorView.theme({
73+
"&": {
74+
fontSize: "12.5px",
75+
height: "100%",
76+
},
77+
".cm-scroller": {
78+
fontFamily: "var(--font-sans), ui-monospace, monospace",
79+
overflow: "auto",
80+
scrollbarWidth: "none",
81+
msOverflowStyle: "none",
82+
},
83+
".cm-scroller::-webkit-scrollbar": {
84+
display: "none",
85+
},
86+
".cm-content": {
87+
paddingTop: "1rem",
88+
lineHeight: "180%",
89+
},
90+
".cm-line": {
91+
lineHeight: "inherit",
92+
},
93+
".cm-gutters": {
94+
paddingLeft: "0.5rem",
95+
backgroundColor: "transparent",
96+
border: "none",
97+
color: "var(--muted-foreground)",
98+
userSelect: "none",
99+
},
100+
".cm-activeLineGutter": {
101+
backgroundColor: "transparent",
102+
},
103+
".cm-activeLine": {
104+
backgroundColor: "transparent",
105+
},
106+
"&.cm-focused .cm-selectionBackground": {
107+
backgroundColor: "rgba(59, 130, 246, 0.1) !important",
108+
},
109+
".cm-matchingBracket, .cm-nonmatchingBracket": {
110+
outline: "none",
111+
borderRadius: "0",
112+
},
113+
".cm-vim-panel": {
114+
backgroundColor: "var(--background)",
115+
color: "var(--foreground)",
116+
},
117+
".cm-vim-panel input": {
118+
fontFamily: "var(--font-sans), ui-monospace, monospace !important",
119+
},
120+
".cm-vim-message": {
121+
color: "var(--muted-foreground) !important",
122+
},
123+
});
124+
28125
export function CodeEditor({
29-
value = "",
126+
value,
30127
onChange,
128+
hotkeys = {},
31129
language = "ballerina",
32130
className,
33131
}: CodeEditorProps) {
34-
const [highlighted, setHighlighted] = React.useState("");
35-
const highlighterRef = React.useRef<HighlighterGeneric<
36-
BundledLanguage,
37-
BundledTheme
38-
> | null>(null);
39-
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
40-
const preRef = React.useRef<HTMLPreElement>(null);
41-
42-
const renderHighlight = React.useCallback(
43-
(
44-
code: string,
45-
hl?: HighlighterGeneric<BundledLanguage, BundledTheme> | null,
46-
) => {
47-
const instance = hl || highlighterRef.current;
48-
if (!instance) return;
49-
50-
try {
51-
const html = instance.codeToHtml(code || " ", {
52-
lang: language,
53-
theme: "github-light",
54-
});
55-
const inner = html
56-
.replace(/^<pre[^>]*><code[^>]*>/, "")
57-
.replace(/<\/code><\/pre>$/, "");
58-
setHighlighted(inner);
59-
} catch {
60-
setHighlighted(escapeHtml(code));
61-
}
62-
},
63-
[language],
64-
);
132+
const parentRef = React.useRef<HTMLDivElement>(null);
133+
const editorRef = React.useRef<ShikiEditor | null>(null);
134+
135+
const languageCompartment = React.useRef(new Compartment());
136+
const vimCompartment = React.useRef(new Compartment());
137+
138+
const onChangeRef = React.useRef(onChange);
139+
onChangeRef.current = onChange;
140+
141+
const hotkeysRef = React.useRef(hotkeys);
142+
hotkeysRef.current = hotkeys;
65143

144+
const vimEnabled = useEditorStore((s) => s.editorMode) === "vim";
145+
146+
const { saveFile } = useFileTreeActions();
147+
148+
const saveFileRef = React.useRef(saveFile);
149+
saveFileRef.current = saveFile;
150+
151+
// biome-ignore lint/correctness/useExhaustiveDependencies: editor is recreated only on lang change; value is synced separately
66152
React.useEffect(() => {
67-
let isMounted = true;
68-
69-
async function initShiki() {
70-
try {
71-
const hl = await createHighlighter({
72-
themes: ["github-light"],
73-
langs: ["ballerina", "toml"],
74-
});
75-
76-
if (isMounted) {
77-
highlighterRef.current = hl;
78-
renderHighlight(textareaRef.current?.value ?? "", hl);
79-
}
80-
} catch {
81-
if (isMounted)
82-
setHighlighted(escapeHtml(textareaRef.current?.value ?? ""));
83-
}
84-
}
85-
86-
initShiki();
153+
const parent = parentRef.current;
154+
if (!parent) return;
155+
156+
const editor = new ShikiEditor({
157+
parent,
158+
doc: value,
159+
lang: language,
160+
themes: {
161+
light: "github-light",
162+
},
163+
defaultColor: "light",
164+
themeStyle: "cm",
165+
onUpdate: (update) => {
166+
if (update.docChanged)
167+
onChangeRef.current?.(update.state.doc.toString());
168+
},
169+
extensions: [
170+
...baseExtensions(hotkeysRef),
171+
languageCompartment.current.of(
172+
language === "ballerina" ? ballerinaMode : [],
173+
),
174+
vimCompartment.current.of(vimEnabled ? vim() : []),
175+
],
176+
});
177+
178+
editorRef.current = editor;
179+
87180
return () => {
88-
isMounted = false;
89-
highlighterRef.current?.dispose();
90-
highlighterRef.current = null;
181+
editorRef.current?.destroy();
182+
editorRef.current = null;
91183
};
92-
}, [renderHighlight]);
184+
}, []);
93185

94186
React.useEffect(() => {
95-
renderHighlight(value);
96-
}, [value, renderHighlight]);
97-
98-
const syncScroll = React.useCallback(() => {
99-
if (textareaRef.current && preRef.current) {
100-
preRef.current.scrollTop = textareaRef.current.scrollTop;
101-
preRef.current.scrollLeft = textareaRef.current.scrollLeft;
102-
}
103-
}, []);
187+
const editor = editorRef.current;
188+
if (!editor) return;
189+
editor.reconfigure(
190+
languageCompartment.current,
191+
language === "ballerina" ? ballerinaMode : [],
192+
);
193+
}, [language]);
104194

105-
const handleKeyDown = React.useCallback(
106-
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
107-
if (e.key !== "Tab") return;
108-
109-
e.preventDefault();
110-
const target = e.currentTarget;
111-
const { selectionStart, selectionEnd, value: currentValue } = target;
112-
113-
const newValue =
114-
currentValue.slice(0, selectionStart) +
115-
" " +
116-
currentValue.slice(selectionEnd);
117-
onChange?.(newValue);
118-
119-
setTimeout(() => {
120-
if (textareaRef.current) {
121-
textareaRef.current.setSelectionRange(
122-
selectionStart + 4,
123-
selectionStart + 4,
124-
);
125-
}
126-
}, 0);
127-
},
128-
[onChange],
129-
);
195+
React.useEffect(() => {
196+
const editor = editorRef.current;
197+
if (!editor) return;
198+
editor.reconfigure(vimCompartment.current, vimEnabled ? vim() : []);
199+
}, [vimEnabled]);
200+
201+
React.useEffect(() => {
202+
Vim.defineEx("write", "w", () => saveFileRef.current?.());
203+
}, []);
130204

131205
return (
132206
<div
207+
ref={parentRef}
133208
className={cn(
134-
"text-[13px] relative flex overflow-hidden h-full min-h-37.5",
209+
"relative overflow-hidden h-full min-h-37.5 cm-editor-host",
135210
className,
136211
)}
137-
>
138-
<div className="relative grow">
139-
<pre
140-
ref={preRef}
141-
aria-hidden="true"
142-
className={cn(sharedClasses, "z-10 pointer-events-none no-scrollbar")}
143-
// biome-ignore lint/security/noDangerouslySetInnerHtml: content is sanitized via Shiki's HTML escaping
144-
dangerouslySetInnerHTML={{ __html: `${highlighted}\n` }}
145-
/>
146-
<textarea
147-
ref={textareaRef}
148-
value={value}
149-
onChange={(e) => onChange?.(e.target.value)}
150-
onScroll={syncScroll}
151-
onKeyDown={handleKeyDown}
152-
spellCheck={false}
153-
autoCapitalize="off"
154-
autoComplete="off"
155-
autoCorrect="off"
156-
className={cn(
157-
sharedClasses,
158-
"z-20 bg-transparent text-transparent caret-blue-500 outline-none resize-none no-scrollbar",
159-
)}
160-
/>
161-
</div>
162-
</div>
212+
/>
163213
);
164214
}

0 commit comments

Comments
 (0)