fix: harden array builder editing workflows
This commit is contained in:
@@ -5,6 +5,7 @@ const PLOT_BACKGROUND = "#0b1714";
|
||||
const MAX_PIXEL_RATIO = 3;
|
||||
const MIN_ZOOM = 0.25;
|
||||
const MAX_ZOOM = 16;
|
||||
const TOUCH_GESTURE_THRESHOLD_PX = 6;
|
||||
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 });
|
||||
|
||||
export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
|
||||
@@ -97,6 +98,26 @@ export function fitArrayViewport(width, height, arrayDefinition, options = {}) {
|
||||
const centerY = (contentYMin + contentYMax) / 2;
|
||||
const visibleWidth = plot.width / scale;
|
||||
const visibleHeight = plot.height / scale;
|
||||
const fittedValues = [
|
||||
scale,
|
||||
1 / scale,
|
||||
centerX,
|
||||
centerY,
|
||||
visibleWidth,
|
||||
visibleHeight,
|
||||
centerX - visibleWidth / 2,
|
||||
centerX + visibleWidth / 2,
|
||||
centerY - visibleHeight / 2,
|
||||
centerY + visibleHeight / 2,
|
||||
];
|
||||
if (fittedValues.some((value) => !Number.isFinite(value))) {
|
||||
return fitArrayViewport(
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
{ aperture, microphones: [] },
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
width: canvasWidth,
|
||||
@@ -168,6 +189,7 @@ export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) {
|
||||
const microphone = microphones[index];
|
||||
if (!validPosition(microphone?.position)) continue;
|
||||
const pixel = arrayPositionToCanvas(microphone.position, viewport);
|
||||
if (!Number.isFinite(pixel.x) || !Number.isFinite(pixel.y)) continue;
|
||||
const distancePx = Math.hypot(pixel.x - point.x, pixel.y - point.y);
|
||||
if (distancePx <= maximumDistance && (!best || distancePx < best.distancePx)) {
|
||||
best = { id: microphone.id, index, microphone, point: pixel, distancePx };
|
||||
@@ -359,13 +381,14 @@ function drawMicrophones(context, model, viewport, drag) {
|
||||
const position = drag?.id === microphone.id ? drag.last : microphone.position;
|
||||
const drawMicrophone = position === microphone.position ? microphone : { ...microphone, position };
|
||||
const point = arrayPositionToCanvas(position, viewport);
|
||||
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
|
||||
const isSelected = microphone.id === selectedId;
|
||||
const inside = positionInsideAperture(position, arrayDefinition.aperture, model.apertureShape);
|
||||
const radius = isSelected ? Math.max(7, baseRadius + 2) : baseRadius;
|
||||
|
||||
context.fillStyle = inside ? "rgba(101,242,191,.96)" : "rgba(255,184,92,.98)";
|
||||
context.shadowColor = inside ? "rgba(101,242,191,.7)" : "rgba(255,184,92,.7)";
|
||||
context.shadowBlur = isSelected ? 16 : 6;
|
||||
context.shadowBlur = isSelected ? 16 : microphones.length > 256 ? 0 : 6;
|
||||
context.beginPath();
|
||||
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
@@ -449,10 +472,9 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
let zoom = 1;
|
||||
let tool = "select";
|
||||
let drag = null;
|
||||
let pendingAdd = null;
|
||||
|
||||
if (canvas.tabIndex < 0) canvas.tabIndex = 0;
|
||||
if (canvas.style) canvas.style.touchAction = "none";
|
||||
|
||||
function updateCanvasAffordance() {
|
||||
if (canvas.dataset) {
|
||||
canvas.dataset.tool = tool;
|
||||
@@ -492,6 +514,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
function setTool(nextTool) {
|
||||
const resolved = normalizedTool(nextTool);
|
||||
if (resolved === tool) return tool;
|
||||
if (resolved !== "add") pendingAdd = null;
|
||||
tool = resolved;
|
||||
updateCanvasAffordance();
|
||||
callbacks.onToolChange?.(tool);
|
||||
@@ -550,14 +573,48 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
return eventToCanvasPoint(event, canvas.getBoundingClientRect());
|
||||
}
|
||||
|
||||
function clientPoint(event) {
|
||||
return {
|
||||
x: finiteNumber(event?.clientX, 0),
|
||||
y: finiteNumber(event?.clientY, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function exceedsTouchThreshold(start, event) {
|
||||
const current = clientPoint(event);
|
||||
return Math.hypot(current.x - start.x, current.y - start.y) >
|
||||
TOUCH_GESTURE_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
function onTouchStart(event) {
|
||||
if (destroyed || tool !== "select" || !model?.arrayDefinition) return;
|
||||
const touch = event.changedTouches?.[0] ?? event.touches?.[0];
|
||||
const currentViewport = ensureViewport();
|
||||
if (!touch || !currentViewport) return;
|
||||
const hit = hitTestMicrophone(
|
||||
pointerPoint(touch),
|
||||
model.arrayDefinition.microphones,
|
||||
currentViewport,
|
||||
);
|
||||
if (hit) event.preventDefault();
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
|
||||
if (drag || pendingAdd) return;
|
||||
focus();
|
||||
if (tool === "add") {
|
||||
const position = constrainedEventPosition(event, 0);
|
||||
if (position) {
|
||||
event.preventDefault();
|
||||
callbacks.onAdd?.(position);
|
||||
if (event.pointerType === "touch") {
|
||||
pendingAdd = {
|
||||
pointerId: event.pointerId,
|
||||
startClient: clientPoint(event),
|
||||
};
|
||||
} else {
|
||||
event.preventDefault();
|
||||
callbacks.onAdd?.(position);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -577,15 +634,31 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
id: hit.id,
|
||||
start: [...hit.microphone.position],
|
||||
last: [...hit.microphone.position],
|
||||
startClient: clientPoint(event),
|
||||
pointerType: event.pointerType,
|
||||
pending: event.pointerType === "touch",
|
||||
};
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
callbacks.onDragStart?.(hit.id);
|
||||
try {
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
} catch {
|
||||
// Synthetic pointers and interrupted input do not always own capture.
|
||||
}
|
||||
if (!drag.pending) callbacks.onDragStart?.(hit.id);
|
||||
updateCanvasAffordance();
|
||||
schedule();
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (pendingAdd?.pointerId === event.pointerId) {
|
||||
if (exceedsTouchThreshold(pendingAdd.startClient, event)) pendingAdd = null;
|
||||
return;
|
||||
}
|
||||
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||
if (drag.pending) {
|
||||
if (!exceedsTouchThreshold(drag.startClient, event)) return;
|
||||
drag.pending = false;
|
||||
callbacks.onDragStart?.(drag.id);
|
||||
}
|
||||
const microphone = microphoneById(drag.id);
|
||||
const position = constrainedEventPosition(event, microphone?.position?.[2] ?? drag.start[2]);
|
||||
if (!position) return;
|
||||
@@ -599,17 +672,37 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
if (!drag || (event?.pointerId !== undefined && event.pointerId !== drag.pointerId)) return;
|
||||
const completed = drag;
|
||||
drag = null;
|
||||
const position = cancel ? completed.start : completed.last;
|
||||
if (cancel) callbacks.onDrag?.(completed.id, [...position]);
|
||||
callbacks.onDragEnd?.(completed.id, [...position]);
|
||||
if (!completed.pending) {
|
||||
const position = cancel ? completed.start : completed.last;
|
||||
if (cancel) callbacks.onDrag?.(completed.id, [...position]);
|
||||
callbacks.onDragEnd?.(completed.id, [...position]);
|
||||
}
|
||||
const pointerId = event?.pointerId ?? completed.pointerId;
|
||||
if (canvas.hasPointerCapture?.(pointerId)) {
|
||||
canvas.releasePointerCapture?.(pointerId);
|
||||
try {
|
||||
if (canvas.hasPointerCapture?.(pointerId)) {
|
||||
canvas.releasePointerCapture?.(pointerId);
|
||||
}
|
||||
} catch {
|
||||
// The browser may have released capture before pointercancel/lostcapture.
|
||||
}
|
||||
updateCanvasAffordance();
|
||||
schedule();
|
||||
}
|
||||
|
||||
function finishPendingAdd(event, cancel = false) {
|
||||
if (!pendingAdd || event?.pointerId !== pendingAdd.pointerId) return false;
|
||||
const completed = pendingAdd;
|
||||
pendingAdd = null;
|
||||
if (!cancel && tool === "add" && !exceedsTouchThreshold(completed.startClient, event)) {
|
||||
const position = constrainedEventPosition(event, 0);
|
||||
if (position) {
|
||||
event.preventDefault();
|
||||
callbacks.onAdd?.(position);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
||||
const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||
@@ -649,10 +742,12 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
}
|
||||
|
||||
function onPointerUp(event) {
|
||||
if (finishPendingAdd(event, false)) return;
|
||||
finishDrag(event, false);
|
||||
}
|
||||
|
||||
function onPointerCancel(event) {
|
||||
if (finishPendingAdd(event, true)) return;
|
||||
finishDrag(event, true);
|
||||
}
|
||||
|
||||
@@ -665,6 +760,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
canvas.addEventListener("pointerup", onPointerUp);
|
||||
canvas.addEventListener("pointercancel", onPointerCancel);
|
||||
canvas.addEventListener("lostpointercapture", onLostPointerCapture);
|
||||
canvas.addEventListener("touchstart", onTouchStart, { passive: false });
|
||||
canvas.addEventListener("keydown", onKeyDown);
|
||||
|
||||
let resizeObserver = null;
|
||||
@@ -698,10 +794,12 @@ export function createArrayCanvas(canvas, callbacks = {}) {
|
||||
canvas.removeEventListener("pointerup", onPointerUp);
|
||||
canvas.removeEventListener("pointercancel", onPointerCancel);
|
||||
canvas.removeEventListener("lostpointercapture", onLostPointerCapture);
|
||||
canvas.removeEventListener("touchstart", onTouchStart);
|
||||
canvas.removeEventListener("keydown", onKeyDown);
|
||||
model = null;
|
||||
viewport = null;
|
||||
drag = null;
|
||||
pendingAdd = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user