/** * Initialize DslEditor with CodeMirror 5 * * Features: * - DSL-based autocompletion * - Line numbers * - Readonly support * - Placeholder support * - Textarea synchronization * - Debounced HTMX server update via updateCommandId * * Required CodeMirror addons: * - addon/hint/show-hint.js * - addon/hint/show-hint.css * - addon/display/placeholder.js * * Requires: * - htmx loaded globally * * @param {Object} config */ function initDslEditor(config) { const { elementId, textareaId, lineNumbers, autocompletion, linting, placeholder, readonly, updateCommandId, dslId, dsl } = config; const wrapper = document.getElementById(elementId); const textarea = document.getElementById(textareaId); const editorContainer = document.getElementById(`cm_${elementId}`); if (!wrapper || !textarea || !editorContainer) { console.error(`DslEditor: Missing elements for ${elementId}`); return; } if (typeof CodeMirror === "undefined") { console.error("DslEditor: CodeMirror 5 not loaded"); return; } /* -------------------------------------------------- * DSL autocompletion hint (async via server) * -------------------------------------------------- */ // Characters that trigger auto-completion const AUTO_TRIGGER_CHARS = [".", "(", '"', " "]; function dslHint(cm, callback) { const cursor = cm.getCursor(); const text = cm.getValue(); // Build URL with query params const params = new URLSearchParams({ e_id: dslId, text: text, line: cursor.line, ch: cursor.ch }); fetch(`/myfasthtml/completions?${params}`) .then(response => response.json()) .then(data => { if (!data || !data.suggestions || data.suggestions.length === 0) { callback(null); return; } callback({ list: data.suggestions.map(s => ({ text: s.label, displayText: s.detail ? `${s.label} - ${s.detail}` : s.label })), from: CodeMirror.Pos(data.from.line, data.from.ch), to: CodeMirror.Pos(data.to.line, data.to.ch) }); }) .catch(err => { console.error("DslEditor: Completion error", err); callback(null); }); } // Mark hint function as async for CodeMirror dslHint.async = true; /* -------------------------------------------------- * DSL linting (async via server) * -------------------------------------------------- */ function dslLint(text, updateOutput, options, cm) { const cursor = cm.getCursor(); const params = new URLSearchParams({ e_id: dslId, text: text, line: cursor.line, ch: cursor.ch }); fetch(`/myfasthtml/validations?${params}`) .then(response => response.json()) .then(data => { if (!data || !data.errors || data.errors.length === 0) { updateOutput([]); return; } // Convert server errors to CodeMirror lint format // Server returns 1-based positions, CodeMirror expects 0-based const annotations = data.errors.map(err => ({ from: CodeMirror.Pos(err.line - 1, Math.max(0, err.column - 1)), to: CodeMirror.Pos(err.line - 1, err.column), message: err.message, severity: err.severity || "error" })); updateOutput(annotations); }) .catch(err => { console.error("DslEditor: Linting error", err); updateOutput([]); }); } // Mark lint function as async for CodeMirror dslLint.async = true; /* -------------------------------------------------- * Register Simple Mode if available and config provided * -------------------------------------------------- */ let modeName = null; if (typeof CodeMirror.defineSimpleMode !== "undefined" && dsl && dsl.simpleModeConfig) { // Generate unique mode name from DSL name modeName = `dsl-${dsl.name.toLowerCase().replace(/\s+/g, '-')}`; // Register the mode if not already registered if (!CodeMirror.modes[modeName]) { try { CodeMirror.defineSimpleMode(modeName, dsl.simpleModeConfig); } catch (err) { console.error(`Failed to register Simple Mode for ${dsl.name}:`, err); modeName = null; } } } /* -------------------------------------------------- * Create CodeMirror editor * -------------------------------------------------- */ const enableCompletion = autocompletion && dslId; // Only enable linting if the lint addon is loaded const lintAddonLoaded = typeof CodeMirror.lint !== "undefined" || (CodeMirror.defaults && "lint" in CodeMirror.defaults); const enableLinting = linting && dslId && lintAddonLoaded; const editorOptions = { value: textarea.value || "", mode: modeName || undefined, // Use Simple Mode if available theme: "daisy", // Use DaisyUI theme for automatic theme switching lineNumbers: !!lineNumbers, readOnly: !!readonly, placeholder: placeholder || "", extraKeys: enableCompletion ? { "Ctrl-Space": "autocomplete" } : {}, hintOptions: enableCompletion ? { hint: dslHint, completeSingle: false } : undefined }; // Add linting options if enabled and addon is available if (enableLinting) { // Include linenumbers gutter if lineNumbers is enabled editorOptions.gutters = lineNumbers ? ["CodeMirror-linenumbers", "CodeMirror-lint-markers"] : ["CodeMirror-lint-markers"]; editorOptions.lint = { getAnnotations: dslLint, async: true }; } const editor = CodeMirror(editorContainer, editorOptions); /* -------------------------------------------------- * Auto-trigger completion on specific characters * -------------------------------------------------- */ if (enableCompletion) { editor.on("inputRead", function (cm, change) { if (change.origin !== "+input") return; const lastChar = change.text[change.text.length - 1]; const lastCharOfInput = lastChar.slice(-1); if (AUTO_TRIGGER_CHARS.includes(lastCharOfInput)) { cm.showHint({completeSingle: false}); } }); } /* -------------------------------------------------- * Debounced update + HTMX transport * -------------------------------------------------- */ let debounceTimer = null; const DEBOUNCE_DELAY = 300; editor.on("change", function (cm) { const value = cm.getValue(); textarea.value = value; if (!updateCommandId) return; clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { wrapper.dispatchEvent( new CustomEvent("dsl-editor-update", { detail: { commandId: updateCommandId, value: value } }) ); }, DEBOUNCE_DELAY); }); /* -------------------------------------------------- * HTMX listener (LOCAL to wrapper) * -------------------------------------------------- */ if (updateCommandId && typeof htmx !== "undefined") { wrapper.addEventListener("dsl-editor-update", function (e) { htmx.ajax("POST", "/myfasthtml/commands", { target: wrapper, swap: "none", values: { c_id: e.detail.commandId, content: e.detail.value } }); }); } /* -------------------------------------------------- * Public API * -------------------------------------------------- */ wrapper._dslEditor = { editor: editor, getContent: () => editor.getValue(), setContent: (content) => editor.setValue(content) }; //console.debug(`DslEditor initialized: ${elementId}, DSL=${dsl?.name || "unknown"}, dsl_id=${dslId}, completion=${enableCompletion ? "enabled" : "disabled"}, linting=${enableLinting ? "enabled" : "disabled"}`); }