SessionZeroWasm/SessionZero/Pages/SzfEditor.razor

855 lines
28 KiB
Plaintext

@page "/szf-editor"
@using System.Text
@using System.Text.RegularExpressions
@using Microsoft.AspNetCore.Components.Rendering
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@inject IJSRuntime JSRuntime
<PageTitle>SZF Editor</PageTitle>
<div class="szf-editor">
<div class="toolbar">
<button class="toolbar-btn" @onclick="OpenFile">
Open
</button>
<InputFile OnChange="HandleFileSelected" style="display: none;" @ref="fileInput" accept=".szf" />
<button class="toolbar-btn" @onclick="SaveFile">Save</button>
<button class="toolbar-btn" @onclick="ToggleConsole">Toggle Console</button>
</div>
<div class="main-container">
<div class="editor-container">
<div class="code-editor" @ref="editorElement">
<div class="editor-gutter">
@for (int i = 1; i <= _lines.Count; i++)
{
<div class="line-number @(HasErrorOnLine(i-1) ? "error-line" : "")">@i</div>
}
</div>
<div class="editor-content">
<textarea @bind="_content"
@bind:event="oninput"
@onkeydown="HandleKeyDown"
@onscroll="HandleScroll"
class="editor-textarea"
spellcheck="false"
autocomplete="off"
@ref="textareaRef"></textarea>
<div class="syntax-overlay" @ref="overlayRef">
@for (int i = 0; i < _highlightedLines.Count; i++)
{
<div class="syntax-line @(HasErrorOnLine(i) ? "error-underline" : "")">@((MarkupString)_highlightedLines[i])</div>
}
</div>
</div>
</div>
<div class="console @(_showConsole ? "" : "hidden")">
@if (_errors.Any())
{
@foreach (var error in _errors)
{
<div class="error">Line @(error.Line + 1): @error.Message</div>
}
}
else
{
<div>No errors.</div>
}
</div>
</div>
<div class="preview-container">
<div class="preview">
@if (_parsedStructure != null)
{
@RenderStructure(_parsedStructure)
}
</div>
</div>
</div>
</div>
@code {
private ElementReference editorElement;
private ElementReference textareaRef;
private ElementReference overlayRef;
private InputFile fileInput = null!;
private string _content = "";
private List<string> _lines = new();
private List<string> _highlightedLines = new();
private List<SzfError> _errors = new();
private Dictionary<string, object>? _parsedStructure;
private bool _showConsole = true;
private static readonly HashSet<string> ValidTypes = new()
{
"text", "text-field", "number", "bool", "calculated",
"system", "entry-reference", "entry-reference-list"
};
protected override void OnInitialized()
{
var exampleSzf = "";
SetContent(exampleSzf);
}
private void SetContent(string content)
{
_content = content;
_lines = content.Split('\n').ToList();
UpdateSyntaxHighlighting();
ParseContent();
}
private void UpdateSyntaxHighlighting()
{
_highlightedLines.Clear();
foreach (var line in _lines)
{
_highlightedLines.Add(HighlightLine(line));
}
StateHasChanged();
}
private string HighlightLine(string line)
{
if (string.IsNullOrWhiteSpace(line))
return "&nbsp;";
var trimmed = line.Trim();
// Comments
if (trimmed.StartsWith("#"))
{
return $"<span class=\"cm-comment\">{System.Web.HttpUtility.HtmlEncode(line)}</span>";
}
// Metadata
if (trimmed.StartsWith("!"))
{
var parts = line.Split(':', 2);
if (parts.Length == 2)
{
var key = System.Web.HttpUtility.HtmlEncode(parts[0]);
var value = System.Web.HttpUtility.HtmlEncode(parts[1]);
return $"<span class=\"cm-meta\">{key}:</span>{value}";
}
return $"<span class=\"cm-meta\">{System.Web.HttpUtility.HtmlEncode(line)}</span>";
}
// Section headers
if (trimmed.StartsWith("[") && trimmed.EndsWith("]"))
{
return $"<span class=\"cm-header\">{System.Web.HttpUtility.HtmlEncode(line)}</span>";
}
// Field definitions
var fieldMatch = Regex.Match(trimmed, @"^([\w\d_]+)\s+\(([\w\-]+)\)\s*=\s*(.*)$");
if (fieldMatch.Success)
{
var fieldName = fieldMatch.Groups[1].Value;
var fieldType = fieldMatch.Groups[2].Value;
var fieldValue = fieldMatch.Groups[3].Value;
var leadingSpaces = line.Length - line.TrimStart().Length;
var spaces = leadingSpaces > 0 ? new string(' ', leadingSpaces) : "";
var highlightedName = $"<span class=\"cm-variable\">{System.Web.HttpUtility.HtmlEncode(fieldName)}</span>";
var highlightedType = $"<span class=\"cm-def\">({System.Web.HttpUtility.HtmlEncode(fieldType)})</span>";
var highlightedOperator = $"<span class=\"cm-operator\"> = </span>";
string highlightedValue;
if (fieldType == "calculated")
{
highlightedValue = HighlightCalculatedValue(fieldValue);
}
else if (fieldType == "number" && Regex.IsMatch(fieldValue.Trim(), @"^-?\d+(\.\d+)?$"))
{
highlightedValue = $"<span class=\"cm-number\">{System.Web.HttpUtility.HtmlEncode(fieldValue)}</span>";
}
else if (fieldType == "bool" && Regex.IsMatch(fieldValue.Trim(), @"^(true|false)$", RegexOptions.IgnoreCase))
{
highlightedValue = $"<span class=\"cm-atom\">{System.Web.HttpUtility.HtmlEncode(fieldValue)}</span>";
}
else
{
highlightedValue = $"<span class=\"cm-string\">{System.Web.HttpUtility.HtmlEncode(fieldValue)}</span>";
}
return $"{spaces}{highlightedName} {highlightedType}{highlightedOperator}{highlightedValue}";
}
return System.Web.HttpUtility.HtmlEncode(line);
}
private string HighlightCalculatedValue(string value)
{
var result = new StringBuilder();
var tokens = Regex.Split(value, @"(\+|\-|\*|\/|\(|\)|[\w\d_]+|\d+(?:\.\d+)?|\s+)");
foreach (var token in tokens)
{
if (string.IsNullOrEmpty(token)) continue;
if (Regex.IsMatch(token, @"^[+\-*/()]$"))
{
result.Append($"<span class=\"cm-operator\">{System.Web.HttpUtility.HtmlEncode(token)}</span>");
}
else if (Regex.IsMatch(token, @"^-?\d+(\.\d+)?$"))
{
result.Append($"<span class=\"cm-number\">{System.Web.HttpUtility.HtmlEncode(token)}</span>");
}
else if (Regex.IsMatch(token, @"^[A-Za-z_][\w\d_]*$"))
{
result.Append($"<span class=\"cm-variable-2\">{System.Web.HttpUtility.HtmlEncode(token)}</span>");
}
else
{
result.Append(System.Web.HttpUtility.HtmlEncode(token));
}
}
return result.ToString();
}
private void ParseContent()
{
var result = ParseSzf(_content);
_parsedStructure = result.Structure;
_errors = result.Errors;
}
private (Dictionary<string, object> Structure, List<SzfError> Errors) ParseSzf(string text)
{
var lines = text.Split('\n');
var structure = new Dictionary<string, object>();
var current = structure;
var path = new List<string>();
var errors = new List<SzfError>();
var fileType = "";
var schemaVersion = "";
var hasRequiredHeader = false;
// First pass - verify header requirements
if (lines.Length >= 2)
{
var typeMatch = Regex.Match(lines[0].Trim(), @"^!type:\s*([\w_-]+)$");
var schemaMatch = Regex.Match(lines[1].Trim(), @"^!schema:\s*([\d.]+)$");
if (typeMatch.Success && schemaMatch.Success)
{
fileType = typeMatch.Groups[1].Value;
schemaVersion = schemaMatch.Groups[1].Value;
hasRequiredHeader = true;
}
}
if (!hasRequiredHeader)
{
errors.Add(new SzfError(0, "Missing required header. First two lines must be !type: and !schema:"));
}
else if (!new[] { "dataset", "character_template", "character", "session" }.Contains(fileType))
{
errors.Add(new SzfError(0, $"Invalid file type: {fileType}. Must be one of: dataset, character_template, character, session"));
}
// Second pass - parse content
for (int idx = 0; idx < lines.Length; idx++)
{
var line = lines[idx];
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith("#"))
continue;
if (trimmed.StartsWith("!"))
{
var parts = trimmed.Substring(1).Split(':', 2);
if (parts.Length < 2)
{
errors.Add(new SzfError(idx, "Invalid metadata: Missing value after ':'"));
}
else
{
structure[parts[0].Trim()] = parts[1].Trim();
}
}
else if (trimmed.StartsWith("[") && trimmed.EndsWith("]"))
{
// Extract section name (remove brackets)
var sectionPath = trimmed.Substring(1, trimmed.Length - 2).Replace(": ", ".");
// Parse the section path and validate each segment
var sections = sectionPath.Split('.');
for (int i = 0; i < sections.Length; i++)
{
var section = sections[i].Trim();
if (string.IsNullOrWhiteSpace(section))
{
errors.Add(new SzfError(idx, "Empty section name is not allowed"));
continue;
}
// Check for Entry: prefix for datasets
if (fileType == "dataset" && i == 0 && section.StartsWith("Entry:"))
{
// This is valid for datasets
}
// Otherwise, check if section follows PascalCase
else if (!Regex.IsMatch(section, @"^([A-Z][a-z0-9]*)+$") && section != "Required_Datasets")
{
errors.Add(new SzfError(idx, $"Section name '{section}' must use PascalCase (e.g., AbilityScores, CharacterInfo)"));
}
}
path = sectionPath.Split('.').Where(p => !string.IsNullOrWhiteSpace(p)).ToList();
current = structure;
foreach (var p in path)
{
var cleanP = p.Trim();
if (!string.IsNullOrEmpty(cleanP))
{
if (!current.ContainsKey(cleanP))
current[cleanP] = new Dictionary<string, object>();
current = (Dictionary<string, object>)current[cleanP];
}
}
}
else
{
// For character files, the field syntax is different (no type)
if (fileType == "character")
{
var characterFieldMatch = Regex.Match(trimmed, @"^([\w\d_]+)\s*=\s*(.*)$");
if (characterFieldMatch.Success)
{
var name = characterFieldMatch.Groups[1].Value;
var value = characterFieldMatch.Groups[2].Value.Trim();
// Validate PascalCase for field names in character files
if (!Regex.IsMatch(name, @"^([A-Z][a-z0-9]*)+$") && !name.Contains("."))
{
errors.Add(new SzfError(idx, $"Field name '{name}' must use PascalCase (e.g., CharacterName, MaxHealth)"));
}
current[name] = value;
continue;
}
}
// Standard field definition with type
var match = Regex.Match(trimmed, @"^([\w\d_]+)\s+\(([\w\-]+)\)\s*=\s*(.*)$");
if (!match.Success)
{
if (!string.IsNullOrEmpty(trimmed))
errors.Add(new SzfError(idx, "Invalid field definition. Expected format: FieldName (type) = value"));
continue;
}
var fieldName = match.Groups[1].Value;
var fieldType = match.Groups[2].Value;
var fieldValue = match.Groups[3].Value.Trim();
// Validate PascalCase for field names
if (!Regex.IsMatch(fieldName, @"^([A-Z][a-z0-9]*)+$"))
{
errors.Add(new SzfError(idx, $"Field name '{fieldName}' must use PascalCase (e.g., CharacterName, MaxHealth)"));
}
// Validate field type
if (!ValidTypes.Contains(fieldType))
{
errors.Add(new SzfError(idx, $"Unknown field type: \"{fieldType}\""));
}
// Validate specific field value formats
if (fieldType == "number" && !string.IsNullOrEmpty(fieldValue) &&
!Regex.IsMatch(fieldValue, @"^-?\d+(\.\d+)?$"))
{
errors.Add(new SzfError(idx, $"Invalid number format: {fieldValue}"));
}
else if (fieldType == "bool" && !string.IsNullOrEmpty(fieldValue) &&
!Regex.IsMatch(fieldValue.ToLower(), @"^(true|false)$"))
{
errors.Add(new SzfError(idx, $"Boolean values must be 'true' or 'false', got: {fieldValue}"));
}
current[fieldName] = new Dictionary<string, object> { ["type"] = fieldType, ["value"] = fieldValue };
}
}
// Verify required metadata sections based on file type
if (hasRequiredHeader)
{
if (!structure.ContainsKey("Metadata") && !(structure.ContainsKey("Metadata") && structure["Metadata"] is Dictionary<string, object>))
{
errors.Add(new SzfError(0, "Missing [Metadata] section which is required for all file types"));
}
else
{
var metadata = structure["Metadata"] as Dictionary<string, object>;
// Check file type specific requirements
if (fileType == "dataset")
{
CheckRequiredMetadata(metadata, new[] {"Name", "Type", "Version"}, errors);
}
else if (fileType == "character_template")
{
CheckRequiredMetadata(metadata, new[] {"Name", "Version"}, errors);
// Check for RequiredDatasets section if referenced
if (structure.ContainsValue("dataset-reference") || structure.ContainsValue("entry-reference"))
{
if (!structure.ContainsKey("RequiredDatasets") && !structure.ContainsKey("Required_Datasets"))
{
errors.Add(new SzfError(0, "Template uses dataset references but is missing [RequiredDatasets] section"));
}
}
}
else if (fileType == "character")
{
CheckRequiredMetadata(metadata, new[] {"TemplateRef"}, errors);
}
}
}
return (structure, errors);
}
private void CheckRequiredMetadata(Dictionary<string, object> metadata, string[] requiredFields, List<SzfError> errors)
{
foreach (var field in requiredFields)
{
if (!metadata.ContainsKey(field))
{
// Reporting the error on a general line since this check runs after initial parsing.
errors.Add(new SzfError(0, $"Missing required metadata field: '{field}'"));
}
}
}
private bool HasErrorOnLine(int lineIndex)
{
return _errors.Any(e => e.Line == lineIndex);
}
private RenderFragment RenderStructure(Dictionary<string, object> structure)
{
return builder =>
{
int sequence = 0;
foreach (var kvp in structure)
{
if (kvp.Value is string stringValue)
{
builder.OpenElement(sequence++, "div");
builder.OpenElement(sequence++, "strong");
builder.AddContent(sequence++, $"{kvp.Key}:");
builder.CloseElement();
builder.AddContent(sequence++, $" {stringValue}");
builder.CloseElement();
}
else if (kvp.Value is Dictionary<string, object> dict)
{
RenderSection(builder, ref sequence, dict, kvp.Key, true);
}
}
};
}
private void RenderSection(RenderTreeBuilder builder, ref int sequence, Dictionary<string, object> obj, string title, bool isTopLevel = false)
{
var sectionContent = new Dictionary<string, object>();
var subsections = new Dictionary<string, object>();
foreach (var kvp in obj)
{
if (kvp.Value is Dictionary<string, object> dict && !dict.ContainsKey("type"))
{
subsections[kvp.Key] = dict;
}
else
{
sectionContent[kvp.Key] = kvp.Value;
}
}
var hasContent = sectionContent.Any();
if (hasContent || isTopLevel)
{
builder.OpenElement(sequence++, "div");
builder.AddAttribute(sequence++, "class", "section");
builder.OpenElement(sequence++, "h3");
builder.AddContent(sequence++, title.Replace("_", " "));
builder.CloseElement();
if (hasContent)
{
builder.OpenElement(sequence++, "table");
builder.OpenElement(sequence++, "thead");
builder.OpenElement(sequence++, "tr");
builder.OpenElement(sequence++, "th");
builder.AddContent(sequence++, "Name");
builder.CloseElement();
builder.OpenElement(sequence++, "th");
builder.AddContent(sequence++, "Type");
builder.CloseElement();
builder.OpenElement(sequence++, "th");
builder.AddContent(sequence++, "Value");
builder.CloseElement();
builder.CloseElement();
builder.CloseElement();
builder.OpenElement(sequence++, "tbody");
foreach (var kvp in sectionContent)
{
if (kvp.Value is Dictionary<string, object> field &&
field.ContainsKey("type") && field.ContainsKey("value"))
{
builder.OpenElement(sequence++, "tr");
builder.OpenElement(sequence++, "td");
builder.AddAttribute(sequence++, "class", "field");
builder.AddContent(sequence++, kvp.Key);
builder.CloseElement();
builder.OpenElement(sequence++, "td");
builder.AddAttribute(sequence++, "class", "type");
builder.AddContent(sequence++, field["type"].ToString());
builder.CloseElement();
builder.OpenElement(sequence++, "td");
builder.AddAttribute(sequence++, "class", "value");
builder.AddContent(sequence++, field["value"].ToString());
builder.CloseElement();
builder.CloseElement();
}
}
builder.CloseElement();
builder.CloseElement();
}
else if (isTopLevel && !subsections.Any())
{
builder.AddContent(sequence++, "No entries.");
}
builder.CloseElement();
}
foreach (var kvp in subsections)
{
if (kvp.Value is Dictionary<string, object> subDict)
{
RenderSection(builder, ref sequence, subDict, $"{title}.{kvp.Key}");
}
}
}
private async Task HandleKeyDown(KeyboardEventArgs e)
{
await Task.Delay(1); // Allow the input to be processed
await InvokeAsync(() =>
{
_lines = _content.Split('\n').ToList();
UpdateSyntaxHighlighting();
ParseContent();
});
}
private async Task HandleScroll()
{
// Sync scroll between textarea and overlay
await JSRuntime.InvokeVoidAsync("syncScroll", textareaRef, overlayRef);
}
private async Task OpenFile()
{
var element = fileInput.Element;
if (element.HasValue)
{
await JSRuntime.InvokeVoidAsync("triggerFileInput", element.Value);
}
}
private async Task HandleFileSelected(InputFileChangeEventArgs e)
{
var file = e.File;
if (file != null)
{
using var reader = new StreamReader(file.OpenReadStream());
var content = await reader.ReadToEndAsync();
SetContent(content);
}
}
private async Task SaveFile()
{
var fileName = "file.szf";
var content = _content;
await JSRuntime.InvokeVoidAsync("downloadFile", fileName, content);
}
private void ToggleConsole()
{
_showConsole = !_showConsole;
}
public class SzfError
{
public int Line { get; }
public string Message { get; }
public SzfError(int line, string message)
{
Line = line;
Message = message;
}
}
}
<style>
html, body {
margin: 0;
height: 100%;
font-family: sans-serif;
background: #1e1e1e;
color: #ddd;
display: flex;
flex-direction: column;
}
.szf-editor {
display: flex;
flex-direction: column;
height: 100vh;
background: #1e1e1e;
color: #ddd;
text-align: left;
}
.toolbar {
display: flex;
padding: 0.5em;
background: #333;
gap: 0.5em;
border-bottom: 1px solid #444;
}
.toolbar-btn {
background: #444;
color: #fff;
border: none;
padding: 0.4em 1em;
cursor: pointer;
border-radius: 4px;
}
.toolbar-btn:hover {
background: #555;
}
.main-container {
display: flex;
flex: 1;
overflow: hidden;
}
.editor-container, .preview-container {
width: 50%;
height: 100%;
display: flex;
flex-direction: column;
}
.code-editor {
flex: 1;
display: flex;
background: #21252b;
position: relative;
overflow: hidden;
}
.editor-gutter {
background: #21252b;
border-right: 1px solid #3a3f4b;
padding: 0;
min-width: 50px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
}
.line-number {
color: #636d83;
padding: 0 8px;
text-align: right;
user-select: none;
height: 21px;
display: flex;
align-items: center;
justify-content: flex-end;
}
.line-number.error-line {
background: #3c1e1e;
color: #ff5555;
}
.editor-content {
flex: 1;
position: relative;
overflow: hidden;
}
.editor-textarea {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: transparent;
color: transparent;
border: none;
outline: none;
resize: none;
font-size: 14px;
line-height: 1.5;
padding: 0 8px;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
overflow-y: auto;
z-index: 2;
caret-color: white;
}
.syntax-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
font-size: 14px;
line-height: 1.5;
padding: 0 8px;
white-space: pre;
overflow: hidden;
z-index: 1;
}
.syntax-line {
height: 21px;
}
.syntax-line.error-underline {
text-decoration: underline wavy #ff5555;
text-decoration-skip-ink: none;
}
/* SZF Syntax Highlighting */
.cm-header { color: #61afef; }
.cm-meta { color: #c678dd; }
.cm-variable { color: #e06c75; }
.cm-def { color: #98c379; font-style: italic; }
.cm-string { color: #56b6c2; }
.cm-operator { color: #c678dd; }
.cm-number { color: #d19a66; }
.cm-atom { color: #d19a66; }
.cm-variable-2 { color: #abb2bf; }
.cm-comment { color: #7f848e; font-style: italic; }
.console {
background: #111;
padding: 0.5em;
font-family: monospace;
font-size: 13px;
max-height: 120px;
overflow-y: auto;
border-top: 1px solid #444;
}
.console.hidden {
display: none;
}
.error {
color: #ff5555;
}
.preview {
flex: 1;
background: #21252b;
overflow: auto;
padding: 1em;
font-family: monospace;
font-size: 14px;
box-sizing: border-box;
}
.section {
border: 1px solid #444;
margin: 0.5em 0;
padding: 0.5em;
border-radius: 4px;
background: #282c34;
}
.section h3 {
margin: 0 0 0.3em 0;
color: #61afef;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 0.5em;
}
th, td {
border: 1px solid #444;
padding: 0.3em 0.5em;
}
th {
background: #333;
color: #ddd;
}
.field { color: #e06c75; }
.type { color: #98c379; }
.value { color: #56b6c2; }
</style>
<script>
window.syncScroll = (textarea, overlay) => {
overlay.scrollTop = textarea.scrollTop;
overlay.scrollLeft = textarea.scrollLeft;
};
window.triggerFileInput = (element) => {
element.click();
};
window.downloadFile = (filename, content) => {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
</script>