673 lines
18 KiB
JavaScript
673 lines
18 KiB
JavaScript
export class Control {
|
|
constructor(offsetX, offsetY, width, height, label = "", readOnly = false, hidden = false) {
|
|
this.offsetX = offsetX;
|
|
this.offsetY = offsetY;
|
|
this.width = width;
|
|
this.height = height;
|
|
|
|
this.label = label;
|
|
this.isHovered = false;
|
|
this.isFocused = false;
|
|
|
|
this.value = null;
|
|
this.onChange = null;
|
|
this.readOnly = readOnly;
|
|
this.hidden = hidden;
|
|
|
|
this.dragStartX = 0;
|
|
this.dragAccum = 0;
|
|
this.dragging = false;
|
|
|
|
}
|
|
|
|
getValue() {
|
|
return this.value;
|
|
}
|
|
|
|
draw(ctx, parentX, parentY) {
|
|
const x = parentX + this.offsetX;
|
|
const y = parentY + this.offsetY;
|
|
|
|
ctx.save();
|
|
ctx.fillStyle = this.isHovered ? "#eee" : "#f8f8f8";
|
|
ctx.strokeStyle = this.isFocused ? "#0af" : "#999";
|
|
ctx.lineWidth = 1;
|
|
|
|
ctx.beginPath();
|
|
ctx.roundRect(x, y, this.width, this.height, 6);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
this.drawLabel(ctx, x, y);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
drawLate(ctx, parentX, parentY) {
|
|
|
|
}
|
|
|
|
drawLabel(ctx, x, y) {
|
|
ctx.fillStyle = "#333";
|
|
ctx.font = "12px sans-serif";
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "top";
|
|
ctx.fillText(this.label, x + 6, y + 6);
|
|
}
|
|
|
|
contains(mx, my, parentX, parentY) {
|
|
const x = parentX + this.offsetX;
|
|
const y = parentY + this.offsetY;
|
|
return mx > x && mx < x + this.width &&
|
|
my > y && my < y + this.height;
|
|
}
|
|
|
|
onEvent(type, data) {
|
|
if (this.readOnly) {
|
|
if (type === "mouseMove") {
|
|
return this.onMouseMove?.(data.mx, data.my, data.parentX, data.parentY, data.modifiers);
|
|
}
|
|
return;
|
|
}
|
|
switch (type) {
|
|
case "mouseDown": return this.onMouseDown?.(data.mx, data.my, data.parentX, data.parentY);
|
|
case "mouseUp": return this.onMouseUp?.(data.mx, data.my, data.parentX, data.parentY);
|
|
case "mouseMove": return this.onMouseMove?.(data.mx, data.my, data.parentX, data.parentY, data.modifiers);
|
|
case "keyDown": return this.onKeyDown?.(data.key);
|
|
case "scroll": return this.onScroll?.(data.delta);
|
|
case "clipboard": return this.onClipboard?.(data.event);
|
|
default:
|
|
console.warn(`Unhandled event type: ${type}`, data);
|
|
|
|
}
|
|
}
|
|
|
|
|
|
onMouseDown(mx, my, parentX, parentY) {
|
|
const x = parentX + this.offsetX;
|
|
const y = parentY + this.offsetY;
|
|
|
|
if (mx > x && mx < x + this.width &&
|
|
my > y && my < y + this.height) {
|
|
this.onClick(); // delegate to subclass
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
|
|
onMouseUp(mx, my, parentX, parentY) {
|
|
const x = parentX + this.offsetX;
|
|
const y = parentY + this.offsetY;
|
|
|
|
const inside = mx > x && mx < x + this.width &&
|
|
my > y && my < y + this.height;
|
|
|
|
this.dragging = false;
|
|
|
|
if (!inside) {
|
|
this.onBlur();
|
|
}
|
|
}
|
|
|
|
|
|
onMouseMove(mx, my, parentX, parentY, modifiers = {}) {
|
|
if (!this.dragging) return;
|
|
|
|
const deltaX = mx - this.dragStartX;
|
|
this.dragStartX = mx;
|
|
this.dragAccum += deltaX;
|
|
|
|
this.onDragDelta?.(deltaX, modifiers);
|
|
}
|
|
|
|
|
|
onScroll(delta) {
|
|
const step = 1;
|
|
this.value = Math.max(this.min, Math.min(this.max, this.value + delta * step));
|
|
if (this.onChange) this.onChange(this.value);
|
|
}
|
|
|
|
|
|
onHover(state) {
|
|
this.isHovered = state;
|
|
}
|
|
|
|
onFocus(state) {
|
|
this.isFocused = state;
|
|
}
|
|
|
|
onClick() {
|
|
this.onFocus(true);
|
|
}
|
|
|
|
onBlur() {
|
|
this.onFocus(false);
|
|
}
|
|
|
|
onClipboard(e) {
|
|
// Default: do nothing
|
|
}
|
|
|
|
}
|
|
|
|
export class SliderControl extends Control {
|
|
constructor(x, y, width, height, label = "", min = 0, max = 100, initial = 50, readOnly = false) {
|
|
super(x, y, width, height, label, readOnly);
|
|
this.min = min;
|
|
this.max = max;
|
|
this.value = initial;
|
|
}
|
|
|
|
getValue() {
|
|
return this.value;
|
|
}
|
|
|
|
draw(ctx, parentX, parentY) {
|
|
const x = parentX + this.offsetX;
|
|
const y = parentY + this.offsetY;
|
|
|
|
// Draw background and border
|
|
ctx.save();
|
|
ctx.fillStyle = this.isHovered ? "#eee" : "#f8f8f8";
|
|
ctx.strokeStyle = this.isFocused ? "#0af" : "#999";
|
|
ctx.lineWidth = 1;
|
|
|
|
ctx.beginPath();
|
|
ctx.roundRect(x, y, this.width, this.height, 6);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
this.drawLabel(ctx, x, y);
|
|
|
|
// Draw value (top-right)
|
|
ctx.textAlign = "right";
|
|
ctx.fillText(this.value.toFixed(0), x + this.width - 6, y + 6);
|
|
|
|
// Draw slider track
|
|
const padding = 10;
|
|
const trackY = y + this.height - 14; // lower in control
|
|
const trackStart = x + padding;
|
|
const trackEnd = x + this.width - padding;
|
|
const trackWidth = trackEnd - trackStart;
|
|
|
|
const percent = (this.value - this.min) / (this.max - this.min);
|
|
const knobX = trackStart + percent * trackWidth;
|
|
|
|
ctx.strokeStyle = "#aaa";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(trackStart, trackY);
|
|
ctx.lineTo(trackEnd, trackY);
|
|
ctx.stroke();
|
|
|
|
// Draw knob
|
|
ctx.fillStyle = this.isFocused ? "#0af" : "#666";
|
|
ctx.beginPath();
|
|
ctx.arc(knobX, trackY, 6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
|
|
|
|
onMouseDown(mx, my, parentX, parentY) {
|
|
const handled = super.onMouseDown(mx, my, parentX, parentY);
|
|
if (handled) {
|
|
this.dragging = true;
|
|
this.dragStartX = mx;
|
|
this.dragAccum = 0;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
|
|
onMouseUp(mx, my, parentX, parentY) {
|
|
this.dragging = false;
|
|
this.onBlur();
|
|
}
|
|
|
|
onScroll(delta) {
|
|
super.onScroll?.(delta); // optional chaining in case base is extended later
|
|
}
|
|
|
|
onDragDelta(accumulatedDelta, modifiers = {}) {
|
|
const trackWidth = this.width - 20;
|
|
const valuePerPixel = (this.max - this.min) / trackWidth;
|
|
|
|
const screenDelta = accumulatedDelta / (1 / modifiers.zoom || 1);
|
|
const sensitivity = modifiers.ctrl ? 0.015 : 1.0;
|
|
|
|
const deltaValue = screenDelta * valuePerPixel * sensitivity;
|
|
|
|
this.dragAccum += deltaValue;
|
|
|
|
if (Math.abs(this.dragAccum) >= 1) {
|
|
const change = Math.round(this.dragAccum);
|
|
this.value = Math.max(this.min, Math.min(this.max, this.value + change));
|
|
this.dragAccum -= change;
|
|
if (this.onChange) this.onChange(this.value);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
export class TextInputControl extends Control {
|
|
constructor(x, y, width, height, inputType = "text", label = "", initial = "", readOnly = false) {
|
|
super(x, y, width, height, label, readOnly);
|
|
this.text = String(initial);
|
|
this.cursorVisible = false;
|
|
this.cursorTimer = 0;
|
|
|
|
this.selecting = false;
|
|
this.selectionStart = 0;
|
|
this.selectionEnd = 0;
|
|
this.cursorIndex = this.text.length;
|
|
|
|
this.inputType = inputType;//"float"; // "text", "int", "float", "password"
|
|
this.allowNegative = true;
|
|
this.maxLength = 20;
|
|
this.regex = /^[a-zA-Z0-9]+$/
|
|
|
|
}
|
|
|
|
getValue() {
|
|
return this.text;
|
|
}
|
|
|
|
draw(ctx, parentX, parentY) {
|
|
const x = parentX + this.offsetX;
|
|
const y = parentY + this.offsetY;
|
|
|
|
ctx.save();
|
|
ctx.fillStyle = this.isHovered ? "#eee" : "#fff";
|
|
ctx.strokeStyle = this.isFocused ? "#0af" : "#999";
|
|
ctx.lineWidth = 1;
|
|
|
|
ctx.beginPath();
|
|
ctx.roundRect(x, y, this.width, this.height, 6);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
this.drawLabel(ctx, x, y);
|
|
|
|
const textBefore = this.text.slice(0, this.selectionStart);
|
|
const selectedText = this.text.slice(this.selectionStart, this.selectionEnd);
|
|
|
|
const textWidthBefore = ctx.measureText(textBefore).width;
|
|
const selectedWidth = ctx.measureText(selectedText).width;
|
|
|
|
if (this.selectionStart !== this.selectionEnd) {
|
|
ctx.fillStyle = "#cce5ff";
|
|
ctx.fillRect(x + 6 + textWidthBefore, textY - 10, selectedWidth, 20);
|
|
}
|
|
|
|
|
|
// Text field
|
|
ctx.font = "13px monospace";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillStyle = "#000";
|
|
ctx.textAlign = "left";
|
|
const textY = y + this.height / 2 + 4;
|
|
const displayText = this.inputType === "password"
|
|
? "*".repeat(this.text.length)
|
|
: this.text;
|
|
ctx.fillText(displayText, x + 6, textY);
|
|
|
|
|
|
// Blinking cursor
|
|
if (this.isFocused && this.cursorVisible) {
|
|
const textWidth = ctx.measureText(this.text).width;
|
|
const cursorX = x + 6 + ctx.measureText(this.text.slice(0, this.cursorIndex)).width;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cursorX, textY - 8);
|
|
ctx.lineTo(cursorX, textY + 8);
|
|
ctx.strokeStyle = "#0af";
|
|
ctx.stroke();
|
|
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
onClick() {
|
|
super.onClick();
|
|
this.cursorVisible = true;
|
|
this.cursorTimer = 0;
|
|
}
|
|
|
|
onScroll(delta) {
|
|
const current = parseFloat(this.text) || 0;
|
|
const step = this.inputType === "int" ? 1 : 0.1;
|
|
const next = current + delta * step;
|
|
|
|
// Clamp if min/max are defined
|
|
const clamped = Math.max(this.min ?? -Infinity, Math.min(this.max ?? Infinity, next));
|
|
|
|
// Round to avoid floating point artifacts
|
|
const precision = step < 1 ? 2 : 0;
|
|
const factor = Math.pow(10, precision);
|
|
const rounded = Math.round(clamped * factor) / factor;
|
|
|
|
this.text = String(rounded);
|
|
this.cursorIndex = this.text.length;
|
|
this.selectionStart = this.selectionEnd = this.cursorIndex;
|
|
|
|
if (this.onChange) this.onChange(this.getValue());
|
|
}
|
|
|
|
|
|
|
|
|
|
onKeyDown(key) {
|
|
if (!this.isFocused) return;
|
|
|
|
const hasShift = window.event?.shiftKey;
|
|
|
|
if (key === "ArrowLeft") {
|
|
if (hasShift) {
|
|
this.cursorIndex = Math.max(0, this.cursorIndex - 1);
|
|
this.selectionEnd = this.cursorIndex;
|
|
} else {
|
|
this.cursorIndex = Math.max(0, this.cursorIndex - 1);
|
|
this.selectionStart = this.selectionEnd = this.cursorIndex;
|
|
}
|
|
} else if (key === "ArrowRight") {
|
|
if (hasShift) {
|
|
this.cursorIndex = Math.min(this.text.length, this.cursorIndex + 1);
|
|
this.selectionEnd = this.cursorIndex;
|
|
} else {
|
|
this.cursorIndex = Math.min(this.text.length, this.cursorIndex + 1);
|
|
this.selectionStart = this.selectionEnd = this.cursorIndex;
|
|
}
|
|
|
|
} else if (key === "Backspace") {
|
|
if (this.selectionStart !== this.selectionEnd) {
|
|
this.text = this.text.slice(0, this.selectionStart) + this.text.slice(this.selectionEnd);
|
|
this.cursorIndex = this.selectionStart;
|
|
} else if (this.cursorIndex > 0) {
|
|
this.text = this.text.slice(0, this.cursorIndex - 1) + this.text.slice(this.cursorIndex);
|
|
this.cursorIndex--;
|
|
}
|
|
this.selectionStart = this.selectionEnd = this.cursorIndex;
|
|
} else if (key === "Delete") {
|
|
if (this.selectionStart !== this.selectionEnd) {
|
|
this.text = this.text.slice(0, this.selectionStart) + this.text.slice(this.selectionEnd);
|
|
this.cursorIndex = this.selectionStart;
|
|
} else if (this.cursorIndex < this.text.length) {
|
|
this.text = this.text.slice(0, this.cursorIndex) + this.text.slice(this.cursorIndex + 1);
|
|
}
|
|
this.selectionStart = this.selectionEnd = this.cursorIndex;
|
|
|
|
|
|
|
|
} else if (key.length === 1) {
|
|
const preview = this.text.slice(0, this.selectionStart) + key + this.text.slice(this.selectionEnd);
|
|
if (!this.isValidInput(preview)) return;
|
|
|
|
if (this.selectionStart !== this.selectionEnd) {
|
|
this.text = this.text.slice(0, this.selectionStart) + key + this.text.slice(this.selectionEnd);
|
|
this.cursorIndex = this.selectionStart + 1;
|
|
} else {
|
|
this.text = this.text.slice(0, this.cursorIndex) + key + this.text.slice(this.cursorIndex);
|
|
this.cursorIndex++;
|
|
}
|
|
|
|
this.selectionStart = this.selectionEnd = this.cursorIndex;
|
|
}
|
|
|
|
|
|
|
|
if (this.onChange) this.onChange(this.text);
|
|
}
|
|
|
|
onClipboard(e) {
|
|
if (e.ctrlKey && e.key === "c") {
|
|
const selected = this.text.slice(this.selectionStart, this.selectionEnd);
|
|
navigator.clipboard.writeText(selected);
|
|
e.preventDefault();
|
|
}
|
|
|
|
if (e.ctrlKey && e.key === "v") {
|
|
navigator.clipboard.readText().then((clip) => {
|
|
let filtered = clip;
|
|
|
|
const preview = this.text.slice(0, this.selectionStart) + clip + this.text.slice(this.selectionEnd);
|
|
if (!this.isValidInput(preview)) return;
|
|
|
|
|
|
|
|
const before = this.text.slice(0, this.selectionStart);
|
|
const after = this.text.slice(this.selectionEnd);
|
|
this.text = before + filtered + after;
|
|
this.cursorIndex = before.length + filtered.length;
|
|
this.selectionStart = this.selectionEnd = this.cursorIndex;
|
|
if (this.onChange) this.onChange(this.text);
|
|
});
|
|
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
|
|
isValidInput(input, strict = false) {
|
|
// Allow empty input
|
|
if (input === "") return true;
|
|
|
|
if (this.inputType === "int") {
|
|
if (!strict) {
|
|
// Allow "-" while typing
|
|
if (input === "-" && this.allowNegative) return true;
|
|
}
|
|
const intPattern = this.allowNegative ? /^-?\d+$/ : /^\d+$/;
|
|
return intPattern.test(input);
|
|
}
|
|
|
|
if (this.inputType === "float") {
|
|
if (!strict) {
|
|
// Relaxed pattern for typing
|
|
const relaxedPattern = this.allowNegative
|
|
? /^-?(\d+)?(\.)?(\d*)?$/
|
|
: /^(\d+)?(\.)?(\d*)?$/;
|
|
return relaxedPattern.test(input);
|
|
}
|
|
|
|
// Auto-correct ".123" → "0.123", "-.123" → "-0.123"
|
|
if (/^\.\d+$/.test(input)) {
|
|
this.text = "0" + input;
|
|
} else if (/^-\.\d+$/.test(input)) {
|
|
input = "-0" + input.slice(1);
|
|
console.log(">");
|
|
}
|
|
|
|
// Strict pattern for finalized float
|
|
const strictPattern = this.allowNegative
|
|
? /^-?\d+(\.\d+)?$/
|
|
: /^\d+(\.\d+)?$/;
|
|
|
|
return strictPattern.test(input);
|
|
}
|
|
|
|
|
|
// Regex check (applies to all types)
|
|
if (this.regex && !this.regex.test(input)) return false;
|
|
|
|
// Max length
|
|
const currentLength = this.text.length - (this.selectionEnd - this.selectionStart);
|
|
if (input.length + currentLength > this.maxLength) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
|
|
onBlur() {
|
|
super.onBlur();
|
|
if (!this.isValidInput(this.text, true)) {
|
|
// Optionally reset or trim invalid input
|
|
// this.text = "";
|
|
// this.cursorIndex = 0;
|
|
// this.selectionStart = this.selectionEnd = 0;
|
|
if (this.onChange) this.onChange(this.text);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
update(deltaTime) {
|
|
if (this.isFocused) {
|
|
this.cursorTimer += deltaTime;
|
|
if (this.cursorTimer > 500) {
|
|
this.cursorVisible = !this.cursorVisible;
|
|
this.cursorTimer = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export class DropdownControl extends Control {
|
|
constructor(x, y, width, height, label = "", options = [], initial = "", readOnly = false) {
|
|
super(x, y, width, height, label, readOnly);
|
|
this.options = options;
|
|
if (typeof initial === "number" && initial >= 0 && initial < options.length) {
|
|
this.value = options[initial];
|
|
} else if (typeof initial === "string" && options.includes(initial)) {
|
|
this.value = initial;
|
|
} else {
|
|
this.value = options.length > 0 ? options[0] : "";
|
|
}
|
|
|
|
this.isOpen = false;
|
|
this.hoveredIndex = -1;
|
|
|
|
}
|
|
|
|
getValue() {
|
|
return this.options.indexOf(this.value);
|
|
//return this.value; // returns string vlue
|
|
}
|
|
|
|
draw(ctx, parentX, parentY) {
|
|
const x = parentX + this.offsetX;
|
|
const y = parentY + this.offsetY;
|
|
|
|
ctx.save();
|
|
ctx.fillStyle = this.isHovered ? "#eee" : "#f8f8f8";
|
|
ctx.strokeStyle = this.isFocused ? "#0af" : "#999";
|
|
ctx.lineWidth = 1;
|
|
|
|
ctx.beginPath();
|
|
ctx.roundRect(x, y, this.width, this.height, 6);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
this.drawLabel(ctx, x, y);
|
|
|
|
// Draw selected value
|
|
ctx.fillStyle = "#000";
|
|
ctx.font = "13px sans-serif";
|
|
ctx.textAlign = "right";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(this.value, x + this.width - 20, y + this.height / 2);
|
|
|
|
// Draw dropdown arrow
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + this.width - 16, y + this.height / 2 - 4);
|
|
ctx.lineTo(x + this.width - 10, y + this.height / 2 - 4);
|
|
ctx.lineTo(x + this.width - 13, y + this.height / 2 + 2);
|
|
ctx.closePath();
|
|
ctx.fillStyle = "#666";
|
|
ctx.fill();
|
|
|
|
|
|
|
|
ctx.restore();
|
|
|
|
}
|
|
|
|
drawLate(ctx, parentX, parentY) {
|
|
const x = parentX + this.offsetX;
|
|
const y = parentY + this.offsetY;
|
|
// Draw options if open
|
|
if (this.isOpen) {
|
|
for (let i = 0; i < this.options.length; i++) {
|
|
const optY = y + this.height + i * this.height;
|
|
const isHovered = i === this.hoveredIndex;
|
|
|
|
ctx.fillStyle = isHovered ? "#cce5ff" : (this.options[i] === this.value ? "#ddd" : "#fff");
|
|
ctx.strokeStyle = "#999";
|
|
ctx.beginPath();
|
|
ctx.rect(x, optY, this.width, this.height);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = "#000";
|
|
ctx.textAlign = "left";
|
|
ctx.fillText(this.options[i], x + 6, optY + this.height / 2);
|
|
}
|
|
}
|
|
}
|
|
|
|
onClick() {
|
|
super.onClick();
|
|
this.isOpen = !this.isOpen;
|
|
}
|
|
|
|
onMouseDown(mx, my, parentX, parentY) {
|
|
const x = parentX + this.offsetX;
|
|
const y = parentY + this.offsetY;
|
|
|
|
if (this.isOpen) {
|
|
for (let i = 0; i < this.options.length; i++) {
|
|
const optY = y + this.height + i * this.height;
|
|
if (mx > x && mx < x + this.width && my > optY && my < optY + this.height) {
|
|
this.value = this.options[i];
|
|
this.isOpen = false;
|
|
if (this.onChange) this.onChange(this.value);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return super.onMouseDown(mx, my, parentX, parentY);
|
|
}
|
|
|
|
onMouseMove(mx, my, parentX, parentY) {
|
|
if (!this.isOpen) {
|
|
this.hoveredIndex = -1;
|
|
return;
|
|
}
|
|
|
|
const x = parentX + this.offsetX;
|
|
const y = parentY + this.offsetY;
|
|
|
|
this.hoveredIndex = -1;
|
|
|
|
for (let i = 0; i < this.options.length; i++) {
|
|
const optY = y + this.height + i * this.height;
|
|
if (mx > x && mx < x + this.width && my > optY && my < optY + this.height) {
|
|
this.hoveredIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
onScroll(delta) {
|
|
delta = -delta;
|
|
const currentIndex = this.options.indexOf(this.value);
|
|
if (currentIndex === -1 || this.options.length === 0) return;
|
|
|
|
const nextIndex = Math.max(0, Math.min(this.options.length - 1, currentIndex + delta));
|
|
this.value = this.options[nextIndex];
|
|
|
|
if (this.onChange) this.onChange(this.getValue());
|
|
}
|
|
|
|
onBlur() {
|
|
super.onBlur();
|
|
this.isOpen = false;
|
|
}
|
|
}
|