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