diff --git a/package-lock.json b/package-lock.json index 4a1c42e..b6b66d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "joplin-plugin-note-graph", "version": "1.0.0", "license": "MIT", + "dependencies": { + "cytoscape": "^3.34.0", + "cytoscape-fcose": "^2.2.0" + }, "devDependencies": { "@types/jest": "^29.5.14", "@types/node": "^18.19.130", @@ -2117,6 +2121,15 @@ "webpack": "^5.1.0" } }, + "node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -2154,6 +2167,27 @@ "node": ">= 8" } }, + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4056,6 +4090,12 @@ "node": ">=6" } }, + "node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/package.json b/package.json index 73c01dd..2331564 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "joplin-plugin-note-graph", "version": "1.0.0", "scripts": { - "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive", + "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --config webview.webpack.config.js && webpack --env joplin-plugin-config=createArchive", + "build:webview": "webpack --config webview.webpack.config.js", "prepare": "npm run dist", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "updateVersion": "webpack --env joplin-plugin-config=updateVersion", @@ -34,5 +35,9 @@ "typescript": "^4.8.2", "webpack": "^5.74.0", "webpack-cli": "^4.10.0" + }, + "dependencies": { + "cytoscape": "^3.34.0", + "cytoscape-fcose": "^2.2.0" } } diff --git a/src/index.ts b/src/index.ts index 11f2474..64db6f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ import joplin from 'api'; import { MenuItemLocation } from 'api/types'; -import { initializeAiNoteGraphPanel, showAiNoteGraphPanel } from './ui/webview'; +import { initializeAiNoteGraphPanel, showAiNoteGraphPanel, postGraphData } from './ui/webview'; import { NoteRepository } from './data/NoteRepository'; import { NotePreprocessor } from './data/NotePreprocessor'; import { Note } from './data/Types'; +import { GraphBuilder } from './services/graph/GraphBuilder'; const SHOW_NOTE_GRAPH_COMMAND = 'showNoteGraph'; const SHOW_NOTE_GRAPH_MENU_ITEM = 'showNoteGraphMenuItem'; @@ -24,6 +25,9 @@ const noteGraphCommand = { try { const enrichedNotes = await loadNotes(); console.info(`Loaded ${enrichedNotes.length} notes.`); + const builder = new GraphBuilder(); + const graphData = builder.build(enrichedNotes); + await postGraphData(graphData); await showAiNoteGraphPanel(); } catch (error) { console.error('Failed to load note graph:', error); diff --git a/src/services/graph/GraphBuilder.test.ts b/src/services/graph/GraphBuilder.test.ts new file mode 100644 index 0000000..e87317a --- /dev/null +++ b/src/services/graph/GraphBuilder.test.ts @@ -0,0 +1,125 @@ +import { GraphBuilder } from './GraphBuilder'; +import { EdgeFactory } from '../similarity/EdgeFactory'; +import { Note } from '../../data/Types'; + +jest.mock('../similarity/EdgeFactory'); + +const MockEdgeFactory = EdgeFactory as jest.MockedClass; + +function note( + id: string, + title: string, + links: string[] = [] +): Note { + return { + id, + parent_id: 'p1', + title, + body: '', + created_time: 0, + updated_time: 1, + links, + tags: [], + }; +} + +describe('GraphBuilder', () => { + let builder: GraphBuilder; + let mockEdgeFactory: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockEdgeFactory = new MockEdgeFactory() as jest.Mocked; + builder = new GraphBuilder(mockEdgeFactory); + }); + + it('creates nodes with degree 0 when no edges', () => { + mockEdgeFactory.createEdges.mockReturnValue([]); + + const notes = [note('a', 'Note A'), note('b', 'Note B')]; + + const result = builder.build(notes); + expect(result.nodes).toHaveLength(2); + expect(result.nodes[0].data).toMatchObject({ + id: 'a', + label: 'Note A', + noteId: 'a', + degree: 0, + }); + expect(result.nodes[1].data).toMatchObject({ + id: 'b', + label: 'Note B', + noteId: 'b', + degree: 0, + }); + }); + + it('computes degree from edges', () => { + mockEdgeFactory.createEdges.mockReturnValue([ + { source: 'a', target: 'b', type: 'link' }, + ]); + + const notes = [note('a', 'A'), note('b', 'B')]; + + const result = builder.build(notes); + expect(result.nodes[0].data.degree).toBe(1); + expect(result.nodes[1].data.degree).toBe(1); + expect(result.edges).toHaveLength(1); + expect(result.edges[0].data).toEqual({ source: 'a', target: 'b', type: 'link' }); + }); + + it('truncates long node labels to 64 chars', () => { + mockEdgeFactory.createEdges.mockReturnValue([]); + + const longTitle = 'A'.repeat(100); + const notes = [note('a', longTitle)]; + + const result = builder.build(notes); + const label = result.nodes[0].data.label; + expect(label).toHaveLength(64); + expect(label.endsWith('...')).toBe(true); + }); + + it('uses (untitled) for empty titles', () => { + mockEdgeFactory.createEdges.mockReturnValue([]); + + const notes = [note('a', '')]; + + const result = builder.build(notes); + expect(result.nodes[0].data.label).toBe('(untitled)'); + }); + + it('filters edges to non-existent nodes', () => { + mockEdgeFactory.createEdges.mockReturnValue([ + { source: 'a', target: 'missing', type: 'link' }, + { source: 'a', target: 'b', type: 'link' }, + ]); + + const notes = [note('a', 'A'), note('b', 'B')]; + + const result = builder.build(notes); + expect(result.edges).toHaveLength(1); + expect(result.edges[0].data).toEqual({ source: 'a', target: 'b', type: 'link' }); + }); + + it('computes correct degree for hub node', () => { + mockEdgeFactory.createEdges.mockReturnValue([ + { source: 'a', target: 'b', type: 'link' }, + { source: 'a', target: 'c', type: 'link' }, + { source: 'c', target: 'd', type: 'tag' }, + ]); + + const notes = [ + note('a', 'A'), + note('b', 'B'), + note('c', 'C'), + note('d', 'D'), + ]; + + const result = builder.build(notes); + const nodeA = result.nodes.find((n) => n.data.id === 'a'); + const nodeC = result.nodes.find((n) => n.data.id === 'c'); + expect(nodeA?.data.degree).toBe(2); + expect(nodeC?.data.degree).toBe(2); + }); +}); diff --git a/src/services/graph/GraphBuilder.ts b/src/services/graph/GraphBuilder.ts index e69de29..a742164 100644 --- a/src/services/graph/GraphBuilder.ts +++ b/src/services/graph/GraphBuilder.ts @@ -0,0 +1,63 @@ +import { Note } from '../../data/Types'; +import { EdgeFactory } from '../similarity/EdgeFactory'; +import { GraphData, GraphNode } from './types'; + +export class GraphBuilder { + private readonly edgeFactory: EdgeFactory; + + public constructor(edgeFactory = new EdgeFactory()) { + this.edgeFactory = edgeFactory; + } + + public build(notes: Note[]): GraphData { + const degreeMap = new Map(); + for (const note of notes) { + degreeMap.set(note.id, 0); + } + + const edges = this.edgeFactory.createEdges(notes); + + for (const edge of edges) { + degreeMap.set(edge.source, (degreeMap.get(edge.source) ?? 0) + 1); + degreeMap.set(edge.target, (degreeMap.get(edge.target) ?? 0) + 1); + } + + const maxDegree = Math.max(1, ...degreeMap.values()); + + const nodes: Array<{ data: GraphNode }> = []; + for (const note of notes) { + const degree = degreeMap.get(note.id) ?? 0; + const label = note.title || '(untitled)'; + nodes.push({ + data: { + id: note.id, + label: + label.length > 64 + ? label.substring(0, 61) + '...' + : label, + noteId: note.id, + degree, + }, + }); + } + + const nodeIdSet = new Set(nodes.map((n) => n.data.id)); + const visibleEdges = edges.filter( + (e) => nodeIdSet.has(e.source) && nodeIdSet.has(e.target) + ); + + const visibleEdgeIds = new Set(); + for (const edge of visibleEdges) { + visibleEdgeIds.add(edge.source); + visibleEdgeIds.add(edge.target); + } + const isolatedCount = nodes.length - visibleEdgeIds.size; + + console.info( + `Graph built: ${nodes.length} nodes, ${visibleEdges.length} edges ` + + `(${isolatedCount} isolated, max degree ${maxDegree})` + ); + + return { nodes, edges: visibleEdges.map((e) => ({ data: e })) }; + } +} diff --git a/src/services/graph/types.ts b/src/services/graph/types.ts index e69de29..be7e59e 100644 --- a/src/services/graph/types.ts +++ b/src/services/graph/types.ts @@ -0,0 +1,19 @@ +export type EdgeType = 'link' | 'tag'; + +export interface GraphNode { + id: string; + label: string; + noteId: string; + degree: number; +} + +export interface GraphEdge { + source: string; + target: string; + type: EdgeType; +} + +export interface GraphData { + nodes: Array<{ data: GraphNode }>; + edges: Array<{ data: GraphEdge }>; +} diff --git a/src/services/similarity/EdgeFactory.test.ts b/src/services/similarity/EdgeFactory.test.ts new file mode 100644 index 0000000..bdc6957 --- /dev/null +++ b/src/services/similarity/EdgeFactory.test.ts @@ -0,0 +1,111 @@ +import { EdgeFactory } from './EdgeFactory'; +import { Note } from '../../data/Types'; + +function note( + id: string, + title: string, + links: string[] = [], + tags: string[] = [] +): Note { + return { + id, + parent_id: 'p1', + title, + body: '', + created_time: 0, + updated_time: 1, + links, + tags, + }; +} + +describe('EdgeFactory', () => { + let factory: EdgeFactory; + + beforeEach(() => { + factory = new EdgeFactory(); + }); + + it('returns empty array for empty notes', () => { + expect(factory.createEdges([])).toEqual([]); + }); + + it('returns empty array for notes with no links or tags', () => { + const notes = [note('a', 'A'), note('b', 'B')]; + expect(factory.createEdges(notes)).toEqual([]); + }); + + it('returns empty for resource links that are not note IDs', () => { + const notes = [ + note('a', 'A', ['resource123']), + note('b', 'B', ['resource123']), + ]; + + expect(factory.createEdges(notes)).toEqual([]); + }); + + it('creates link edge when note body references another note ID', () => { + const notes = [ + note('a', 'A', ['b']), + note('b', 'B', []), + ]; + + const edges = factory.createEdges(notes); + expect(edges).toEqual([{ source: 'a', target: 'b', type: 'link' }]); + }); + + it('creates bidirectional link when notes reference each other', () => { + const notes = [ + note('a', 'A', ['b']), + note('b', 'B', ['a']), + ]; + + const edges = factory.createEdges(notes); + expect(edges).toHaveLength(1); + expect(edges).toContainEqual({ source: 'a', target: 'b', type: 'link' }); + }); + + it('creates tag edges for shared tags', () => { + const notes = [ + note('a', 'A', [], ['shared']), + note('b', 'B', [], ['shared']), + ]; + + expect(factory.createEdges(notes)).toEqual([ + { source: 'a', target: 'b', type: 'tag' }, + ]); + }); + + it('creates many tag edges for multiple shared tags', () => { + const notes = [ + note('a', 'A', [], ['t1', 't2']), + note('b', 'B', [], ['t1', 't2']), + note('c', 'C', [], ['t1']), + ]; + + const edges = factory.createEdges(notes); + expect(edges).toContainEqual({ source: 'a', target: 'b', type: 'tag' }); + expect(edges).toContainEqual({ source: 'a', target: 'c', type: 'tag' }); + expect(edges).toContainEqual({ source: 'b', target: 'c', type: 'tag' }); + }); + + it('creates both link and tag edges for the same pair', () => { + const notes = [ + note('a', 'A', ['b'], ['shared']), + note('b', 'B', [], ['shared']), + ]; + + const edges = factory.createEdges(notes); + expect(edges).toContainEqual({ source: 'a', target: 'b', type: 'link' }); + expect(edges).toContainEqual({ source: 'a', target: 'b', type: 'tag' }); + expect(edges).toHaveLength(2); + }); + + it('ignores self-referencing links', () => { + const notes = [ + note('a', 'A', ['a']), + ]; + + expect(factory.createEdges(notes)).toEqual([]); + }); +}); diff --git a/src/services/similarity/EdgeFactory.ts b/src/services/similarity/EdgeFactory.ts index e69de29..62267f5 100644 --- a/src/services/similarity/EdgeFactory.ts +++ b/src/services/similarity/EdgeFactory.ts @@ -0,0 +1,49 @@ +import { Note } from '../../data/Types'; +import { GraphEdge, EdgeType } from '../graph/types'; + +export class EdgeFactory { + public createEdges(notes: Note[]): GraphEdge[] { + const noteIdSet = new Set(notes.map((n) => n.id)); + const edgeSet = new Set(); + const edges: GraphEdge[] = []; + + const addEdge = (source: string, target: string, type: EdgeType) => { + const key = + source < target + ? `${source}::${target}::${type}` + : `${target}::${source}::${type}`; + if (!edgeSet.has(key)) { + edgeSet.add(key); + edges.push({ source, target, type }); + } + }; + + for (const note of notes) { + for (const link of note.links ?? []) { + if (noteIdSet.has(link) && link !== note.id) { + addEdge(note.id, link, 'link'); + } + } + } + + const tagToNotes = new Map(); + for (const note of notes) { + for (const tag of note.tags ?? []) { + if (!tagToNotes.has(tag)) { + tagToNotes.set(tag, []); + } + tagToNotes.get(tag)!.push(note.id); + } + } + + for (const [, noteIds] of tagToNotes) { + for (let i = 0; i < noteIds.length; i++) { + for (let j = i + 1; j < noteIds.length; j++) { + addEdge(noteIds[i], noteIds[j], 'tag'); + } + } + } + + return edges; + } +} diff --git a/src/ui/App.ts b/src/ui/App.ts index 50b4d0e..e4b841a 100644 --- a/src/ui/App.ts +++ b/src/ui/App.ts @@ -4,6 +4,9 @@ const renderPanelHtml = (): string => { return `
${renderHeader()} +
+ Loading graph... +
`; }; diff --git a/src/ui/graph-view.js b/src/ui/graph-view.js new file mode 100644 index 0000000..d7db8c2 --- /dev/null +++ b/src/ui/graph-view.js @@ -0,0 +1,245 @@ +import cytoscape from 'cytoscape'; +import fcose from 'cytoscape-fcose'; + +cytoscape.use(fcose); + +var FCOSE_OPTIONS = { + name: 'fcose', + quality: 'default', + randomize: true, + animate: true, + animationDuration: 800, + fit: true, + padding: 40, + nodeDimensionsIncludeLabels: false, + uniformNodeDimensions: true, + packComponents: true, + nodeSeparation: 140, + nodeRepulsion: function () { return 8000; }, + gravity: 0.12, + gravityRange: 5.0, + idealEdgeLength: 180, + edgeElasticity: 0.2, + numIter: 3000, + tile: true, + tilingPaddingVertical: 25, + tilingPaddingHorizontal: 25, + step: 'all', +}; + +var cy; +var statusEl; +var pollTimer; + +function showStatus(text) { + if (statusEl) { + statusEl.textContent = text; + statusEl.style.display = ''; + } +} + +function hideStatus() { + if (statusEl) { + statusEl.style.display = 'none'; + } +} + +function isDarkTheme() { + var bg = getComputedStyle(document.body).getPropertyValue('--joplin-background-color').trim(); + if (!bg) return false; + + var r = parseInt(bg.slice(1, 3), 16); + var g = parseInt(bg.slice(3, 5), 16); + var b = parseInt(bg.slice(5, 7), 16); + var lum = 0.299 * r + 0.587 * g + 0.114 * b; + return lum < 128; +} + +function buildStylesheet() { + var dark = isDarkTheme(); + + return [ + { + selector: 'node', + style: { + 'background-color': dark ? '#5b9bd5' : '#5b9bd5', + label: 'data(label)', + color: dark ? '#ddd' : '#222', + 'font-size': '9px', + 'text-valign': 'top', + 'text-halign': 'center', + 'text-margin-y': -4, + 'text-wrap': 'ellipsis', + 'text-max-width': '100px', + width: 28, + height: 28, + 'border-width': 0, + }, + }, + { + selector: 'node:selected', + style: { + 'background-color': '#ffa500', + 'border-width': 0, + }, + }, + { + selector: 'edge', + style: { + width: function (ele) { + return ele.data('type') === 'tag' ? 1 : 2; + }, + 'line-color': function (ele) { + return ele.data('type') === 'tag' + ? dark ? '#666' : '#b0b0b0' + : dark ? '#999' : '#555'; + }, + 'curve-style': 'bezier', + 'line-style': function (ele) { + return ele.data('type') === 'tag' ? 'dotted' : 'solid'; + }, + 'target-arrow-shape': function (ele) { + return ele.data('type') === 'tag' ? 'none' : 'triangle'; + }, + 'target-arrow-color': dark ? '#999' : '#555', + 'arrow-scale': 0.8, + }, + }, + ]; +} + +function onNodeTap(evt) { + var node = evt.target; + if (typeof webviewApi !== 'undefined') { + webviewApi.postMessage({ + type: 'node-clicked', + nodeId: node.id(), + nodeLabel: node.data('label'), + }); + } +} + +function onNodeDblClick(evt) { + var node = evt.target; + cy.animate({ + fit: { eles: node, padding: 40 }, + center: { eles: node }, + duration: 400, + }); +} + +function renderGraph(message) { + if (!message || !message.nodes || !message.nodes.length) { + showStatus('No graph data received'); + return; + } + + var nodeCount = message.nodes.length; + var edgeCount = (message.edges || []).length; + + if (edgeCount === 0) { + showStatus(nodeCount + ' notes loaded, 0 connections found'); + return; + } + + hideStatus(); + + cy.elements().remove(); + cy.add(message.nodes); + cy.add(message.edges); + + cy.layout(FCOSE_OPTIONS).run(); +} + +function pollForData() { + if (typeof webviewApi === 'undefined') { + return; + } + + pollTimer = setInterval(function () { + webviewApi.postMessage({ type: 'request-data' }).then(function (response) { + if (response && response.type === 'graph-data') { + clearInterval(pollTimer); + renderGraph(response); + } + }); + }, 1000); +} + +function init() { + var container = document.getElementById('graph-container'); + if (!container) { + return; + } + + var header = document.querySelector('.panel-header'); + var headerH = header ? header.offsetHeight : 0; + container.style.height = (window.innerHeight - headerH) + 'px'; + container.style.minHeight = '350px'; + container.style.width = '100%'; + + document.body.style.margin = '0'; + document.body.style.padding = '0'; + document.body.style.height = window.innerHeight + 'px'; + + statusEl = document.getElementById('graph-status'); + if (statusEl) { + statusEl.style.display = ''; + } + + try { + cy = cytoscape({ + container: container, + style: buildStylesheet(), + elements: [], + wheelSensitivity: 0.3, + }); + + cy.on('tap', 'node', onNodeTap); + cy.on('dblclick', 'node', onNodeDblClick); + + cy.on('tap', function (evt) { + if (evt.target === cy) { + cy.elements().unselect(); + } + }); + + var observer = new ResizeObserver(function () { + var h = header ? header.offsetHeight : 0; + container.style.height = (window.innerHeight - h) + 'px'; + cy.resize(); + cy.fit(undefined, 30); + }); + observer.observe(container); + observer.observe(document.body); + + var lastBg = getComputedStyle(document.body).getPropertyValue('--joplin-background-color').trim(); + var themeObserver = new MutationObserver(function () { + var currentBg = getComputedStyle(document.body).getPropertyValue('--joplin-background-color').trim(); + if (currentBg !== lastBg) { + lastBg = currentBg; + cy.style().fromJson(buildStylesheet()).update(); + } + }); + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] }); + + showStatus('Graph engine ready: waiting for data...'); + pollForData(); + + if (typeof webviewApi !== 'undefined') { + webviewApi.onMessage(function (message) { + if (message && message.type === 'fit-to-screen') { + cy.fit(undefined, 30); + } + }); + } + } catch (e) { + showStatus('Error: ' + (e && e.message ? e.message : String(e))); + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/src/ui/styles/panel.css b/src/ui/styles/panel.css index 09c4018..46eeb9f 100644 --- a/src/ui/styles/panel.css +++ b/src/ui/styles/panel.css @@ -1,34 +1,31 @@ html, -body, -#app { - height: 100%; +body { margin: 0; padding: 0; + overflow: hidden; } -:root, body { background-color: var(--joplin-background-color); color: var(--joplin-color); - margin: 0; - padding: 0; } .panel-root { display: flex; flex-direction: column; - min-height: 100%; + height: 100vh; width: 100%; } .panel-header { - border-bottom: 1px solid var(--joplin-color-faded, rgb(86, 85, 85)); + border-bottom: 1px solid var(--joplin-color-faded, #565959); align-items: center; display: flex; justify-content: space-between; - padding: 10px 12px 9px; + padding: 8px 12px; width: 100%; box-sizing: border-box; + flex-shrink: 0; } .panel-header__title { @@ -54,6 +51,24 @@ body { vertical-align: middle; display: inline-block; } + .graph-panel_close svg path { fill: currentColor; } + +#graph-container { + flex: 1 1 auto; + width: 100%; + position: relative; + overflow: hidden; +} + +#graph-status { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--joplin-color-faded, #888); + font-size: 13px; + z-index: 1; +} diff --git a/src/ui/webview.ts b/src/ui/webview.ts index ee67331..8ae9176 100644 --- a/src/ui/webview.ts +++ b/src/ui/webview.ts @@ -1,23 +1,39 @@ import joplin from 'api'; import { ViewHandle } from 'api/types'; import { renderPanelHtml } from './App'; +import { GraphData } from '../services/graph/types'; const PANEL_ID = 'aiNoteGraphPanel'; const PANEL_HTML = renderPanelHtml(); -const PANEL_SCRIPTS = ['./ui/styles/panel.css', './ui/setup.js']; +const PANEL_SCRIPTS = ['./ui/styles/panel.css', './ui/setup.js', './ui/graph-view.js']; let panelHandle: ViewHandle; +let currentGraphData: GraphData | null = null; const createPanel = async (): Promise => { const handle = await joplin.views.panels.create(PANEL_ID); await joplin.views.panels.setHtml(handle, PANEL_HTML); await joplin.views.panels.onMessage( handle, - async (message: { type?: string }) => { + async (message: { type?: string; nodeId?: string; nodeLabel?: string }) => { if (message?.type === 'close-note-graph') { await joplin.views.panels.hide(handle); return { done: true }; } + if (message?.type === 'request-data') { + if (currentGraphData) { + return { type: 'graph-data', ...currentGraphData }; + } + return { type: 'no-data' }; + } + if (message?.type === 'node-clicked' && message?.nodeId) { + try { + await joplin.commands.execute('openNote', message.nodeId); + } catch { + await joplin.commands.execute('openItem', message.nodeId); + } + return { done: true }; + } } ); @@ -47,3 +63,7 @@ export const showAiNoteGraphPanel = async (): Promise => { const handle = getPanel(); await joplin.views.panels.show(handle); }; + +export const postGraphData = async (graphData: GraphData): Promise => { + currentGraphData = graphData; +}; diff --git a/webview.webpack.config.js b/webview.webpack.config.js new file mode 100644 index 0000000..55fcfc6 --- /dev/null +++ b/webview.webpack.config.js @@ -0,0 +1,14 @@ +const path = require('path'); + +module.exports = { + mode: 'production', + target: 'web', + entry: './src/ui/graph-view.js', + output: { + filename: 'ui/graph-view.js', + path: path.resolve(__dirname, 'dist'), + }, + resolve: { + extensions: ['.js'], + }, +};