Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slick-beds-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nodesecure/vis-network": patch
---

Skip `neighbourHighlight` on Ctrl+Click and Cmd+Click to allow drill-down interactions without conflicting focus animations
215 changes: 215 additions & 0 deletions public/components/drill-breadcrumb/drill-breadcrumb.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// Import Third-party Dependencies
import { LitElement, html, css, nothing } from "lit";

// Import Internal Dependencies
import { EVENTS } from "../../core/events.js";

class DrillBreadcrumb extends LitElement {
static styles = css`
:host {
position: absolute;
top: 38px;
left: 10px;
z-index: 20;
display: flex;
align-items: center;
gap: 4px;
background: rgb(10 10 20 / 72%);
border-radius: 6px;
padding: 4px 10px;
font-family: mononoki, monospace;
font-size: 12px;
color: #fff;
}

:host([hidden]) {
display: none !important;
}

button {
background: transparent;
border: none;
color: rgb(255 255 255 / 85%);
cursor: pointer;
font-family: mononoki, monospace;
font-size: 12px;
padding: 0;
}

button:hover {
color: #fff;
text-decoration: underline;
}

.separator-wrapper {
position: relative;
display: inline-flex;
align-items: center;
}

.separator {
opacity: 0.5;
cursor: default;
text-decoration: none !important;
}

.separator.has-siblings {
cursor: pointer;
opacity: 0.8;
}

.separator.has-siblings:hover {
opacity: 1;
color: var(--secondary);
text-decoration: none;
}

.active {
color: var(--secondary);
font-weight: bold;
}

.dropdown {
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: rgb(10 10 20 / 95%);
border: 1px solid rgb(255 255 255 / 15%);
border-radius: 6px;
padding: 4px 0;
min-width: 180px;
max-height: 260px;
overflow-y: auto;
z-index: 30;
box-shadow: 0 4px 16px rgb(0 0 0 / 50%);
}

.dropdown button {
display: block;
width: 100%;
text-align: left;
padding: 5px 12px;
white-space: nowrap;
color: rgb(255 255 255 / 80%);
font-size: 11px;
border-radius: 0;
}

.dropdown button:hover {
background: rgb(255 255 255 / 10%);
color: #fff;
text-decoration: none;
}
`;

static properties = {
root: { type: Object },
stack: { type: Array },
siblings: { type: Array },
_openDropdown: { state: true }
};

constructor() {
super();
this.root = null;
this.stack = [];
this.siblings = [];
this._openDropdown = null;
}

connectedCallback() {
super.connectedCallback();
document.addEventListener("click", this.#handleDocumentClick);
}

disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("click", this.#handleDocumentClick);
}

updated() {
this.hidden = this.stack.length === 0 || this.root === null;
}

#handleDocumentClick = () => {
if (this._openDropdown !== null) {
this._openDropdown = null;
}
};

#handleReset() {
this.dispatchEvent(new CustomEvent(EVENTS.DRILL_RESET, {
bubbles: true,
composed: true
}));
}

#handleBack(index) {
this.dispatchEvent(new CustomEvent(EVENTS.DRILL_BACK, {
detail: { index },
bubbles: true,
composed: true
}));
}

#toggleDropdown(index, event) {
event.stopPropagation();

this._openDropdown = this._openDropdown === index ? null : index;
}

#handleSiblingClick(stackIndex, nodeId, event) {
event.stopPropagation();

this._openDropdown = null;
this.dispatchEvent(new CustomEvent(EVENTS.DRILL_SWITCH, {
detail: { stackIndex, nodeId },
bubbles: true,
composed: true
}));
}

render() {
if (this.stack.length === 0 || this.root === null) {
return nothing;
}

return html`
<button @click="${this.#handleReset}">${this.root.name}@${this.root.version}</button>
${this.stack.map((entry, stackIndex) => {
const siblingList = this.siblings?.[stackIndex] ?? [];
const hasSiblings = siblingList.length > 0;

return html`
<span class="separator-wrapper">
${hasSiblings
? html`
<button
class="separator has-siblings"
@click="${(event) => this.#toggleDropdown(stackIndex, event)}"
>›</button>
${this._openDropdown === stackIndex ? html`
<div class="dropdown">
${siblingList.map((sibling) => html`
<button @click="${(event) => this.#handleSiblingClick(stackIndex, sibling.nodeId, event)}">
${sibling.name}@${sibling.version}
</button>
`)}
</div>
` : nothing}
`
: html`<span class="separator">›</span>`
}
</span>
${stackIndex === this.stack.length - 1
? html`<span class="active">${entry.name}@${entry.version}</span>`
: html`<button @click="${() => this.#handleBack(stackIndex)}">${entry.name}@${entry.version}</button>`
}
`;
})}
`;
}
}

customElements.define("drill-breadcrumb", DrillBreadcrumb);
5 changes: 4 additions & 1 deletion public/core/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,8 @@ export const EVENTS = {
MODAL_OPENED: "modal-opened",
NETWORK_VIEW_HID: "network-view-hid",
NETWORK_VIEW_SHOWED: "network-view-showed",
SEARCH_COMMAND_INIT: "search-command-init"
SEARCH_COMMAND_INIT: "search-command-init",
DRILL_RESET: "drill-reset",
DRILL_BACK: "drill-back",
DRILL_SWITCH: "drill-switch"
};
132 changes: 131 additions & 1 deletion public/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import "./components/search-command/search-command.js";
import { Settings } from "./components/views/settings/settings.js";
import { HomeView } from "./components/views/home/home.js";
import "./components/views/search/search.js";
import "./components/drill-breadcrumb/drill-breadcrumb.js";
import { NetworkNavigation } from "./core/network-navigation.js";
import { i18n } from "./core/i18n.js";
import { initSearchNav } from "./core/search-nav.js";
Expand All @@ -24,7 +25,9 @@ let secureDataSet;
let nsn;
let homeView;
let searchview;
let drillBreadcrumb;
let packageInfoOpened = false;
const drillStack = [];

document.addEventListener("DOMContentLoaded", async() => {
searchview = document.querySelector("search-view");
Expand All @@ -39,6 +42,17 @@ document.addEventListener("DOMContentLoaded", async() => {
// update searchview after window.i18n is set
searchview.requestUpdate();

drillBreadcrumb = document.querySelector("drill-breadcrumb");
drillBreadcrumb.addEventListener(EVENTS.DRILL_RESET, resetDrill);
drillBreadcrumb.addEventListener(EVENTS.DRILL_BACK, function handleDrillBack(event) {
drillBackTo(event.detail.index);
});
drillBreadcrumb.addEventListener(EVENTS.DRILL_SWITCH, function handleDrillSwitch(event) {
const { stackIndex, nodeId } = event.detail;
drillStack.length = stackIndex;
drillInto(nodeId);
});

await init();
window.dispatchEvent(
new CustomEvent(EVENTS.SETTINGS_SAVED, {
Expand Down Expand Up @@ -122,6 +136,107 @@ function dispatchSearchCommandInit() {
window.dispatchEvent(event);
}

function computeSiblings(parentId, excludeId) {
const seen = new Set();
const result = [];

for (const edge of secureDataSet.rawEdgesData) {
if (edge.to === parentId && edge.from !== excludeId && !seen.has(edge.from)) {
seen.add(edge.from);

const entry = secureDataSet.linker.get(edge.from);
result.push({
nodeId: edge.from,
name: entry.name,
version: entry.version
});
}
}

return result.sort((nodeA, nodeB) => nodeA.name.localeCompare(nodeB.name));
}

function computeDrillSubtree(rootNodeId) {
const subtreeIds = new Set([rootNodeId]);
const queue = [rootNodeId];

while (queue.length > 0) {
const current = queue.shift();
for (const edge of secureDataSet.rawEdgesData) {
if (edge.to === current && !subtreeIds.has(edge.from)) {
subtreeIds.add(edge.from);
queue.push(edge.from);
}
}
}

return subtreeIds;
}

function applyDrill(nodeId) {
const subtreeIds = computeDrillSubtree(nodeId);
const updates = [...secureDataSet.linker.keys()].map((id) => {
return {
id,
hidden: !subtreeIds.has(id)
};
});
nsn.nodes.update(updates);
nsn.network.unselectAll();
updateDrillBreadcrumb();
PackageInfo.close();
nsn.neighbourHighlight({ nodes: [nodeId], edges: [] });
}

function drillInto(nodeId) {
const currentRoot = drillStack.length === 0 ? 0 : drillStack.at(-1);
if (nodeId === currentRoot) {
return;
}

drillStack.push(nodeId);
applyDrill(nodeId);
}

function drillBackTo(stackIndex) {
drillStack.length = stackIndex + 1;
applyDrill(drillStack[stackIndex]);
}

function resetDrill() {
drillStack.length = 0;
const updates = [...secureDataSet.linker.keys()].map((id) => {
return {
id,
hidden: false
};
});
nsn.nodes.update(updates);
updateDrillBreadcrumb();
PackageInfo.close();
}

function updateDrillBreadcrumb() {
const rootEntry = secureDataSet.linker.get(0);
drillBreadcrumb.root = {
name: rootEntry.name,
version: rootEntry.version
};
drillBreadcrumb.stack = drillStack.map((nodeId) => {
const entry = secureDataSet.linker.get(nodeId);

return {
name: entry.name,
version: entry.version
};
});
drillBreadcrumb.siblings = drillStack.map((nodeId, index) => {
const parentId = index === 0 ? 0 : drillStack[index - 1];

return computeSiblings(parentId, nodeId);
});
}

async function init(options = {}) {
const { navigateToNetworkView = false } = options;

Expand Down Expand Up @@ -158,7 +273,22 @@ async function init(options = {}) {
packageInfoOpened = false;
});

nsn.network.on("click", updateShowInfoMenu);
nsn.network.on("click", (params) => {
const srcEvent = params.event?.srcEvent;
const isDrillClick = srcEvent?.ctrlKey || srcEvent?.metaKey;

if (isDrillClick && params.nodes.length > 0) {
const nodeId = Number(params.nodes[0]);
drillInto(nodeId);

return;
}

updateShowInfoMenu(params);
});

drillStack.length = 0;
updateDrillBreadcrumb();

const networkNavigation = new NetworkNavigation(secureDataSet, nsn);
window.networkNav = networkNavigation;
Expand Down
Loading
Loading