Compare commits

...

3 commits

Author SHA1 Message Date
0ddc42c00f sidebar layout
switch the app from floating panels to a static sidebar on the right with resizable tools
expect more layout bugs from now on
2025-06-27 23:24:09 +02:00
f78f3136d9 optimizing loading times
- reducing the waterfall for JavaScript
- making the throbber appear properly if not all CSS is loaded; fonts.css and index.css are made non render-blocking
2025-06-26 19:17:06 +02:00
b4acab2c9c fix jank when moving the mouse cursor outside the canvas
mouse events are now consistently sourced from the window, so if you try to draw and move your mouse over the panel, it won't glitch out
2025-06-26 18:54:28 +02:00
11 changed files with 287 additions and 186 deletions

View file

@ -18,6 +18,9 @@
--font-monospace: "Iosevka Hyperlegible", monospace;
--line-height: 1.5;
--line-height-em: 1.5em;
--z-resize-handle: 50;
--z-modal: 100;
}
/* Reset */
@ -92,6 +95,10 @@ button.icon {
input {
border: none;
border-bottom: 1px solid var(--color-panel-border);
&:hover:not(:focus) {
background-color: var(--color-shaded-background);
}
}
*:focus-visible {
@ -101,14 +108,23 @@ input {
/* Modal dialogs */
dialog:not([open]) {
/* Weird this doesn't seem to work by default. */
dialog {
&:not([open]) {
/* Default to dialogs being invisible.
Otherwise dialogs placed on the page via HTML display on top of everything. */
display: none;
}
&::backdrop {
background-color: var(--dialog-backdrop);
backdrop-filter: blur(8px);
}
}
dialog:not([open]) {
}
dialog::backdrop {
background-color: var(--dialog-backdrop);
backdrop-filter: blur(8px);
}
/* Details */
@ -217,8 +233,6 @@ abbr {
.icon {
vertical-align: middle;
width: 16px;
height: 16px;
background-repeat: no-repeat;
background-position: 50% 50%;

View file

@ -132,8 +132,6 @@ export class BrushBox extends HTMLElement {
connectedCallback() {
this.saveData.attachToElement(this);
this.classList.add("rkgk-panel");
this.brushes = [];
this.brushesContainer = this.appendChild(document.createElement("div"));

View file

@ -1,6 +1,9 @@
import { CodeEditor, Selection } from "rkgk/code-editor.js";
import { SaveData } from "rkgk/framework.js";
import { builtInPresets } from "rkgk/brush-box.js";
import { ResizeHandle } from "rkgk/resize-handle.js";
import { BrushPreview } from "rkgk/brush-preview.js";
import { BrushCostGauges } from "rkgk/brush-cost.js";
export class BrushEditor extends HTMLElement {
saveData = new SaveData("brushEditor");
@ -10,13 +13,14 @@ 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.editorContainer = this.appendChild(document.createElement("div"));
this.editorContainer.classList.add("editor");
this.nameEditor = this.editorContainer.appendChild(document.createElement("input"));
this.nameEditor.value = this.saveData.get("name", defaultBrush.name);
this.nameEditor.classList.add("name");
this.nameEditor.addEventListener("input", () => {
@ -31,7 +35,7 @@ export class BrushEditor extends HTMLElement {
);
});
this.codeEditor = this.appendChild(
this.codeEditor = this.editorContainer.appendChild(
new CodeEditor([
{
className: "layer-syntax",
@ -59,11 +63,28 @@ export class BrushEditor extends HTMLElement {
);
});
this.errorHeader = this.appendChild(document.createElement("h1"));
this.errorHeader.classList.add("error-header");
this.output = document.createElement("output");
this.errorArea = this.appendChild(document.createElement("pre"));
this.errorArea.classList.add("errors");
this.appendChild(
new ResizeHandle({
direction: "horizontal",
inverse: true,
targetElement: this,
targetProperty: "--brush-preview-height",
initSize: 192,
minSize: 64,
}),
).classList.add("always-visible");
this.appendChild(this.output);
this.ok = this.output.appendChild(document.createElement("div"));
this.ok.classList.add("ok");
this.brushPreview = this.ok.appendChild(new BrushPreview());
this.brushCostGauges = this.ok.appendChild(new BrushCostGauges());
this.errors = this.output.appendChild(document.createElement("pre"));
this.errors.classList.add("errors");
// NOTE(localStorage): Migration from old storage key.
localStorage.removeItem("rkgk.brushEditor.code");
@ -87,11 +108,19 @@ export class BrushEditor extends HTMLElement {
}
resetErrors() {
this.errorHeader.textContent = "";
this.errorArea.textContent = "";
this.output.dataset.state = "ok";
this.errors.textContent = "";
}
renderHakuResult(phase, result) {
async updatePreview(haku, { getStats }) {
let previewResult = await this.brushPreview.renderBrush(haku);
this.brushCostGauges.update(getStats());
if (previewResult.status == "error") {
this.renderHakuResult(previewResult.result);
}
}
renderHakuResult(result) {
this.resetErrors();
this.errorSquiggles = null;
@ -102,7 +131,7 @@ export class BrushEditor extends HTMLElement {
return;
}
this.errorHeader.textContent = `${phase} failed`;
this.output.dataset.state = "error";
if (result.errorKind == "diagnostics") {
this.codeEditor.rebuildLineMap();
@ -112,13 +141,13 @@ export class BrushEditor extends HTMLElement {
);
this.codeEditor.renderLayer("layer-error-squiggles");
this.errorArea.textContent = result.diagnostics
this.errors.textContent = result.diagnostics
.map(
(diagnostic) => `${diagnostic.start}..${diagnostic.end}: ${diagnostic.message}`,
)
.join("\n");
} else if (result.errorKind == "plain") {
this.errorHeader.textContent = result.message;
this.errors.textContent = result.message;
} else if (result.errorKind == "exception") {
let renderer = new ErrorException(result);
let squiggles = renderer.prepareSquiggles();
@ -144,11 +173,11 @@ export class BrushEditor extends HTMLElement {
this.codeEditor.setSelection(new Selection(span.start, span.end));
});
this.errorArea.replaceChildren();
this.errorArea.appendChild(renderer);
this.errors.replaceChildren();
this.errors.appendChild(renderer);
} else {
console.warn(`unknown error kind: ${result.errorKind}`);
this.errorHeader.textContent = "(unknown error kind)";
this.errors.textContent = "(unknown error kind)";
}
}

View file

@ -13,16 +13,24 @@ export class BrushPreview extends HTMLElement {
this.ctx = this.canvas.getContext("2d");
this.#resizeCanvas();
if (this.width == null || this.height == null) {
new ResizeObserver(() => this.#resizeCanvas()).observe(this);
}
}
#resizeCanvas() {
this.canvas.width = this.width ?? this.clientWidth;
this.canvas.height = this.height ?? this.clientHeight;
// This can happen if the element's `display: none`.
if (this.canvas.width == 0 || this.canvas.height == 0) return;
if (this.pixmap != null) {
this.pixmap.destroy();
}
this.pixmap = new Pixmap(this.canvas.width, this.canvas.height);
this.dispatchEvent(new Event(".pixmapLost"));
}
async #renderBrushInner(haku) {
@ -31,9 +39,9 @@ export class BrushPreview extends HTMLElement {
runDotter: async () => {
return {
fromX: this.canvas.width / 2,
fromY: this.canvas.width / 2,
fromY: this.canvas.height / 2,
toX: this.canvas.width / 2,
toY: this.canvas.width / 2,
toY: this.canvas.height / 2,
num: 0,
};
},

View file

@ -451,10 +451,10 @@ class CanvasRenderer extends HTMLElement {
async #cursorReportingBehaviour() {
while (true) {
let event = await listen([this, "mousemove"]);
let event = await listen([window, "mousemove"]);
let [x, y] = this.viewport.toViewportSpace(
event.clientX - this.clientLeft,
event.offsetY - this.clientTop,
event.clientY - this.clientTop,
this.getWindowSize(),
);
this.dispatchEvent(Object.assign(new Event(".cursor"), { x, y }));
@ -493,10 +493,7 @@ class InteractEvent extends Event {
continueAsDotter() {
(async () => {
let event = await listen(
[this.canvasRenderer, "mousemove"],
[this.canvasRenderer, "mouseup"],
);
let event = await listen([window, "mousemove"], [window, "mouseup"]);
if (event.type == "mousemove") {
let [mouseX, mouseY] = this.canvasRenderer.viewport.toViewportSpace(

View file

@ -44,6 +44,9 @@ export class CodeEditor extends HTMLElement {
this.undoHistory = [];
this.undoHistoryTop = 0;
this.textArea.addEventListener("input", () => {
this.#codeChanged();
});
this.#textAreaAutoSizingBehaviour();
this.#keyShortcutBehaviours();
@ -76,9 +79,6 @@ export class CodeEditor extends HTMLElement {
// Resizing the text area
#textAreaAutoSizingBehaviour() {
this.textArea.addEventListener("input", () => {
this.#codeChanged();
});
this.#resizeTextArea();
document.fonts.addEventListener("loadingdone", () => this.#resizeTextArea());
new ResizeObserver(() => this.#resizeTextArea()).observe(this.textArea);

View file

@ -65,7 +65,7 @@ export class SaveData {
}
attachToElement(element) {
this.elementId = element.dataset.storageId;
this.elementId = element.id;
}
getRaw(key) {

View file

@ -44,9 +44,6 @@ main {
& > .panels {
--right-width: 384px; /* Overridden by JavaScript */
box-sizing: border-box;
padding: 16px;
display: grid;
grid-template-columns: [left] 1fr [right-resize] auto [right] minmax(
0,
@ -63,9 +60,9 @@ main {
& > .left {
display: flex;
flex-direction: column;
padding: 16px;
pointer-events: none;
& > * {
pointer-events: auto;
}
@ -80,8 +77,8 @@ main {
min-height: 0;
display: grid;
grid-template-rows: minmax(0, min-content);
grid-template-columns: [floating] max-content [resize] min-content [docked] auto;
grid-template-rows: 100%;
grid-template-columns: [resize] min-content [docked] auto;
padding-left: 16px;
@ -92,29 +89,14 @@ main {
min-height: 0;
}
& > rkgk-resize-handle {
pointer-events: auto;
}
& > .docked {
display: flex;
flex-direction: column;
& > * {
pointer-events: auto;
}
}
& > .docked > rkgk-brush-editor {
max-height: 100%;
}
& > .floating {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
& > rkgk-brush-preview {
width: 128px;
height: 128px;
@ -126,6 +108,35 @@ main {
pointer-events: auto;
}
}
& > rkgk-resize-handle {
pointer-events: auto;
}
& > .docked {
display: flex;
flex-direction: column;
height: 100%;
max-height: 100%;
background-color: var(--color-panel-background);
box-shadow: 0 0 0 1px var(--color-panel-border);
pointer-events: auto;
& > * {
flex-shrink: 0;
}
& > rkgk-brush-editor {
flex-grow: 1;
flex-shrink: 1;
}
& > .menu-bar {
border-bottom: 1px solid var(--color-panel-border);
}
}
}
}
@ -148,6 +159,7 @@ main {
& > #js-loading {
background-color: var(--color-panel-background);
z-index: var(--z-modal);
display: flex;
align-items: center;
@ -158,47 +170,54 @@ main {
/* Resize handle */
rkgk-resize-handle {
--width: 16px;
--line-width: 2px;
--width: 8px;
--line: none;
display: flex;
justify-content: center;
flex-shrink: 0;
z-index: var(--z-resize-handle);
&.always-visible {
--line: 1px solid var(--color-panel-border);
}
&[data-direction="horizontal"] {
width: 100%;
height: var(--width);
margin: calc(var(--width) / -2) 0;
flex-direction: column;
cursor: row-resize;
& > .visual {
width: 100%;
height: var(--line-width);
height: 0;
border-bottom: var(--line);
}
}
&[data-direction="vertical"] {
width: var(--width);
height: 100%;
margin: 0 calc(var(--width) / -2);
flex-direction: row;
cursor: col-resize;
& > .visual {
width: var(--line-width);
width: 0;
height: 100%;
border-left: var(--line);
}
}
& > .visual {
background-color: var(--color-brand-blue);
opacity: 0%;
}
&:hover > .visual,
&.dragging > .visual {
opacity: 100%;
--line: 2px solid var(--color-brand-blue);
}
}
@ -261,12 +280,15 @@ rkgk-reticle-cursor {
/* Brush box */
rkgk-brush-box.rkgk-panel {
rkgk-brush-box {
--button-size: 56px;
height: var(--height);
padding: 12px;
overflow-x: hidden;
overflow-y: auto;
& > .brushes {
display: grid;
grid-template-columns: repeat(
@ -275,8 +297,6 @@ rkgk-brush-box.rkgk-panel {
);
max-height: 100%;
overflow-x: hidden;
overflow-y: auto;
& > button {
padding: 0;
@ -337,20 +357,20 @@ rkgk-brush-box.rkgk-panel {
/* Code editor */
rkgk-code-editor {
--gutter-width: 2.75em;
--gutter-width: 3.5em;
--vertical-padding: 12px;
display: block;
position: relative;
width: 100%;
padding: var(--vertical-padding) 0;
overflow: auto;
& > .layer {
position: absolute;
left: 0;
top: 0;
top: var(--vertical-padding);
width: 100%;
height: 100%;
box-sizing: border-box;
margin: 0;
@ -468,47 +488,60 @@ rkgk-code-editor {
&:has(textarea:focus) {
outline: 1px solid var(--color-brand-blue);
outline-offset: 4px;
outline-offset: -1px;
}
}
/* Brush editor */
rkgk-brush-editor.rkgk-panel {
padding: 12px;
rkgk-brush-editor {
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
position: relative;
& > .editor {
display: flex;
flex-direction: column;
height: calc(100% - var(--brush-preview-height));
& > .name {
margin-bottom: 4px;
margin: 12px;
margin-bottom: 0;
font-weight: bold;
}
& > .text-area {
display: block;
width: 100%;
margin: 0;
resize: none;
white-space: pre-wrap;
border: none;
overflow: hidden;
box-sizing: border-box;
& > rkgk-code-editor {
height: 100%;
flex-shrink: 1;
}
}
& > .errors:empty,
& > .error-header:empty {
& > output {
height: 64px;
flex-grow: 1;
user-select: text;
&[data-state="ok"] > .errors {
display: none;
}
&[data-state="error"] > .ok {
display: none;
}
& > .error-header {
margin: 0;
margin-top: 0.5em;
font-size: 1rem;
color: var(--color-error);
& > .ok {
display: flex;
flex-direction: row;
height: 100%;
& > rkgk-brush-preview {
flex-grow: 1;
}
}
& > .errors {
@ -516,11 +549,10 @@ rkgk-brush-editor.rkgk-panel {
color: var(--color-error);
white-space: pre-wrap;
user-select: text;
max-height: 20em;
overflow-y: auto;
}
}
}
rkgk-brush-editor-error-exception {
@ -594,7 +626,6 @@ rkgk-brush-preview {
var(--checkerboard-dark) 0% 50%
)
50% 50% / var(--checkerboard-size) var(--checkerboard-size);
border-radius: 4px;
& > canvas {
display: block;
@ -625,26 +656,20 @@ rkgk-brush-preview {
/* Brush cost gauges */
rkgk-brush-cost-gauges,
rkgk-brush-cost-gauges.rkgk-panel {
rkgk-brush-cost-gauges {
--gauge-size: 20px;
height: var(--gauge-size);
border-radius: 4px;
display: flex;
flex-direction: row;
overflow: clip; /* clip corners */
&.hidden {
display: none;
}
& > rkgk-brush-cost-gauge {
display: block;
height: var(--gauge-size);
flex-grow: 1;
width: var(--gauge-size);
height: 100%;
&.hidden {
display: none;
@ -654,7 +679,7 @@ rkgk-brush-cost-gauges.rkgk-panel {
width: 100%;
height: 100%;
clip-path: xywh(0 0 var(--progress) 100%);
clip-path: inset(calc(100% - var(--progress)) 0 0 0);
background-color: var(--gauge-color);
}
@ -672,6 +697,10 @@ rkgk-brush-cost-gauges.rkgk-panel {
--gauge-color: #5aca40;
}
}
& .icon {
background-position: 50% calc(100% - 4px);
}
}
/* Zoom indicator */
@ -789,21 +818,38 @@ rkgk-context-menu.rkgk-panel {
/* Menu bar */
.menu-bar {
--border-radius: 4px;
display: flex;
align-items: center;
box-sizing: border-box;
width: fit-content;
height: 24px;
border-radius: var(--border-radius);
width: 100%;
height: 28px;
& > a {
margin: 0;
padding: 0;
list-style: none;
& > li {
display: flex;
flex-direction: row;
align-items: center;
height: 100%;
}
& > li.icon {
display: block;
width: 28px;
height: 28px;
}
& > li > a {
display: flex;
flex-direction: row;
align-items: center;
color: var(--color-text);
padding: 4px 8px;
height: 100%;
padding: 0 8px;
text-decoration: none;
line-height: 1;
@ -816,16 +862,6 @@ rkgk-context-menu.rkgk-panel {
width: 24px;
height: 24px;
}
&:first-child {
border-top-left-radius: var(--border-radius);
border-bottom-left-radius: var(--border-radius);
}
&:last-child {
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
}
}
& > hr {

View file

@ -18,8 +18,6 @@ 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");
let zoomIndicator = main.querySelector("rkgk-zoom-indicator");
let welcome = main.querySelector("rkgk-welcome");
let connectionStatus = main.querySelector("rkgk-connection-status");
@ -253,7 +251,7 @@ function readUrl(urlString) {
let result = await currentUser.haku.evalBrush(
selfController(interactionQueue, wall, event),
);
brushEditor.renderHakuResult(result.phase == "eval" ? "Evaluation" : "Rendering", result);
brushEditor.renderHakuResult(result);
});
canvasRenderer.addEventListener(".viewportUpdate", () => reticleRenderer.render());
@ -263,26 +261,19 @@ function readUrl(urlString) {
// Brush editor
function updateBrushPreview() {
brushEditor.updatePreview(currentUser.haku, {
getStats: () => currentUser.getStats(session.wallInfo),
});
}
function compileBrush() {
let compileResult = currentUser.setBrush(brushEditor.code);
brushEditor.renderHakuResult("Compilation", compileResult);
brushEditor.renderHakuResult(compileResult);
brushCostGauges.update(currentUser.getStats(session.wallInfo));
if (compileResult.status != "ok") {
brushPreview.setErrorFlag();
return;
if (compileResult.status == "ok") {
updateBrushPreview();
}
brushPreview.renderBrush(currentUser.haku).then((previewResult) => {
brushCostGauges.update(currentUser.getStats(session.wallInfo));
if (previewResult.status == "error") {
brushEditor.renderHakuResult(
previewResult.phase == "eval" ? "Evaluation" : "Rendering",
previewResult.result,
);
}
});
}
compileBrush();
@ -294,6 +285,8 @@ function readUrl(urlString) {
});
});
brushEditor.brushPreview.addEventListener(".pixmapLost", updateBrushPreview);
// Brush box
function updateBrushBoxDirtyState() {

View file

@ -3,13 +3,31 @@ import { listen, SaveData } from "rkgk/framework.js";
export class ResizeHandle extends HTMLElement {
saveData = new SaveData("resizeHandle");
constructor(props) {
super();
this.props = props;
}
connectedCallback() {
this.direction = this.getAttribute("data-direction");
this.targetId = this.getAttribute("data-target");
let props = this.props ?? this.dataset;
this.direction = this.dataset.direction = props.direction;
this.targetProperty = props.targetProperty;
this.initSize = parseInt(props.initSize);
this.minSize = parseInt(props.minSize);
this.inverse = props.inverse != null;
if (props.targetElement != null) {
// In case you want to construct the resize handle programatically:
// pass in the target element via targetElement.
// Don't forget to set its id.
this.target = props.targetElement;
this.targetId = this.target.id;
} else {
// Else use data-target.
this.targetId = props.target;
this.target = document.getElementById(this.targetId);
this.targetProperty = this.getAttribute("data-target-property");
this.initSize = parseInt(this.getAttribute("data-init-size"));
this.minSize = parseInt(this.getAttribute("data-min-size"));
}
this.saveData.elementId = this.targetId;
@ -58,8 +76,10 @@ export class ResizeHandle extends HTMLElement {
if (event.type == "mousemove") {
let delta =
this.direction == "vertical"
? mouseDown.clientX - event.clientX
? event.clientX - mouseDown.clientX
: event.clientY - mouseDown.clientY;
if (this.inverse) delta = -delta;
this.#setSize(startingSize + delta);
this.#updateTargetProperty();
} else if (event.type == "mouseup") {

View file

@ -7,8 +7,6 @@
<title>rakugaki</title>
<link rel="stylesheet" href="{{ static 'base.css' }}">
<link rel="stylesheet" href="{{ static 'fonts.css' }}">
<link rel="stylesheet" href="{{ static 'index.css' }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
@ -27,13 +25,20 @@
import "rkgk/brush-editor.js";
import "rkgk/brush-preview.js";
import "rkgk/canvas-renderer.js";
import "rkgk/code-editor.js";
import "rkgk/connection-status.js";
import "rkgk/context-menu.js";
import "rkgk/framework.js";
import "rkgk/haku.js";
import "rkgk/online-users.js";
import "rkgk/painter.js";
import "rkgk/random.js";
import "rkgk/resize-handle.js";
import "rkgk/reticle-renderer.js";
import "rkgk/session.js";
import "rkgk/throbber.js";
import "rkgk/viewport.js";
import "rkgk/wall.js";
import "rkgk/welcome.js";
import "rkgk/zoom-indicator.js";
@ -55,44 +60,45 @@
</head>
<body>
<!-- We shouldn't consider these stylesheets render-blocking.
About the only render-blocking thing on the page is the throbber. -->
<link rel="stylesheet" href="{{ static 'fonts.css' }}">
<link rel="stylesheet" href="{{ static 'index.css' }}">
<main>
<rkgk-canvas-renderer class="fullscreen"></rkgk-canvas-renderer>
<rkgk-reticle-renderer class="fullscreen"></rkgk-reticle-renderer>
<div class="panels fullscreen" id="panels-overlay">
<div class="left">
<div class="rkgk-panel menu-bar">
<a class="icon icon-rkgk-grayscale" title="I know this menu bar is really ugly. Sorry about that."></a>
<hr>
<a href="/docs/rkgk.html">Manual</a>
</div>
<rkgk-zoom-indicator class="rkgk-panel"></rkgk-zoom-indicator>
</div>
<div class="right">
<div class="floating">
<rkgk-brush-preview></rkgk-brush-preview>
<rkgk-brush-cost-gauges class="rkgk-panel"></rkgk-brush-cost-gauges>
</div>
<rkgk-resize-handle
data-direction="vertical"
data-inverse
data-target="panels-overlay"
data-target-property="--right-width"
data-init-size="528"
data-min-size="384"></rkgk-resize-handle>
data-min-size="256"></rkgk-resize-handle>
<div class="docked">
<rkgk-brush-box
id="brush-box"
data-storage-id="brush-box"></rkgk-brush-box>
<menu class="menu-bar">
<li class="icon icon-rkgk-grayscale"></li>
<hr>
<li><a href="/docs/rkgk.html">Manual</a></li>
</menu>
<rkgk-brush-box id="brush-box"></rkgk-brush-box>
<rkgk-resize-handle
class="always-visible"
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>
<rkgk-brush-editor id="brush-editor"></rkgk-brush-editor>
</div>
</div>
</div>