diff --git a/project_docs/UI_ADVANCED_SELECTION_V1.md b/project_docs/UI_ADVANCED_SELECTION_V1.md new file mode 100644 index 0000000..65ca464 --- /dev/null +++ b/project_docs/UI_ADVANCED_SELECTION_V1.md @@ -0,0 +1,202 @@ +# UI Advanced Selection v1 + +## 1. Doel +Advanced Selection v1 maakt selectiegedrag in de dual-pane UI natuurlijker voor dagelijks file-managergebruik, zonder de bestaande interacties te breken. De uitbreiding richt zich op twee bekende patronen: + +- range-selectie via `Shift + ArrowUp/ArrowDown` +- niet-aangrenzende selectie via `Cmd + klik` op macOS en `Ctrl + klik` op niet-Mac + +Het doel is niet om een volledige desktop file manager exact te emuleren, maar om de meest bruikbare selectiepatronen toe te voegen met laag regressierisico. + +## 2. Nieuwe Interacties In Scope +In scope voor v1: + +- `Shift + ArrowDown` +- `Shift + ArrowUp` +- `Cmd + klik` op Mac +- `Ctrl + klik` op niet-Mac + +Niet in scope in deze stap: + +- `Shift + klik` range-selectie +- `Ctrl/Cmd + A` +- `Alt`-gebaseerde selectie +- OS-specifieke volledige parity met Finder/Explorer +- backendwijzigingen + +## 3. Gewenst Gedrag +### Shift + ArrowDown / ArrowUp +`Shift + ArrowDown` en `Shift + ArrowUp` breiden de selectie uit of verkleinen die vanaf een vast selectie-anker binnen het actieve paneel. + +Voorstel: + +- elk paneel krijgt naast `currentRowIndex` en `selectedItems` ook `selectionAnchorIndex` +- als nog niets geselecteerd is: + - eerste `Shift + ArrowDown/ArrowUp` selecteert de current row en de eerstvolgende rij in de gekozen richting + - `selectionAnchorIndex` wordt gezet op de oorspronkelijke current row +- als al een range actief is: + - verdere `Shift + ArrowDown/ArrowUp` verplaatst de actieve rand van de selectie + - selectie groeit of krimpt tussen `selectionAnchorIndex` en de nieuwe `currentRowIndex` +- current row blijft het bewegende uiteinde van de range + +### Ankerpunt +Het range-anker begint op het moment dat range-selectie start. Dat anker blijft staan totdat een andere actie de selectiemodus logisch reset, bijvoorbeeld: + +- gewone rij-click zonder modifier +- klik op filenaam +- klik op directorynaam die navigeert +- `Escape` +- paneelnavigatie naar andere directory + +### Current row versus selected items +- `currentRowIndex` blijft altijd exact 1 rij aanwijzen in het actieve paneel +- `selectedItems` kan 0, 1 of meerdere items bevatten +- bij range-selectie hoeft current row niet de enige geselecteerde rij te zijn; current row is alleen de focusrij binnen de geselecteerde set + +### Als nog niets geselecteerd is +Voor veilige voorspelbaarheid: + +- `Shift + ArrowDown/ArrowUp` gebruikt de huidige current row als startpunt +- current row moet dus al bestaan; in een leeg paneel doet de shortcut niets + +### Cmd/Ctrl + klik +Modifier-click werkt als toggle op een individueel item zonder andere selectie te wissen. + +Voorstel: + +- `Cmd + klik` op Mac: toggle selectie van dat item +- `Ctrl + klik` op niet-Mac: toggle selectie van dat item +- current row verhuist naar het aangeklikte item +- `selectionAnchorIndex` wordt gezet op dat item, zodat een daaropvolgende range-selectie logisch verdergaat vanaf de laatst gemodificeerde rij + +Dit geeft willekeurige toevoeging/verwijdering van items zonder checkbox verplicht te maken. + +## 4. Interactie Met Bestaande Regels +### Klik op checkbox +Blijft een expliciete toggle van dat ene item. + +Aanvulling: + +- checkbox-toggle mag ook `selectionAnchorIndex` zetten op het betreffende item +- daarmee sluit checkboxgedrag aan op latere range-selectie + +### Klik op rij +Blijft single-selectie op dat item zonder modifier. + +Gevolg: + +- eerdere multi-selectie wordt vervangen door exact dit item +- `currentRowIndex` en `selectionAnchorIndex` worden beide op deze rij gezet + +### Klik op directorynaam +Blijft directory openen. + +Gevolg: + +- selectie van dat paneel wordt gewist +- current row reset logisch op de eerste zichtbare rij in de nieuwe map +- `selectionAnchorIndex` wordt gewist + +### Klik op filenaam +Blijft single-selectie van dat item. + +Gevolg: + +- eerdere multi-selectie vervalt +- current row en anker worden op die rij gezet + +### Space toggle +Blijft toggle op current row. + +Aanvulling: + +- `Space` zet ook `selectionAnchorIndex` op current row +- zo blijft keyboardselectie consistent met modifier-click en checkbox-toggle + +### Escape clear +Blijft selectie van actief paneel wissen. + +Aanvulling: + +- wist ook `selectionAnchorIndex` +- current row blijft behouden + +### currentRowIndex +Blijft de focusrij voor keyboardnavigatie en acties zoals `Space`, `Enter` en toekomstige range-selectie. + +### activePane +Alle advanced selection-interacties gelden alleen voor het actieve paneel. Het inactieve paneel behoudt zijn selectie ongewijzigd. + +## 5. Scopebeperking +Niet meenemen in v1, tenzij later expliciet gevraagd: + +- `Shift + klik` range-selectie +- `Cmd/Ctrl + A` +- drag selection +- desktop-specifieke contextmenu-semantiek +- backendwijzigingen + +Aanbeveling: `Shift + klik` nu niet toevoegen. Dat is bruikbaar, maar verhoogt regressierisico in een web-UI met bestaande naam-click, rij-click en checkbox-click verschillen. + +## 6. UX-regels +- current row moet visueel zichtbaar blijven, ook binnen een multi-selectie +- range-selectie moet eruitzien als normale multi-selectie; current row blijft herkenbaar als focusrij +- selectiegedrag moet voorspelbaar blijven: + - gewone klik reset selectie + - modifier-click togglet één item + - shift-arrow werkt vanaf een vast anker +- current row moet bij keyboard range-selectie in beeld blijven via bestaande scrolllogica +- in een leeg paneel doen shortcuts niets + +## 7. Impactanalyse +Waarschijnlijk te wijzigen frontendbestanden: + +- `webui/html/app.js` +- mogelijk beperkt `webui/html/style.css` voor subtielere current-row/selected-row combinatie +- `webui/backend/tests/golden/test_ui_smoke_golden.py` waarschijnlijk niet of nauwelijks + +Geen backendimpact verwacht. + +Regressierisico: + +- medium in `app.js`, omdat selectiegedrag al meerdere paden heeft: checkbox, rij-click, filenaam, directorynaam, keyboard en wildcardselectie +- laag in CSS, zolang alleen bestaande states duidelijker gecombineerd worden + +Belangrijkste regressierisico's: + +- onbedoeld resetten van multi-selectie bij modifier-click +- current row en selectie die uit sync raken +- range-selectie die een verkeerd anker gebruikt na navigatie of escape + +## 8. Teststrategie +### Smoke/regressietests +Kleine frontend regressiechecks zijn zinvol voor: + +- aanwezigheid van bestaande dual-pane UI na wijziging +- geen breuk in bestaande rooktesten voor panelen/modals/assets + +Headless UI smoke-tests dekken keyboarddetail beperkt. De kernvalidatie zal daarom vooral handmatig zijn. + +### Handmatige validatie +Minimaal handmatig verifiëren: + +1. gewone rij-click blijft single-selectie +2. checkbox blijft toggle zonder neveneffecten +3. `Cmd/Ctrl + klik` voegt items toe en verwijdert ze weer +4. `Shift + ArrowDown` start een range vanaf current row +5. `Shift + ArrowUp` verkleint of verplaatst de range correct +6. `Escape` wist selectie maar laat current row staan +7. directory openen wist selectie van alleen dat paneel +8. current row blijft zichtbaar bij range-selectie met keyboard +9. bestaande copy/move/delete/rename werken nog op de resulterende selectie + +## 9. Aanbeveling +Aanbevolen v1-richting met laag regressierisico: + +- voeg alleen `Shift + ArrowUp/ArrowDown` toe voor keyboard range-selectie +- voeg alleen `Cmd + klik` / `Ctrl + klik` toe voor niet-aangrenzende toggle-selectie +- gebruik een expliciete `selectionAnchorIndex` per paneel +- laat gewone click-semantiek ongewijzigd +- voeg nog geen `Shift + klik` toe in deze fase + +Deze aanpak is klein genoeg om veilig te implementeren, sluit aan op standaard file manager gedrag en versterkt de bestaande dual-pane workflow zonder de huidige interacties semantisch te herontwerpen. diff --git a/webui/html/app.js b/webui/html/app.js index 759e143..4af0652 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -7,6 +7,7 @@ let state = { selectedItems: [], visibleItems: [], currentRowIndex: -1, + selectionAnchorIndex: null, }, right: { currentPath: "/Volumes", @@ -15,6 +16,7 @@ let state = { selectedItems: [], visibleItems: [], currentRowIndex: -1, + selectionAnchorIndex: null, }, }, activePane: "left", @@ -198,6 +200,14 @@ function setSelectedItem(pane, item) { updateActionButtons(); } +function clearSelectionAnchor(pane) { + paneState(pane).selectionAnchorIndex = null; +} + +function setSelectionAnchor(pane, index) { + paneState(pane).selectionAnchorIndex = Number.isInteger(index) ? index : null; +} + function selectedPaths(pane) { return paneState(pane).selectedItems.map((item) => item.path); } @@ -206,6 +216,11 @@ function setSingleSelection(pane, item) { setSelectedItem(pane, item); } +function setSingleSelectionAtIndex(pane, item, index) { + setSingleSelection(pane, item); + setSelectionAnchor(pane, index); +} + function toggleSelection(pane, item) { const model = paneState(pane); const index = model.selectedItems.findIndex((selected) => selected.path === item.path); @@ -222,6 +237,43 @@ function toggleSelection(pane, item) { updateActionButtons(); } +function toggleSelectionAtIndex(pane, item, index) { + toggleSelection(pane, item); + setSelectionAnchor(pane, index); +} + +function selectedEntryFromItem(entry) { + return { path: entry.path, name: entry.name, kind: entry.kind }; +} + +function setRangeSelection(pane, anchorIndex, currentIndex) { + const model = paneState(pane); + if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { + return; + } + const start = Math.max(0, Math.min(anchorIndex, currentIndex)); + const end = Math.min(model.visibleItems.length - 1, Math.max(anchorIndex, currentIndex)); + model.selectedItems = model.visibleItems + .slice(start, end + 1) + .filter((entry) => !entry.isParent) + .map((entry) => selectedEntryFromItem(entry)); + const current = model.visibleItems[currentIndex]; + model.selectedItem = current && !current.isParent ? selectedEntryFromItem(current) : (model.selectedItems[model.selectedItems.length - 1] || null); + updateActionButtons(); +} + +function isMacLike() { + const platform = navigator.platform || navigator.userAgent || ""; + return /Mac|iPhone|iPad|iPod/i.test(platform); +} + +function isToggleModifierClick(event) { + if (isMacLike()) { + return !!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey; + } + return !!event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey; +} + function currentRowItem(pane) { const model = paneState(pane); if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { @@ -490,6 +542,7 @@ function renderPaneItems(pane) { up.onclick = () => { setActivePane(pane); model.currentRowIndex = index; + clearSelectionAnchor(pane); renderPaneItems(pane); }; up.append(document.createElement("span")); @@ -526,7 +579,7 @@ function renderPaneItems(pane) { row.onclick = () => { setActivePane(pane); model.currentRowIndex = index; - setSingleSelection(pane, { path: entry.path, name: entry.name, kind: entry.kind }); + setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); renderPaneItems(pane); }; const checkbox = row.querySelector(".select-marker"); @@ -535,7 +588,7 @@ function renderPaneItems(pane) { ev.stopPropagation(); setActivePane(pane); model.currentRowIndex = index; - toggleSelection(pane, { path: entry.path, name: entry.name, kind: entry.kind }); + toggleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); renderPaneItems(pane); }; } @@ -553,10 +606,24 @@ function renderPaneItems(pane) { ev.stopPropagation(); setActivePane(pane); model.currentRowIndex = index; - setSingleSelection(pane, { path: entry.path, name: entry.name, kind: entry.kind }); + if (isToggleModifierClick(ev)) { + toggleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); + } else { + setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); + } renderPaneItems(pane); }; } + row.onclick = (ev) => { + setActivePane(pane); + model.currentRowIndex = index; + if (isToggleModifierClick(ev)) { + toggleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); + } else { + setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); + } + renderPaneItems(pane); + }; items.append(row); }); updateActionButtons(); @@ -606,6 +673,7 @@ function navigateTo(pane, path) { const model = paneState(pane); model.currentPath = path; model.currentRowIndex = 0; + clearSelectionAnchor(pane); setSelectedItem(pane, null); loadBrowsePane(pane); } @@ -1290,6 +1358,27 @@ function moveCurrentRow(delta) { scrollCurrentRowIntoView(pane); } +function extendSelectionByRow(delta) { + const pane = state.activePane; + const model = paneState(pane); + if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { + model.currentRowIndex = -1; + return; + } + const originalIndex = model.currentRowIndex < 0 ? 0 : model.currentRowIndex; + if (model.currentRowIndex < 0) { + model.currentRowIndex = 0; + } + if (!Number.isInteger(model.selectionAnchorIndex)) { + model.selectionAnchorIndex = originalIndex; + } + const maxIndex = model.visibleItems.length - 1; + model.currentRowIndex = Math.max(0, Math.min(maxIndex, model.currentRowIndex + delta)); + setRangeSelection(pane, model.selectionAnchorIndex, model.currentRowIndex); + renderPaneItems(pane); + scrollCurrentRowIntoView(pane); +} + function jumpCurrentRow(edge) { const pane = state.activePane; const model = paneState(pane); @@ -1317,12 +1406,14 @@ function toggleCurrentSelection() { if (!item || item.isParent) { return; } + setSelectionAnchor(pane, paneState(pane).currentRowIndex); toggleSelection(pane, { path: item.path, name: item.name, kind: item.kind }); renderPaneItems(pane); } function clearSelectionForActivePane() { const pane = state.activePane; + clearSelectionAnchor(pane); setSelectedItem(pane, null); renderPaneItems(pane); } @@ -1415,11 +1506,21 @@ function handleKeyboardShortcuts(event) { return; } if (event.key === "ArrowUp") { + if (event.shiftKey) { + event.preventDefault(); + extendSelectionByRow(-1); + return; + } event.preventDefault(); moveCurrentRow(-1); return; } if (event.key === "ArrowDown") { + if (event.shiftKey) { + event.preventDefault(); + extendSelectionByRow(1); + return; + } event.preventDefault(); moveCurrentRow(1); return;