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
This commit is contained in:
Bruno Charest 2026-03-30 20:56:34 -04:00
parent 628a664c59
commit db812a6176
2 changed files with 137 additions and 0 deletions

View File

@ -1963,6 +1963,8 @@
const isReadonly = false; const isReadonly = false;
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly); 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); container.appendChild(vaultItem);
@ -2058,6 +2060,8 @@
const isReadonly = false; const isReadonly = false;
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly); 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); container.appendChild(vaultItem);
@ -2264,6 +2268,8 @@
if (item.type === "directory") { 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)]); 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); fragment.appendChild(dirItem);
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` }); const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
@ -2296,6 +2302,8 @@
const fileIconName = getFileIcon(item.name); const fileIconName = getFileIcon(item.name);
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : 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}`)]); 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", () => { fileItem.addEventListener("click", () => {
scrollTreeItemIntoView(fileItem, false); scrollTreeItemIntoView(fileItem, false);
openFile(vaultName, item.path); openFile(vaultName, item.path);
@ -4727,6 +4735,87 @@
return s; 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 dactions");
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) { function getVaultIcon(vaultName, size = 16) {
const v = allVaults.find((val) => val.name === vaultName); const v = allVaults.find((val) => val.name === vaultName);
const type = v ? v.type : "VAULT"; const type = v ? v.type : "VAULT";

View File

@ -837,6 +837,9 @@ select {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
} }
.tree-item > .badge-small {
margin-left: auto;
}
.tree-item.active { .tree-item.active {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--accent); color: var(--accent);
@ -880,6 +883,44 @@ select {
color: var(--accent-green); 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 { .tree-children {
padding-left: 16px; padding-left: 16px;
overflow: hidden; overflow: hidden;
@ -903,6 +944,13 @@ select {
padding: 4px 0 10px 12px; 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 { .filter-result-item {
align-items: flex-start; align-items: flex-start;
white-space: normal; white-space: normal;