logancyang_obsidian-copilot/src/hooks/useChatFileDrop.ts
Logan Yang cd726a0e83
feat: drag relevant notes and sources into editor to insert wikilinks (#2288)
* feat: drag relevant notes and sources into editor to insert wikilinks

Adds Obsidian-native drag-and-drop support to the Relevant Notes panel
and Sources modal, using app.dragManager so dropping onto an editor
automatically inserts [[wikilink]] without any custom drop handler.

- New useNoteDrag hook wraps app.dragManager.dragLink/onDragStart
- RelevantNotes: title link (expanded) and badge (collapsed) are draggable
- SourcesModal: source links are draggable (vanilla JS class context)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: prevent drop zone overlay when dragging from relevant notes

The chat container's "drop files here" overlay incorrectly appeared when
dragging notes from the relevant notes pane. Mark internal drags with a
custom data transfer type so the drop zone hook can skip them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:56:55 -07:00

258 lines
8.7 KiB
TypeScript

import { isAllowedFileForNoteContext } from "@/utils";
import { App, Notice, TFile } from "obsidian";
import { RefObject, useEffect, useState } from "react";
/**
* Props for the useChatFileDrop hook
*/
export interface UseChatFileDropProps {
/** Obsidian app instance for vault operations */
app: App;
/** Current context notes */
contextNotes: TFile[];
/** Callback to update context notes */
setContextNotes: (notes: TFile[] | ((prev: TFile[]) => TFile[])) => void;
/** Currently selected images */
selectedImages: File[];
/** Callback to add images */
onAddImage: (files: File[]) => void;
/** Reference to the container element for drag-and-drop */
containerRef: RefObject<HTMLElement>;
}
/**
* Return value from the useChatFileDrop hook
*/
export interface UseChatFileDropReturn {
/** Whether a file is currently being dragged over the container */
isDragActive: boolean;
}
/**
* Helper function to parse Obsidian URI and resolve to a TFile.
* Uses the file system as source of truth: tries loading the file path directly,
* and if that fails, tries adding .md extension (for markdown files without extension).
* @param app - The Obsidian app instance
* @param uriString - The URI string to parse
* @returns The resolved TFile, or null if file not found
*/
function parseObsidianUri(app: App, uriString: string): TFile | null {
// Parse Obsidian URI format: obsidian://open?vault=...&file=...
const match = uriString.match(/obsidian:\/\/open\?vault=.*?&file=(.*)$/);
if (!match) return null;
const filePath = decodeURIComponent(match[1]);
// Try 1: Load file as-is (works for images, pdfs, canvas, and .md files)
let file = app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) return file;
// Try 2: Add .md extension (for markdown files without extension in URI)
file = app.vault.getAbstractFileByPath(filePath + ".md");
if (file instanceof TFile) return file;
// Give up - file not found
return null;
}
/**
* Parse multiple Obsidian URIs from a newline-separated string.
* This handles the case where multiple files are dropped from the nav bar,
* which come as a single string with URIs separated by newlines.
* @param app - The Obsidian app instance
* @param uriString - String potentially containing multiple URIs
* @returns Array of resolved TFile objects
*/
function parseObsidianUris(app: App, uriString: string): TFile[] {
// Split by newlines and filter empty lines
const lines = uriString.split("\n").filter((line) => line.trim());
// Parse each line as a URI and collect resolved files
const files: TFile[] = [];
for (const line of lines) {
const file = parseObsidianUri(app, line.trim());
if (file) {
files.push(file);
}
}
return files;
}
/**
* Custom hook to handle drag-and-drop of files into the chat.
* Supports:
* - Dropping files from Obsidian nav bar (md, pdf, canvas, images)
* - Dropping external image files
*
* @param props - Configuration for the drag-and-drop functionality
* @returns Object containing drag state
*/
export function useChatFileDrop(props: UseChatFileDropProps): UseChatFileDropReturn {
const { app, contextNotes, setContextNotes, selectedImages, onAddImage, containerRef } = props;
const [isDragActive, setIsDragActive] = useState(false);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
/**
* Handle dragover event to show visual feedback
*/
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "copy";
// Skip showing drop zone for drags originating from within the plugin (e.g. relevant notes)
if (e.dataTransfer.types.includes("copilot/internal-drag")) {
return;
}
// Check if we have string items (Obsidian nav bar drag) or file items (external files)
const hasStringItems = Array.from(e.dataTransfer.items).some(
(item) => item.kind === "string"
);
const hasFileItems = Array.from(e.dataTransfer.items).some((item) => item.kind === "file");
if (hasStringItems || hasFileItems) {
setIsDragActive(true);
}
}
};
/**
* Handle dragleave event to clear visual feedback
*/
const handleDragLeave = (e: DragEvent) => {
// Check if we're actually leaving the container (not just entering a child)
const rect = container.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
// If mouse is outside the container bounds, clear drag state
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
setIsDragActive(false);
}
};
/**
* Handle drop event to process dropped files
*/
const handleDrop = async (e: DragEvent) => {
if (!e.dataTransfer) return;
e.preventDefault();
// Clear drag state
setIsDragActive(false);
const items = e.dataTransfer.items;
const stringItems: DataTransferItem[] = [];
const fileItems: DataTransferItem[] = [];
// Separate string items (Obsidian nav bar) from file items (external files)
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "string") {
stringItems.push(item);
} else if (item.kind === "file") {
fileItems.push(item);
}
}
// Process Obsidian URI strings from nav bar
if (stringItems.length > 0) {
// Stop propagation to prevent other handlers from processing the same drop
e.stopPropagation();
// Collect all URI strings first to avoid race conditions
// getAsString is async and multiple callbacks could run concurrently
const uriStringPromises = stringItems.map(
(item) =>
new Promise<string>((resolve) => {
item.getAsString((data) => resolve(data));
})
);
const uriStrings = await Promise.all(uriStringPromises);
// Parse all URIs and collect unique files (deduplicate by path)
const fileMap = new Map<string, TFile>();
for (const uriString of uriStrings) {
const files = parseObsidianUris(app, uriString);
for (const file of files) {
fileMap.set(file.path, file);
}
}
// Now process each unique file sequentially
for (const file of fileMap.values()) {
// Check if it's an image file
const isImage = ["png", "gif", "jpeg", "jpg", "webp"].includes(file.extension);
if (isImage) {
// Handle as image
// Check for duplicate images
const isDuplicate = selectedImages.some((img) => img.name === file.name);
if (isDuplicate) {
new Notice("This image is already in the context");
continue;
}
// Read file as File object for image handling
const arrayBuffer = await app.vault.readBinary(file);
const blob = new Blob([arrayBuffer]);
const imageFile = new File([blob], file.name, {
type: `image/${file.extension}`,
});
onAddImage([imageFile]);
} else if (isAllowedFileForNoteContext(file)) {
// Handle as note (md, pdf, canvas)
// Check for duplicate notes
const isDuplicate = contextNotes.some((note) => note.path === file.path);
if (isDuplicate) {
new Notice("This note is already in the context");
continue;
}
// Add to context notes
setContextNotes((prev) => [...prev, file]);
} else {
// Unsupported file type
new Notice(
`Unsupported file type: ${file.extension}. Supported types: md, pdf, canvas, and images.`
);
}
}
} else if (fileItems.length > 0) {
// Process external file drops (images only)
const files: File[] = [];
for (const item of fileItems) {
const file = item.getAsFile();
if (file && file.type.startsWith("image/")) {
files.push(file);
}
}
if (files.length > 0) {
onAddImage(files);
}
}
};
// Attach event listeners
container.addEventListener("dragover", handleDragOver);
container.addEventListener("dragleave", handleDragLeave);
container.addEventListener("drop", handleDrop);
// Cleanup
return () => {
container.removeEventListener("dragover", handleDragOver);
container.removeEventListener("dragleave", handleDragLeave);
container.removeEventListener("drop", handleDrop);
};
}, [app, contextNotes, selectedImages, onAddImage, setContextNotes, containerRef]);
return { isDragActive };
}