new plug: graph view (imported from separate repo)
This commit is contained in:
@@ -62,6 +62,7 @@ clean:
|
||||
rm -f silverbullet silverbullet-arm64 silverbullet-amd64 silverbullet-arm silverbullet.exe silverbullet-server-*.zip
|
||||
rm -f sb sb.exe sb-*.zip
|
||||
rm -rf plugs/configuration-manager/assets
|
||||
rm -rf plugs/object-graph/assets
|
||||
|
||||
check:
|
||||
npm run check
|
||||
|
||||
Generated
+359
@@ -34,9 +34,11 @@
|
||||
"@replit/codemirror-vim": "6.3.0",
|
||||
"commander": "^14.0.3",
|
||||
"crelt": "1.0.6",
|
||||
"d3-force": "^3.0.0",
|
||||
"esbuild": "^0.27.3",
|
||||
"fast-diff": "1.3.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"force-graph": "^1.49.0",
|
||||
"gitignore-parser": "0.0.2",
|
||||
"idb": "8.0.3",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -55,6 +57,7 @@
|
||||
"@biomejs/biome": "2.4.6",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@preact/preset-vite": "^2.10.3",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/gitignore-parser": "^0.0.3",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.0.0",
|
||||
@@ -2102,6 +2105,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "25.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
|
||||
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -2113,6 +2122,13 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-force": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -2279,6 +2295,15 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/accessor-fn": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
|
||||
"integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
@@ -2315,6 +2340,16 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bezier-js": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz",
|
||||
"integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
@@ -2389,6 +2424,18 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvas-color-tracker": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz",
|
||||
"integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinycolor2": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -2466,6 +2513,236 @@
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-binarytree": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
|
||||
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force-3d": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
|
||||
"integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-binarytree": "1",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-octree": "1",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-octree": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
|
||||
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2719,6 +2996,46 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/float-tooltip": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz",
|
||||
"integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-selection": "2 - 3",
|
||||
"kapsule": "^1.16",
|
||||
"preact": "10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/force-graph": {
|
||||
"version": "1.51.4",
|
||||
"resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz",
|
||||
"integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tweenjs/tween.js": "18 - 25",
|
||||
"accessor-fn": "1",
|
||||
"bezier-js": "3 - 6",
|
||||
"canvas-color-tracker": "^1.3",
|
||||
"d3-array": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-force-3d": "2 - 3",
|
||||
"d3-scale": "1 - 4",
|
||||
"d3-scale-chromatic": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-zoom": "2 - 3",
|
||||
"float-tooltip": "^1.7",
|
||||
"index-array-by": "1",
|
||||
"kapsule": "^1.16",
|
||||
"lodash-es": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2799,6 +3116,24 @@
|
||||
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/index-array-by": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz",
|
||||
"integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -2874,6 +3209,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/kapsule": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz",
|
||||
"integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/kolorist": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
|
||||
@@ -2881,6 +3228,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -3396,6 +3749,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinycolor2": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
|
||||
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
|
||||
@@ -96,8 +96,10 @@
|
||||
"@replit/codemirror-vim": "6.3.0",
|
||||
"commander": "^14.0.3",
|
||||
"crelt": "1.0.6",
|
||||
"d3-force": "^3.0.0",
|
||||
"esbuild": "^0.27.3",
|
||||
"fast-diff": "1.3.0",
|
||||
"force-graph": "^1.49.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"gitignore-parser": "0.0.2",
|
||||
"idb": "8.0.3",
|
||||
@@ -114,6 +116,7 @@
|
||||
"@biomejs/biome": "2.4.6",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@preact/preset-vite": "^2.10.3",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/gitignore-parser": "^0.0.3",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -7,6 +7,7 @@ export const builtinPlugNames = [
|
||||
"emoji",
|
||||
"image-viewer",
|
||||
"configuration-manager",
|
||||
"object-graph",
|
||||
];
|
||||
|
||||
export const builtinPlugPaths = builtinPlugNames.map(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
assets/
|
||||
@@ -0,0 +1,22 @@
|
||||
name: object-graph
|
||||
build:
|
||||
- in: ./ui/index.tsx
|
||||
out: ./assets/object-graph.js
|
||||
- type: sass
|
||||
in: ./ui/object-graph.scss
|
||||
out: ./assets/object-graph.css
|
||||
assets:
|
||||
- "assets/**"
|
||||
functions:
|
||||
showGraph:
|
||||
path: ./src/object_graph.ts:showGraph
|
||||
command:
|
||||
name: "Graph: Explore"
|
||||
key: "Ctrl-Shift-g"
|
||||
mac: "Cmd-Shift-g"
|
||||
showGlobalGraph:
|
||||
path: ./src/object_graph.ts:showGlobalGraph
|
||||
command:
|
||||
name: "Graph: Global Page Map"
|
||||
expandObject:
|
||||
path: ./src/object_graph.ts:expandObject
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyKind,
|
||||
collapseEdges,
|
||||
deriveTagFields,
|
||||
endpointMatchesRef,
|
||||
isCoreTag,
|
||||
type RelationRow,
|
||||
stripPagePos,
|
||||
} from "./graph_builder.ts";
|
||||
|
||||
describe("stripPagePos", () => {
|
||||
it("returns bare refs unchanged", () => {
|
||||
expect(stripPagePos("Page")).toBe("Page");
|
||||
expect(stripPagePos("Drivers/Max Verstappen")).toBe(
|
||||
"Drivers/Max Verstappen",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips @pos, #header, $anchor", () => {
|
||||
expect(stripPagePos("Page@744")).toBe("Page");
|
||||
expect(stripPagePos("Page#header")).toBe("Page");
|
||||
expect(stripPagePos("Page$anchor")).toBe("Page");
|
||||
});
|
||||
|
||||
it("leaves url and file refs untouched", () => {
|
||||
expect(stripPagePos("https://example.com/path#x")).toBe(
|
||||
"https://example.com/path#x",
|
||||
);
|
||||
expect(stripPagePos("docs.pdf")).toBe("docs.pdf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("endpointMatchesRef", () => {
|
||||
it("matches exact refs", () => {
|
||||
expect(endpointMatchesRef("Page", "Page")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches positional / header / anchor variants", () => {
|
||||
expect(endpointMatchesRef("Page@123", "Page")).toBe(true);
|
||||
expect(endpointMatchesRef("Page#h1", "Page")).toBe(true);
|
||||
expect(endpointMatchesRef("Page$anchor", "Page")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects prefix overlap that isn't a real boundary", () => {
|
||||
expect(endpointMatchesRef("Pages", "Page")).toBe(false);
|
||||
expect(endpointMatchesRef("Page/sub", "Page")).toBe(false);
|
||||
expect(endpointMatchesRef("Page-x", "Page")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unrelated endpoints", () => {
|
||||
expect(endpointMatchesRef("Other", "Page")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyKind", () => {
|
||||
it("identifies URLs", () => {
|
||||
expect(classifyKind("https://example.com")).toBe("url");
|
||||
expect(classifyKind("http://localhost:3000")).toBe("url");
|
||||
});
|
||||
|
||||
it("identifies files (single dot, non-md)", () => {
|
||||
expect(classifyKind("doc.pdf")).toBe("file");
|
||||
expect(classifyKind("notes.txt")).toBe("file");
|
||||
});
|
||||
|
||||
it("treats .md and pathy refs as pages", () => {
|
||||
expect(classifyKind("Notes")).toBe("page");
|
||||
expect(classifyKind("Notes/2025")).toBe("page");
|
||||
expect(classifyKind("Notes.md")).toBe("page");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCoreTag", () => {
|
||||
it("recognises structural tags", () => {
|
||||
expect(isCoreTag("page")).toBe(true);
|
||||
expect(isCoreTag("item")).toBe(true);
|
||||
expect(isCoreTag("task")).toBe(true);
|
||||
expect(isCoreTag("block")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognises meta and meta/* tags", () => {
|
||||
expect(isCoreTag("meta")).toBe(true);
|
||||
expect(isCoreTag("meta/library")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects user tags", () => {
|
||||
expect(isCoreTag("driver")).toBe(false);
|
||||
expect(isCoreTag("project")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTagFields", () => {
|
||||
it("uses hostTag as rootTag for ordinary structural objects", () => {
|
||||
const r = deriveTagFields(["page", "driver"], "page");
|
||||
expect(r.rootTag).toBe("page");
|
||||
expect(r.primaryTag).toBe("driver");
|
||||
expect(r.tags).toEqual(["driver"]);
|
||||
});
|
||||
|
||||
it("promotes item to task when the task tag is present", () => {
|
||||
const r = deriveTagFields(["item", "task", "todo"], "item");
|
||||
expect(r.rootTag).toBe("task");
|
||||
expect(r.primaryTag).toBe("todo");
|
||||
});
|
||||
|
||||
it("filters core + meta tags out of the user-tag list", () => {
|
||||
const r = deriveTagFields(
|
||||
["page", "meta", "meta/template", "research"],
|
||||
"page",
|
||||
);
|
||||
expect(r.tags).toEqual(["research"]);
|
||||
expect(r.primaryTag).toBe("research");
|
||||
});
|
||||
|
||||
it("returns null rootTag for non-core host tags", () => {
|
||||
expect(deriveTagFields(["foo"], "foo").rootTag).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapseEdges", () => {
|
||||
const baseRow = (overrides: Partial<RelationRow>): RelationRow => ({
|
||||
from: "A",
|
||||
to: "B",
|
||||
kind: "mention",
|
||||
page: "A",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("passes through a single mention as-is", () => {
|
||||
const out = collapseEdges([baseRow({})]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].source).toBe("A");
|
||||
expect(out[0].target).toBe("B");
|
||||
expect(out[0].label).toBe("mention");
|
||||
expect(out[0].undirected).toBe(false);
|
||||
});
|
||||
|
||||
it("collapses reciprocal co-mention pairs into one undirected edge", () => {
|
||||
const rows: RelationRow[] = [
|
||||
baseRow({ kind: "co-mention", via: "page1" }),
|
||||
baseRow({
|
||||
from: "B",
|
||||
to: "A",
|
||||
kind: "co-mention",
|
||||
via: "page1",
|
||||
page: "page1",
|
||||
}),
|
||||
];
|
||||
const out = collapseEdges(rows);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].undirected).toBe(true);
|
||||
expect(out[0].refs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps distinct co-mention pairs separate when 'via' differs", () => {
|
||||
const rows: RelationRow[] = [
|
||||
baseRow({ kind: "co-mention", via: "p1" }),
|
||||
baseRow({
|
||||
from: "B",
|
||||
to: "A",
|
||||
kind: "co-mention",
|
||||
via: "p2",
|
||||
}),
|
||||
];
|
||||
expect(collapseEdges(rows)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("uses the relation kind as the edge label", () => {
|
||||
const out = collapseEdges([baseRow({ kind: "principal" })]);
|
||||
expect(out[0].label).toBe("principal");
|
||||
});
|
||||
|
||||
it("preserves provenance positions from the range tuple", () => {
|
||||
const out = collapseEdges([baseRow({ range: [42, 60], snippet: "hello" })]);
|
||||
expect(out[0].refs[0]).toEqual({ page: "A", pos: 42, snippet: "hello" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
import { index, lua } from "@silverbulletmd/silverbullet/syscalls";
|
||||
import type {
|
||||
Edge,
|
||||
EdgeProvenance,
|
||||
ExpansionResult,
|
||||
GraphUniverse,
|
||||
ObjectKind,
|
||||
ObjectNode,
|
||||
} from "./model.ts";
|
||||
|
||||
/**
|
||||
* Core tags published by SilverBullet's indexer. These are NEVER shown in
|
||||
* the "Tags" sidebar (which is reserved for user tags); they appear in the
|
||||
* "Root tags" sidebar instead, and a single one is stored as each node's
|
||||
* `rootTag`.
|
||||
*/
|
||||
const CORE_TAGS = new Set([
|
||||
"page",
|
||||
"item",
|
||||
"task",
|
||||
"header",
|
||||
"paragraph",
|
||||
"block",
|
||||
"meta",
|
||||
"aspiring-page",
|
||||
"document",
|
||||
"url",
|
||||
]);
|
||||
|
||||
export function isCoreTag(t: string): boolean {
|
||||
return CORE_TAGS.has(t) || t.startsWith("meta/");
|
||||
}
|
||||
|
||||
export type RelationRow = {
|
||||
from: string;
|
||||
to: string;
|
||||
fromTag?: string;
|
||||
toTag?: string;
|
||||
kind: string;
|
||||
via?: string;
|
||||
page: string;
|
||||
range?: [number, number];
|
||||
snippet?: string;
|
||||
};
|
||||
|
||||
type IndexedObject = {
|
||||
ref?: string;
|
||||
name?: string;
|
||||
tag?: string;
|
||||
tags?: string[];
|
||||
pageDecoration?: { prefix?: string };
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip positional / header / anchor suffixes from a page-style ref so a
|
||||
* link to `Page@744` (or `Page#header`, `Page$anchor`) lands on the bare
|
||||
* `Page` node. URL / file refs are untouched.
|
||||
*/
|
||||
export function stripPagePos(ref: string): string {
|
||||
if (classifyKind(ref) !== "page") return ref;
|
||||
return ref.split(/[@#$]/)[0] || ref;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph cache populated lazily on the first call and reused across every
|
||||
* subsequent `expandObject` / `buildGlobalGraph` round-trip the panel makes.
|
||||
* SilverBullet's `index.queryLuaObjects` isn't cheap; one bulk pull + in-memory filter is much faster overall than
|
||||
* a per-ref query for every expansion. The cache is cleared by
|
||||
* `invalidateGraphCache()` at the start of each top-level command so a
|
||||
* freshly opened modal sees fresh data.
|
||||
*/
|
||||
type GraphCache = {
|
||||
relations: RelationRow[];
|
||||
pagesByName: Map<string, IndexedObject>;
|
||||
};
|
||||
let cache: GraphCache | null = null;
|
||||
|
||||
export function invalidateGraphCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
async function ensureCache(): Promise<GraphCache> {
|
||||
if (cache) return cache;
|
||||
const [relations, pages] = (await Promise.all([
|
||||
index.queryLuaObjects<RelationRow>("relation", { objectVariable: "r" }),
|
||||
index.queryLuaObjects<IndexedObject>("page", { objectVariable: "p" }),
|
||||
])) as [RelationRow[], IndexedObject[]];
|
||||
const pagesByName = new Map<string, IndexedObject>();
|
||||
for (const p of pages) {
|
||||
const name = String(p.name ?? "");
|
||||
if (name) pagesByName.set(name, p);
|
||||
}
|
||||
cache = { relations, pagesByName };
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an edge endpoint against a bare ref, including positional /
|
||||
* header / anchor variants (`Page@123`, `Page#header`, `Page$anchor`).
|
||||
*/
|
||||
export function endpointMatchesRef(endpoint: string, ref: string): boolean {
|
||||
if (endpoint === ref) return true;
|
||||
if (!endpoint.startsWith(ref)) return false;
|
||||
const c = endpoint.charAt(ref.length);
|
||||
return c === "@" || c === "#" || c === "$";
|
||||
}
|
||||
|
||||
async function queryRelations(ref: string): Promise<RelationRow[]> {
|
||||
const { relations } = await ensureCache();
|
||||
return relations.filter(
|
||||
(r) => endpointMatchesRef(r.from, ref) || endpointMatchesRef(r.to, ref),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse co-mention pairs in opposite directions sharing the same `via`
|
||||
* into a single undirected edge. Other parallel edges (different `kind`)
|
||||
* remain distinct.
|
||||
*/
|
||||
export function collapseEdges(rows: RelationRow[]): Edge[] {
|
||||
const out: Edge[] = [];
|
||||
const seenCoMention = new Map<string, number>(); // key -> out index
|
||||
|
||||
for (const r of rows) {
|
||||
const label = r.kind;
|
||||
const prov: EdgeProvenance = {
|
||||
page: r.page,
|
||||
pos: r.range?.[0],
|
||||
snippet: r.snippet,
|
||||
};
|
||||
|
||||
if (r.kind === "co-mention") {
|
||||
const k = [r.from, r.to, r.via ?? ""].sort().join(" ");
|
||||
const existing = seenCoMention.get(k);
|
||||
if (existing !== undefined) {
|
||||
out[existing].refs.push(prov);
|
||||
out[existing].undirected = true;
|
||||
continue;
|
||||
}
|
||||
seenCoMention.set(k, out.length);
|
||||
out.push({
|
||||
source: r.from,
|
||||
target: r.to,
|
||||
label,
|
||||
kind: r.kind,
|
||||
refs: [prov],
|
||||
undirected: false, // becomes true when the reverse pair lands
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({
|
||||
source: r.from,
|
||||
target: r.to,
|
||||
label,
|
||||
kind: r.kind,
|
||||
refs: [prov],
|
||||
undirected: false,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a ref to an indexed object. Pages are served instantly from the
|
||||
* in-memory cache (the common case). Items and blocks fall back to a
|
||||
* single targeted query each — they're rare as relation endpoints, so
|
||||
* pre-loading all of them would cost more than the occasional miss.
|
||||
*/
|
||||
async function findIndexed(
|
||||
ref: string,
|
||||
): Promise<{ tag: string; obj: IndexedObject } | null> {
|
||||
const { pagesByName } = await ensureCache();
|
||||
const page = pagesByName.get(ref);
|
||||
if (page) return { tag: "page", obj: page };
|
||||
const byRefExpr = await lua.parseExpression("o.ref == ref");
|
||||
const queryFor = (tag: string) =>
|
||||
index.queryLuaObjects<IndexedObject>(
|
||||
tag,
|
||||
{ objectVariable: "o", where: byRefExpr },
|
||||
{ ref },
|
||||
) as Promise<IndexedObject[]>;
|
||||
const [items, blocks] = await Promise.all([
|
||||
queryFor("item"),
|
||||
queryFor("block"),
|
||||
]);
|
||||
if (items.length) return { tag: "item", obj: items[0] };
|
||||
if (blocks.length) return { tag: "block", obj: blocks[0] };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function classifyKind(ref: string): ObjectKind {
|
||||
if (/^https?:\/\//.test(ref)) return "url";
|
||||
if (/^[^/]+\.[^/]+$/.test(ref) && !ref.endsWith(".md")) return "file";
|
||||
return "page";
|
||||
}
|
||||
|
||||
function deriveTitle(
|
||||
ref: string,
|
||||
kind: ObjectKind,
|
||||
obj: IndexedObject | null,
|
||||
): string {
|
||||
if (obj?.name) return String(obj.name);
|
||||
if (kind === "url" || kind === "file") return ref.split(/[\\/]/).pop() ?? ref;
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function deriveTagFields(
|
||||
allTags: string[],
|
||||
hostTag: string,
|
||||
): {
|
||||
rootTag: string | null;
|
||||
primaryTag: string | null;
|
||||
tags: string[];
|
||||
} {
|
||||
// `hostTag` is the structural tag the indexer keyed on (page/item/block).
|
||||
// SilverBullet's indexer also exposes additional user tags via `tags`.
|
||||
const hasTask = allTags.includes("task");
|
||||
let rootTag: string | null;
|
||||
if (hostTag === "item" && hasTask) rootTag = "task";
|
||||
else if (CORE_TAGS.has(hostTag)) rootTag = hostTag;
|
||||
else rootTag = null;
|
||||
|
||||
const userTags = allTags.filter((t) => !isCoreTag(t));
|
||||
return {
|
||||
rootTag,
|
||||
primaryTag: userTags[0] ?? null,
|
||||
tags: userTags,
|
||||
};
|
||||
}
|
||||
|
||||
// Project an already-fetched indexed object into the panel's ObjectNode shape.
|
||||
function projectIndexed(
|
||||
ref: string,
|
||||
structuralTag: string,
|
||||
obj: IndexedObject,
|
||||
): ObjectNode {
|
||||
const tagsField = Array.isArray(obj.tags) ? obj.tags.map(String) : [];
|
||||
const allTags = tagsField.includes(structuralTag)
|
||||
? tagsField
|
||||
: [structuralTag, ...tagsField];
|
||||
const { rootTag, primaryTag, tags } = deriveTagFields(allTags, structuralTag);
|
||||
const kind: ObjectKind =
|
||||
structuralTag === "page"
|
||||
? "page"
|
||||
: structuralTag === "item" || structuralTag === "task"
|
||||
? "item"
|
||||
: "block";
|
||||
return {
|
||||
ref,
|
||||
kind,
|
||||
title: deriveTitle(ref, kind, obj),
|
||||
rootTag,
|
||||
primaryTag,
|
||||
tags,
|
||||
dangling: false,
|
||||
prefix: obj.pageDecoration?.prefix,
|
||||
attributes: obj as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
function stubNode(
|
||||
ref: string,
|
||||
kind: ObjectKind,
|
||||
dangling: boolean,
|
||||
): ObjectNode {
|
||||
return {
|
||||
ref,
|
||||
kind,
|
||||
title: deriveTitle(ref, kind, null),
|
||||
rootTag: null,
|
||||
primaryTag: null,
|
||||
tags: [],
|
||||
dangling,
|
||||
attributes: { ref, ...(dangling ? { dangling: true } : {}) },
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveObject(ref: string): Promise<ObjectNode> {
|
||||
const kindHint = classifyKind(ref);
|
||||
if (kindHint === "url" || kindHint === "file") {
|
||||
return stubNode(ref, kindHint, false);
|
||||
}
|
||||
const found = await findIndexed(ref);
|
||||
if (!found) return stubNode(ref, "page", true);
|
||||
return projectIndexed(ref, found.tag, found.obj);
|
||||
}
|
||||
|
||||
export async function expandObject(ref: string): Promise<ExpansionResult> {
|
||||
const normRef = stripPagePos(ref);
|
||||
const rows = await queryRelations(normRef);
|
||||
// Normalize endpoints so positional variants (`Page@123`) collapse to the
|
||||
// bare page ref across the whole object graph.
|
||||
const edges = collapseEdges(rows).map((e) => ({
|
||||
...e,
|
||||
source: stripPagePos(e.source),
|
||||
target: stripPagePos(e.target),
|
||||
}));
|
||||
|
||||
const neighborRefs = new Set<string>();
|
||||
for (const e of edges) {
|
||||
if (e.source !== normRef) neighborRefs.add(e.source);
|
||||
if (e.target !== normRef) neighborRefs.add(e.target);
|
||||
}
|
||||
|
||||
const [object, neighbors] = await Promise.all([
|
||||
resolveObject(normRef),
|
||||
Promise.all([...neighborRefs].map(resolveObject)),
|
||||
]);
|
||||
|
||||
return { object, neighbors, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the data for the global view: every page + every relation in the
|
||||
* space. The caller is expected to default the label filter to "mention
|
||||
* only" — but the data is here so the user can toggle other labels on
|
||||
* without a refetch. `rootRef` picks the anchor (current page if available).
|
||||
*/
|
||||
export async function buildGlobalGraph(
|
||||
rootRef: string | null,
|
||||
): Promise<ExpansionResult | null> {
|
||||
const { relations, pagesByName } = await ensureCache();
|
||||
const allPages = [...pagesByName.values()];
|
||||
// Equivalent of `index.contentPages()`: drop meta-tagged pages.
|
||||
const isMeta = (p: IndexedObject) => {
|
||||
const tags = Array.isArray(p.tags) ? (p.tags as string[]) : [];
|
||||
return tags.some((t) => t === "meta" || t.startsWith("meta/"));
|
||||
};
|
||||
const pages = allPages.filter((p) => !isMeta(p));
|
||||
const metaRefs = new Set(
|
||||
allPages.filter(isMeta).map((p) => String(p.name ?? "")),
|
||||
);
|
||||
if (pages.length === 0) return null;
|
||||
const rows = relations;
|
||||
|
||||
const nodeByRef = new Map<string, ObjectNode>();
|
||||
for (const p of pages) {
|
||||
const ref = String(p.name ?? p.ref ?? "");
|
||||
if (!ref) continue;
|
||||
nodeByRef.set(ref, projectIndexed(ref, "page", p));
|
||||
}
|
||||
|
||||
// Drop edges that touch meta pages, and constrain the global view to
|
||||
// page-to-page edges only: URL / file / item / block endpoints are
|
||||
// out of scope here. They'd otherwise drag in non-page nodes (random
|
||||
// URLs and so on) that the Global Page Map promises not to show.
|
||||
const edges = collapseEdges(rows)
|
||||
.map((e) => ({
|
||||
...e,
|
||||
source: stripPagePos(e.source),
|
||||
target: stripPagePos(e.target),
|
||||
}))
|
||||
.filter(
|
||||
(e) =>
|
||||
!metaRefs.has(e.source) &&
|
||||
!metaRefs.has(e.target) &&
|
||||
nodeByRef.has(e.source) &&
|
||||
nodeByRef.has(e.target),
|
||||
);
|
||||
|
||||
// Choose the root: prefer the caller-supplied ref when available;
|
||||
// otherwise pick an arbitrary page (first by name).
|
||||
const anchor =
|
||||
rootRef && nodeByRef.has(rootRef)
|
||||
? rootRef
|
||||
: [...nodeByRef.keys()].sort()[0];
|
||||
const object = nodeByRef.get(anchor)!;
|
||||
const neighbors = [...nodeByRef.values()].filter((n) => n.ref !== anchor);
|
||||
|
||||
return { object, neighbors, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct root tags, user tags, and relation labels present in the space.
|
||||
* Drives the sidebar's option lists so checkboxes show every value the
|
||||
* user might want to toggle, not just what's in the current explored set.
|
||||
*/
|
||||
export async function buildUniverse(): Promise<GraphUniverse> {
|
||||
const { relations, pagesByName } = await ensureCache();
|
||||
|
||||
// User tags: every non-core tag attached to any page in the space.
|
||||
const tagSet = new Set<string>();
|
||||
for (const p of pagesByName.values()) {
|
||||
const tags = Array.isArray(p.tags) ? (p.tags as string[]) : [];
|
||||
for (const t of tags) {
|
||||
if (isCoreTag(t)) continue;
|
||||
tagSet.add(t);
|
||||
}
|
||||
}
|
||||
const tags = [...tagSet].sort();
|
||||
|
||||
// Relation labels: distinct `kind` across all relations.
|
||||
const labelSet = new Set<string>();
|
||||
for (const r of relations) labelSet.add(r.kind);
|
||||
const labels = [...labelSet].sort();
|
||||
|
||||
return { tags, labels };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { asset } from "@silverbulletmd/silverbullet/syscalls";
|
||||
import { panelStyles } from "@silverbulletmd/silverbullet/ui";
|
||||
import type { RootViewModel } from "./model.ts";
|
||||
|
||||
const PLUG_NAME = "object-graph";
|
||||
|
||||
export async function buildGraphHtml(
|
||||
view: RootViewModel,
|
||||
): Promise<{ html: string; script: string }> {
|
||||
const [preamble, css, jsTemplate] = await Promise.all([
|
||||
// Shared component stylesheet (`.sb-*`) + the user's space styles, so the
|
||||
// standard UI components render themed; our own CSS is appended after so
|
||||
// it stays highest-precedence for graph-specific styling.
|
||||
panelStyles(),
|
||||
asset.readAsset(PLUG_NAME, "assets/object-graph.css"),
|
||||
asset.readAsset(PLUG_NAME, "assets/object-graph.js"),
|
||||
]);
|
||||
|
||||
const html =
|
||||
`${preamble}<style>${css}</style><div id="graph-root" tabindex="-1"></div>`;
|
||||
|
||||
// `var` (not `const`) so the declaration hoists into the eval'd scope and
|
||||
// is lexically visible to the IIFE the bundled script wraps us in.
|
||||
const script = `var __GRAPH = ${JSON.stringify(view)};\n${jsTemplate}`;
|
||||
|
||||
return { html, script };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// The relation `kind` is either a reserved structural value (`mention`,
|
||||
// `co-mention`) or a user predicate (e.g. `spouse`). Mirrors `plugs/index/relation.ts`.
|
||||
export const STRUCTURAL_KINDS = new Set(["mention", "co-mention"]);
|
||||
|
||||
export type ObjectKind = "page" | "item" | "block" | "url" | "file";
|
||||
|
||||
/**
|
||||
* A node in the explored graph. Carries enough data for the panel to render
|
||||
* the node, drive filters, and populate the right-panel object view.
|
||||
*/
|
||||
export type ObjectNode = {
|
||||
ref: string;
|
||||
kind: ObjectKind;
|
||||
title: string;
|
||||
// Single core tag (page/item/task/block); drives the Root-tags filter.
|
||||
rootTag: string | null;
|
||||
// First non-core tag; drives node color. null when none.
|
||||
primaryTag: string | null;
|
||||
// All non-core tags; drives the Tags filter.
|
||||
tags: string[];
|
||||
dangling: boolean;
|
||||
// pageDecoration.prefix passthrough (e.g. emoji prefix).
|
||||
prefix?: string;
|
||||
// Full indexed payload, rendered verbatim into the object view.
|
||||
attributes: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type EdgeProvenance = {
|
||||
page: string;
|
||||
pos?: number;
|
||||
snippet?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One edge between two ObjectNodes. Parallel typed edges between the same
|
||||
* pair stay distinct. Co-mention pairs in opposite directions are collapsed
|
||||
* into one record with `undirected: true`.
|
||||
*/
|
||||
export type Edge = {
|
||||
source: string;
|
||||
target: string;
|
||||
label: string; // == kind
|
||||
kind: string;
|
||||
refs: EdgeProvenance[];
|
||||
undirected: boolean;
|
||||
};
|
||||
|
||||
export type ExpansionResult = {
|
||||
object: ObjectNode;
|
||||
neighbors: ObjectNode[];
|
||||
edges: Edge[];
|
||||
};
|
||||
|
||||
export type Filters = {
|
||||
hiddenTags: string[];
|
||||
hiddenLabels: string[];
|
||||
// When true, edge labels on the canvas are suppressed entirely.
|
||||
hideEdgeLabels: boolean;
|
||||
// When true, nodes with no visible incoming or outgoing relation in
|
||||
// the current filter set are hidden (except the root).
|
||||
hideOrphans: boolean;
|
||||
};
|
||||
|
||||
// Tunable force-simulation knobs exposed as sliders in the sidebar.
|
||||
export type ForceSettings = {
|
||||
centerStrength: number;
|
||||
chargeStrength: number;
|
||||
linkDistance: number;
|
||||
linkStrength: number;
|
||||
};
|
||||
|
||||
export const defaultForceSettings: ForceSettings = {
|
||||
centerStrength: 0.18,
|
||||
chargeStrength: -430,
|
||||
linkDistance: 223,
|
||||
linkStrength: 0.1,
|
||||
};
|
||||
|
||||
export const defaultFilters: Filters = {
|
||||
hiddenTags: [],
|
||||
hiddenLabels: [],
|
||||
hideEdgeLabels: false,
|
||||
hideOrphans: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Universe of filter options that exist in the whole space, regardless of
|
||||
* what the user has currently explored. Drives the sidebar's option lists
|
||||
* so checkboxes for not-yet-visible tags / labels remain togglable.
|
||||
* Counts in the sidebar still reflect the explored subgraph.
|
||||
*/
|
||||
export type GraphUniverse = {
|
||||
tags: string[];
|
||||
labels: string[];
|
||||
};
|
||||
|
||||
export type RootViewModel = {
|
||||
root: ExpansionResult;
|
||||
universe: GraphUniverse;
|
||||
filters: Filters;
|
||||
forces: ForceSettings;
|
||||
/**
|
||||
* When true, the panel marks all of `root.neighbors` as already-expanded
|
||||
* (instead of the default ghost state). Used by the global view, which
|
||||
* ships every page at once and lets the user trim down rather than walk out.
|
||||
*/
|
||||
initialAllExpanded?: boolean;
|
||||
/**
|
||||
* When false, the panel does not persist in-session filter changes back
|
||||
* to the datastore. Used by transient views like the global view so they
|
||||
* don't pollute the local-view filter preferences.
|
||||
*/
|
||||
persistFilters?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { datastore, editor } from "@silverbulletmd/silverbullet/syscalls";
|
||||
import {
|
||||
buildGlobalGraph,
|
||||
buildUniverse,
|
||||
expandObject as expandObjectImpl,
|
||||
invalidateGraphCache,
|
||||
} from "./graph_builder.ts";
|
||||
import { buildGraphHtml } from "./graph_html.ts";
|
||||
import {
|
||||
defaultFilters,
|
||||
defaultForceSettings,
|
||||
type ExpansionResult,
|
||||
type Filters,
|
||||
type ForceSettings,
|
||||
type RootViewModel,
|
||||
} from "./model.ts";
|
||||
|
||||
const FILTERS_KEY = ["object-graph", "filters"];
|
||||
const FORCES_KEY = ["object-graph", "forces"];
|
||||
|
||||
async function loadFilters(): Promise<Filters> {
|
||||
const raw = (await datastore.get(FILTERS_KEY)) as
|
||||
| Partial<Filters>
|
||||
| undefined;
|
||||
if (!raw) return defaultFilters;
|
||||
return { ...defaultFilters, ...raw } as Filters;
|
||||
}
|
||||
|
||||
async function loadForces(): Promise<ForceSettings> {
|
||||
const raw = (await datastore.get(FORCES_KEY)) as
|
||||
| Partial<ForceSettings>
|
||||
| undefined;
|
||||
if (!raw) return defaultForceSettings;
|
||||
return { ...defaultForceSettings, ...raw } as ForceSettings;
|
||||
}
|
||||
|
||||
export async function showGraph() {
|
||||
const currentPage = await editor.getCurrentPage();
|
||||
if (!currentPage) {
|
||||
await editor.flashNotification("Graph: no current page", "error");
|
||||
return;
|
||||
}
|
||||
// Each top-level open of the modal gets fresh data. Subsequent
|
||||
// `expandObject` round-trips from the panel reuse the populated cache.
|
||||
invalidateGraphCache();
|
||||
const [root, universe, filters, forces] = await Promise.all([
|
||||
expandObjectImpl(currentPage),
|
||||
buildUniverse(),
|
||||
loadFilters(),
|
||||
loadForces(),
|
||||
]);
|
||||
const view: RootViewModel = { root, universe, filters, forces };
|
||||
const { html, script } = await buildGraphHtml(view);
|
||||
await editor.showPanel("modal", 100, html, script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the panel via system.invokeFunction("object-graph.expandObject", ref).
|
||||
* Pure: takes a ref, returns an ExpansionResult, no side effects.
|
||||
*/
|
||||
export async function expandObject(ref: string): Promise<ExpansionResult> {
|
||||
return expandObjectImpl(ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Graph: Global Page Map" — all pages + every relation in the space,
|
||||
* pre-expanded. Filter state defaults to "mention only" and is not
|
||||
* persisted (so the local view's saved filters aren't disturbed).
|
||||
*/
|
||||
export async function showGlobalGraph() {
|
||||
invalidateGraphCache();
|
||||
const [root, universe] = await Promise.all([
|
||||
buildGlobalGraph((await editor.getCurrentPage()) ?? null),
|
||||
buildUniverse(),
|
||||
]);
|
||||
if (!root) {
|
||||
await editor.flashNotification("Graph: no pages in space", "error");
|
||||
return;
|
||||
}
|
||||
// Default the global view to "mention only" — every other relation
|
||||
// label in the universe is hidden but can be toggled on without a
|
||||
// refetch (the full relation set is already loaded).
|
||||
const hiddenLabels = universe.labels.filter((l) => l !== "mention");
|
||||
const view: RootViewModel = {
|
||||
root,
|
||||
universe,
|
||||
filters: {
|
||||
hiddenTags: [],
|
||||
hiddenLabels,
|
||||
hideEdgeLabels: true,
|
||||
hideOrphans: false,
|
||||
},
|
||||
forces: await loadForces(),
|
||||
initialAllExpanded: true,
|
||||
persistFilters: false,
|
||||
};
|
||||
const { html, script } = await buildGraphHtml(view);
|
||||
await editor.showPanel("modal", 100, html, script);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { colorForTag, UNTAGGED_COLOR_VAR } from "./colors.ts";
|
||||
|
||||
describe("colorForTag", () => {
|
||||
it("returns the untagged CSS variable for null", () => {
|
||||
expect(colorForTag(null)).toBe(UNTAGGED_COLOR_VAR);
|
||||
});
|
||||
|
||||
it("is deterministic — same tag yields the same color", () => {
|
||||
expect(colorForTag("driver")).toBe(colorForTag("driver"));
|
||||
expect(colorForTag("research")).toBe(colorForTag("research"));
|
||||
});
|
||||
|
||||
it("produces different colors for distinct tags (typically)", () => {
|
||||
// Hash collisions are possible in principle; the F1 demo tags do not collide.
|
||||
expect(colorForTag("driver")).not.toBe(colorForTag("team"));
|
||||
expect(colorForTag("driver")).not.toBe(colorForTag("person"));
|
||||
});
|
||||
|
||||
it("returns hsl() strings", () => {
|
||||
expect(colorForTag("anything")).toMatch(/^hsl\(\d+ \d+% \d+%\)$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
// Color used for nodes/swatches with no user tag. The CSS custom
|
||||
// property is defined in the stylesheet; the hex is the static fallback
|
||||
// for canvas drawing (which can't resolve vars).
|
||||
export const UNTAGGED_COLOR_VAR = "var(--gv-untagged, #9e9e9e)";
|
||||
|
||||
// Deterministic color per user tag: stable hash → HSL hue with fixed
|
||||
// saturation and lightness. `null` (no tag) returns the untagged fallback.
|
||||
export function colorForTag(tag: string | null): string {
|
||||
if (!tag) return UNTAGGED_COLOR_VAR;
|
||||
let h = 0;
|
||||
for (let i = 0; i < tag.length; i++) {
|
||||
h = (h * 31 + tag.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return `hsl(${h % 360} 60% 55%)`;
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { datastore, system } from "@silverbulletmd/silverbullet/syscalls";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "preact/hooks";
|
||||
import type {
|
||||
Edge,
|
||||
ExpansionResult,
|
||||
Filters,
|
||||
ForceSettings,
|
||||
ObjectNode,
|
||||
RootViewModel,
|
||||
} from "../../src/model.ts";
|
||||
import { GraphCanvas } from "./graph_canvas.tsx";
|
||||
import { Header } from "./header.tsx";
|
||||
import { Sidebar } from "./sidebar.tsx";
|
||||
import { computeVisible, type NodeState } from "./visibility.ts";
|
||||
|
||||
const FILTERS_KEY = ["plug", "object-graph", "filters"];
|
||||
const FORCES_KEY = ["plug", "object-graph", "forces"];
|
||||
|
||||
function edgeKey(e: Edge): string {
|
||||
return `${e.source} ${e.target} ${e.label} ${e.kind}`;
|
||||
}
|
||||
|
||||
export function App({ vm }: { vm: RootViewModel }) {
|
||||
const [nodes, setNodes] = useState<Map<string, NodeState>>(() => {
|
||||
const m = new Map<string, NodeState>();
|
||||
const initialStatus: NodeState["status"] = vm.initialAllExpanded
|
||||
? "expanded"
|
||||
: "ghost";
|
||||
m.set(vm.root.object.ref, { node: vm.root.object, status: "expanded" });
|
||||
for (const n of vm.root.neighbors) {
|
||||
if (!m.has(n.ref)) m.set(n.ref, { node: n, status: initialStatus });
|
||||
}
|
||||
return m;
|
||||
});
|
||||
const [edges, setEdges] = useState<Edge[]>(() => [...vm.root.edges]);
|
||||
// Derived dedupe index kept in lock-step with `edges`. Lives in a ref
|
||||
// (not state) because we mutate it in place and never want it to be a
|
||||
// trigger for re-renders.
|
||||
const edgeKeysRef = useRef<Set<string>>(new Set(vm.root.edges.map(edgeKey)));
|
||||
const [selectedRef, setSelectedRef] = useState<string | null>(
|
||||
vm.root.object.ref,
|
||||
);
|
||||
const [filters, setFilters] = useState<Filters>(vm.filters);
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(230);
|
||||
const [forces, setForces] = useState<ForceSettings>(vm.forces);
|
||||
const [cache] = useState<Map<string, ExpansionResult>>(() => {
|
||||
const m = new Map<string, ExpansionResult>();
|
||||
m.set(vm.root.object.ref, vm.root);
|
||||
return m;
|
||||
});
|
||||
|
||||
// Persist filters whenever they change. Disabled for transient modes
|
||||
// (e.g. the global view) so they don't trample the local view's saved
|
||||
// preferences. Skip the initial mount — those values came FROM the
|
||||
// datastore (or the view's preset) and writing them back is pointless.
|
||||
const persistFilters = vm.persistFilters !== false;
|
||||
const didMountRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!persistFilters) return;
|
||||
if (!didMountRef.current) {
|
||||
didMountRef.current = true;
|
||||
return;
|
||||
}
|
||||
void datastore.set(FILTERS_KEY, filters);
|
||||
}, [filters, persistFilters]);
|
||||
|
||||
// Forces persist regardless of view (global and explore share them).
|
||||
const didMountForcesRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!didMountForcesRef.current) {
|
||||
didMountForcesRef.current = true;
|
||||
return;
|
||||
}
|
||||
void datastore.set(FORCES_KEY, forces);
|
||||
}, [forces]);
|
||||
|
||||
const fetchExpansion = useCallback(
|
||||
async (ref: string): Promise<ExpansionResult> => {
|
||||
const cached = cache.get(ref);
|
||||
if (cached) return cached;
|
||||
const result = (await system.invokeFunction(
|
||||
"object-graph.expandObject",
|
||||
ref,
|
||||
)) as ExpansionResult;
|
||||
cache.set(ref, result);
|
||||
return result;
|
||||
},
|
||||
[cache],
|
||||
);
|
||||
|
||||
const applyExpansions = useCallback((results: ExpansionResult[]) => {
|
||||
if (results.length === 0) return;
|
||||
setNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const r of results) {
|
||||
next.set(r.object.ref, { node: r.object, status: "expanded" });
|
||||
for (const n of r.neighbors) {
|
||||
if (!next.has(n.ref)) next.set(n.ref, { node: n, status: "ghost" });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setEdges((prev) => {
|
||||
const additions: Edge[] = [];
|
||||
const keys = edgeKeysRef.current;
|
||||
for (const r of results) {
|
||||
for (const e of r.edges) {
|
||||
const k = edgeKey(e);
|
||||
if (!keys.has(k)) {
|
||||
keys.add(k);
|
||||
additions.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return additions.length ? [...prev, ...additions] : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expandRef = useCallback(
|
||||
async (ref: string) => {
|
||||
applyExpansions([await fetchExpansion(ref)]);
|
||||
},
|
||||
[applyExpansions, fetchExpansion],
|
||||
);
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(ref: string) => {
|
||||
const state = nodes.get(ref);
|
||||
setSelectedRef(ref);
|
||||
if (state && state.status === "ghost") {
|
||||
void expandRef(ref);
|
||||
}
|
||||
},
|
||||
[nodes, expandRef],
|
||||
);
|
||||
|
||||
const removeNode = useCallback((ref: string) => {
|
||||
setNodes((prev) => {
|
||||
if (!prev.has(ref)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.delete(ref);
|
||||
return next;
|
||||
});
|
||||
setEdges((prev) => {
|
||||
const remaining: Edge[] = [];
|
||||
const keys = edgeKeysRef.current;
|
||||
for (const e of prev) {
|
||||
if (e.source === ref || e.target === ref) {
|
||||
keys.delete(edgeKey(e));
|
||||
continue;
|
||||
}
|
||||
remaining.push(e);
|
||||
}
|
||||
return remaining.length === prev.length ? prev : remaining;
|
||||
});
|
||||
setSelectedRef((sel) => (sel === ref ? null : sel));
|
||||
}, []);
|
||||
|
||||
const selected: ObjectNode | null = selectedRef
|
||||
? (nodes.get(selectedRef)?.node ?? null)
|
||||
: null;
|
||||
|
||||
// Stable ref to the current visibleNodes so callbacks always see the latest
|
||||
// set without forcing the callback identity to churn on every filter tweak.
|
||||
const visibleNodesRef = useRef<NodeState[]>([]);
|
||||
|
||||
// Transitive expansion: follow every relation outward, round by round,
|
||||
// until no new ghosts remain along allowed labels. Hidden-label edges
|
||||
// are recorded (so toggling them on later reveals them) but they
|
||||
// don't seed further exploration. Safety-capped at 5000 nodes.
|
||||
const expandAllVisibleGhosts = useCallback(async () => {
|
||||
const SAFETY_CAP = 5000;
|
||||
const hiddenLabels = new Set(filters.hiddenLabels);
|
||||
const expanded = new Set<string>();
|
||||
const pending = new Set<string>();
|
||||
for (const ns of visibleNodesRef.current) {
|
||||
if (ns.status === "expanded") expanded.add(ns.node.ref);
|
||||
else pending.add(ns.node.ref);
|
||||
}
|
||||
while (pending.size > 0) {
|
||||
if (expanded.size + pending.size > SAFETY_CAP) break;
|
||||
const refs = [...pending];
|
||||
pending.clear();
|
||||
const results = await Promise.all(refs.map(fetchExpansion));
|
||||
applyExpansions(results);
|
||||
for (const r of results) {
|
||||
expanded.add(r.object.ref);
|
||||
// Only follow edges whose label is currently visible.
|
||||
for (const e of r.edges) {
|
||||
if (hiddenLabels.has(e.label)) continue;
|
||||
for (const ref of [e.source, e.target]) {
|
||||
if (!expanded.has(ref) && !pending.has(ref)) pending.add(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [fetchExpansion, applyExpansions, filters.hiddenLabels]);
|
||||
|
||||
/**
|
||||
* Reset the explored set to "currently-selected object + its 1-hop
|
||||
* ghosts". The selection stays put; only the broader exploration is
|
||||
* collapsed back to that node's immediate neighborhood.
|
||||
*
|
||||
* Always issues a fresh worker call (skipping the cache) because the
|
||||
* cache may hold a wide expansion for this ref — notably, in the
|
||||
* Global Page Map view the cache entry for the anchor IS the global
|
||||
* graph, and returning that here would defeat the whole point of Focus.
|
||||
*/
|
||||
const collapseAll = useCallback(async () => {
|
||||
const anchor = selectedRef ?? vm.root.object.ref;
|
||||
const result = (await system.invokeFunction(
|
||||
"object-graph.expandObject",
|
||||
anchor,
|
||||
)) as ExpansionResult;
|
||||
cache.set(anchor, result);
|
||||
const m = new Map<string, NodeState>();
|
||||
m.set(result.object.ref, { node: result.object, status: "expanded" });
|
||||
for (const n of result.neighbors) {
|
||||
if (!m.has(n.ref)) m.set(n.ref, { node: n, status: "ghost" });
|
||||
}
|
||||
setNodes(m);
|
||||
setEdges([...result.edges]);
|
||||
const keys = edgeKeysRef.current;
|
||||
keys.clear();
|
||||
for (const e of result.edges) keys.add(edgeKey(e));
|
||||
setSelectedRef(result.object.ref);
|
||||
}, [selectedRef, cache, vm.root.object.ref]);
|
||||
|
||||
// Keyboard: Backspace / Delete removes selected, unless focused in an input.
|
||||
useEffect(() => {
|
||||
const handler = (ev: KeyboardEvent) => {
|
||||
if (ev.key !== "Backspace" && ev.key !== "Delete") return;
|
||||
const tag = (ev.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
||||
if (!selectedRef) return;
|
||||
ev.preventDefault();
|
||||
removeNode(selectedRef);
|
||||
};
|
||||
globalThis.addEventListener("keydown", handler);
|
||||
return () => globalThis.removeEventListener("keydown", handler);
|
||||
}, [selectedRef, removeNode]);
|
||||
|
||||
// Visible subgraph (view-layer only). Filter rules:
|
||||
// • The root is always visible — it's the user's anchor into the graph.
|
||||
// • Every other node (expanded or ghost) must pass the tag filter.
|
||||
// • Edges must pass the label filter and connect two visible nodes.
|
||||
// • A ghost that has no remaining edges to a visible neighbor is
|
||||
// hidden — otherwise unchecking an edge label leaves orphan ghosts
|
||||
// floating with no visible reason to be there.
|
||||
const rootRef = vm.root.object.ref;
|
||||
const { visibleNodes, visibleEdges } = useMemo(
|
||||
() => computeVisible(nodes.values(), edges, filters, rootRef),
|
||||
[nodes, edges, filters, rootRef],
|
||||
);
|
||||
|
||||
// Keep the ref in sync so expandAllVisibleGhosts sees the latest set.
|
||||
visibleNodesRef.current = visibleNodes;
|
||||
|
||||
const ghostCount = visibleNodes.reduce(
|
||||
(a, ns) => a + (ns.status === "ghost" ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
|
||||
// Stable arrays for the sidebar's tally inputs (the underlying state
|
||||
// changes only when nodes/edges actually change; without these, every
|
||||
// App render would produce fresh array identities and re-tally everything).
|
||||
const allObjectNodes = useMemo(
|
||||
() => [...nodes.values()].map((ns) => ns.node),
|
||||
[nodes],
|
||||
);
|
||||
const visibleObjectNodes = useMemo(
|
||||
() => visibleNodes.map((ns) => ns.node),
|
||||
[visibleNodes],
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="gv-app">
|
||||
<Header
|
||||
ghostCount={ghostCount}
|
||||
onExpandAll={expandAllVisibleGhosts}
|
||||
onCollapseAll={collapseAll}
|
||||
hideEdgeLabels={filters.hideEdgeLabels}
|
||||
onToggleHideEdgeLabels={(v) =>
|
||||
setFilters({ ...filters, hideEdgeLabels: v })
|
||||
}
|
||||
hideOrphans={filters.hideOrphans}
|
||||
onToggleHideOrphans={(v) => setFilters({ ...filters, hideOrphans: v })}
|
||||
/>
|
||||
<div
|
||||
class="gv-body"
|
||||
style={{ "--gv-sidebar-width": `${sidebarWidth}px` }}
|
||||
>
|
||||
<Sidebar
|
||||
nodes={visibleObjectNodes}
|
||||
edges={visibleEdges}
|
||||
allNodes={allObjectNodes}
|
||||
allEdges={edges}
|
||||
universe={vm.universe}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
forces={forces}
|
||||
onForcesChange={setForces}
|
||||
selected={selected}
|
||||
/>
|
||||
<SidebarResizer width={sidebarWidth} onResize={setSidebarWidth} />
|
||||
<GraphCanvas
|
||||
nodes={visibleNodes}
|
||||
edges={visibleEdges}
|
||||
selectedRef={selectedRef}
|
||||
onNodeClick={onNodeClick}
|
||||
hideEdgeLabels={filters.hideEdgeLabels}
|
||||
forces={forces}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_SIDEBAR_WIDTH = 160;
|
||||
const MAX_SIDEBAR_WIDTH = 600;
|
||||
|
||||
function SidebarResizer({
|
||||
width,
|
||||
onResize,
|
||||
}: {
|
||||
width: number;
|
||||
onResize: (w: number) => void;
|
||||
}) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const startRef = useRef<{ x: number; w: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const start = startRef.current;
|
||||
if (!start) return;
|
||||
const next = Math.min(
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
Math.max(MIN_SIDEBAR_WIDTH, start.w + (e.clientX - start.x)),
|
||||
);
|
||||
onResize(next);
|
||||
};
|
||||
const onUp = () => setDragging(false);
|
||||
globalThis.addEventListener("mousemove", onMove);
|
||||
globalThis.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
globalThis.removeEventListener("mousemove", onMove);
|
||||
globalThis.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [dragging, onResize]);
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`gv-resizer${dragging ? " dragging" : ""}`}
|
||||
onMouseDown={(e) => {
|
||||
startRef.current = { x: e.clientX, w: width };
|
||||
setDragging(true);
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
import { editor } from "@silverbulletmd/silverbullet/syscalls";
|
||||
import { Button, Checkbox } from "@silverbulletmd/silverbullet/ui";
|
||||
|
||||
type Props = {
|
||||
ghostCount: number;
|
||||
onExpandAll: () => void;
|
||||
onCollapseAll: () => void;
|
||||
hideEdgeLabels: boolean;
|
||||
onToggleHideEdgeLabels: (v: boolean) => void;
|
||||
hideOrphans: boolean;
|
||||
onToggleHideOrphans: (v: boolean) => void;
|
||||
};
|
||||
|
||||
export function Header({
|
||||
ghostCount,
|
||||
onExpandAll,
|
||||
onCollapseAll,
|
||||
hideEdgeLabels,
|
||||
onToggleHideEdgeLabels,
|
||||
hideOrphans,
|
||||
onToggleHideOrphans,
|
||||
}: Props) {
|
||||
return (
|
||||
<header class="gv-header">
|
||||
<h1 class="gv-header-title">Object Graph</h1>
|
||||
<div class="gv-header-actions">
|
||||
<label
|
||||
class="gv-header-toggle"
|
||||
title="Suppress edge labels on the canvas"
|
||||
>
|
||||
<Checkbox
|
||||
checked={hideEdgeLabels}
|
||||
onChange={(e) =>
|
||||
onToggleHideEdgeLabels((e.currentTarget as HTMLInputElement).checked)
|
||||
}
|
||||
/>
|
||||
Hide labels
|
||||
</label>
|
||||
<label
|
||||
class="gv-header-toggle"
|
||||
title="Hide nodes with no visible incoming or outgoing relation"
|
||||
>
|
||||
<Checkbox
|
||||
checked={hideOrphans}
|
||||
onChange={(e) =>
|
||||
onToggleHideOrphans((e.currentTarget as HTMLInputElement).checked)
|
||||
}
|
||||
/>
|
||||
Hide orphans
|
||||
</label>
|
||||
<Button
|
||||
title="Follow every enabled relation outward until no ghosts remain"
|
||||
disabled={ghostCount === 0}
|
||||
onClick={onExpandAll}
|
||||
>
|
||||
Expand all
|
||||
</Button>
|
||||
<Button
|
||||
title="Reset view to the selected object and its direct relations"
|
||||
onClick={onCollapseAll}
|
||||
>
|
||||
Focus
|
||||
</Button>
|
||||
<Button
|
||||
variant="icon"
|
||||
class="gv-close-button"
|
||||
title="Close (Esc)"
|
||||
onClick={() => editor.hidePanel("modal")}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { syscall } from "@silverbulletmd/silverbullet/syscall";
|
||||
import { editor } from "@silverbulletmd/silverbullet/syscalls";
|
||||
import { Checkbox, Input } from "@silverbulletmd/silverbullet/ui";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import {
|
||||
defaultForceSettings,
|
||||
type Edge,
|
||||
type Filters,
|
||||
type ForceSettings,
|
||||
type GraphUniverse,
|
||||
type ObjectNode,
|
||||
} from "../../src/model.ts";
|
||||
import { colorForTag } from "../colors.ts";
|
||||
|
||||
type Props = {
|
||||
// Currently-visible nodes (used to compute counts for present-only filter UX).
|
||||
nodes: ObjectNode[];
|
||||
edges: Edge[];
|
||||
// Full set; used so the filter list still shows recently-hidden entries.
|
||||
allNodes: ObjectNode[];
|
||||
allEdges: Edge[];
|
||||
// Full option lists derived from the entire space.
|
||||
universe: GraphUniverse;
|
||||
filters: Filters;
|
||||
onFiltersChange: (f: Filters) => void;
|
||||
forces: ForceSettings;
|
||||
onForcesChange: (f: ForceSettings) => void;
|
||||
// Selected node; rendered as an object-detail section below the filters.
|
||||
selected: ObjectNode | null;
|
||||
};
|
||||
|
||||
const LONG_LIST_THRESHOLD = 10;
|
||||
const MAX_VISIBLE = 50;
|
||||
|
||||
type Counted = { key: string; count: number };
|
||||
|
||||
export function Sidebar(props: Props) {
|
||||
return (
|
||||
<aside class="gv-sidebar">
|
||||
<TagsSection {...props} />
|
||||
<LabelsSection {...props} />
|
||||
<ForcesSection
|
||||
forces={props.forces}
|
||||
onForcesChange={props.onForcesChange}
|
||||
/>
|
||||
<ObjectSection selected={props.selected} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ForcesSection({
|
||||
forces,
|
||||
onForcesChange,
|
||||
}: {
|
||||
forces: ForceSettings;
|
||||
onForcesChange: (f: ForceSettings) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const update = (patch: Partial<ForceSettings>) =>
|
||||
onForcesChange({ ...forces, ...patch });
|
||||
const sliders: {
|
||||
label: string;
|
||||
key: keyof ForceSettings;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
}[] = [
|
||||
{
|
||||
label: "Center pull",
|
||||
key: "centerStrength",
|
||||
min: 0,
|
||||
max: 0.3,
|
||||
step: 0.005,
|
||||
},
|
||||
{
|
||||
label: "Repulsion",
|
||||
key: "chargeStrength",
|
||||
min: -800,
|
||||
max: 0,
|
||||
step: 10,
|
||||
},
|
||||
{ label: "Link distance", key: "linkDistance", min: 10, max: 600, step: 1 },
|
||||
{
|
||||
label: "Link strength",
|
||||
key: "linkStrength",
|
||||
min: 0.01,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div class="gv-section">
|
||||
<header class="gv-section-header" onClick={() => setOpen(!open)}>
|
||||
<span class="gv-section-title">
|
||||
<span class={`gv-twisty ${open ? "open" : "closed"}`}>▸</span>
|
||||
Forces
|
||||
</span>
|
||||
<span class="gv-section-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<a onClick={() => onForcesChange(defaultForceSettings)}>reset</a>
|
||||
</span>
|
||||
</header>
|
||||
{open && (
|
||||
<div class="gv-section-body gv-forces-body">
|
||||
{sliders.map((s) => (
|
||||
<label class="gv-force-row" key={s.key}>
|
||||
<div class="gv-force-label">
|
||||
<span>{s.label}</span>
|
||||
<span class="gv-force-value">
|
||||
{forces[s.key].toFixed(s.step < 0.1 ? 3 : 0)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={s.min}
|
||||
max={s.max}
|
||||
step={s.step}
|
||||
value={forces[s.key]}
|
||||
onInput={(e) =>
|
||||
update({
|
||||
[s.key]: Number((e.target as HTMLInputElement).value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectSection({ selected }: { selected: ObjectNode | null }) {
|
||||
const [yamlText, setYamlText] = useState<string>("");
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
let cancelled = false;
|
||||
void syscall("yaml.stringify", selected.attributes).then((text: string) => {
|
||||
if (!cancelled) setYamlText(text);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selected]);
|
||||
if (!selected) return null;
|
||||
const swatch = colorForTag(selected.primaryTag);
|
||||
const navigate = async () => {
|
||||
try {
|
||||
await editor.navigate(selected.ref);
|
||||
await editor.hidePanel("modal");
|
||||
} catch (err) {
|
||||
console.error("object-graph: object navigation failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="gv-object-section">
|
||||
<header class="gv-object-header">
|
||||
<a
|
||||
class="gv-object-title gv-object-title-link"
|
||||
title={`Open ${selected.title}`}
|
||||
onClick={navigate}
|
||||
>
|
||||
<span class="gv-swatch" style={{ background: swatch }} />
|
||||
{selected.title}
|
||||
</a>
|
||||
</header>
|
||||
<pre class="gv-object-body">{yamlText}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
rows,
|
||||
hidden,
|
||||
onToggle,
|
||||
onAll,
|
||||
onNone,
|
||||
renderRow,
|
||||
}: {
|
||||
title: string;
|
||||
rows: Counted[];
|
||||
hidden: string[];
|
||||
onToggle: (key: string) => void;
|
||||
onAll: () => void;
|
||||
onNone: () => void;
|
||||
renderRow: (r: Counted) => preact.ComponentChildren;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [query, setQuery] = useState("");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const filtered = query
|
||||
? rows.filter((r) => r.key.toLowerCase().includes(query.toLowerCase()))
|
||||
: rows;
|
||||
const display = showAll ? filtered : filtered.slice(0, MAX_VISIBLE);
|
||||
const overflow = filtered.length - display.length;
|
||||
|
||||
return (
|
||||
<div class="gv-section">
|
||||
<header class="gv-section-header" onClick={() => setOpen(!open)}>
|
||||
<span class="gv-section-title">
|
||||
<span class={`gv-twisty ${open ? "open" : "closed"}`}>▸</span>
|
||||
{title}
|
||||
</span>
|
||||
<span class="gv-section-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<a onClick={onAll}>all</a>
|
||||
{" · "}
|
||||
<a onClick={onNone}>none</a>
|
||||
</span>
|
||||
</header>
|
||||
{open && (
|
||||
<div class="gv-section-body">
|
||||
{rows.length > LONG_LIST_THRESHOLD && (
|
||||
<Input
|
||||
type="text"
|
||||
class="gv-section-search"
|
||||
placeholder="Filter…"
|
||||
value={query}
|
||||
onInput={(e) => setQuery((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
)}
|
||||
{display.map((r) => (
|
||||
<label class="gv-row" key={r.key}>
|
||||
<Checkbox
|
||||
checked={!hidden.includes(r.key)}
|
||||
onChange={() => onToggle(r.key)}
|
||||
/>
|
||||
{renderRow(r)}
|
||||
<span class="gv-count">{r.count}</span>
|
||||
</label>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<a class="gv-more" onClick={() => setShowAll(true)}>
|
||||
show {overflow} more
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `Counted[]` whose key set is exactly `keys`, with counts pulled
|
||||
* from the explored-graph tally (defaulting to 0). Sorted: present-in-graph
|
||||
* entries first (by count desc), then absent entries alphabetically.
|
||||
*/
|
||||
function universeRows(keys: string[], counts: Map<string, number>): Counted[] {
|
||||
const rows: Counted[] = keys.map((key) => ({
|
||||
key,
|
||||
count: counts.get(key) ?? 0,
|
||||
}));
|
||||
rows.sort((a, b) => {
|
||||
if (a.count !== b.count) return b.count - a.count;
|
||||
return a.key.localeCompare(b.key);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function TagsSection({ allNodes, universe, filters, onFiltersChange }: Props) {
|
||||
const rows = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const n of allNodes) {
|
||||
if (n.tags.length === 0) {
|
||||
counts.set("(untagged)", (counts.get("(untagged)") ?? 0) + 1);
|
||||
continue;
|
||||
}
|
||||
for (const t of n.tags) {
|
||||
counts.set(t, (counts.get(t) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
// "(untagged)" is always presented at the bottom of the universe list
|
||||
// — it's a bucket, not a real tag.
|
||||
return universeRows([...universe.tags, "(untagged)"], counts);
|
||||
}, [allNodes, universe.tags]);
|
||||
const toggle = (key: string) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
hiddenTags: filters.hiddenTags.includes(key)
|
||||
? filters.hiddenTags.filter((x) => x !== key)
|
||||
: [...filters.hiddenTags, key],
|
||||
});
|
||||
return (
|
||||
<Section
|
||||
title="Tags"
|
||||
rows={rows}
|
||||
hidden={filters.hiddenTags}
|
||||
onToggle={toggle}
|
||||
onAll={() => onFiltersChange({ ...filters, hiddenTags: [] })}
|
||||
onNone={() =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
hiddenTags: rows.map((r) => r.key),
|
||||
})
|
||||
}
|
||||
renderRow={(r) => (
|
||||
<span class="gv-tag-row">
|
||||
<span
|
||||
class="gv-swatch"
|
||||
style={{
|
||||
background: colorForTag(r.key === "(untagged)" ? null : r.key),
|
||||
}}
|
||||
/>
|
||||
{r.key}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelsSection({
|
||||
allEdges,
|
||||
universe,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
}: Props) {
|
||||
const rows = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const e of allEdges)
|
||||
counts.set(e.label, (counts.get(e.label) ?? 0) + 1);
|
||||
return universeRows(universe.labels, counts);
|
||||
}, [allEdges, universe.labels]);
|
||||
const toggle = (key: string) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
hiddenLabels: filters.hiddenLabels.includes(key)
|
||||
? filters.hiddenLabels.filter((x) => x !== key)
|
||||
: [...filters.hiddenLabels, key],
|
||||
});
|
||||
return (
|
||||
<Section
|
||||
title="Kind"
|
||||
rows={rows}
|
||||
hidden={filters.hiddenLabels}
|
||||
onToggle={toggle}
|
||||
onAll={() => onFiltersChange({ ...filters, hiddenLabels: [] })}
|
||||
onNone={() =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
hiddenLabels: rows.map((r) => r.key),
|
||||
})
|
||||
}
|
||||
renderRow={(r) => <span>{r.key}</span>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge, Filters, ObjectNode } from "../../src/model.ts";
|
||||
import { computeVisible, type NodeState } from "./visibility.ts";
|
||||
|
||||
const node = (
|
||||
ref: string,
|
||||
tags: string[] = [],
|
||||
rootTag: string | null = "page",
|
||||
): ObjectNode => ({
|
||||
kind: "page",
|
||||
ref,
|
||||
title: ref,
|
||||
rootTag,
|
||||
primaryTag: tags[0] ?? null,
|
||||
tags,
|
||||
dangling: false,
|
||||
attributes: {},
|
||||
});
|
||||
|
||||
const ns = (ref: string, tags: string[] = []): NodeState => ({
|
||||
node: node(ref, tags),
|
||||
status: "expanded",
|
||||
});
|
||||
|
||||
const edge = (source: string, target: string, label: string): Edge => ({
|
||||
source,
|
||||
target,
|
||||
label,
|
||||
kind: "mention",
|
||||
refs: [],
|
||||
undirected: false,
|
||||
});
|
||||
|
||||
const filters = (overrides: Partial<Filters> = {}): Filters => ({
|
||||
hiddenTags: [],
|
||||
hiddenLabels: [],
|
||||
hideEdgeLabels: false,
|
||||
hideOrphans: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("computeVisible", () => {
|
||||
it("keeps the root visible even with no edges", () => {
|
||||
const r = computeVisible(
|
||||
[ns("Root"), ns("B"), ns("C")],
|
||||
[],
|
||||
filters(),
|
||||
"Root",
|
||||
);
|
||||
expect(r.visibleNodes.map((n) => n.node.ref)).toEqual(["Root"]);
|
||||
expect(r.visibleEdges).toEqual([]);
|
||||
});
|
||||
|
||||
it("hides orphans (non-root with no labeled edge)", () => {
|
||||
const r = computeVisible(
|
||||
[ns("Root"), ns("B"), ns("Orphan")],
|
||||
[edge("Root", "B", "mention")],
|
||||
filters(),
|
||||
"Root",
|
||||
);
|
||||
const refs = r.visibleNodes.map((n) => n.node.ref).sort();
|
||||
expect(refs).toEqual(["B", "Root"]);
|
||||
});
|
||||
|
||||
it("hideOrphans=false keeps everyone visible regardless of edges", () => {
|
||||
const r = computeVisible(
|
||||
[ns("Root"), ns("B"), ns("Orphan")],
|
||||
[edge("Root", "B", "mention")],
|
||||
filters({ hideOrphans: false }),
|
||||
"Root",
|
||||
);
|
||||
expect(r.visibleNodes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("drops edges with hidden labels and re-evaluates orphan status", () => {
|
||||
const r = computeVisible(
|
||||
[ns("Root"), ns("X")],
|
||||
[edge("Root", "X", "co-mention")],
|
||||
filters({ hiddenLabels: ["co-mention"] }),
|
||||
"Root",
|
||||
);
|
||||
// X is now an orphan because its only edge was filtered out.
|
||||
expect(r.visibleNodes.map((n) => n.node.ref)).toEqual(["Root"]);
|
||||
expect(r.visibleEdges).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies the tag filter uniformly (root excepted)", () => {
|
||||
const r = computeVisible(
|
||||
[ns("Root", []), ns("Driver", ["driver"]), ns("Engineer", ["engineer"])],
|
||||
[edge("Root", "Driver", "mention"), edge("Root", "Engineer", "mention")],
|
||||
filters({ hiddenTags: ["engineer"] }),
|
||||
"Root",
|
||||
);
|
||||
const refs = r.visibleNodes.map((n) => n.node.ref).sort();
|
||||
expect(refs).toEqual(["Driver", "Root"]);
|
||||
expect(r.visibleEdges).toHaveLength(1);
|
||||
expect(r.visibleEdges[0].target).toBe("Driver");
|
||||
});
|
||||
|
||||
it("treats untagged nodes as a separate hideable bucket", () => {
|
||||
const r = computeVisible(
|
||||
[ns("Root"), ns("Untagged", [])],
|
||||
[edge("Root", "Untagged", "mention")],
|
||||
filters({ hiddenTags: ["(untagged)"] }),
|
||||
"Root",
|
||||
);
|
||||
expect(r.visibleNodes.map((n) => n.node.ref)).toEqual(["Root"]);
|
||||
});
|
||||
|
||||
it("drops edges that touch a tag-filtered endpoint", () => {
|
||||
const r = computeVisible(
|
||||
[ns("Root"), ns("Visible", ["driver"]), ns("Hidden", ["engineer"])],
|
||||
[
|
||||
edge("Visible", "Hidden", "mention"),
|
||||
edge("Root", "Visible", "mention"),
|
||||
],
|
||||
filters({ hiddenTags: ["engineer"] }),
|
||||
"Root",
|
||||
);
|
||||
expect(r.visibleEdges).toHaveLength(1);
|
||||
expect(r.visibleEdges[0].source).toBe("Root");
|
||||
});
|
||||
|
||||
it("keeps an edge if any tag on a node passes (multi-tag union)", () => {
|
||||
const r = computeVisible(
|
||||
[ns("Root"), ns("Both", ["driver", "person"])],
|
||||
[edge("Root", "Both", "mention")],
|
||||
filters({ hiddenTags: ["driver"] }),
|
||||
"Root",
|
||||
);
|
||||
expect(r.visibleNodes.map((n) => n.node.ref).sort()).toEqual([
|
||||
"Both",
|
||||
"Root",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Edge, Filters, ObjectNode } from "../../src/model.ts";
|
||||
|
||||
export type NodeState = { node: ObjectNode; status: "expanded" | "ghost" };
|
||||
|
||||
export type VisibilityResult = {
|
||||
visibleNodes: NodeState[];
|
||||
visibleEdges: Edge[];
|
||||
};
|
||||
|
||||
// Pure derivation of what the panel paints from the explored-graph state.
|
||||
// Three stages:
|
||||
// 1. candidate = nodes that pass the tag filter (root is always a candidate).
|
||||
// 2. labeledEdges = edges with a visible label that connect two candidates.
|
||||
// 3. orphan rule: with `hideOrphans` on, any candidate other than the root
|
||||
// is dropped if it has no labeledEdge incidence.
|
||||
export function computeVisible(
|
||||
nodes: Iterable<NodeState>,
|
||||
edges: Edge[],
|
||||
filters: Filters,
|
||||
rootRef: string,
|
||||
): VisibilityResult {
|
||||
function passesTags(n: ObjectNode): boolean {
|
||||
if (n.ref === rootRef) return true;
|
||||
if (n.tags.length === 0) return !filters.hiddenTags.includes("(untagged)");
|
||||
return n.tags.some((t) => !filters.hiddenTags.includes(t));
|
||||
}
|
||||
|
||||
const candidate = new Set<string>();
|
||||
const nodeList: NodeState[] = [];
|
||||
for (const ns of nodes) {
|
||||
nodeList.push(ns);
|
||||
if (passesTags(ns.node)) candidate.add(ns.node.ref);
|
||||
}
|
||||
|
||||
const labeledEdges = edges.filter(
|
||||
(e) =>
|
||||
candidate.has(e.source) &&
|
||||
candidate.has(e.target) &&
|
||||
!filters.hiddenLabels.includes(e.label),
|
||||
);
|
||||
|
||||
const degree = new Map<string, number>();
|
||||
for (const e of labeledEdges) {
|
||||
degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
|
||||
degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const out: NodeState[] = [];
|
||||
const hideOrphans = filters.hideOrphans;
|
||||
for (const ns of nodeList) {
|
||||
if (!candidate.has(ns.node.ref)) continue;
|
||||
if (
|
||||
!hideOrphans ||
|
||||
ns.node.ref === rootRef ||
|
||||
(degree.get(ns.node.ref) ?? 0) > 0
|
||||
) {
|
||||
out.push(ns);
|
||||
}
|
||||
}
|
||||
|
||||
const visibleRefSet = new Set(out.map((ns) => ns.node.ref));
|
||||
const visibleEdges = labeledEdges.filter(
|
||||
(e) => visibleRefSet.has(e.source) && visibleRefSet.has(e.target),
|
||||
);
|
||||
|
||||
return { visibleNodes: out, visibleEdges };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render } from "preact";
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import type { RootViewModel } from "../src/model.ts";
|
||||
import { App } from "./components/app.tsx";
|
||||
import { useEscape } from "./use_escape.ts";
|
||||
|
||||
declare const __GRAPH: RootViewModel;
|
||||
|
||||
function Root({ vm }: { vm: RootViewModel }) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
useEscape();
|
||||
useEffect(() => {
|
||||
rootRef.current?.focus();
|
||||
}, []);
|
||||
return (
|
||||
<div ref={rootRef} class="graph-root-inner" tabIndex={-1}>
|
||||
<App vm={vm} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.getElementById("graph-root");
|
||||
if (!root) throw new Error("graph-root not found");
|
||||
|
||||
// Focus the iframe document BEFORE rendering so the first keydown (often
|
||||
// Escape) is delivered to us rather than the parent SilverBullet editor.
|
||||
root.setAttribute("tabindex", "-1");
|
||||
(root as HTMLElement).focus();
|
||||
|
||||
render(<Root vm={__GRAPH} />, root);
|
||||
@@ -0,0 +1,464 @@
|
||||
// Visual language aligned with SilverBullet's configuration-manager plug
|
||||
// — color tokens, section header style, button style, and border radii
|
||||
// match `silverbullet/plugs/configuration-manager/ui/configuration.scss`.
|
||||
// Dark mode is driven by both `prefers-color-scheme` (for iframes that
|
||||
// don't see the parent's data-theme) AND `html[data-theme="dark"]` (for
|
||||
// when SilverBullet propagates its explicit theme into the iframe).
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
|
||||
// Chrome colors (background, text, borders, sections, hover, accent) come
|
||||
// from the shared panel-surface palette in components.css. Only the
|
||||
// graph-specific node/edge palette below is defined locally.
|
||||
|
||||
// Graph palette — node-color tokens are object-graph specific.
|
||||
--gv-node: #111;
|
||||
--gv-node-current: #1e63d8;
|
||||
--gv-node-dim: #9e4705; // missing-link rust
|
||||
--gv-label: #333;
|
||||
--gv-label-dim: #777;
|
||||
--gv-link: #c8c8c8;
|
||||
--gv-link-dim: #e6e6e6;
|
||||
--gv-link-hot: #464cfc;
|
||||
--gv-accent: #650007; // SB meta-color — current-page ring
|
||||
--gv-label-halo: rgba(255, 255, 255, 0.78);
|
||||
|
||||
--gv-tag-bg: #4070d0;
|
||||
--gv-tag-fg: #ffffff;
|
||||
--gv-tag-border: #2a5099;
|
||||
|
||||
--gv-untagged: #9e9e9e;
|
||||
}
|
||||
|
||||
// Dark tokens — same values configuration-manager uses, applied for either
|
||||
// the explicit theme attribute or the user's system preference.
|
||||
@mixin gv-dark-tokens {
|
||||
color-scheme: dark;
|
||||
|
||||
--gv-node: #fff;
|
||||
--gv-node-current: #6e9bff;
|
||||
--gv-node-dim: #c97c4a;
|
||||
--gv-label: #d8d8d8;
|
||||
--gv-label-dim: #888;
|
||||
--gv-link: #555;
|
||||
--gv-link-dim: #333;
|
||||
--gv-link-hot: #6e74ff;
|
||||
--gv-accent: #d6222e;
|
||||
--gv-label-halo: rgba(30, 30, 30, 0.78);
|
||||
|
||||
--gv-tag-bg: #5b85e8;
|
||||
--gv-tag-fg: #ffffff;
|
||||
--gv-tag-border: #3868c8;
|
||||
|
||||
--gv-untagged: #6e6e6e;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] { @include gv-dark-tokens; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not([data-theme="light"]) { @include gv-dark-tokens; }
|
||||
}
|
||||
|
||||
html, body { height: 100%; background: var(--ui-surface-background-color); color: var(--ui-surface-color); }
|
||||
|
||||
#graph-root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--ui-surface-background-color);
|
||||
color: var(--ui-surface-color);
|
||||
// No border/radius: the host modal panel already frames the iframe.
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.graph-root-inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.graph-app {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.graph-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
// Floating controls overlay (bottom-right of the canvas stage).
|
||||
// Pan pad on the left, zoom in/out stacked on the right.
|
||||
.graph-controls {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
z-index: 2;
|
||||
pointer-events: none; // children opt in
|
||||
|
||||
button {
|
||||
pointer-events: auto;
|
||||
background: var(--ui-surface-background-color);
|
||||
color: var(--ui-surface-color);
|
||||
border: 1px solid var(--ui-surface-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
|
||||
&:hover { background: var(--ui-surface-hover-background-color); }
|
||||
&:active { background: var(--ui-surface-section-background-color); }
|
||||
}
|
||||
}
|
||||
|
||||
.graph-zoom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
button {
|
||||
flex: 1 1 0;
|
||||
height: auto; // let flex stretch determine the height
|
||||
}
|
||||
}
|
||||
|
||||
// 3x3 directional pad — middle row has ←, center, →; top/bottom rows
|
||||
// have just the up/down arrow in the middle column.
|
||||
.graph-pan {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 28px);
|
||||
grid-template-rows: repeat(3, 28px);
|
||||
gap: 4px;
|
||||
|
||||
.graph-pan-up { grid-column: 2; grid-row: 1; }
|
||||
.graph-pan-left { grid-column: 1; grid-row: 2; }
|
||||
.graph-pan-center{ grid-column: 2; grid-row: 2; }
|
||||
.graph-pan-right { grid-column: 3; grid-row: 2; }
|
||||
.graph-pan-down { grid-column: 2; grid-row: 3; }
|
||||
}
|
||||
|
||||
.gv-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--ui-surface-background-color);
|
||||
color: var(--ui-surface-color);
|
||||
}
|
||||
.gv-body {
|
||||
display: grid;
|
||||
grid-template-columns: var(--gv-sidebar-width, 230px) 5px minmax(0, 1fr);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gv-resizer {
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
border-left: 1px solid var(--ui-surface-border-color);
|
||||
user-select: none;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.gv-resizer:hover,
|
||||
.gv-resizer.dragging {
|
||||
background: var(--ui-accent-color, rgba(70, 76, 252, 0.25));
|
||||
}
|
||||
.gv-canvas-wrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gv-canvas-wrap .graph-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Object section inside the sidebar (selected node attributes). */
|
||||
.gv-object-section {
|
||||
margin-top: auto;
|
||||
padding: 6px 14px 10px;
|
||||
border-top: 1px solid var(--ui-surface-border-color);
|
||||
background: var(--ui-surface-background-color);
|
||||
}
|
||||
.gv-object-section .gv-object-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--ui-surface-border-color);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ui-surface-color);
|
||||
}
|
||||
.gv-object-section .gv-object-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gv-object-section .gv-object-title-link {
|
||||
cursor: pointer;
|
||||
color: var(--ui-accent-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
.gv-object-section .gv-object-title-link:hover { text-decoration: underline; }
|
||||
.gv-object-section .gv-object-body {
|
||||
margin: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--ui-surface-color);
|
||||
}
|
||||
|
||||
/* Header — sized roughly like configuration-manager's #cfg-header. */
|
||||
.gv-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--ui-surface-border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gv-header-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gv-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Toolbar buttons + checkboxes now use the shared `.sb-button` / `.sb-checkbox`
|
||||
// components; only the toggle-label layout and close-button positioning remain
|
||||
// graph-specific.
|
||||
.gv-header-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--ui-surface-color);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Close button uses the `.sb-button-icon` component; keep only positioning and
|
||||
// the larger glyph size for the `×`.
|
||||
.gv-close-button {
|
||||
margin-left: auto;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Ghost-node hover tooltip */
|
||||
.gv-ghost-tooltip {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
max-width: 280px;
|
||||
background: var(--ui-surface-background-color);
|
||||
border: 1px solid var(--ui-surface-border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
||||
padding: 5px 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 12px;
|
||||
color: var(--ui-surface-color);
|
||||
pointer-events: none;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* Edge hover tooltip */
|
||||
.gv-edge-tooltip {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
max-width: 360px;
|
||||
background: var(--ui-surface-background-color);
|
||||
border: 1px solid var(--ui-surface-border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
||||
padding: 6px 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 12px;
|
||||
color: var(--ui-surface-color);
|
||||
pointer-events: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.gv-edge-tooltip-label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.gv-edge-tooltip-snippet {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--ui-surface-color);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 8em;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gv-edge-tooltip-empty {
|
||||
color: var(--ui-surface-subtle-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.gv-sidebar {
|
||||
border-right: 1px solid var(--ui-surface-border-color);
|
||||
overflow: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
// Filter sections — title underline matches configuration-manager's
|
||||
// .lib-section-header / .cfg-category-title pattern.
|
||||
.gv-section { padding: 12px 14px 6px; }
|
||||
.gv-section + .gv-section { padding-top: 14px; }
|
||||
.gv-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--ui-surface-border-color);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ui-surface-subtle-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.gv-section-title { display: inline-flex; align-items: center; }
|
||||
.gv-twisty {
|
||||
display: inline-block;
|
||||
transition: transform 0.12s;
|
||||
margin-right: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--ui-surface-subtle-color);
|
||||
}
|
||||
.gv-twisty.open { transform: rotate(90deg); }
|
||||
.gv-section-actions {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.gv-section-actions a {
|
||||
cursor: pointer;
|
||||
color: var(--ui-surface-subtle-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
.gv-section-actions a:hover { color: var(--ui-surface-color); text-decoration: underline; }
|
||||
.gv-section-body { padding: 0; }
|
||||
.gv-forces-body { display: flex; flex-direction: column; gap: 8px; padding: 4px 0; }
|
||||
.gv-force-row { display: flex; flex-direction: column; gap: 2px; font-size: 12px; }
|
||||
.gv-force-row input[type="range"] { width: 100%; }
|
||||
.gv-force-label { display: flex; justify-content: space-between; color: var(--ui-surface-subtle-color); }
|
||||
.gv-force-value { font-variant-numeric: tabular-nums; }
|
||||
// Search field uses the shared `.sb-input`; keep only layout here.
|
||||
.gv-section-search {
|
||||
width: 100%;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
.gv-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.gv-count { margin-left: auto; color: var(--ui-surface-subtle-color); font-size: 12px; }
|
||||
.gv-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gv-tag-row { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.gv-more {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--ui-accent-color);
|
||||
}
|
||||
.gv-more:hover { text-decoration: underline; }
|
||||
|
||||
/* Object view */
|
||||
.gv-object-view {
|
||||
border-left: 1px solid var(--ui-surface-border-color);
|
||||
background: var(--ui-surface-background-color);
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.gv-object-view-empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ui-surface-subtle-color);
|
||||
font-size: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
.gv-object-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--ui-surface-border-color);
|
||||
}
|
||||
.gv-object-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gv-object-body {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
color: var(--ui-surface-color);
|
||||
}
|
||||
|
||||
/* Empty graph overlay */
|
||||
.gv-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ui-surface-subtle-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { editor } from "@silverbulletmd/silverbullet/syscalls";
|
||||
import { useEffect } from "preact/hooks";
|
||||
|
||||
export function useEscape() {
|
||||
useEffect(() => {
|
||||
const handler = async (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
await editor.hidePanel("modal");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
}
|
||||
@@ -3,7 +3,8 @@ An attempt at documenting the changes/new features introduced in each release.
|
||||
## Edge
|
||||
Whenever a commit is pushed to the `main` branch, within ~5 minutes, it will be released as a docker image with the `:v2` tag, and a binary in the [edge release](https://github.com/silverbulletmd/silverbullet/releases/tag/edge). If you want to live on the bleeding edge of SilverBullet goodness (or regression) this is where to do it.
|
||||
|
||||
* New [[Object/relation]] indexed object capturing generalized object-to-object relationships: typed edges from frontmatter, inline `[key: value]` attributes, and `#tag` fenced data blocks; untyped mentions; and co-mention edges between refs co-occurring in the same item, nested item, or paragraph. The (now) legacy `link` is reimplemented as a virtual collection on top of `relation` now and should keep acting as before. This _should_ be backwards compatible, but watch for regressions. A space reindex should trigger upon upgrade automatically. If not run `Space: Reindex` manually on your clients.
|
||||
* New [[Object/relation]] indexed object capturing generalized object-to-object relationships. This is a successor to [[Object/link]], which still exists as a virtual collection built on top of `relation`.
|
||||
* New experimental [[Object Graph]]: an interactive, force-directed graph explorer over the [[Object/relation]] index. Try it via ${widgets.commandButton("Graph: Explore")} and ${widgets.commandButton("Graph: Global Page Map")}.
|
||||
* Picker fuzzy search: replaced Fuse.js with a custom scorer that supports multi-token queries, path-aware ranking, and some typo tolerance.
|
||||
* UX: a _lot_ of little visual tweaks and usability fixes all over the place that hopefully will trigger less of your OCD, including:
|
||||
* On narrow viewports (<800px) header `#` markers no longer get pushed off-screen when the cursor enters a heading
|
||||
@@ -11,7 +12,7 @@ Whenever a commit is pushed to the `main` branch, within ~5 minutes, it will be
|
||||
* List/outline alignment: bullets, checkboxes and ordered-list numbers now line up in a clean column regardless of nesting depth, list type, or whether items are tasks, see [[Outline Stress Test]].
|
||||
* Note: potentially **breaking CSS change for theme authors**: per-nesting-level indent values previously carried by `.sb-line-ul.sb-line-li-N`, `.sb-line-ol.sb-line-li-N`, `.sb-line-task` and `.sb-line-blockquote.sb-line-li-N` selectors have been removed.
|
||||
* Task checkboxes are now drawn in CSS (`appearance: none` + bordered box + rotated-rectangle checkmark) instead of relying on the native checkboxes. Should improve rendering on webkit browsers, and gives more control over the width.
|
||||
* Start of shared UI components (between SB core and plugs): component styles (for buttons, inputs, selects, checkboxes, tabs, alerts, badges, progress bars), and a `@silverbulletmd/silverbullet/ui` package export providing optional Preact wrappers. See [[Plugs/Development/Reference]] for notes on how to use this as a plug author. Built-in plugs like [[Configuration Manager]] now also load [[Space Style]] inside the iframe, so components should become themable.
|
||||
* Start of shared UI components (between SB core and plugs): component styles (for buttons, inputs, selects, checkboxes, tabs, alerts, badges, progress bars), and a `@silverbulletmd/silverbullet/ui` package export providing optional Preact wrappers. See [[Plugs/Development/Reference]] for notes on how to use this as a plug author. Built-in plugs like [[Configuration Manager]] and [[Object Graph]] use these components already. In addition, these now also load [[Space Style]] inside the iframe, so components should become themable.
|
||||
* Technical simplification: replaced the CodeMirror-based mini-editor used in the page/command picker, prompt dialogs, and the top-bar page-name field with native text inputs, improving accessibility, mobile keyboard behavior, and removing several Safari/layout hacks.
|
||||
* Potentially **breaking CSS change for theme authors**: these three fields are no longer CodeMirror instances, so any styling that targeted them via `.sb-mini-editor`, the `.cm-content` / `.cm-line` / `.cm-scroller` rules inside `.sb-modal-box`, or the `.cm-scroller` / `.cm-content` rules under `#sb-current-page`, no longer applies. They are now native `<input>` elements sharing the `.sb-input` base class, each with a context-specific class to retarget:
|
||||
* Picker / command-palette filter: `.sb-input.sb-filter-input` (inside `.sb-modal-box .sb-header`)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#maturity/experimental
|
||||
|
||||
An interactive object-graph explorer visualizes [[Object|objects]] (pages, items, blocks) and the typed [[Object/relation|relations]] between them — letting you navigate your space by structure rather than by name.
|
||||
|
||||
# Commands
|
||||
* ${widgets.commandButton("Graph: Explore")} (`Ctrl/Cmd-Shift-G`): Opens an exploratory graph anchored on the currently open page. Its 1-hop neighborhood is loaded immediately, expand more by clicking ghost nodes.
|
||||
* ${widgets.commandButton("Graph: Global Page Map")}: Loads every content page in the space and the `mention` relations between them, all pre-expanded. Useful for a bird's-eye view; toggle other relation labels on from the sidebar to bring in further edges without a refetch.
|
||||
|
||||
# Interacting with the graph
|
||||
* **Click a translucent (ghost) node** to expand its 1-hop neighborhood into the graph.
|
||||
* **Click an expanded (solid) node** to select it — its attributes appear in the sidebar’s Object section.
|
||||
* **Double-click any node or edge** to navigate to its definition.
|
||||
* **Hover an edge** to see its label and snippet.
|
||||
* **Drag** a node to pin it, the layout reflows around it.
|
||||
* **Hover** a node to dim everything outside its neighborhood.
|
||||
* **Scroll / pinch** to zoom; drag empty space to pan.
|
||||
* **`Esc`** or the `×` button closes the modal.
|
||||
|
||||
# Panels
|
||||
* **Header** — buttons for **Expand** (open every visible ghost in one batch), **Collapse** (reset the explored set to the selected object + its 1-hop), and a **Hide labels** checkbox to suppress edge labels on dense graphs.
|
||||
* **Sidebar**
|
||||
* **Root tags** — toggle visibility by structural object kind.
|
||||
* **Tags** — toggle visibility by user [[Tag]]. Untagged objects share an `(untagged)` bucket.
|
||||
* **Relations** — toggle [[Object/relation|relation]] labels (`mention`, `co-mention`, custom relation types e.g. `spouse`, `team`). `co-mention` is hidden by default in Explore mode; only `mention` is visible by default in Global Page Map mode.
|
||||
* **Object** — the selected node's full indexed attributes rendered as YAML. Click the title to navigate to the object. The `×` button (or `Delete` / `Backspace` while the node is selected) removes the node from the graph.
|
||||
|
||||
Each filter section has `all` / `none` shortcuts, a search box when the list is long, and shows counts from the current graph.
|
||||
|
||||
# Visual conventions
|
||||
* **Solid circles** — expanded nodes (their 1-hop neighborhood is loaded).
|
||||
* **Translucent circles** — ghost nodes, click to expand.
|
||||
* **Dashed outline** — dangling refs (referenced by some relation but not yet indexed — see [[Aspiring Pages]]).
|
||||
* **Color** is driven by the node’s primary user tag, auto-derived from the tag name. Untagged nodes use a neutral gray.
|
||||
* **Edge labels** are the relation `kind` (a reserved value like `mention`, or a user predicate like `spouse`), drawn along the edge. Directed edges carry an arrowhead at the target; relations that connect the same pair in both directions get arrows at both ends; collapsed `co-mention` pairs are rendered undirected.
|
||||
|
||||
The graph is powered entirely by the [[Object/relation]] index.
|
||||
@@ -6,6 +6,10 @@ SilverBullet automatically maintains an [[Object Index]] extracted from all [[Ma
|
||||
|
||||
Objects are a feature that powers a lot of SilverBullet functionality, including the [[Page Picker]], [[Linked Mention|Linked Mentions]] and many others. They can also be queried by the user directly, typically via [[Space Lua/Integrated Query]].
|
||||
|
||||
There are various ways to visualize what objects exist:
|
||||
* [[X-Ray]]: an in-editor overlay visualizing indexed objects
|
||||
* [[Object Graph]]: a graph-style visualization of indexed objects and their relationships.
|
||||
|
||||
# Terminology
|
||||
* [[Object]]: represent _things_ in your space at various level of granularity. Examples include [[Object/page]] at the highest level, but also more granular things like [[Object/task]] and [[Object/link]]. In relational database parlance, you can think of Objects as **database rows**.
|
||||
* [[Tag]]: represent Object **types** or **tables** (in relational database parlance). Every Object has at least one tag, but can have additional tags attached explicitly, usually through the [[Markdown/Hashtags]] syntax.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
X-Ray is an editor lens that shows you the structure SilverBullet’s indexer sees in your document. When switched on, every text range the indexer extracts as an [[Object]] is underlined. When you hover your mouse over it, it will show you all the attributes extracted as [[YAML]].
|
||||
|
||||
You may also be interested in the [[Object Graph]].
|
||||
|
||||
# Activation
|
||||
Run ${widgets.commandButton "Editor: Toggle X-Ray"}. The setting is sticky, it persists across page navigations and reloads until you run the command again.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user