Skip to content

Commit 7f358b2

Browse files
trangdoan982claudegraphite-app[bot]
authored
ENG-1507: Drag and drop files from wikilinks (#960)
* widget approach * ENG-1507: Add debug logging to wikilink drag handler Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * clean up * address PR comment * clean further * PR comment * Update apps/obsidian/src/utils/wikilinkDragHandler.ts Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * fix * hide/show the widget * PR comment --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
1 parent 5ddf586 commit 7f358b2

2 files changed

Lines changed: 218 additions & 0 deletions

File tree

apps/obsidian/src/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
openConvertImageToNodeModal,
1818
} from "~/utils/editorMenuUtils";
1919
import { createImageEmbedHoverExtension } from "~/utils/imageEmbedHoverIcon";
20+
import { createWikilinkDragExtension } from "~/utils/wikilinkDragHandler";
2021
import { registerCommands } from "~/utils/registerCommands";
2122
import { DiscourseContextView } from "~/components/DiscourseContextView";
2223
import { VIEW_TYPE_TLDRAW_DG_PREVIEW, FRONTMATTER_KEY } from "~/constants";
@@ -232,6 +233,32 @@ export default class DiscourseGraphPlugin extends Plugin {
232233
}),
233234
);
234235

236+
type EditorWithCm = { cm: EditorView };
237+
const hasCodeMirrorView = (editor: unknown): editor is EditorWithCm => {
238+
if (!editor || typeof editor !== "object") return false;
239+
return "cm" in editor;
240+
};
241+
242+
// Dispatch a no-op CM6 transaction to every markdown editor so their
243+
// ViewPlugin re-evaluates hasVisibleCanvasLeaf and shows/hides widgets.
244+
// layout-change covers splits/moves, active-leaf-change covers tab switches.
245+
const refreshMarkdownEditors = (): void => {
246+
this.app.workspace.iterateAllLeaves((leaf) => {
247+
if (
248+
leaf.view instanceof MarkdownView &&
249+
hasCodeMirrorView(leaf.view.editor)
250+
) {
251+
leaf.view.editor.cm.dispatch({});
252+
}
253+
});
254+
};
255+
this.registerEvent(
256+
this.app.workspace.on("layout-change", refreshMarkdownEditors),
257+
);
258+
this.registerEvent(
259+
this.app.workspace.on("active-leaf-change", refreshMarkdownEditors),
260+
);
261+
235262
// Register editor keydown listener for node tag hotkey
236263
this.setupNodeTagHotkey();
237264
}
@@ -281,6 +308,9 @@ export default class DiscourseGraphPlugin extends Plugin {
281308

282309
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
283310
this.registerEditorExtension(createImageEmbedHoverExtension(this));
311+
312+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
313+
this.registerEditorExtension(createWikilinkDragExtension(this));
284314
}
285315

286316
private createStyleElement() {
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import {
2+
type PluginValue,
3+
ViewPlugin,
4+
type ViewUpdate,
5+
WidgetType,
6+
Decoration,
7+
type DecorationSet,
8+
EditorView,
9+
} from "@codemirror/view";
10+
import { TFile, WorkspaceLeaf } from "obsidian";
11+
import { VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants";
12+
import type DiscourseGraphPlugin from "~/index";
13+
14+
const buildObsidianUrl = (vaultName: string, filePath: string): string => {
15+
return `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(filePath)}`;
16+
};
17+
18+
const resolveFileFromLinkText = (
19+
linkText: string,
20+
plugin: DiscourseGraphPlugin,
21+
): TFile | null => {
22+
const activeFile = plugin.app.workspace.getActiveFile();
23+
if (!activeFile) return null;
24+
25+
const resolved = plugin.app.metadataCache.getFirstLinkpathDest(
26+
linkText,
27+
activeFile.path,
28+
);
29+
return resolved instanceof TFile ? resolved : null;
30+
};
31+
32+
const setDragData = (
33+
e: DragEvent,
34+
file: TFile,
35+
plugin: DiscourseGraphPlugin,
36+
): void => {
37+
const vaultName = plugin.app.vault.getName();
38+
const url = buildObsidianUrl(vaultName, file.path);
39+
e.dataTransfer?.setData("text/uri-list", url);
40+
e.dataTransfer?.setData("text/plain", url);
41+
};
42+
43+
// --- Live Preview ---
44+
45+
/**
46+
* Extract the file path from a link match.
47+
* Handles wikilinks (`[[path]]`, `[[path|alias]]`) and
48+
* markdown links (`[text](path.md)`), decoding URL-encoded paths.
49+
*/
50+
const extractLinkPath = (match: string): string => {
51+
// Wikilink: [[path]] or [[path|alias]]
52+
if (match.startsWith("[[")) {
53+
const inner = match.slice(2, -2);
54+
const pipeIndex = inner.indexOf("|");
55+
return pipeIndex >= 0 ? inner.slice(0, pipeIndex) : inner;
56+
}
57+
58+
// Markdown link: [text](path)
59+
const parenOpen = match.lastIndexOf("(");
60+
const rawPath = match.slice(parenOpen + 1, -1);
61+
try {
62+
return decodeURIComponent(rawPath);
63+
} catch (error) {
64+
return rawPath;
65+
}
66+
};
67+
68+
/**
69+
* Widget that renders a small drag handle next to an internal link.
70+
* CM6 widgets get `ignoreEvent() → true` by default, which means
71+
* the editor completely ignores mouse events on them — native drag works.
72+
*/
73+
class WikilinkDragHandleWidget extends WidgetType {
74+
constructor(
75+
private linkPath: string,
76+
private plugin: DiscourseGraphPlugin,
77+
) {
78+
super();
79+
}
80+
81+
eq(other: WikilinkDragHandleWidget): boolean {
82+
return this.linkPath === other.linkPath;
83+
}
84+
85+
toDOM(): HTMLElement {
86+
const handle = document.createElement("span");
87+
handle.className =
88+
"inline-block cursor-grab opacity-30 text-[10px] text-[var(--text-muted)] align-middle ml-0.5 transition-opacity duration-150 ease-in-out select-none";
89+
handle.draggable = true;
90+
handle.setAttribute("aria-label", "Drag to canvas");
91+
handle.textContent = "⠿";
92+
93+
handle.addEventListener("mouseenter", () => {
94+
handle.style.opacity = "1";
95+
});
96+
handle.addEventListener("mouseleave", () => {
97+
handle.style.opacity = "";
98+
});
99+
100+
handle.addEventListener("dragstart", (e) => {
101+
const file = resolveFileFromLinkText(this.linkPath, this.plugin);
102+
if (!file) {
103+
e.preventDefault();
104+
return;
105+
}
106+
setDragData(e, file, this.plugin);
107+
});
108+
109+
return handle;
110+
}
111+
}
112+
113+
// Matches wikilinks [[...]] and markdown links [text](path.md).
114+
// Embed exclusion (![[...]] and ![text](...)) is handled in the loop.
115+
const INTERNAL_LINK_RE = /\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)]+\.md)\)/g;
116+
117+
const hasVisibleCanvasLeaf = (plugin: DiscourseGraphPlugin): boolean =>
118+
plugin.app.workspace
119+
.getLeavesOfType(VIEW_TYPE_TLDRAW_DG_PREVIEW)
120+
.some((leaf) =>
121+
(leaf as WorkspaceLeaf & { isVisible(): boolean }).isVisible(),
122+
);
123+
const buildWidgetDecorations = (
124+
view: EditorView,
125+
plugin: DiscourseGraphPlugin,
126+
): DecorationSet => {
127+
if (!hasVisibleCanvasLeaf(plugin)) return Decoration.none;
128+
129+
const widgets = [];
130+
131+
for (const { from, to } of view.visibleRanges) {
132+
const text = view.state.doc.sliceString(from, to);
133+
let match: RegExpExecArray | null;
134+
INTERNAL_LINK_RE.lastIndex = 0;
135+
136+
while ((match = INTERNAL_LINK_RE.exec(text)) !== null) {
137+
const checkPos = from + match.index - 1;
138+
const isEmbed =
139+
checkPos >= 0 &&
140+
view.state.doc.sliceString(checkPos, checkPos + 1) === "!";
141+
if (isEmbed) continue;
142+
const matchEnd = from + match.index + match[0].length;
143+
const linkPath = extractLinkPath(match[0]);
144+
const widget = new WikilinkDragHandleWidget(linkPath, plugin);
145+
widgets.push(Decoration.widget({ widget, side: 1 }).range(matchEnd));
146+
}
147+
}
148+
149+
// Decorations must be sorted by position
150+
widgets.sort((a, b) => a.from - b.from);
151+
return Decoration.set(widgets);
152+
};
153+
154+
/**
155+
* CM6 ViewPlugin that adds a draggable grip icon after each internal link
156+
* in Live Preview. Matches both wikilinks (`[[...]]`) and markdown links
157+
* (`[text](path.md)`), inserting a widget decoration after each match.
158+
*/
159+
export const createWikilinkDragExtension = (
160+
plugin: DiscourseGraphPlugin,
161+
): ViewPlugin<PluginValue> => {
162+
return ViewPlugin.fromClass(
163+
class {
164+
decorations: DecorationSet;
165+
private canvasVisible: boolean;
166+
167+
constructor(view: EditorView) {
168+
this.canvasVisible = hasVisibleCanvasLeaf(plugin);
169+
this.decorations = buildWidgetDecorations(view, plugin);
170+
}
171+
172+
update(update: ViewUpdate): void {
173+
const canvasVisible = hasVisibleCanvasLeaf(plugin);
174+
if (
175+
update.docChanged ||
176+
update.viewportChanged ||
177+
canvasVisible !== this.canvasVisible
178+
) {
179+
this.canvasVisible = canvasVisible;
180+
this.decorations = buildWidgetDecorations(update.view, plugin);
181+
}
182+
}
183+
},
184+
{
185+
decorations: (v) => v.decorations,
186+
},
187+
);
188+
};

0 commit comments

Comments
 (0)