diff --git a/src/lib/commandManager.js b/src/lib/commandManager.js new file mode 100644 index 000000000..30ab4b8b8 --- /dev/null +++ b/src/lib/commandManager.js @@ -0,0 +1,383 @@ + + +// commandManager.js +import {EditorView, keymap} from "@codemirror/view"; +import fsOperation from "fileSystem"; +import {StateEffect} from "@codemirror/state"; +import {defaultKeymap} from "@codemirror/commands"; +import Url from "../utils/Url"; +import keyBindings from "./keyBindings"; + +export default class CommandManager { + constructor(view) { + this.view = view; + this.commands = {}; // name → command function + this.addedKeybindings = new Map(); // name → keybinding object + this.addedExtensions = new Map(); // name → extension for cleanup + + this.defaultKeymapExtension = keymap.of(defaultKeymap); + + // Macro system + this.macros = {}; + this.isRecording = false; + this.currentMacro = []; + this.currentMacroName = null; + + // ⭐ Hooks (like Ace) + this._onBeforeExec = []; + this._onAfterExec = []; + } + + + loadKeybindingsFromConfig(config) { + // Reset if needed (you can add "reset: true" to your JSON if desired) + // if (config.reset) this.resetKeybindings(); + + for (let [commandName, def] of Object.entries(config)) { + const { + key, + description, + readOnly = false, + editorOnly = false, + action = commandName // fallback to commandName + } = def; + + if (!key) continue; + + // Split multiple keys: "Ctrl+0|Ctrl-Numpad0" → ["Ctrl+0", "Ctrl-Numpad0"] + const keyCombos = key.split("|"); + + keyCombos.forEach(rawKey => { + const bindingName = `${commandName}-${rawKey}`; + + this.addCommand({ + name: bindingName, + bindKey: rawKey, + exec: (view, args) => { + // If custom action is provided, try to exec it + // Otherwise, exec commandName + const targetCommand = action || commandName; + return this.exec(targetCommand, view, args); + }, + readOnly, + description, + editorOnly + }); + }); + } + + console.log(`✅ Loaded ${Object.keys(config).length} command definitions with ${this.addedKeybindings.size} keybindings.`); + } + + normalizeAceKey(aceKey) { + // First, replace Ace-style "+" with "-" + let key = aceKey.replace(/\+/g, "-"); + + // Normalize modifiers and special keys + key = key + .replace(/Cmd/g, "Mod") + .replace(/Ctrl/g, "Ctrl") + .replace(/Shift/g, "Shift") + .replace(/Alt/g, "Alt") + .replace(/Enter/g, "Enter") + .replace(/Return/g, "Enter") + .replace(/Tab/g, "Tab") + .replace(/Delete/g, "Delete") + .replace(/Backspace/g, "Backspace") + .replace(/Esc/g, "Escape") + .replace(/Space/g, " ") + .replace(/Numpad(\d)/g, "Numpad$1") // Keep Numpad0, Numpad1, etc. + .toLowerCase(); + + // Re-capitalize first letter after modifiers for CM6 + key = key + .replace(/mod-shift-(.)/, (m, c) => `Mod-Shift-${c.toUpperCase()}`) + .replace(/mod-(.)/, (m, c) => `Mod-${c.toUpperCase()}`) + .replace(/ctrl-shift-(.)/, (m, c) => `Ctrl-Shift-${c.toUpperCase()}`) + .replace(/ctrl-(.)/, (m, c) => `Ctrl-${c.toUpperCase()}`) + .replace(/shift-(.)/, (m, c) => `Shift-${c.toUpperCase()}`) + .replace(/alt-(.)/, (m, c) => `Alt-${c.toUpperCase()}`); + + return key; + } + + async loadKeybindingsFromFile(file) { + try { + // First, reset to CM6 defaults + await this.resetKeybindings(); + + const bindingsFile = fsOperation(file); + if (await bindingsFile.exists()) { + const config = await bindingsFile.readFile("json"); + + // Then apply user keybindings on top + this.loadKeybindingsFromConfig(config); + console.log("📁 Keybindings loaded from file:", file.name); + } + } catch (err) { + console.warn("⚠️ Failed to load keybindings file, falling back to CodeMirror 6 defaults:", err); + + // Fallback: reset to defaults + await this.resetKeybindings(); + } + } + + _defaultKeybindingsConfig = { + focusEditor: { + key: "Ctrl-1", + description: "Focus Editor", + readOnly: false + }, + resetFontSize: { + key: "Ctrl-0|Ctrl-Numpad0", + description: "Reset Font Size", + editorOnly: true + }, + openTerminal: { + key: "Ctrl-`", + description: "Open terminal", + readOnly: true, + action: "open-terminal" + } + // Add more defaults here + }; + + async resetKeybindings() { + try { + // Clear all our dynamic keymap extensions + this.addedExtensions.clear(); + this.addedKeybindings.clear(); + + const fs = fsOperation(KEYBINDING_FILE); + const fileName = Url.basename(KEYBINDING_FILE); + const defaultKeymapJSON = defaultKeymap.reduce((acc, c) => { + acc[c.key] = c; + return acc + }, {}) + const content = JSON.stringify(defaultKeymapJSON, undefined, 2); + + if (!(await fs.exists())) { + await fsOperation(DATA_STORAGE).createFile(fileName, content); + return; + } + + await fs.writeFile(content); + + // Reconfigure with ONLY CodeMirror 6 defaults + this.view.dispatch({ + // TODO: reset to CodeMirror 6 defaults Along with Acode App-based Default keymaps + effects: StateEffect.reconfigure.of([this.defaultKeymapExtension]) + }); + + // Re-add defaults + // this.loadKeybindingsFromConfig(this._defaultKeybindingsConfig); + + console.log("🔄 Keybindings reset to defaults"); + } catch (err) { + console.log("⚠️ Failed to reset keybindings err: ", err) + } + } + + exportKeybindings() { + const config = {}; + + for (let [bindingName, bindingDef] of this.addedKeybindings.entries()) { + // Extract original command name (before "-key" suffix) + const match = bindingName.match(/^(.+?)-[^-]+$/); + const commandName = match ? match[1] : bindingName; + + // Get full command def (if available) + const cmd = this.commands[bindingName]; + + if (!config[commandName]) { + config[commandName] = { + key: [], + description: cmd?.description || "", + readOnly: cmd?.readOnly || false, + editorOnly: cmd?.editorOnly || false, + action: cmd?.action || (commandName === bindingName ? undefined : commandName) + }; + } + + // Add this key combo + config[commandName].key.push(bindingDef.key || bindingDef.mac || ""); + } + + // Join keys with "|" + for (let def of Object.values(config)) { + def.key = def.key.join("|"); + } + + return JSON.stringify(config, null, 2); + } + + // ➕ Add command (like Ace) + addCommand(commandDef) { + // Support both `exec` and `run` (Ace uses both) + const { name, exec, run, bindKey, readOnly = false, ...rest } = commandDef; + const handler = exec || run; // Prefer exec, fallback to run + + if (!name || typeof handler !== "function") { + console.warn(`Command must have 'name' and 'exec' or 'run' function`); + return; + } + + // ⚠️ Warn if handler doesn't seem to accept parameters (we pass 3 args) + // Check function length (number of declared parameters) + if (handler.length === 0) { + console.warn(`Command '${name}' has handler with 0 parameters. Expected at least (view, args, commandManager). Consider adding parameters for full functionality.`); + } + + // Wrap handler with hook + readOnly support + const wrappedExec = (view, args = {}) => { + if (readOnly && !view.state.readOnly) { + console.warn(`Command '${name}' is read-only but editor is not.`); + return false; + } + + // 🔔 Trigger beforeExec hooks + for (let hook of this._onBeforeExec) { + const result = hook({ command: name, args, view }); + if (result === false) return false; // Cancel execution + } + + // Execute command — always pass view, args, this (commandManager) + const result = handler(view, args, this); + + // 🔔 Trigger afterExec hooks + for (let hook of this._onAfterExec) { + hook({ command: name, args, view, result }); + } + + // Record if macro recording + if (this.isRecording) { + this.currentMacro.push({ command: name, args }); + } + + return result; + }; + + this.commands[name] = wrappedExec; + + // Register keybinding if provided + if (bindKey) { + const keyBinding = this.normalizeBindKey(bindKey, name); + const ext = keymap.of([keyBinding]); + this.view.dispatch({ + effects: StateEffect.appendConfig.of(ext) + }); + this.addedKeybindings.set(name, keyBinding); + this.addedExtensions.set(name, ext); + } + + console.log(`➕ Command added: ${name}`); + } + + normalizeBindKey(bindKey, commandName) { + if (typeof bindKey === "string") { + return { + key: bindKey, + run: (view) => this.exec(commandName, view) + }; + } else { + return { + key: bindKey.win || bindKey.pc || bindKey.linux || "", + mac: bindKey.mac || "", + run: (view) => this.exec(commandName, view) + }; + } + } + + // ➖ Remove command (like Ace) + removeCommand(name) { + delete this.commands[name]; + + if (this.addedExtensions.has(name)) { + const extToRemove = this.addedExtensions.get(name); + + // Get current dynamic extensions (excluding this one) + const remainingExtensions = [...this.addedExtensions.entries()] + .filter(([key]) => key !== name) + .map(([, ext]) => ext); + + // Reconfigure with remaining dynamic extensions + this.view.dispatch({ + effects: StateEffect.reconfigure.of(remainingExtensions) + }); + + this.addedExtensions.delete(name); + this.addedKeybindings.delete(name); + + console.log(`🗑️ Command and keybinding removed: ${name}`); + } + } + + // ▶️ Execute command by name + exec(name, view = this.view, args = {}) { + const cmd = this.commands[name]; + if (!cmd) { + console.warn(`⚠️ Command not found: ${name}`); + return false; + } + return cmd(view, args); + } + + // 🎥 Macro System + startRecording(name = "default") { + this.isRecording = true; + this.currentMacro = []; + this.currentMacroName = name; + console.log(`📹 Recording macro: ${name}`); + } + + stopRecording() { + if (!this.isRecording) return; + this.macros[this.currentMacroName] = [...this.currentMacro]; + this.isRecording = false; + console.log(`✅ Macro "${this.currentMacroName}" recorded (${this.currentMacro.length} steps)`); + } + + replayMacro(name = "default") { + const steps = this.macros[name]; + if (!steps) { + console.warn(`Macro "${name}" not found.`); + return false; + } + + console.log(`▶️ Replaying macro: ${name}`); + let success = true; + + for (let step of steps) { + const { command, args = {} } = step; + const result = this.exec(command, this.view, args); + if (!result) success = false; + } + + return success; + } + + // 🎣 Hook System (like Ace's .on("beforeExec", ...) ) + on(event, handler) { + if (event === "beforeExec") { + this._onBeforeExec.push(handler); + } else if (event === "afterExec") { + this._onAfterExec.push(handler); + } else { + console.warn(`Unknown event: ${event}`); + } + } + + off(event, handler) { + if (event === "beforeExec") { + this._onBeforeExec = this._onBeforeExec.filter(h => h !== handler); + } else if (event === "afterExec") { + this._onAfterExec = this._onAfterExec.filter(h => h !== handler); + } + } + + // Optional: Clear all hooks + clearAllHooks() { + this._onBeforeExec = []; + this._onAfterExec = []; + } +} diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index b3a710b3d..ced1c31e8 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -2,6 +2,7 @@ import sidebarApps from "sidebarApps"; // TODO: Migrate commands and key bindings to CodeMirror // import { setCommands, setKeyBindings } from "ace/commands"; +import CommandManager from "./commandManager"; // TODO: Migrate touch handlers to CodeMirror // import touchListeners, { scrollAnimationFrame } from "ace/touchHandler"; @@ -290,10 +291,10 @@ async function EditorManager($header, $body) { transition: "opacity .12s ease", }, ".cm-gutter.cm-foldGutter:hover .cm-gutterElement, .cm-gutter.cm-foldGutter .cm-gutterElement:hover": - { - opacity: 1, - pointerEvents: "auto", - }, + { + opacity: 1, + pointerEvents: "auto", + }, }); }, }, @@ -376,7 +377,7 @@ async function EditorManager($header, $body) { * @param {string} text * @returns {boolean} success */ - editor.insert = function (text) { + editor.insert = function(text) { try { const { from, to } = editor.state.selection.main; const insertText = String(text ?? ""); @@ -395,7 +396,7 @@ async function EditorManager($header, $body) { }; // Set CodeMirror theme by id registered in our registry - editor.setTheme = function (themeId) { + editor.setTheme = function(themeId) { try { const id = String(themeId || ""); const theme = getThemeById(id) || getThemeById(id.replace(/-/g, "_")); @@ -421,7 +422,7 @@ async function EditorManager($header, $body) { * @param {boolean} animate - Whether to animate (not used in CodeMirror, for compatibility) * @returns {boolean} success */ - editor.gotoLine = function (line, column = 0, animate = false) { + editor.gotoLine = function(line, column = 0, animate = false) { try { const { state } = editor; const { doc } = state; @@ -498,7 +499,7 @@ async function EditorManager($header, $body) { * Get current cursor position) * @returns {{row: number, column: number}} Cursor position */ - editor.getCursorPosition = function () { + editor.getCursorPosition = function() { try { const head = editor.state.selection.main.head; const cursor = editor.state.doc.lineAt(head); @@ -514,7 +515,7 @@ async function EditorManager($header, $body) { * Move cursor to specific position * @param {{row: number, column: number}} pos - Position to move to */ - editor.moveCursorToPosition = function (pos) { + editor.moveCursorToPosition = function(pos) { try { const lineNum = Math.max(1, pos.row || 1); const col = Math.max(0, pos.column || 0); @@ -528,7 +529,7 @@ async function EditorManager($header, $body) { * Get the entire document value * @returns {string} Document content */ - editor.getValue = function () { + editor.getValue = function() { try { return editor.state.doc.toString(); } catch (_) { @@ -556,7 +557,7 @@ async function EditorManager($header, $body) { * Get current selection range * @returns {{start: {row: number, column: number}, end: {row: number, column: number}}} Selection range */ - getRange: function () { + getRange: function() { try { const { from, to } = editor.state.selection.main; const fromLine = editor.state.doc.lineAt(from); @@ -580,7 +581,7 @@ async function EditorManager($header, $body) { * Get cursor position * @returns {{row: number, column: number}} Cursor position */ - getCursor: function () { + getCursor: function() { return editor.getCursorPosition(); }, }; @@ -589,7 +590,7 @@ async function EditorManager($header, $body) { * Get selected text or text under cursor (CodeMirror implementation) * @returns {string} Selected text */ - editor.getCopyText = function () { + editor.getCopyText = function() { try { const { from, to } = editor.state.selection.main; if (from === to) return ""; // No selection @@ -630,7 +631,7 @@ async function EditorManager($header, $body) { editor.dispatch({ effects: languageCompartment.reconfigure(ext || []), }); - } catch (_) {} + } catch (_) { } }) .catch(() => { // ignore load errors; remain in plain text @@ -690,7 +691,7 @@ async function EditorManager($header, $body) { const mainIndex = sel.mainIndex ?? 0; restoreSelection(editor, { ranges, mainIndex }); } - } catch (_) {} + } catch (_) { } // Restore folds from previous state if available try { @@ -698,7 +699,7 @@ async function EditorManager($header, $body) { if (folds && folds.length) { restoreFolds(editor, folds); } - } catch (_) {} + } catch (_) { } // Restore last known scroll position if present if ( @@ -745,7 +746,7 @@ async function EditorManager($header, $body) { }); const manager = { files: [], - onupdate: () => {}, + onupdate: () => { }, activeFile: null, addFile, editor, @@ -807,7 +808,7 @@ async function EditorManager($header, $body) { try { const desired = appSettings?.value?.editorTheme || "one_dark"; editor.setTheme(desired); - } catch (_) {} + } catch (_) { } // Ensure initial options reflect settings applyOptions(); @@ -818,7 +819,7 @@ async function EditorManager($header, $body) { ); $hScrollbar.onhide = $vScrollbar.onhide = updateFloatingButton.bind({}, true); - appSettings.on("update:textWrap", function () { + appSettings.on("update:textWrap", function() { updateMargin(); applyOptions(["textWrap"]); }); @@ -839,30 +840,30 @@ async function EditorManager($header, $body) { applyOptions(["linenumbers", "relativeLineNumbers"]); } - appSettings.on("update:tabSize", function () { + appSettings.on("update:tabSize", function() { updateEditorIndentationSettings(); }); - appSettings.on("update:softTab", function () { + appSettings.on("update:softTab", function() { updateEditorIndentationSettings(); }); // Show spaces/tabs and trailing whitespace - appSettings.on("update:showSpaces", function () { + appSettings.on("update:showSpaces", function() { applyOptions(["showSpaces"]); }); // Font size update for CodeMirror - appSettings.on("update:fontSize", function () { + appSettings.on("update:fontSize", function() { updateEditorStyleFromSettings(); }); // Font family update for CodeMirror - appSettings.on("update:editorFont", function () { + appSettings.on("update:editorFont", function() { updateEditorStyleFromSettings(); }); - appSettings.on("update:openFileListPos", function (value) { + appSettings.on("update:openFileListPos", function(value) { initFileTabContainer(); $vScrollbar.resize(); }); @@ -871,27 +872,27 @@ async function EditorManager($header, $body) { // // manager.editor.setOption("showPrintMargin", value); // }); - appSettings.on("update:scrollbarSize", function (value) { + appSettings.on("update:scrollbarSize", function(value) { $vScrollbar.size = value; $hScrollbar.size = value; }); // Live autocompletion (activateOnTyping) - appSettings.on("update:liveAutoCompletion", function () { + appSettings.on("update:liveAutoCompletion", function() { applyOptions(["liveAutoCompletion"]); }); - appSettings.on("update:linenumbers", function () { + appSettings.on("update:linenumbers", function() { updateMargin(true); updateEditorLineNumbersFromSettings(); }); // Line height update for CodeMirror - appSettings.on("update:lineHeight", function () { + appSettings.on("update:lineHeight", function() { updateEditorStyleFromSettings(); }); - appSettings.on("update:relativeLineNumbers", function () { + appSettings.on("update:relativeLineNumbers", function() { updateEditorLineNumbersFromSettings(); }); @@ -899,7 +900,7 @@ async function EditorManager($header, $body) { // // Not applicable in CodeMirror (Ace-era). No-op for now. // }); - appSettings.on("update:rtlText", function () { + appSettings.on("update:rtlText", function() { applyOptions(["rtlText"]); }); @@ -911,26 +912,26 @@ async function EditorManager($header, $body) { // // Not applicable in CodeMirror (Ace-era). No-op for now. // }); - appSettings.on("update:colorPreview", function () { + appSettings.on("update:colorPreview", function() { const file = manager.activeFile; if (file?.type === "editor") applyFileToEditor(file); }); - appSettings.on("update:showSideButtons", function () { + appSettings.on("update:showSideButtons", function() { updateMargin(); updateSideButtonContainer(); }); - appSettings.on("update:showAnnotations", function () { + appSettings.on("update:showAnnotations", function() { updateMargin(true); }); - appSettings.on("update:fadeFoldWidgets", function () { + appSettings.on("update:fadeFoldWidgets", function() { applyOptions(["fadeFoldWidgets"]); }); // Toggle rainbow brackets - appSettings.on("update:rainbowBrackets", function () { + appSettings.on("update:rainbowBrackets", function() { applyOptions(["rainbowBrackets"]); }); @@ -946,7 +947,7 @@ async function EditorManager($header, $body) { // Mirror latest state only on doc changes to avoid clobbering async loads try { file.session = update.state; - } catch (_) {} + } catch (_) { } // Debounced change handling (unsaved flag, cache, autosave) if (checkTimeout) clearTimeout(checkTimeout); @@ -957,7 +958,7 @@ async function EditorManager($header, $body) { file.isUnsaved = changed; try { await file.writeToCache(); - } catch (_) {} + } catch (_) { } events.emit("file-content-changed", file); manager.onupdate("file-changed"); @@ -1002,7 +1003,7 @@ async function EditorManager($header, $body) { editor.dispatch({ effects: StateEffect.appendConfig.of(getDocSyncListener()), }); - } catch (_) {} + } catch (_) { } return manager; @@ -1108,6 +1109,9 @@ async function EditorManager($header, $body) { // touchListeners(editor); // TODO: Implement commands for CodeMirror // setCommands(editor); + editor.commands = new CommandManager(editor); + await editor.commands.loadKeybindingsFromFile(KEYBINDING_FILE) + // loading commands from main file // TODO: Implement key bindings for CodeMirror // await setKeyBindings(editor); // TODO: Implement Emmet for CodeMirror @@ -1400,11 +1404,11 @@ async function EditorManager($header, $body) { if (prev?.type === "editor") { try { prev.session = editor.state; - } catch (_) {} + } catch (_) { } try { prev.lastScrollTop = editor.scrollDOM?.scrollTop || 0; prev.lastScrollLeft = editor.scrollDOM?.scrollLeft || 0; - } catch (_) {} + } catch (_) { } } manager.activeFile = file; diff --git a/webpack.config.js b/webpack.config.js index f936408eb..999ef7b05 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -39,31 +39,38 @@ module.exports = (env, options) => { // if (mode === 'production') { rules.push({ test: /\.m?js$/, - exclude: /node_modules\/(@codemirror|codemirror)/, // Exclude CodeMirror files from html-tag-js loader - use: [ - 'html-tag-js/jsx/tag-loader.js', + oneOf: [ { - loader: 'babel-loader', - options: { - presets: ['@babel/preset-env'], - }, + // 1. FIRST branch – only for CodeMirror packages + // We want *only* babel-loader here so that + // html-tag-js’s tag-loader never touches these files. + include: /node_modules[\\/](?:@codemirror|codemirror)/, + use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env'] } }, }, - ], - }); - - // Separate rule for CodeMirror files - only babel-loader, no html-tag-js - rules.push({ - test: /\.m?js$/, - include: /node_modules\/(@codemirror|codemirror)/, - use: [ { - loader: 'babel-loader', - options: { - presets: ['@babel/preset-env'], - }, + // 2. SECOND branch – all other JS + // Both html-tag-js’s tag-loader and babel-loader run here. + use: [ + 'html-tag-js/jsx/tag-loader.js', + { loader: 'babel-loader', options: { presets: ['@babel/preset-env'] } }, + ], }, ], }); + + // Separate rule for CodeMirror files - only babel-loader, no html-tag-js + // rules.push({ + // test: /\.m?js$/, + // include: /node_modules\/(@codemirror|codemirror)/, + // use: [ + // { + // loader: 'babel-loader', + // options: { + // presets: ['@babel/preset-env'], + // }, + // }, + // ], + // }); // } const main = {