Fix multi-list swimlane drops and show named drag previews

Recognize empty swimlane bodies as mixed-selection drop destinations while keeping swimlane drag starts on headers. Replace the count-only indicator with a tilted stack of selected object names and icons rendered as text. Verify two-list moves, card parents, ordering, collapsed containers, both handle modes and persistence after reload.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-09-26 00:27:11 +03:00
1 parent fc434e47d3
commit f772663e5b
4 files changed
+136 -4

No files matched your search

@@ -20,7 +20,17 @@ button.js-structural-selection:focus-visible { outline: 2px solid currentColor;
color: #333;
border: 2px solid #216694;
pointer-events: none;
min-width: 160px;
max-width: 280px;
border-radius: 5px;
transform: rotate(-3deg);
transform-origin: top left;
box-shadow: 3px 4px 0 #ddd, 6px 8px 0 #bbb, 0 8px 18px #0004;
}
.structural-drag-count { font-weight: bold; margin-bottom: 4px; }
.structural-drag-name { display: flex; gap: 8px; padding: 4px; }
.structural-drag-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.structural-drag-name .fa { flex: 0 0 16px; }
.structural-drop-target { outline: 2px dashed #216694; }
/* Header checkboxes occupy the left margin, without adding a header row. */
.js-list-header > button.js-structural-selection {
+45 -4
View File
@@ -68,7 +68,7 @@ const entitySelectors = [
['item', '.js-checklist-item'], ['checklist', '.minicard-checklist, .js-checklist'],
['card', '.js-minicard'], ['list', '.js-list'], ['swimlane', '.js-swimlane-header'],
];
function entityAt(element) {
function entityAt(element, dropTarget = false) {
for (const [kind, selector] of entitySelectors) {
const node = element?.closest(selector);
if (!node) continue;
@@ -76,6 +76,13 @@ function entityAt(element) {
const doc = kind === 'item' ? data?.item : kind === 'checklist' ? data?.checklist : data;
if (doc?._id) return { kind, doc, node };
}
// Empty lane space has no list/card ancestor. Accept it on drop, while
// retaining the header-only start target for dragging a selected swimlane.
if (dropTarget) {
const node = element?.closest('.js-swimlane');
const doc = node && Blaze.getData(node);
if (doc?._id) return { kind: 'swimlane', doc, node };
}
return null;
}
function targetFor(entity) {
@@ -88,6 +95,40 @@ function targetFor(entity) {
if (kind === 'swimlane') target.swimlaneId = doc._id;
return target;
}
// Render names as text, never clone interactive controls or insert title HTML.
function createDragPreview(selection) {
const preview = document.createElement('div');
preview.className = 'structural-drag-preview';
const count = document.createElement('div');
count.className = 'structural-drag-count';
count.textContent = String(selection.length);
preview.append(count);
const types = {
swimlane: ['getSwimlane', 'fa-bars'], list: ['getList', 'fa-list'],
card: ['getCard', 'fa-sticky-note-o'], checklist: ['getChecklist', 'fa-check-square-o'],
item: ['getChecklistItem', 'fa-check'],
};
for (const entry of selection.slice(0, 8)) {
const type = types[entry.kind];
if (!type) continue;
const doc = ReactiveCache[type[0]](entry.id);
const row = document.createElement('div');
row.className = 'structural-drag-name';
const icon = document.createElement('i');
icon.className = `fa ${type[1]}`;
icon.setAttribute('aria-hidden', 'true');
const name = document.createElement('span');
name.textContent = doc?.title || entry.id;
row.append(icon, name);
preview.append(row);
}
if (selection.length > 8) {
const more = document.createElement('div');
more.textContent = `+${selection.length - 8}`;
preview.append(more);
}
return preview;
}
Meteor.startup(() => {
let drag;
document.addEventListener('mousedown', event => {
@@ -112,13 +153,13 @@ Meteor.startup(() => {
if (!drag) return;
if (!drag.preview && Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 7) return;
if (!drag.preview) {
drag.preview = document.createElement('div'); drag.preview.className = 'structural-drag-preview';
drag.preview.textContent = `${drag.selection.length}`; document.body.append(drag.preview);
drag.preview = createDragPreview(drag.selection);
document.body.append(drag.preview);
}
event.preventDefault();
drag.preview.style.left = `${event.clientX + 12}px`; drag.preview.style.top = `${event.clientY + 12}px`;
drag.target?.node.classList.remove('structural-drop-target');
drag.target = entityAt(document.elementFromPoint(event.clientX, event.clientY));
drag.target = entityAt(document.elementFromPoint(event.clientX, event.clientY), true);
drag.target?.node.classList.add('structural-drop-target');
}, true);
document.addEventListener('mouseup', async event => {
@@ -0,0 +1,45 @@
'use strict';
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
for (const handles of [false,true]) for (const target of ['body','header','collapsed-header']) {
test(`two selected lists move to another swimlane ${target} (handles ${handles})`, async ({boardPage:page,board,user}) => {
await page.setViewportSize({width:1500,height:1500});
db.updateOne('users',{_id:user.id},{$set:{'profile.showDesktopDragHandles':handles}});
for (const id of board.listIds) db.updateOne('lists',{_id:id},{$set:{swimlaneId:board.swimlaneId}});
const laneId=db.uid('lane');
db.insertOne('swimlanes',{...db.findOne('swimlanes',{_id:board.swimlaneId}),_id:laneId,title:'Destination lane',sort:1,height:300});
const cards=db.find('cards',{boardId:board.boardId,listId:{$in:board.listIds.slice(0,2)}});
await page.reload();
if (target === 'collapsed-header') {
await page.locator('.js-swimlane-header').filter({hasText:'Destination lane'}).locator('.js-collapse-swimlane').click();
for (const id of board.listIds.slice(0,2)) await page.locator(`#js-list-${id} .js-collapse`).click();
}
await page.locator('.js-multiselection-activate').click();
for(const id of board.listIds.slice(0,2)) await page.locator(`.js-structural-selection[data-kind="list"][data-id="${id}"]`).click();
const fromNode=page.locator(`#js-list-${board.listIds[0]}`).locator(handles?'.js-list-handle:visible':'.list-header-name').first();
const toNode=target==='body'?page.locator(`#swimlane-${laneId}`):page.locator('.js-swimlane-header').filter({hasText:'Destination lane'});
const from=await fromNode.boundingBox(),to=await toNode.boundingBox();
await page.mouse.move(from.x+from.width/2,from.y+from.height/2);
await page.mouse.down();
await page.mouse.move(to.x+to.width/2,to.y+to.height/2,{steps:30});
const preview = page.locator('.structural-drag-preview');
await expect(preview).toBeVisible();
await expect(preview.locator('.structural-drag-name span')).toHaveText(['List A','List B']);
expect(await preview.evaluate(node=>getComputedStyle(node).transform)).not.toBe('none');
if (!handles && target === 'body') await page.screenshot({path:test.info().outputPath('named-preview.png')});
await page.mouse.up();
await expect.poll(()=>db.find('lists',{_id:{$in:board.listIds.slice(0,2)},swimlaneId:laneId}).length).toBe(2);
expect(db.findOne('lists',{_id:board.listIds[2]}).swimlaneId).toBe(board.swimlaneId);
for(const card of cards) {
const moved=db.findOne('cards',{_id:card._id});
expect(moved.swimlaneId).toBe(laneId);
expect(moved.listId).toBe(card.listId);
}
const movedLists = db.find('lists',{_id:{$in:board.listIds.slice(0,2)}}).sort((a,b)=>a.sort-b.sort);
expect(movedLists.map(list=>list._id)).toEqual(board.listIds.slice(0,2));
if (target === 'collapsed-header') await page.locator('.js-swimlane-header').filter({hasText:'Destination lane'}).locator('.js-collapse-swimlane').click();
for(const id of board.listIds.slice(0,2)) await expect(page.locator(`#swimlane-${laneId} #js-list-${id}`)).toBeVisible();
await page.reload();
for(const id of board.listIds.slice(0,2)) await expect(page.locator(`#swimlane-${laneId} #js-list-${id}`)).toBeVisible();
});
}
+36
View File
@@ -46,3 +46,39 @@ assert.equal(state.hasObjects(), false);
state.reset();
assert.equal(state.isSelected('card'), false);
console.log('structuralSelection: board scoping, mixed Shift selection, toggle and reset passed');
// A lane's empty body is a drop target, but not a new swimlane drag handle.
const dragSource = fs.readFileSync('client/components/main/structuralSelection.js', 'utf8');
const entityCode = dragSource.slice(dragSource.indexOf('const entitySelectors'), dragSource.indexOf('Meteor.startup'));
const laneNode = { doc: { _id: 'destination-lane' } };
const listNode = { doc: { _id: 'target-list', swimlaneId: 'destination-lane' } };
const bodyElement = { closest: selector => selector === '.js-swimlane' ? laneNode : null };
const listElement = { closest: selector => selector === '.js-list' ? listNode : selector === '.js-swimlane' ? laneNode : null };
const resolver = vm.runInNewContext(`${entityCode}\n({entityAt,targetFor})`, {
Blaze: { getData: node => node.doc }, Session: { get: () => 'board' },
});
assert.equal(resolver.entityAt(bodyElement), null);
assert.equal(resolver.entityAt(bodyElement, true).doc._id, 'destination-lane');
assert.equal(resolver.targetFor(resolver.entityAt(bodyElement,true)).swimlaneId, 'destination-lane');
assert.equal(resolver.entityAt(listElement,true).kind, 'list');
assert.equal(resolver.entityAt({closest:()=>null},true), null);
console.log('structuralSelection: empty lane drop, header-only drag start and nested-target precedence passed');
const previewDocument = { createElement(tag) {
return { tag, children: [], append(...nodes) { this.children.push(...nodes); }, setAttribute() {} };
} };
const buildPreview = vm.runInNewContext(`${entityCode}\ncreateDragPreview;`, {
document: previewDocument,
ReactiveCache: {
getList: id => ({title:id === 'a' ? '<img src=x onerror=alert(1)>' : 'Second list'}),
getCard: () => ({title:'A selected card'}),
},
});
const preview = buildPreview([{kind:'list',id:'a'},{kind:'list',id:'b'},{kind:'card',id:'c'}]);
assert.equal(preview.children[0].textContent, '3');
assert.equal(preview.children[1].children[1].textContent, '<img src=x onerror=alert(1)>');
assert.equal(preview.children[1].children[1].tag, 'span');
assert.equal(preview.children[2].children[1].textContent, 'Second list');
assert.equal(preview.children[3].children[1].textContent, 'A selected card');
assert.equal(buildPreview(Array.from({length:10},()=>({kind:'list',id:'b'}))).children.at(-1).textContent, '+2');
console.log('structuralSelection: named mixed preview, literal title text and bounded large selections passed');