Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
language: 'en-US'
early_access: false
reviews:
profile: 'chill'
request_changes_workflow: false
high_level_summary: true
poem: true
review_status: true
collapse_walkthrough: false
auto_review:
enabled: true
drafts: true
base_branches: ['*']
chat:
auto_reply: true
18 changes: 0 additions & 18 deletions .github/workflows/pull-request-comment.yml

This file was deleted.

171 changes: 171 additions & 0 deletions src/Allonsh.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { DEFAULTS, CSS_CLASSES } from './constants.js';
import { EventManager } from './EventManager.js';
import { DragManager } from './DragManager.js';
import { DropzoneManager } from './DropzoneManager.js';
import { StyleManager } from './StyleManager.js';

export default class Allonsh {
constructor(options = {}) {
const {
draggableSelector,
dropzoneSelector = null,
playAreaSelector = null,
restrictToDropzones = false,
enableStacking = false,
stackDirection = DEFAULTS.STACK_DIRECTION,
stackSpacing = DEFAULTS.STACK_SPACING,
useGhostEffect = false,
} = options;

if (!draggableSelector) {
throw new Error("Allonsh Error: 'draggableSelector' is required.");
}

this.options = { draggableSelector, dropzoneSelector, playAreaSelector };
this.restrictToDropzones = restrictToDropzones;
this.enableStacking = enableStacking;
this.stackDirection = stackDirection;
this.stackSpacing = stackSpacing;
this.useGhostEffect = useGhostEffect;

this.playAreaElement = playAreaSelector
? document.querySelector(`.${playAreaSelector}`)
: document.body;
if (!this.playAreaElement) {
throw new Error(
`Allonsh Error: Play area element with class '${playAreaSelector}' not found.`
);
}

this.draggableElements = this.playAreaElement.querySelectorAll(
`.${draggableSelector}`
);
if (!this.draggableElements.length) {
console.warn(
`Allonsh Warning: No draggable elements found with selector '.${draggableSelector}'.`
);
}
Comment on lines +31 to +47
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Accept real CSS selectors instead of forcing class names

The API calls these “selectors” but code prepends a dot, breaking valid selectors (e.g., '#id', '[data-x]'). Normalize: if the string starts with '.', '#', or '[' use as-is; otherwise treat as a class.

Apply this diff:

 export default class Allonsh {
+  _normalizeSelector(sel) {
+    if (!sel) return sel;
+    const s = sel.trim();
+    return /^[.#\[]/.test(s) ? s : `.${s}`;
+  }
   constructor(options = {}) {
@@
-    this.playAreaElement = playAreaSelector
-      ? document.querySelector(`.${playAreaSelector}`)
+    this.playAreaElement = playAreaSelector
+      ? document.querySelector(this._normalizeSelector(playAreaSelector))
       : document.body;
@@
-    this.draggableElements = this.playAreaElement.querySelectorAll(
-      `.${draggableSelector}`
-    );
+    this.draggableElements = this.playAreaElement.querySelectorAll(
+      this._normalizeSelector(draggableSelector)
+    );
@@
-      const newPlayArea = document.querySelector(`.${playAreaSelector}`);
+      const newPlayArea = document.querySelector(this._normalizeSelector(playAreaSelector));
@@
-      this.draggableElements = this.playAreaElement.querySelectorAll(
-        `.${draggableSelector}`
-      );
+      this.draggableElements = this.playAreaElement.querySelectorAll(
+        this._normalizeSelector(draggableSelector)
+      );
@@
   addDraggable(element) {
     this.eventManager.bindEvents([element]);
-    this.draggableElements = this.playAreaElement.querySelectorAll(
-      `.${CSS_CLASSES.DRAGGABLE}`
-    );
+    this.draggableElements = this.playAreaElement.querySelectorAll(
+      this._normalizeSelector(this.options.draggableSelector)
+    );
   }
@@
   removeDraggable(element) {
     this.eventManager.unbindEvents([element]);
     element.classList.remove(CSS_CLASSES.DRAGGABLE);
-    this.draggableElements = this.playAreaElement.querySelectorAll(
-      `.${CSS_CLASSES.DRAGGABLE}`
-    );
+    this.draggableElements = this.playAreaElement.querySelectorAll(
+      this._normalizeSelector(this.options.draggableSelector)
+    );
   }

Also applies to: 104-116, 118-124, 152-165

🤖 Prompt for AI Agents
In src/Allonsh.js around lines 31-47 (and also update the same pattern at
104-116, 118-124, and 152-165), the constructor and other methods currently
force a class selector by prepending '.' which breaks valid CSS selectors like
'#id' or '[data-x]'; change the logic to normalize incoming selector strings: if
the selector is falsy leave it (use document.body where intended), else if it
starts with '.' or '#' or '[' use it as-is, otherwise prepend '.' to treat it as
a class; then use this normalized selector for document.querySelector /
querySelectorAll and for all error/warning messages so they report the exact
selector string used. Ensure the normalization is applied consistently at the
listed line ranges and that checks for "no elements found" use the normalized
selector rather than assuming a class name.


this.styleManager = new StyleManager();
this.dropzoneManager = new DropzoneManager(
this.playAreaElement,
enableStacking,
stackDirection,
stackSpacing,
this.styleManager
);
this.dragManager = new DragManager(
this.playAreaElement,
this.dropzoneManager,
this.styleManager,
useGhostEffect,
restrictToDropzones
);
this.eventManager = new EventManager(
this.dragManager,
this.dropzoneManager,
this.styleManager
);

this._initialize();
}

_initialize() {
this.styleManager.applyPlayAreaStyles(this.playAreaElement);
if (!this.draggableElements.length) return;
this.eventManager.bindEvents(this.draggableElements);
this.dropzoneManager.initializeDropzones(this.options.dropzoneSelector);
}
Comment on lines +73 to +78
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Dropzones not initialized when there are zero draggables

Early return skips dropzone initialization. Initialize dropzones unconditionally; only guard event binding.

Apply this diff:

   _initialize() {
     this.styleManager.applyPlayAreaStyles(this.playAreaElement);
-    if (!this.draggableElements.length) return;
-    this.eventManager.bindEvents(this.draggableElements);
-    this.dropzoneManager.initializeDropzones(this.options.dropzoneSelector);
+    if (this.draggableElements.length) {
+      this.eventManager.bindEvents(this.draggableElements);
+    }
+    this.dropzoneManager.initializeDropzones(this.options.dropzoneSelector);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_initialize() {
this.styleManager.applyPlayAreaStyles(this.playAreaElement);
if (!this.draggableElements.length) return;
this.eventManager.bindEvents(this.draggableElements);
this.dropzoneManager.initializeDropzones(this.options.dropzoneSelector);
}
_initialize() {
this.styleManager.applyPlayAreaStyles(this.playAreaElement);
if (this.draggableElements.length) {
this.eventManager.bindEvents(this.draggableElements);
}
this.dropzoneManager.initializeDropzones(this.options.dropzoneSelector);
}
🤖 Prompt for AI Agents
In src/Allonsh.js around lines 73 to 78, the early return prevents dropzones
from being initialized when there are zero draggable elements; change the logic
so dropzoneManager.initializeDropzones(this.options.dropzoneSelector) is always
called and only eventManager.bindEvents(this.draggableElements) is conditional
on this.draggableElements.length. Move or remove the early return and wrap only
the event binding in an if (this.draggableElements.length) guard so dropzones
initialize unconditionally while event binding remains protected.


update(newOptions = {}) {
const {
draggableSelector = this.options.draggableSelector,
dropzoneSelector = this.options.dropzoneSelector,
playAreaSelector = this.options.playAreaSelector,
restrictToDropzones = this.restrictToDropzones,
enableStacking = this.enableStacking,
stackDirection = this.stackDirection,
stackSpacing = this.stackSpacing,
useGhostEffect = this.useGhostEffect,
} = newOptions;

this.options = {
...this.options,
draggableSelector,
dropzoneSelector,
playAreaSelector,
};
this.restrictToDropzones = restrictToDropzones;
this.enableStacking = enableStacking;
this.stackDirection = stackDirection;
this.stackSpacing = stackSpacing;
this.useGhostEffect = useGhostEffect;

if (playAreaSelector !== this.options.playAreaSelector) {
const newPlayArea = document.querySelector(`.${playAreaSelector}`);
if (newPlayArea) {
this.playAreaElement = newPlayArea;
this.styleManager.applyPlayAreaStyles(this.playAreaElement);
this.dragManager.playAreaElement = this.playAreaElement;
this.dropzoneManager.playAreaElement = this.playAreaElement;
} else {
console.warn(
`Allonsh Warning: Play area element with class '${playAreaSelector}' not found.`
);
}
}

if (draggableSelector !== this.options.draggableSelector) {
this.eventManager.unbindEvents(this.draggableElements);
this.draggableElements = this.playAreaElement.querySelectorAll(
`.${draggableSelector}`
);
this.eventManager.bindEvents(this.draggableElements);
}

if (dropzoneSelector !== this.options.dropzoneSelector) {
this.dropzoneManager.setDropzones(dropzoneSelector);
}

Comment on lines +92 to +129
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

update() never applies changes due to comparing against already-mutated options

You assign this.options before comparing, so all equality checks are false negatives. Capture prevOptions first and compare against that.

Apply this diff:

   update(newOptions = {}) {
     const {
       draggableSelector = this.options.draggableSelector,
       dropzoneSelector = this.options.dropzoneSelector,
       playAreaSelector = this.options.playAreaSelector,
       restrictToDropzones = this.restrictToDropzones,
       enableStacking = this.enableStacking,
       stackDirection = this.stackDirection,
       stackSpacing = this.stackSpacing,
       useGhostEffect = this.useGhostEffect,
     } = newOptions;

-    this.options = {
-      ...this.options,
-      draggableSelector,
-      dropzoneSelector,
-      playAreaSelector,
-    };
+    const prevOptions = { ...this.options };
+    this.options = { ...this.options, draggableSelector, dropzoneSelector, playAreaSelector };
     this.restrictToDropzones = restrictToDropzones;
     this.enableStacking = enableStacking;
     this.stackDirection = stackDirection;
     this.stackSpacing = stackSpacing;
     this.useGhostEffect = useGhostEffect;

-    if (playAreaSelector !== this.options.playAreaSelector) {
+    if (playAreaSelector !== prevOptions.playAreaSelector) {
       const newPlayArea = document.querySelector(`.${playAreaSelector}`);
       if (newPlayArea) {
         this.playAreaElement = newPlayArea;
         this.styleManager.applyPlayAreaStyles(this.playAreaElement);
         this.dragManager.playAreaElement = this.playAreaElement;
         this.dropzoneManager.playAreaElement = this.playAreaElement;
       } else {
         console.warn(
           `Allonsh Warning: Play area element with class '${playAreaSelector}' not found.`
         );
       }
     }

-    if (draggableSelector !== this.options.draggableSelector) {
+    if (draggableSelector !== prevOptions.draggableSelector) {
       this.eventManager.unbindEvents(this.draggableElements);
       this.draggableElements = this.playAreaElement.querySelectorAll(
         `.${draggableSelector}`
       );
       this.eventManager.bindEvents(this.draggableElements);
     }

-    if (dropzoneSelector !== this.options.dropzoneSelector) {
+    if (dropzoneSelector !== prevOptions.dropzoneSelector) {
       this.dropzoneManager.setDropzones(dropzoneSelector);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.options = {
...this.options,
draggableSelector,
dropzoneSelector,
playAreaSelector,
};
this.restrictToDropzones = restrictToDropzones;
this.enableStacking = enableStacking;
this.stackDirection = stackDirection;
this.stackSpacing = stackSpacing;
this.useGhostEffect = useGhostEffect;
if (playAreaSelector !== this.options.playAreaSelector) {
const newPlayArea = document.querySelector(`.${playAreaSelector}`);
if (newPlayArea) {
this.playAreaElement = newPlayArea;
this.styleManager.applyPlayAreaStyles(this.playAreaElement);
this.dragManager.playAreaElement = this.playAreaElement;
this.dropzoneManager.playAreaElement = this.playAreaElement;
} else {
console.warn(
`Allonsh Warning: Play area element with class '${playAreaSelector}' not found.`
);
}
}
if (draggableSelector !== this.options.draggableSelector) {
this.eventManager.unbindEvents(this.draggableElements);
this.draggableElements = this.playAreaElement.querySelectorAll(
`.${draggableSelector}`
);
this.eventManager.bindEvents(this.draggableElements);
}
if (dropzoneSelector !== this.options.dropzoneSelector) {
this.dropzoneManager.setDropzones(dropzoneSelector);
}
update(newOptions = {}) {
const {
draggableSelector = this.options.draggableSelector,
dropzoneSelector = this.options.dropzoneSelector,
playAreaSelector = this.options.playAreaSelector,
restrictToDropzones = this.restrictToDropzones,
enableStacking = this.enableStacking,
stackDirection = this.stackDirection,
stackSpacing = this.stackSpacing,
useGhostEffect = this.useGhostEffect,
} = newOptions;
// Capture old options before mutating
const prevOptions = { ...this.options };
this.options = {
...this.options,
draggableSelector,
dropzoneSelector,
playAreaSelector,
};
this.restrictToDropzones = restrictToDropzones;
this.enableStacking = enableStacking;
this.stackDirection = stackDirection;
this.stackSpacing = stackSpacing;
this.useGhostEffect = useGhostEffect;
if (playAreaSelector !== prevOptions.playAreaSelector) {
const newPlayArea = document.querySelector(`.${playAreaSelector}`);
if (newPlayArea) {
this.playAreaElement = newPlayArea;
this.styleManager.applyPlayAreaStyles(this.playAreaElement);
this.dragManager.playAreaElement = this.playAreaElement;
this.dropzoneManager.playAreaElement = this.playAreaElement;
} else {
console.warn(
`Allonsh Warning: Play area element with class '${playAreaSelector}' not found.`
);
}
}
if (draggableSelector !== prevOptions.draggableSelector) {
this.eventManager.unbindEvents(this.draggableElements);
this.draggableElements = this.playAreaElement.querySelectorAll(
`.${draggableSelector}`
);
this.eventManager.bindEvents(this.draggableElements);
}
if (dropzoneSelector !== prevOptions.dropzoneSelector) {
this.dropzoneManager.setDropzones(dropzoneSelector);
}
}

this.dropzoneManager.enableStacking = this.enableStacking;
this.dropzoneManager.stackDirection = this.stackDirection;
this.dropzoneManager.stackSpacing = this.stackSpacing;
this.dragManager.useGhostEffect = this.useGhostEffect;
this.dragManager.restrictToDropzones = this.restrictToDropzones;

this.dropzoneManager.dropzoneElements.forEach((dropzone) => {
this.enableStacking
? this.dropzoneManager.applyStackingStyles(dropzone)
: this.dropzoneManager.removeStackingStyles(dropzone);
});
}

resetAll() {
this.draggableElements.forEach((el) => {
if (this.playAreaElement.contains(el)) {
this.playAreaElement.appendChild(el);
this.styleManager.resetPosition(el);
}
});
}

addDraggable(element) {
this.eventManager.bindEvents([element]);
this.draggableElements = this.playAreaElement.querySelectorAll(
`.${CSS_CLASSES.DRAGGABLE}`
);
}

removeDraggable(element) {
this.eventManager.unbindEvents([element]);
element.classList.remove(CSS_CLASSES.DRAGGABLE);
this.draggableElements = this.playAreaElement.querySelectorAll(
`.${CSS_CLASSES.DRAGGABLE}`
);
}
Comment on lines +152 to +165
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Keep draggable discovery consistent with configuration

Re-querying by CSS_CLASSES.DRAGGABLE can diverge from the configured selector. The diff above switches these to use options.draggableSelector for consistency.

Run to check no hidden reliance on CSS_CLASSES.DRAGGABLE for discovery:


🏁 Script executed:

#!/bin/bash
# Expect matches only in Style/Event managers (for styling), not for querying lists in Allonsh.
rg -n --type=js 'CSS_CLASSES\.DRAGGABLE' src | sed -n '1,200p'

Length of output: 313


Use configured draggableSelector instead of CSS_CLASSES.DRAGGABLE for queries
Replace the selector in addDraggable (line 155) and removeDraggable (line 163) with this.options.draggableSelector so discovery aligns with the configured selector.

🤖 Prompt for AI Agents
In src/Allonsh.js around lines 152 to 165, the querySelectorAll calls currently
use `.${CSS_CLASSES.DRAGGABLE}` which ignores a configured selector; update both
occurrences (in addDraggable and removeDraggable) to use
`this.options.draggableSelector` instead so discovery aligns with configuration,
e.g. replace the selector argument with `this.options.draggableSelector`
(preserve the rest of the logic), and ensure `this.options.draggableSelector` is
defined/available where these methods run.


setDropzones(selector) {
this.dropzoneManager.setDropzones(selector);
this.options.dropzoneSelector = selector;
}
}
174 changes: 174 additions & 0 deletions src/DragManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import {
CSS_POSITIONS,
OPACITY,
Z_INDEX,
EVENTS,
POINTER_EVENTS,
} from './constants.js';

export class DragManager {
constructor(
playAreaElement,
dropzoneManager,
styleManager,
useGhostEffect,
restrictToDropzones
) {
this.playAreaElement = playAreaElement;
this.dropzoneManager = dropzoneManager;
this.styleManager = styleManager;
this.useGhostEffect = useGhostEffect;
this.restrictToDropzones = restrictToDropzones;
this.currentDraggedElement = null;
this.ghostElement = null;
this.originalParent = null;
this.originalDropzone = null;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
}

startDrag(event, clientX, clientY) {
this.currentDraggedElement = event.currentTarget;
this.styleManager.applyDraggingStyles(this.currentDraggedElement);
this.originalParent = this.currentDraggedElement.parentElement;
this.originalDropzone = this.dropzoneManager.findClosestDropzone(
this.currentDraggedElement
);

const playAreaRect = this.playAreaElement.getBoundingClientRect();
const rect = this.currentDraggedElement.getBoundingClientRect();

if (this.useGhostEffect && this.originalParent !== this.playAreaElement) {
this._createGhostElement(rect, playAreaRect);
}

this.dragOffsetX = clientX - rect.left;
this.dragOffsetY = clientY - rect.top;

this.currentDraggedElement.dispatchEvent(
new CustomEvent(EVENTS.DRAG_START, { detail: { originalEvent: event } })
);

this.dropzoneManager.toggleHighlight(true);
}
Comment on lines +38 to +53
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix coordinate-space mismatch when dragging from a dropzone without ghosting

When ghosting is disabled and the element’s parent isn’t the play area, left/top are computed relative to the play area but applied relative to the element’s current offset parent (often the dropzone). Move the element into the play area at drag start to align coordinate systems and preserve its on-screen position.

Apply this diff:

-    const playAreaRect = this.playAreaElement.getBoundingClientRect();
-    const rect = this.currentDraggedElement.getBoundingClientRect();
+    const playAreaRect = this.playAreaElement.getBoundingClientRect();
+    let rect = this.currentDraggedElement.getBoundingClientRect();

     if (this.useGhostEffect && this.originalParent !== this.playAreaElement) {
       this._createGhostElement(rect, playAreaRect);
+    } else if (this.originalParent !== this.playAreaElement) {
+      // Align coordinate space with play area
+      this.playAreaElement.appendChild(this.currentDraggedElement);
+      this.styleManager.applyAbsolutePositioning(this.currentDraggedElement);
+      this.currentDraggedElement.style.left = `${rect.left - playAreaRect.left}px`;
+      this.currentDraggedElement.style.top = `${rect.top - playAreaRect.top}px`;
+      // Recompute rect after reparenting to keep offsets accurate
+      rect = this.currentDraggedElement.getBoundingClientRect();
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const playAreaRect = this.playAreaElement.getBoundingClientRect();
const rect = this.currentDraggedElement.getBoundingClientRect();
if (this.useGhostEffect && this.originalParent !== this.playAreaElement) {
this._createGhostElement(rect, playAreaRect);
}
this.dragOffsetX = clientX - rect.left;
this.dragOffsetY = clientY - rect.top;
this.currentDraggedElement.dispatchEvent(
new CustomEvent(EVENTS.DRAG_START, { detail: { originalEvent: event } })
);
this.dropzoneManager.toggleHighlight(true);
}
const playAreaRect = this.playAreaElement.getBoundingClientRect();
let rect = this.currentDraggedElement.getBoundingClientRect();
if (this.useGhostEffect && this.originalParent !== this.playAreaElement) {
this._createGhostElement(rect, playAreaRect);
} else if (this.originalParent !== this.playAreaElement) {
// Align coordinate space with play area
this.playAreaElement.appendChild(this.currentDraggedElement);
this.styleManager.applyAbsolutePositioning(this.currentDraggedElement);
this.currentDraggedElement.style.left = `${rect.left - playAreaRect.left}px`;
this.currentDraggedElement.style.top = `${rect.top - playAreaRect.top}px`;
// Recompute rect after reparenting to keep offsets accurate
rect = this.currentDraggedElement.getBoundingClientRect();
}
this.dragOffsetX = clientX - rect.left;
this.dragOffsetY = clientY - rect.top;
this.currentDraggedElement.dispatchEvent(
new CustomEvent(EVENTS.DRAG_START, { detail: { originalEvent: event } })
);
this.dropzoneManager.toggleHighlight(true);
🤖 Prompt for AI Agents
In src/DragManager.js around lines 38-53, when ghosting is disabled and the
dragged element's originalParent !== playAreaElement the code computes left/top
relative to the play area but leaves the element inside its offset parent
(dropzone), causing a coordinate-space mismatch; fix by moving the element into
the play area at drag start while preserving its on-screen position: compute the
element.getBoundingClientRect() and playAreaElement.getBoundingClientRect(), set
the dragged element to position:absolute (or ensure it already is), set its
style.left = (elemRect.left - playAreaRect.left) + 'px' and style.top =
(elemRect.top - playAreaRect.top) + 'px', append it to playAreaElement, then
recompute rect (or use the preserved values) and calculate
dragOffsetX/dragOffsetY from clientX/clientY relative to the element's new rect
before dispatching DRAG_START and toggling the dropzone highlight.


_createGhostElement(rect, playAreaRect) {
this._removeGhostElement();
this.ghostElement = this.currentDraggedElement.cloneNode(true);
this.styleManager.applyGhostStyles(this.ghostElement);
this.playAreaElement.appendChild(this.ghostElement);
this.ghostElement.style.left = `${rect.left - playAreaRect.left}px`;
this.ghostElement.style.top = `${rect.top - playAreaRect.top}px`;
this.currentDraggedElement.style.opacity = OPACITY.GHOST;
}
Comment on lines +55 to +63
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Prevent ghost from intercepting hit-tests and lock its size

The ghost can sit under the pointer and consume elementFromPoint hits, breaking dropzone detection. Also, cloning without locking width/height can cause size drift under different CSS context.

Apply this diff:

   _createGhostElement(rect, playAreaRect) {
     this._removeGhostElement();
     this.ghostElement = this.currentDraggedElement.cloneNode(true);
     this.styleManager.applyGhostStyles(this.ghostElement);
     this.playAreaElement.appendChild(this.ghostElement);
     this.ghostElement.style.left = `${rect.left - playAreaRect.left}px`;
     this.ghostElement.style.top = `${rect.top - playAreaRect.top}px`;
+    this.ghostElement.style.width = `${rect.width}px`;
+    this.ghostElement.style.height = `${rect.height}px`;
+    this.ghostElement.style.pointerEvents = POINTER_EVENTS.NONE;
     this.currentDraggedElement.style.opacity = OPACITY.GHOST;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_createGhostElement(rect, playAreaRect) {
this._removeGhostElement();
this.ghostElement = this.currentDraggedElement.cloneNode(true);
this.styleManager.applyGhostStyles(this.ghostElement);
this.playAreaElement.appendChild(this.ghostElement);
this.ghostElement.style.left = `${rect.left - playAreaRect.left}px`;
this.ghostElement.style.top = `${rect.top - playAreaRect.top}px`;
this.currentDraggedElement.style.opacity = OPACITY.GHOST;
}
_createGhostElement(rect, playAreaRect) {
this._removeGhostElement();
this.ghostElement = this.currentDraggedElement.cloneNode(true);
this.styleManager.applyGhostStyles(this.ghostElement);
this.playAreaElement.appendChild(this.ghostElement);
this.ghostElement.style.left = `${rect.left - playAreaRect.left}px`;
this.ghostElement.style.top = `${rect.top - playAreaRect.top}px`;
this.ghostElement.style.width = `${rect.width}px`;
this.ghostElement.style.height = `${rect.height}px`;
this.ghostElement.style.pointerEvents = POINTER_EVENTS.NONE;
this.currentDraggedElement.style.opacity = OPACITY.GHOST;
}
🤖 Prompt for AI Agents
In src/DragManager.js around lines 55 to 63, the ghost element currently can
intercept hit-tests and its size can drift; after creating and styling the
cloned ghost, set its CSS pointer-events to 'none' so it won't block
elementFromPoint, and lock its size by explicitly setting style.width and
style.height from rect.width and rect.height (in px) before
appending/positioning it; keep the existing left/top positioning and ghost
opacity changes for the source element.


updateDragPosition(clientX, clientY) {
if (!this.currentDraggedElement) return;

const playAreaRect = this.playAreaElement.getBoundingClientRect();
const element = this.ghostElement || this.currentDraggedElement;
const rect = element.getBoundingClientRect();
const maxLeft = playAreaRect.width - rect.width;
const maxTop = playAreaRect.height - rect.height;

let newLeft = clientX - playAreaRect.left - this.dragOffsetX;
let newTop = clientY - playAreaRect.top - this.dragOffsetY;

newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));

if (this.ghostElement) {
this.ghostElement.style.left = `${newLeft}px`;
this.ghostElement.style.top = `${newTop}px`;
} else {
this.styleManager.applyAbsolutePositioning(this.currentDraggedElement);
this.currentDraggedElement.style.left = `${newLeft}px`;
this.currentDraggedElement.style.top = `${newTop}px`;
}
}

handleDrop(clientX, clientY, event) {
if (!this.currentDraggedElement) return;

this.currentDraggedElement.style.opacity = OPACITY.FULL;
const playAreaRect = this.playAreaElement.getBoundingClientRect();
const clampedX = Math.min(
Math.max(clientX, playAreaRect.left + 1),
playAreaRect.right - 1
);
const clampedY = Math.min(
Math.max(clientY, playAreaRect.top + 1),
playAreaRect.bottom - 1
);

this.currentDraggedElement.style.pointerEvents = POINTER_EVENTS.NONE;
const elementBelow = document.elementFromPoint(clampedX, clampedY);
this.currentDraggedElement.style.pointerEvents = POINTER_EVENTS.AUTO;

if (!elementBelow && this.restrictToDropzones) {
this._returnToOrigin();
this._resetDraggedElementState();
return;
}

const dropzoneFound =
this.dropzoneManager.findClosestDropzone(elementBelow);
if (dropzoneFound) {
this.dropzoneManager.handleDropzoneDrop(
this.currentDraggedElement,
dropzoneFound,
event
);
} else if (!this.restrictToDropzones) {
this._placeInPlayArea(clampedX, clampedY, playAreaRect);
} else {
this._returnToOrigin();
}

this._resetDraggedElementState();
}
Comment on lines +90 to +129
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Emit DRAG_END/DROP events for parity with DRAG_START

Downstream listeners likely expect a matching lifecycle. Emit DROP when a drop target is resolved and DRAG_END at the end of the gesture.

Apply this diff (adjust event names if your constants differ):

     if (dropzoneFound) {
       this.dropzoneManager.handleDropzoneDrop(
         this.currentDraggedElement,
         dropzoneFound,
         event
       );
+      this.currentDraggedElement.dispatchEvent(
+        new CustomEvent(EVENTS.DROP, { detail: { target: dropzoneFound, originalEvent: event } })
+      );
     } else if (!this.restrictToDropzones) {
       this._placeInPlayArea(clampedX, clampedY, playAreaRect);
+      this.currentDraggedElement.dispatchEvent(
+        new CustomEvent(EVENTS.DROP, { detail: { target: this.playAreaElement, originalEvent: event } })
+      );
     } else {
       this._returnToOrigin();
     }

-    this._resetDraggedElementState();
+    this.currentDraggedElement.dispatchEvent(
+      new CustomEvent(EVENTS.DRAG_END, { detail: { originalEvent: event } })
+    );
+    this._resetDraggedElementState();

To verify constants and usages across the repo:


🏁 Script executed:

#!/bin/bash
# Expect to see matching constants and no typos.
rg -n --type=js -C2 'DRAG_END|DROP' src constants.js

Length of output: 859


🏁 Script executed:

#!/bin/bash
# Search for any existing DRAG_END references across the source
rg -n "DRAG_END" -C2 src || true
rg -n "DRAG_END" -C2 . || true

Length of output: 61


Add DRAG_END constant and emit DROP/DRAG_END events

  • In src/constants.js, add:
     export const EVENTS = {
  • DRAG_END: 'allonsh-dragend',
    DRAG_START: 'allonsh-dragstart',
    DROP: 'allonsh-drop',
- In `src/DragManager.js#handleDrop`, after handling a drop or placing the element:
```diff
    if (dropzoneFound) {
      this.dropzoneManager.handleDropzoneDrop(
        this.currentDraggedElement,
        dropzoneFound,
        event
      );
+        this.currentDraggedElement.dispatchEvent(
+          new CustomEvent(EVENTS.DROP, { detail: { target: dropzoneFound, originalEvent: event } })
+        );
    } else if (!this.restrictToDropzones) {
      this._placeInPlayArea(clampedX, clampedY, playAreaRect);
+        this.currentDraggedElement.dispatchEvent(
+          new CustomEvent(EVENTS.DROP, { detail: { target: this.playAreaElement, originalEvent: event } })
+        );
    } else {
      this._returnToOrigin();
    }

-    this._resetDraggedElementState();
+    this.currentDraggedElement.dispatchEvent(
+      new CustomEvent(EVENTS.DRAG_END, { detail: { originalEvent: event } })
+    );
+    this._resetDraggedElementState();
  • (Optional) Emit DRAG_END before the early return in the restrictToDropzones branch to signal cancellation.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
handleDrop(clientX, clientY, event) {
if (!this.currentDraggedElement) return;
this.currentDraggedElement.style.opacity = OPACITY.FULL;
const playAreaRect = this.playAreaElement.getBoundingClientRect();
const clampedX = Math.min(
Math.max(clientX, playAreaRect.left + 1),
playAreaRect.right - 1
);
const clampedY = Math.min(
Math.max(clientY, playAreaRect.top + 1),
playAreaRect.bottom - 1
);
this.currentDraggedElement.style.pointerEvents = POINTER_EVENTS.NONE;
const elementBelow = document.elementFromPoint(clampedX, clampedY);
this.currentDraggedElement.style.pointerEvents = POINTER_EVENTS.AUTO;
if (!elementBelow && this.restrictToDropzones) {
this._returnToOrigin();
this._resetDraggedElementState();
return;
}
const dropzoneFound =
this.dropzoneManager.findClosestDropzone(elementBelow);
if (dropzoneFound) {
this.dropzoneManager.handleDropzoneDrop(
this.currentDraggedElement,
dropzoneFound,
event
);
} else if (!this.restrictToDropzones) {
this._placeInPlayArea(clampedX, clampedY, playAreaRect);
} else {
this._returnToOrigin();
}
this._resetDraggedElementState();
}
handleDrop(clientX, clientY, event) {
if (!this.currentDraggedElement) return;
this.currentDraggedElement.style.opacity = OPACITY.FULL;
const playAreaRect = this.playAreaElement.getBoundingClientRect();
const clampedX = Math.min(
Math.max(clientX, playAreaRect.left + 1),
playAreaRect.right - 1
);
const clampedY = Math.min(
Math.max(clientY, playAreaRect.top + 1),
playAreaRect.bottom - 1
);
this.currentDraggedElement.style.pointerEvents = POINTER_EVENTS.NONE;
const elementBelow = document.elementFromPoint(clampedX, clampedY);
this.currentDraggedElement.style.pointerEvents = POINTER_EVENTS.AUTO;
if (!elementBelow && this.restrictToDropzones) {
this._returnToOrigin();
this._resetDraggedElementState();
return;
}
const dropzoneFound =
this.dropzoneManager.findClosestDropzone(elementBelow);
if (dropzoneFound) {
this.dropzoneManager.handleDropzoneDrop(
this.currentDraggedElement,
dropzoneFound,
event
);
this.currentDraggedElement.dispatchEvent(
new CustomEvent(EVENTS.DROP, {
detail: { target: dropzoneFound, originalEvent: event }
})
);
} else if (!this.restrictToDropzones) {
this._placeInPlayArea(clampedX, clampedY, playAreaRect);
this.currentDraggedElement.dispatchEvent(
new CustomEvent(EVENTS.DROP, {
detail: { target: this.playAreaElement, originalEvent: event }
})
);
} else {
this._returnToOrigin();
}
this.currentDraggedElement.dispatchEvent(
new CustomEvent(EVENTS.DRAG_END, {
detail: { originalEvent: event }
})
);
this._resetDraggedElementState();
}
🤖 Prompt for AI Agents
In src/DragManager.js around lines 90 to 129, import the new DROP and DRAG_END
constants from src/constants.js and update handleDrop to emit a DROP event when
a drop actually happens (pass the dragged element, the dropzone when
dropzoneFound is true, or the final coordinates/playAreaRect when placed in the
play area), and always emit DRAG_END after handling the drop/placement; also
emit DRAG_END before the early return in the restrictToDropzones cancellation
branch so listeners know the drag sequence ended. Ensure the event emit calls
match the existing event bus/api used in this class (e.g. this.eventBus.emit or
this.emit) and include the original DOM event in the payload for context.


_placeInPlayArea(clampedX, clampedY, playAreaRect) {
if (this.currentDraggedElement.parentElement !== this.playAreaElement) {
this.playAreaElement.appendChild(this.currentDraggedElement);
this.styleManager.applyAbsolutePositioning(this.currentDraggedElement);
const offsetX = clampedX - playAreaRect.left - this.dragOffsetX;
const offsetY = clampedY - playAreaRect.top - this.dragOffsetY;
this.currentDraggedElement.style.left = `${offsetX}px`;
this.currentDraggedElement.style.top = `${offsetY}px`;
this.currentDraggedElement.style.zIndex = Z_INDEX.DRAGGING;
}
}

_returnToOrigin() {
if (
this.originalDropzone &&
!this.dropzoneManager.isDropzoneRestricted(this.originalDropzone)
) {
this.dropzoneManager.applyStackingStyles(this.originalDropzone);
this.styleManager.resetPosition(this.currentDraggedElement);
this.originalDropzone.appendChild(this.currentDraggedElement);
} else {
this.styleManager.resetPosition(this.currentDraggedElement);
this.playAreaElement.appendChild(this.currentDraggedElement);
}
}

_removeGhostElement() {
if (this.ghostElement && this.ghostElement.parentElement) {
this.ghostElement.parentElement.removeChild(this.ghostElement);
this.ghostElement = null;
}
}

_resetDraggedElementState() {
if (this.currentDraggedElement) {
this.styleManager.resetDraggableStyles(this.currentDraggedElement);
this.dropzoneManager.toggleHighlight(false);
}
this._removeGhostElement();
this.currentDraggedElement = null;
this.originalParent = null;
this.originalDropzone = null;
}
}
Loading