Add drag-and-drop for modifiers at all levels

- Modifiers can be dragged and dropped to reorder within same parent
- Modifiers can be dropped on other modifiers (same or different parent)
- Modifiers can be dropped on items to add as top-level modifier
- Default action is COPY (drag creates a copy)
- Hold Ctrl+Alt while dragging to MOVE instead of copy
- Deep cloning preserves nested options with new IDs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-14 12:15:44 -08:00
parent 12b47c3e41
commit fb329727d2

View file

@ -2122,6 +2122,209 @@
} }
}, },
// Find modifier/option and its parent array
findModifierWithParent(modifierId) {
for (const cat of this.menu.categories) {
for (const item of cat.items) {
// Check if it's a top-level modifier
const idx = item.modifiers.findIndex(m => m.id === modifierId);
if (idx !== -1) {
return { array: item.modifiers, index: idx, parentId: item.id, isItem: true };
}
// Check nested
const nested = this.findNestedModifierWithParent(item.modifiers, modifierId);
if (nested) return nested;
}
}
return null;
},
findNestedModifierWithParent(modifiers, modifierId) {
for (const mod of modifiers) {
if (mod.options && mod.options.length > 0) {
const idx = mod.options.findIndex(o => o.id === modifierId);
if (idx !== -1) {
return { array: mod.options, index: idx, parentId: mod.id, isItem: false };
}
const nested = this.findNestedModifierWithParent(mod.options, modifierId);
if (nested) return nested;
}
}
return null;
},
// Get the parent array for a parentId (item or modifier)
getParentModifiersArray(parentId) {
for (const cat of this.menu.categories) {
for (const item of cat.items) {
if (item.id === parentId) {
return item.modifiers;
}
const found = this.findOptionRecursive(item.modifiers, parentId);
if (found) {
if (!found.options) found.options = [];
return found.options;
}
}
}
return null;
},
// Deep clone a modifier with new IDs
cloneModifierDeep(mod) {
const clone = JSON.parse(JSON.stringify(mod));
clone.id = this.generateId();
clone.dbId = null; // Will get new dbId on save
if (clone.options && clone.options.length > 0) {
clone.options = clone.options.map(opt => this.cloneModifierDeep(opt));
}
return clone;
},
// Reorder modifier within same parent
reorderModifier(draggedId, targetId, parentId, position) {
this.saveState();
const parentArray = this.getParentModifiersArray(parentId);
if (!parentArray) return;
const draggedIdx = parentArray.findIndex(m => m.id === draggedId);
const targetIdx = parentArray.findIndex(m => m.id === targetId);
if (draggedIdx === -1 || targetIdx === -1) return;
// Remove dragged
const [dragged] = parentArray.splice(draggedIdx, 1);
// Find new target index (may have shifted)
let newTargetIdx = parentArray.findIndex(m => m.id === targetId);
if (position === 'after') newTargetIdx++;
// Insert at new position
parentArray.splice(newTargetIdx, 0, dragged);
// Update sort orders
parentArray.forEach((m, i) => m.sortOrder = i);
this.render();
this.toast('Modifier reordered', 'success');
},
// Move modifier from one parent to another
moveModifier(draggedId, fromParentId, targetModId, toParentId, position) {
this.saveState();
const fromArray = this.getParentModifiersArray(fromParentId);
const toArray = this.getParentModifiersArray(toParentId);
if (!fromArray || !toArray) return;
const draggedIdx = fromArray.findIndex(m => m.id === draggedId);
if (draggedIdx === -1) return;
// Remove from source
const [dragged] = fromArray.splice(draggedIdx, 1);
// Find target position in destination
const targetIdx = toArray.findIndex(m => m.id === targetModId);
let insertIdx = targetIdx === -1 ? toArray.length : targetIdx;
if (position === 'after') insertIdx++;
// Insert at new position
toArray.splice(insertIdx, 0, dragged);
// Update sort orders in both arrays
fromArray.forEach((m, i) => m.sortOrder = i);
toArray.forEach((m, i) => m.sortOrder = i);
this.render();
this.toast('Modifier moved', 'success');
},
// Copy modifier to position relative to another modifier
copyModifier(draggedId, fromParentId, targetModId, toParentId, position) {
this.saveState();
const fromArray = this.getParentModifiersArray(fromParentId);
const toArray = this.getParentModifiersArray(toParentId);
if (!fromArray || !toArray) return;
const dragged = fromArray.find(m => m.id === draggedId);
if (!dragged) return;
// Clone with new IDs
const clone = this.cloneModifierDeep(dragged);
// Find target position
const targetIdx = toArray.findIndex(m => m.id === targetModId);
let insertIdx = targetIdx === -1 ? toArray.length : targetIdx;
if (position === 'after') insertIdx++;
// Insert clone
toArray.splice(insertIdx, 0, clone);
// Update sort orders
toArray.forEach((m, i) => m.sortOrder = i);
this.render();
this.toast('Modifier copied', 'success');
},
// Move modifier to an item (as top-level modifier)
moveModifierToItem(draggedId, fromParentId, toItemId) {
this.saveState();
const fromArray = this.getParentModifiersArray(fromParentId);
if (!fromArray) return;
// Find target item
let targetItem = null;
for (const cat of this.menu.categories) {
targetItem = cat.items.find(i => i.id === toItemId);
if (targetItem) break;
}
if (!targetItem) return;
const draggedIdx = fromArray.findIndex(m => m.id === draggedId);
if (draggedIdx === -1) return;
// Remove from source
const [dragged] = fromArray.splice(draggedIdx, 1);
// Add to target item's modifiers
dragged.sortOrder = targetItem.modifiers.length;
targetItem.modifiers.push(dragged);
// Update sort orders
fromArray.forEach((m, i) => m.sortOrder = i);
this.render();
this.toast('Modifier moved to item', 'success');
},
// Copy modifier to an item (as top-level modifier)
copyModifierToItem(draggedId, fromParentId, toItemId) {
this.saveState();
const fromArray = this.getParentModifiersArray(fromParentId);
if (!fromArray) return;
// Find target item
let targetItem = null;
for (const cat of this.menu.categories) {
targetItem = cat.items.find(i => i.id === toItemId);
if (targetItem) break;
}
if (!targetItem) return;
const dragged = fromArray.find(m => m.id === draggedId);
if (!dragged) return;
// Clone with new IDs
const clone = this.cloneModifierDeep(dragged);
clone.sortOrder = targetItem.modifiers.length;
// Add to target item
targetItem.modifiers.push(clone);
this.render();
this.toast('Modifier copied to item', 'success');
},
// Update category
// Update category // Update category
updateCategory(categoryId, field, value) { updateCategory(categoryId, field, value) {
this.saveState(); this.saveState();
@ -2756,7 +2959,9 @@
const hasOptions = mod.options && mod.options.length > 0; const hasOptions = mod.options && mod.options.length > 0;
const modExpanded = this.expandedModifierIds.has(mod.id); const modExpanded = this.expandedModifierIds.has(mod.id);
return ` return `
<div class="item-card modifier depth-${depth} ${modExpanded ? 'expanded' : ''}" data-modifier-id="${mod.id}" data-parent-item-id="${parentItemId}" data-depth="${depth}" <div class="item-card modifier depth-${depth} ${modExpanded ? 'expanded' : ''}"
data-modifier-id="${mod.id}" data-parent-id="${parentItemId}" data-depth="${depth}"
draggable="true"
style="margin-left: ${indent}px;" style="margin-left: ${indent}px;"
onclick="event.stopPropagation(); MenuBuilder.selectOption('${parentItemId}', '${mod.id}', ${depth})"> onclick="event.stopPropagation(); MenuBuilder.selectOption('${parentItemId}', '${mod.id}', ${depth})">
${hasOptions ? ` ${hasOptions ? `
@ -2765,11 +2970,14 @@
<path d="M9 18l6-6-6-6"/> <path d="M9 18l6-6-6-6"/>
</svg> </svg>
</div> </div>
` : ` ` : ''}
<div class="drag-handle" style="visibility: hidden;"> <div class="drag-handle">
<svg width="12" height="12"></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
</svg>
</div> </div>
`}
<div class="item-image" style="width: ${iconSize}px; height: ${iconSize}px; font-size: ${iconSize/2}px;">${icon}</div> <div class="item-image" style="width: ${iconSize}px; height: ${iconSize}px; font-size: ${iconSize/2}px;">${icon}</div>
<div class="item-info" onclick="event.stopPropagation(); ${hasOptions ? `MenuBuilder.toggleModifier('${mod.id}')` : `MenuBuilder.selectOption('${parentItemId}', '${mod.id}', ${depth})`}" style="cursor: pointer;"> <div class="item-info" onclick="event.stopPropagation(); ${hasOptions ? `MenuBuilder.toggleModifier('${mod.id}')` : `MenuBuilder.selectOption('${parentItemId}', '${mod.id}', ${depth})`}" style="cursor: pointer;">
<div class="item-name">${this.escapeHtml(mod.name)}</div> <div class="item-name">${this.escapeHtml(mod.name)}</div>
@ -2794,6 +3002,7 @@
}).join(''); }).join('');
}, },
// Render menu structure
// Render menu structure // Render menu structure
render() { render() {
const container = document.getElementById('menuStructure'); const container = document.getElementById('menuStructure');
@ -3034,6 +3243,111 @@
this.reorderItemInCategory(draggedItemId, targetItemId, position); this.reorderItemInCategory(draggedItemId, targetItemId, position);
} }
card.classList.remove('drop-before', 'drop-after'); card.classList.remove('drop-before', 'drop-after');
});
// Modifier drag handlers
document.querySelectorAll('.item-card.modifier').forEach(card => {
card.addEventListener('dragstart', (e) => {
e.stopPropagation();
e.dataTransfer.setData('modifierId', card.dataset.modifierId);
e.dataTransfer.setData('parentId', card.dataset.parentId);
e.dataTransfer.setData('depth', card.dataset.depth);
e.dataTransfer.setData('source', 'modifier');
// Default is copy, hold Ctrl+Alt for move
e.dataTransfer.effectAllowed = (e.ctrlKey && e.altKey) ? 'move' : 'copy';
card.classList.add('dragging');
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
document.querySelectorAll('.drop-before, .drop-after, .drag-over').forEach(el => {
el.classList.remove('drop-before', 'drop-after', 'drag-over');
});
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
const isDraggingModifier = e.dataTransfer.types.includes('modifierid');
if (isDraggingModifier && !card.classList.contains('dragging')) {
const rect = card.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
card.classList.remove('drop-before', 'drop-after');
if (e.clientY < midpoint) {
card.classList.add('drop-before');
} else {
card.classList.add('drop-after');
}
}
});
card.addEventListener('dragleave', (e) => {
card.classList.remove('drop-before', 'drop-after');
});
card.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
const draggedModId = e.dataTransfer.getData('modifierId');
const draggedParentId = e.dataTransfer.getData('parentId');
const targetModId = card.dataset.modifierId;
const targetParentId = card.dataset.parentId;
const isMove = e.dataTransfer.effectAllowed === 'move';
if (draggedModId && targetModId && draggedModId !== targetModId) {
const position = card.classList.contains('drop-before') ? 'before' : 'after';
if (isMove && draggedParentId === targetParentId) {
// Same parent + move = reorder
this.reorderModifier(draggedModId, targetModId, targetParentId, position);
} else if (isMove) {
// Different parent + move = move modifier
this.moveModifier(draggedModId, draggedParentId, targetModId, targetParentId, position);
} else {
// Copy (default)
this.copyModifier(draggedModId, draggedParentId, targetModId, targetParentId, position);
}
}
card.classList.remove('drop-before', 'drop-after');
});
});
// Allow dropping modifiers onto items (to add as top-level modifier)
document.querySelectorAll('.item-card:not(.modifier)').forEach(card => {
card.addEventListener('dragover', (e) => {
const isDraggingModifier = e.dataTransfer.types.includes('modifierid');
if (isDraggingModifier) {
e.preventDefault();
e.stopPropagation();
card.classList.add('drag-over');
}
});
card.addEventListener('dragleave', (e) => {
if (e.dataTransfer.types.includes('modifierid')) {
card.classList.remove('drag-over');
}
});
card.addEventListener('drop', (e) => {
const isDraggingModifier = e.dataTransfer.types.includes('modifierid');
if (isDraggingModifier) {
e.preventDefault();
e.stopPropagation();
const draggedModId = e.dataTransfer.getData('modifierId');
const draggedParentId = e.dataTransfer.getData('parentId');
const targetItemId = card.dataset.itemId;
const isMove = e.dataTransfer.effectAllowed === 'move';
if (draggedModId && targetItemId) {
if (isMove) {
this.moveModifierToItem(draggedModId, draggedParentId, targetItemId);
} else {
this.copyModifierToItem(draggedModId, draggedParentId, targetItemId);
}
}
card.classList.remove('drag-over');
}
}); });
}); });
}, },