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:
parent
628a664c59
commit
db812a6176
@ -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 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) {
|
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";
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user