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 @@