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// Extensions that we treat as text (previewable in CodeMirror). Kept here so
41// the preview type and the language come from one place.
42const TEXT_EXTS = new Set([
43 ...Object.keys(EXT_TO_LANG),
44 // plain text / config / markup without a dedicated grammar
45 "txt", "text", "log", "csv", "tsv", "ini", "cfg", "conf", "toml", "yml", "yaml", "env", "properties", "gradle", "dockerfile", "sh", "bash", "zsh",
46 "fish", "bat", "ps1", "vb", "cs", "go", "kt", "kts", "swift", "dart", "lua",
47 "r", "pl", "pm", "perl", "rb", "ex", "exs", "erl", "hs", "ml", "mli", "zig",
48 "nim", "v", "proto", "thrift", "graphql", "gql", "twig", "ejs", "hbs", "vue",
49 "svelte", "dockerfile", "makefile", "gitignore", "gitattributes", "editorconfig",
50]);
51
52// Common text files with no extension.
53const EXTLESS_TEXT = new Set([
54 "dockerfile", "makefile", "justfile", "readme", "license", "licence",
55 "authors", "changelog", "contributing", "code_of_conduct",
56 "procfile", "brewfile", "gemfile", "rakefile", "vagrantfile",
57 "gitignore", "gitattributes", "editorconfig", "env.example",
58]);
59
60function langFromFilename(filename) {
61 const base = (filename || "").toLowerCase();
62 const dot = base.lastIndexOf(".");
63 const ext = dot >= 0 ? base.slice(dot + 1) : base;
64 return EXT_TO_LANG[ext] || "text";
65}
66
67function langExtension(langId) {
68 switch (langId) {
69 case "javascript": return javascript();
70 case "typescript": return javascript({ typescript: true });
71 case "jsx": return javascript({ jsx: true });
72 case "tsx": return javascript({ jsx: true, typescript: true });
73 case "json": return json();
74 case "html": return html();
75 case "css": return css();
76 case "markdown": return markdown();
77 case "python": return python();
78 case "rust": return rust();
79 case "cpp": return cpp();
80 case "xml": return xml();
81 case "sql": return sql();
82 case "java": return java();
83 case "php": return php();
84 default: return null;
85 }
86}
87
88function isDark() {
89 return typeof window !== "undefined" &&
90 window.matchMedia &&
91 window.matchMedia("(prefers-color-scheme: dark)").matches;
92}
93
94// A subtle theme that follows the app's light/dark look for plain text.
95const appTheme = EditorView.theme({
96 "&": { fontSize: "13px", height: "100%" },
97 ".cm-scroller": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" },
98 "&.cm-focused": { outline: "none" },
99});
100
101window.__fbng_cm = {
102 version: 1,
103
104 // Is this filename text (previewable in the editor)?
105 isText(filename) {
106 const base = ((filename || "").toLowerCase().split("/").pop() || "").trim();
107 if (EXTLESS_TEXT.has(base)) return true;
108 const dot = base.lastIndexOf(".");
109 const ext = dot >= 0 ? base.slice(dot + 1) : "";
110 return TEXT_EXTS.has(ext);
111 },
112
113 // Create an editor in `container`. Returns an opaque view handle.
114 // opts: { value, filename, editable, onUpdate? }
115 create(container, opts) {
116 const lang = langFromFilename(opts.filename);
117 const extensions = [appTheme, basicSetup];
118 if (isDark()) extensions.push(oneDark);
119 const le = langExtension(lang);
120 if (le) extensions.push(le);
121
122 if (opts.editable) {
123 // Ctrl/Cmd-S is intercepted by the host (see Rust keybinding).
124 extensions.push(EditorState.allowMultipleSelections.of(true));
125 } else {
126 extensions.push(EditorState.readOnly.of(true), EditorView.editable.of(false));
127 }
128
129 if (typeof opts.onUpdate === "function") {
130 extensions.push(
131 EditorView.updateListener.of((update) => {
132 if (update.docChanged) opts.onUpdate();
133 })
134 );
135 }
136
137 const view = new EditorView({
138 state: EditorState.create({ doc: opts.value || "", extensions }),
139 parent: container,
140 });
141 return view;
142 },
143
144 destroy(view) {
145 try { view.destroy(); } catch (_) { /* already destroyed */ }
146 },
147
148 setValue(view, text) {
149 const len = view.state.doc.length;
150 view.dispatch({ changes: { from: 0, to: len, insert: text } });
151 },
152
153 getValue(view) {
154 return view.state.doc.toString();
155 },
156
157 focus(view) {
158 view.focus();
159 },
160};
161