Skip to content
Merged
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
52 changes: 39 additions & 13 deletions src/lib/components/FlowCanvas.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -521,8 +521,13 @@
cleanups.push(graphStore.nodes.subscribe((graphNodesMap: Map<string, NodeInstance>) => {
if (isSyncing) return;

// Convert Map to array for processing
const filteredGraphNodes = Array.from(graphNodesMap.values());
// Convert Map to array for processing — exclude nodes hidden via `_hidden`
// param. Hidden nodes stay in the store (simulation still uses them) but
// disappear from the canvas; toggling `_hidden` triggers this subscriber
// again so they come back the same way they were removed.
const filteredGraphNodes = Array.from(graphNodesMap.values()).filter(
(n) => !n.params?.['_hidden']
);

// Track nodes that need handle updates (port count changed)
const nodesToUpdate: string[] = [];
Expand Down Expand Up @@ -689,25 +694,46 @@
annotationNodes = Array.from(annotationsMap.values()).map(toAnnotationNode);
}));

// Compute the set of node IDs currently visible (not `_hidden`). Read on
// every edge rebuild so connections to hidden nodes can be filtered out.
function getVisibleNodeIds(): Set<string> {
const graphNodesMap = get(graphStore.nodes);
const ids = new Set<string>();
for (const n of graphNodesMap.values()) {
if (!n.params?.['_hidden']) ids.add(n.id);
}
return ids;
}

function rebuildEdges(connections: Connection[]): void {
const visibleIds = getVisibleNodeIds();
const currentEdgeSelection = new Map(edges.map((e) => [e.id, e.selected]));
edges = connections
.filter((c) => visibleIds.has(c.sourceNodeId) && visibleIds.has(c.targetNodeId))
.map((conn) => {
const edge = toFlowEdge(conn);
if (currentEdgeSelection.get(conn.id)) edge.selected = true;
return edge;
});
}

// Subscribe to current connections (filtered by current navigation context)
cleanups.push(graphStore.connections.subscribe((connections: Connection[]) => {
if (isSyncing) return;
// Preserve selection state from existing edges
const currentEdgeSelection = new Map(edges.map(e => [e.id, e.selected]));
edges = connections.map(conn => {
const edge = toFlowEdge(conn);
// Preserve selection state
const wasSelected = currentEdgeSelection.get(conn.id);
if (wasSelected) {
edge.selected = true;
}
return edge;
});
rebuildEdges(connections);
// Recalculate routes when connections change
// Debounced to coalesce rapid changes (paste, undo, bulk operations)
scheduleRoutingUpdate();
}));

// When `_hidden` flips on a node, the connections store doesn't fire — the
// connections themselves are unchanged. Subscribe to the nodes store too
// and rebuild edges so newly hidden/visible nodes' edges follow along.
cleanups.push(graphStore.nodes.subscribe(() => {
if (isSyncing) return;
rebuildEdges(get(graphStore.connections));
}));

// Track last snapped positions during drag for discrete routing updates
let lastDraggedPositions = new Map<string, { x: number; y: number }>();

Expand Down
24 changes: 24 additions & 0 deletions src/lib/components/contextMenuBuilders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,13 @@ function buildNodeMenu(nodeId: string): MenuItemType[] {

items.push(
DIVIDER,
{
label: 'Hide',
icon: 'eye-off',
action: () => historyStore.mutate(() =>
graphStore.updateNodeParams(nodeId, { _hidden: true })
)
},
{
label: 'View Code',
icon: 'braces',
Expand Down Expand Up @@ -239,6 +246,13 @@ function buildNodeMenu(nodeId: string): MenuItemType[] {

items.push(
DIVIDER,
{
label: 'Hide',
icon: 'eye-off',
action: () => historyStore.mutate(() =>
graphStore.updateNodeParams(nodeId, { _hidden: true })
)
},
{
label: 'View Code',
icon: 'braces',
Expand Down Expand Up @@ -316,6 +330,16 @@ function buildSelectionMenu(
action: () => clipboardStore.copy()
},
DIVIDER,
{
label: `Hide ${count} nodes`,
icon: 'eye-off',
action: () => historyStore.mutate(() => {
for (const id of nodeIds) {
graphStore.updateNodeParams(id, { _hidden: true });
}
})
},
DIVIDER,
{
label: `Delete ${count} nodes`,
icon: 'trash',
Expand Down
23 changes: 23 additions & 0 deletions src/lib/components/dialogs/BlockPropertiesDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,18 @@
// Check if node can be exported (not Interface blocks)
const canExport = $derived(node?.type !== NODE_TYPES.INTERFACE);

// Hide button is meaningless for Interface blocks (they define the
// subsystem's outer ports) — skip them. All other nodes can be hidden.
const canHide = $derived(node?.type !== NODE_TYPES.INTERFACE);

function handleHide() {
if (!node) return;
const id = node.id;
historyStore.mutate(() => graphStore.updateNodeParams(id, { _hidden: true }));
// Dialog targets a node that's now invisible; close it.
closeNodeDialog();
}

// Check if node is a recording node (Scope or Spectrum)
const isRecordingNode = $derived(node?.type === 'Scope' || node?.type === 'Spectrum');

Expand Down Expand Up @@ -304,6 +316,17 @@
<Icon name="upload" size={16} />
</button>
{/if}
<!-- Hide button -->
{#if canHide}
<button
class="icon-btn"
onclick={handleHide}
use:tooltip={"Hide"}
aria-label="Hide"
>
<Icon name="eye-off" size={16} />
</button>
{/if}
{/if}
<!-- Toggle code view button -->
<button
Expand Down
128 changes: 128 additions & 0 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,50 @@

// App state
let nodeCount = $state(0);
let hiddenNodes = $state<import('$lib/nodes/types').NodeInstance[]>([]);
let hiddenMenuOpen = $state(false);
let hiddenOpenTimer: ReturnType<typeof setTimeout> | null = null;
let hiddenCloseTimer: ReturnType<typeof setTimeout> | null = null;

function clearHiddenTimers() {
if (hiddenOpenTimer) {
clearTimeout(hiddenOpenTimer);
hiddenOpenTimer = null;
}
if (hiddenCloseTimer) {
clearTimeout(hiddenCloseTimer);
hiddenCloseTimer = null;
}
}

function handleHiddenGroupEnter() {
clearHiddenTimers();
hiddenOpenTimer = setTimeout(() => {
hiddenMenuOpen = true;
hiddenOpenTimer = null;
}, 250);
}

function handleHiddenGroupLeave() {
clearHiddenTimers();
hiddenCloseTimer = setTimeout(() => {
hiddenMenuOpen = false;
hiddenCloseTimer = null;
}, 180);
}

function handleUnhide(nodeId: string) {
historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _hidden: false }));
}

function handleShowAll() {
clearHiddenTimers();
hiddenMenuOpen = false;
const ids = hiddenNodes.map((n) => n.id);
historyStore.mutate(() => {
for (const id of ids) graphStore.updateNodeParams(id, { _hidden: false });
});
}
let pyodideReady = $state(false);
let pyodideLoading = $state(false);
let simRunning = $state(false);
Expand Down Expand Up @@ -575,6 +619,7 @@

const unsubNodeCount = graphStore.nodesArray.subscribe((nodes) => {
nodeCount = nodes.length;
hiddenNodes = nodes.filter((n) => n.params?.['_hidden']);
});

const unsubPyodide = pyodideState.subscribe((s) => {
Expand Down Expand Up @@ -1355,6 +1400,46 @@
</button>
</div>

<!-- Hidden nodes -->
{#if hiddenNodes.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="toolbar-group hidden-group"
onmouseenter={handleHiddenGroupEnter}
onmouseleave={handleHiddenGroupLeave}
>
<button
class="toolbar-btn hidden-btn"
use:tooltip={`${hiddenNodes.length} hidden node${hiddenNodes.length === 1 ? '' : 's'}`}
aria-label="Hidden nodes"
>
<Icon name="eye-off" size={16} />
<span class="hidden-badge">{hiddenNodes.length}</span>
</button>
{#if hiddenMenuOpen}
<div class="recent-menu" role="menu">
<div class="recent-menu-header">Hidden nodes</div>
{#each hiddenNodes as node (node.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="recent-item" role="menuitem" tabindex="0" onclick={() => handleUnhide(node.id)}>
<Icon name="eye" size={14} />
<span class="recent-name" title={node.name}>{node.name}</span>
<span class="hidden-type">{node.type}</span>
</div>
{/each}
{#if hiddenNodes.length > 1}
<div class="recent-divider"></div>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="recent-item show-all" role="menuitem" tabindex="0" onclick={handleShowAll}>
<Icon name="eye" size={14} />
<span class="recent-name">Show all</span>
</div>
{/if}
</div>
{/if}
</div>
{/if}

<!-- Help -->
<div class="toolbar-group">
<button
Expand Down Expand Up @@ -1937,6 +2022,49 @@
background: color-mix(in srgb, var(--error) 15%, transparent);
}

/* Hidden-nodes group reuses .open-group/.recent-menu layout */
.hidden-group {
position: relative;
}

.hidden-btn {
position: relative;
}

.hidden-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--accent);
color: var(--surface);
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 0 2px var(--surface);
}

.hidden-type {
font-size: 10px;
color: var(--text-disabled);
font-family: var(--font-mono);
}

.recent-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}

.recent-item.show-all {
color: var(--accent);
}

.mutation-badge {
position: absolute;
top: -4px;
Expand Down
Loading