Skip to content

Commit c2d645e

Browse files
committed
Added feature #12 - Click & Drag chips from the palette
1 parent f0b7dfc commit c2d645e

2 files changed

Lines changed: 231 additions & 14 deletions

File tree

RR Circuits API.bsdesign

1.72 KB
Binary file not shown.

Source/assets/js/Pages/editor.mjs

Lines changed: 231 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -247,24 +247,241 @@ $(function () {
247247
chipPaletteRender[0].dataset.rendered = 'true';
248248
}
249249

250-
// Load click event for chips
251-
chipElement.on('click', async function (e) {
252-
// Prevent clicks on buttons inside the chip from triggering the node add.
253-
if ($(e.target).closest('button').length > 0) {
254-
// If the click was on the info button, open the info modal.
255-
if ($(e.target).closest('button').hasClass(_.palette.data.chipOpenInfoModalBtnClass)) {
256-
_.palette.functions.openInfoModal(chipData);
257-
}
258-
return;
250+
// Allow info button clicks without spawning / dragging.
251+
chipElement.on('click', 'button', function (e) {
252+
if ($(e.target).closest('button').hasClass(_.palette.data.chipOpenInfoModalBtnClass)) {
253+
e.preventDefault();
254+
e.stopPropagation();
255+
_.palette.functions.openInfoModal(chipData);
259256
}
257+
});
258+
259+
// Click-to-spawn (center) + click-hold-drag to place.
260+
(() => {
261+
const DRAG_THRESHOLD_PX = 6;
262+
let active = false;
263+
let pointerId = null;
264+
let startX = 0;
265+
let startY = 0;
266+
let didDrag = false;
267+
let createdNodeId = null;
268+
let createdNodeW = 0;
269+
let createdNodeH = 0;
270+
let createPromise = null;
271+
let lastMoveX = 0;
272+
let lastMoveY = 0;
273+
let allowDrag = true;
274+
let pressTimer = null;
275+
276+
const cleanup = () => {
277+
active = false;
278+
pointerId = null;
279+
startX = 0;
280+
startY = 0;
281+
didDrag = false;
282+
createdNodeId = null;
283+
createdNodeW = 0;
284+
createdNodeH = 0;
285+
createPromise = null;
286+
lastMoveX = 0;
287+
lastMoveY = 0;
288+
allowDrag = true;
289+
if (pressTimer) {
290+
try { clearTimeout(pressTimer); } catch { /* ignore */ }
291+
pressTimer = null;
292+
}
293+
};
294+
295+
const _getClampedWorldFromClient = (clientX, clientY) => {
296+
const vpEl = _.graph.data?.elements?.graphCanvasViewport?.element;
297+
if (!vpEl) return null;
298+
const rect = vpEl.getBoundingClientRect();
299+
const cx = Math.max(rect.left, Math.min(rect.right, clientX));
300+
const cy = Math.max(rect.top, Math.min(rect.bottom, clientY));
301+
const sx = cx - rect.left;
302+
const sy = cy - rect.top;
303+
const world = _.graph.functions.screenToWorld(sx, sy);
304+
return { world, rect, cx, cy };
305+
};
306+
307+
const _moveCreatedNodeToClient = (clientX, clientY) => {
308+
if (!createdNodeId) return;
309+
const info = _getClampedWorldFromClient(clientX, clientY);
310+
if (!info?.world) return;
311+
312+
// Keep the pointer on the *center* of the chip.
313+
// Use live bounding box (screen px) divided by zoom to get world size.
314+
let w = 0;
315+
let h = 0;
316+
try {
317+
const nodeEl = _.graph.data?.nodes?.find?.(n => n?.id === createdNodeId)?.element?.[0];
318+
const s = Number(_.graph?.data?.cameraState?.scale ?? 1) || 1;
319+
if (nodeEl && typeof nodeEl.getBoundingClientRect === 'function') {
320+
const r = nodeEl.getBoundingClientRect();
321+
w = Number(r.width || 0) / s;
322+
h = Number(r.height || 0) / s;
323+
}
324+
} catch { /* ignore */ }
325+
if (!w || !h) {
326+
w = Number(createdNodeW || 0);
327+
h = Number(createdNodeH || 0);
328+
}
329+
330+
const px = Number(info.world.x || 0) - (w ? w * 0.5 : 0);
331+
const py = Number(info.world.y || 0) - (h ? h * 0.5 : 0);
332+
_.graph.node.setPosition(createdNodeId, px, py);
333+
};
334+
335+
const _ensureNodeCreated = async (clientX, clientY) => {
336+
if (createdNodeId) return createdNodeId;
337+
if (createPromise) return createPromise;
338+
339+
createPromise = (async () => {
340+
const vpEl = _.graph.data?.elements?.graphCanvasViewport?.element;
341+
if (!vpEl) return null;
342+
343+
// One undo step for the whole gesture: create node + drag placement.
344+
_.graph.functions._recordHistory?.();
345+
const created = await _.graph.node.add(chipData, { skipHistory: true });
346+
if (!created?.id) return null;
347+
348+
createdNodeId = created.id;
349+
createdNodeW = Number(created.element?.outerWidth?.() || 0);
350+
createdNodeH = Number(created.element?.outerHeight?.() || 0);
351+
352+
// Make it the active selection.
353+
for (const n of (_.graph.data.nodes || [])) {
354+
if (n?.selected) _.graph.functions.deselectNode(n.element);
355+
}
356+
_.graph.functions.selectNode?.(createdNodeId);
357+
358+
// Place immediately at the palette click position projected onto the canvas.
359+
_moveCreatedNodeToClient(clientX, clientY);
360+
return createdNodeId;
361+
})();
362+
363+
return createPromise;
364+
};
365+
366+
const onPointerMove = (e) => {
367+
if (!active) return;
368+
if (pointerId !== null && e.pointerId !== pointerId) return;
369+
370+
lastMoveX = e.clientX;
371+
lastMoveY = e.clientY;
372+
373+
const dx = e.clientX - startX;
374+
const dy = e.clientY - startY;
375+
const dist = Math.hypot(dx, dy);
376+
377+
// On touch, require a short long-press before we treat movement as a drag.
378+
if (!allowDrag && dist >= DRAG_THRESHOLD_PX) {
379+
// User likely intended to scroll; cancel gesture and don't spawn.
380+
window.removeEventListener('pointermove', onPointerMove, true);
381+
window.removeEventListener('pointerup', onPointerUp, true);
382+
window.removeEventListener('pointercancel', onPointerCancel, true);
383+
cleanup();
384+
return;
385+
}
386+
387+
if (!didDrag && dist >= DRAG_THRESHOLD_PX) {
388+
didDrag = true;
389+
// Capture the pointer once we know this is a drag so palette scrolling still works.
390+
try { chipElement[0]?.setPointerCapture?.(e.pointerId); } catch { /* ignore */ }
391+
392+
// Create the real node on the canvas where the palette was clicked, then start dragging it.
393+
_ensureNodeCreated(startX, startY).then(() => {
394+
_moveCreatedNodeToClient(lastMoveX || startX, lastMoveY || startY);
395+
});
396+
}
397+
398+
if (didDrag) {
399+
// Prevent page scrolling while dragging a chip.
400+
e.preventDefault?.();
401+
// Keep the created node under the pointer (projected into the canvas bounds).
402+
if (createdNodeId) _moveCreatedNodeToClient(e.clientX, e.clientY);
403+
}
404+
};
405+
406+
const onPointerUp = async (e) => {
407+
if (!active) return;
408+
if (pointerId !== null && e.pointerId !== pointerId) return;
409+
410+
window.removeEventListener('pointermove', onPointerMove, true);
411+
window.removeEventListener('pointerup', onPointerUp, true);
412+
window.removeEventListener('pointercancel', onPointerCancel, true);
413+
414+
try { chipElement[0]?.releasePointerCapture?.(e.pointerId); } catch { /* ignore */ }
415+
416+
// If this was a click (no drag), keep existing behavior.
417+
if (!didDrag) {
418+
cleanup();
419+
await _.graph.node.add(chipData);
420+
if (_.data.mobile.isMobile) $('body').trigger('openPalette');
421+
return;
422+
}
260423

261-
// await chip.render($('#render'), chipData, { size: 1, log: true });
262-
await _.graph.node.add(chipData);
424+
// Drag-drop: node is created on drag start and follows the pointer.
425+
if (createPromise) {
426+
try { await createPromise; } catch { /* ignore */ }
427+
}
428+
if (createdNodeId) {
429+
_moveCreatedNodeToClient(e.clientX, e.clientY);
430+
}
431+
cleanup();
432+
if (_.data.mobile.isMobile) $('body').trigger('openPalette');
433+
};
434+
435+
const onPointerCancel = async (e) => {
436+
if (!active) return;
437+
if (pointerId !== null && e.pointerId !== pointerId) return;
438+
window.removeEventListener('pointermove', onPointerMove, true);
439+
window.removeEventListener('pointerup', onPointerUp, true);
440+
window.removeEventListener('pointercancel', onPointerCancel, true);
441+
// If we created a node during this drag, remove it on cancel.
442+
if (createPromise) {
443+
try { await createPromise; } catch { /* ignore */ }
444+
}
445+
if (createdNodeId) {
446+
try { _.graph.functions.deleteNode?.(createdNodeId); } catch { /* ignore */ }
447+
}
448+
cleanup();
449+
};
450+
451+
chipElement.on('pointerdown', function (e) {
452+
// Ignore if pointer events aren't supported.
453+
if (!window.PointerEvent) return;
454+
// Ignore non-primary buttons.
455+
if (typeof e.button === 'number' && e.button !== 0) return;
456+
// Ignore gestures that start on buttons.
457+
if ($(e.target).closest('button').length > 0) return;
458+
459+
active = true;
460+
pointerId = e.pointerId;
461+
startX = e.clientX;
462+
startY = e.clientY;
463+
464+
allowDrag = e.pointerType !== 'touch';
465+
if (!allowDrag) {
466+
pressTimer = setTimeout(() => {
467+
allowDrag = true;
468+
}, 160);
469+
}
263470

264-
if (_.data.mobile.isMobile) {
265-
$('body').trigger('openPalette');
471+
window.addEventListener('pointermove', onPointerMove, { capture: true, passive: false });
472+
window.addEventListener('pointerup', onPointerUp, true);
473+
window.addEventListener('pointercancel', onPointerCancel, true);
474+
});
475+
476+
// Fallback for very old browsers: keep click-to-spawn.
477+
if (!window.PointerEvent) {
478+
chipElement.on('click', async function (e) {
479+
if ($(e.target).closest('button').length > 0) return;
480+
await _.graph.node.add(chipData);
481+
if (_.data.mobile.isMobile) $('body').trigger('openPalette');
482+
});
266483
}
267-
});
484+
})();
268485
}
269486
},
270487
searchInput: async () => {

0 commit comments

Comments
 (0)