From df47bd13b312db5bca319b5e1b61559e91716629 Mon Sep 17 00:00:00 2001 From: kodi Date: Wed, 11 Mar 2026 12:35:47 +0100 Subject: [PATCH] feat: keyboard functionaliteit cmd/option up/down toegevoegd --- .../UI_KEYBOARD_V1_1_AND_WILDCARD_DESIGN.md | 147 +++++++++ webui/html/app.js | 287 ++++++++++++++++-- webui/html/style.css | 12 + 3 files changed, 422 insertions(+), 24 deletions(-) create mode 100644 project_docs/UI_KEYBOARD_V1_1_AND_WILDCARD_DESIGN.md diff --git a/project_docs/UI_KEYBOARD_V1_1_AND_WILDCARD_DESIGN.md b/project_docs/UI_KEYBOARD_V1_1_AND_WILDCARD_DESIGN.md new file mode 100644 index 0000000..fc8431b --- /dev/null +++ b/project_docs/UI_KEYBOARD_V1_1_AND_WILDCARD_DESIGN.md @@ -0,0 +1,147 @@ +# UI_KEYBOARD_V1_1_AND_WILDCARD_DESIGN.md + +## Deel 1: Keyboard Navigation v1.1 (Mac-vriendelijk) + +### Doel +Keyboard v1.1 bouwt voort op v1 en maakt navigatie sneller op Mac zonder de bestaande muisflow of backendcontracten te wijzigen. + +### Shortcut scope (v1.1) +- `Tab`: wissel actief paneel (`left` <-> `right`) +- `ArrowUp` / `ArrowDown`: verplaats current row +- `Enter`: open directory op current row +- `Space`: toggle selectie op current row +- `Escape`: wis selectie in actief paneel +- `Cmd + ArrowUp`: ga naar eerste rij +- `Cmd + ArrowDown`: ga naar laatste rij +- `Alt + ArrowUp`: grotere stap omhoog +- `Alt + ArrowDown`: grotere stap omlaag + +### State impact +Geen nieuw globaal model nodig; uitbreiding op bestaand model: +- per paneel blijft: + - `currentPath` + - `visibleItems` + - `selectedItems` + - `currentRowIndex` +- globaal blijft: + - `activePane` + +Aanvullende constante voor stapgrootte: +- `PAGE_STEP` (voorstel: 10 rijen) + +### Navigatie- en scrollgedrag +- `Cmd + ArrowUp`: `currentRowIndex = 0` +- `Cmd + ArrowDown`: `currentRowIndex = visibleItems.length - 1` +- `Alt + ArrowUp`: `currentRowIndex = max(0, currentRowIndex - PAGE_STEP)` +- `Alt + ArrowDown`: `currentRowIndex = min(last, currentRowIndex + PAGE_STEP)` +- na elke indexwijziging: `scrollIntoView({ block: "nearest" })` +- bij leeg paneel: alle row-shortcuts zijn no-op + +### Focusregels +Shortcuts alleen actief als focus niet in interactieve controls zit: +- `input`, `textarea`, `select`, `button`, `checkbox`, `contenteditable` + +Extra voor Mac-combinaties: +- alleen onderscheppen als de combinatie exact matcht met ondersteunde shortcuts +- geen brede override van browser/system shortcuts buiten scope + +### Regressierisico +- Mac key-detectie kan verschillen (`metaKey`/`altKey` + `Arrow*` combinaties) +- Overcapturing van `Alt+Arrow` kan botsen met OS/browsergedrag op sommige layouts +- Onbedoelde interactie met bestaande klikselectie bij snelle keyboard/muis mix + +Mitigatie: +- centrale keyboard dispatcher met expliciete guard +- alleen exacte combinaties afvangen +- bestaande click handlers ongewijzigd laten + +### Teststrategie +Geautomatiseerd (beperkt): +- bestaande UI smoke tests behouden +- optioneel kleine statische regressiecheck op paneelstructuur ongewijzigd + +Handmatig (primair): +- `Cmd+ArrowUp/Down` springt naar begin/eind van lijst +- `Alt+ArrowUp/Down` springt met grote stap en scrollt correct mee +- shortcuts werken alleen in actief paneel +- shortcuts doen niets in inputs/checkboxfocus +- bestaande v1 shortcuts blijven correct werken + +--- + +## Deel 2: Wildcard selectie ontwerp + +### Doel +Snelle bulkselectie op patroon in het actieve paneel, zonder backendaanpassing. + +### Trigger shortcuts +- `Shift + +` (oftewel `Shift` + `=` op veel toetsenborden): selecteer items op patroon +- `Shift + -` : deselecteer items op patroon + +### Scopekeuzes (v1) +- Werkt **alleen op actief paneel** +- Werkt **alleen op zichtbare items** (`visibleItems`), niet recursief +- Werkt op **files én directories** in v1 (consistent met zichtbare lijst) +- `..` parent-entry doet niet mee + +### Patroonformaat +- Glob-achtig, minimaal: + - `*` = willekeurige reeks + - `?` = één karakter +- Voorbeelden: + - `*.mkv` + - `S??E??*` + - `Project*` + +### Case sensitivity +- Voorstel v1: **case-insensitive matching** +- Reden: voorspelbaarder voor eindgebruikers en sluit aan op typische file-manager verwachtingen + +### Gedrag met bestaande selectie +- `Shift + +`: + - additief: matchende items worden toegevoegd aan bestaande selectie + - niet-matchende selectie blijft behouden +- `Shift + -`: + - subtractief: alleen matchende items worden uit selectie verwijderd + +### Minimale popup UX +Eenvoudige modal/prompt met: +- titel: `Select by pattern` of `Deselect by pattern` +- één inputveld: patroon +- knoppen: `Apply`, `Cancel` +- compacte feedbackregel na apply: + - `N items matched, M changed` + +Paneelcontext: +- popup wordt gestart vanuit actief paneel en toont dat expliciet (`Active pane: left/right`) + +### Wat expliciet nog niet in scope is +- Geen regex-modus +- Geen include/exclude in één dialoog +- Geen persistente pattern-history +- Geen backend batch-endpoints +- Geen recursieve mapmatching +- Geen geavanceerde filters op size/date/type + +### Regressierisico +- Shortcut-conflict met keyboard-layouts (`+` op verschillende toetsen) +- Matchlogica kan onduidelijk zijn bij verborgen bestanden (afhankelijk van `show_hidden`) +- Onbedoelde selectie van directories als gebruiker file-only verwacht + +Mitigatie: +- in popup korte hinttekst over scope (`visible items in active pane`) +- heldere result-feedback (`matched/changed`) +- parent-entry expliciet uitsluiten + +### Teststrategie +Geautomatiseerd: +- basis smoke: UI laadt, paneelstructuur blijft intact +- (indien kleine JS-tests bestaan) unitniveau voor glob-matcher helper + +Handmatig: +- `Shift + +` selecteert matchende zichtbare items in actief paneel +- `Shift + -` deselecteert matchende geselecteerde items +- inactief paneel blijft onaangeraakt +- behavior met gemixte selectie (file+dir) is consistent +- case-insensitive matching bevestigd + diff --git a/webui/html/app.js b/webui/html/app.js index d3a821f..31abde7 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -5,18 +5,23 @@ let state = { showHidden: false, selectedItem: null, selectedItems: [], + visibleItems: [], + currentRowIndex: -1, }, right: { currentPath: "storage1", showHidden: false, selectedItem: null, selectedItems: [], + visibleItems: [], + currentRowIndex: -1, }, }, activePane: "left", selectedTaskId: null, lastTaskCount: 0, }; +const ROW_JUMP_STEP = 10; function paneState(pane) { return state.panes[pane]; @@ -122,6 +127,17 @@ function toggleSelection(pane, item) { updateActionButtons(); } +function currentRowItem(pane) { + const model = paneState(pane); + if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { + return null; + } + if (model.currentRowIndex < 0 || model.currentRowIndex >= model.visibleItems.length) { + return null; + } + return model.visibleItems[model.currentRowIndex]; +} + function updateActionButtons() { const selectedItems = activePaneState().selectedItems; const count = selectedItems.length; @@ -189,10 +205,15 @@ function formatModified(isoString) { function createBrowseItem(pane, entry, kind) { const li = document.createElement("li"); li.className = "selectable"; + li.dataset.path = entry.path; const paths = selectedPaths(pane); + const model = paneState(pane); if (paths.includes(entry.path)) { li.classList.add("is-selected"); } + if (model.currentRowIndex >= 0 && model.visibleItems[model.currentRowIndex]?.path === entry.path) { + li.classList.add("is-current-row"); + } li.onclick = () => { setActivePane(pane); @@ -251,32 +272,56 @@ function createBrowseItem(pane, entry, kind) { return li; } -async function loadBrowsePane(pane) { - setError(`${pane}-browse-error`, ""); - try { - const model = paneState(pane); - const query = new URLSearchParams({ - path: model.currentPath, - show_hidden: String(model.showHidden), - }); - const data = await apiRequest("GET", `/api/browse?${query.toString()}`); - model.currentPath = data.path; - document.getElementById(`${pane}-current-path`).textContent = data.path; - renderBreadcrumbs(pane, data.path); +function scrollCurrentRowIntoView(pane) { + const model = paneState(pane); + if (model.currentRowIndex < 0) { + return; + } + const list = document.getElementById(`${pane}-items`); + const row = list.querySelector(`li[data-row-index="${model.currentRowIndex}"]`); + if (row) { + row.scrollIntoView({ block: "nearest" }); + } +} - const items = document.getElementById(`${pane}-items`); - items.innerHTML = ""; +function renderPaneItems(pane) { + const model = paneState(pane); + const items = document.getElementById(`${pane}-items`); + items.innerHTML = ""; - const parent = currentParentPath(data.path); - if (parent) { + if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { + model.currentRowIndex = -1; + updateActionButtons(); + return; + } + if (model.currentRowIndex < 0 || model.currentRowIndex >= model.visibleItems.length) { + model.currentRowIndex = 0; + } + + model.visibleItems.forEach((entry, index) => { + if (entry.isParent) { const up = document.createElement("li"); up.className = "selectable"; + up.dataset.rowIndex = String(index); + up.dataset.path = entry.path; + if (index === model.currentRowIndex) { + up.classList.add("is-current-row"); + } + up.onclick = () => { + setActivePane(pane); + model.currentRowIndex = index; + renderPaneItems(pane); + }; up.append(document.createElement("span")); const upName = document.createElement("button"); upName.type = "button"; upName.className = "dir-link"; upName.textContent = "../"; - upName.onclick = () => navigateTo(pane, parent); + upName.onclick = (ev) => { + ev.stopPropagation(); + setActivePane(pane); + navigateTo(pane, entry.path); + }; const upNameCell = document.createElement("span"); upNameCell.className = "entry-name entry-dir"; upNameCell.append(upName); @@ -290,23 +335,87 @@ async function loadBrowsePane(pane) { upModified.textContent = "-"; up.append(upModified); items.append(up); + return; } - const visiblePaths = new Set(); + const row = createBrowseItem(pane, entry, entry.kind); + row.dataset.rowIndex = String(index); + if (index === model.currentRowIndex) { + row.classList.add("is-current-row"); + } + row.onclick = () => { + setActivePane(pane); + model.currentRowIndex = index; + setSingleSelection(pane, { path: entry.path, name: entry.name, kind: entry.kind }); + renderPaneItems(pane); + }; + const checkbox = row.querySelector(".select-marker"); + if (checkbox) { + checkbox.onclick = (ev) => { + ev.stopPropagation(); + setActivePane(pane); + model.currentRowIndex = index; + toggleSelection(pane, { path: entry.path, name: entry.name, kind: entry.kind }); + renderPaneItems(pane); + }; + } + const dirLink = row.querySelector(".dir-link"); + if (dirLink) { + dirLink.onclick = (ev) => { + ev.stopPropagation(); + setActivePane(pane); + navigateTo(pane, entry.path); + }; + } + const fileName = row.querySelector(".entry-file span"); + if (fileName) { + fileName.onclick = (ev) => { + ev.stopPropagation(); + setActivePane(pane); + model.currentRowIndex = index; + setSingleSelection(pane, { path: entry.path, name: entry.name, kind: entry.kind }); + renderPaneItems(pane); + }; + } + items.append(row); + }); + updateActionButtons(); +} + +async function loadBrowsePane(pane) { + setError(`${pane}-browse-error`, ""); + try { + const model = paneState(pane); + const query = new URLSearchParams({ + path: model.currentPath, + show_hidden: String(model.showHidden), + }); + const data = await apiRequest("GET", `/api/browse?${query.toString()}`); + model.currentPath = data.path; + document.getElementById(`${pane}-current-path`).textContent = data.path; + renderBreadcrumbs(pane, data.path); + + const visibleItems = []; + const parent = currentParentPath(data.path); + if (parent) { + visibleItems.push({ path: parent, name: "..", kind: "directory", isParent: true }); + } for (const entry of data.directories) { - visiblePaths.add(entry.path); - items.append(createBrowseItem(pane, entry, "directory")); + visibleItems.push({ ...entry, kind: "directory" }); } for (const entry of data.files) { - visiblePaths.add(entry.path); - items.append(createBrowseItem(pane, entry, "file")); + visibleItems.push({ ...entry, kind: "file" }); } + model.visibleItems = visibleItems; + const visiblePaths = new Set(visibleItems.filter((item) => !item.isParent).map((item) => item.path)); model.selectedItems = model.selectedItems.filter((item) => visiblePaths.has(item.path)); if (model.selectedItem && !visiblePaths.has(model.selectedItem.path)) { model.selectedItem = model.selectedItems.length > 0 ? model.selectedItems[model.selectedItems.length - 1] : null; } - updateActionButtons(); + + renderPaneItems(pane); + scrollCurrentRowIntoView(pane); setStatus(`Loaded ${pane}: ${data.path}`); } catch (err) { setError(`${pane}-browse-error`, `Browse: ${err.message}`); @@ -314,7 +423,9 @@ async function loadBrowsePane(pane) { } function navigateTo(pane, path) { - paneState(pane).currentPath = path; + const model = paneState(pane); + model.currentPath = path; + model.currentRowIndex = 0; setSelectedItem(pane, null); loadBrowsePane(pane); } @@ -488,6 +599,133 @@ async function addBookmark() { } } +function shouldHandleShortcut(target) { + if (!target || !(target instanceof Element)) { + return true; + } + if (target.closest("[contenteditable='true']")) { + return false; + } + const control = target.closest("input, textarea, select, button"); + if (!control) { + return true; + } + if (control.tagName === "INPUT" && control.type === "checkbox") { + return false; + } + return false; +} + +function moveCurrentRow(delta) { + const pane = state.activePane; + const model = paneState(pane); + if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { + model.currentRowIndex = -1; + return; + } + if (model.currentRowIndex < 0) { + model.currentRowIndex = 0; + } else { + const maxIndex = model.visibleItems.length - 1; + model.currentRowIndex = Math.max(0, Math.min(maxIndex, model.currentRowIndex + delta)); + } + renderPaneItems(pane); + scrollCurrentRowIntoView(pane); +} + +function jumpCurrentRow(edge) { + const pane = state.activePane; + const model = paneState(pane); + if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { + model.currentRowIndex = -1; + return; + } + model.currentRowIndex = edge === "start" ? 0 : model.visibleItems.length - 1; + renderPaneItems(pane); + scrollCurrentRowIntoView(pane); +} + +function openCurrentDirectory() { + const pane = state.activePane; + const item = currentRowItem(pane); + if (!item || item.kind !== "directory") { + return; + } + navigateTo(pane, item.path); +} + +function toggleCurrentSelection() { + const pane = state.activePane; + const item = currentRowItem(pane); + if (!item || item.isParent) { + return; + } + toggleSelection(pane, { path: item.path, name: item.name, kind: item.kind }); + renderPaneItems(pane); +} + +function clearSelectionForActivePane() { + const pane = state.activePane; + setSelectedItem(pane, null); + renderPaneItems(pane); +} + +function handleKeyboardShortcuts(event) { + if (!shouldHandleShortcut(event.target)) { + return; + } + + if (event.metaKey && event.key === "ArrowUp") { + event.preventDefault(); + jumpCurrentRow("start"); + return; + } + if (event.metaKey && event.key === "ArrowDown") { + event.preventDefault(); + jumpCurrentRow("end"); + return; + } + if (event.altKey && event.key === "ArrowUp") { + event.preventDefault(); + moveCurrentRow(-ROW_JUMP_STEP); + return; + } + if (event.altKey && event.key === "ArrowDown") { + event.preventDefault(); + moveCurrentRow(ROW_JUMP_STEP); + return; + } + if (event.key === "Tab") { + event.preventDefault(); + setActivePane(otherPane(state.activePane)); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + moveCurrentRow(-1); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + moveCurrentRow(1); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + openCurrentDirectory(); + return; + } + if (event.key === " " || event.code === "Space") { + event.preventDefault(); + toggleCurrentSelection(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + clearSelectionForActivePane(); + } +} + function setupPaneEvents(pane) { document.getElementById(`${pane}-pane`).onclick = () => setActivePane(pane); document.getElementById(`${pane}-hidden-toggle`).onchange = (ev) => { @@ -500,6 +738,7 @@ function setupPaneEvents(pane) { function setupEvents() { setupPaneEvents("left"); setupPaneEvents("right"); + document.addEventListener("keydown", handleKeyboardShortcuts); document.getElementById("rename-btn").onclick = renameSelected; document.getElementById("delete-btn").onclick = deleteSelected; document.getElementById("copy-btn").onclick = startCopySelected; diff --git a/webui/html/style.css b/webui/html/style.css index 9a289b2..de9cbb6 100644 --- a/webui/html/style.css +++ b/webui/html/style.css @@ -215,6 +215,18 @@ button:disabled { background: #e9f0fd; } +.list li.is-current-row { + box-shadow: inset 0 0 0 1px #9cb7e8; +} + +.list li.is-current-row:not(.is-selected) { + background: #f4f8ff; +} + +.list li.is-current-row.is-selected { + background: #e3edff; +} + .select-marker { appearance: none; width: 10px;