feat(classifier): multi-select source files + drag to fill a contiguous block of rows
A contiguous run of drawings on the left commonly maps to a contiguous set of rows on the right, so make that a single gesture. - Left tree: ctrl/cmd-click toggles a source file into a multi-selection, shift-click ranges from the anchor, ctrl-shift-click adds a range (over the visible file order). Selected files get a highlight and drag together, in top-to-bottom order. A plain click still previews. - By-tracking grid: dropping N dragged files onto a placeholder row fills the N consecutive placeholder rows from there (file[i] → row[i], Excel-style column fill). Drops are now handled at the grid-container level so the dragover shows a live indicator outlining EXACTLY the rows that will be updated (.tg-fill-target). Dropping over empty space / a file row still just adds the files as new rows. Fill walks the rows in DOM (display) order and looks each worklist row up by id from the model, so a re-render between binds can't disturb the loop. Test: a 3-file drop on the first of three placeholders fills m1/m2/m3 in order and consumes all three placeholders. 70 classify green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f82d6919b4
commit
054cf2d79b
4 changed files with 127 additions and 23 deletions
|
|
@ -501,6 +501,10 @@
|
|||
.file-item:hover { background: var(--bg-hover); }
|
||||
.file-item:active { cursor: grabbing; }
|
||||
.file-item.match-highlight { background: var(--primary-light); outline: 1px solid var(--primary); }
|
||||
/* Multi-selected source files (ctrl/shift-click) — these drag together and fill a
|
||||
contiguous block of grid rows. */
|
||||
.file-item.selected { background: var(--primary-light, rgba(37,99,235,0.12)); outline: 1px solid var(--primary); outline-offset: -1px; }
|
||||
.file-item.selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
|
||||
.folder-item[draggable="true"] { cursor: grab; }
|
||||
.file-icon { color: var(--text-muted); }
|
||||
.file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
|
@ -759,6 +763,8 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
|||
.tg-x__btn { opacity: 0.5; }
|
||||
.seltable__row:hover .tg-x__btn { opacity: 1; }
|
||||
.tg-drop-hover { outline: 2px dashed var(--primary); outline-offset: -3px; background: var(--primary-light); }
|
||||
/* The exact placeholder rows a multi-file drop will fill (the drag indicator). */
|
||||
.seltable__row.tg-fill-target { background: var(--primary-light, rgba(37,99,235,0.18)); outline: 2px solid var(--primary); outline-offset: -2px; }
|
||||
|
||||
/* "Columns ▾" chooser menu */
|
||||
.col-chooser {
|
||||
|
|
|
|||
|
|
@ -407,10 +407,8 @@
|
|||
rowId: function (r) { return r.id; },
|
||||
columns: trackingColumns(),
|
||||
persistKey: GRID_PREFS_KEY,
|
||||
onRowDrop: function (id, keys) {
|
||||
if (id.indexOf('p:') === 0) fillFromRow(id, keys); // placeholder → bind, fan out over consecutive placeholders
|
||||
else onGridDrop(keys); // file row → just add the dragged files
|
||||
},
|
||||
// Drops are handled at the container level (setupGridDrop) so a
|
||||
// multi-file drop can fan out over several rows with a live indicator.
|
||||
});
|
||||
trackingGrid.render();
|
||||
return trackingGrid;
|
||||
|
|
@ -434,32 +432,64 @@
|
|||
// (setFilter re-renders the body, so the rows are always fresh on render).
|
||||
trackingGrid.setFilter(rfTerms.join(' '));
|
||||
}
|
||||
// The placeholder rows currently shown in the grid, in DISPLAY (DOM) order —
|
||||
// the same order the fill indicator highlights, so fill and preview agree.
|
||||
function visiblePlaceholderIds() {
|
||||
if (!els.trackingTree) return [];
|
||||
return Array.prototype.filter.call(els.trackingTree.querySelectorAll('.seltable__row'), function (r) {
|
||||
return r.dataset.id && r.dataset.id.indexOf('p:') === 0;
|
||||
}).map(function (r) { return r.dataset.id; });
|
||||
}
|
||||
// Drop N dragged files onto a starting placeholder row → bind file[i] to the
|
||||
// i-th consecutive PLACEHOLDER row from there (Excel-style column fill). A
|
||||
// single file just binds to the one row.
|
||||
// single file just binds to the one row. Worklist rows are looked up by id
|
||||
// (from the model), so a re-render between binds doesn't disturb the loop.
|
||||
function fillFromRow(startId, keys) {
|
||||
var c = C();
|
||||
var rows = trackingGrid ? trackingGrid.getFilteredRows() : gridRows();
|
||||
var placeholders = rows.filter(function (r) { return r.kind === 'placeholder'; });
|
||||
var start = placeholders.map(function (r) { return r.id; }).indexOf(startId);
|
||||
var c = C(), ids = visiblePlaceholderIds(), start = ids.indexOf(startId);
|
||||
if (start < 0) { var row0 = c.getWorklistRow(startId.slice(2)); if (row0) c.assignFromRow(keys, row0); return; }
|
||||
for (var i = 0; i < keys.length && (start + i) < placeholders.length; i++) {
|
||||
c.assignFromRow([keys[i]], placeholders[start + i].wl);
|
||||
for (var i = 0; i < keys.length && (start + i) < ids.length; i++) {
|
||||
var wl = c.getWorklistRow(ids[start + i].slice(2));
|
||||
if (wl) c.assignFromRow([keys[i]], wl);
|
||||
}
|
||||
}
|
||||
|
||||
// Drag files onto the grid → add as rows; auto-fill any already ZDDC-named.
|
||||
// Drag files onto the grid. Over a PLACEHOLDER row, N dragged files preview +
|
||||
// fill N consecutive placeholder rows (the fill indicator highlights exactly
|
||||
// those rows). Over empty space / a file row, they're added as new grid rows.
|
||||
function setupGridDrop(container) {
|
||||
function placeholderUnder(e) {
|
||||
var tr = e.target.closest && e.target.closest('.seltable__row');
|
||||
return (tr && tr.dataset.id && tr.dataset.id.indexOf('p:') === 0) ? tr : null;
|
||||
}
|
||||
function clearFill() {
|
||||
Array.prototype.forEach.call(container.querySelectorAll('.tg-fill-target'), function (el) { el.classList.remove('tg-fill-target'); });
|
||||
container.classList.remove('tg-drop-hover');
|
||||
}
|
||||
container.addEventListener('dragover', function (e) {
|
||||
if (!window.app.modules.dnd.active()) return;
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; container.classList.add('tg-drop-hover');
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'copy';
|
||||
clearFill();
|
||||
var tr = placeholderUnder(e);
|
||||
if (tr) {
|
||||
var n = (window.app.modules.dnd.getDrag() || []).length || 1;
|
||||
var rows = Array.prototype.filter.call(container.querySelectorAll('.seltable__row'), function (r) {
|
||||
return r.dataset.id && r.dataset.id.indexOf('p:') === 0;
|
||||
});
|
||||
var start = rows.indexOf(tr);
|
||||
for (var i = 0; i < n && (start + i) < rows.length; i++) rows[start + i].classList.add('tg-fill-target');
|
||||
} else {
|
||||
container.classList.add('tg-drop-hover');
|
||||
}
|
||||
});
|
||||
container.addEventListener('dragleave', function (e) { if (e.target === container) container.classList.remove('tg-drop-hover'); });
|
||||
container.addEventListener('dragleave', function (e) { if (e.target === container) clearFill(); });
|
||||
container.addEventListener('drop', function (e) {
|
||||
container.classList.remove('tg-drop-hover');
|
||||
e.preventDefault();
|
||||
var tr = placeholderUnder(e);
|
||||
clearFill();
|
||||
var keys = window.app.modules.dnd.getDrag(); window.app.modules.dnd.clearDrag();
|
||||
if (keys.length) onGridDrop(keys);
|
||||
if (!keys.length) return;
|
||||
if (tr) fillFromRow(tr.dataset.id, keys);
|
||||
else onGridDrop(keys);
|
||||
});
|
||||
}
|
||||
function onGridDrop(keys) {
|
||||
|
|
|
|||
|
|
@ -477,11 +477,12 @@
|
|||
item.className = 'file-item';
|
||||
item.style.paddingLeft = `${level * 1.5}rem`;
|
||||
item.draggable = true;
|
||||
item.title = 'Click to preview · drag onto a tracking folder or transmittal to assign';
|
||||
item.title = 'Click to preview · ctrl/shift-click to multi-select · drag onto the grid (a block of rows) or a transmittal';
|
||||
const key = c.srcKeyForFile(file);
|
||||
item.dataset.key = key;
|
||||
const st = c.fileState(file);
|
||||
if (st === 'excluded') item.classList.add('excluded');
|
||||
if (selectedFileKeys[key]) item.classList.add('selected');
|
||||
|
||||
item.appendChild(stateDot(st));
|
||||
|
||||
|
|
@ -497,11 +498,43 @@
|
|||
|
||||
item.addEventListener('dragstart', function (e) {
|
||||
e.stopPropagation();
|
||||
window.app.modules.dnd.setDrag([key], e);
|
||||
// Drag the whole multi-selection (in visible top-to-bottom order) when
|
||||
// this file is part of it; otherwise just this one.
|
||||
var keys = (selectedFileKeys[key] && fileSelectionCount() > 1)
|
||||
? visibleFileKeys().filter(function (k) { return selectedFileKeys[k]; })
|
||||
: [key];
|
||||
window.app.modules.dnd.setDrag(keys, e);
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
// ── source-file multi-selection (drives a multi-row drag onto the grid) ──
|
||||
var selectedFileKeys = Object.create(null), fileAnchorKey = null;
|
||||
function fileSelectionCount() { return Object.keys(selectedFileKeys).length; }
|
||||
function visibleFileKeys() {
|
||||
return Array.prototype.map.call(window.app.dom.folderTree.querySelectorAll('.file-item'), function (el) { return el.dataset.key; });
|
||||
}
|
||||
function applyFileSelectionClasses() {
|
||||
Array.prototype.forEach.call(window.app.dom.folderTree.querySelectorAll('.file-item'), function (el) {
|
||||
el.classList.toggle('selected', !!selectedFileKeys[el.dataset.key]);
|
||||
});
|
||||
}
|
||||
// click = replace + anchor; ctrl/cmd = toggle; shift = range from anchor;
|
||||
// ctrl-shift = add range. Ranges run over the visible (DOM) file order.
|
||||
function selectFileClick(key, e) {
|
||||
var keys = visibleFileKeys(), a, b;
|
||||
if (e.shiftKey && fileAnchorKey != null && (a = keys.indexOf(fileAnchorKey)) >= 0 && (b = keys.indexOf(key)) >= 0) {
|
||||
if (!(e.ctrlKey || e.metaKey)) selectedFileKeys = Object.create(null);
|
||||
for (var i = Math.min(a, b); i <= Math.max(a, b); i++) selectedFileKeys[keys[i]] = true;
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
if (selectedFileKeys[key]) delete selectedFileKeys[key]; else selectedFileKeys[key] = true;
|
||||
fileAnchorKey = key;
|
||||
} else {
|
||||
selectedFileKeys = Object.create(null); selectedFileKeys[key] = true; fileAnchorKey = key;
|
||||
}
|
||||
applyFileSelectionClasses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle folder click with multi-select support
|
||||
*/
|
||||
|
|
@ -826,15 +859,19 @@
|
|||
var ft = window.app.dom.folderTree;
|
||||
if (!ft) { classifyWired = false; return; }
|
||||
ft.addEventListener('contextmenu', onContextMenu);
|
||||
// Single-click a source file → preview it (the "look at it, then assign"
|
||||
// half of the workflow). Drag still assigns; right-click excludes.
|
||||
// Click a source file → update the multi-selection (ctrl/shift) AND, on a
|
||||
// plain click, preview it (the "look at it, then assign" half). Drag of a
|
||||
// selected file drags the whole selection; right-click excludes.
|
||||
ft.addEventListener('click', function (e) {
|
||||
if (!classifyOn()) return;
|
||||
var fe = e.target.closest('.file-item');
|
||||
if (!fe || !fe.dataset.key) return;
|
||||
var file = findFileByKey(fe.dataset.key);
|
||||
if (file && window.app.modules.preview && window.app.modules.preview.previewFile) {
|
||||
window.app.modules.preview.previewFile(file);
|
||||
selectFileClick(fe.dataset.key, e);
|
||||
if (!e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
var file = findFileByKey(fe.dataset.key);
|
||||
if (file && window.app.modules.preview && window.app.modules.preview.previewFile) {
|
||||
window.app.modules.preview.previewFile(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1733,3 +1733,34 @@ test('paste rows: a full path with no tracking yet is claimed, then placed when
|
|||
expect(r.tracking).toBe('ACME-MECH-0007'); // placed once the tracking number is filled in
|
||||
expect(r.rev).toBe('B');
|
||||
});
|
||||
|
||||
test('grid: dropping a multi-file selection fills consecutive placeholder rows', async ({ page }) => {
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||
c.reset();
|
||||
const fs = ['a', 'b', 'c'].map((n) => ({ originalFilename: 'scan ' + n, extension: 'pdf', folderPath: 'R' }));
|
||||
window.app.folderTree = [{ name: 'R', path: 'R', files: fs, children: [] }];
|
||||
const keys = fs.map((f) => c.srcKeyForFile(f));
|
||||
c.setWorklist([
|
||||
{ id: 'm1', trackingNumber: 'ACME-MECH-0001', revisionCell: 'A (IFR)', title: 'One' },
|
||||
{ id: 'm2', trackingNumber: 'ACME-MECH-0002', revisionCell: 'A (IFR)', title: 'Two' },
|
||||
{ id: 'm3', trackingNumber: 'ACME-MECH-0003', revisionCell: 'A (IFR)', title: 'Three' },
|
||||
]);
|
||||
tt.render();
|
||||
// Drop the 3 files onto the FIRST placeholder → fills m1, m2, m3 in order.
|
||||
const startRow = document.querySelector('#trackingTree .seltable__row[data-id="p:m1"]');
|
||||
window.app.modules.dnd.setDrag(keys);
|
||||
startRow.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
||||
return {
|
||||
t0: c.deriveTarget(fs[0]).tracking,
|
||||
t1: c.deriveTarget(fs[1]).tracking,
|
||||
t2: c.deriveTarget(fs[2]).tracking,
|
||||
placeholdersLeft: c.getWorklist().filter((w) => !Object.keys(w.placed || {}).length).length,
|
||||
};
|
||||
});
|
||||
expect(r.t0).toBe('ACME-MECH-0001'); // file a → row m1 (the drop target)
|
||||
expect(r.t1).toBe('ACME-MECH-0002'); // file b → row m2 (the next row down)
|
||||
expect(r.t2).toBe('ACME-MECH-0003'); // file c → row m3
|
||||
expect(r.placeholdersLeft).toBe(0); // all three placeholders consumed
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue