// phys8-drag.js — универсальный drag-and-drop движок для Физики 8. // Поддерживает: // - drag SVG-объектов (по data-p8-drag атрибуту или ref) // - drag DOM-элементов (HTML chips, palette items) // - drag «логических» объектов в canvas-симуляциях (через hit-callback) // - constraints: lockX, lockY, bounds, snap, snapToGrid // - touch + mouse + pointer events // Экспорт в window.P8Drag = { attach, attachCanvas, snapToGrid, hitTest } (function () { 'use strict'; const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); // === Конвертация события в координаты внутри контейнера === function pointerInContainer(ev, container) { const r = container.getBoundingClientRect(); // Учитываем масштаб SVG (viewBox vs реальный размер) let scaleX = 1, scaleY = 1; if (container.tagName && container.tagName.toLowerCase() === 'svg') { const vb = container.viewBox && container.viewBox.baseVal; if (vb && vb.width) { scaleX = vb.width / r.width; scaleY = vb.height / r.height; } } return { x: (ev.clientX - r.left) * scaleX, y: (ev.clientY - r.top) * scaleY, clientX: ev.clientX, clientY: ev.clientY }; } // === Snap-to-grid === function snapToGrid(x, y, gridSize = 20) { return { x: Math.round(x / gridSize) * gridSize, y: Math.round(y / gridSize) * gridSize }; } // === Hit-test для логических объектов на canvas === // objects: [{ x, y, r, ...meta }] // returns: object or null function hitTest(objects, px, py) { for (let i = objects.length - 1; i >= 0; i--) { const o = objects[i]; const dx = px - o.x, dy = py - o.y; const r = o.r || 20; if (dx * dx + dy * dy <= r * r) return o; } return null; } // === attach: универсальный drag для DOM/SVG элемента === // Использование: // const handle = P8Drag.attach(svgCircle, { // onStart: (ev, pos) => {...}, // onMove: (ev, pos, delta) => {...}, // onEnd: (ev, pos) => {...}, // lockX: false, lockY: false, // bounds: { minX, maxX, minY, maxY }, // snap: 0 // grid size, 0 = no snap // }); // handle.destroy() function attach(el, opts = {}) { if (!el) return { destroy: () => {} }; const container = opts.container || el.ownerSVGElement || el.parentElement || el; const onStart = opts.onStart || (() => {}); const onMove = opts.onMove || (() => {}); const onEnd = opts.onEnd || (() => {}); const lockX = !!opts.lockX; const lockY = !!opts.lockY; const snap = opts.snap || 0; const bounds = opts.bounds || null; let dragging = false; let startPos = null; let pointerId = null; el.style.touchAction = 'none'; el.classList.add('p8-draggable'); function applyConstraints(pos) { let x = pos.x, y = pos.y; if (snap > 0) { const s = snapToGrid(x, y, snap); x = s.x; y = s.y; } if (bounds) { if (bounds.minX !== undefined) x = clamp(x, bounds.minX, bounds.maxX || x); if (bounds.minY !== undefined) y = clamp(y, bounds.minY, bounds.maxY || y); } if (lockX) x = startPos.x; if (lockY) y = startPos.y; return { x, y, clientX: pos.clientX, clientY: pos.clientY }; } function down(ev) { if (ev.button !== undefined && ev.button !== 0) return; ev.preventDefault(); dragging = true; pointerId = ev.pointerId; try { el.setPointerCapture(pointerId); } catch (e) {} el.classList.add('is-dragging'); const pos = pointerInContainer(ev, container); startPos = pos; onStart(ev, pos); } function move(ev) { if (!dragging) return; const pos = applyConstraints(pointerInContainer(ev, container)); const delta = { dx: pos.x - startPos.x, dy: pos.y - startPos.y }; onMove(ev, pos, delta); } function up(ev) { if (!dragging) return; dragging = false; el.classList.remove('is-dragging'); try { el.releasePointerCapture(pointerId); } catch (e) {} const pos = applyConstraints(pointerInContainer(ev, container)); onEnd(ev, pos); startPos = null; pointerId = null; } el.addEventListener('pointerdown', down); el.addEventListener('pointermove', move); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); return { destroy() { el.removeEventListener('pointerdown', down); el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); el.classList.remove('p8-draggable', 'is-dragging'); }, get isDragging() { return dragging; } }; } // === attachCanvas: drag «логических» объектов на canvas === // Использование: // const handle = P8Drag.attachCanvas(canvas, { // objects: [...], // массив объектов с { x, y, r } // onPickup: (obj, pos) => {...}, // onDrag: (obj, pos, delta) => {...}, // onDrop: (obj, pos) => {...}, // onClick: (pos, hitObj) => {...}, // when no drag (mousedown without move) // }); function attachCanvas(canvas, opts = {}) { if (!canvas) return { destroy: () => {} }; const objects = opts.objects || []; const onPickup = opts.onPickup || (() => {}); const onDrag = opts.onDrag || (() => {}); const onDrop = opts.onDrop || (() => {}); const onClick = opts.onClick || (() => {}); let active = null; let startPos = null; let lastPos = null; let pointerId = null; let moved = false; const dragThreshold = 4; canvas.style.touchAction = 'none'; function getPos(ev) { const r = canvas.getBoundingClientRect(); return { x: (ev.clientX - r.left) * canvas.width / r.width, y: (ev.clientY - r.top) * canvas.height / r.height, clientX: ev.clientX, clientY: ev.clientY }; } function down(ev) { if (ev.button !== undefined && ev.button !== 0) return; ev.preventDefault(); const pos = getPos(ev); const hit = hitTest(objects, pos.x, pos.y); if (hit) { active = hit; startPos = pos; lastPos = pos; moved = false; pointerId = ev.pointerId; try { canvas.setPointerCapture(pointerId); } catch (e) {} canvas.style.cursor = 'grabbing'; onPickup(active, pos); } else { // start an empty drag — used for "click anywhere" handlers startPos = pos; lastPos = pos; moved = false; pointerId = ev.pointerId; try { canvas.setPointerCapture(pointerId); } catch (e) {} } } function move(ev) { if (!startPos) return; const pos = getPos(ev); const dx = pos.x - startPos.x, dy = pos.y - startPos.y; if (!moved && (dx * dx + dy * dy) > dragThreshold * dragThreshold) { moved = true; } if (active && moved) { active.x = pos.x; active.y = pos.y; onDrag(active, pos, { dx: pos.x - lastPos.x, dy: pos.y - lastPos.y }); lastPos = pos; } } function up(ev) { if (!startPos) return; const pos = getPos(ev); if (active) { onDrop(active, pos); } else if (!moved) { onClick(pos, null); } try { canvas.releasePointerCapture(pointerId); } catch (e) {} canvas.style.cursor = ''; active = null; startPos = null; lastPos = null; pointerId = null; } // Hover cursor change function hover(ev) { if (active) return; const pos = getPos(ev); const hit = hitTest(objects, pos.x, pos.y); canvas.style.cursor = hit ? 'grab' : ''; } canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', up); canvas.addEventListener('mousemove', hover); return { destroy() { canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', up); canvas.removeEventListener('mousemove', hover); canvas.style.cursor = ''; }, get active() { return active; }, updateObjects(newObjects) { objects.length = 0; objects.push(...newObjects); } }; } // === Palette: drag items from palette to canvas/svg === // items: NodeList of .p8-palette-item with data-component attr // dropTarget: HTML element with .p8-droptarget class // onDrop(componentType, pos): callback when dropped on target function attachPalette(paletteItems, dropTarget, opts = {}) { const onDrop = opts.onDrop || (() => {}); const onDragStart = opts.onDragStart || (() => {}); const onDragEnd = opts.onDragEnd || (() => {}); const handlers = []; [...paletteItems].forEach(item => { let ghost = null; let pointerId = null; function down(ev) { if (ev.button !== undefined && ev.button !== 0) return; ev.preventDefault(); pointerId = ev.pointerId; try { item.setPointerCapture(pointerId); } catch (e) {} const r = item.getBoundingClientRect(); ghost = item.cloneNode(true); ghost.style.cssText = `position:fixed;z-index:9999;pointer-events:none;opacity:.85; width:${r.width}px;left:${r.left}px;top:${r.top}px; box-shadow:0 12px 28px rgba(0,0,0,.20);transform:rotate(-2deg);`; document.body.appendChild(ghost); item.style.opacity = '.35'; const componentType = item.dataset.component || item.textContent.trim(); onDragStart(componentType); } function move(ev) { if (!ghost) return; ghost.style.left = (ev.clientX - 30) + 'px'; ghost.style.top = (ev.clientY - 20) + 'px'; if (dropTarget) { const tr = dropTarget.getBoundingClientRect(); const inside = ev.clientX >= tr.left && ev.clientX <= tr.right && ev.clientY >= tr.top && ev.clientY <= tr.bottom; dropTarget.classList.toggle('p8-drop-over', inside); } } function up(ev) { if (!ghost) return; const componentType = item.dataset.component || item.textContent.trim(); if (dropTarget) { const tr = dropTarget.getBoundingClientRect(); const inside = ev.clientX >= tr.left && ev.clientX <= tr.right && ev.clientY >= tr.top && ev.clientY <= tr.bottom; dropTarget.classList.remove('p8-drop-over'); if (inside) { const pos = { x: ev.clientX - tr.left, y: ev.clientY - tr.top, clientX: ev.clientX, clientY: ev.clientY }; onDrop(componentType, pos); } } ghost.remove(); ghost = null; item.style.opacity = ''; try { item.releasePointerCapture(pointerId); } catch (e) {} pointerId = null; onDragEnd(componentType); } item.style.touchAction = 'none'; item.addEventListener('pointerdown', down); item.addEventListener('pointermove', move); item.addEventListener('pointerup', up); item.addEventListener('pointercancel', up); handlers.push({ item, down, move, up }); }); return { destroy() { handlers.forEach(({ item, down, move, up }) => { item.removeEventListener('pointerdown', down); item.removeEventListener('pointermove', move); item.removeEventListener('pointerup', up); item.removeEventListener('pointercancel', up); }); } }; } // === Export === window.P8Drag = { attach, attachCanvas, attachPalette, snapToGrid, hitTest, pointerInContainer }; })();