wrapper.js
⎇
Raw
1// CodeMirror 6 wrapper for filebrowser-ng.
2//
3// Bundled to web/cm6.js (an IIFE) by `bun run build` and exposed on
4// `window.__fbng_cm`. The Rust side drives it through this tiny surface, so
5// swapping the editor later only requires re-implementing these functions.
6
7import { EditorView, basicSetup } from "codemirror";
8import { EditorState } from "@codemirror/state";
9import { oneDark } from "@codemirror/theme-one-dark";
10import { javascript } from "@codemirror/lang-javascript";
11import { json } from "@codemirror/lang-json";
12import { html } from "@codemirror/lang-html";
13import { css } from "@codemirror/lang-css";
14import { markdown } from "@codemirror/lang-markdown";
15import { python } from "@codemirror/lang-python";
16import { rust } from "@codemirror/lang-rust";
17import { cpp } from "@codemirror/lang-cpp";
18import { xml } from "@codemirror/lang-xml";
19import { sql } from "@codemirror/lang-sql";
20import { java } from "@codemirror/lang-java";
21import { php } from "@codemirror/lang-php";
22
23// Map a lowercased file extension to a language id. Unknown → plain text.
24const EXT_TO_LANG = {
25 js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx",
26 ts: "typescript", mts: "typescript", cts: "typescript", tsx: "tsx",
27 json: "json", jsonc: "json",
28 html: "html", htm: "html",
29 css: "css", scss: "css", less: "css",
30 md: "markdown", markdown: "markdown",
31 py: "python", pyw: "python",
32 rs: "rust",
33 c: "cpp", h: "cpp", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", hh: "cpp",
34 xml: "xml", svg: "xml",
35 sql: "sql",
36 java: "java",
37 php: "php",
38};
39
40// Note: there is deliberately no extension list for "is this text" here. The
41// server sniffs each file's leading bytes and reports a FileKind, so that
42// question is answered once, from content, before the client ever sees it.
43// This file only maps an extension to a *grammar*, which content sniffing
44// cannot do (a .h is C or C++).
45
46function langFromFilename(filename) {
47 const base = (filename || "").toLowerCase();
48 const dot = base.lastIndexOf(".");
49 const ext = dot >= 0 ? base.slice(dot + 1) : base;
50 return EXT_TO_LANG[ext] || "text";
51}
52
53function langExtension(langId) {
54 switch (langId) {
55 case "javascript": return javascript();
56 case "typescript": return javascript({ typescript: true });
57 case "jsx": return javascript({ jsx: true });
58 case "tsx": return javascript({ jsx: true, typescript: true });
59 case "json": return json();
60 case "html": return html();
61 case "css": return css();
62 case "markdown": return markdown();
63 case "python": return python();
64 case "rust": return rust();
65 case "cpp": return cpp();
66 case "xml": return xml();
67 case "sql": return sql();
68 case "java": return java();
69 case "php": return php();
70 default: return null;
71 }
72}
73
74function isDark() {
75 if (typeof window === "undefined") return false;
76 // The app's forced theme (class on <html>) wins over the OS setting.
77 const cls = document.documentElement.className;
78 if (cls.includes("dark")) return true;
79 if (cls.includes("light")) return false;
80 return (
81 window.matchMedia &&
82 window.matchMedia("(prefers-color-scheme: dark)").matches
83 );
84}
85
86// A subtle theme that follows the app's light/dark look for plain text.
87const appTheme = EditorView.theme({
88 "&": { fontSize: "13px", height: "100%" },
89 ".cm-scroller": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" },
90 "&.cm-focused": { outline: "none" },
91});
92
93window.__fbng_cm = {
94 version: 1,
95
96 // Create an editor in `container`. Returns an opaque view handle.
97 // opts: { value, filename, editable, onUpdate? }
98 create(container, opts) {
99 const lang = langFromFilename(opts.filename);
100 const extensions = [appTheme, basicSetup];
101 if (isDark()) extensions.push(oneDark);
102 const le = langExtension(lang);
103 if (le) extensions.push(le);
104
105 if (opts.editable) {
106 // Ctrl/Cmd-S is intercepted by the host (see Rust keybinding).
107 extensions.push(EditorState.allowMultipleSelections.of(true));
108 } else {
109 extensions.push(EditorState.readOnly.of(true), EditorView.editable.of(false));
110 }
111
112 if (typeof opts.onUpdate === "function") {
113 extensions.push(
114 EditorView.updateListener.of((update) => {
115 if (update.docChanged) opts.onUpdate();
116 })
117 );
118 }
119
120 const view = new EditorView({
121 state: EditorState.create({ doc: opts.value || "", extensions }),
122 parent: container,
123 });
124 return view;
125 },
126
127 destroy(view) {
128 try { view.destroy(); } catch (_) { /* already destroyed */ }
129 },
130
131 setValue(view, text) {
132 const len = view.state.doc.length;
133 view.dispatch({ changes: { from: 0, to: len, insert: text } });
134 },
135
136 getValue(view) {
137 return view.state.doc.toString();
138 },
139
140 focus(view) {
141 view.focus();
142 },
143};
144