/** * CodeEditor - Custom syntax highlighting code editor for Berry Simulator * Tasmota-themed with DSL and Berry language support * * Features: * - Token-based syntax highlighting (no external dependencies) * - DSL and Berry language modes with separate buffers * - Error line highlighting * - Resizable via CSS resize: both * - Code change callbacks * - Dual buffer support: separate DSL and Berry code buffers * - Auto-display of transpiled Berry code when switching to Berry mode */ (function(window) { 'use strict'; /** * Token definitions for DSL language */ var DSL_TOKENS = { comment: /^#.*/, string: /^"(?:[^"\\]|\\.)*"|^'(?:[^'\\]|\\.)*'/, number: /^-?\d+(?:\.\d+)?(?:ms|s|%)?/, keyword: /^(?:animation|sequence|template|layer|color|palette|gradient|oscillator|provider|import|from|as|if|else|while|for|in|end|def|return|true|false|nil|run)\b/, builtin: /^(?:solid|pulse|breathe|fire|comet|sparkle|wave|beacon|rainbow|chase|twinkle|meteor|wipe|fade|blend|cycle|random)\b/, property: /^(?:color|colors|speed|brightness|width|decay|density|direction|offset|delay|duration|repeat|blend_mode|start|stop|step|hue|saturation|value|red|green|blue|alpha|position|size|count|interval|probability|range|min|max|default)\b/, color: /^(?:red|green|blue|yellow|cyan|magenta|white|black|orange|purple|pink|lime|aqua|navy|maroon|olive|teal|silver|gray|gold)\b/, operator: /^(?:->|=>|<=|>=|==|!=|&&|\|\||[+\-*\/%=<>!&|^~])/, punctuation: /^[{}()\[\]:,;.]/, identifier: /^[a-zA-Z_][a-zA-Z0-9_]*/ }; /** * Token definitions for Berry language */ var BERRY_TOKENS = { comment: /^#.*/, string: /^"(?:[^"\\]|\\.)*"|^'(?:[^'\\]|\\.)*'/, number: /^-?(?:0x[0-9a-fA-F]+|\d+(?:\.\d+)?(?:e[+-]?\d+)?)/, keyword: /^(?:if|elif|else|while|for|break|continue|return|def|end|class|var|import|as|try|except|raise|true|false|nil|self|super|static|do)\b/, builtin: /^(?:print|type|classname|classof|str|int|real|number|list|map|range|bytes|size|assert|compile|call|module|input|super|isinstance|issubclass)\b/, operator: /^(?:->|=>|<=|>=|==|!=|&&|\|\||<<|>>|[+\-*\/%=<>!&|^~])/, punctuation: /^[{}()\[\]:,;.]/, identifier: /^[a-zA-Z_][a-zA-Z0-9_]*/ }; /** * CodeEditor class * @param {Object} options - Configuration options * @param {string} options.containerId - ID of the container element * @param {string} options.initialLanguage - 'dsl' or 'berry' * @param {string} options.initialCode - Initial code content * @param {Function} options.onChange - Callback when code changes */ function CodeEditor(options) { this.options = options || {}; this.container = document.getElementById(options.containerId); this.currentLanguage = options.initialLanguage || 'dsl'; this.errorLines = []; this.onChangeCallback = options.onChange || null; this.debounceTimer = null; // Dual buffer support: separate buffers for DSL and Berry code this.buffers = { dsl: '', berry: '' }; // Track if Berry buffer contains transpiled code (read-only indicator) this.berryIsTranspiled = false; // Track the last DSL code that was transpiled this.lastTranspiledDSL = ''; if (!this.container) { console.error('CodeEditor: Container not found:', options.containerId); return; } this.createEditorStructure(); this.setupEventListeners(); // Set initial code if provided if (options.initialCode) { this.setCode(options.initialCode); // Store in the appropriate buffer this.buffers[this.currentLanguage] = options.initialCode; } } /** * Create the editor DOM structure * Uses overlay technique: transparent textarea over highlighted pre element */ CodeEditor.prototype.createEditorStructure = function() { // Clear container this.container.innerHTML = ''; this.container.className = 'code-editor-container'; // Create wrapper for the editor area (for resize) this.editorWrapper = document.createElement('div'); this.editorWrapper.className = 'code-editor-wrapper'; // Create highlighted code display (background) this.highlightPre = document.createElement('pre'); this.highlightPre.className = 'code-editor-highlight'; this.highlightPre.setAttribute('aria-hidden', 'true'); this.highlightCode = document.createElement('code'); this.highlightPre.appendChild(this.highlightCode); // Create textarea for input (foreground, transparent) this.textarea = document.createElement('textarea'); this.textarea.className = 'code-editor-textarea'; this.textarea.spellcheck = false; this.textarea.autocomplete = 'off'; this.textarea.autocapitalize = 'off'; this.textarea.setAttribute('wrap', 'off'); // Assemble editor this.editorWrapper.appendChild(this.highlightPre); this.editorWrapper.appendChild(this.textarea); this.container.appendChild(this.editorWrapper); }; /** * Setup event listeners for the editor */ CodeEditor.prototype.setupEventListeners = function() { var self = this; // Input event for real-time highlighting this.textarea.addEventListener('input', function() { self.highlightSyntax(); self.triggerOnChange(); }); // Scroll sync between textarea and highlight this.textarea.addEventListener('scroll', function() { self.highlightPre.scrollTop = self.textarea.scrollTop; self.highlightPre.scrollLeft = self.textarea.scrollLeft; }); // Tab key handling (insert spaces instead of changing focus) this.textarea.addEventListener('keydown', function(e) { if (e.key === 'Tab') { e.preventDefault(); var start = self.textarea.selectionStart; var end = self.textarea.selectionEnd; var value = self.textarea.value; // Insert 2 spaces self.textarea.value = value.substring(0, start) + ' ' + value.substring(end); self.textarea.selectionStart = self.textarea.selectionEnd = start + 2; self.highlightSyntax(); self.triggerOnChange(); } }); // Initial highlight this.highlightSyntax(); }; /** * Trigger onChange callback with debouncing */ CodeEditor.prototype.triggerOnChange = function() { var self = this; if (this.debounceTimer) { clearTimeout(this.debounceTimer); } this.debounceTimer = setTimeout(function() { if (self.onChangeCallback) { self.onChangeCallback(self.getCode(), self.currentLanguage); } }, 150); }; /** * Tokenize and highlight code */ CodeEditor.prototype.highlightSyntax = function() { var code = this.textarea.value; var tokens = this.currentLanguage === 'berry' ? BERRY_TOKENS : DSL_TOKENS; var lines = code.split('\n'); var highlightedLines = []; for (var lineNum = 0; lineNum < lines.length; lineNum++) { var line = lines[lineNum]; var highlightedLine = this.tokenizeLine(line, tokens); // Check if this line has an error var hasError = this.errorLines.indexOf(lineNum + 1) !== -1; if (hasError) { highlightedLine = '' + highlightedLine + ''; } highlightedLines.push(highlightedLine); } // Add extra newline at end to match textarea behavior this.highlightCode.innerHTML = highlightedLines.join('\n') + '\n'; }; /** * Tokenize a single line of code * @param {string} line - Line of code to tokenize * @param {Object} tokens - Token definitions * @returns {string} HTML with syntax highlighting spans */ CodeEditor.prototype.tokenizeLine = function(line, tokens) { var result = ''; var remaining = line; while (remaining.length > 0) { var matched = false; // Skip whitespace var wsMatch = remaining.match(/^\s+/); if (wsMatch) { result += this.escapeHtml(wsMatch[0]); remaining = remaining.substring(wsMatch[0].length); continue; } // Try each token type for (var tokenType in tokens) { var regex = tokens[tokenType]; var match = remaining.match(regex); if (match) { var tokenValue = match[0]; result += '' + this.escapeHtml(tokenValue) + ''; remaining = remaining.substring(tokenValue.length); matched = true; break; } } // If no token matched, output character as-is if (!matched) { result += this.escapeHtml(remaining[0]); remaining = remaining.substring(1); } } return result; }; /** * Escape HTML special characters * @param {string} text - Text to escape * @returns {string} Escaped text */ CodeEditor.prototype.escapeHtml = function(text) { var div = document.createElement('div'); div.textContent = text; return div.innerHTML; }; /** * Get the current code * @returns {string} Current code content */ CodeEditor.prototype.getCode = function() { return this.textarea.value; }; /** * Get the DSL code buffer (always returns DSL code, even if viewing Berry) * @returns {string} DSL code content */ CodeEditor.prototype.getDSLCode = function() { // If currently in DSL mode, return current textarea value if (this.currentLanguage === 'dsl') { return this.textarea.value; } // Otherwise return the stored DSL buffer return this.buffers.dsl; }; /** * Get the Berry code buffer * @returns {string} Berry code content */ CodeEditor.prototype.getBerryCode = function() { // If currently in Berry mode, return current textarea value if (this.currentLanguage === 'berry') { return this.textarea.value; } // Otherwise return the stored Berry buffer return this.buffers.berry; }; /** * Set the code content * @param {string} code - Code to set */ CodeEditor.prototype.setCode = function(code) { this.textarea.value = code || ''; // Update the current buffer this.buffers[this.currentLanguage] = code || ''; this.clearErrors(); this.highlightSyntax(); }; /** * Set the Berry code buffer (used for transpiled code) * @param {string} code - Berry code to set * @param {boolean} isTranspiled - Whether this is transpiled from DSL */ CodeEditor.prototype.setBerryCode = function(code, isTranspiled) { this.buffers.berry = code || ''; this.berryIsTranspiled = isTranspiled || false; // If currently viewing Berry, update the display if (this.currentLanguage === 'berry') { this.textarea.value = code || ''; this.highlightSyntax(); } }; /** * Set the DSL code buffer * @param {string} code - DSL code to set */ CodeEditor.prototype.setDSLCode = function(code) { this.buffers.dsl = code || ''; // If currently viewing DSL, update the display if (this.currentLanguage === 'dsl') { this.textarea.value = code || ''; this.highlightSyntax(); } }; /** * Get the current language * @returns {string} 'dsl' or 'berry' */ CodeEditor.prototype.getLanguage = function() { return this.currentLanguage; }; /** * Set the language mode and switch buffers * @param {string} lang - 'dsl' or 'berry' * @param {boolean} skipBufferSwitch - If true, don't switch buffers (used internally) */ CodeEditor.prototype.setLanguage = function(lang, skipBufferSwitch) { if (lang !== 'dsl' && lang !== 'berry') return; if (lang === this.currentLanguage) return; if (!skipBufferSwitch) { // Save current buffer before switching this.buffers[this.currentLanguage] = this.textarea.value; // Load the new buffer this.textarea.value = this.buffers[lang] || ''; } this.currentLanguage = lang; this.clearErrors(); this.highlightSyntax(); // Update read-only state for Berry transpiled code this._updateReadOnlyState(); }; /** * Update read-only state based on whether Berry code is transpiled * @private */ CodeEditor.prototype._updateReadOnlyState = function() { // If viewing transpiled Berry code, make it read-only with visual indicator if (this.currentLanguage === 'berry' && this.berryIsTranspiled) { this.textarea.classList.add('transpiled-readonly'); // Don't make it actually readonly - user might want to edit // Just add visual indicator } else { this.textarea.classList.remove('transpiled-readonly'); } }; /** * Check if the Berry buffer contains transpiled code * @returns {boolean} */ CodeEditor.prototype.isBerryTranspiled = function() { return this.berryIsTranspiled; }; /** * Clear the transpiled flag (when user edits Berry code directly) */ CodeEditor.prototype.clearTranspiledFlag = function() { this.berryIsTranspiled = false; this._updateReadOnlyState(); }; /** * Get the last DSL code that was transpiled * @returns {string} */ CodeEditor.prototype.getLastTranspiledDSL = function() { return this.lastTranspiledDSL; }; /** * Set the last transpiled DSL code * @param {string} dslCode */ CodeEditor.prototype.setLastTranspiledDSL = function(dslCode) { this.lastTranspiledDSL = dslCode || ''; }; /** * Show error on specific lines * @param {number|number[]} lines - Line number(s) with errors (1-indexed) * @param {string} message - Error message (optional) */ CodeEditor.prototype.showError = function(lines, message) { this.errorLines = Array.isArray(lines) ? lines : [lines]; this.highlightSyntax(); // Scroll to first error line if (this.errorLines.length > 0) { this.scrollToLine(this.errorLines[0]); } }; /** * Clear all error highlights */ CodeEditor.prototype.clearErrors = function() { this.errorLines = []; this.highlightSyntax(); }; /** * Scroll to a specific line * @param {number} lineNum - Line number (1-indexed) */ CodeEditor.prototype.scrollToLine = function(lineNum) { var lineHeight = parseInt(getComputedStyle(this.textarea).lineHeight) || 18; var scrollTop = (lineNum - 1) * lineHeight; this.textarea.scrollTop = scrollTop; }; /** * Set the onChange callback * @param {Function} callback - Function to call when code changes */ CodeEditor.prototype.setOnChange = function(callback) { this.onChangeCallback = callback; }; /** * Focus the editor */ CodeEditor.prototype.focus = function() { this.textarea.focus(); }; /** * Get cursor position * @returns {Object} {line, column} (1-indexed) */ CodeEditor.prototype.getCursorPosition = function() { var pos = this.textarea.selectionStart; var text = this.textarea.value.substring(0, pos); var lines = text.split('\n'); return { line: lines.length, column: lines[lines.length - 1].length + 1 }; }; /** * Initialize code editor with language toggle * @param {Object} options - Configuration options * @param {string} options.containerId - ID of the container element * @param {string} options.radioGroupName - Name of the radio button group for language toggle * @param {string} options.initialLanguage - 'dsl' or 'berry' * @param {string} options.initialCode - Initial code content * @param {Function} options.onChange - Callback when code changes * @param {Function} options.onLanguageChange - Callback when language changes (lang, editor) * @returns {CodeEditor} Editor instance */ function initCodeEditor(options) { var editor = new CodeEditor(options); // Store onLanguageChange callback editor.onLanguageChangeCallback = options.onLanguageChange || null; // Setup language toggle if radio group name provided if (options.radioGroupName) { var radios = document.querySelectorAll('input[name="' + options.radioGroupName + '"]'); radios.forEach(function(radio) { radio.addEventListener('change', function(e) { if (e.target.checked) { var newLang = e.target.value; var oldLang = editor.getLanguage(); // Switch language (this handles buffer switching) editor.setLanguage(newLang); if (window.consoleManager) { window.consoleManager.info('Language mode: ' + newLang.toUpperCase()); // Show info about transpiled code if (newLang === 'berry' && editor.isBerryTranspiled()) { window.consoleManager.info('Showing transpiled Berry code from DSL'); } } // Call language change callback if (editor.onLanguageChangeCallback) { editor.onLanguageChangeCallback(newLang, editor); } } }); // Set initial checked state if (radio.value === options.initialLanguage) { radio.checked = true; } }); } return editor; } // Export to window window.CodeEditor = CodeEditor; window.initCodeEditor = initCodeEditor; })(window);