- First/last waypoints now only extend/shorten along their segment direction, preventing unwanted rotation of the end boxes - Middle waypoints remain freely draggable for direction changes - Add default middle waypoint so new EPCs have 3 control points Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
849 lines
29 KiB
TypeScript
849 lines
29 KiB
TypeScript
import { layout } from '../stores/layout.svelte.js';
|
|
import { isCurvedType, isEpcType, getSymbolGroup, SYMBOLS, EPC_CONFIG } from '../symbols.js';
|
|
import type { EpcWaypoint } from '../types.js';
|
|
import { getAABB, snapToGrid, clamp, findValidPosition } from './collision.js';
|
|
import { orientedBoxCorners, pastDragThreshold } from './geometry.js';
|
|
import { toSymbolLocal, pointInRect, pointInTrapezoid, hitEpcWaypoint, hitEpcSegmentMidpoint, hitEpcSegmentNearest, hitResizeHandle, hitTestSymbols } from './hit-testing.js';
|
|
|
|
/** Ensure an EPC symbol has its waypoints array initialized */
|
|
function ensureEpcWaypoints(sym: { epcWaypoints?: import('../types.js').EpcWaypoint[] }) {
|
|
if (!sym.epcWaypoints) {
|
|
sym.epcWaypoints = EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp }));
|
|
}
|
|
return sym.epcWaypoints;
|
|
}
|
|
|
|
const ZOOM_MIN = 0.1;
|
|
const ZOOM_MAX = 5;
|
|
const ZOOM_IN_FACTOR = 1.1;
|
|
const ZOOM_OUT_FACTOR = 0.9;
|
|
const ROTATION_STEP = 15;
|
|
const CONVEYOR_MIN_W = 40;
|
|
const DRAG_THRESHOLD = 4; // pixels before drag actually starts
|
|
|
|
interface DragState {
|
|
type: 'palette' | 'move' | 'multi-move' | 'resize-left' | 'resize-right' | 'resize-spur-top' | 'resize-spur-bottom' | 'epc-waypoint' | 'pdf' | 'assign-label' | 'marquee';
|
|
placedId?: number;
|
|
offsetX?: number;
|
|
offsetY?: number;
|
|
startX?: number;
|
|
startY?: number;
|
|
origW?: number;
|
|
origW2?: number;
|
|
origX?: number;
|
|
origY?: number;
|
|
origPdfOffsetX?: number;
|
|
origPdfOffsetY?: number;
|
|
symbolDef?: (typeof SYMBOLS)[number];
|
|
ghost?: HTMLDivElement;
|
|
multiOffsets?: { id: number; offsetX: number; offsetY: number }[];
|
|
dragActivated?: boolean; // true once mouse moves past threshold
|
|
waypointIndex?: number; // which EPC waypoint is being dragged
|
|
deviceLabel?: string; // label to assign when dropping from device dock
|
|
}
|
|
|
|
let dragState: DragState | null = null;
|
|
let isPanning = false;
|
|
|
|
// Marquee selection rectangle (canvas coords), accessible by renderer
|
|
export let marqueeRect: { x: number; y: number; w: number; h: number } | null = null;
|
|
let panStartX = 0;
|
|
let panStartY = 0;
|
|
let canvasEl: HTMLCanvasElement | null = null;
|
|
let wrapperEl: HTMLDivElement | null = null;
|
|
|
|
// Context menu callback
|
|
let contextMenuCallback: ((x: number, y: number, symId: number) => void) | null = null;
|
|
|
|
export function setContextMenuCallback(cb: (x: number, y: number, symId: number) => void) {
|
|
contextMenuCallback = cb;
|
|
}
|
|
|
|
export function initInteractions(canvas: HTMLCanvasElement, wrapper: HTMLDivElement) {
|
|
canvasEl = canvas;
|
|
wrapperEl = wrapper;
|
|
|
|
canvas.addEventListener('mousedown', onCanvasMousedown);
|
|
canvas.addEventListener('contextmenu', onContextMenu);
|
|
wrapper.addEventListener('wheel', onWheel, { passive: false });
|
|
wrapper.addEventListener('mousedown', onWrapperMousedown);
|
|
document.addEventListener('mousemove', onMousemove);
|
|
document.addEventListener('mouseup', onMouseup);
|
|
document.addEventListener('keydown', onKeydown);
|
|
}
|
|
|
|
export function destroyInteractions() {
|
|
if (canvasEl) {
|
|
canvasEl.removeEventListener('mousedown', onCanvasMousedown);
|
|
canvasEl.removeEventListener('contextmenu', onContextMenu);
|
|
}
|
|
if (wrapperEl) {
|
|
wrapperEl.removeEventListener('wheel', onWheel);
|
|
wrapperEl.removeEventListener('mousedown', onWrapperMousedown);
|
|
}
|
|
document.removeEventListener('mousemove', onMousemove);
|
|
document.removeEventListener('mouseup', onMouseup);
|
|
document.removeEventListener('keydown', onKeydown);
|
|
}
|
|
|
|
function screenToCanvas(clientX: number, clientY: number): { x: number; y: number } {
|
|
if (!wrapperEl) return { x: 0, y: 0 };
|
|
const rect = wrapperEl.getBoundingClientRect();
|
|
return {
|
|
x: (clientX - rect.left - layout.panX) / layout.zoomLevel,
|
|
y: (clientY - rect.top - layout.panY) / layout.zoomLevel,
|
|
};
|
|
}
|
|
|
|
function hitTest(cx: number, cy: number): number | null {
|
|
return hitTestSymbols(cx, cy, layout.symbols);
|
|
}
|
|
|
|
/** Check if a device type (symbolDef) is compatible with a placed symbol for label assignment.
|
|
* Matches by SVG file so base and _v variants are treated as the same type. */
|
|
function isTypeCompatible(symbolDef: (typeof SYMBOLS)[number], placedSymbolId: string): boolean {
|
|
const placedDef = SYMBOLS.find(s => s.id === placedSymbolId);
|
|
if (!placedDef) return false;
|
|
return symbolDef.file === placedDef.file;
|
|
}
|
|
|
|
function onCanvasMousedown(e: MouseEvent) {
|
|
if (e.button !== 0) return;
|
|
e.preventDefault();
|
|
|
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
|
|
// PDF editing mode: drag the PDF background
|
|
if (layout.editingBackground) {
|
|
dragState = {
|
|
type: 'pdf',
|
|
startX: pos.x,
|
|
startY: pos.y,
|
|
origPdfOffsetX: layout.pdfOffsetX,
|
|
origPdfOffsetY: layout.pdfOffsetY,
|
|
};
|
|
return;
|
|
}
|
|
|
|
// Check EPC waypoint handles and resize handles (only for single selection)
|
|
if (layout.selectedIds.size === 1) {
|
|
const selId = [...layout.selectedIds][0];
|
|
const sel = layout.symbols.find(s => s.id === selId);
|
|
if (sel && isEpcType(sel.symbolId)) {
|
|
// Double-click on existing waypoint to remove it (if >2 waypoints)
|
|
if (e.detail === 2) {
|
|
const wpIdx = hitEpcWaypoint(pos.x, pos.y, sel);
|
|
const wps = sel.epcWaypoints || EPC_CONFIG.defaultWaypoints;
|
|
if (wpIdx >= 0 && wps.length > 2) {
|
|
layout.pushUndo();
|
|
if (!sel.epcWaypoints) {
|
|
sel.epcWaypoints = EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp }));
|
|
}
|
|
sel.epcWaypoints.splice(wpIdx, 1);
|
|
recalcEpcBounds(sel);
|
|
layout.markDirty();
|
|
layout.saveMcmState();
|
|
return;
|
|
}
|
|
// Double-click on "+" midpoint to add waypoint
|
|
const insertIdx = hitEpcSegmentMidpoint(pos.x, pos.y, sel);
|
|
if (insertIdx >= 0) {
|
|
layout.pushUndo();
|
|
if (!sel.epcWaypoints) {
|
|
sel.epcWaypoints = EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp }));
|
|
}
|
|
const prev = sel.epcWaypoints[insertIdx - 1];
|
|
const next = sel.epcWaypoints[insertIdx];
|
|
sel.epcWaypoints.splice(insertIdx, 0, {
|
|
x: (prev.x + next.x) / 2,
|
|
y: (prev.y + next.y) / 2,
|
|
});
|
|
layout.markDirty();
|
|
layout.saveMcmState();
|
|
return;
|
|
}
|
|
}
|
|
// Check waypoint handles for drag
|
|
const wpIdx = hitEpcWaypoint(pos.x, pos.y, sel);
|
|
if (wpIdx >= 0) {
|
|
layout.pushUndo();
|
|
ensureEpcWaypoints(sel);
|
|
dragState = {
|
|
type: 'epc-waypoint',
|
|
placedId: sel.id,
|
|
waypointIndex: wpIdx,
|
|
startX: pos.x,
|
|
startY: pos.y,
|
|
dragActivated: false,
|
|
};
|
|
return;
|
|
}
|
|
}
|
|
if (sel && layout.selectedIds.size === 1) {
|
|
const handle = hitResizeHandle(pos.x, pos.y, sel);
|
|
if (handle) {
|
|
layout.pushUndo();
|
|
if (handle === 'spur-top' || handle === 'spur-bottom') {
|
|
dragState = {
|
|
type: handle === 'spur-top' ? 'resize-spur-top' : 'resize-spur-bottom',
|
|
placedId: sel.id,
|
|
startX: pos.x,
|
|
startY: pos.y,
|
|
origW: sel.w,
|
|
origW2: sel.w2 ?? sel.w,
|
|
origX: sel.x,
|
|
origY: sel.y,
|
|
};
|
|
} else {
|
|
dragState = {
|
|
type: handle === 'left' ? 'resize-left' : 'resize-right',
|
|
placedId: sel.id,
|
|
startX: pos.x,
|
|
startY: pos.y,
|
|
origW: sel.w,
|
|
origX: sel.x,
|
|
origY: sel.y,
|
|
};
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const hitId = hitTest(pos.x, pos.y);
|
|
|
|
if (hitId !== null) {
|
|
if (e.shiftKey) {
|
|
// Shift+Click: toggle symbol in/out of selection
|
|
const newSet = new Set(layout.selectedIds);
|
|
if (newSet.has(hitId)) {
|
|
newSet.delete(hitId);
|
|
} else {
|
|
newSet.add(hitId);
|
|
}
|
|
layout.selectedIds = newSet;
|
|
layout.markDirty();
|
|
return; // Don't start drag on shift+click
|
|
}
|
|
|
|
// No shift: if symbol not already selected, make it the only selection
|
|
if (!layout.selectedIds.has(hitId)) {
|
|
layout.selectedIds = new Set([hitId]);
|
|
}
|
|
|
|
layout.pushUndo();
|
|
|
|
// Start drag for selected symbols (with threshold — won't move until mouse travels DRAG_THRESHOLD px)
|
|
if (layout.selectedIds.size > 1) {
|
|
const multiOffsets = [...layout.selectedIds].map(id => {
|
|
const sym = layout.symbols.find(s => s.id === id)!;
|
|
return { id, offsetX: pos.x - sym.x, offsetY: pos.y - sym.y };
|
|
});
|
|
dragState = { type: 'multi-move', multiOffsets, startX: pos.x, startY: pos.y, dragActivated: false };
|
|
} else {
|
|
const sym = layout.symbols.find(s => s.id === hitId)!;
|
|
dragState = {
|
|
type: 'move',
|
|
placedId: hitId,
|
|
offsetX: pos.x - sym.x,
|
|
offsetY: pos.y - sym.y,
|
|
startX: pos.x,
|
|
startY: pos.y,
|
|
dragActivated: false,
|
|
};
|
|
}
|
|
layout.markDirty();
|
|
} else {
|
|
// Empty space: start marquee selection
|
|
if (!e.shiftKey) {
|
|
layout.selectedIds = new Set();
|
|
}
|
|
dragState = {
|
|
type: 'marquee',
|
|
startX: pos.x,
|
|
startY: pos.y,
|
|
dragActivated: false,
|
|
};
|
|
layout.markDirty();
|
|
}
|
|
}
|
|
|
|
function onContextMenu(e: MouseEvent) {
|
|
e.preventDefault();
|
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
|
|
// Right-click on selected EPC: add waypoint at nearest segment
|
|
if (layout.selectedIds.size === 1) {
|
|
const selId = [...layout.selectedIds][0];
|
|
const sel = layout.symbols.find(s => s.id === selId);
|
|
if (sel && isEpcType(sel.symbolId)) {
|
|
const insertIdx = hitEpcSegmentNearest(pos.x, pos.y, sel);
|
|
if (insertIdx >= 0) {
|
|
layout.pushUndo();
|
|
ensureEpcWaypoints(sel);
|
|
const prev = sel.epcWaypoints![insertIdx - 1];
|
|
const next = sel.epcWaypoints![insertIdx];
|
|
const local = toSymbolLocal(pos.x, pos.y, sel);
|
|
sel.epcWaypoints!.splice(insertIdx, 0, {
|
|
x: local.x - sel.x,
|
|
y: local.y - sel.y,
|
|
});
|
|
recalcEpcBounds(sel);
|
|
layout.markDirty();
|
|
layout.saveMcmState();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const hitId = hitTest(pos.x, pos.y);
|
|
if (hitId !== null && contextMenuCallback) {
|
|
contextMenuCallback(e.clientX, e.clientY, hitId);
|
|
}
|
|
}
|
|
|
|
function onWheel(e: WheelEvent) {
|
|
e.preventDefault();
|
|
if (!wrapperEl) return;
|
|
|
|
const rect = wrapperEl.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left;
|
|
const my = e.clientY - rect.top;
|
|
const cx = (mx - layout.panX) / layout.zoomLevel;
|
|
const cy = (my - layout.panY) / layout.zoomLevel;
|
|
|
|
const factor = e.deltaY > 0 ? ZOOM_OUT_FACTOR : ZOOM_IN_FACTOR;
|
|
layout.zoomLevel = clamp(layout.zoomLevel * factor, ZOOM_MIN, ZOOM_MAX);
|
|
|
|
layout.panX = mx - cx * layout.zoomLevel;
|
|
layout.panY = my - cy * layout.zoomLevel;
|
|
}
|
|
|
|
function onWrapperMousedown(e: MouseEvent) {
|
|
if (e.button === 1) {
|
|
e.preventDefault();
|
|
isPanning = true;
|
|
panStartX = e.clientX - layout.panX;
|
|
panStartY = e.clientY - layout.panY;
|
|
document.body.style.cursor = 'grabbing';
|
|
}
|
|
}
|
|
|
|
const SPUR_MIN_W = 10;
|
|
|
|
// orientedBoxCorners imported from geometry.ts
|
|
|
|
/** Recalculate EPC symbol w/h to encompass all waypoints + oriented boxes.
|
|
* Symbol origin stays fixed — only w/h expand. Negative local coords are allowed. */
|
|
function recalcEpcBounds(sym: typeof layout.symbols[0]) {
|
|
const wps = sym.epcWaypoints;
|
|
if (!wps || wps.length < 2) return;
|
|
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
|
|
// Include all waypoints
|
|
for (const wp of wps) {
|
|
minX = Math.min(minX, wp.x);
|
|
minY = Math.min(minY, wp.y);
|
|
maxX = Math.max(maxX, wp.x);
|
|
maxY = Math.max(maxY, wp.y);
|
|
}
|
|
|
|
// Left box corners (oriented along first segment)
|
|
const lbDir = { x: wps[1].x - wps[0].x, y: wps[1].y - wps[0].y };
|
|
const lbCorners = orientedBoxCorners(wps[0].x, wps[0].y, lbDir.x, lbDir.y, EPC_CONFIG.leftBox.w, EPC_CONFIG.leftBox.h, 'right');
|
|
for (const [bx, by] of lbCorners) {
|
|
minX = Math.min(minX, bx); minY = Math.min(minY, by);
|
|
maxX = Math.max(maxX, bx); maxY = Math.max(maxY, by);
|
|
}
|
|
|
|
// Right box corners (oriented along last segment)
|
|
const last = wps[wps.length - 1];
|
|
const prev = wps[wps.length - 2];
|
|
const rbDir = { x: last.x - prev.x, y: last.y - prev.y };
|
|
const rbCorners = orientedBoxCorners(last.x, last.y, rbDir.x, rbDir.y, EPC_CONFIG.rightBox.w, EPC_CONFIG.rightBox.h, 'right');
|
|
for (const [bx, by] of rbCorners) {
|
|
minX = Math.min(minX, bx); minY = Math.min(minY, by);
|
|
maxX = Math.max(maxX, bx); maxY = Math.max(maxY, by);
|
|
}
|
|
|
|
// Add small padding
|
|
minX -= 1; minY -= 1; maxX += 1; maxY += 1;
|
|
|
|
// Set w/h to cover full extent from origin (allow negative local coords)
|
|
sym.w = Math.max(maxX, 1);
|
|
sym.h = Math.max(maxY, 1);
|
|
}
|
|
|
|
function snapWidth(w: number, ctrlKey: boolean, minW: number = CONVEYOR_MIN_W): number {
|
|
if (layout.snapEnabled && !ctrlKey) {
|
|
w = Math.round(w / layout.gridSize) * layout.gridSize;
|
|
}
|
|
return Math.max(minW, Math.round(w));
|
|
}
|
|
|
|
function resizeCurved(sym: typeof layout.symbols[0], dx: number, dy: number, isRight: boolean, ctrlKey: boolean) {
|
|
const arcAngle = sym.curveAngle || 90;
|
|
let delta: number;
|
|
if (isRight) {
|
|
delta = dx;
|
|
} else {
|
|
const arcRad = (arcAngle * Math.PI) / 180;
|
|
delta = dx * Math.cos(arcRad) - dy * Math.sin(arcRad);
|
|
}
|
|
const newR = snapWidth(dragState!.origW! + delta, ctrlKey);
|
|
const arcCenterY = dragState!.origY! + dragState!.origW!;
|
|
sym.w = newR;
|
|
sym.h = newR;
|
|
sym.x = dragState!.origX!;
|
|
sym.y = arcCenterY - newR;
|
|
}
|
|
|
|
function resizeSpur(sym: typeof layout.symbols[0], dx: number, dy: number, isTop: boolean, ctrlKey: boolean) {
|
|
const rad = (sym.rotation || 0) * Math.PI / 180;
|
|
const projectedDelta = dx * Math.cos(rad) + dy * Math.sin(rad);
|
|
if (isTop) {
|
|
sym.w2 = snapWidth(dragState!.origW2! + projectedDelta, ctrlKey, SPUR_MIN_W);
|
|
} else {
|
|
sym.w = snapWidth(dragState!.origW! + projectedDelta, ctrlKey, SPUR_MIN_W);
|
|
}
|
|
}
|
|
|
|
function resizeStraight(sym: typeof layout.symbols[0], dx: number, dy: number, isRight: boolean, ctrlKey: boolean) {
|
|
const rad = (sym.rotation || 0) * Math.PI / 180;
|
|
const projectedDelta = dx * Math.cos(rad) + dy * Math.sin(rad);
|
|
const newW = snapWidth(dragState!.origW! + (isRight ? projectedDelta : -projectedDelta), ctrlKey);
|
|
|
|
const cosR = Math.cos(rad), sinR = Math.sin(rad);
|
|
const origCx = dragState!.origX! + dragState!.origW! / 2;
|
|
const origCy = dragState!.origY! + sym.h / 2;
|
|
// Anchor the opposite edge: left edge for resize-right, right edge for resize-left
|
|
const anchorDir = isRight ? -1 : 1;
|
|
const anchorX = origCx + (anchorDir * dragState!.origW! / 2) * cosR;
|
|
const anchorY = origCy + (anchorDir * dragState!.origW! / 2) * sinR;
|
|
const newCx = anchorX + (-anchorDir * newW / 2) * cosR;
|
|
const newCy = anchorY + (-anchorDir * newW / 2) * sinR;
|
|
sym.w = newW;
|
|
sym.x = newCx - newW / 2;
|
|
sym.y = newCy - sym.h / 2;
|
|
}
|
|
|
|
function onMousemove(e: MouseEvent) {
|
|
if (isPanning) {
|
|
layout.panX = e.clientX - panStartX;
|
|
layout.panY = e.clientY - panStartY;
|
|
return;
|
|
}
|
|
|
|
if (!dragState) return;
|
|
|
|
if (dragState.type === 'pdf') {
|
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
layout.pdfOffsetX = dragState.origPdfOffsetX! + (pos.x - dragState.startX!);
|
|
layout.pdfOffsetY = dragState.origPdfOffsetY! + (pos.y - dragState.startY!);
|
|
layout.markDirty();
|
|
return;
|
|
}
|
|
|
|
if (dragState.type === 'epc-waypoint' && dragState.placedId != null && dragState.waypointIndex != null) {
|
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
if (!dragState.dragActivated) {
|
|
if (!pastDragThreshold(pos.x, pos.y, dragState.startX!, dragState.startY!, DRAG_THRESHOLD)) return;
|
|
dragState.dragActivated = true;
|
|
}
|
|
const sym = layout.symbols.find(s => s.id === dragState!.placedId);
|
|
if (!sym || !sym.epcWaypoints) return;
|
|
const wps = sym.epcWaypoints;
|
|
const wpIdx = dragState.waypointIndex;
|
|
|
|
// Transform mouse position to local (unrotated) coords
|
|
const local = toSymbolLocal(pos.x, pos.y, sym);
|
|
let localX = local.x - sym.x;
|
|
let localY = local.y - sym.y;
|
|
|
|
// Snap to grid
|
|
if (layout.snapEnabled && !e.ctrlKey) {
|
|
const worldSnappedX = Math.round((sym.x + localX) / layout.gridSize) * layout.gridSize;
|
|
const worldSnappedY = Math.round((sym.y + localY) / layout.gridSize) * layout.gridSize;
|
|
localX = worldSnappedX - sym.x;
|
|
localY = worldSnappedY - sym.y;
|
|
}
|
|
|
|
// Constrain first/last waypoint to slide along their segment direction only
|
|
// (extend/shorten without rotating the end boxes)
|
|
if (wpIdx === 0 && wps.length >= 2) {
|
|
const next = wps[1];
|
|
const dx = wps[0].x - next.x, dy = wps[0].y - next.y;
|
|
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
const ux = dx / len, uy = dy / len;
|
|
const dot = (localX - next.x) * ux + (localY - next.y) * uy;
|
|
localX = next.x + ux * dot;
|
|
localY = next.y + uy * dot;
|
|
} else if (wpIdx === wps.length - 1 && wps.length >= 2) {
|
|
const prev = wps[wps.length - 2];
|
|
const dx = wps[wpIdx].x - prev.x, dy = wps[wpIdx].y - prev.y;
|
|
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
const ux = dx / len, uy = dy / len;
|
|
const dot = (localX - prev.x) * ux + (localY - prev.y) * uy;
|
|
localX = prev.x + ux * dot;
|
|
localY = prev.y + uy * dot;
|
|
}
|
|
|
|
wps[wpIdx] = { x: localX, y: localY };
|
|
// Recalculate bounding box
|
|
recalcEpcBounds(sym);
|
|
layout.markDirty();
|
|
}
|
|
|
|
if (dragState.type === 'palette' && dragState.ghost && dragState.symbolDef) {
|
|
const sym = dragState.symbolDef;
|
|
dragState.ghost.style.left = (e.clientX - sym.w / 2) + 'px';
|
|
dragState.ghost.style.top = (e.clientY - sym.h / 2) + 'px';
|
|
// Show drop-target highlight when dragging a device over a compatible symbol
|
|
if (dragState.deviceLabel) {
|
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
const hoverId = hitTest(pos.x, pos.y);
|
|
const hoverSym = hoverId !== null ? layout.symbols.find(s => s.id === hoverId) : null;
|
|
const compatible = hoverSym && isTypeCompatible(sym, hoverSym.symbolId) ? hoverId : null;
|
|
if (layout.labelDropTarget !== compatible) {
|
|
layout.labelDropTarget = compatible;
|
|
layout.markDirty();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (dragState.type === 'assign-label' && dragState.ghost) {
|
|
dragState.ghost.style.left = (e.clientX + 8) + 'px';
|
|
dragState.ghost.style.top = (e.clientY - 10) + 'px';
|
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
const hoverId = hitTest(pos.x, pos.y);
|
|
if (layout.labelDropTarget !== hoverId) {
|
|
layout.labelDropTarget = hoverId;
|
|
layout.markDirty();
|
|
}
|
|
}
|
|
|
|
if (dragState.type === 'move' && dragState.placedId != null) {
|
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
if (!dragState.dragActivated) {
|
|
if (!pastDragThreshold(pos.x, pos.y, dragState.startX!, dragState.startY!, DRAG_THRESHOLD)) return;
|
|
dragState.dragActivated = true;
|
|
}
|
|
// Shift: constrain to orthogonal axis (horizontal or vertical)
|
|
if (e.shiftKey) {
|
|
if (Math.abs(pos.x - dragState.startX!) >= Math.abs(pos.y - dragState.startY!)) {
|
|
pos.y = dragState.startY!;
|
|
} else {
|
|
pos.x = dragState.startX!;
|
|
}
|
|
}
|
|
const sym = layout.symbols.find(s => s.id === dragState!.placedId);
|
|
if (!sym) return;
|
|
const bb = getAABB(0, 0, sym.w, sym.h, sym.rotation);
|
|
const newX = clamp(pos.x - dragState.offsetX!, -bb.x, layout.canvasW - bb.w - bb.x);
|
|
const newY = clamp(pos.y - dragState.offsetY!, -bb.y, layout.canvasH - bb.h - bb.y);
|
|
// Hold Ctrl for pixel-precise positioning (bypass grid snap)
|
|
const snapped = e.ctrlKey ? { x: Math.round(newX), y: Math.round(newY) } : snapToGrid(newX, newY, sym.w, sym.h, sym.rotation);
|
|
sym.x = snapped.x;
|
|
sym.y = snapped.y;
|
|
layout.markDirty();
|
|
}
|
|
|
|
if (dragState.type === 'multi-move' && dragState.multiOffsets) {
|
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
if (!dragState.dragActivated) {
|
|
if (!pastDragThreshold(pos.x, pos.y, dragState.startX!, dragState.startY!, DRAG_THRESHOLD)) return;
|
|
dragState.dragActivated = true;
|
|
}
|
|
// Shift: constrain to orthogonal axis
|
|
if (e.shiftKey) {
|
|
if (Math.abs(pos.x - dragState.startX!) >= Math.abs(pos.y - dragState.startY!)) {
|
|
pos.y = dragState.startY!;
|
|
} else {
|
|
pos.x = dragState.startX!;
|
|
}
|
|
}
|
|
for (const { id, offsetX, offsetY } of dragState.multiOffsets) {
|
|
const sym = layout.symbols.find(s => s.id === id);
|
|
if (!sym) continue;
|
|
const bb = getAABB(0, 0, sym.w, sym.h, sym.rotation);
|
|
const rawX = clamp(pos.x - offsetX, -bb.x, layout.canvasW - bb.w - bb.x);
|
|
const rawY = clamp(pos.y - offsetY, -bb.y, layout.canvasH - bb.h - bb.y);
|
|
const snapped = e.ctrlKey
|
|
? { x: Math.round(rawX), y: Math.round(rawY) }
|
|
: snapToGrid(rawX, rawY, sym.w, sym.h, sym.rotation);
|
|
sym.x = snapped.x;
|
|
sym.y = snapped.y;
|
|
}
|
|
layout.markDirty();
|
|
}
|
|
|
|
if ((dragState.type === 'resize-spur-top' || dragState.type === 'resize-spur-bottom') && dragState.placedId != null) {
|
|
const sym = layout.symbols.find(s => s.id === dragState!.placedId);
|
|
if (!sym) return;
|
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
let dx = pos.x - dragState.startX!;
|
|
const dy = pos.y - dragState.startY!;
|
|
if (sym.mirrored) dx = -dx;
|
|
resizeSpur(sym, dx, dy, dragState.type === 'resize-spur-top', e.ctrlKey);
|
|
layout.markDirty();
|
|
}
|
|
|
|
if ((dragState.type === 'resize-left' || dragState.type === 'resize-right') && dragState.placedId != null) {
|
|
const sym = layout.symbols.find(s => s.id === dragState!.placedId);
|
|
if (!sym) return;
|
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
let dx = pos.x - dragState.startX!;
|
|
const dy = pos.y - dragState.startY!;
|
|
let isRight = dragState.type === 'resize-right';
|
|
// When mirrored, swap handle direction and invert horizontal delta
|
|
if (sym.mirrored) {
|
|
dx = -dx;
|
|
isRight = !isRight;
|
|
}
|
|
|
|
if (isCurvedType(sym.symbolId)) {
|
|
resizeCurved(sym, dx, dy, isRight, e.ctrlKey);
|
|
} else {
|
|
resizeStraight(sym, dx, dy, isRight, e.ctrlKey);
|
|
}
|
|
layout.markDirty();
|
|
}
|
|
|
|
if (dragState.type === 'marquee') {
|
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
if (!dragState.dragActivated) {
|
|
if (!pastDragThreshold(pos.x, pos.y, dragState.startX!, dragState.startY!, DRAG_THRESHOLD)) return;
|
|
dragState.dragActivated = true;
|
|
}
|
|
const x1 = Math.min(dragState.startX!, pos.x);
|
|
const y1 = Math.min(dragState.startY!, pos.y);
|
|
const x2 = Math.max(dragState.startX!, pos.x);
|
|
const y2 = Math.max(dragState.startY!, pos.y);
|
|
marqueeRect = { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
|
|
|
|
// Select all visible symbols whose AABB intersects the marquee
|
|
const selected = new Set<number>();
|
|
for (const sym of layout.symbols) {
|
|
if (sym.hidden || layout.hiddenGroups.has(getSymbolGroup(sym.symbolId))) continue;
|
|
const bb = getAABB(sym.x, sym.y, sym.w, sym.h, sym.rotation);
|
|
if (bb.x + bb.w >= x1 && bb.x <= x2 && bb.y + bb.h >= y1 && bb.y <= y2) {
|
|
selected.add(sym.id);
|
|
}
|
|
}
|
|
layout.selectedIds = selected;
|
|
layout.markDirty();
|
|
}
|
|
}
|
|
|
|
function onMouseup(e: MouseEvent) {
|
|
if (e.button === 1 && isPanning) {
|
|
isPanning = false;
|
|
document.body.style.cursor = '';
|
|
return;
|
|
}
|
|
|
|
if (!dragState) return;
|
|
|
|
if (dragState.type === 'pdf') {
|
|
layout.saveMcmState();
|
|
dragState = null;
|
|
return;
|
|
}
|
|
|
|
if (dragState.type === 'assign-label' && dragState.ghost && dragState.deviceLabel) {
|
|
dragState.ghost.remove();
|
|
layout.labelDropTarget = null;
|
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
const hitId = hitTest(pos.x, pos.y);
|
|
if (hitId !== null) {
|
|
const sym = layout.symbols.find(s => s.id === hitId);
|
|
if (sym) {
|
|
layout.pushUndo();
|
|
sym.label = dragState.deviceLabel;
|
|
layout.markDirty();
|
|
layout.saveMcmState();
|
|
}
|
|
}
|
|
dragState = null;
|
|
return;
|
|
}
|
|
|
|
if (dragState.type === 'palette' && dragState.ghost && dragState.symbolDef) {
|
|
dragState.ghost.remove();
|
|
layout.labelDropTarget = null;
|
|
const sym = dragState.symbolDef;
|
|
const rot = sym.defaultRotation || 0;
|
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
|
|
// If dropped onto a compatible existing symbol and we have a device label, assign the label
|
|
const hitId = dragState.deviceLabel ? hitTest(pos.x, pos.y) : null;
|
|
const target = hitId !== null ? layout.symbols.find(s => s.id === hitId) : null;
|
|
if (target && dragState.deviceLabel && isTypeCompatible(sym, target.symbolId)) {
|
|
layout.pushUndo();
|
|
target.label = dragState.deviceLabel;
|
|
layout.markDirty();
|
|
layout.saveMcmState();
|
|
} else {
|
|
// Drop onto empty space: place a new symbol
|
|
let dropX = pos.x - sym.w / 2;
|
|
let dropY = pos.y - sym.h / 2;
|
|
|
|
if (dropX >= -sym.w && dropX <= layout.canvasW && dropY >= -sym.h && dropY <= layout.canvasH) {
|
|
dropX = clamp(dropX, 0, layout.canvasW - sym.w);
|
|
dropY = clamp(dropY, 0, layout.canvasH - sym.h);
|
|
const valid = findValidPosition(-1, dropX, dropY, sym.w, sym.h, sym.id, rot, sym.curveAngle, sym.w2);
|
|
layout.addSymbol({
|
|
symbolId: sym.id,
|
|
file: sym.file,
|
|
name: sym.name,
|
|
label: dragState.deviceLabel || '',
|
|
x: valid.x,
|
|
y: valid.y,
|
|
w: sym.w,
|
|
h: sym.h,
|
|
w2: sym.w2,
|
|
rotation: rot,
|
|
curveAngle: sym.curveAngle,
|
|
epcWaypoints: isEpcType(sym.id) ? EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp })) : undefined,
|
|
pdpCBs: undefined,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (dragState.type === 'move' && dragState.placedId != null) {
|
|
if (dragState.dragActivated) {
|
|
const sym = layout.symbols.find(s => s.id === dragState!.placedId);
|
|
if (sym) {
|
|
if (!e.ctrlKey) {
|
|
const valid = findValidPosition(sym.id, sym.x, sym.y, sym.w, sym.h, sym.symbolId, sym.rotation, sym.curveAngle, sym.w2, sym.mirrored);
|
|
sym.x = valid.x;
|
|
sym.y = valid.y;
|
|
}
|
|
}
|
|
layout.markDirty();
|
|
layout.saveMcmState();
|
|
}
|
|
}
|
|
|
|
if (dragState.type === 'multi-move' && dragState.dragActivated) {
|
|
layout.markDirty();
|
|
layout.saveMcmState();
|
|
}
|
|
|
|
if (dragState.type === 'resize-left' || dragState.type === 'resize-right' || dragState.type === 'resize-spur-top' || dragState.type === 'resize-spur-bottom') {
|
|
layout.markDirty();
|
|
layout.saveMcmState();
|
|
}
|
|
|
|
if (dragState.type === 'epc-waypoint' && dragState.dragActivated) {
|
|
layout.markDirty();
|
|
layout.saveMcmState();
|
|
}
|
|
|
|
if (dragState.type === 'marquee') {
|
|
marqueeRect = null;
|
|
layout.markDirty();
|
|
}
|
|
|
|
dragState = null;
|
|
}
|
|
|
|
function rotateSelected(delta: number) {
|
|
if (layout.selectedIds.size === 0) return;
|
|
layout.pushUndo();
|
|
for (const id of layout.selectedIds) {
|
|
const sym = layout.symbols.find(s => s.id === id);
|
|
if (!sym) continue;
|
|
sym.rotation = ((sym.rotation || 0) + delta + 360) % 360;
|
|
}
|
|
layout.markDirty();
|
|
layout.saveMcmState();
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
// Ctrl+Z: undo
|
|
if (e.ctrlKey && e.key === 'z') {
|
|
e.preventDefault();
|
|
layout.undo();
|
|
return;
|
|
}
|
|
|
|
// Ctrl+D: duplicate selected
|
|
if (e.ctrlKey && (e.key === 'd' || e.key === 'D')) {
|
|
e.preventDefault();
|
|
layout.duplicateSelected();
|
|
return;
|
|
}
|
|
|
|
// Delete: remove all selected
|
|
if (e.key === 'Delete' && layout.selectedIds.size > 0) {
|
|
layout.removeSelected();
|
|
return;
|
|
}
|
|
|
|
// E/Q: rotate all selected
|
|
if (layout.selectedIds.size > 0 && (e.key === 'e' || e.key === 'E')) {
|
|
rotateSelected(ROTATION_STEP);
|
|
}
|
|
if (layout.selectedIds.size > 0 && (e.key === 'q' || e.key === 'Q')) {
|
|
rotateSelected(-ROTATION_STEP);
|
|
}
|
|
|
|
// M: mirror selected
|
|
if (layout.selectedIds.size > 0 && (e.key === 'm' || e.key === 'M')) {
|
|
layout.mirrorSelected();
|
|
}
|
|
}
|
|
|
|
// Called from Palette component to initiate a palette drag
|
|
export function startPaletteDrag(e: MouseEvent, symbolDef: (typeof SYMBOLS)[number], deviceLabel?: string) {
|
|
const ghost = document.createElement('div');
|
|
ghost.style.cssText = `
|
|
position: fixed;
|
|
pointer-events: none;
|
|
opacity: 0.6;
|
|
z-index: 9999;
|
|
width: ${symbolDef.w}px;
|
|
height: ${symbolDef.h}px;
|
|
`;
|
|
if (symbolDef.defaultRotation) {
|
|
ghost.style.transform = `rotate(${symbolDef.defaultRotation}deg)`;
|
|
}
|
|
const img = document.createElement('img');
|
|
img.src = symbolDef.file;
|
|
img.style.width = '100%';
|
|
img.style.height = '100%';
|
|
ghost.appendChild(img);
|
|
ghost.style.left = (e.clientX - symbolDef.w / 2) + 'px';
|
|
ghost.style.top = (e.clientY - symbolDef.h / 2) + 'px';
|
|
document.body.appendChild(ghost);
|
|
|
|
dragState = { type: 'palette', symbolDef, ghost, deviceLabel };
|
|
}
|
|
|
|
// Called from DeviceDock IDs tab to drag a label onto a placed symbol
|
|
export function startLabelDrag(e: MouseEvent, label: string) {
|
|
const ghost = document.createElement('div');
|
|
ghost.style.cssText = `
|
|
position: fixed;
|
|
pointer-events: none;
|
|
z-index: 9999;
|
|
background: #e94560;
|
|
color: #fff;
|
|
padding: 2px 8px;
|
|
border-radius: 3px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
opacity: 0.85;
|
|
`;
|
|
ghost.textContent = label;
|
|
ghost.style.left = (e.clientX + 8) + 'px';
|
|
ghost.style.top = (e.clientY - 10) + 'px';
|
|
document.body.appendChild(ghost);
|
|
|
|
dragState = { type: 'assign-label', ghost, deviceLabel: label };
|
|
}
|