add error squiggles to brush editor
gosh I hate this code. CSS custom highlight API when, pleeeeeease Firefooooox
This commit is contained in:
parent
c59a651ea3
commit
ec773b7fe1
|
@ -14,6 +14,8 @@
|
||||||
|
|
||||||
--font-body: "Atkinson Hyperlegible", sans-serif;
|
--font-body: "Atkinson Hyperlegible", sans-serif;
|
||||||
--font-monospace: "Iosevka Hyperlegible", monospace;
|
--font-monospace: "Iosevka Hyperlegible", monospace;
|
||||||
|
--line-height: 1.5;
|
||||||
|
--line-height-em: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reset */
|
/* Reset */
|
||||||
|
@ -21,7 +23,7 @@
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
line-height: 1.5;
|
line-height: var(--line-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fonts */
|
/* Fonts */
|
||||||
|
@ -37,7 +39,7 @@ button, textarea, input {
|
||||||
|
|
||||||
pre, code, textarea {
|
pre, code, textarea {
|
||||||
font-family: var(--font-monospace);
|
font-family: var(--font-monospace);
|
||||||
line-height: 1.5;
|
line-height: var(--line-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { CodeEditor } from "rkgk/code-editor.js";
|
import { CodeEditor, getLineStart } from "rkgk/code-editor.js";
|
||||||
|
|
||||||
const defaultBrush = `
|
const defaultBrush = `
|
||||||
-- This is your brush.
|
-- This is your brush.
|
||||||
|
@ -16,7 +16,14 @@ export class BrushEditor extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.classList.add("rkgk-panel");
|
this.classList.add("rkgk-panel");
|
||||||
|
|
||||||
this.codeEditor = this.appendChild(new CodeEditor());
|
this.codeEditor = this.appendChild(
|
||||||
|
new CodeEditor([
|
||||||
|
{
|
||||||
|
className: "layer-error-squiggles",
|
||||||
|
render: (code, element) => this.#renderErrorSquiggles(code, element),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
this.codeEditor.setCode(localStorage.getItem("rkgk.brushEditor.code") ?? defaultBrush);
|
this.codeEditor.setCode(localStorage.getItem("rkgk.brushEditor.code") ?? defaultBrush);
|
||||||
this.codeEditor.addEventListener(".codeChanged", (event) => {
|
this.codeEditor.addEventListener(".codeChanged", (event) => {
|
||||||
localStorage.setItem("rkgk.brushEditor.code", event.newCode);
|
localStorage.setItem("rkgk.brushEditor.code", event.newCode);
|
||||||
|
@ -46,15 +53,25 @@ export class BrushEditor extends HTMLElement {
|
||||||
|
|
||||||
renderHakuResult(phase, result) {
|
renderHakuResult(phase, result) {
|
||||||
this.resetErrors();
|
this.resetErrors();
|
||||||
|
this.errorSquiggles = null;
|
||||||
|
|
||||||
if (result.status != "error") return;
|
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.errorHeader.textContent = `${phase} failed`;
|
this.errorHeader.textContent = `${phase} failed`;
|
||||||
|
|
||||||
if (result.errorKind == "diagnostics") {
|
if (result.errorKind == "diagnostics") {
|
||||||
// This is kind of wooden; I'd prefer if the error spans were rendered inline in text,
|
this.codeEditor.rebuildLineMap();
|
||||||
// but I haven't integrated anything for syntax highlighting yet that would let me
|
this.errorSquiggles = this.#computeErrorSquiggles(
|
||||||
// do that.
|
this.codeEditor.lineMap,
|
||||||
|
result.diagnostics,
|
||||||
|
);
|
||||||
|
this.codeEditor.renderLayer("layer-error-squiggles");
|
||||||
|
|
||||||
this.errorArea.textContent = result.diagnostics
|
this.errorArea.textContent = result.diagnostics
|
||||||
.map(
|
.map(
|
||||||
(diagnostic) => `${diagnostic.start}..${diagnostic.end}: ${diagnostic.message}`,
|
(diagnostic) => `${diagnostic.start}..${diagnostic.end}: ${diagnostic.message}`,
|
||||||
|
@ -70,6 +87,96 @@ export class BrushEditor extends HTMLElement {
|
||||||
this.errorHeader.textContent = "(unknown error kind)";
|
this.errorHeader.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.start);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lineElement.textContent = lineBounds.substring;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("rkgk-brush-editor", BrushEditor);
|
customElements.define("rkgk-brush-editor", BrushEditor);
|
||||||
|
|
|
@ -431,14 +431,16 @@ class CanvasRenderer extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
async #zoomingBehaviour() {
|
async #zoomingBehaviour() {
|
||||||
while (true) {
|
this.addEventListener(
|
||||||
let event = await listen([this, "wheel"]);
|
"wheel",
|
||||||
|
(event) => {
|
||||||
// TODO: Touchpad zoom
|
// TODO: Touchpad zoom
|
||||||
this.viewport.zoomIn(event.deltaY > 0 ? -1 : 1);
|
this.viewport.zoomIn(event.deltaY > 0 ? -1 : 1);
|
||||||
this.sendViewportUpdate();
|
this.sendViewportUpdate();
|
||||||
this.dispatchEvent(new Event(".viewportUpdateEnd"));
|
this.dispatchEvent(new Event(".viewportUpdateEnd"));
|
||||||
}
|
},
|
||||||
|
{ bubbling: false },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #paintingBehaviour() {
|
async #paintingBehaviour() {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
export class CodeEditor extends HTMLElement {
|
export class CodeEditor extends HTMLElement {
|
||||||
constructor() {
|
constructor(layers) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
this.layers = layers;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
@ -9,6 +11,16 @@ export class CodeEditor extends HTMLElement {
|
||||||
this.layerGutter = this.appendChild(document.createElement("pre"));
|
this.layerGutter = this.appendChild(document.createElement("pre"));
|
||||||
this.layerGutter.classList.add("layer", "layer-gutter");
|
this.layerGutter.classList.add("layer", "layer-gutter");
|
||||||
|
|
||||||
|
this.userLayers = [];
|
||||||
|
for (let layer of this.layers) {
|
||||||
|
let element = this.appendChild(document.createElement("pre"));
|
||||||
|
element.classList.add("layer", layer.className);
|
||||||
|
this.userLayers.push({
|
||||||
|
def: layer,
|
||||||
|
element,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.textArea = this.appendChild(document.createElement("textarea"));
|
this.textArea = this.appendChild(document.createElement("textarea"));
|
||||||
this.textArea.spellcheck = false;
|
this.textArea.spellcheck = false;
|
||||||
this.textArea.rows = 1;
|
this.textArea.rows = 1;
|
||||||
|
@ -66,26 +78,6 @@ export class CodeEditor extends HTMLElement {
|
||||||
new ResizeObserver(() => this.#resizeTextArea()).observe(this.textArea);
|
new ResizeObserver(() => this.#resizeTextArea()).observe(this.textArea);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSelection() {
|
|
||||||
// NOTE: We only support one selection, because multiple selections are only
|
|
||||||
// supported by Firefox.
|
|
||||||
|
|
||||||
if (document.activeElement != this.textArea) return null;
|
|
||||||
|
|
||||||
if (this.textArea.selectionDirection == "forward") {
|
|
||||||
return new Selection(this.textArea.selectionStart, this.textArea.selectionEnd);
|
|
||||||
} else {
|
|
||||||
return new Selection(this.textArea.selectionEnd, this.textArea.selectionStart);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelection(selection) {
|
|
||||||
this.textArea.selectionDirection =
|
|
||||||
selection.anchor < selection.cursor ? "forward" : "backward";
|
|
||||||
this.textArea.selectionStart = selection.start;
|
|
||||||
this.textArea.selectionEnd = selection.end;
|
|
||||||
}
|
|
||||||
|
|
||||||
#resizeTextArea() {
|
#resizeTextArea() {
|
||||||
this.textArea.style.height = "";
|
this.textArea.style.height = "";
|
||||||
this.textArea.style.height = `${this.textArea.scrollHeight}px`;
|
this.textArea.style.height = `${this.textArea.scrollHeight}px`;
|
||||||
|
@ -93,17 +85,36 @@ export class CodeEditor extends HTMLElement {
|
||||||
|
|
||||||
// Layers
|
// Layers
|
||||||
|
|
||||||
|
rebuildLineMap() {
|
||||||
|
this.lineMap = new LineMap(this.code);
|
||||||
|
}
|
||||||
|
|
||||||
#renderLayers() {
|
#renderLayers() {
|
||||||
|
this.rebuildLineMap();
|
||||||
|
|
||||||
this.#renderGutter();
|
this.#renderGutter();
|
||||||
|
for (let userLayer of this.userLayers) {
|
||||||
|
userLayer.element.replaceChildren();
|
||||||
|
userLayer.def.render(this.lineMap, userLayer.element);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#renderGutter() {
|
#renderGutter() {
|
||||||
this.layerGutter.replaceChildren();
|
this.layerGutter.replaceChildren();
|
||||||
|
|
||||||
for (let line of this.code.split("\n")) {
|
for (let lineBounds of this.lineMap.lineBounds) {
|
||||||
let lineElement = this.layerGutter.appendChild(document.createElement("span"));
|
let lineElement = this.layerGutter.appendChild(document.createElement("span"));
|
||||||
lineElement.classList.add("line");
|
lineElement.classList.add("line");
|
||||||
lineElement.textContent = line;
|
lineElement.textContent = lineBounds.substring;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLayer(className) {
|
||||||
|
for (let userLayer of this.userLayers) {
|
||||||
|
if (userLayer.def.className == className) {
|
||||||
|
userLayer.element.replaceChildren();
|
||||||
|
userLayer.def.render(this.lineMap, userLayer.element);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,6 +142,26 @@ export class CodeEditor extends HTMLElement {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSelection() {
|
||||||
|
// NOTE: We only support one selection, because multiple selections are only
|
||||||
|
// supported by Firefox.
|
||||||
|
|
||||||
|
if (document.activeElement != this.textArea) return null;
|
||||||
|
|
||||||
|
if (this.textArea.selectionDirection == "forward") {
|
||||||
|
return new Selection(this.textArea.selectionStart, this.textArea.selectionEnd);
|
||||||
|
} else {
|
||||||
|
return new Selection(this.textArea.selectionEnd, this.textArea.selectionStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelection(selection) {
|
||||||
|
this.textArea.selectionDirection =
|
||||||
|
selection.anchor < selection.cursor ? "forward" : "backward";
|
||||||
|
this.textArea.selectionStart = selection.start;
|
||||||
|
this.textArea.selectionEnd = selection.end;
|
||||||
|
}
|
||||||
|
|
||||||
replace(selection, text) {
|
replace(selection, text) {
|
||||||
let left = this.code.substring(0, selection.start);
|
let left = this.code.substring(0, selection.start);
|
||||||
let right = this.code.substring(selection.end);
|
let right = this.code.substring(selection.end);
|
||||||
|
@ -286,7 +317,7 @@ export class CodeEditor extends HTMLElement {
|
||||||
|
|
||||||
customElements.define("rkgk-code-editor", CodeEditor);
|
customElements.define("rkgk-code-editor", CodeEditor);
|
||||||
|
|
||||||
class Selection {
|
export class Selection {
|
||||||
constructor(anchor, cursor) {
|
constructor(anchor, cursor) {
|
||||||
this.anchor = anchor;
|
this.anchor = anchor;
|
||||||
this.cursor = cursor;
|
this.cursor = cursor;
|
||||||
|
@ -317,7 +348,7 @@ class Selection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLineStart(string, position) {
|
export function getLineStart(string, position) {
|
||||||
do {
|
do {
|
||||||
--position;
|
--position;
|
||||||
} while (string.charAt(position) != "\n" && position > 0);
|
} while (string.charAt(position) != "\n" && position > 0);
|
||||||
|
@ -325,16 +356,16 @@ function getLineStart(string, position) {
|
||||||
return position;
|
return position;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLineEnd(string, position) {
|
export function getLineEnd(string, position) {
|
||||||
while (string.charAt(position) != "\n" && position < string.length) ++position;
|
while (string.charAt(position) != "\n" && position < string.length) ++position;
|
||||||
return position + 1;
|
return position + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPositionInLine(string, position) {
|
export function getPositionInLine(string, position) {
|
||||||
return position - getLineStart(string, position);
|
return position - getLineStart(string, position);
|
||||||
}
|
}
|
||||||
|
|
||||||
function countSpaces(string, position) {
|
export function countSpaces(string, position) {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
while (string.charAt(position) == " ") {
|
while (string.charAt(position) == " ") {
|
||||||
++count;
|
++count;
|
||||||
|
@ -342,3 +373,53 @@ function countSpaces(string, position) {
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class LineMap {
|
||||||
|
constructor(string) {
|
||||||
|
// This simplifies the algorithm below a bit.
|
||||||
|
string += "\n";
|
||||||
|
|
||||||
|
this.string = string;
|
||||||
|
this.lineBounds = [];
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
for (let i = 0; i < string.length; ++i) {
|
||||||
|
if (string.charAt(i) == "\n") {
|
||||||
|
let substring = string.substring(start, i);
|
||||||
|
this.lineBounds.push({ start, end: i, substring });
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start < string.length) {
|
||||||
|
this.lineBounds.push({ start, end: string.length, substring: string.substring(start) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(lineIndex) {
|
||||||
|
return this.lineBounds[lineIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
lineIndexAt(position) {
|
||||||
|
// Ported from the Rust 1.81 standard library binary search.
|
||||||
|
// I was too lazy to come up with the algorithm myself. Sorry to disappoint.
|
||||||
|
|
||||||
|
let size = this.lineBounds.length;
|
||||||
|
let left = 0;
|
||||||
|
let right = size;
|
||||||
|
while (left < right) {
|
||||||
|
let mid = (left + size / 2) | 0;
|
||||||
|
|
||||||
|
let isLess = this.lineBounds[mid].start < position;
|
||||||
|
let isEqual = this.lineBounds[mid].start == position;
|
||||||
|
left = isLess && !isEqual ? mid + 1 : left;
|
||||||
|
right = !isLess && !isEqual ? mid : right;
|
||||||
|
if (isEqual) {
|
||||||
|
return mid;
|
||||||
|
}
|
||||||
|
|
||||||
|
size = right - left;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -193,6 +193,8 @@ rkgk-code-editor {
|
||||||
&>.line {
|
&>.line {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
||||||
|
min-height: var(--line-height-em);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -225,7 +227,27 @@ rkgk-code-editor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&>.layer:not(.layer-gutter) {
|
||||||
|
margin-left: var(--gutter-width);
|
||||||
|
width: calc(100% - var(--gutter-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
&>.layer-error-squiggles {
|
||||||
|
color: transparent;
|
||||||
|
|
||||||
|
&>.line {
|
||||||
|
&>.squiggle {
|
||||||
|
text-decoration: underline wavy black;
|
||||||
|
text-decoration-skip-ink: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&>.squiggle-error {
|
||||||
|
text-decoration-color: var(--color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&>textarea {
|
&>textarea {
|
||||||
display: block;
|
display: block;
|
||||||
width: calc(100% - var(--gutter-width));
|
width: calc(100% - var(--gutter-width));
|
||||||
|
|
Loading…
Reference in a new issue