Skip to content

Commit b9d0446

Browse files
committed
widget approach
1 parent ab00780 commit b9d0446

3 files changed

Lines changed: 236 additions & 0 deletions

File tree

apps/obsidian/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import {
1717
openConvertImageToNodeModal,
1818
} from "~/utils/editorMenuUtils";
1919
import { createImageEmbedHoverExtension } from "~/utils/imageEmbedHoverIcon";
20+
import {
21+
createWikilinkDragExtension,
22+
wikilinkDragPostProcessor,
23+
} from "~/utils/wikilinkDragHandler";
2024
import { registerCommands } from "~/utils/registerCommands";
2125
import { DiscourseContextView } from "~/components/DiscourseContextView";
2226
import { VIEW_TYPE_TLDRAW_DG_PREVIEW, FRONTMATTER_KEY } from "~/constants";
@@ -232,6 +236,9 @@ export default class DiscourseGraphPlugin extends Plugin {
232236
}),
233237
);
234238

239+
// Make wikilinks draggable so they can be dropped onto tldraw canvases
240+
this.registerMarkdownPostProcessor(wikilinkDragPostProcessor(this));
241+
235242
// Register editor keydown listener for node tag hotkey
236243
this.setupNodeTagHotkey();
237244
}
@@ -281,6 +288,9 @@ export default class DiscourseGraphPlugin extends Plugin {
281288

282289
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
283290
this.registerEditorExtension(createImageEmbedHoverExtension(this));
291+
292+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
293+
this.registerEditorExtension(createWikilinkDragExtension(this));
284294
}
285295

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

apps/obsidian/styles.css

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,27 @@
3535
background: var(--interactive-accent-hover);
3636
}
3737

38+
/* Drag handle next to wikilinks in Live Preview */
39+
.dg-wikilink-drag-handle {
40+
display: inline-block;
41+
cursor: grab;
42+
opacity: 0;
43+
font-size: 10px;
44+
vertical-align: middle;
45+
margin-left: 2px;
46+
color: var(--text-muted);
47+
transition: opacity 0.15s ease;
48+
user-select: none;
49+
}
50+
51+
.cm-line:hover .dg-wikilink-drag-handle {
52+
opacity: 0.6;
53+
}
54+
55+
.dg-wikilink-drag-handle:hover {
56+
opacity: 1 !important;
57+
}
58+
3859
/* Neutralize host button styling inside our editor */
3960
.tldraw__editor button {
4061
background: transparent !important;

0 commit comments

Comments
 (0)