Spaces:
Building
Building
import { Component, Input, forwardRef, OnInit, ViewChild, ElementRef } from '@angular/core'; | |
import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormControl } from '@angular/forms'; | |
import { CommonModule } from '@angular/common'; | |
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; | |
import { MatFormFieldModule } from '@angular/material/form-field'; | |
import { MatInputModule } from '@angular/material/input'; | |
import { MatIconModule } from '@angular/material/icon'; | |
import { MatChipsModule } from '@angular/material/chips'; | |
import { MatExpansionModule } from '@angular/material/expansion'; | |
({ | |
selector: 'app-json-editor', | |
standalone: true, | |
imports: [ | |
CommonModule, | |
FormsModule, | |
ReactiveFormsModule, | |
MatFormFieldModule, | |
MatInputModule, | |
MatIconModule, | |
MatChipsModule, | |
MatExpansionModule | |
], | |
providers: [ | |
{ | |
provide: NG_VALUE_ACCESSOR, | |
useExisting: forwardRef(() => JsonEditorComponent), | |
multi: true | |
} | |
], | |
template: ` | |
<mat-form-field appearance="outline" | |
class="full-width" | |
[class.json-valid]="isValidJson()" | |
[class.json-invalid]="!isValidJson() && value"> | |
<mat-label>{{ label }}</mat-label> | |
<textarea matInput | |
#textareaRef | |
[(ngModel)]="value" | |
[rows]="rows" | |
[placeholder]="placeholder" | |
(keydown)="handleKeydown($event)" | |
(click)="handleCursorMove($event)" | |
(keyup)="handleCursorMove($event)" | |
(blur)="onTouched()" | |
class="code-editor"></textarea> | |
<mat-hint>{{ hint }}</mat-hint> | |
@if (!isValidJson() && value) { | |
<mat-error>Invalid JSON format</mat-error> | |
} | |
</mat-form-field> | |
<!-- JSON Validation Indicator --> | |
<div class="json-validation-status"> | |
@if (isValidJson()) { | |
<mat-icon class="valid">check_circle</mat-icon> | |
<span class="valid">Valid JSON</span> | |
} @else if (value) { | |
<mat-icon class="invalid">error</mat-icon> | |
<span class="invalid">Invalid JSON</span> | |
} | |
</div> | |
<!-- Collapsible Variables Panel --> | |
@if (availableVariables && availableVariables.length > 0) { | |
<mat-expansion-panel class="variables-panel"> | |
<mat-expansion-panel-header> | |
<mat-panel-title> | |
<mat-icon>code</mat-icon> | |
Available Variables | |
</mat-panel-title> | |
<mat-panel-description> | |
Click to insert template variables | |
</mat-panel-description> | |
</mat-expansion-panel-header> | |
<mat-chip-set> | |
@for (variable of availableVariables; track variable) { | |
<mat-chip (click)="insertVariable(variable)"> | |
{{ variable }} | |
</mat-chip> | |
} | |
</mat-chip-set> | |
</mat-expansion-panel> | |
} | |
`, | |
styles: [` | |
:host { | |
display: block; | |
margin-bottom: 16px; | |
} | |
.full-width { | |
width: 100%; | |
} | |
.code-editor { | |
font-family: 'Consolas', 'Monaco', 'Courier New', monospace !important; | |
font-size: 13px; | |
line-height: 1.5; | |
tab-size: 2; | |
-moz-tab-size: 2; | |
white-space: pre; | |
} | |
.json-validation-status { | |
display: flex; | |
align-items: center; | |
gap: 4px; | |
margin-top: -6px; | |
margin-bottom: 16px; | |
font-size: 12px; | |
mat-icon { | |
font-size: 16px; | |
width: 16px; | |
height: 20px; | |
margin-top:-2px; | |
&.valid { | |
color: #4caf50; | |
} | |
&.invalid { | |
color: #f44336; | |
} | |
} | |
span { | |
&.valid { | |
color: #4caf50; | |
} | |
&.invalid { | |
color: #f44336; | |
} | |
} | |
} | |
.variables-panel { | |
margin-bottom: 16px; | |
box-shadow: none; | |
border: 1px solid rgba(0, 0, 0, 0.12); | |
.mat-expansion-panel-header { | |
padding: 0 16px; | |
height: 40px; | |
.mat-panel-title { | |
display: flex; | |
align-items: center; | |
gap: 8px; | |
font-size: 13px; | |
color: #666; | |
mat-icon { | |
font-size: 18px; | |
width: 18px; | |
height: 18px; | |
} | |
} | |
.mat-panel-description { | |
font-size: 12px; | |
color: #999; | |
} | |
} | |
.mat-expansion-panel-body { | |
padding: 8px 16px 16px; | |
} | |
mat-chip-set { | |
mat-chip { | |
font-size: 12px; | |
min-height: 24px; | |
padding: 4px 8px; | |
cursor: pointer; | |
transition: all 0.2s; | |
&:hover { | |
background-color: #e3f2fd; | |
color: #1976d2; | |
} | |
} | |
} | |
} | |
// JSON field background colors | |
.json-valid { | |
textarea { | |
background-color: rgba(76, 175, 80, 0.05) !important; | |
} | |
} | |
.json-invalid { | |
textarea { | |
background-color: rgba(244, 67, 54, 0.05) !important; | |
} | |
} | |
`] | |
}) | |
export class JsonEditorComponent implements ControlValueAccessor, OnInit { | |
'textareaRef') textareaRef!: ElementRef<HTMLTextAreaElement>; | (|
'JSON Editor'; | () label =|
'{}'; | () placeholder =|
'Enter valid JSON'; | () hint =|
8; | () rows =|
availableVariables: string[] = []; | ()|
(json: string) => string; | () variableReplacer?:|
value = ''; | |
onChange: (value: string) => void = () => {}; | |
onTouched: () => void = () => {}; | |
private bracketPairs: { [key: string]: string } = { | |
'{': '}', | |
'[': ']', | |
'(': ')' | |
}; | |
private cursorPosition = 0; | |
ngOnInit() {} | |
writeValue(value: string): void { | |
this.value = value || ''; | |
} | |
registerOnChange(fn: (value: string) => void): void { | |
this.onChange = fn; | |
} | |
registerOnTouched(fn: () => void): void { | |
this.onTouched = fn; | |
} | |
setDisabledState?(isDisabled: boolean): void { | |
// Handle disabled state if needed | |
} | |
isValidJson(): boolean { | |
if (!this.value || !this.value.trim()) return true; | |
try { | |
let jsonStr = this.value; | |
// If variableReplacer is provided, use it to replace variables for validation | |
if (this.variableReplacer) { | |
jsonStr = this.variableReplacer(jsonStr); | |
} else { | |
// Default variable replacement for validation | |
jsonStr = this.replaceVariablesForValidation(jsonStr); | |
} | |
JSON.parse(jsonStr); | |
return true; | |
} catch { | |
return false; | |
} | |
} | |
private replaceVariablesForValidation(jsonStr: string): string { | |
let processed = jsonStr; | |
processed = processed.replace(/\{\{([^}]+)\}\}/g, (match, variablePath) => { | |
if (variablePath.includes('variables.')) { | |
const varName = variablePath.split('.').pop()?.toLowerCase() || ''; | |
const numericVars = ['count', 'passenger_count', 'timeout_seconds', 'retry_count', 'amount', 'price', 'quantity', 'age', 'id']; | |
const booleanVars = ['enabled', 'published', 'is_active', 'confirmed', 'canceled', 'deleted', 'required']; | |
if (numericVars.some(v => varName.includes(v))) { | |
return '1'; | |
} else if (booleanVars.some(v => varName.includes(v))) { | |
return 'true'; | |
} else { | |
return '"placeholder"'; | |
} | |
} | |
return '"placeholder"'; | |
}); | |
return processed; | |
} | |
handleKeydown(event: KeyboardEvent): void { | |
if (event.key === 'Tab') { | |
this.handleTabKey(event); | |
} else if (event.key === 'Enter') { | |
this.handleEnterKey(event); | |
} else if (event.key in this.bracketPairs || Object.values(this.bracketPairs).includes(event.key)) { | |
this.handleBracketInput(event); | |
} | |
} | |
handleTabKey(event: KeyboardEvent): void { | |
event.preventDefault(); | |
const textarea = event.target as HTMLTextAreaElement; | |
const start = textarea.selectionStart; | |
const end = textarea.selectionEnd; | |
if (start !== end) { | |
this.handleBlockIndent(textarea, !event.shiftKey); | |
} else { | |
const newValue = this.value.substring(0, start) + '\t' + this.value.substring(end); | |
this.updateValue(newValue); | |
setTimeout(() => { | |
textarea.selectionStart = textarea.selectionEnd = start + 1; | |
textarea.focus(); | |
}, 0); | |
} | |
} | |
private handleBlockIndent(textarea: HTMLTextAreaElement, indent: boolean): void { | |
const start = textarea.selectionStart; | |
const end = textarea.selectionEnd; | |
const value = this.value; | |
const lineStart = value.lastIndexOf('\n', start - 1) + 1; | |
const lineEnd = value.indexOf('\n', end); | |
const actualEnd = lineEnd === -1 ? value.length : lineEnd; | |
const selectedLines = value.substring(lineStart, actualEnd); | |
const lines = selectedLines.split('\n'); | |
let newLines: string[]; | |
if (indent) { | |
newLines = lines.map(line => '\t' + line); | |
} else { | |
newLines = lines.map(line => line.startsWith('\t') ? line.substring(1) : line); | |
} | |
const newText = newLines.join('\n'); | |
const newValue = value.substring(0, lineStart) + newText + value.substring(actualEnd); | |
this.updateValue(newValue); | |
setTimeout(() => { | |
const lengthDiff = newText.length - selectedLines.length; | |
textarea.selectionStart = lineStart; | |
textarea.selectionEnd = actualEnd + lengthDiff; | |
textarea.focus(); | |
}, 0); | |
} | |
handleEnterKey(event: KeyboardEvent): void { | |
event.preventDefault(); | |
const textarea = event.target as HTMLTextAreaElement; | |
const start = textarea.selectionStart; | |
const value = this.value; | |
const lineStart = value.lastIndexOf('\n', start - 1) + 1; | |
const currentLine = value.substring(lineStart, start); | |
const indent = currentLine.match(/^[\t ]*/)?.[0] || ''; | |
const prevChar = value[start - 1]; | |
const nextChar = value[start]; | |
let newLineContent = '\n' + indent; | |
let cursorOffset = newLineContent.length; | |
if (prevChar in this.bracketPairs) { | |
newLineContent = '\n' + indent + '\t'; | |
cursorOffset = newLineContent.length; | |
if (nextChar === this.bracketPairs[prevChar]) { | |
newLineContent += '\n' + indent; | |
} | |
} | |
const newValue = value.substring(0, start) + newLineContent + value.substring(start); | |
this.updateValue(newValue); | |
setTimeout(() => { | |
textarea.selectionStart = textarea.selectionEnd = start + cursorOffset; | |
textarea.focus(); | |
}, 0); | |
} | |
handleBracketInput(event: KeyboardEvent): void { | |
const textarea = event.target as HTMLTextAreaElement; | |
const char = event.key; | |
if (char in this.bracketPairs) { | |
event.preventDefault(); | |
const start = textarea.selectionStart; | |
const end = textarea.selectionEnd; | |
const value = this.value; | |
const selectedText = value.substring(start, end); | |
const closingBracket = this.bracketPairs[char]; | |
let newValue: string; | |
let cursorPos: number; | |
if (selectedText) { | |
newValue = value.substring(0, start) + char + selectedText + closingBracket + value.substring(end); | |
cursorPos = start + 1 + selectedText.length; | |
} else { | |
newValue = value.substring(0, start) + char + closingBracket + value.substring(end); | |
cursorPos = start + 1; | |
} | |
this.updateValue(newValue); | |
setTimeout(() => { | |
textarea.selectionStart = textarea.selectionEnd = cursorPos; | |
textarea.focus(); | |
}, 0); | |
} else if (Object.values(this.bracketPairs).includes(char)) { | |
const start = textarea.selectionStart; | |
const value = this.value; | |
const nextChar = value[start]; | |
if (nextChar === char) { | |
event.preventDefault(); | |
textarea.selectionStart = textarea.selectionEnd = start + 1; | |
} | |
} | |
} | |
handleCursorMove(event: any): void { | |
this.cursorPosition = event.target.selectionStart; | |
} | |
insertVariable(variable: string): void { | |
const textarea = this.textareaRef.nativeElement; | |
const start = this.cursorPosition; | |
const variableText = `{{${variable}}}`; | |
const newValue = this.value.substring(0, start) + variableText + this.value.substring(start); | |
this.updateValue(newValue); | |
setTimeout(() => { | |
const newPos = start + variableText.length; | |
textarea.selectionStart = textarea.selectionEnd = newPos; | |
textarea.focus(); | |
}, 0); | |
} | |
private updateValue(newValue: string): void { | |
this.value = newValue; | |
this.onChange(newValue); | |
} | |
} |