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:
ZDDC 2026-06-16 09:57:57 -05:00
parent f82d6919b4
commit 054cf2d79b
4 changed files with 127 additions and 23 deletions

View file

@ -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 {

View file

@ -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) {

View file

@ -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);
}
}
});
}

View 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
});