SessionZeroWasm/SessionZero/Pages/SzfEditor.razor

749 lines
22 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", "group", "calculated",
"system", "dataset-reference", "dataset-type",
"dataset-reference-multiple", "dataset-type-multiple"
};
protected override void OnInitialized()
{
var exampleSzf = @"!type: character_template
!schema: 1.0.0
bad line here
# This is a comment. It will be ignored by the parser.
# It's useful for adding notes to your templates.
[Metadata]
Name (text) = Robust Fantasy Character
Author (text) = Gemini
GameSystem (text) = Fantasy RPG Universal
Description (text-field) = A character sheet with calculated values and dependencies.
InvalidType (faketype) = some value
[Required_Datasets]
Classes (dataset-reference) = Core Classes|c3d4e5f6-g7h8-9012-cdef-123456789012|1.5.0
[Section: Ability Scores]
Strength (number) = 14
Dexterity (number) = 12
Constitution (number) = 15
IsCool (bool) = true
[Section.Ability_Scores.Modifiers]
# Modifiers are calculated automatically.
StrengthMod (calculated) = (Strength - 10) / 2
DexterityMod (calculated) = (Dexterity - 10) / 2
ConstitutionMod (calculated) = (Constitution - 10) / 2
[Section: Combat]
ProficiencyBonus (number) = 2
HitPoints (calculated) = 10 + ConstitutionMod
ArmorClass (calculated) = 10 + DexterityMod
";
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>();
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("]"))
{
var sectionPath = trimmed.Substring(1, trimmed.Length - 2).Replace(": ", ".");
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
{
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 name = match.Groups[1].Value;
var type = match.Groups[2].Value;
var value = match.Groups[3].Value.Trim();
if (!ValidTypes.Contains(type))
{
errors.Add(new SzfError(idx, $"Unknown field type: \"{type}\""));
}
current[name] = new Dictionary<string, object> { ["type"] = type, ["value"] = value };
}
}
return (structure, errors);
}
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>