mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-02-20 07:37:26 -05:00
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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user