rkgk/static/brush-editor.js
リキ萌 0ddc42c00f sidebar layout
switch the app from floating panels to a static sidebar on the right with resizable tools
expect more layout bugs from now on
2025-06-27 23:24:09 +02:00

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);