From db812a61763d516447c37fa6fe721a8962c66009 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 30 Mar 2026 20:56:34 -0400 Subject: [PATCH] feat: add action buttons and long-press support for vault tree items with mobile-friendly context menus - Add action buttons (ellipsis icon) to vault, directory, and file tree items - Implement long-press gesture detection for mobile devices with 550ms delay and 10px movement threshold - Show action buttons on hover for desktop and always visible on mobile/touch devices - Position context menus near action buttons to prevent off-screen rendering - Prevent click events from firing after long-press ges --- frontend/app.js | 89 ++++++++++++++++++++++++++++++++++++++++++++++ frontend/style.css | 48 +++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/frontend/app.js b/frontend/app.js index 9df24d9..be80e54 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1963,6 +1963,8 @@ const isReadonly = false; ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly); }); + attachTreeItemActionButton(vaultItem, v.name, "", "vault", false); + attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false })); container.appendChild(vaultItem); @@ -2058,6 +2060,8 @@ const isReadonly = false; ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly); }); + attachTreeItemActionButton(vaultItem, v.name, "", "vault", false); + attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false })); container.appendChild(vaultItem); @@ -2264,6 +2268,8 @@ if (item.type === "directory") { const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), document.createTextNode(` ${item.name} `), smallBadge(item.children_count)]); + attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false); + attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false })); fragment.appendChild(dirItem); const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); @@ -2296,6 +2302,8 @@ const fileIconName = getFileIcon(item.name); const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), document.createTextNode(` ${displayName}`)]); + attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); + attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); fileItem.addEventListener("click", () => { scrollTreeItemIntoView(fileItem, false); openFile(vaultName, item.path); @@ -4727,6 +4735,87 @@ return s; } + function getContextMenuPositionFromElement(target) { + const rect = target.getBoundingClientRect(); + return { + x: Math.min(rect.right - 8, window.innerWidth - 16), + y: Math.min(rect.top + rect.height / 2, window.innerHeight - 16), + }; + } + + function attachTreeItemActionButton(itemEl, vault, path, type, isReadonly) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "tree-item-action-btn"; + button.setAttribute("aria-label", "Afficher le menu d’actions"); + button.setAttribute("title", "Actions"); + button.appendChild(icon("ellipsis", 16)); + button.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const pos = getContextMenuPositionFromElement(button); + ContextMenuManager.show(pos.x, pos.y, vault, path, type, isReadonly); + }); + itemEl.appendChild(button); + } + + function attachTreeItemLongPress(itemEl, getMenuData) { + let pressTimer = null; + let pressHandled = false; + let startX = 0; + let startY = 0; + const longPressDelay = 550; + const moveThreshold = 10; + + const clearPressTimer = () => { + if (pressTimer) { + clearTimeout(pressTimer); + pressTimer = null; + } + }; + + itemEl.addEventListener("touchstart", (e) => { + if (!e.touches || e.touches.length !== 1) return; + pressHandled = false; + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + clearPressTimer(); + pressTimer = setTimeout(() => { + const data = getMenuData(); + if (!data) return; + pressHandled = true; + ContextMenuManager.show(startX, startY, data.vault, data.path, data.type, data.isReadonly); + }, longPressDelay); + }, { passive: true }); + + itemEl.addEventListener("touchmove", (e) => { + if (!e.touches || e.touches.length !== 1) return; + const dx = Math.abs(e.touches[0].clientX - startX); + const dy = Math.abs(e.touches[0].clientY - startY); + if (dx > moveThreshold || dy > moveThreshold) { + clearPressTimer(); + } + }, { passive: true }); + + itemEl.addEventListener("touchend", () => { + clearPressTimer(); + }, { passive: true }); + + itemEl.addEventListener("touchcancel", () => { + clearPressTimer(); + }, { passive: true }); + + itemEl.addEventListener("click", (e) => { + if (pressHandled) { + e.preventDefault(); + e.stopPropagation(); + setTimeout(() => { + pressHandled = false; + }, 0); + } + }, true); + } + function getVaultIcon(vaultName, size = 16) { const v = allVaults.find((val) => val.name === vaultName); const type = v ? v.type : "VAULT"; diff --git a/frontend/style.css b/frontend/style.css index 15548a5..77fc1f2 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -837,6 +837,9 @@ select { background: var(--bg-hover); color: var(--text-primary); } +.tree-item > .badge-small { + margin-left: auto; +} .tree-item.active { background: var(--bg-hover); color: var(--accent); @@ -880,6 +883,44 @@ select { color: var(--accent-green); } +.tree-item-action-btn { + margin-left: auto; + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: + opacity 140ms ease, + color 140ms ease, + background 140ms ease, + border-color 140ms ease; + flex-shrink: 0; +} + +.tree-item-action-btn:hover, +.tree-item-action-btn:focus-visible { + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-color: color-mix(in srgb, var(--accent) 25%, var(--border)); + color: var(--accent); + outline: none; +} + +.tree-item:hover .tree-item-action-btn, +.tree-item:focus-within .tree-item-action-btn, +.tree-item.path-selected .tree-item-action-btn, +.tree-item.active .tree-item-action-btn { + opacity: 1; + pointer-events: auto; +} + .tree-children { padding-left: 16px; overflow: hidden; @@ -903,6 +944,13 @@ select { padding: 4px 0 10px 12px; } +@media (hover: none), (pointer: coarse), (max-width: 768px) { + .tree-item-action-btn { + opacity: 1; + pointer-events: auto; + } +} + .filter-result-item { align-items: flex-start; white-space: normal;