igurielidze 18c0e03287 Add marquee selection: click and drag on empty space to select multiple symbols
- Blue dashed rectangle drawn while dragging
- Selects all visible symbols whose bounding box intersects the marquee
- Respects hidden symbols and hidden groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:42:21 +04:00

775 lines
26 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);
}
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, 'left');
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;
}
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';
}
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;
}
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;
}
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);
const dx = pos.x - dragState.startX!;
const dy = pos.y - dragState.startY!;
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);
const dx = pos.x - dragState.startX!;
const dy = pos.y - dragState.startY!;
const isRight = dragState.type === 'resize-right';
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();
const sym = dragState.symbolDef;
const rot = sym.defaultRotation || 0;
const pos = screenToCanvas(e.clientX, e.clientY);
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.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 };
}