import { app } from '../../scripts/app.js' import * as shared from './comfy_shared.js' import { errorLogger, infoLogger } from './comfy_shared.js' import * as mtb_ui from './mtb_ui.js' const defaultOptions = { capabilities: { execute: false, }, lint: false, mode: 'python', theme: 'dracula', } // wrapper around ACE export class MtbEditor { setOptions(options) { this.options = shared.deepMerge(defaultOptions, options || {}) if ( this.options.capabilities.execute === false && this.options.lint === true ) { infoLogger('ME: Disabling lint because execute is disabled') this.options.lint = false } if (this.options.lint) { this.debouncedLint = shared.debounce(this.lintCode.bind(this), 500) } } constructor(node, options) { infoLogger('ME: construct') this.uuid = shared.makeUUID() this._code = '' // sanitize options this.setOptions(options) this.setupUI() infoLogger('ME: inputDiv', this.inputDiv) if (node) { infoLogger('ME: Hooking up parent node', shared.safe_json(node)) this.parentNode = node const editor = this infoLogger('ME: NODE', shared.safe_json(this.parentNode)) infoLogger( 'ME: NODE KEYS', shared.safe_json(Object.keys(this.parentNode).sort()), ) shared.chainCallback(this.parentNode, 'onExecuted', function (res) { infoLogger('Executed', res) const { error, output_html } = res if (error[0]) { editor.appendOutput( `
Error:
${output_html[0]}`, ) } else { editor.appendOutput(output_html[0]) } }) shared.chainCallback(this.parentNode, 'onConfigure', function (serial) { shared .infoLogger('ME Node: configure called', { node: this, serial }) .notify() // editor.setCode(serial.properties.inputCode) // if (serial.properties.uuid) { // editor.uuid = serial.properties.uuid // } }) shared.chainCallback(this.parentNode, 'onRemoved', function (...serial) { errorLogger('ME Node: removed called TODO', { node: this, serial, }) }) shared.chainCallback(this.parentNode, 'onSerialize', function (serial) { infoLogger('ME Node: serialize called', { editor, node: this, serial, }) // if (editor.aceEditor) { // serial.properties.inputCode = editor.aceEditor.getValue() // } const uuid_widget = this.widgets.find((e) => e.name === 'uuid') if (uuid_widget) { if (uuid_widget.value) { editor.uuid = uuid_widget.value } else { uuid_widget.value = editor.uuid } } // serial.properties.inputHeightRatio = this.properties.inputHeightRatio }) // shared.chainCallback(this.parentNode,"onResize", function(size) { // editor.container.style.width = `${size[0] - 10}px` // Account for padding // container.style.height = `${size[1] - 10}px` // // }) infoLogger('ME: callback setup on node') this.debouncedUpdateNodeProperty = shared.debounce(() => { if (this.aceEditor && this.parentNode) { const currentCode = this.aceEditor.getValue() // this.parentNode.setProperty('inputCode', currentCode) this._widget.value = currentCode infoLogger("ME: Node property 'inputCode' updated.") } }, 300) } this.loadAceEditor() } setupUI() { this.container = mtb_ui.makeElement('div', { boxSizing: 'border-box', display: 'flex', flexDirection: 'column', fontFamily: 'monospace', height: '100%', padding: '5px', width: '100%', }) this.inputDiv = mtb_ui.makeElement( 'div', { // backgroundColor: '#333', // color: '#eee', // border: '1px solid #555', // borderRadius: '4px', // marginBottom: '5px', boxSizing: 'border-box', minHeight: '100px', width: 'calc(100% - 10px)', // overflow: 'hidden', }, this.container, ) if (this.options.capabilities.execute) { this.handleDiv = mtb_ui.makeElement( 'div', { backgroundColor: '#666', borderRadius: '2px', cursor: 'ns-resize', height: '5px', marginBottom: '5px', width: '100%', }, this.container, ) this.handleDiv.addEventListener( 'mousedown', this.startResizing.bind(this), ) const runButton = mtb_ui.makeElement( 'button', { backgroundColor: '#555', border: 'none', borderRadius: '4px', color: '#fff', cursor: 'pointer', fontSize: '14px', marginBottom: '5px', padding: '8px', width: '100%', }, this.container, ) runButton.textContent = 'Run Code (Alt+Enter)' runButton.onclick = () => { // node.__repl('RUN') //node.executeCode() this.executeCode() } const clearButton = mtb_ui.makeElement( 'button', { backgroundColor: '#555', border: 'none', borderRadius: '4px', color: '#fff', cursor: 'pointer', fontSize: '14px', marginBottom: '5px', padding: '8px', width: '100%', }, this.container, ) clearButton.textContent = 'Clear Output' clearButton.onclick = () => { this.outputArea.innerHTML = '' // this.properties.outputHistory = '' this.parentNode?.setProperty('outputHistory', '') } this.outputArea = mtb_ui.makeElement( 'div', { backgroundColor: '#222', border: '1px solid #555', borderRadius: '4px', boxSizing: 'border-box', color: '#ddd', flexGrow: '1', fontFamily: 'monospace', fontSize: '14px', overflowY: 'auto', padding: '5px', whiteSpace: 'pre-wrap', width: 'calc(100% - 10px)', }, this.container, ) // augment the node // node.__repl = function (msg) { // console.log('Called msg on api REPl', { msg, node: this }) // } // node.setProperty('inputCode', '') // node.title = '🐍 REPL (mtb)' } } get code() { return this._code } async lintCode() { if (this.options.mode !== 'python') { errorLogger('ME: Only python supports lint').notify() return } if (!this.aceEditor) { shared.warnLogger('ME: Ace Editor not loaded yet') return } const code = this.aceEditor.getValue() if (!code.trim()) { this.aceEditor.session.setAnnotations([]) return } try { const response = await fetch('/mtb/lint', { body: JSON.stringify({ code: code, name: this.uuid }), headers: { 'Content-Type': 'application/json', }, method: 'POST', }) if (!response.ok) { errorLogger('ME: Failed to lint', response) throw new Error( `HTTP error! status: ${response.status} ${response.statusText}`, ) } const result = await response.json() // result.diagnostics should be an array of {row, column, text, type} this.aceEditor.session.setAnnotations(result.diagnostics) } catch (e) { errorLogger('ME: Linting Error', e) this.aceEditor.session.setAnnotations([ { column: 0, row: 0, text: `Linting failed: ${e.message}`, type: 'error', }, ]) } } setupWidget(name, typeName) { if (this._widget) { throw Error('Widget already setup, this should not happen') } this._widget = this.parentNode.addDOMWidget( name, typeName, this.container, { getValue: () => { // infoLogger( // 'Called get value of Code Editor', // node.properties, // editor.code, // ) // return 'foobar' // return node.properties.inputCode // return this.parentNode?.properties?.inputCode // editor.code return this.code }, hideOnZoom: false, setValue: (v) => { infoLogger('Called set value of Code Editor with:', v) // this.parentNode.setProperty('inputCode', v) this._code = v }, }, ) return this._widget } // --- Resizing Logic --- startResizing(e) { if (!this.inputDiv) { infoLogger("The input div isn't ready", this) errorLogger("The input div isn't ready") return } this.isResizing = true this.initialMouseY = e.clientY this.initialInputHeight = this.inputDiv.offsetHeight this.initialOutputHeight = this.outputArea.offsetHeight document.addEventListener('mousemove', this.doResize.bind(this)) document.addEventListener('mouseup', this.stopResizing.bind(this)) document.body.style.cursor = 'ns-resize' // Change cursor globally } doResize(e) { if (!this.aceEditor || !this.isResizing) return const deltaY = e.clientY - this.initialMouseY let new_input_height = this.initialInputHeight + deltaY let new_output_height = this.initialOutputHeight - deltaY const minInputHeight = 50 // Minimum height for Ace editor const minOutputHeight = 50 // Minimum height for output area // Clamp heights to minimums if (new_input_height < minInputHeight) { new_input_height = minInputHeight new_output_height = this.initialInputHeight + this.initialOutputHeight - minInputHeight } if (new_output_height < minOutputHeight) { new_output_height = minOutputHeight new_input_height = this.initialInputHeight + this.initialOutputHeight - minOutputHeight } this.inputDiv.style.height = `${new_input_height}px` this.outputArea.style.height = `${new_output_height}px` // Update the stored ratio for persistence const totalDynamicHeight = this.inputDiv.offsetHeight + this.outputArea.offsetHeight if (totalDynamicHeight > 0) { this.parentNode.setProperty( 'inputHeightRatio', new_input_height / totalDynamicHeight, ) } this.aceEditor.resize() // Important for Ace to redraw } stopResizing() { this.isResizing = false document.removeEventListener('mousemove', this.doResize) document.removeEventListener('mouseup', this.stopResizing) document.body.style.cursor = '' // Restore default cursor } setCode(code) { infoLogger('called set code') this._code = code || this._code if (!this._code) { return } if (this._widget) { this._widget.value = this._code } if (this.reference) { this.reference.value = this._code } if (this.aceEditor) { this.aceEditor.setValue(this._code) } // if (this.parentNode) { // this.parentNode.setProperty('inputCode', this._code) // } // TODO: probably shouldn't be auto called depending on the call chain.. if (this.options.lint) { this.debouncedLint() } } loadAceEditor() { if (window.MTB?.ace_loaded) { if (this.aceEditor) { infoLogger('ACE is already loaded, nothing to do') } else { this.initAceEditor() } return } // NOTE: this need more testing, globals can come from other extensions... // // let NEED_PATCH = false if (window.ace) { infoLogger( 'A global ace was found in scope, to avoid issues with it we will patch it', ) // NEED_PATCH = true // // window._backupAce = window.ace // // window.ace = null } shared .loadScript('/mtb_async/ace/ace.js') .then((m) => { infoLogger('ACE was loaded', m) // window.MTB_ACE = window.ace if (!window.MTB) { // might happen now that we don't control the lifecycle window.MTB = {} } window.MTB.ace_loaded = true this.initAceEditor() // this.aceEditor.setValue(this.properties.inputCode, -1) // this.aceEditor.setValue(this.properties.inputCode, -1) this.setCode() }) .catch((e) => { errorLogger(`Error loading ace: ${e}`) }) .finally(() => { // if (NEED_PATCH) { // console.log('Patching back window object') // window.ace = window._backupAce // } }) } appendOutput(html) { this.outputArea.innerHTML += html this.outputArea.scrollTop = this.outputArea.scrollHeight if (this.parentNode) { const currentHistory = this.parentNode.properties.outputHistory || '' this.parentNode.setProperty('outputHistory', currentHistory + html) } this.outputArea.scrollTop = this.outputArea.scrollHeight } async executeCode() { if (!this.aceEditor) { infoLogger('Editor is gone, recreating it.') this.initAceEditor() } const code = this.aceEditor.getValue() if (!code.trim()) { infoLogger('No code to execute') return } const inputPrompt = `
>>> ${code}
` this.appendOutput(inputPrompt) try { const response = await fetch('/mtb/execute', { body: JSON.stringify({ code: code, name: this.uuid, reset: true }), headers: { 'Content-Type': 'application/json', }, method: 'POST', }) if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) } const result = await response.json() infoLogger('Received from backend', result) const outputHtml = result.output_html || '' const error = result.error if (error) { this.appendOutput( `
Error:
${outputHtml}`, ) } else { this.appendOutput(outputHtml) } } catch (e) { const errorMessage = `
Frontend Error: ${e.message}
` this.appendOutput(errorMessage) errorLogger('MTBEditor Backend Error:', e).notify() } finally { // Not clearing // this.inputArea.value = '' // Clear input after execution // this.properties.inputCode = '' // Clear persisted input } } initAceEditor() { if (!window.MTB?.ace_loaded) { errorLogger('ACE editor not loaded. Cannot set up editors.') return } if (!this.inputDiv) { errorLogger('Input div not found for Ace editor initialization.') return } this.aceEditor = ace.edit(this.inputDiv) this.aceEditor.setTheme(`ace/theme/${this.options.theme || 'dracula'}`) //"ace/theme/monokai", "ace/theme/github" this.aceEditor.session.setMode(`ace/mode/${this.options.mode}`) this.aceEditor.setOptions({ autoScrollEditorIntoView: true, behavioursEnabled: true, cursorStyle: 'ace', // "ace" | "slim" | "smooth" | "wide" displayIndentGuides: true, fixedWidthGutter: true, fontFamily: 'monospace', // enableBasicAutocompletion: true, // enableLiveAutocompletion: true, // enableSnippets: true, fontSize: '14px', hasCssTransforms: true, highlightActiveLine: true, highlightSelectedWord: true, scrollPastEnd: 0.5, showPrintMargin: false, tabSize: 4, useSoftTabs: true, wrap: true, }) // Custom keybinding for Ctrl+Enter // this.aceEditor.commands.addCommand({ // name: 'runCode', // bindKey: { win: 'Ctrl-Enter', mac: 'Command-Enter' }, // exec: () => this.executeCode(), // }) // this.addCommand = this.aceEditor.commands.addCommand if (this.options.capabilities.execute) { this.aceEditor.commands.addCommand({ bindKey: { mac: 'Alt-Enter', win: 'Alt-Enter' }, exec: () => this.executeCode(), name: 'runCode', }) } // Listen for changes to trigger linting // for now keeping it like that but we might just not register // the event at all. this.aceEditor.session.on('change', () => { this.debouncedUpdateNodeProperty() if (this.options.lint) { this.debouncedLint() } }) infoLogger('ACE editor created', { aceEditor: this.aceEditor }) // this.outputArea.scrollTop = this.outputArea.scrollHeight } } // the widget // This is a bit hacky and backward but the simplest I could // find for the bidirectionality export const CODE_EDITOR = (node, name, inputData, _app) => { infoLogger('CODE EDITOR NODE NOW', { inputData: shared.safe_json(inputData), node: shared.safe_json(node), }) const [typeName, options] = inputData infoLogger('CODE_EDITOR', { name, node, options }) switch ( options.lang //(node.type || node.title) { ) { case undefined: { shared .errorLogger( 'Using CODE_EDITOR without specifying lang is not supported anymore', ) .notify() break } // case 'Repl (mtb)': { case 'python': { infoLogger('Handling python editor', { node }) const editor = new MtbEditor(node, { capabilities: { execute: true, // options.allow_exec }, lang: 'python', }) infoLogger('MTBEditor created', editor) return editor.setupWidget(name, typeName) } default: { break } } } app.registerExtension({ getCustomWidgets: () => { return { CODE_EDITOR, } }, name: 'mtb.code_editor', })