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>
This commit is contained in:
Logan Yang 2026-03-13 11:56:55 -07:00 committed by GitHub
parent ec5cdcfe25
commit cd726a0e83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 70 additions and 2 deletions

View file

@ -6,6 +6,7 @@ import { HelpTooltip } from "@/components/ui/help-tooltip";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useChatInput } from "@/context/ChatInputContext";
import { useActiveFile } from "@/hooks/useActiveFile";
import { useNoteDrag } from "@/hooks/useNoteDrag";
import { cn } from "@/lib/utils";
import { logWarn } from "@/logger";
import { SemanticSearchToggleModal } from "@/components/modals/SemanticSearchToggleModal";
@ -131,6 +132,7 @@ function RelevantNote({
}) {
const [isOpen, setIsOpen] = useState(false);
const [fileContent, setFileContent] = useState<string | null>(null);
const handleDragStart = useNoteDrag();
const loadContent = useCallback(async () => {
if (fileContent) return; // Don't load if we already have content
@ -181,6 +183,13 @@ function RelevantNote({
<div className="tw-flex-1 tw-overflow-hidden">
<a
draggable
onDragStart={(e) => {
const file = app.vault.getAbstractFileByPath(note.document.path);
if (file instanceof TFile) {
handleDragStart(e, file);
}
}}
onClick={(e) => {
e.preventDefault();
const openInNewLeaf = e.metaKey || e.ctrlKey;
@ -194,7 +203,7 @@ function RelevantNote({
}
}}
className="tw-block tw-w-full tw-truncate tw-text-sm tw-font-bold tw-text-normal"
title={note.document.title}
title={`${note.document.title} - drag to insert wikilink`}
>
{note.document.title}
</a>
@ -293,6 +302,7 @@ export const RelevantNotes = memo(
const activeFile = useActiveFile();
const chatInput = useChatInput();
const hasIndex = useHasIndex(activeFile?.path ?? "", refresher);
const handleDragStart = useNoteDrag();
const navigateToNote = (notePath: string, openInNewLeaf = false) => {
const file = app.vault.getAbstractFileByPath(notePath);
if (file instanceof TFile) {
@ -404,7 +414,15 @@ export const RelevantNotes = memo(
<Badge
variant="outline"
key={note.document.path}
draggable
onDragStart={(e) => {
const file = app.vault.getAbstractFileByPath(note.document.path);
if (file instanceof TFile) {
handleDragStart(e, file);
}
}}
className="tw-max-w-40 tw-text-xs tw-text-muted hover:tw-cursor-pointer hover:tw-bg-interactive-hover"
title={`${note.document.title} - drag to insert wikilink`}
>
<span className="tw-truncate">{note.document.title}</span>
</Badge>

View file

@ -1,5 +1,5 @@
// src/components/SourcesModal.tsx
import { App, Modal } from "obsidian";
import { App, Modal, TFile } from "obsidian";
export class SourcesModal extends Modal {
sources: { title: string; path: string; score: number; explanation?: any }[];
@ -55,6 +55,19 @@ export class SourcesModal extends Modal {
href: `obsidian://open?vault=${encodeURIComponent(this.app.vault.getName())}&file=${encodeURIComponent(source.path || source.title)}`,
text: displayText,
});
link.title = `${displayText} - drag to insert wikilink`;
link.draggable = true;
link.addEventListener("dragstart", (e) => {
const filePath = source.path || source.title;
const file = this.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
const dragManager = (this.app as any).dragManager;
if (!dragManager) return;
const linkText = this.app.metadataCache.fileToLinktext(file, "");
const dragData = dragManager.dragLink(e, linkText);
dragManager.onDragStart(e, dragData);
}
});
link.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();

View file

@ -104,6 +104,11 @@ export function useChatFileDrop(props: UseChatFileDropProps): UseChatFileDropRet
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"

32
src/hooks/useNoteDrag.ts Normal file
View file

@ -0,0 +1,32 @@
import { useCallback } from "react";
import { TFile } from "obsidian";
/**
* Returns a drag-start handler that integrates with Obsidian's native dragManager API.
* When a note element is dropped onto the Obsidian editor, the editor automatically
* inserts the corresponding `[[wikilink]]` without any additional drop handler.
*
* Usage:
* ```tsx
* const handleDragStart = useNoteDrag();
* const file = app.vault.getAbstractFileByPath(path);
* if (file instanceof TFile) {
* <div draggable onDragStart={(e) => handleDragStart(e, file)}> ... </div>
* }
* ```
*/
export function useNoteDrag() {
const handleDragStart = useCallback((e: React.DragEvent, file: TFile): void => {
const dragManager = (app as any).dragManager;
if (!dragManager) return;
// Mark this drag as internal so the chat drop zone overlay doesn't appear
e.dataTransfer.setData("copilot/internal-drag", "true");
const linkText = app.metadataCache.fileToLinktext(file, "");
const dragData = dragManager.dragLink(e.nativeEvent, linkText);
dragManager.onDragStart(e.nativeEvent, dragData);
}, []);
return handleDragStart;
}