From 054cf2d79b45347028016fb15b9fd46e783b0349 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 16 Jun 2026 09:57:57 -0500 Subject: [PATCH] feat(classifier): multi-select source files + drag to fill a contiguous block of rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- classifier/css/layout.css | 6 ++++ classifier/js/target-tree.js | 62 ++++++++++++++++++++++++++---------- classifier/js/tree.js | 51 +++++++++++++++++++++++++---- tests/classify.spec.js | 31 ++++++++++++++++++ 4 files changed, 127 insertions(+), 23 deletions(-) diff --git a/classifier/css/layout.css b/classifier/css/layout.css index e3a39ea..d9edeb0 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -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 { diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index e3c657a..8d8e732 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -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) { diff --git a/classifier/js/tree.js b/classifier/js/tree.js index 62fb6db..bb0e231 100644 --- a/classifier/js/tree.js +++ b/classifier/js/tree.js @@ -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); + } } }); } diff --git a/tests/classify.spec.js b/tests/classify.spec.js index 28f409c..c4ae3ad 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -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 +});