Skip to content

Commit 242feb7

Browse files
committed
feat: reliable dragging using a cycle marker for all possible drop points
1 parent 210c2e6 commit 242feb7

File tree

1 file changed

+222
-33
lines changed

1 file changed

+222
-33
lines changed

src/LiveDevelopment/BrowserScripts/RemoteFunctions.js

Lines changed: 222 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ function RemoteFunctions(config = {}) {
8080
let _autoScrollTimer = null;
8181
let _isAutoScrolling = false; // to disable highlights when auto scrolling
8282

83+
// these variables are used when cycling between the possible drop locations
84+
let _dragHoverTimer = null;
85+
let _lastDragX = null;
86+
let _lastDragY = null;
87+
let _lastDragTarget = null;
88+
let _parentCycleList = [];
89+
let _currentCycleIndex = 0;
90+
let _currentDropZone = null;
91+
let _currentIndicatorType = null;
92+
8393
// initialized from config, defaults to true if not set
8494
let imageGallerySelected = config.imageGalleryState !== undefined ? config.imageGalleryState : true;
8595

@@ -137,6 +147,8 @@ function RemoteFunctions(config = {}) {
137147
_autoScrollTimer = setInterval(() => {
138148
window.scrollBy(0, scrollDirection);
139149
}, 16); // 16 is ~60fps
150+
} else {
151+
_isAutoScrolling = false;
140152
}
141153
}
142154

@@ -523,6 +535,7 @@ function RemoteFunctions(config = {}) {
523535
element.style.opacity = 1;
524536
}
525537
delete element._originalDragOpacity;
538+
_stopParentCycling();
526539
}
527540

528541
/**
@@ -1235,6 +1248,161 @@ function RemoteFunctions(config = {}) {
12351248
}
12361249
}
12371250

1251+
/**
1252+
* this function is responsible to get all the elements that could be the users possible drop targets
1253+
* @param {DOMElement} target - the ele to start with
1254+
* @returns {Array} - array of all the possible elements (closest -> farthest)
1255+
*/
1256+
function _getAllValidParentCandidates(target) {
1257+
const parents = [];
1258+
let current = target.parentElement;
1259+
1260+
while (current) {
1261+
if (current.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR) && isElementEditable(current)) {
1262+
parents.push(current);
1263+
}
1264+
1265+
// need to stop at body or html
1266+
if (current.tagName.toLowerCase() === 'body' ||
1267+
current.tagName.toLowerCase() === 'html') {
1268+
break;
1269+
}
1270+
1271+
current = current.parentElement;
1272+
}
1273+
1274+
return parents;
1275+
}
1276+
1277+
/**
1278+
* Cycle to the next parent target and update drop markers
1279+
*/
1280+
function _cycleToNextParent() {
1281+
if (_parentCycleList.length <= 1) {
1282+
return;
1283+
}
1284+
1285+
_currentCycleIndex = (_currentCycleIndex + 1) % _parentCycleList.length;
1286+
const nextTarget = _parentCycleList[_currentCycleIndex];
1287+
1288+
_currentIndicatorType = _getIndicatorType(nextTarget);
1289+
1290+
_clearDropMarkers();
1291+
_createDropMarker(nextTarget, _currentDropZone, _currentIndicatorType);
1292+
1293+
_lastDragTarget = nextTarget;
1294+
1295+
_dragHoverTimer = setTimeout(() => {
1296+
_cycleToNextParent();
1297+
}, 1000);
1298+
}
1299+
1300+
/**
1301+
* this function is to check whether an element is a semantic ele or not
1302+
* @param {DOMElement} element - the ele that we need to check
1303+
* @returns {Boolean} - true when its semantic otherwise false
1304+
*/
1305+
function _isSemanticElement(element) {
1306+
const semanticTags = [
1307+
'div', 'section', 'article', 'aside', 'header', 'footer', 'main', 'nav',
1308+
'form', 'fieldset', 'details', 'figure', 'ul', 'ol', 'dl', 'table', 'tbody',
1309+
'thead', 'tfoot', 'tr', 'blockquote', 'pre', 'address'
1310+
];
1311+
return semanticTags.includes(element.tagName.toLowerCase());
1312+
}
1313+
1314+
/**
1315+
* to check whether the 3 edges are aligned or not based on the drop zone
1316+
* for ex: if arrow marker is shown for the top, then top + left + right egde should align
1317+
* for bottom: bottom + left + right
1318+
* this means that there might be a possibility that the child element is blocking access of the parent ele
1319+
* @param {DOMElement} child
1320+
* @param {DOMElement} parent
1321+
* @param {String} dropZone - "before" or "after" (not called for "inside")
1322+
* @returns {Boolean} - true if matches
1323+
*/
1324+
function _hasThreeEdgesAligned(child, parent, dropZone) {
1325+
const tolerance = 2; // 2px tolerance
1326+
const childRect = child.getBoundingClientRect();
1327+
const parentRect = parent.getBoundingClientRect();
1328+
1329+
const topAligned = Math.abs(childRect.top - parentRect.top) <= tolerance;
1330+
const bottomAligned = Math.abs(childRect.bottom - parentRect.bottom) <= tolerance;
1331+
const leftAligned = Math.abs(childRect.left - parentRect.left) <= tolerance;
1332+
const rightAligned = Math.abs(childRect.right - parentRect.right) <= tolerance;
1333+
1334+
// For "before" drops (top), check: top + left + right (bottom doesn't matter)
1335+
// For "after" drops (bottom), check: bottom + left + right (top doesn't matter)
1336+
if (dropZone === "before") {
1337+
return topAligned && leftAligned && rightAligned;
1338+
} else if (dropZone === "after") {
1339+
return bottomAligned && leftAligned && rightAligned;
1340+
}
1341+
1342+
return false;
1343+
}
1344+
1345+
/**
1346+
* start the parent cycling timer, only to go it when parent has a genuine possibility of being the drop target
1347+
* 3 cases we check to say if parent might be a possibility:
1348+
* (same arrow type, correct 3 edges aligned, semantic parent)
1349+
*
1350+
* @param {DOMElement} initialTarget - The element user is hovering over
1351+
*/
1352+
function _startParentCycling(initialTarget) {
1353+
// no cycling when drop zone is inside
1354+
if (_currentDropZone === "inside") {
1355+
return;
1356+
}
1357+
1358+
const initialIndicatorType = _currentIndicatorType;
1359+
const allParents = _getAllValidParentCandidates(initialTarget);
1360+
1361+
// (same arrow type, correct 3 edges aligned, semantic parent)
1362+
// all this cases should match for it to be a valid parent
1363+
const validParents = allParents.filter(parent => {
1364+
if (!_isSemanticElement(parent)) {
1365+
return false;
1366+
}
1367+
1368+
// make sure parent has the same indicator type
1369+
const parentIndicatorType = _getIndicatorType(parent);
1370+
if (parentIndicatorType !== initialIndicatorType) {
1371+
return false;
1372+
}
1373+
1374+
// the correct 3 edges should align
1375+
return _hasThreeEdgesAligned(initialTarget, parent, _currentDropZone);
1376+
});
1377+
1378+
_parentCycleList = [initialTarget, ...validParents];
1379+
if (_parentCycleList.length <= 1) {
1380+
return;
1381+
}
1382+
1383+
_currentCycleIndex = 0;
1384+
_dragHoverTimer = setTimeout(() => {
1385+
_cycleToNextParent();
1386+
}, 1000);
1387+
}
1388+
1389+
/**
1390+
* Stop parent cycling and clean up state
1391+
*/
1392+
function _stopParentCycling() {
1393+
if (_dragHoverTimer) {
1394+
clearTimeout(_dragHoverTimer);
1395+
_dragHoverTimer = null;
1396+
}
1397+
_parentCycleList = [];
1398+
_currentCycleIndex = 0;
1399+
_lastDragX = null;
1400+
_lastDragY = null;
1401+
_lastDragTarget = null;
1402+
_currentDropZone = null;
1403+
_currentIndicatorType = null;
1404+
}
1405+
12381406
/**
12391407
* this function is for finding the best target element on where to drop the dragged element
12401408
* for ex: div > image...here both the div and image are of the exact same size, then when user is dragging some
@@ -1361,27 +1529,41 @@ function RemoteFunctions(config = {}) {
13611529
target = bestParent;
13621530
}
13631531

1364-
// Store original styles before modifying them
1365-
if (target._originalDragBackgroundColor === undefined) {
1366-
target._originalDragBackgroundColor = target.style.backgroundColor;
1367-
}
1368-
if (target._originalDragTransition === undefined) {
1369-
target._originalDragTransition = target.style.transition;
1370-
}
1532+
// check if cursor position changed significantly (>5px threshold to avoid jitter)
1533+
const positionChanged = _lastDragX === null || _lastDragY === null ||
1534+
Math.abs(_lastDragX - event.clientX) > 5 ||
1535+
Math.abs(_lastDragY - event.clientY) > 5;
13711536

1372-
// Add subtle hover effect to target element
1373-
target.style.backgroundColor = "rgba(66, 133, 244, 0.22)";
1374-
target.style.transition = "background-color 0.2s ease";
1537+
// check if we moved to a different target element
1538+
// during cycling, ignore element detection to prevent reset loop
1539+
const elementChanged = (_parentCycleList.length === 0) && (_lastDragTarget !== target);
13751540

1376-
// Determine indicator type and drop zone based on container layout and cursor position
1377-
const indicatorType = _getIndicatorType(target);
1378-
const dropZone = _getDropZone(
1379-
target, event.clientX, event.clientY, indicatorType, window._currentDraggedElement
1380-
);
1541+
if (elementChanged || positionChanged) {
1542+
// reset as user moved to a new element or position changed significantly
1543+
_stopParentCycling();
13811544

1382-
// before creating a drop marker, make sure that we clear all the drop markers
1383-
_clearDropMarkers();
1384-
_createDropMarker(target, dropZone, indicatorType);
1545+
_lastDragX = event.clientX;
1546+
_lastDragY = event.clientY;
1547+
_lastDragTarget = target;
1548+
1549+
const indicatorType = _getIndicatorType(target);
1550+
const dropZone = _getDropZone(target, event.clientX, event.clientY, indicatorType, window._currentDraggedElement);
1551+
1552+
_currentIndicatorType = indicatorType;
1553+
_currentDropZone = dropZone;
1554+
1555+
if (!_isAutoScrolling) {
1556+
_clearDropMarkers();
1557+
_createDropMarker(target, dropZone, indicatorType);
1558+
}
1559+
1560+
_startParentCycling(target);
1561+
} else if (_dragHoverTimer === null && !_isAutoScrolling) {
1562+
// hovering in same spot but timer was cleared, so restart it
1563+
_startParentCycling(target);
1564+
}
1565+
1566+
// Handle auto-scroll
13851567
_handleAutoScroll(event.clientY);
13861568
}
13871569

@@ -1393,6 +1575,7 @@ function RemoteFunctions(config = {}) {
13931575
if (!event.relatedTarget) {
13941576
_clearDropMarkers();
13951577
_stopAutoScroll();
1578+
_stopParentCycling();
13961579
}
13971580
}
13981581

@@ -1409,21 +1592,32 @@ function RemoteFunctions(config = {}) {
14091592
event.preventDefault();
14101593
event.stopPropagation();
14111594

1412-
// get the element under the cursor
1413-
let target = document.elementFromPoint(event.clientX, event.clientY);
1595+
let target = _lastDragTarget;
14141596

1415-
// get the closest editable element
1416-
while (target && !target.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR)) {
1417-
target = target.parentElement;
1418-
}
1597+
if (!target) {
1598+
// get the element under the cursor
1599+
target = document.elementFromPoint(event.clientX, event.clientY);
14191600

1420-
if (!isElementEditable(target) || target === window._currentDraggedElement) {
1421-
// if direct detection fails, we try to find a nearby valid target
1422-
target = _findNearestValidTarget(event.clientX, event.clientY);
1601+
// get the closest editable element
1602+
while (target && !target.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR)) {
1603+
target = target.parentElement;
1604+
}
1605+
1606+
if (!isElementEditable(target) || target === window._currentDraggedElement) {
1607+
// if direct detection fails, we try to find a nearby valid target
1608+
target = _findNearestValidTarget(event.clientX, event.clientY);
1609+
}
1610+
1611+
// Check if we should prefer a parent when all edges are aligned
1612+
const bestParent = _findBestParentTarget(target);
1613+
if (bestParent) {
1614+
target = bestParent;
1615+
}
14231616
}
14241617

14251618
// skip if no valid target found or if it's the dragged element
14261619
if (!isElementEditable(target) || target === window._currentDraggedElement) {
1620+
_stopParentCycling();
14271621
_clearDropMarkers();
14281622
_stopAutoScroll();
14291623
_dragEndChores(window._currentDraggedElement);
@@ -1432,12 +1626,6 @@ function RemoteFunctions(config = {}) {
14321626
return;
14331627
}
14341628

1435-
// Check if we should prefer a parent when all edges are aligned
1436-
const bestParent = _findBestParentTarget(target);
1437-
if (bestParent) {
1438-
target = bestParent;
1439-
}
1440-
14411629
// Determine drop position based on container layout and cursor position
14421630
const indicatorType = _getIndicatorType(target);
14431631
const dropZone = _getDropZone(
@@ -1470,6 +1658,7 @@ function RemoteFunctions(config = {}) {
14701658
// send message to the editor
14711659
window._Brackets_MessageBroker.send(messageData);
14721660

1661+
_stopParentCycling();
14731662
_clearDropMarkers();
14741663
_stopAutoScroll();
14751664
_dragEndChores(window._currentDraggedElement);

0 commit comments

Comments
 (0)