@@ -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