diff --git a/static/brush-editor.js b/static/brush-editor.js index 7fbbaac..b0ff273 100644 --- a/static/brush-editor.js +++ b/static/brush-editor.js @@ -1,3 +1,5 @@ +import { CodeEditor } from "rkgk/code-editor.js"; + const defaultBrush = ` -- This is your brush. -- Try playing around with the numbers, @@ -14,24 +16,15 @@ export class BrushEditor extends HTMLElement { connectedCallback() { this.classList.add("rkgk-panel"); - this.textArea = this.appendChild(document.createElement("textarea")); - this.textArea.classList.add("text-area"); - this.textArea.value = localStorage.getItem("rkgk.brushEditor.code") ?? defaultBrush; - this.textArea.spellcheck = false; - this.textArea.rows = 1; - this.textArea.addEventListener("input", () => { - this.#resizeTextArea(); - - localStorage.setItem("rkgk.brushEditor.code", this.code); + this.codeEditor = this.appendChild(new CodeEditor()); + this.codeEditor.setCode(localStorage.getItem("rkgk.brushEditor.code") ?? defaultBrush); + this.codeEditor.addEventListener(".codeChanged", (event) => { this.dispatchEvent( Object.assign(new Event(".codeChanged"), { - newCode: this.code, + newCode: event.newCode, }), ); }); - requestAnimationFrame(() => this.#resizeTextArea()); - document.fonts.addEventListener("loadingdone", () => this.#resizeTextArea()); - new ResizeObserver(() => this.#resizeTextArea()).observe(this); this.errorHeader = this.appendChild(document.createElement("h1")); this.errorHeader.classList.add("error-header"); @@ -40,13 +33,8 @@ export class BrushEditor extends HTMLElement { this.errorArea.classList.add("errors"); } - #resizeTextArea() { - this.textArea.style.height = ""; - this.textArea.style.height = `${this.textArea.scrollHeight}px`; - } - get code() { - return this.textArea.value; + return this.codeEditor.code; } resetErrors() { diff --git a/static/code-editor.js b/static/code-editor.js new file mode 100644 index 0000000..a132600 --- /dev/null +++ b/static/code-editor.js @@ -0,0 +1,319 @@ +export class CodeEditor extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + this.indentWidth = 2; + + this.textArea = this.appendChild(document.createElement("textarea")); + this.textArea.spellcheck = false; + this.textArea.rows = 1; + + this.keyShortcuts = { + Enter: () => this.enter(), + + Tab: () => this.tab(), + "Shift-Tab": () => this.decreaseIndent(), + "Ctrl-[": () => this.decreaseIndent(), + "Ctrl-]": () => this.increaseIndent(), + + "Ctrl-z": () => this.undo(), + "Ctrl-Shift-z": () => this.redo(), + "Ctrl-y": () => this.redo(), + }; + + this.undoMergeTimeout = 300; + this.undoHistory = []; + this.undoHistoryTop = 0; + + this.#textAreaAutoSizingBehaviour(); + this.#keyShortcutBehaviours(); + } + + get code() { + return this.textArea.value; + } + + #sendCodeChanged() { + this.dispatchEvent( + Object.assign(new Event(".codeChanged"), { + newCode: this.code, + }), + ); + } + + setCode(value) { + this.textArea.value = value; + this.#resizeTextArea(); + this.#sendCodeChanged(); + } + + #textAreaAutoSizingBehaviour() { + this.textArea.addEventListener("input", () => { + this.#resizeTextArea(); + this.#sendCodeChanged(); + }); + this.#resizeTextArea(); + document.fonts.addEventListener("loadingdone", () => this.#resizeTextArea()); + 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() { + this.textArea.style.height = ""; + this.textArea.style.height = `${this.textArea.scrollHeight}px`; + } + + #keyShortcutBehaviours() { + this.textArea.addEventListener("keydown", (event) => { + let keyComponents = []; + if (event.ctrlKey) keyComponents.push("Ctrl"); + if (event.altKey) keyComponents.push("Alt"); + if (event.shiftKey) keyComponents.push("Shift"); + keyComponents.push(event.key); + + let keyChord = keyComponents.join("-"); + + let shortcut = this.keyShortcuts[keyChord]; + if (shortcut != null) { + shortcut(); + event.preventDefault(); + } + }); + + this.textArea.addEventListener("beforeinput", () => { + this.pushHistory({ allowMerge: true }); + }); + } + + replace(selection, text) { + let left = this.code.substring(0, selection.start); + let right = this.code.substring(selection.end); + this.setCode(left + text + right); + } + + pushHistory({ allowMerge }) { + this.undoHistory.splice(this.undoHistoryTop); + + if (allowMerge && this.undoHistory.length > 1) { + let last = this.undoHistory[this.undoHistory.length - 1]; + let elapsed = performance.now() - last.time; + if (elapsed < this.undoMergeTimeout) { + last.time = performance.now(); + last.code = this.code; + last.selection = this.getSelection(); + return; + } + } + + this.undoHistory.push({ + time: performance.now(), + code: this.code, + selection: this.getSelection(), + }); + this.undoHistoryTop += 1; + } + + popHistory() { + let entry = this.undoHistory[this.undoHistoryTop - 1]; + if (entry == null) return null; + + this.undoHistoryTop -= 1; + this.setCode(entry.code); + this.setSelection(entry.selection); + + return entry; + } + + insertTab(selection) { + let positionInLine = getPositionInLine(this.code, selection.cursor); + let positionInTab = positionInLine % this.indentWidth; + let spaceCount = this.indentWidth - positionInTab; + this.replace(selection, " ".repeat(spaceCount)); + selection.advance(this.code, spaceCount); + } + + indent(selection) { + let start = getLineStart(this.code, selection.start); + let end = getLineEnd(this.code, selection.end); + + let indent = " ".repeat(this.indentWidth); + let indented = this.code.substring(start, end).split(/^/m); + for (let i = 0; i < indented.length; ++i) { + indented[i] = indent + indented[i]; + } + this.replace(new Selection(start, end), indented.join("")); + + if (selection.anchor < selection.cursor) { + selection.anchor += this.indentWidth; + selection.cursor += this.indentWidth * indented.length; + } else { + selection.anchor += this.indentWidth * indented.length; + selection.cursor += this.indentWidth; + } + } + + unindent(selection) { + let start = getLineStart(this.code, selection.start); + let end = getLineEnd(this.code, selection.end); + + let indent = " ".repeat(this.indentWidth); + let unindented = this.code.substring(start, end).split(/^/m); + for (let i = 0; i < unindented.length; ++i) { + if (unindented[i].startsWith(indent)) { + unindented[i] = unindented[i].substring(this.indentWidth); + } + } + this.replace(new Selection(start, end), unindented.join("")); + + if (selection.anchor < selection.cursor) { + selection.anchor -= this.indentWidth; + selection.cursor -= this.indentWidth * unindented.length; + } else { + selection.anchor -= this.indentWidth * unindented.length; + selection.cursor -= this.indentWidth; + } + } + + enter() { + this.pushHistory({ allowMerge: false }); + + let selection = this.getSelection(); + + let start = getLineStart(this.code, selection.start); + let indent = countSpaces(this.code, start); + + let newLine = "\n" + " ".repeat(indent); + this.replace(selection, newLine); + selection.set(this.code, selection.start + newLine.length); + + this.setSelection(selection); + } + + tab() { + this.pushHistory({ allowMerge: false }); + + let selection = this.getSelection(); + if (selection == null) return; + + if (selection.anchor == selection.cursor) { + this.insertTab(selection); + } else { + this.indent(selection); + } + + this.setSelection(selection); + } + + increaseIndent() { + this.pushHistory({ allowMerge: false }); + + let selection = this.getSelection(); + this.indent(selection); + this.setSelection(selection); + } + + decreaseIndent() { + this.pushHistory({ allowMerge: false }); + + let selection = this.getSelection(); + this.unindent(selection); + this.setSelection(selection); + } + + undo() { + let code = this.code; + let popped = this.popHistory(); + if (popped != null && popped.code == code) { + this.popHistory(); + } + } + + redo() { + let entry = this.undoHistory[this.undoHistoryTop]; + if (entry == null) return; + + this.undoHistoryTop += 1; + this.setCode(entry.code); + this.setSelection(entry.selection); + } +} + +customElements.define("rkgk-code-editor", CodeEditor); + +class Selection { + constructor(anchor, cursor) { + this.anchor = anchor; + this.cursor = cursor; + } + + get start() { + return Math.min(this.anchor, this.cursor); + } + + get end() { + return Math.max(this.anchor, this.cursor); + } + + clampCursor(text) { + this.cursor = Math.max(0, Math.min(this.cursor, text.length)); + } + + set(text, n) { + this.cursor = n; + this.clampCursor(text); + this.anchor = this.cursor; + } + + advance(text, n) { + this.cursor += n; + this.clampCursor(text); + this.anchor = this.cursor; + } +} + +function getLineStart(string, position) { + do { + --position; + } while (string.charAt(position) != "\n" && position > 0); + if (string.charAt(position) == "\n") ++position; + return position; +} + +function getLineEnd(string, position) { + while (string.charAt(position) != "\n" && position < string.length) ++position; + return position + 1; +} + +function getPositionInLine(string, position) { + return position - getLineStart(string, position); +} + +function countSpaces(string, position) { + let count = 0; + while (string.charAt(position) == " ") { + ++count; + ++position; + } + return count; +} diff --git a/static/index.css b/static/index.css index c937ee8..c83f76f 100644 --- a/static/index.css +++ b/static/index.css @@ -166,6 +166,21 @@ rkgk-reticle-cursor { } } +/* Code editor */ + +rkgk-code-editor { + &>textarea { + display: block; + width: 100%; + margin: 0; + resize: none; + white-space: pre-wrap; + border: none; + overflow: hidden; + box-sizing: border-box; + } +} + /* Brush editor */ rkgk-brush-editor {