749 lines
22 KiB
Plaintext
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 " ";
|
|
|
|
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> |