@lapis-notes/graph 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE.md +8 -0
  3. package/README.md +20 -0
  4. package/dist/embed.d.ts +5 -0
  5. package/dist/embed.js +4 -0
  6. package/dist/graph-build-generation.d.ts +6 -0
  7. package/dist/graph-build-generation.js +13 -0
  8. package/dist/graph-controls-overlay.svelte +840 -0
  9. package/dist/graph-controls-overlay.svelte.d.ts +30 -0
  10. package/dist/graph-data-coordinator.d.ts +41 -0
  11. package/dist/graph-data-coordinator.js +265 -0
  12. package/dist/graph-data.d.ts +23 -0
  13. package/dist/graph-data.js +493 -0
  14. package/dist/graph-embed.svelte +92 -0
  15. package/dist/graph-embed.svelte.d.ts +12 -0
  16. package/dist/graph-file-preview.svelte +115 -0
  17. package/dist/graph-file-preview.svelte.d.ts +12 -0
  18. package/dist/graph-hover-intent.d.ts +22 -0
  19. package/dist/graph-hover-intent.js +85 -0
  20. package/dist/graph-load-alignment.d.ts +2 -0
  21. package/dist/graph-load-alignment.js +3 -0
  22. package/dist/graph-navigation.d.ts +7 -0
  23. package/dist/graph-navigation.js +78 -0
  24. package/dist/graph-node-activation.d.ts +4 -0
  25. package/dist/graph-node-activation.js +16 -0
  26. package/dist/graph-plugin.d.ts +38 -0
  27. package/dist/graph-plugin.js +177 -0
  28. package/dist/graph-problems.d.ts +15 -0
  29. package/dist/graph-problems.js +46 -0
  30. package/dist/graph-query-resolution.d.ts +9 -0
  31. package/dist/graph-query-resolution.js +44 -0
  32. package/dist/graph-renderer.d.ts +226 -0
  33. package/dist/graph-renderer.js +1350 -0
  34. package/dist/graph-settings.d.ts +20 -0
  35. package/dist/graph-settings.js +125 -0
  36. package/dist/graph-types.d.ts +74 -0
  37. package/dist/graph-types.js +1 -0
  38. package/dist/graph-view.d.ts +62 -0
  39. package/dist/graph-view.js +553 -0
  40. package/dist/graph-viewport-alignment.d.ts +6 -0
  41. package/dist/graph-viewport-alignment.js +9 -0
  42. package/dist/index.d.ts +9 -0
  43. package/dist/index.js +8 -0
  44. package/dist/mount-local-component.svelte.d.ts +7 -0
  45. package/dist/mount-local-component.svelte.js +12 -0
  46. package/dist/public-components.d.ts +2 -0
  47. package/dist/public-components.js +2 -0
  48. package/dist/styles.css +441 -0
  49. package/manifest.json +30 -0
  50. package/package.json +84 -0
  51. package/styles.css +1 -0
@@ -0,0 +1,493 @@
1
+ const GLOBAL_GRAPH_PAGE_SIZE = 256;
2
+ const LOCAL_GRAPH_QUERY_LIMIT = 10_000;
3
+ const LOCAL_GRAPH_PATH_BATCH_SIZE = 200;
4
+ function graphPathBatches(paths) {
5
+ const unique = [...new Set(paths)];
6
+ const batches = [];
7
+ for (let index = 0; index < unique.length; index += LOCAL_GRAPH_PATH_BATCH_SIZE) {
8
+ batches.push(unique.slice(index, index + LOCAL_GRAPH_PATH_BATCH_SIZE));
9
+ }
10
+ return batches;
11
+ }
12
+ function basename(path) {
13
+ return path.split("/").at(-1) ?? path;
14
+ }
15
+ function labelForPath(path) {
16
+ return basename(path).replace(/\.[^.]+$/, "");
17
+ }
18
+ function fileNodeId(path) {
19
+ return `note:${path}`;
20
+ }
21
+ function attachmentNodeId(path) {
22
+ return `attachment:${path}`;
23
+ }
24
+ function unresolvedNodeId(target) {
25
+ return `unresolved:${target}`;
26
+ }
27
+ function tagNodeId(tag) {
28
+ return `tag:${tag}`;
29
+ }
30
+ function parseLinkText(linkText) {
31
+ const [value] = linkText.split("|", 1);
32
+ const [path] = value.split("#", 1);
33
+ return path.trim();
34
+ }
35
+ export function normalizeGraphTag(tag) {
36
+ const value = tag.trim().replace(/^#+/, "");
37
+ return value.length ? `#${value}` : "";
38
+ }
39
+ function isMarkdownPath(path) {
40
+ return /\.(?:md|markdown)$/i.test(path);
41
+ }
42
+ function cloneNode(node) {
43
+ return {
44
+ ...node,
45
+ tags: [...node.tags],
46
+ groupIds: [...node.groupIds],
47
+ };
48
+ }
49
+ function ensureNoteNodeFromRow(nodes, row) {
50
+ const { file } = row;
51
+ const id = fileNodeId(file.path);
52
+ let node = nodes.get(id);
53
+ if (!node) {
54
+ node = {
55
+ id,
56
+ label: labelForPath(file.path),
57
+ path: file.path,
58
+ type: "note",
59
+ exists: true,
60
+ refCount: 0,
61
+ outgoingCount: 0,
62
+ tags: [],
63
+ groupIds: [],
64
+ mtime: file.mtime,
65
+ extension: file.extension.toLowerCase(),
66
+ };
67
+ nodes.set(id, node);
68
+ }
69
+ node.tags = [
70
+ ...new Set(row.tags.map((record) => normalizeGraphTag(record.tag)).filter(Boolean)),
71
+ ];
72
+ node.mtime = file.mtime;
73
+ node.extension = file.extension.toLowerCase();
74
+ return node;
75
+ }
76
+ function ensureNoteNodeFromFile(nodes, file) {
77
+ const id = fileNodeId(file.path);
78
+ let node = nodes.get(id);
79
+ if (!node) {
80
+ node = {
81
+ id,
82
+ label: labelForPath(file.path),
83
+ path: file.path,
84
+ type: "note",
85
+ exists: true,
86
+ refCount: 0,
87
+ outgoingCount: 0,
88
+ tags: [],
89
+ groupIds: [],
90
+ ctime: file.stat?.ctime,
91
+ mtime: file.stat?.mtime,
92
+ extension: file.extension?.toLowerCase(),
93
+ };
94
+ nodes.set(id, node);
95
+ }
96
+ return node;
97
+ }
98
+ function ensureAttachmentNode(nodes, path, file) {
99
+ const id = attachmentNodeId(path);
100
+ let node = nodes.get(id);
101
+ if (!node) {
102
+ node = {
103
+ id,
104
+ label: basename(path),
105
+ path,
106
+ type: "attachment",
107
+ exists: true,
108
+ refCount: 0,
109
+ outgoingCount: 0,
110
+ tags: [],
111
+ groupIds: [],
112
+ ctime: file?.stat?.ctime,
113
+ mtime: file?.stat?.mtime,
114
+ extension: file?.extension?.toLowerCase() ?? basename(path).split(".").at(-1),
115
+ };
116
+ nodes.set(id, node);
117
+ }
118
+ return node;
119
+ }
120
+ function ensureUnresolvedNode(nodes, target) {
121
+ const id = unresolvedNodeId(target);
122
+ let node = nodes.get(id);
123
+ if (!node) {
124
+ node = {
125
+ id,
126
+ label: target,
127
+ path: target,
128
+ type: "unresolved",
129
+ exists: false,
130
+ refCount: 0,
131
+ outgoingCount: 0,
132
+ tags: [],
133
+ groupIds: [],
134
+ };
135
+ nodes.set(id, node);
136
+ }
137
+ return node;
138
+ }
139
+ function ensureTagNode(nodes, tag) {
140
+ const normalized = normalizeGraphTag(tag);
141
+ const id = tagNodeId(normalized);
142
+ let node = nodes.get(id);
143
+ if (!node) {
144
+ node = {
145
+ id,
146
+ label: normalized,
147
+ path: normalized,
148
+ type: "tag",
149
+ exists: true,
150
+ refCount: 0,
151
+ outgoingCount: 0,
152
+ tags: [],
153
+ groupIds: [],
154
+ };
155
+ nodes.set(id, node);
156
+ }
157
+ return node;
158
+ }
159
+ function addLink(links, source, target, type, count, directed) {
160
+ const id = `${type}:${source}:${target}`;
161
+ const existing = links.get(id);
162
+ if (existing) {
163
+ existing.count += count;
164
+ return;
165
+ }
166
+ links.set(id, { id, source, target, count, type, directed });
167
+ }
168
+ function applyNodeCounts(nodes, links) {
169
+ for (const node of nodes.values()) {
170
+ node.refCount = 0;
171
+ node.outgoingCount = 0;
172
+ }
173
+ for (const link of links) {
174
+ const source = nodes.get(link.source);
175
+ const target = nodes.get(link.target);
176
+ if (source)
177
+ source.outgoingCount += link.count;
178
+ if (target)
179
+ target.refCount += link.count;
180
+ }
181
+ }
182
+ function assignGroups(nodes, settings, groupPathMatches) {
183
+ if (!settings.groups.length)
184
+ return nodes.map(cloneNode);
185
+ return nodes.map((node) => {
186
+ const nextNode = cloneNode(node);
187
+ nextNode.groupIds = [];
188
+ nextNode.primaryColor = undefined;
189
+ for (const group of settings.groups) {
190
+ const query = group.query.trim().toLowerCase();
191
+ if (!query)
192
+ continue;
193
+ const resolvedPaths = groupPathMatches?.get(group.id);
194
+ const matches = groupPathMatches
195
+ ? Boolean(resolvedPaths && nextNode.path && resolvedPaths.has(nextNode.path))
196
+ : [nextNode.label, nextNode.path ?? "", ...nextNode.tags]
197
+ .join(" ")
198
+ .toLowerCase()
199
+ .includes(query);
200
+ if (!matches)
201
+ continue;
202
+ nextNode.groupIds.push(group.id);
203
+ nextNode.primaryColor ??= group.color;
204
+ }
205
+ return nextNode;
206
+ });
207
+ }
208
+ function matchesSearch(node, query) {
209
+ const value = query.trim().toLowerCase();
210
+ if (!value)
211
+ return true;
212
+ return [node.label, node.path ?? "", ...node.tags]
213
+ .join(" ")
214
+ .toLowerCase()
215
+ .includes(value);
216
+ }
217
+ export function filterGraphBySettings(graph, settings, matches = {}) {
218
+ const visibleNodes = new Map();
219
+ for (const node of graph.nodes) {
220
+ if (node.type === "tag" && !settings.filters.showTags)
221
+ continue;
222
+ if (node.type === "attachment" && !settings.filters.showAttachments)
223
+ continue;
224
+ if (settings.filters.existingFilesOnly && !node.exists)
225
+ continue;
226
+ if (matches.filterPaths) {
227
+ if (!node.path || !matches.filterPaths.has(node.path))
228
+ continue;
229
+ }
230
+ else if (!matchesSearch(node, settings.filters.searchQuery)) {
231
+ continue;
232
+ }
233
+ visibleNodes.set(node.id, cloneNode(node));
234
+ }
235
+ let visibleLinks = graph.links.filter((link) => visibleNodes.has(link.source) && visibleNodes.has(link.target));
236
+ if (!settings.filters.showOrphans) {
237
+ const connected = new Set();
238
+ for (const link of visibleLinks) {
239
+ connected.add(link.source);
240
+ connected.add(link.target);
241
+ }
242
+ for (const nodeId of [...visibleNodes.keys()]) {
243
+ if (connected.has(nodeId) || nodeId === graph.centerNodeId)
244
+ continue;
245
+ visibleNodes.delete(nodeId);
246
+ }
247
+ visibleLinks = visibleLinks.filter((link) => visibleNodes.has(link.source) && visibleNodes.has(link.target));
248
+ }
249
+ return {
250
+ nodes: assignGroups([...visibleNodes.values()], settings, matches.groupPaths),
251
+ links: visibleLinks.map((link) => ({ ...link })),
252
+ centerNodeId: graph.centerNodeId ?? null,
253
+ };
254
+ }
255
+ function buildSingleFileGraph(settings, file) {
256
+ const nodes = new Map();
257
+ const node = ensureNoteNodeFromFile(nodes, file);
258
+ return {
259
+ nodes: assignGroups([node], settings),
260
+ links: [],
261
+ centerNodeId: node.id,
262
+ };
263
+ }
264
+ function resolvedFile(app, path) {
265
+ const file = app.vault.getFileByPath(path);
266
+ if (!file || !("extension" in file))
267
+ return null;
268
+ return file;
269
+ }
270
+ function canIncludeExpandedEdge(sourcePath, depthByPath, maxDepth) {
271
+ if (!depthByPath || maxDepth == null)
272
+ return true;
273
+ return (depthByPath.get(sourcePath) ?? maxDepth) < maxDepth;
274
+ }
275
+ function buildCanonicalGraphFromRows(app, rows, options = {}) {
276
+ const nodes = new Map();
277
+ const links = new Map();
278
+ const rowsByPath = new Map(rows.map((row) => [row.file.path, row]));
279
+ const allowedMarkdownPaths = new Set(rows.map((row) => row.file.path));
280
+ for (const row of rows) {
281
+ const node = ensureNoteNodeFromRow(nodes, row);
282
+ const file = resolvedFile(app, row.file.path);
283
+ node.ctime = file?.stat?.ctime;
284
+ node.mtime = file?.stat?.mtime ?? row.file.mtime;
285
+ }
286
+ for (const row of rows) {
287
+ const sourceNode = ensureNoteNodeFromRow(nodes, row);
288
+ const includeExpanded = canIncludeExpandedEdge(row.file.path, options.depthByPath, options.maxDepth);
289
+ if (includeExpanded) {
290
+ for (const tag of sourceNode.tags) {
291
+ const tagNode = ensureTagNode(nodes, tag);
292
+ addLink(links, sourceNode.id, tagNode.id, "tag", 1, false);
293
+ }
294
+ }
295
+ for (const record of row.links) {
296
+ const targetText = parseLinkText(record.targetText);
297
+ if (record.resolvedTargetPath) {
298
+ const targetPath = record.resolvedTargetPath;
299
+ if (isMarkdownPath(targetPath)) {
300
+ if (!allowedMarkdownPaths.has(targetPath))
301
+ continue;
302
+ const targetRow = rowsByPath.get(targetPath);
303
+ const targetNode = targetRow
304
+ ? ensureNoteNodeFromRow(nodes, targetRow)
305
+ : ensureNoteNodeFromFile(nodes, resolvedFile(app, targetPath) ?? {
306
+ path: targetPath,
307
+ extension: "md",
308
+ stat: { ctime: 0, mtime: 0, size: 0 },
309
+ });
310
+ addLink(links, sourceNode.id, targetNode.id, record.type === "embed" ? "embed" : "internal-link", record.count, true);
311
+ continue;
312
+ }
313
+ if (!includeExpanded)
314
+ continue;
315
+ const targetNode = ensureAttachmentNode(nodes, targetPath, resolvedFile(app, targetPath));
316
+ addLink(links, sourceNode.id, targetNode.id, record.type === "embed" ? "embed" : "internal-link", record.count, true);
317
+ continue;
318
+ }
319
+ if (!includeExpanded || !targetText)
320
+ continue;
321
+ const targetNode = ensureUnresolvedNode(nodes, targetText);
322
+ addLink(links, sourceNode.id, targetNode.id, record.type === "embed" ? "embed" : "internal-link", record.count, true);
323
+ }
324
+ }
325
+ const nextLinks = [...links.values()].map((link) => ({ ...link }));
326
+ applyNodeCounts(nodes, nextLinks);
327
+ return {
328
+ nodes: [...nodes.values()].map(cloneNode),
329
+ links: nextLinks,
330
+ centerNodeId: null,
331
+ };
332
+ }
333
+ async function queryGlobalRows(app, options = {}) {
334
+ const rows = [];
335
+ let total = 0;
336
+ if (typeof app.vault.iterateFiles === "function") {
337
+ for (const file of app.vault.iterateFiles()) {
338
+ if (!isMarkdownPath(file.path))
339
+ continue;
340
+ if (file.path.split("/").some((segment) => segment.startsWith("."))) {
341
+ continue;
342
+ }
343
+ total += 1;
344
+ }
345
+ }
346
+ let after;
347
+ let pages = 0;
348
+ do {
349
+ throwIfGraphBuildCancelled(options.signal);
350
+ const page = await app.metadataCache.queryMetadataPage({
351
+ after,
352
+ limit: GLOBAL_GRAPH_PAGE_SIZE,
353
+ include: ["tags", "links"],
354
+ query: {
355
+ extensions: ["md", "markdown"],
356
+ excludeHiddenPaths: true,
357
+ },
358
+ });
359
+ rows.push(...page.rows);
360
+ pages += 1;
361
+ options.onProgress?.({
362
+ processed: rows.length,
363
+ total: Math.max(total, rows.length),
364
+ pages,
365
+ });
366
+ after = page.nextCursor;
367
+ if (after) {
368
+ await new Promise((resolve) => setTimeout(resolve, 0));
369
+ }
370
+ } while (after);
371
+ return rows;
372
+ }
373
+ function throwIfGraphBuildCancelled(signal) {
374
+ if (!signal?.aborted)
375
+ return;
376
+ const error = new Error("Graph build cancelled");
377
+ error.name = "AbortError";
378
+ throw error;
379
+ }
380
+ async function queryRowsForPaths(app, paths) {
381
+ if (!paths.length)
382
+ return [];
383
+ const requestedPaths = [...new Set(paths)];
384
+ const pathSet = new Set(requestedPaths);
385
+ const rowsByPath = new Map();
386
+ for (const batch of graphPathBatches(requestedPaths)) {
387
+ const rows = await app.metadataCache.queryMetadata({
388
+ extensions: ["md", "markdown"],
389
+ pathPrefixes: batch,
390
+ excludeHiddenPaths: true,
391
+ limit: Math.max(LOCAL_GRAPH_QUERY_LIMIT, batch.length),
392
+ });
393
+ for (const row of rows) {
394
+ if (pathSet.has(row.file.path))
395
+ rowsByPath.set(row.file.path, row);
396
+ }
397
+ }
398
+ return requestedPaths.flatMap((path) => {
399
+ const row = rowsByPath.get(path);
400
+ return row ? [row] : [];
401
+ });
402
+ }
403
+ async function queryLocalLinks(app, query) {
404
+ const records = [];
405
+ for (const paths of graphPathBatches(query.paths)) {
406
+ const remaining = LOCAL_GRAPH_QUERY_LIMIT - records.length;
407
+ if (remaining <= 0)
408
+ break;
409
+ records.push(...(await app.metadataCache.queryLinks({
410
+ ...query,
411
+ paths,
412
+ limit: remaining,
413
+ })));
414
+ }
415
+ return records.slice(0, LOCAL_GRAPH_QUERY_LIMIT);
416
+ }
417
+ function markdownNeighbors(records) {
418
+ const paths = new Set();
419
+ for (const record of records) {
420
+ if (isMarkdownPath(record.sourcePath))
421
+ paths.add(record.sourcePath);
422
+ if (record.resolvedTargetPath &&
423
+ isMarkdownPath(record.resolvedTargetPath)) {
424
+ paths.add(record.resolvedTargetPath);
425
+ }
426
+ }
427
+ return [...paths];
428
+ }
429
+ async function collectLocalRows(app, settings, activeFile) {
430
+ const depthByPath = new Map([[activeFile.path, 0]]);
431
+ const rowsByPath = new Map();
432
+ let frontier = [activeFile.path];
433
+ for (let depth = 0; depth <= settings.localGraph.depth && frontier.length; depth++) {
434
+ const rows = await queryRowsForPaths(app, frontier);
435
+ for (const row of rows)
436
+ rowsByPath.set(row.file.path, row);
437
+ if (depth >= settings.localGraph.depth)
438
+ break;
439
+ const [incoming, outgoing] = await Promise.all([
440
+ queryLocalLinks(app, {
441
+ direction: "incoming",
442
+ paths: frontier,
443
+ resolution: "resolved",
444
+ }),
445
+ queryLocalLinks(app, {
446
+ direction: "outgoing",
447
+ paths: frontier,
448
+ resolution: "all",
449
+ }),
450
+ ]);
451
+ const candidates = new Set(markdownNeighbors([...incoming, ...outgoing]));
452
+ frontier = [...candidates].filter((path) => {
453
+ if (depthByPath.has(path))
454
+ return false;
455
+ depthByPath.set(path, depth + 1);
456
+ return true;
457
+ });
458
+ }
459
+ return { rows: [...rowsByPath.values()], depthByPath };
460
+ }
461
+ export async function buildCanonicalGraph(app, options = {}) {
462
+ const rows = await queryGlobalRows(app, options);
463
+ throwIfGraphBuildCancelled(options.signal);
464
+ return buildCanonicalGraphFromRows(app, rows);
465
+ }
466
+ export async function buildGlobalGraph(app, settings) {
467
+ return filterGraphBySettings(await buildCanonicalGraph(app), settings);
468
+ }
469
+ export async function buildLocalGraph(app, settings, activeFile) {
470
+ return filterGraphBySettings(await buildCanonicalLocalGraph(app, settings, activeFile), settings);
471
+ }
472
+ export async function buildCanonicalLocalGraph(app, settings, activeFile) {
473
+ if (!activeFile)
474
+ return { nodes: [], links: [], centerNodeId: null };
475
+ const { rows, depthByPath } = await collectLocalRows(app, settings, activeFile);
476
+ if (!rows.some((row) => row.file.path === activeFile.path)) {
477
+ return buildSingleFileGraph(settings, activeFile);
478
+ }
479
+ const canonical = buildCanonicalGraphFromRows(app, rows, {
480
+ depthByPath,
481
+ maxDepth: settings.localGraph.depth,
482
+ });
483
+ const scoped = {
484
+ ...canonical,
485
+ centerNodeId: fileNodeId(activeFile.path),
486
+ };
487
+ return scoped.nodes.length
488
+ ? scoped
489
+ : buildSingleFileGraph(settings, activeFile);
490
+ }
491
+ export function graphNodeIdForFile(path) {
492
+ return fileNodeId(path);
493
+ }
@@ -0,0 +1,92 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte";
3
+
4
+ import { GraphRenderer } from "./graph-renderer";
5
+ import { DEFAULT_GRAPH_SETTINGS, mergeGraphSettings } from "./graph-settings";
6
+ import type { GraphData, GraphNode, GraphSettings } from "./graph-types";
7
+
8
+ type Props = {
9
+ data: GraphData;
10
+ settings?: Partial<GraphSettings> | GraphSettings | null;
11
+ focusedNodeId?: string | null;
12
+ class?: string;
13
+ onNodeClick?: (node: GraphNode, event: MouseEvent) => void;
14
+ onNodeContextMenu?: (node: GraphNode, event: MouseEvent) => void;
15
+ };
16
+
17
+ let {
18
+ data,
19
+ settings = DEFAULT_GRAPH_SETTINGS,
20
+ focusedNodeId = null,
21
+ class: className = "",
22
+ onNodeClick = () => {},
23
+ onNodeContextMenu = () => {},
24
+ }: Props = $props();
25
+
26
+ let surfaceEl: HTMLDivElement | null = null;
27
+ let renderer: GraphRenderer | null = null;
28
+
29
+ const resolvedSettings = $derived(mergeGraphSettings(settings));
30
+ const preferredCenterNodeId = $derived(
31
+ focusedNodeId ?? data.centerNodeId ?? null,
32
+ );
33
+
34
+ onMount(() => {
35
+ if (!surfaceEl) {
36
+ return;
37
+ }
38
+
39
+ renderer = new GraphRenderer(surfaceEl, {
40
+ onNodeClick: (node, event) => {
41
+ onNodeClick(node, event);
42
+ },
43
+ onNodeContextMenu: (node, event) => {
44
+ onNodeContextMenu(node, event);
45
+ },
46
+ });
47
+
48
+ renderer.setGraph(data, resolvedSettings);
49
+ renderer.focusNode(preferredCenterNodeId);
50
+
51
+ return () => {
52
+ renderer?.destroy();
53
+ renderer = null;
54
+ };
55
+ });
56
+
57
+ $effect(() => {
58
+ if (!renderer) {
59
+ return;
60
+ }
61
+
62
+ renderer.setGraph(data, resolvedSettings);
63
+ });
64
+
65
+ $effect(() => {
66
+ if (!renderer) {
67
+ return;
68
+ }
69
+
70
+ renderer.focusNode(preferredCenterNodeId);
71
+ });
72
+
73
+ function mergeClassName(
74
+ baseClassName: string,
75
+ extraClassName: string,
76
+ ): string {
77
+ return `${baseClassName} ${extraClassName}`.trim();
78
+ }
79
+ </script>
80
+
81
+ <div
82
+ class={mergeClassName("graph-view", className)}
83
+ data-graph-embed
84
+ data-ui-component="graph"
85
+ data-ui-part="embed"
86
+ >
87
+ <div
88
+ class="graph-view__surface"
89
+ data-ui-part="surface"
90
+ bind:this={surfaceEl}
91
+ ></div>
92
+ </div>
@@ -0,0 +1,12 @@
1
+ import type { GraphData, GraphNode, GraphSettings } from "./graph-types";
2
+ type Props = {
3
+ data: GraphData;
4
+ settings?: Partial<GraphSettings> | GraphSettings | null;
5
+ focusedNodeId?: string | null;
6
+ class?: string;
7
+ onNodeClick?: (node: GraphNode, event: MouseEvent) => void;
8
+ onNodeContextMenu?: (node: GraphNode, event: MouseEvent) => void;
9
+ };
10
+ declare const GraphEmbed: import("svelte").Component<Props, {}, "">;
11
+ type GraphEmbed = ReturnType<typeof GraphEmbed>;
12
+ export default GraphEmbed;
@@ -0,0 +1,115 @@
1
+ <script lang="ts">
2
+ import { TFile, type App } from "@lapis-notes/api";
3
+ import { FileEmbed } from "@lapis-notes/markdown/embed";
4
+ import * as Popover from "@lapismd/design-core/shadcn/popover";
5
+ import * as ScrollArea from "@lapismd/design-core/shadcn/scroll-area";
6
+ import { onDestroy } from "svelte";
7
+ import type { GraphNodePreview, GraphPreviewAnchor } from "./graph-renderer";
8
+
9
+ let {
10
+ app,
11
+ preview,
12
+ closeDelayMs,
13
+ onOpenFile,
14
+ onDismiss,
15
+ }: {
16
+ app: App;
17
+ preview: GraphNodePreview | null;
18
+ closeDelayMs: number;
19
+ onOpenFile: (preview: GraphNodePreview, event: MouseEvent) => void;
20
+ onDismiss: () => void;
21
+ } = $props();
22
+
23
+ let visiblePreview = $state<GraphNodePreview | null>(null);
24
+ let previewAnchor = $state<GraphPreviewAnchor | null>(null);
25
+ let closeTimer: ReturnType<typeof setTimeout> | null = null;
26
+ let pointerInside = false;
27
+ const file = $derived.by(() => {
28
+ const path = visiblePreview?.node.path;
29
+ const resolved = path ? app.vault.getFileByPath(path) : null;
30
+ return resolved instanceof TFile ? resolved : null;
31
+ });
32
+
33
+ $effect(() => {
34
+ if (preview) {
35
+ cancelClose();
36
+ previewAnchor = preview.anchor;
37
+ visiblePreview = preview;
38
+ return;
39
+ }
40
+ scheduleClose();
41
+ });
42
+
43
+ onDestroy(cancelClose);
44
+
45
+ function cancelClose(): void {
46
+ if (closeTimer !== null) clearTimeout(closeTimer);
47
+ closeTimer = null;
48
+ }
49
+
50
+ function scheduleClose(): void {
51
+ cancelClose();
52
+ if (pointerInside) return;
53
+ const delay = Math.max(0, closeDelayMs);
54
+ if (delay === 0) {
55
+ visiblePreview = null;
56
+ return;
57
+ }
58
+ closeTimer = setTimeout(() => {
59
+ closeTimer = null;
60
+ visiblePreview = null;
61
+ }, delay);
62
+ }
63
+
64
+ function closeImmediately(): void {
65
+ cancelClose();
66
+ pointerInside = false;
67
+ visiblePreview = null;
68
+ onDismiss();
69
+ }
70
+
71
+ function handlePointerEnter(): void {
72
+ pointerInside = true;
73
+ cancelClose();
74
+ }
75
+
76
+ function handlePointerLeave(): void {
77
+ pointerInside = false;
78
+ scheduleClose();
79
+ }
80
+ </script>
81
+
82
+ <Popover.Root
83
+ open={visiblePreview !== null && file !== null}
84
+ onOpenChange={(open) => {
85
+ if (!open && visiblePreview) closeImmediately();
86
+ }}
87
+ >
88
+ {#if visiblePreview && file && previewAnchor}
89
+ <Popover.Content
90
+ class="graph-file-preview"
91
+ customAnchor={previewAnchor}
92
+ updatePositionStrategy="always"
93
+ side="right"
94
+ align="center"
95
+ sideOffset={8}
96
+ collisionPadding={12}
97
+ onOpenAutoFocus={(event) => event.preventDefault()}
98
+ onCloseAutoFocus={(event) => event.preventDefault()}
99
+ onpointerenter={handlePointerEnter}
100
+ onpointerleave={handlePointerLeave}
101
+ onkeydown={(event) => {
102
+ if (event.key === "Escape") closeImmediately();
103
+ }}
104
+ >
105
+ <ScrollArea.Root class="graph-file-preview__scroll">
106
+ <FileEmbed
107
+ {app}
108
+ {file}
109
+ frontmatterOpen={false}
110
+ onopen={(event) => onOpenFile(visiblePreview!, event)}
111
+ />
112
+ </ScrollArea.Root>
113
+ </Popover.Content>
114
+ {/if}
115
+ </Popover.Root>