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"; import { BrushRenderer } from "rkgk/brush-renderer.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 d.From d.To `.trim(), }, { id: "builtin/thick", name: "Thick", code: ` color: #000 thickness: 48 withDotter \\d -> stroke thickness color d.From d.To `.trim(), }, { id: "builtin/dashes", name: "Dashes", code: ` color: #000 thickness: 4 length: 5 duty: 0.5 withDotter \\d -> visible? = d.Num |mod length < length * duty if (visible?) stroke thickness color d.From d.To else () `.trim(), }, { id: "builtin/pencil", name: "Pencil", code: ` color: #0003 thickness: 6 withDotter \\d -> stroke thickness color d.From d.To `.trim(), }, { id: "builtin/woobly", name: "Woobly", code: ` color: #000 minThickness: 8 maxThickness: 20 wavelength: 1 withDotter \\d -> pi = 3.14159265 a = sin (d.Num * wavelength / pi) + 1 / 2 range = maxThickness - minThickness thickness = a * range + minThickness stroke thickness color 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 -> l = mag u u / vec l l perpClockwise: \\v -> vec vecY.v -(vecX.v) withDotter \\d -> pi = 3.14159265 a = sin (d.Num * wavelength / pi) * amplitude clockwise = norm (perpClockwise d.To-d.From) * vec a a from = d.From + clockwise to = d.To + clockwise stroke thickness color from to `.trim(), }, { id: "builtin/rainbow", name: "Rainbow", code: ` wavelength: 0.1 thickness: 8 colorCurve: \\n -> n |cos |abs withDotter \\d -> pi = 3.14159265 l = wavelength r = colorCurve (d.Num * l) g = colorCurve (d.Num * l + pi/3) b = colorCurve (d.Num * l + 2*pi/3) color = rgba r g b 1 stroke thickness color d.From d.To `.trim(), }, // ...feel free to add more presets here! ]; function presetButton(info) { let button = document.createElement("button"); let preview = button.appendChild(document.createElement("div")); preview.classList.add("preview"); 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.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.brushesCanvas = this.appendChild(document.createElement("canvas")); this.gl = this.brushesCanvas.getContext("webgl2"); let canvasResizeObserver = new ResizeObserver(() => this.renderBrushes()); canvasResizeObserver.observe(this); this.brushRenderer = new BrushRenderer(this.gl, this.canvasSource()); 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." + "
Switching to another brush will discard them." + "
Are you sure?", ); confirmation.addSeparator(); confirmation .addButton("Discard changes and switch brush", { classList: ["destructive"], }) .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); } canvasSource() { let brushBox = this; return { useCanvas(gl, i) { let brush = brushBox.brushes[i]; let canvasRect = brushBox.brushesCanvas.getBoundingClientRect(); let previewRect = brush.presetButton.preview.getBoundingClientRect(); let viewport = { x: previewRect.x - canvasRect.x, y: canvasRect.bottom - previewRect.bottom, width: previewRect.width, height: previewRect.height, }; gl.enable(gl.SCISSOR_TEST); gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height); gl.scissor(viewport.x, viewport.y, viewport.width, viewport.height); return viewport; }, resetCanvas(gl) {}, }; } async renderBrushes() { let gl = this.gl; this.brushesCanvas.width = this.brushesCanvas.clientWidth; this.brushesCanvas.height = this.brushesContainer.scrollHeight; gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); for (let i = 0; i < this.brushes.length; ++i) { let brush = this.brushes[i]; let previewRect = brush.presetButton.preview.getBoundingClientRect(); this.haku.setBrush(brush.preset.code); await this.haku.evalBrush({ runDotter: async () => { return { fromX: previewRect.width / 2, fromY: previewRect.height / 2, toX: previewRect.width / 2, toY: previewRect.height / 2, num: 0, }; }, runScribble: async (renderToCanvas) => { renderToCanvas(this.brushRenderer, i, 0, 0); }, }); } } 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.info("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.info("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);