assets_js_controllers_code-editor-controller.js
import { Controller } from "@hotwired/stimulus"
/**
* Code Editor Controller
*
* Provides syntax validation and enhanced editing for YAML and JSON content.
* Shows real-time validation errors and line numbers.
*
* Usage:
* <div data-controller="code-editor"
* data-code-editor-language-value="yaml"
* data-code-editor-validate-on-change-value="true">
* <textarea data-code-editor-target="textarea"></textarea>
* <div data-code-editor-target="errorDisplay"></div>
* </div>
*
* Values:
* - language: 'yaml' or 'json'
* - validateOnChange: boolean, whether to validate as user types
* - minHeight: minimum height of the editor (default: '300px')
*/
class CodeEditorController extends Controller {
static targets = ["textarea", "errorDisplay", "lineNumbers"]
static values = {
language: { type: String, default: 'yaml' },
validateOnChange: { type: Boolean, default: true },
minHeight: { type: String, default: '300px' }
}
connect() {
this.setupEditor()
this.validateContent()
}
disconnect() {
if (this._onInput && this.hasTextareaTarget) {
this.textareaTarget.removeEventListener('input', this._onInput)
}
if (this._onScroll && this.hasTextareaTarget) {
this.textareaTarget.removeEventListener('scroll', this._onScroll)
}
if (this._onKeydown && this.hasTextareaTarget) {
this.textareaTarget.removeEventListener('keydown', this._onKeydown)
}
this._onInput = this._onScroll = this._onKeydown = null
}
setupEditor() {
if (!this.hasTextareaTarget) return
if (this._isSetup) return
const textarea = this.textareaTarget
// Create wrapper for editor with line numbers
const wrapper = document.createElement('div')
wrapper.className = 'code-editor-wrapper'
wrapper.style.cssText = `
display: flex;
border: 1px solid #ced4da;
border-radius: 0.375rem;
overflow: hidden;
min-height: ${this.minHeightValue};
`
// Create line numbers element
const lineNumbers = document.createElement('div')
lineNumbers.className = 'code-editor-line-numbers'
lineNumbers.style.cssText = `
background: #f7f7f7;
border-right: 1px solid #ddd;
padding: 10px 8px;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.5;
color: #999;
text-align: right;
user-select: none;
min-width: 40px;
`
lineNumbers.setAttribute('data-code-editor-target', 'lineNumbers')
this.lineNumbersElement = lineNumbers
// Style the textarea
textarea.style.cssText = `
flex: 1;
border: none;
padding: 10px;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.5;
resize: vertical;
outline: none;
min-height: ${this.minHeightValue};
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
tab-size: 2;
`
// Insert wrapper before textarea
textarea.parentNode.insertBefore(wrapper, textarea)
wrapper.appendChild(lineNumbers)
wrapper.appendChild(textarea)
// Update line numbers on content change
this._onInput = () => {
this.updateLineNumbers()
if (this.validateOnChangeValue) {
this.validateContent()
}
}
textarea.addEventListener('input', this._onInput)
this._onScroll = () => {
lineNumbers.scrollTop = textarea.scrollTop
}
textarea.addEventListener('scroll', this._onScroll)
this._onKeydown = (e) => this.handleKeydown(e)
textarea.addEventListener('keydown', this._onKeydown)
// Initial line numbers
this.updateLineNumbers()
this._isSetup = true
}
updateLineNumbers() {
if (!this.lineNumbersElement || !this.hasTextareaTarget) return
const lines = this.textareaTarget.value.split('\n')
const lineNumbers = []
for (let i = 1; i <= lines.length; i++) {
lineNumbers.push(i)
}
this.lineNumbersElement.innerHTML = lineNumbers.join('<br>')
}
handleKeydown(e) {
const textarea = this.textareaTarget
// Handle Tab key for indentation
if (e.key === 'Tab') {
e.preventDefault()
const start = textarea.selectionStart
const end = textarea.selectionEnd
const spaces = ' ' // 2 spaces for YAML/JSON indentation
if (e.shiftKey) {
// Shift+Tab: Remove indentation
const beforeCursor = textarea.value.substring(0, start)
const lineStart = beforeCursor.lastIndexOf('\n') + 1
const lineEndSearch = textarea.value.indexOf('\n', start)
const lineEnd = lineEndSearch === -1 ? textarea.value.length : lineEndSearch
const line = textarea.value.substring(lineStart, lineEnd)
let removeCount = 0
if (line.startsWith(' ')) {
removeCount = 2
} else if (line.startsWith(' ')) {
removeCount = 1
}
if (removeCount > 0) {
const newLine = line.substring(removeCount)
const beforeLine = textarea.value.substring(0, lineStart)
const afterLine = textarea.value.substring(lineEnd)
textarea.value = beforeLine + newLine + afterLine
const adjust = removeCount
const newSelectionStart = start >= lineStart + adjust ? start - adjust : lineStart
const newSelectionEnd = end >= lineStart + adjust ? end - adjust : lineStart
textarea.selectionStart = newSelectionStart
textarea.selectionEnd = newSelectionEnd
}
} else {
// Tab: Add indentation
textarea.value = textarea.value.substring(0, start) + spaces + textarea.value.substring(end)
textarea.selectionStart = textarea.selectionEnd = start + spaces.length
}
this.updateLineNumbers()
if (this.validateOnChangeValue) {
this.validateContent()
}
}
// Handle Enter key - auto-indent
if (e.key === 'Enter') {
const start = textarea.selectionStart
const beforeCursor = textarea.value.substring(0, start)
const currentLineStart = beforeCursor.lastIndexOf('\n') + 1
const currentLine = beforeCursor.substring(currentLineStart)
const indent = currentLine.match(/^\s*/)[0]
// Don't prevent default, but set up to add indent after
setTimeout(() => {
const newPos = textarea.selectionStart
textarea.value = textarea.value.substring(0, newPos) + indent + textarea.value.substring(newPos)
textarea.selectionStart = textarea.selectionEnd = newPos + indent.length
this.updateLineNumbers()
}, 0)
}
}
validateContent() {
if (!this.hasTextareaTarget) return
const content = this.textareaTarget.value
let error = null
if (this.languageValue === 'json') {
error = this.validateJSON(content)
} else if (this.languageValue === 'yaml') {
error = this.validateYAML(content)
}
this.displayError(error)
return error === null
}
validateJSON(content) {
if (!content.trim()) return null
try {
JSON.parse(content)
return null
} catch (e) {
// Extract line number from error message if possible
const match = e.message.match(/position (\d+)/)
let lineInfo = ''
if (match) {
const position = parseInt(match[1])
const lines = content.substring(0, position).split('\n')
lineInfo = ` (line ${lines.length}, column ${lines[lines.length - 1].length + 1})`
}
return `JSON Error${lineInfo}: ${e.message}`
}
}
validateYAML(content) {
if (!content.trim()) return null
// Basic YAML validation - check for common issues
const lines = content.split('\n')
const errors = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNum = i + 1
// Skip empty lines and comments
if (!line.trim() || line.trim().startsWith('#')) continue
// Check for tabs (YAML should use spaces)
if (line.includes('\t')) {
errors.push(`Line ${lineNum}: Tabs are not allowed in YAML, use spaces`)
}
// Check for missing space after colon in key-value pairs
const colonMatch = line.match(/^(\s*)([^:]+):([^\s])/)
if (colonMatch && !line.includes(': ') && !line.match(/:\s*$/)) {
// This might be a string with colon, check if it looks like a key
const beforeColon = colonMatch[2].trim()
if (!beforeColon.includes(' ') && !beforeColon.startsWith('-')) {
errors.push(`Line ${lineNum}: Missing space after colon`)
}
}
}
// Try to parse as JavaScript object to catch more errors
// This is a heuristic check for common YAML patterns
try {
// Check for basic structure issues
if (content.includes('{{') && content.includes('}}')) {
// Template syntax, skip strict validation
return null
}
// Check for unquoted special characters that need quoting
const specialChars = /[{}\[\]&*#?|>!%@`]/
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.trim().startsWith('-') || line.trim().startsWith('#')) continue
const colonIndex = line.indexOf(':')
if (colonIndex > 0) {
const value = line.substring(colonIndex + 1).trim()
// Check if unquoted value starts with special char
if (value && !value.startsWith('"') && !value.startsWith("'")) {
if (value[0] && specialChars.test(value[0]) && value[0] !== '>') {
errors.push(`Line ${i + 1}: Value may need to be quoted: ${value.substring(0, 20)}...`)
}
}
}
}
} catch (e) {
errors.push(`Parse error: ${e.message}`)
}
if (errors.length > 0) {
return errors.slice(0, 3).join('\n') // Show first 3 errors
}
return null
}
displayError(error) {
const applyContent = (container) => {
if (error) {
container.innerHTML = this.formatError(error)
container.classList.remove('d-none')
container.classList.add('alert', 'alert-danger', 'mt-2', 'mb-0', 'small')
} else {
container.innerHTML = ''
container.classList.add('d-none')
container.classList.remove('alert', 'alert-danger')
}
}
if (this.hasErrorDisplayTarget) {
applyContent(this.errorDisplayTarget)
return
}
const container = this._resolveErrorContainer()
if (!container) return
// Find or create the error element within the container
let errorEl = container.querySelector('.code-editor-error')
if (!errorEl) {
errorEl = document.createElement('div')
errorEl.className = 'code-editor-error d-none'
container.appendChild(errorEl)
}
applyContent(errorEl)
}
_resolveErrorContainer() {
if (this.hasErrorDisplayTarget) {
return this.errorDisplayTarget
}
if (this.hasTextareaTarget) {
const group = this.textareaTarget.closest('.form-group')
if (group) return group
}
return this.element
}
formatError(error) {
return `<i class="bi bi-exclamation-triangle me-1"></i><strong>Syntax Error:</strong><br><pre class="mb-0 mt-1" style="white-space: pre-wrap;">${this.escapeHtml(error)}</pre>`
}
escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// Action to manually trigger validation
validate(event) {
event?.preventDefault()
const isValid = this.validateContent()
return isValid
}
// Check validation before form submit
beforeSubmit(event) {
if (!this.validateContent()) {
const proceed = confirm('There are syntax errors in the content. Do you want to save anyway?')
if (!proceed) {
event.preventDefault()
}
}
}
}
// Add to global controllers registry
if (!window.Controllers) {
window.Controllers = {};
}
window.Controllers["code-editor"] = CodeEditorController;