sophia_controller/nodeeditor/Control.js

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;
}
}