switch the app from floating panels to a static sidebar on the right with resizable tools expect more layout bugs from now on
426 lines
16 KiB
JavaScript
426 lines
16 KiB
JavaScript
import { CodeEditor, Selection } from "rkgk/code-editor.js";
|
|
import { SaveData } from "rkgk/framework.js";
|
|
import { builtInPresets } from "rkgk/brush-box.js";
|
|
import { ResizeHandle } from "rkgk/resize-handle.js";
|
|
import { BrushPreview } from "rkgk/brush-preview.js";
|
|
import { BrushCostGauges } from "rkgk/brush-cost.js";
|
|
|
|
export class BrushEditor extends HTMLElement {
|
|
saveData = new SaveData("brushEditor");
|
|
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.saveData.attachToElement(this);
|
|
|
|
const defaultBrush = builtInPresets[0];
|
|
|
|
this.editorContainer = this.appendChild(document.createElement("div"));
|
|
this.editorContainer.classList.add("editor");
|
|
|
|
this.nameEditor = this.editorContainer.appendChild(document.createElement("input"));
|
|
this.nameEditor.value = this.saveData.get("name", defaultBrush.name);
|
|
this.nameEditor.classList.add("name");
|
|
this.nameEditor.addEventListener("input", () => {
|
|
this.saveData.set("name", this.nameEditor.value);
|
|
|
|
this.dispatchEvent(
|
|
Object.assign(
|
|
new Event(".nameChanged", {
|
|
newName: this.nameEditor.value,
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
|
|
this.codeEditor = this.editorContainer.appendChild(
|
|
new CodeEditor([
|
|
{
|
|
className: "layer-syntax",
|
|
render: (code, element) => this.#renderSyntax(code, element),
|
|
},
|
|
{
|
|
className: "layer-error-squiggles",
|
|
render: (code, element) => this.#renderErrorSquiggles(code, element),
|
|
},
|
|
]),
|
|
);
|
|
this.codeEditor.setCode(
|
|
// NOTE(localStorage): Migration from old storage key.
|
|
this.saveData.get("code") ??
|
|
localStorage.getItem("rkgk.brushEditor.code") ??
|
|
defaultBrush.code,
|
|
);
|
|
this.codeEditor.addEventListener(".codeChanged", (event) => {
|
|
this.saveData.set("code", event.newCode);
|
|
|
|
this.dispatchEvent(
|
|
Object.assign(new Event(".codeChanged"), {
|
|
newCode: event.newCode,
|
|
}),
|
|
);
|
|
});
|
|
|
|
this.output = document.createElement("output");
|
|
|
|
this.appendChild(
|
|
new ResizeHandle({
|
|
direction: "horizontal",
|
|
inverse: true,
|
|
targetElement: this,
|
|
targetProperty: "--brush-preview-height",
|
|
initSize: 192,
|
|
minSize: 64,
|
|
}),
|
|
).classList.add("always-visible");
|
|
|
|
this.appendChild(this.output);
|
|
|
|
this.ok = this.output.appendChild(document.createElement("div"));
|
|
this.ok.classList.add("ok");
|
|
this.brushPreview = this.ok.appendChild(new BrushPreview());
|
|
this.brushCostGauges = this.ok.appendChild(new BrushCostGauges());
|
|
|
|
this.errors = this.output.appendChild(document.createElement("pre"));
|
|
this.errors.classList.add("errors");
|
|
|
|
// NOTE(localStorage): Migration from old storage key.
|
|
localStorage.removeItem("rkgk.brushEditor.code");
|
|
}
|
|
|
|
get name() {
|
|
return this.nameEditor.value;
|
|
}
|
|
|
|
setName(newName) {
|
|
this.nameEditor.value = newName;
|
|
this.saveData.set("name", this.nameEditor.value);
|
|
}
|
|
|
|
get code() {
|
|
return this.codeEditor.code;
|
|
}
|
|
|
|
setCode(newCode) {
|
|
this.codeEditor.setCode(newCode);
|
|
}
|
|
|
|
resetErrors() {
|
|
this.output.dataset.state = "ok";
|
|
this.errors.textContent = "";
|
|
}
|
|
|
|
async updatePreview(haku, { getStats }) {
|
|
let previewResult = await this.brushPreview.renderBrush(haku);
|
|
this.brushCostGauges.update(getStats());
|
|
if (previewResult.status == "error") {
|
|
this.renderHakuResult(previewResult.result);
|
|
}
|
|
}
|
|
|
|
renderHakuResult(result) {
|
|
this.resetErrors();
|
|
this.errorSquiggles = null;
|
|
|
|
if (result.status != "error") {
|
|
// We need to request a rebuild if there's no error to remove any squiggles that may be
|
|
// left over from the error state.
|
|
this.codeEditor.renderLayer("layer-error-squiggles");
|
|
return;
|
|
}
|
|
|
|
this.output.dataset.state = "error";
|
|
|
|
if (result.errorKind == "diagnostics") {
|
|
this.codeEditor.rebuildLineMap();
|
|
this.errorSquiggles = this.#computeErrorSquiggles(
|
|
this.codeEditor.lineMap,
|
|
result.diagnostics,
|
|
);
|
|
this.codeEditor.renderLayer("layer-error-squiggles");
|
|
|
|
this.errors.textContent = result.diagnostics
|
|
.map(
|
|
(diagnostic) => `${diagnostic.start}..${diagnostic.end}: ${diagnostic.message}`,
|
|
)
|
|
.join("\n");
|
|
} else if (result.errorKind == "plain") {
|
|
this.errors.textContent = result.message;
|
|
} else if (result.errorKind == "exception") {
|
|
let renderer = new ErrorException(result);
|
|
let squiggles = renderer.prepareSquiggles();
|
|
|
|
this.codeEditor.rebuildLineMap();
|
|
this.errorSquiggles = this.#computeErrorSquiggles(
|
|
this.codeEditor.lineMap,
|
|
squiggles.diagnostics,
|
|
);
|
|
this.codeEditor.renderLayer("layer-error-squiggles");
|
|
|
|
renderer.addEventListener(".functionNameMouseEnter", (event) => {
|
|
squiggles.highlightSegment(event.frameIndex);
|
|
});
|
|
|
|
renderer.addEventListener(".functionNameMouseLeave", () => {
|
|
squiggles.highlightSegment(null);
|
|
});
|
|
|
|
renderer.addEventListener(".functionNameClick", (event) => {
|
|
let span = event.frame.span;
|
|
this.codeEditor.textArea.focus();
|
|
this.codeEditor.setSelection(new Selection(span.start, span.end));
|
|
});
|
|
|
|
this.errors.replaceChildren();
|
|
this.errors.appendChild(renderer);
|
|
} else {
|
|
console.warn(`unknown error kind: ${result.errorKind}`);
|
|
this.errors.textContent = "(unknown error kind)";
|
|
}
|
|
}
|
|
|
|
#computeErrorSquiggles(lineMap, diagnostics) {
|
|
// This is an extremely inefficient algorithm.
|
|
// If we had better control of drawing (I'm talking: letter per letter, with a shader!)
|
|
// this could be done lot more efficiently. But alas, HTML giveth, HTML taketh away.
|
|
|
|
// The first step is to determine per-line spans.
|
|
// Since we render squiggles by line, it makes the most sense to split the work up to a
|
|
// line by line basis.
|
|
|
|
let rawLineSpans = new Map();
|
|
|
|
for (let diagnostic of diagnostics) {
|
|
let firstLine = lineMap.lineIndexAt(diagnostic.start);
|
|
let lastLine = lineMap.lineIndexAt(diagnostic.end);
|
|
for (let i = firstLine; i <= lastLine; ++i) {
|
|
let bounds = lineMap.get(i);
|
|
let start = i == firstLine ? diagnostic.start - bounds.start : 0;
|
|
let end = i == lastLine ? diagnostic.end - bounds.start : bounds.end;
|
|
|
|
if (!rawLineSpans.has(i)) {
|
|
rawLineSpans.set(i, []);
|
|
}
|
|
let onThisLine = rawLineSpans.get(i);
|
|
onThisLine.push({ start, end, diagnostic });
|
|
}
|
|
}
|
|
|
|
// Once we have the diagnostics subdivided per line, it's time to determine the _boundaries_
|
|
// where diagnostics need to appear.
|
|
// Later we will turn those boundaries into spans, and assign them appropriate classes.
|
|
|
|
let segmentedLines = new Map();
|
|
for (let [line, spans] of rawLineSpans) {
|
|
let lineBounds = lineMap.get(line);
|
|
|
|
let spanBorderSet = new Set([0]);
|
|
for (let { start, end } of spans) {
|
|
spanBorderSet.add(start);
|
|
spanBorderSet.add(end);
|
|
}
|
|
spanBorderSet.add(lineBounds.end - lineBounds.start);
|
|
let spanBorders = Array.from(spanBorderSet).sort((a, b) => a - b);
|
|
|
|
let segments = [];
|
|
let previous = 0;
|
|
for (let i = 1; i < spanBorders.length; ++i) {
|
|
segments.push({ start: previous, end: spanBorders[i], diagnostics: [] });
|
|
previous = spanBorders[i];
|
|
}
|
|
|
|
for (let span of spans) {
|
|
for (let segment of segments) {
|
|
if (segment.start >= span.start && segment.end <= span.end) {
|
|
segment.diagnostics.push(span.diagnostic);
|
|
}
|
|
}
|
|
}
|
|
|
|
segmentedLines.set(line, segments);
|
|
}
|
|
|
|
return segmentedLines;
|
|
}
|
|
|
|
#renderErrorSquiggles(lines, element) {
|
|
if (this.errorSquiggles == null) return;
|
|
|
|
for (let i = 0; i < lines.lineBounds.length; ++i) {
|
|
let lineBounds = lines.lineBounds[i];
|
|
let lineElement = element.appendChild(document.createElement("span"));
|
|
lineElement.classList.add("line");
|
|
|
|
let segments = this.errorSquiggles.get(i);
|
|
if (segments != null) {
|
|
for (let segment of segments) {
|
|
let text = (lineBounds.substring + " ").substring(segment.start, segment.end);
|
|
if (segment.diagnostics.length == 0) {
|
|
lineElement.append(text);
|
|
} else {
|
|
let spanElement = lineElement.appendChild(document.createElement("span"));
|
|
spanElement.classList.add("squiggle", "squiggle-error");
|
|
spanElement.textContent = text;
|
|
|
|
for (let diagnostic of segment.diagnostics) {
|
|
diagnostic.customizeSegment?.(diagnostic, spanElement);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
lineElement.textContent = lineBounds.substring;
|
|
}
|
|
}
|
|
}
|
|
|
|
#renderSyntax(lines, element) {
|
|
// TODO: Syntax highlighting.
|
|
// Right now we just render a layer of black text to have the text visible in the code editor.
|
|
element.textContent = lines.string;
|
|
}
|
|
}
|
|
|
|
customElements.define("rkgk-brush-editor", BrushEditor);
|
|
|
|
class ErrorException extends HTMLElement {
|
|
constructor(result) {
|
|
super();
|
|
this.result = result;
|
|
}
|
|
|
|
prepareSquiggles() {
|
|
let diagnostics = [];
|
|
let segments = [];
|
|
|
|
const customizeSegment = (diagnostic, segment) => {
|
|
// The control flow here is kind of spaghetti, but basically this fills out `segments`
|
|
// as the squiggles are being rendered.
|
|
// Once squiggles are rendered, you're free to call highlightSegment(i).
|
|
segments.push({
|
|
frameIndex: diagnostic.frameIndex,
|
|
colorIndex: diagnostic.colorIndex,
|
|
segment,
|
|
});
|
|
if (diagnostic.colorIndex != null) {
|
|
segment.classList.add("squiggle-colored");
|
|
segment.style.setProperty("--color-index", diagnostic.colorIndex);
|
|
}
|
|
};
|
|
|
|
// Iterate in reverse to let uppermost stack frames "win" and overwrite bottommost stack
|
|
// frames' colouring.
|
|
for (let i = this.result.stackTrace.length - 1; i >= 0; i--) {
|
|
let frame = this.result.stackTrace[i];
|
|
if (frame.span != null) {
|
|
diagnostics.push({
|
|
start: frame.span.start,
|
|
end: frame.span.end,
|
|
|
|
customizeSegment,
|
|
frameIndex: i,
|
|
colorIndex: i / this.result.stackTrace.length,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
diagnostics,
|
|
highlightSegment(frameIndex) {
|
|
let justHighlighted = new Set();
|
|
for (let entry of segments.values()) {
|
|
let segment = entry.segment;
|
|
if (entry.frameIndex == frameIndex) {
|
|
segment.classList.add("squiggle-highlighted");
|
|
// This logic exists so that a specific segment can display on top of
|
|
// another one if it's highlighted.
|
|
segment.style.setProperty("--highlight-color-index", entry.colorIndex);
|
|
justHighlighted.add(segment);
|
|
} else if (!justHighlighted.has(segment)) {
|
|
segment.classList.remove("squiggle-highlighted");
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
connectedCallback() {
|
|
let message = this.appendChild(document.createElement("p"));
|
|
message.classList.add("message");
|
|
message.textContent = this.result.message;
|
|
|
|
let stackTrace = this.appendChild(document.createElement("ol"));
|
|
stackTrace.classList.add("stack-trace");
|
|
for (let i = 0; i < this.result.stackTrace.length; ++i) {
|
|
let frame = this.result.stackTrace[i];
|
|
let line = stackTrace.appendChild(document.createElement("li"));
|
|
|
|
let [_, name, lambdasString] = frame.functionName.match(/([^λ]+)(λ*)/);
|
|
|
|
let inCaption = line.appendChild(document.createElement("span"));
|
|
inCaption.classList.add("in");
|
|
inCaption.innerText = "in ";
|
|
|
|
let functionName = line.appendChild(document.createElement("button"));
|
|
functionName.classList.add("function-name");
|
|
functionName.disabled = true;
|
|
functionName.innerText = name;
|
|
if (name == "(brush)") {
|
|
functionName.classList.add("function-name-brush");
|
|
functionName.title = "Top-level brush code";
|
|
}
|
|
let lambdas = functionName.appendChild(document.createElement("abbr"));
|
|
lambdas.classList.add("lambdas");
|
|
lambdas.textContent = lambdasString;
|
|
lambdas.title =
|
|
"λ - unnamed function\n" +
|
|
"Symbolizes functions that were not given a name.\n" +
|
|
"Each additional λ is one level of nesting inside another function.";
|
|
|
|
if (frame.isSystem) {
|
|
line.append(" ");
|
|
let system = line.appendChild(document.createElement("abbr"));
|
|
system.classList.add("system");
|
|
system.innerText = "(built-in)";
|
|
system.title =
|
|
"This function is built into rakugaki and does not exist in your brush's source code, so you can't see it in the editor.";
|
|
}
|
|
|
|
if (frame.span != null) {
|
|
functionName.disabled = false;
|
|
functionName.classList.add("source-link");
|
|
functionName.style.setProperty("--color-index", i / this.result.stackTrace.length);
|
|
|
|
functionName.addEventListener("mouseenter", () => {
|
|
this.dispatchEvent(
|
|
Object.assign(new Event(".functionNameMouseEnter"), {
|
|
frameIndex: i,
|
|
frame,
|
|
}),
|
|
);
|
|
});
|
|
|
|
functionName.addEventListener("mouseleave", () => {
|
|
this.dispatchEvent(
|
|
Object.assign(new Event(".functionNameMouseLeave"), {
|
|
frameIndex: i,
|
|
frame,
|
|
}),
|
|
);
|
|
});
|
|
|
|
functionName.addEventListener("click", () => {
|
|
this.dispatchEvent(
|
|
Object.assign(new Event(".functionNameClick"), {
|
|
frameIndex: i,
|
|
frame,
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
customElements.define("rkgk-brush-editor-error-exception", ErrorException);
|