brush picker!
This commit is contained in:
parent
9b82b211b4
commit
c1612b2a94
12 changed files with 849 additions and 45 deletions
|
@ -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 {
|
||||
|
|
348
static/brush-box.js
Normal file
348
static/brush-box.js
Normal file
|
@ -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(
|
||||
"<strong>Your brush has unsaved changes.</strong>" +
|
||||
"<br>Loading another brush will discard them." +
|
||||
"<br>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);
|
|
@ -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 = "";
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
115
static/context-menu.js
Normal file
115
static/context-menu.js
Normal file
|
@ -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");
|
|
@ -28,3 +28,43 @@ export function debounce(time, fn) {
|
|||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class SaveData {
|
||||
constructor(prefix) {
|
||||
this.prefix = `rkgk.${prefix}`;
|
||||
this.elementId = "<global>";
|
||||
}
|
||||
|
||||
#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));
|
||||
}
|
||||
}
|
||||
|
|
189
static/index.css
189
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,18 +158,41 @@ 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%;
|
||||
}
|
||||
}
|
||||
|
||||
& > .visual {
|
||||
background-color: var(--color-brand-blue);
|
||||
margin: 0 auto;
|
||||
opacity: 0%;
|
||||
}
|
||||
|
||||
|
@ -164,7 +201,6 @@ rkgk-resize-handle {
|
|||
opacity: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Canvas renderer */
|
||||
|
||||
|
@ -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 {
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
9
static/random.js
Normal file
9
static/random.js
Normal file
|
@ -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;
|
||||
}
|
|
@ -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");
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
<script type="module" async>
|
||||
import "rkgk/live-reload.js";
|
||||
|
||||
import "rkgk/brush-box.js";
|
||||
import "rkgk/brush-cost.js";
|
||||
import "rkgk/brush-editor.js";
|
||||
import "rkgk/brush-preview.js";
|
||||
|
@ -77,10 +78,21 @@
|
|||
data-direction="vertical"
|
||||
data-target="panels-overlay"
|
||||
data-target-property="--right-width"
|
||||
data-init-size="512"
|
||||
data-init-size="528"
|
||||
data-min-size="384"></rkgk-resize-handle>
|
||||
<div class="docked">
|
||||
<rkgk-brush-editor></rkgk-brush-editor>
|
||||
<rkgk-brush-box
|
||||
id="brush-box"
|
||||
data-storage-id="brush-box"></rkgk-brush-box>
|
||||
<rkgk-resize-handle
|
||||
data-direction="horizontal"
|
||||
data-target="brush-box"
|
||||
data-target-property="--height"
|
||||
data-init-size="168"
|
||||
data-min-size="96"></rkgk-resize-handle>
|
||||
|
||||
<rkgk-brush-editor
|
||||
data-storage-id="brush-editor"></rkgk-brush-editor>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -140,6 +152,8 @@
|
|||
</dialog>
|
||||
</rkgk-connection-status>
|
||||
|
||||
<rkgk-context-menu-space class="fullscreen"></rkgk-context-menu-space>
|
||||
|
||||
<div class="fullscreen" id="js-loading">
|
||||
<rkgk-throbber class="loading"></rkgk-throbber>
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue