diff --git a/static/base.css b/static/base.css
index f2b59de..1843701 100644
--- a/static/base.css
+++ b/static/base.css
@@ -9,6 +9,7 @@
--color-panel-border: rgba(0, 0, 0, 20%);
--color-panel-background: #fff;
--color-shaded-background: rgba(0, 0, 0, 5%);
+ --color-active-background: rgba(0, 0, 0, 10%);
--dialog-backdrop: rgba(255, 255, 255, 0.5);
@@ -53,10 +54,28 @@ textarea {
/* Buttons */
button {
+ color: var(--color-text);
+
border: 1px solid var(--color-panel-border);
border-radius: 9999px;
padding: 0.5rem 1.5rem;
- background-color: var(--color-panel-background);
+ background: none;
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-shaded-background);
+ }
+
+ &:active:not(:disabled) {
+ background-color: var(--color-active-background);
+ }
+
+ &:disabled {
+ opacity: 50%;
+ }
+}
+
+button.destructive {
+ color: var(--color-error);
}
button.icon {
diff --git a/static/brush-box.js b/static/brush-box.js
new file mode 100644
index 0000000..170e34c
--- /dev/null
+++ b/static/brush-box.js
@@ -0,0 +1,348 @@
+import { BrushPreview } from "rkgk/brush-preview.js";
+import { Haku } from "rkgk/haku.js";
+import { randomId } from "rkgk/random.js";
+import { SaveData } from "rkgk/framework.js";
+import { ContextMenu, globalContextMenuSpace } from "rkgk/context-menu.js";
+
+export const builtInPresets = [
+ {
+ id: "builtin/default",
+ name: "Default",
+ code: `
+-- Try playing around with the values
+-- and see what happens!
+color = #000
+thickness = 8
+
+withDotter \\d ->
+ stroke thickness color (line (d From) (d To))
+ `.trim(),
+ },
+
+ {
+ id: "builtin/thick",
+ name: "Thick",
+ code: `
+color = #000
+thickness = 48
+
+withDotter \\d ->
+ stroke thickness color (line (d From) (d To))
+ `.trim(),
+ },
+
+ {
+ id: "builtin/pencil",
+ name: "Pencil",
+ code: `
+color = #0003
+thickness = 6
+
+withDotter \\d ->
+ stroke thickness color (line (d From) (d To))
+ `.trim(),
+ },
+
+ {
+ id: "builtin/woobly",
+ name: "Woobly",
+ code: `
+color = #000
+minThickness = 8
+maxThickness = 20
+wavelength = 1
+
+withDotter \\d ->
+ let pi = 3.14159265
+ let a = (sin (d Num * wavelength / pi) + 1) / 2
+ let range = maxThickness - minThickness
+ let thickness = minThickness + a * range
+ stroke thickness color (line (d From) (d To))
+ `.trim(),
+ },
+
+ {
+ id: "builtin/wavy",
+ name: "Wavy",
+ code: `
+color = #000
+thickness = 4
+amplitude = 50
+wavelength = 1
+
+mag = \\v ->
+ hypot (vecX v) (vecY v)
+
+norm = \\u ->
+ let l = mag u
+ u / vec l l
+
+perpClockwise = \\v ->
+ vec (vecY v) (-(vecX v))
+
+withDotter \\d ->
+ let pi = 3.14159265
+ let a = sin (d Num * wavelength / pi) * amplitude
+ let direction = (d To) - (d From)
+ let clockwise = norm (perpClockwise direction) * vec a a
+ let from = d From + clockwise
+ let to = d To + clockwise
+ stroke thickness color (line from to)
+ `.trim(),
+ },
+
+ {
+ id: "builtin/rainbow",
+ name: "Rainbow",
+ code: `
+wavelength = 0.1
+thickness = 8
+
+colorCurve = \\n ->
+ abs (cos n)
+
+withDotter \\d ->
+ let pi = 3.14159265
+ let l = wavelength
+ let r = colorCurve (d Num * l)
+ let g = colorCurve (d Num * l + pi/3)
+ let b = colorCurve (d Num * l + 2*pi/3)
+ let color = rgba r g b 1
+ stroke thickness color (line (d From) (d To))
+ `.trim(),
+ },
+
+ // ...feel free to add more presets here!
+];
+
+function presetButton(info) {
+ let button = document.createElement("button");
+ let preview = button.appendChild(new BrushPreview(56, 56));
+ let label = button.appendChild(document.createElement("p"));
+ label.classList.add("label");
+ label.innerText = info.name;
+ return { button, preview };
+}
+
+export class BrushBox extends HTMLElement {
+ dirty = false;
+
+ saveData = new SaveData("brushBox");
+
+ connectedCallback() {
+ this.saveData.attachToElement(this);
+
+ this.classList.add("rkgk-panel");
+
+ this.brushes = [];
+
+ this.brushesContainer = this.appendChild(document.createElement("div"));
+ this.brushesContainer.classList.add("brushes");
+
+ this.userPresets = this.saveData.get("userPresets", []);
+
+ this.updateBrushes();
+
+ // NOTE: Restoring the currently selected brush from local storage must NOT end up in a
+ // code editor update, so as not to reset the user's currently WIP brush.
+ let savedCurrentBrush = this.saveData.get("currentBrush", "builtin/default");
+ if (this.findBrushPreset(savedCurrentBrush)) {
+ this.setCurrentBrush(savedCurrentBrush);
+ } else {
+ console.warn("brush preset does not exist", savedCurrentBrush);
+ this.setCurrentBrush("builtin/default");
+ this.#sendBrushChange(this.findBrushPreset(this.currentPresetId));
+ }
+ }
+
+ initialize(wallLimits) {
+ this.haku = new Haku(wallLimits);
+ this.renderBrushes();
+ }
+
+ updateBrushes() {
+ this.brushesContainer.replaceChildren();
+ this.brushes = [];
+
+ const addBrush = (preset, { mutable }) => {
+ let pb = presetButton(preset);
+ this.brushesContainer.appendChild(pb.button);
+ this.brushes.push({
+ preset,
+ presetButton: pb,
+ });
+
+ pb.button.addEventListener("click", (event) => {
+ if (this.dirty) {
+ let confirmation = new ContextMenu();
+
+ confirmation.addHtmlP(
+ "Your brush has unsaved changes." +
+ "
Loading another brush will discard them." +
+ "
Are you sure?",
+ );
+ confirmation.addSeparator();
+ confirmation.addButton("Yes").addEventListener("click", () => {
+ this.#sendBrushChange(preset);
+ });
+ confirmation.addHtmlP("Click anywhere else to cancel", {
+ classList: ["small"],
+ });
+
+ globalContextMenuSpace.openAtCursor(event, confirmation);
+ } else {
+ this.#sendBrushChange(preset);
+ }
+ });
+
+ pb.button.addEventListener("contextmenu", (event) => {
+ event.preventDefault();
+
+ let menu = new ContextMenu();
+
+ menu.addButton(this.currentPresetId == preset.id ? "Save" : "Overwrite", {
+ disabled: !mutable,
+ tooltip: mutable ? "" : "This is a built-in brush and cannot be modified",
+ }).addEventListener("click", () => {
+ this.dispatchEvent(
+ Object.assign(new Event(".overwritePreset"), {
+ preset,
+ }),
+ );
+
+ this.updateBrushes();
+ this.renderBrushes();
+ this.markClean();
+ this.saveUserPresets();
+ });
+
+ menu.addButton("Duplicate").addEventListener("click", () => {
+ let id = this.saveUserPreset(preset);
+ this.updateBrushes();
+ this.renderBrushes();
+
+ if (!this.dirty) {
+ this.currentPresetId = id;
+ let duped = this.findBrushPreset(this.currentPresetId);
+ this.#sendBrushChange(duped);
+ }
+ });
+
+ menu.addButton("Delete", {
+ classList: ["destructive"],
+ disabled: !mutable,
+ tooltip: mutable ? "" : "This is a built-in brush and cannot be deleted",
+ }).addEventListener("click", () => {
+ // NOTE: This leaves the box in a weird state where there is no current brush.
+ // That's okay; code should not assume there's a current preset ID.
+ if (this.currentPresetId == preset.id) {
+ this.currentPresetId = null;
+ this.markDirty();
+ }
+
+ let index = this.userPresets.indexOf(preset);
+ this.userPresets.splice(index, 1);
+
+ this.updateBrushes();
+ this.renderBrushes();
+ this.saveUserPresets();
+ // NOTE: No brush change because we don't want to overwrite the editor.
+ });
+
+ globalContextMenuSpace.openAtCursor(event, menu);
+ });
+ };
+
+ for (let preset of builtInPresets) addBrush(preset, { mutable: false });
+ for (let preset of this.userPresets) addBrush(preset, { mutable: true });
+
+ let saveButton = this.brushesContainer.appendChild(document.createElement("button"));
+ saveButton.classList.add("save-button");
+ let plusIcon = saveButton.appendChild(document.createElement("div"));
+ plusIcon.classList.add("icon", "icon-plus");
+ let saveLabel = saveButton.appendChild(document.createElement("p"));
+ saveLabel.classList.add("label");
+ saveLabel.innerText = "Save new";
+
+ saveButton.addEventListener("click", () => {
+ this.dispatchEvent(new Event(".clickNew"));
+ });
+
+ if (this.currentPresetId != null) this.setCurrentBrush(this.currentPresetId);
+ }
+
+ async renderBrushes() {
+ for (let brush of this.brushes) {
+ this.haku.setBrush(brush.preset.code);
+ await brush.presetButton.preview.renderBrush(this.haku);
+ }
+ }
+
+ setCurrentBrush(id) {
+ this.currentPresetId = id;
+ this.saveData.set("currentBrush", this.currentPresetId);
+
+ for (let brush of this.brushes) {
+ let button = brush.presetButton.button;
+ if (brush.preset.id != id) button.classList.remove("current");
+ else button.classList.add("current");
+ }
+ }
+
+ indexOfBrushPreset(id) {
+ for (let i in this.brushes) {
+ if (this.brushes[i].preset.id == id) return i;
+ }
+ return null;
+ }
+
+ findBrushPreset(id) {
+ let index = this.indexOfBrushPreset(id);
+ if (index == null) return null;
+ return this.brushes[index].preset;
+ }
+
+ markDirty() {
+ this.dirty = true;
+ this.classList.add("unsaved");
+ }
+
+ markClean() {
+ this.dirty = false;
+ this.classList.remove("unsaved");
+ }
+
+ saveUserPreset({ name, code }, id = null) {
+ if (id == null) {
+ // Save a new preset.
+ id = `user/${randomId()}`;
+ console.log("saving new brush", id);
+ this.userPresets.push({
+ id,
+ name,
+ code,
+ });
+ } else {
+ // Overwrite an existing one.
+ let preset = this.userPresets.find((p) => p.id == id);
+ console.log("overwriting existing brush", preset);
+ preset.code = code;
+ }
+ this.saveUserPresets();
+ return id;
+ }
+
+ saveUserPresets() {
+ this.saveData.set("userPresets", this.userPresets);
+ }
+
+ #sendBrushChange(preset) {
+ this.dispatchEvent(
+ Object.assign(new Event(".brushChange"), {
+ preset,
+ }),
+ );
+ }
+}
+
+customElements.define("rkgk-brush-box", BrushBox);
diff --git a/static/brush-editor.js b/static/brush-editor.js
index e88e3af..41fe576 100644
--- a/static/brush-editor.js
+++ b/static/brush-editor.js
@@ -1,15 +1,10 @@
import { CodeEditor } from "rkgk/code-editor.js";
-
-const defaultBrush = `
--- This is your brush.
--- Try playing around with the numbers,
--- and see what happens!
-
-withDotter \\d ->
- stroke 8 #000 (d To)
-`.trim();
+import { SaveData } from "rkgk/framework.js";
+import { builtInPresets } from "rkgk/brush-box.js";
export class BrushEditor extends HTMLElement {
+ saveData = new SaveData("brushEditor");
+
constructor() {
super();
}
@@ -17,6 +12,25 @@ export class BrushEditor extends HTMLElement {
connectedCallback() {
this.classList.add("rkgk-panel");
+ this.saveData.attachToElement(this);
+
+ const defaultBrush = builtInPresets[0];
+
+ this.nameEditor = this.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.appendChild(
new CodeEditor([
{
@@ -25,9 +39,14 @@ export class BrushEditor extends HTMLElement {
},
]),
);
- this.codeEditor.setCode(localStorage.getItem("rkgk.brushEditor.code") ?? defaultBrush);
+ 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) => {
- localStorage.setItem("rkgk.brushEditor.code", event.newCode);
+ this.saveData.set("code", event.newCode);
this.dispatchEvent(
Object.assign(new Event(".codeChanged"), {
@@ -41,12 +60,28 @@ export class BrushEditor extends HTMLElement {
this.errorArea = this.appendChild(document.createElement("pre"));
this.errorArea.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.errorHeader.textContent = "";
this.errorArea.textContent = "";
diff --git a/static/brush-preview.js b/static/brush-preview.js
index 6f550d2..1697415 100644
--- a/static/brush-preview.js
+++ b/static/brush-preview.js
@@ -1,19 +1,23 @@
import { Pixmap } from "rkgk/haku.js";
export class BrushPreview extends HTMLElement {
- constructor() {
+ constructor(width, height) {
super();
+
+ this.width = width;
+ this.height = height;
}
connectedCallback() {
this.canvas = this.appendChild(document.createElement("canvas"));
this.ctx = this.canvas.getContext("2d");
+
this.#resizeCanvas();
}
#resizeCanvas() {
- this.canvas.width = this.clientWidth;
- this.canvas.height = this.clientHeight;
+ this.canvas.width = this.width ?? this.clientWidth;
+ this.canvas.height = this.height ?? this.clientHeight;
if (this.pixmap != null) {
this.pixmap.destroy();
@@ -51,6 +55,7 @@ export class BrushPreview extends HTMLElement {
this.unsetErrorFlag();
let result = await this.#renderBrushInner(haku);
if (result.status == "error") {
+ console.error(result);
this.setErrorFlag();
}
return result;
diff --git a/static/code-editor.js b/static/code-editor.js
index 431e0df..b0197f1 100644
--- a/static/code-editor.js
+++ b/static/code-editor.js
@@ -54,6 +54,10 @@ export class CodeEditor extends HTMLElement {
return this.textArea.value;
}
+ set code(newCode) {
+ this.textArea.value = newCode;
+ }
+
#codeChanged() {
this.#resizeTextArea();
this.#renderLayers();
diff --git a/static/context-menu.js b/static/context-menu.js
new file mode 100644
index 0000000..546a6ec
--- /dev/null
+++ b/static/context-menu.js
@@ -0,0 +1,115 @@
+// - Create your context menu by instantiating ContextMenu and adding elements to it
+// - Open your menu in response to a contextmenu event by using globalContextMenuSpace.openAtCursor(event, menu)
+
+import { listen } from "rkgk/framework.js";
+
+export class ContextMenu extends HTMLElement {
+ connectedCallback() {
+ this.classList.add("rkgk-panel");
+
+ this.closeController = new AbortController();
+
+ this.hasMouse = false;
+ this.addEventListener("mouseover", () => (this.hasMouse = true));
+ this.addEventListener("mouseleave", () => (this.hasMouse = false));
+
+ window.addEventListener(
+ "mousedown",
+ () => {
+ if (!this.hasMouse) {
+ this.close();
+ }
+ },
+ { signal: this.closeController.signal },
+ );
+
+ window.addEventListener(
+ "keydown",
+ (event) => {
+ if (event.key == "Escape") {
+ this.close();
+ }
+ },
+ { signal: this.closeController.signal },
+ );
+ }
+
+ disconnectedCallback() {
+ this.closeController.abort();
+ }
+
+ close() {
+ this.space.close(this);
+ }
+
+ addButton(label, options) {
+ let { classList, disabled, tooltip, noCloseOnClick } = options ?? {};
+ classList ??= [];
+ disabled ??= false;
+ tooltip ??= "";
+ noCloseOnClick ??= false;
+
+ let button = this.appendChild(document.createElement("button"));
+ button.classList.add(...classList);
+ button.innerText = label;
+ button.disabled = disabled;
+ button.title = tooltip;
+
+ button.addEventListener("click", () => {
+ if (!noCloseOnClick) this.close();
+ });
+
+ return button;
+ }
+
+ addHtmlP(text, options) {
+ let { classList } = options ?? {};
+ classList ??= [];
+
+ let p = this.appendChild(document.createElement("p"));
+ p.innerHTML = text;
+ p.classList.add(...classList);
+ return p;
+ }
+
+ addSeparator() {
+ this.appendChild(document.createElement("hr"));
+ }
+}
+
+customElements.define("rkgk-context-menu", ContextMenu);
+
+export class ContextMenuSpace extends HTMLElement {
+ open(contextMenu) {
+ contextMenu.space = this;
+ this.appendChild(contextMenu);
+ }
+
+ close(contextMenu) {
+ this.removeChild(contextMenu);
+ }
+
+ openAtCursor(cursorEvent, contextMenu) {
+ this.open(contextMenu);
+
+ // NOTE: This assumes the context menu space is positioned at (0, 0) and spans the full page.
+
+ let areaWidth = this.clientWidth;
+ let areaHeight = this.clientHeight;
+ let menuWidth = contextMenu.clientWidth;
+ let menuHeight = contextMenu.clientHeight;
+
+ let x = cursorEvent.clientX;
+ let y = cursorEvent.clientY;
+
+ // If the menu would reach outside the screen bounds, snap it back in.
+ if (x + menuWidth > areaWidth) x -= menuWidth;
+ if (y + menuHeight > areaHeight) y -= menuHeight;
+
+ contextMenu.style.transform = `translate(${x}px, ${y}px)`;
+ }
+}
+
+customElements.define("rkgk-context-menu-space", ContextMenuSpace);
+
+export const globalContextMenuSpace = document.querySelector("rkgk-context-menu-space");
diff --git a/static/framework.js b/static/framework.js
index 13f3883..7754428 100644
--- a/static/framework.js
+++ b/static/framework.js
@@ -28,3 +28,43 @@ export function debounce(time, fn) {
}
};
}
+
+export class SaveData {
+ constructor(prefix) {
+ this.prefix = `rkgk.${prefix}`;
+ this.elementId = "";
+ }
+
+ #localStorageKey(key) {
+ return `${this.prefix}.${this.elementId}.${key}`;
+ }
+
+ attachToElement(element) {
+ this.elementId = element.dataset.storageId;
+ }
+
+ getRaw(key) {
+ return localStorage.getItem(this.#localStorageKey(key));
+ }
+
+ setRaw(key, value) {
+ localStorage.setItem(this.#localStorageKey(key), value);
+ }
+
+ get(key, defaultValue) {
+ let value = this.getRaw(key);
+ if (value == null) {
+ return defaultValue;
+ } else {
+ try {
+ return JSON.parse(value);
+ } catch (e) {
+ throw new Error(`${this.#localStorageKey(key)}`, { cause: e });
+ }
+ }
+ }
+
+ set(key, value) {
+ this.setRaw(key, JSON.stringify(value));
+ }
+}
diff --git a/static/index.css b/static/index.css
index 028f1fb..ebcb5c2 100644
--- a/static/index.css
+++ b/static/index.css
@@ -1,6 +1,12 @@
/* index.css - styles for index.html and generally main parts of the app
For shared styles (such as color definitions) check out base.css. */
+* {
+ /* On the main page, we don't really want to permit selecting things.
+ It comes off as janky-looking. */
+ user-select: none;
+}
+
/* Main container layout */
html {
@@ -90,9 +96,17 @@ main {
pointer-events: auto;
}
+ & > .docked {
+ display: flex;
+ flex-direction: column;
+
+ & > * {
+ pointer-events: auto;
+ }
+ }
+
& > .docked > rkgk-brush-editor {
max-height: 100%;
- pointer-events: auto;
}
& > .floating {
@@ -144,25 +158,47 @@ main {
/* Resize handle */
rkgk-resize-handle {
+ --width: 16px;
+ --line-width: 2px;
+
+ display: flex;
+ justify-content: center;
+ flex-shrink: 0;
+
+ &[data-direction="horizontal"] {
+ width: 100%;
+ height: var(--width);
+ flex-direction: column;
+
+ cursor: row-resize;
+
+ & > .visual {
+ width: 100%;
+ height: var(--line-width);
+ }
+ }
+
&[data-direction="vertical"] {
- display: block;
- width: 16px;
+ width: var(--width);
height: 100%;
+ flex-direction: row;
cursor: col-resize;
& > .visual {
- width: 2px;
+ width: var(--line-width);
height: 100%;
- background-color: var(--color-brand-blue);
- margin: 0 auto;
- opacity: 0%;
}
+ }
- &:hover > .visual,
- &.dragging > .visual {
- opacity: 100%;
- }
+ & > .visual {
+ background-color: var(--color-brand-blue);
+ opacity: 0%;
+ }
+
+ &:hover > .visual,
+ &.dragging > .visual {
+ opacity: 100%;
}
}
@@ -223,6 +259,81 @@ rkgk-reticle-cursor {
}
}
+/* Brush box */
+
+rkgk-brush-box.rkgk-panel {
+ --button-size: 56px;
+
+ height: var(--height);
+ padding: 12px;
+
+ & > .brushes {
+ display: grid;
+ grid-template-columns: repeat(
+ auto-fit,
+ minmax(var(--button-size), 1fr)
+ );
+
+ max-height: 100%;
+ overflow-x: hidden;
+ overflow-y: auto;
+
+ & > button {
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ width: 100%;
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+
+ overflow: hidden;
+ white-space: nowrap;
+
+ &.current {
+ border-color: var(--color-brand-blue);
+ }
+
+ & > rkgk-brush-preview {
+ width: var(--button-size);
+ aspect-ratio: 1 / 1;
+ background: none;
+ }
+
+ & > .label {
+ margin: 0;
+ font-size: 80%;
+ }
+
+ &.save-button {
+ display: none; /* visible when current brush is modified */
+
+ & > .icon {
+ width: var(--button-size);
+ height: var(--button-size);
+ }
+
+ & > .icon,
+ & > .label {
+ opacity: 35%;
+ }
+ }
+ }
+ }
+
+ &.unsaved > .brushes {
+ & > .current > .label::after {
+ content: "*";
+ }
+
+ & > .save-button {
+ display: flex;
+ }
+ }
+}
+
/* Code editor */
rkgk-code-editor {
@@ -258,8 +369,6 @@ rkgk-code-editor {
}
& > .layer-gutter {
- user-select: none;
-
counter-reset: line;
color: transparent;
@@ -318,10 +427,18 @@ rkgk-code-editor {
resize: none;
white-space: pre-wrap;
border: none;
+ background: none;
&:focus {
+ /* The outline is displayed on the parent element to also surround the gutter. */
outline: none;
}
+
+ &::selection {
+ /* The selection color has to be overridden for a good contrast ratio between text and
+ the selection. */
+ background-color: rgba(from var(--color-brand-blue) r g b / 0.3);
+ }
}
&:has(textarea:focus) {
@@ -341,6 +458,11 @@ rkgk-brush-editor.rkgk-panel {
position: relative;
+ & > .name {
+ margin-bottom: 4px;
+ font-weight: bold;
+ }
+
& > .text-area {
display: block;
width: 100%;
@@ -389,8 +511,12 @@ rkgk-brush-preview {
border-radius: 4px;
& > canvas {
+ display: block;
border-radius: 4px;
+ max-width: 100%;
+ height: auto;
+
/* The brush preview doesn't scale with DPI as easily as the canvas renderer does,
so instead we pixelate it. */
image-rendering: pixelated;
@@ -487,8 +613,6 @@ rkgk-zoom-indicator.rkgk-panel {
line-height: 1;
width: 6ch;
- user-select: none;
-
font-variant-numeric: tabular-nums;
text-align: center;
}
@@ -531,6 +655,51 @@ rkgk-connection-status {
}
}
+/* Context menu */
+
+rkgk-context-menu-space {
+ pointer-events: none;
+
+ & > rkgk-context-menu {
+ pointer-events: all;
+ }
+}
+
+rkgk-context-menu.rkgk-panel {
+ width: max-content;
+ display: flex;
+ flex-direction: column;
+
+ border-radius: 4px;
+ overflow: clip;
+
+ & > p {
+ margin: 0;
+ padding: 6px 12px;
+
+ &.small {
+ padding: 4px 12px;
+ font-size: 80%;
+ opacity: 75%;
+ }
+ }
+
+ & > button {
+ padding: 6px 12px;
+
+ border: none;
+ border-radius: 0;
+
+ text-align: left;
+ }
+
+ & > hr {
+ margin: 0;
+ width: 100%;
+ border-bottom: 1px solid var(--color-panel-border);
+ }
+}
+
/* Menu bar */
.menu-bar {
diff --git a/static/index.js b/static/index.js
index d287b0c..0045a00 100644
--- a/static/index.js
+++ b/static/index.js
@@ -16,6 +16,7 @@ const updateInterval = 1000 / 60;
let main = document.querySelector("main");
let canvasRenderer = main.querySelector("rkgk-canvas-renderer");
let reticleRenderer = main.querySelector("rkgk-reticle-renderer");
+let brushBox = main.querySelector("rkgk-brush-box");
let brushEditor = main.querySelector("rkgk-brush-editor");
let brushPreview = main.querySelector("rkgk-brush-preview");
let brushCostGauges = main.querySelector("rkgk-brush-cost-gauges");
@@ -134,6 +135,7 @@ function readUrl(urlString) {
});
let wall = new Wall(session.wallInfo);
+ brushBox.initialize(session.wallInfo.hakuLimits);
canvasRenderer.initialize(wall);
for (let onlineUser of session.wallInfo.online) {
@@ -290,6 +292,45 @@ function readUrl(urlString) {
});
});
+ // Brush box
+
+ function updateBrushBoxDirtyState() {
+ if (brushBox.currentPresetId == null) return;
+
+ let currentPreset = brushBox.findBrushPreset(brushBox.currentPresetId);
+ if (brushEditor.name != currentPreset.name || brushEditor.code != currentPreset.code) {
+ brushBox.markDirty();
+ } else {
+ brushBox.markClean();
+ }
+ }
+
+ updateBrushBoxDirtyState();
+ brushEditor.addEventListener(".nameChanged", updateBrushBoxDirtyState);
+ brushEditor.addEventListener(".codeChanged", updateBrushBoxDirtyState);
+
+ brushBox.addEventListener(".clickNew", () => {
+ let id = brushBox.saveUserPreset({ name: brushEditor.name, code: brushEditor.code });
+ brushBox.updateBrushes();
+ brushBox.renderBrushes();
+ brushBox.setCurrentBrush(id);
+ brushBox.markClean();
+ });
+
+ brushBox.addEventListener(".brushChange", (event) => {
+ let preset = event.preset;
+ brushEditor.setName(preset.name);
+ brushEditor.setCode(preset.code);
+ brushBox.setCurrentBrush(preset.id);
+ brushBox.markClean();
+ });
+
+ brushBox.addEventListener(".overwritePreset", (event) => {
+ let preset = event.preset;
+ preset.name = brushEditor.name;
+ preset.code = brushEditor.code;
+ });
+
// Zoom indicator
canvasRenderer.addEventListener(".viewportUpdate", () => {
diff --git a/static/random.js b/static/random.js
new file mode 100644
index 0000000..141d65b
--- /dev/null
+++ b/static/random.js
@@ -0,0 +1,9 @@
+const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
+
+export function randomId(length = 32) {
+ let r = "";
+ for (let i = 0; i < length; ++i) {
+ r += charset.charAt(Math.floor(Math.random() * charset.length));
+ }
+ return r;
+}
diff --git a/static/resize-handle.js b/static/resize-handle.js
index f8f8cac..b441be5 100644
--- a/static/resize-handle.js
+++ b/static/resize-handle.js
@@ -1,9 +1,7 @@
-import { listen } from "rkgk/framework.js";
+import { listen, SaveData } from "rkgk/framework.js";
export class ResizeHandle extends HTMLElement {
- constructor() {
- super();
- }
+ saveData = new SaveData("resizeHandle");
connectedCallback() {
this.direction = this.getAttribute("data-direction");
@@ -13,7 +11,13 @@ export class ResizeHandle extends HTMLElement {
this.initSize = parseInt(this.getAttribute("data-init-size"));
this.minSize = parseInt(this.getAttribute("data-min-size"));
- this.#setSize(parseInt(localStorage.getItem(this.#localStorageKey)));
+ this.saveData.elementId = this.targetId;
+
+ // NOTE(localStorage): Migration from old local storage key.
+ let oldSizeKey = `rkgk.resizeHandle.size.${this.targetId}`;
+ this.#setSize(
+ this.saveData.get("size") ?? localStorage.getItem(oldSizeKey) ?? this.initSize,
+ );
this.#saveSize();
this.#updateTargetProperty();
@@ -21,6 +25,9 @@ export class ResizeHandle extends HTMLElement {
this.visual.classList.add("visual");
this.#draggingBehaviour();
+
+ // NOTE(localStorage): Migration from old local storage key.
+ localStorage.removeItem(oldSizeKey);
}
#setSize(newSize) {
@@ -30,12 +37,8 @@ export class ResizeHandle extends HTMLElement {
this.size = Math.max(this.minSize, newSize);
}
- get #localStorageKey() {
- return `rkgk.resizeHandle.size.${this.targetId}`;
- }
-
#saveSize() {
- localStorage.setItem(this.#localStorageKey, this.size);
+ this.saveData.set("size", this.size);
}
#updateTargetProperty() {
@@ -53,9 +56,11 @@ export class ResizeHandle extends HTMLElement {
while (true) {
let event = await listen([window, "mousemove"], [window, "mouseup"]);
if (event.type == "mousemove") {
- if (this.direction == "vertical") {
- this.#setSize(startingSize + (mouseDown.clientX - event.clientX));
- }
+ let delta =
+ this.direction == "vertical"
+ ? mouseDown.clientX - event.clientX
+ : event.clientY - mouseDown.clientY;
+ this.#setSize(startingSize + delta);
this.#updateTargetProperty();
} else if (event.type == "mouseup") {
this.classList.remove("dragging");
diff --git a/template/index.hbs.html b/template/index.hbs.html
index 3d455d8..fa55672 100644
--- a/template/index.hbs.html
+++ b/template/index.hbs.html
@@ -22,6 +22,7 @@