Implement clipboard functionality and enhance keybind handling

- Added clipboard event handling to support native clipboard operations (copy, cut, paste) based on input focus.
- Introduced functions to check if an input element is focused and to execute native clipboard commands.
- Updated keybind listener to trigger appropriate actions based on clipboard events, differentiating between text input and file operations.
- Enhanced menu setup to ensure clipboard actions are always enabled for better user experience.
This commit is contained in:
Jamie Pine
2025-12-25 13:07:52 -08:00
parent 7e23371c5d
commit 3e64221f2c
4 changed files with 120 additions and 24 deletions

View File

@@ -1684,22 +1684,24 @@ fn setup_menu(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
.item(&delete_item)
.build()?;
// Edit menu with custom file operations and text operations
// Edit menu with custom file operations and native text operations
// Accelerators are handled smartly: native clipboard for text inputs, file ops for explorer
// IMPORTANT: Keep these always enabled so accelerators work in text inputs
let cut_item = MenuItemBuilder::with_id("cut", "Cut")
.accelerator("Cmd+X")
.enabled(false)
.enabled(true)
.build(app)?;
menu_items_map.insert("cut".to_string(), cut_item.clone());
let copy_item = MenuItemBuilder::with_id("copy", "Copy")
.accelerator("Cmd+C")
.enabled(false)
.enabled(true)
.build(app)?;
menu_items_map.insert("copy".to_string(), copy_item.clone());
let paste_item = MenuItemBuilder::with_id("paste", "Paste")
.accelerator("Cmd+V")
.enabled(false)
.enabled(true)
.build(app)?;
menu_items_map.insert("paste".to_string(), paste_item.clone());
@@ -1907,10 +1909,18 @@ fn setup_menu(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
tracing::error!("Failed to emit menu action: {}", e);
}
}
// Edit menu clipboard actions - trigger keybind handlers
// Edit menu clipboard actions - emit event for smart handling in frontend
"cut" | "copy" | "paste" => {
let keybind_id = format!("explorer.{}", event_id);
keybinds::emit_keybind_triggered(&app_handle, &keybind_id);
tracing::info!("[Menu] Clipboard action triggered: {}", event_id);
// Emit generic clipboard event - frontend will decide if it's a text or file operation
if let Err(e) = app_handle.emit("clipboard-action", event_id) {
tracing::error!("Failed to emit clipboard action: {}", e);
} else {
tracing::info!(
"[Menu] Clipboard action event emitted successfully: {}",
event_id
);
}
}
_ => {}
}

View File

@@ -9,6 +9,50 @@ type KeybindHandler = () => void | Promise<void>;
const keybindHandlers = new Map<string, KeybindHandler>();
let eventUnlisten: UnlistenFn | null = null;
let clipboardUnlisten: UnlistenFn | null = null;
// Check if an input element is currently focused
function isInputFocused(): boolean {
const activeElement = document.activeElement;
console.log('[Clipboard] Active element:', {
element: activeElement,
tagName: activeElement?.tagName,
type: (activeElement as HTMLInputElement)?.type,
contenteditable: activeElement?.getAttribute('contenteditable')
});
if (!activeElement) {
console.log('[Clipboard] No active element');
return false;
}
const tagName = activeElement.tagName.toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
console.log('[Clipboard] Input element focused:', tagName);
return true;
}
// Check for contenteditable
if (activeElement.getAttribute('contenteditable') === 'true') {
console.log('[Clipboard] Contenteditable element focused');
return true;
}
console.log('[Clipboard] Non-input element focused:', tagName);
return false;
}
// Execute native clipboard operation (for text inputs)
function executeNativeClipboard(action: 'copy' | 'cut' | 'paste'): void {
console.log(`[Clipboard] Executing native ${action} operation`);
try {
// Use execCommand for compatibility (deprecated but still works)
const result = document.execCommand(action);
console.log(`[Clipboard] execCommand('${action}') result:`, result);
} catch (err) {
console.error(`[Clipboard] Failed to execute native ${action}:`, err);
}
}
// Initialize Tauri keybind listener
export async function initializeKeybindHandler(): Promise<void> {
@@ -27,6 +71,36 @@ export async function initializeKeybindHandler(): Promise<void> {
}
});
// Listen for clipboard actions from native menu
clipboardUnlisten = await listen<string>('clipboard-action', async (event) => {
const action = event.payload as 'copy' | 'cut' | 'paste';
console.log(`[Clipboard] Received clipboard-action event:`, action);
// Check if an input is focused
if (isInputFocused()) {
// Execute native browser clipboard operation
console.log('[Clipboard] Input focused, executing native operation');
executeNativeClipboard(action);
} else {
// Trigger file operation via keybind system
const keybindId = `explorer.${action}`;
console.log('[Clipboard] No input focused, triggering file operation:', keybindId);
const handler = keybindHandlers.get(keybindId);
if (handler) {
try {
await handler();
console.log(`[Clipboard] File operation ${keybindId} completed`);
} catch (err) {
console.error(`[Clipboard] Handler error for ${keybindId}:`, err);
}
} else {
console.warn(`[Clipboard] No handler registered for ${keybindId}`);
}
}
});
console.log('[Clipboard] Action listener initialized');
console.log('[Keybind] Handler initialized');
}
@@ -69,6 +143,11 @@ export async function cleanupKeybindHandler(): Promise<void> {
eventUnlisten = null;
}
if (clipboardUnlisten) {
clipboardUnlisten();
clipboardUnlisten = null;
}
// Unregister all keybinds
const ids = Array.from(keybindHandlers.keys());
for (const id of ids) {

View File

@@ -82,15 +82,13 @@ export function SelectionProvider({
const hasSelection = selectedFiles.length > 0;
const isSingleSelection = selectedFiles.length === 1;
const hasClipboard = clipboard.hasClipboard();
platform.updateMenuItems?.([
{ id: "copy", enabled: hasSelection },
{ id: "cut", enabled: hasSelection },
// NOTE: copy/cut/paste are always enabled to support text input operations
// They intelligently route to file ops or native clipboard based on focus
{ id: "duplicate", enabled: hasSelection },
{ id: "rename", enabled: isSingleSelection },
{ id: "delete", enabled: hasSelection },
{ id: "paste", enabled: hasClipboard },
]);
}, [selectedFiles, clipboard, platform, isActiveTab]);

View File

@@ -34,7 +34,7 @@ import { useSelection } from "../SelectionContext";
import { useOpenWith } from "../../../hooks/useOpenWith";
interface UseFileContextMenuProps {
file: File;
file?: File | null;
selectedFiles: File[];
selected: boolean;
}
@@ -59,7 +59,7 @@ export function useFileContextMenu({
const targets =
selected && selectedFiles.length > 0 ? selectedFiles : [file];
return targets
.filter((f) => "Physical" in f.sd_path)
.filter((f) => f && f.sd_path && "Physical" in f.sd_path)
.map((f) => (f.sd_path as any).Physical.path);
};
@@ -73,13 +73,13 @@ export function useFileContextMenu({
const targets =
selected && selectedFiles.length > 0 ? selectedFiles : [file];
// Filter out virtual files - they cannot be copied/moved/deleted
return targets.filter((f) => !isVirtualFile(f));
return targets.filter((f) => f && !isVirtualFile(f));
};
// Check if any selected files are virtual (to disable certain operations)
const hasVirtualFiles = selected
? selectedFiles.some((f) => isVirtualFile(f))
: isVirtualFile(file);
: file ? isVirtualFile(file) : false;
return useContextMenu({
items: [
@@ -87,15 +87,18 @@ export function useFileContextMenu({
icon: Eye,
label: "Quick Look",
onClick: () => {
if (!file) return;
console.log("Quick Look:", file.name);
// TODO: Implement quick look
},
keybind: "Space",
condition: () => !!file,
},
{
icon: FolderOpen,
label: "Open",
onClick: async () => {
if (!file) return;
if (file.kind === "Directory") {
navigateToPath(file.sd_path);
} else if ("Physical" in file.sd_path) {
@@ -104,19 +107,21 @@ export function useFileContextMenu({
}
},
keybind: "⌘O",
condition: () => file.kind === "Directory" || file.kind === "File",
condition: () => !!file && (file.kind === "Directory" || file.kind === "File"),
},
{
type: "submenu",
icon: ArrowSquareOut,
label: "Open With",
condition: () =>
!!file &&
file.kind === "File" &&
"Physical" in file.sd_path &&
apps.length > 0,
submenu: apps.map((app) => ({
label: app.name,
onClick: async () => {
if (!file) return;
if (selected && selectedFiles.length > 1) {
await openMultipleWithApp(physicalPaths, app.id);
} else if ("Physical" in file.sd_path) {
@@ -131,6 +136,7 @@ export function useFileContextMenu({
icon: MagnifyingGlass,
label: "Show in Finder",
onClick: async () => {
if (!file) return;
// Extract the physical path from SdPath
if ("Physical" in file.sd_path) {
const physicalPath = file.sd_path.Physical.path;
@@ -152,17 +158,18 @@ export function useFileContextMenu({
},
keybind: "⌘⇧R",
condition: () =>
"Physical" in file.sd_path && !!platform.revealFile,
!!file && "Physical" in file.sd_path && !!platform.revealFile,
},
{ type: "separator" },
{
icon: Pencil,
label: "Rename",
onClick: () => {
if (!file) return;
startRename(file.id);
},
keybindId: "explorer.renameFile",
condition: () => selected && selectedFiles.length === 1 && !hasVirtualFiles,
condition: () => !!file && selected && selectedFiles.length === 1 && !hasVirtualFiles,
},
{
icon: FolderPlus,
@@ -292,7 +299,7 @@ export function useFileContextMenu({
type: "submenu",
icon: Image,
label: "Image Processing",
condition: () => getContentKind(file) === "image",
condition: () => !!file && getContentKind(file) === "image",
submenu: [
{
icon: Sparkle,
@@ -304,7 +311,7 @@ export function useFileContextMenu({
generate_blurhash: true,
});
},
condition: () => !file.image_media_data?.blurhash,
condition: () => !!file && !file.image_media_data?.blurhash,
},
{
icon: Crop,
@@ -334,7 +341,7 @@ export function useFileContextMenu({
type: "submenu",
icon: Video,
label: "Video Processing",
condition: () => getContentKind(file) === "video",
condition: () => !!file && getContentKind(file) === "video",
submenu: [
{
icon: FilmStrip,
@@ -347,6 +354,7 @@ export function useFileContextMenu({
});
},
condition: () =>
!!file &&
!file.sidecars?.some(
(s) => s.kind === "thumbstrip",
),
@@ -361,7 +369,7 @@ export function useFileContextMenu({
generate_blurhash: true,
});
},
condition: () => !file.video_media_data?.blurhash,
condition: () => !!file && !file.video_media_data?.blurhash,
},
{
icon: Crop,
@@ -403,7 +411,7 @@ export function useFileContextMenu({
type: "submenu",
icon: Microphone,
label: "Audio Processing",
condition: () => getContentKind(file) === "audio",
condition: () => !!file && getContentKind(file) === "audio",
submenu: [
{
icon: TextAa,
@@ -424,6 +432,7 @@ export function useFileContextMenu({
icon: FileText,
label: "Document Processing",
condition: () =>
!!file &&
file.kind === "File" &&
["pdf", "doc", "docx"].includes(file.extension || ""),
submenu: [
@@ -506,7 +515,7 @@ export function useFileContextMenu({
const message =
targets.length > 1
? `Delete ${targets.length} items?`
: `Delete "${file.name}"?`;
: `Delete "${file?.name ?? "this file"}"?`;
if (confirm(message)) {
console.log(