diff --git a/README.md b/README.md index 86dfeb67..aa8dc0d1 100644 --- a/README.md +++ b/README.md @@ -504,6 +504,7 @@ Press `?` to see all shortcuts in the app. Key shortcuts: | | `X` / `Y` | Flip H/V | | | `Arrows` | Nudge selection | | **Wires** | `\` | Add waypoint to selected edge | +| **Labels** | `L` | Toggle port labels | | **View** | `F` | Fit view | | | `H` | Go to root | | | `T` | Toggle theme | @@ -621,6 +622,15 @@ Shapes are defined in `src/lib/nodes/shapes/registry.ts` and applied via CSS cla Colors are CSS-driven - see `src/app.css` for variables and `src/lib/utils/colors.ts` for palettes. +### Port Labels + +Port labels show the name of each input/output port alongside the node. Toggle globally with `L` key, or per-node via right-click menu. + +- **Global toggle**: Press `L` to show/hide port labels for all nodes +- **Per-node override**: Right-click node → "Show Input Labels" / "Show Output Labels" +- **Truncation**: Labels are truncated to 5 characters for compact display +- **SVG export**: Port labels are included when exporting the graph as SVG + ### Adding Custom Shapes 1. Register the shape in `src/lib/nodes/shapes/registry.ts`: diff --git a/package-lock.json b/package-lock.json index 1186be83..2781aeed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1220,7 +1220,6 @@ "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1260,7 +1259,6 @@ "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -1369,7 +1367,6 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1423,7 +1420,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1721,7 +1717,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -1872,7 +1867,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2618,7 +2612,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2652,7 +2645,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2776,7 +2768,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3004,7 +2995,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.0.tgz", "integrity": "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3172,7 +3162,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3211,7 +3200,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index 39da3924..2e644281 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -25,10 +25,44 @@ import { hasExportableData, exportRecordingData } from '$lib/utils/csvExport'; import { exportToSVG } from '$lib/export/svg'; import { downloadSvg } from '$lib/utils/download'; import { plotSettingsStore, DEFAULT_BLOCK_SETTINGS } from '$lib/stores/plotSettings'; +import { portLabelsStore } from '$lib/stores/portLabels'; +import { getEffectivePortLabelVisibility } from '$lib/utils/portLabels'; +import type { NodeInstance } from '$lib/types/nodes'; /** Divider menu item */ const DIVIDER: MenuItemType = { label: '', action: () => {}, divider: true }; +/** Build port label toggle menu items for a node */ +function buildPortLabelItems(nodeId: string, node: NodeInstance): MenuItemType[] { + const globalLabels = get(portLabelsStore); + const { inputs: showInputLabels, outputs: showOutputLabels } = getEffectivePortLabelVisibility(node, globalLabels); + const hasInputs = node.inputs && node.inputs.length > 0; + const hasOutputs = node.outputs && node.outputs.length > 0; + + if (!hasInputs && !hasOutputs) return []; + + const items: MenuItemType[] = [DIVIDER]; + if (hasInputs) { + items.push({ + label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) + ) + }); + } + if (hasOutputs) { + items.push({ + label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) + ) + }); + } + return items; +} + /** Show block code in preview dialog */ function showBlockCode(nodeId: string): void { const node = graphStore.getNode(nodeId); @@ -73,7 +107,7 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { // Interface blocks have limited options if (isInterface) { - return [ + const items: MenuItemType[] = [ { label: 'Properties', icon: 'settings', @@ -84,19 +118,26 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { label: 'Exit Subsystem', icon: 'exit', action: () => graphStore.drillUp() - }, + } + ]; + + items.push(...buildPortLabelItems(nodeId, node)); + + items.push( DIVIDER, { label: 'View Code', icon: 'braces', action: () => showBlockCode(nodeId) } - ]; + ); + + return items; } // Subsystem blocks get "Enter" option if (isSubsystem) { - return [ + const items: MenuItemType[] = [ { label: 'Properties', icon: 'settings', @@ -107,7 +148,12 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { icon: 'enter', shortcut: 'Dbl-click', action: () => graphStore.drillDown(nodeId) - }, + } + ]; + + items.push(...buildPortLabelItems(nodeId, node)); + + items.push( DIVIDER, { label: 'View Code', @@ -145,7 +191,9 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { shortcut: 'Del', action: () => historyStore.mutate(() => graphStore.removeNode(nodeId)) } - ]; + ); + + return items; } // Check if this is a recording node (Scope or Spectrum) @@ -159,14 +207,19 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { icon: 'settings', shortcut: 'Dbl-click', action: () => openNodeDialog(nodeId) - }, + } + ]; + + items.push(...buildPortLabelItems(nodeId, node)); + + items.push( DIVIDER, { label: 'View Code', icon: 'braces', action: () => showBlockCode(nodeId) } - ]; + ); // Add CSV export for recording nodes if (isRecordingNode) { diff --git a/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte b/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte index be3eb955..af9bae29 100644 --- a/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte +++ b/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte @@ -58,6 +58,7 @@ { keys: ['H'], description: 'Go to root' }, { keys: ['+'], description: 'Zoom in' }, { keys: ['-'], description: 'Zoom out' }, + { keys: ['L'], description: 'Port labels' }, { keys: ['T'], description: 'Theme' } ] }, diff --git a/src/lib/components/icons/Icon.svelte b/src/lib/components/icons/Icon.svelte index b8cbc042..336c3626 100644 --- a/src/lib/components/icons/Icon.svelte +++ b/src/lib/components/icons/Icon.svelte @@ -415,6 +415,11 @@ +{:else if name === 'tag'} + + + + {:else if name === 'font-size-increase'} A diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index fb851536..0fded88a 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -8,12 +8,14 @@ import { graphStore } from '$lib/stores/graph'; import { historyStore } from '$lib/stores/history'; import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews'; + import { portLabelsStore } from '$lib/stores/portLabels'; import { hoveredHandle, selectedNodeHighlight } from '$lib/stores/hoveredHandle'; import { showTooltip, hideTooltip } from '$lib/components/Tooltip.svelte'; import { paramInput } from '$lib/actions/paramInput'; import { plotDataStore } from '$lib/plotting/processing/plotDataStore'; - import { NODE, getPortPositionCalc, calculateNodeDimensions, snapTo2G } from '$lib/constants/dimensions'; - import { containsMath, renderInlineMath, renderInlineMathSync, measureRenderedMath, getBaselineTextHeight } from '$lib/utils/inlineMathRenderer'; + import { PORT_LABEL, getPortPositionCalc, calculateNodeDimensions } from '$lib/constants/dimensions'; + import { truncatePortLabel } from '$lib/utils/portLabels'; + import { containsMath, renderInlineMath, renderInlineMathSync, measureRenderedMath } from '$lib/utils/inlineMathRenderer'; import { getKatexCssUrl } from '$lib/utils/katexLoader'; import PlotPreview from './PlotPreview.svelte'; @@ -58,9 +60,39 @@ hasPlotData = state.plots.has(id); }); + // Global port labels visibility + let globalShowPortLabels = $state(false); + const unsubscribePortLabels = portLabelsStore.subscribe((value) => { + globalShowPortLabels = value; + }); + + // Per-node overrides (undefined = follow global) + const nodeShowInputLabels = $derived(data.params?.['_showInputLabels'] as boolean | undefined); + const nodeShowOutputLabels = $derived(data.params?.['_showOutputLabels'] as boolean | undefined); + + // Effective visibility settings (per-node overrides global) + const showInputLabels = $derived(nodeShowInputLabels ?? globalShowPortLabels); + const showOutputLabels = $derived(nodeShowOutputLabels ?? globalShowPortLabels); + + // Actual visibility: setting is ON and ports exist (single source of truth) + const hasVisibleInputLabels = $derived(showInputLabels && data.inputs.length > 0); + const hasVisibleOutputLabels = $derived(showOutputLabels && data.outputs.length > 0); + + // For CSS class (show-labels when any labels are actually displayed) + const showPortLabels = $derived(hasVisibleInputLabels || hasVisibleOutputLabels); + + // Re-measure node when port labels toggle changes + $effect(() => { + // Dependency on showInputLabels and showOutputLabels + if (showInputLabels !== undefined || showOutputLabels !== undefined) { + updateNodeInternals(id); + } + }); + onDestroy(() => { unsubscribePinned(); unsubscribePlotData(); + unsubscribePortLabels(); if (hoverTimeout) clearTimeout(hoverTimeout); }); @@ -179,6 +211,13 @@ const maxPortsOnSide = $derived(Math.max(data.inputs.length, data.outputs.length)); const pinnedCount = $derived(validPinnedParams().length); + // Measured name dimensions for math rendering (null if not measured or no math) + const measuredName = $derived( + nameHasMath && measuredNameWidth !== null && measuredNameHeight !== null + ? { width: measuredNameWidth, height: measuredNameHeight } + : null + ); + // Node dimensions - calculated from shared utility (same as SvelteFlow bounds) const nodeDimensions = $derived(calculateNodeDimensions( data.name, @@ -186,57 +225,99 @@ data.outputs.length, pinnedCount, rotation, - typeDef?.name + typeDef?.name, + hasVisibleInputLabels, + hasVisibleOutputLabels, + measuredName )); - // Use measured width if math is rendered and measured, otherwise use calculated - const nodeWidth = $derived(() => { - if (measuredNameWidth !== null && nameHasMath) { - // For math names, use measured width instead of string-length estimate - // But still respect minimum width needed for ports, pinned params, type label - const isVertical = rotation === 1 || rotation === 3; - const maxPortsOnSide = Math.max(data.inputs.length, data.outputs.length); - const minPortDimension = Math.max(1, maxPortsOnSide) * NODE.portSpacing; - const typeWidth = typeDef ? typeDef.name.length * 5 + 20 : 0; - const pinnedParamsWidth = pinnedCount > 0 ? 160 : 0; - - // Minimum width for layout (without name string-length estimate) - const minLayoutWidth = snapTo2G(Math.max( - NODE.baseWidth, - typeWidth, - pinnedParamsWidth, - isVertical ? minPortDimension : 0 - )); - - // Add horizontal padding from .node-content (12px each side = 24px) - const measuredMathWidth = snapTo2G(measuredNameWidth + 24); - return Math.max(minLayoutWidth, measuredMathWidth); + + // Grid layout for port labels (computed in JS, replaces CSS grid-placement selectors) + const gridLayout = $derived(() => { + if (!showPortLabels) { + return { + columns: undefined, rows: undefined, + inputStyle: '', innerStyle: '', outputStyle: '' + }; } - return nodeDimensions.width; - }); - // Height calculation - only override for tall math (like \displaystyle) - // Compare measured math height to baseline text height for robustness - const nodeHeight = $derived(() => { - if (measuredNameHeight !== null && nameHasMath) { - // Get baseline height of standard text - only grow if math is significantly taller - const baselineHeight = getBaselineTextHeight(); - if (measuredNameHeight > baselineHeight * 1.2) { - const isVertical = rotation === 1 || rotation === 3; - const maxPortsOnSide = Math.max(data.inputs.length, data.outputs.length); - const minPortDimension = Math.max(1, maxPortsOnSide) * NODE.portSpacing; - - // Pinned params height: border(1) + padding(10) + rows(24 each) - const pinnedParamsHeight = pinnedCount > 0 ? 7 + 24 * pinnedCount : 0; - - // Content height: math height + type label (12px) + padding (12px) - const contentHeight = measuredNameHeight + 24 + pinnedParamsHeight; - - return isVertical - ? snapTo2G(contentHeight) - : snapTo2G(Math.max(contentHeight, minPortDimension)); + const labelSize = `${PORT_LABEL.columnWidth}px`; + let columns: string | undefined; + let rows: string | undefined; + let inputStyle = ''; + let innerStyle = ''; + let outputStyle = ''; + + if (isVertical) { + // Vertical: rows for labels, single column + const inputBorder = rotation === 1 ? 'border-bottom' : 'border-top'; + const outputBorder = rotation === 1 ? 'border-top' : 'border-bottom'; + const colStyle = 'grid-column: 1;'; + + if (hasVisibleInputLabels && hasVisibleOutputLabels) { + // rotation 1: input(row1) content(row2) output(row3) + // rotation 3: output(row1) content(row2) input(row3) + rows = `${labelSize} 1fr ${labelSize}`; + if (rotation === 1) { + inputStyle = `${colStyle} grid-row: 1; ${inputBorder}: 1px solid var(--border);`; + innerStyle = `${colStyle} grid-row: 2;`; + outputStyle = `${colStyle} grid-row: 3; ${outputBorder}: 1px solid var(--border);`; + } else { + outputStyle = `${colStyle} grid-row: 1; ${outputBorder}: 1px solid var(--border);`; + innerStyle = `${colStyle} grid-row: 2;`; + inputStyle = `${colStyle} grid-row: 3; ${inputBorder}: 1px solid var(--border);`; + } + } else if (hasVisibleInputLabels) { + rows = rotation === 1 ? `${labelSize} 1fr` : `1fr ${labelSize}`; + const inputRow = rotation === 1 ? 1 : 2; + const innerRow = rotation === 1 ? 2 : 1; + inputStyle = `${colStyle} grid-row: ${inputRow}; ${inputBorder}: 1px solid var(--border);`; + innerStyle = `${colStyle} grid-row: ${innerRow};`; + } else if (hasVisibleOutputLabels) { + rows = rotation === 1 ? `1fr ${labelSize}` : `${labelSize} 1fr`; + const outputRow = rotation === 1 ? 2 : 1; + const innerRow = rotation === 1 ? 1 : 2; + outputStyle = `${colStyle} grid-row: ${outputRow}; ${outputBorder}: 1px solid var(--border);`; + innerStyle = `${colStyle} grid-row: ${innerRow};`; + } + } else { + // Horizontal: columns for labels, single row + const rowStyle = 'grid-row: 1;'; + // rotation 0: inputs left (border-right), outputs right (border-left) + // rotation 2: inputs right (border-left), outputs left (border-right) + const inputBorder = rotation === 0 ? 'border-right' : 'border-left'; + const outputBorder = rotation === 0 ? 'border-left' : 'border-right'; + + if (hasVisibleInputLabels && hasVisibleOutputLabels) { + columns = `${labelSize} 1fr ${labelSize}`; + if (rotation === 0) { + // input(col1) content(col2) output(col3) + inputStyle = `${rowStyle} grid-column: 1; ${inputBorder}: 1px solid var(--border);`; + innerStyle = `${rowStyle} grid-column: 2;`; + outputStyle = `${rowStyle} grid-column: 3; ${outputBorder}: 1px solid var(--border);`; + } else { + // output(col1) content(col2) input(col3) + outputStyle = `${rowStyle} grid-column: 1; ${outputBorder}: 1px solid var(--border);`; + innerStyle = `${rowStyle} grid-column: 2;`; + inputStyle = `${rowStyle} grid-column: 3; ${inputBorder}: 1px solid var(--border);`; + } + } else if (hasVisibleInputLabels) { + // rotation 0: input(col1) content(col2) | rotation 2: content(col1) input(col2) + columns = rotation === 0 ? `${labelSize} 1fr` : `1fr ${labelSize}`; + const inputCol = rotation === 0 ? 1 : 2; + const innerCol = rotation === 0 ? 2 : 1; + inputStyle = `${rowStyle} grid-column: ${inputCol}; ${inputBorder}: 1px solid var(--border);`; + innerStyle = `${rowStyle} grid-column: ${innerCol};`; + } else if (hasVisibleOutputLabels) { + // rotation 0: content(col1) output(col2) | rotation 2: output(col1) content(col2) + columns = rotation === 0 ? `1fr ${labelSize}` : `${labelSize} 1fr`; + const outputCol = rotation === 0 ? 2 : 1; + const innerCol = rotation === 0 ? 1 : 2; + outputStyle = `${rowStyle} grid-column: ${outputCol}; ${outputBorder}: 1px solid var(--border);`; + innerStyle = `${rowStyle} grid-column: ${innerCol};`; } } - return nodeDimensions.height; + + return { columns, rows, inputStyle, innerStyle, outputStyle }; }); // Check if this is a Subsystem or Interface node (using shapes utility) @@ -379,8 +460,9 @@ class:vertical={isVertical} class:preview-hovered={showPreview} class:subsystem-type={isSubsystemType} + class:show-labels={showPortLabels} data-rotation={rotation} - style="width: {nodeWidth()}px; height: {nodeHeight()}px; --node-color: {nodeColor};" + style="width: {nodeDimensions.width}px; height: {nodeDimensions.height}px; --node-color: {nodeColor};" ondblclick={handleDoubleClick} onmouseenter={handleMouseEnter} onmouseleave={handleMouseLeave} @@ -400,43 +482,92 @@
{/if} - -
- -
- {#if renderedNameHtml} - {@html renderedNameHtml} + +
+ + {#if hasVisibleInputLabels} + {#if isVertical} +
+ {#each data.inputs as port, i} + + {truncatePortLabel(port.name)} + + {/each} +
{:else} - {data.name} +
+ {#each data.inputs as port, i} + + {truncatePortLabel(port.name)} + + {/each} +
{/if} - {#if typeDef} - {typeDef.name} + {/if} + + +
+ +
+ {#if renderedNameHtml} + {@html renderedNameHtml} + {:else} + {data.name} + {/if} + {#if typeDef} + {typeDef.name} + {/if} +
+ + + {#if validPinnedParams().length > 0 && typeDef} + +
e.stopPropagation()} ondblclick={(e) => e.stopPropagation()}> + {#each validPinnedParams() as paramName} + {@const paramDef = typeDef.params.find(p => p.name === paramName)} + {#if paramDef} +
+ + handlePinnedParamChange(paramName, e.currentTarget.value)} + onmousedown={(e) => e.stopPropagation()} + onfocus={(e) => e.stopPropagation()} + use:paramInput + /> +
+ {/if} + {/each} +
{/if}
- - {#if validPinnedParams().length > 0 && typeDef} - -
e.stopPropagation()} ondblclick={(e) => e.stopPropagation()}> - {#each validPinnedParams() as paramName} - {@const paramDef = typeDef.params.find(p => p.name === paramName)} - {#if paramDef} -
- - handlePinnedParamChange(paramName, e.currentTarget.value)} - onmousedown={(e) => e.stopPropagation()} - onfocus={(e) => e.stopPropagation()} - use:paramInput - /> -
- {/if} - {/each} -
+ + {#if hasVisibleOutputLabels} + {#if isVertical} +
+ {#each data.outputs as port, i} + + {truncatePortLabel(port.name)} + + {/each} +
+ {:else} +
+ {#each data.outputs as port, i} + + {truncatePortLabel(port.name)} + + {/each} +
+ {/if} {/if}
@@ -492,29 +623,32 @@ position: relative; /* Dimensions set via inline style using grid constants */ /* Note: center-origin handled by SvelteFlow's nodeOrigin={[0.5, 0.5]} */ - display: flex; - flex-direction: column; background: var(--surface-raised); border: 1px solid var(--edge); font-size: 10px; - overflow: visible; + overflow: visible; /* Allow handles to extend outside */ + --node-radius: 8px; } - /* Shape variants */ + /* Shape variants - set both border-radius and custom property for inner clipping */ .shape-pill { - border-radius: 20px; + --node-radius: 20px; + border-radius: var(--node-radius); } .shape-rect { - border-radius: 4px; + --node-radius: 4px; + border-radius: var(--node-radius); } .shape-circle { - border-radius: 16px; + --node-radius: 16px; + border-radius: var(--node-radius); } .shape-diamond { - border-radius: 4px; + --node-radius: 4px; + border-radius: var(--node-radius); transform: rotate(45deg); } @@ -523,11 +657,13 @@ } .shape-mixed { + --node-radius: 12px; border-radius: 12px 4px 12px 4px; } .shape-default { - border-radius: 8px; + --node-radius: 8px; + border-radius: var(--node-radius); } /* Subsystem/Interface dashed border */ @@ -550,13 +686,22 @@ z-index: 1000 !important; } - /* Inner wrapper for content - fills node, clips to rounded corners */ + /* Clip wrapper - fills node, clips content to rounded corners */ + .node-clip { + position: absolute; + inset: 0; + overflow: hidden; + border-radius: max(0px, calc(var(--node-radius, 8px) - 1px)); + display: flex; + flex-direction: column; + } + + /* Inner wrapper for content */ .node-inner { flex: 1; display: flex; flex-direction: column; - border-radius: inherit; - overflow: hidden; + min-width: 0; min-height: 0; } @@ -613,21 +758,24 @@ margin-top: 2px; } - /* Pinned parameters */ + /* Pinned parameters - rectangular, clipped by node-clip's overflow:hidden */ .pinned-params { display: flex; flex-direction: column; gap: 4px; - padding: 4px 10px 6px; + padding: 4px 8px 6px; border-top: 1px solid var(--border); background: var(--surface); + border-radius: 0; + overflow: hidden; } .pinned-param { display: flex; align-items: center; - gap: 6px; + gap: 4px; min-width: 0; + max-width: 100%; } .pinned-param label { @@ -644,7 +792,7 @@ flex: 1; min-width: 0; height: 20px; - padding: 2px 8px; + padding: 2px 6px; font-size: 8px; font-family: var(--font-mono); background: var(--surface-raised); @@ -870,4 +1018,94 @@ opacity: 1; } } + + /* Port labels - grid layout when labels are shown */ + /* Grid template columns/rows are set via inline style from JS */ + .node.show-labels .node-clip { + display: grid; + } + + /* Label containers */ + .port-labels { + position: relative; + min-width: 0; + min-height: 0; + overflow: visible; + } + + /* Individual port labels (absolute positioning for horizontal) */ + .port-label { + position: absolute; + font-size: 8px; + color: var(--text-muted); + white-space: nowrap; + transform: translateY(-50%); + overflow: hidden; + text-overflow: ellipsis; + max-width: 36px; + line-height: 1; + } + + /* Input labels: align right (near separator), away from handle edge */ + .port-labels-input .port-label { + right: 6px; + text-align: right; + } + + /* Output labels: align left (near separator), away from handle edge */ + .port-labels-output .port-label { + left: 6px; + text-align: left; + } + + /* Rotation 2: swap alignment */ + .node[data-rotation="2"] .port-labels-input .port-label { + right: auto; + left: 6px; + text-align: left; + } + .node[data-rotation="2"] .port-labels-output .port-label { + left: auto; + right: 6px; + text-align: right; + } + + /* Vertical rotation - row of labels with 90deg rotation */ + .port-labels-row { + position: relative; + } + + /* Reset horizontal-specific styles for vertical labels */ + .port-labels-row .port-label { + position: absolute; + width: auto; + max-width: none; + right: auto; + /* Use center origin for simpler positioning */ + transform-origin: center center; + /* text-align: left = text starts at original left edge = visual bottom after -90deg rotation */ + text-align: left; + } + + /* Input labels at top row: center vertically, shift toward bottom separator */ + .node.show-labels.vertical .port-labels-input .port-label { + top: 50%; + bottom: auto; + transform: translateX(-50%) translateY(calc(-50% + 6px)) rotate(-90deg); + } + + /* Output labels at bottom row: center vertically, shift toward top separator */ + .node.show-labels.vertical .port-labels-output .port-label { + top: 50%; + bottom: auto; + transform: translateX(-50%) translateY(calc(-50% - 6px)) rotate(-90deg); + } + + /* Rotation 3: swap the vertical shifts */ + .node.show-labels.vertical[data-rotation="3"] .port-labels-input .port-label { + transform: translateX(-50%) translateY(calc(-50% - 6px)) rotate(-90deg); + } + .node.show-labels.vertical[data-rotation="3"] .port-labels-output .port-label { + transform: translateX(-50%) translateY(calc(-50% + 6px)) rotate(-90deg); + } diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index f95c5b24..83500862 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -42,6 +42,14 @@ export const EVENT = { /** Export padding: 4 grid units = 40px */ export const EXPORT_PADDING = G.x4; +/** Port label dimensions (when labels are shown) */ +export const PORT_LABEL = { + /** Width of label column for horizontal ports: 4 grid units = 40px */ + columnWidth: G.x4, + /** Height of label row for vertical ports: 4 grid units = 40px (same as column width) */ + rowHeight: G.x4 +} as const; + /** * Round up to next 2G (20px) boundary. * This ensures nodes expand by 1G in each direction (symmetric from center). @@ -50,6 +58,23 @@ export function snapTo2G(value: number): number { return Math.ceil(value / G.x2) * G.x2; } +/** + * Calculate port offset from center in pixels. + * Used for SVG rendering and numeric calculations. + * + * @param index - Port index (0-based) + * @param total - Total number of ports on this edge + * @returns Offset in pixels from center (negative = before center, positive = after) + */ +export function getPortOffset(index: number, total: number): number { + if (total <= 0 || total === 1) { + return 0; // Single port at center + } + // For N ports with spacing S: span = (N-1)*S, offset from center = -span/2 + i*S + const span = (total - 1) * NODE.portSpacing; + return -span / 2 + index * NODE.portSpacing; +} + /** * Calculate port position as CSS calc() expression. * Uses offset from center to ensure grid alignment regardless of node size, @@ -60,21 +85,23 @@ export function snapTo2G(value: number): number { * @returns CSS position value (e.g., "50%" or "calc(50% + 10px)") */ export function getPortPositionCalc(index: number, total: number): string { - if (total <= 0 || total === 1) { - return '50%'; // Single port at center - } - // For N ports with spacing S: span = (N-1)*S, offset from center = -span/2 + i*S - const span = (total - 1) * NODE.portSpacing; - const offsetFromCenter = -span / 2 + index * NODE.portSpacing; - if (offsetFromCenter === 0) { + const offset = getPortOffset(index, total); + if (offset === 0) { return '50%'; } - return `calc(50% + ${offsetFromCenter}px)`; + return `calc(50% + ${offset}px)`; } +/** Baseline text height for comparing math rendering (approximate) */ +const BASELINE_TEXT_HEIGHT = 14; + /** * Calculate node dimensions from node data. * Used by both SvelteFlow (for bounds) and BaseNode (for CSS). + * + * @param hasVisibleInputLabels - True if input labels are visible (setting ON and inputs exist) + * @param hasVisibleOutputLabels - True if output labels are visible (setting ON and outputs exist) + * @param measuredName - Optional measured dimensions for math-rendered names */ export function calculateNodeDimensions( name: string, @@ -82,22 +109,30 @@ export function calculateNodeDimensions( outputCount: number, pinnedParamCount: number, rotation: number, - typeName?: string + typeName?: string, + hasVisibleInputLabels?: boolean, + hasVisibleOutputLabels?: boolean, + measuredName?: { width: number; height: number } | null ): { width: number; height: number } { const isVertical = rotation === 1 || rotation === 3; const maxPortsOnSide = Math.max(inputCount, outputCount); const minPortDimension = Math.max(1, maxPortsOnSide) * NODE.portSpacing; - // Pinned params height: border(1) + padding(10) + rows(20 each) + gaps(4 between) + // Pinned params dimensions const pinnedParamsHeight = pinnedParamCount > 0 ? 7 + 24 * pinnedParamCount : 0; + const pinnedParamsWidth = pinnedParamCount > 0 ? 160 : 0; - // Width: base, name estimate, type name estimate, pinned params minimum, port dimension (if vertical) - // Name uses 10px font (~6px per char), type uses 8px font (~5px per char), plus padding for node margins - // Use slightly larger estimates to ensure text fits (ceil behavior) - const nameWidth = name.length * 6 + 20; + // Type label width estimate (8px font, ~5px per char) const typeWidth = typeName ? typeName.length * 5 + 20 : 0; - const pinnedParamsWidth = pinnedParamCount > 0 ? 160 : 0; - const width = snapTo2G(Math.max( + + // Name width: use measured if available, otherwise estimate (10px font, ~6px per char) + // Add 24px for horizontal padding in .node-content (12px each side) + const nameWidth = measuredName + ? snapTo2G(measuredName.width + 24) + : name.length * 6 + 20; + + // Content width (without port labels) + let contentWidth = snapTo2G(Math.max( NODE.baseWidth, nameWidth, typeWidth, @@ -105,11 +140,30 @@ export function calculateNodeDimensions( isVertical ? minPortDimension : 0 )); - // Height: content height vs port dimension (they share vertical space) - const contentHeight = NODE.baseHeight + pinnedParamsHeight; - const height = isVertical + // Content height: check if math is significantly taller than baseline text + let contentHeight: number; + if (measuredName && measuredName.height > BASELINE_TEXT_HEIGHT * 1.2) { + // Math is tall (e.g., \displaystyle fractions) - use measured height + type label + padding + contentHeight = measuredName.height + 24 + pinnedParamsHeight; + } else { + // Normal text height + contentHeight = NODE.baseHeight + pinnedParamsHeight; + } + + // Final dimensions accounting for port space + let width = contentWidth; + let height = isVertical ? snapTo2G(contentHeight) : snapTo2G(Math.max(contentHeight, minPortDimension)); + // Add space for port labels if visible + if (isVertical) { + if (hasVisibleInputLabels) height += PORT_LABEL.rowHeight; + if (hasVisibleOutputLabels) height += PORT_LABEL.rowHeight; + } else { + if (hasVisibleInputLabels) width += PORT_LABEL.columnWidth; + if (hasVisibleOutputLabels) width += PORT_LABEL.columnWidth; + } + return { width, height }; } diff --git a/src/lib/constants/theme.ts b/src/lib/constants/theme.ts index fd58fa02..4165cda0 100644 --- a/src/lib/constants/theme.ts +++ b/src/lib/constants/theme.ts @@ -17,6 +17,8 @@ export interface ThemeColors { text: string; /** Muted text color */ textMuted: string; + /** Disabled text color (lighter than muted) */ + textDisabled: string; /** Accent color (default node color) */ accent: string; } @@ -27,18 +29,20 @@ export const THEMES: Record<'light' | 'dark', ThemeColors> = { surface: '#08080c', surfaceRaised: '#1c1c26', border: 'rgba(255, 255, 255, 0.08)', - edge: '#7F7F7F', + edge: '#808090', text: '#f0f0f5', textMuted: '#808090', + textDisabled: '#505060', accent: '#0070C0' }, light: { surface: '#f0f0f4', surfaceRaised: '#ffffff', border: 'rgba(0, 0, 0, 0.10)', - edge: '#7F7F7F', + edge: '#808090', // inherits from :root text: '#1a1a1f', - textMuted: '#606068', + textMuted: '#808090', // inherits from :root + textDisabled: '#909098', accent: '#0070C0' } } as const; diff --git a/src/lib/export/svg/renderer.ts b/src/lib/export/svg/renderer.ts index a7e2d851..fc402f4d 100644 --- a/src/lib/export/svg/renderer.ts +++ b/src/lib/export/svg/renderer.ts @@ -12,8 +12,10 @@ import { get } from 'svelte/store'; import { graphStore } from '$lib/stores/graph'; import { eventStore } from '$lib/stores/events'; import { getThemeColors } from '$lib/constants/theme'; -import { NODE, EVENT } from '$lib/constants/dimensions'; +import { NODE, EVENT, PORT_LABEL, getPortOffset } from '$lib/constants/dimensions'; import { getHandlePath } from '$lib/constants/handlePaths'; +import { portLabelsStore } from '$lib/stores/portLabels'; +import { getEffectivePortLabelVisibility, truncatePortLabel } from '$lib/utils/portLabels'; import { latexToSvg, getSvgDimensions, preloadMathJax } from '$lib/utils/mathjaxSvg'; // Preload MathJax when module loads @@ -199,6 +201,143 @@ function renderHandles(nodeId: string, nodeX: number, nodeY: number, ctx: Render return handles.join('\n'); } +// ============================================================================ +// PORT LABEL RENDERING +// ============================================================================ + +/** + * Resolve showPortLabels option: 'auto' reads from store, otherwise use boolean value + */ +function resolveShowPortLabels(option: boolean | 'auto'): boolean { + if (option === 'auto') { + return portLabelsStore.get(); + } + return option; +} + +/** + * Render port labels for a node as SVG text + separator lines. + * Returns SVG string and the pixel offsets consumed by label columns/rows + * (used to shift content center). + */ +function renderPortLabels( + node: NodeInstance, + x: number, + y: number, + width: number, + height: number, + globalShowLabels: boolean, + ctx: RenderContext +): { svg: string; inputOffset: number; outputOffset: number } { + const { inputs: hasInputLabels, outputs: hasOutputLabels } = getEffectivePortLabelVisibility(node, globalShowLabels); + if (!hasInputLabels && !hasOutputLabels) { + return { svg: '', inputOffset: 0, outputOffset: 0 }; + } + + const parts: string[] = []; + const rotation = (node.params?.['_rotation'] as number) || 0; + const isVertical = rotation === 1 || rotation === 3; + const labelColumnWidth = PORT_LABEL.columnWidth; + + if (isVertical) { + // Vertical: label rows at top/bottom + // rotation 1: inputs top, outputs bottom + // rotation 3: inputs bottom, outputs top + const inputRowY = rotation === 1 + ? y // top + : y + height - labelColumnWidth; // bottom + const outputRowY = rotation === 1 + ? y + height - labelColumnWidth // bottom + : y; // top + + if (hasInputLabels) { + // Separator line + const sepY = rotation === 1 ? inputRowY + labelColumnWidth : inputRowY; + parts.push(``); + + // Port label text (rotated -90deg) + const centerY = inputRowY + labelColumnWidth / 2; + for (let i = 0; i < node.inputs.length; i++) { + const offset = getPortOffset(i, node.inputs.length); + const labelX = x + width / 2 + offset; + const label = truncatePortLabel(node.inputs[i].name); + parts.push(`${escapeXml(label)}`); + } + } + + if (hasOutputLabels) { + // Separator line + const sepY = rotation === 1 ? outputRowY : outputRowY + labelColumnWidth; + parts.push(``); + + // Port label text (rotated -90deg) + const centerY = outputRowY + labelColumnWidth / 2; + for (let i = 0; i < node.outputs.length; i++) { + const offset = getPortOffset(i, node.outputs.length); + const labelX = x + width / 2 + offset; + const label = truncatePortLabel(node.outputs[i].name); + parts.push(`${escapeXml(label)}`); + } + } + + return { + svg: parts.join('\n'), + inputOffset: hasInputLabels ? labelColumnWidth : 0, + outputOffset: hasOutputLabels ? labelColumnWidth : 0 + }; + } else { + // Horizontal: label columns at left/right + // rotation 0: inputs left, outputs right + // rotation 2: inputs right, outputs left + const inputColX = rotation === 0 + ? x // left + : x + width - labelColumnWidth; // right + const outputColX = rotation === 0 + ? x + width - labelColumnWidth // right + : x; // left + + if (hasInputLabels) { + // Separator line + const sepX = rotation === 0 ? inputColX + labelColumnWidth : inputColX; + parts.push(``); + + // Port label text + // rotation 0: align right (near separator), rotation 2: align left + const textAnchor = rotation === 0 ? 'end' : 'start'; + const textX = rotation === 0 ? inputColX + labelColumnWidth - 6 : inputColX + 6; + for (let i = 0; i < node.inputs.length; i++) { + const offset = getPortOffset(i, node.inputs.length); + const labelY = y + height / 2 + offset; + const label = truncatePortLabel(node.inputs[i].name); + parts.push(`${escapeXml(label)}`); + } + } + + if (hasOutputLabels) { + // Separator line + const sepX = rotation === 0 ? outputColX : outputColX + labelColumnWidth; + parts.push(``); + + // Port label text + // rotation 0: align left (near separator), rotation 2: align right + const textAnchor = rotation === 0 ? 'start' : 'end'; + const textX = rotation === 0 ? outputColX + 6 : outputColX + labelColumnWidth - 6; + for (let i = 0; i < node.outputs.length; i++) { + const offset = getPortOffset(i, node.outputs.length); + const labelY = y + height / 2 + offset; + const label = truncatePortLabel(node.outputs[i].name); + parts.push(`${escapeXml(label)}`); + } + } + + return { + svg: parts.join('\n'), + inputOffset: hasInputLabels ? labelColumnWidth : 0, + outputOffset: hasOutputLabels ? labelColumnWidth : 0 + }; + } +} + // ============================================================================ // NODE RENDERING - Pure SVG with DOM-read styles // ============================================================================ @@ -246,20 +385,56 @@ async function renderNode(node: NodeInstance, ctx: RenderContext): Promise` ); + // Port labels + const showPortLabels = resolveShowPortLabels(ctx.options.showPortLabels); + const portLabelResult = showPortLabels + ? renderPortLabels(node, x, y, width, height, showPortLabels, ctx) + : { svg: '', inputOffset: 0, outputOffset: 0 }; + if (portLabelResult.svg) { + parts.push(portLabelResult.svg); + } + // Check for pinned params section in DOM const pinnedParamsEl = nodeEl.querySelector('.pinned-params') as HTMLElement; - // Calculate content center (above pinned params if present) + // Calculate content center, accounting for port label columns/rows and pinned params + const rotation = (node.params?.['_rotation'] as number) || 0; + const isVerticalNode = rotation === 1 || rotation === 3; + + let contentCenterX = x + width / 2; let contentCenterY = y + height / 2; + + // Shift content center for port label columns/rows + if (isVerticalNode) { + // Vertical: label rows shift Y center + const topOffset = (rotation === 1 ? portLabelResult.inputOffset : portLabelResult.outputOffset); + const bottomOffset = (rotation === 1 ? portLabelResult.outputOffset : portLabelResult.inputOffset); + const contentTop = y + topOffset; + const contentBottom = y + height - bottomOffset; + contentCenterY = (contentTop + contentBottom) / 2; + } else { + // Horizontal: label columns shift X center + const leftOffset = (rotation === 0 ? portLabelResult.inputOffset : portLabelResult.outputOffset); + const rightOffset = (rotation === 0 ? portLabelResult.outputOffset : portLabelResult.inputOffset); + const contentLeft = x + leftOffset; + const contentRight = x + width - rightOffset; + contentCenterX = (contentLeft + contentRight) / 2; + } + if (pinnedParamsEl) { + // pinnedTop is from DOM, already accounts for grid layout including port label rows const pinnedRect = pinnedParamsEl.getBoundingClientRect(); - const pinnedTop = (pinnedRect.top - nodeRect.top) / zoom; - contentCenterY = y + pinnedTop / 2; + const pinnedTopFromNode = (pinnedRect.top - nodeRect.top) / zoom; + // Content area is from content start to pinned params top + const contentAreaTop = isVerticalNode + ? y + (rotation === 1 ? portLabelResult.inputOffset : portLabelResult.outputOffset) + : y; + contentCenterY = (contentAreaTop + y + pinnedTopFromNode) / 2; } // Labels if (ctx.options.showLabels) { - const centerX = x + width / 2; + const centerX = contentCenterX; if (ctx.options.showTypeLabels && nodeType) { // Name above center (may contain math) - use original node.name for LaTeX source diff --git a/src/lib/export/svg/types.ts b/src/lib/export/svg/types.ts index 9b54a7cb..904cd231 100644 --- a/src/lib/export/svg/types.ts +++ b/src/lib/export/svg/types.ts @@ -19,6 +19,8 @@ export interface ExportOptions { showTypeLabels?: boolean; /** Whether to render handle shapes (default: true) */ showHandles?: boolean; + /** Whether to render port labels: true, false, or 'auto' to match canvas state (default: 'auto') */ + showPortLabels?: boolean | 'auto'; } /** Render context passed to all renderers */ @@ -44,5 +46,6 @@ export const DEFAULT_OPTIONS: Required = { padding: EXPORT_PADDING, showLabels: true, showTypeLabels: true, - showHandles: true + showHandles: true, + showPortLabels: 'auto' }; diff --git a/src/lib/nodes/uiConfig.ts b/src/lib/nodes/uiConfig.ts index f7e367e5..a697c86d 100644 --- a/src/lib/nodes/uiConfig.ts +++ b/src/lib/nodes/uiConfig.ts @@ -9,6 +9,28 @@ export interface PortLabelConfig { param: string; direction: 'input' | 'output'; + /** Optional custom parser to convert param value to label strings. + * Default uses parsePythonList (for ["a", "b"] format). */ + parser?: (value: unknown) => string[] | null; +} + +/** + * Parse an operations string into individual character labels. + * Handles Python-style quoted strings: '+-' or "+-" → ['+', '-'] + * Also handles unquoted: +- → ['+', '-'] + */ +function parseOperationsString(value: unknown): string[] | null { + if (value === null || value === undefined || value === 'None' || value === '') { + return null; + } + let str = String(value).trim(); + if (str.length === 0) return null; + // Strip surrounding Python quotes (single or double) + if ((str.startsWith("'") && str.endsWith("'")) || (str.startsWith('"') && str.endsWith('"'))) { + str = str.slice(1, -1); + } + if (str.length === 0) return null; + return [...str]; } /** @@ -18,7 +40,8 @@ export interface PortLabelConfig { */ export const portLabelParams: Record = { Scope: { param: 'labels', direction: 'input' }, - Spectrum: { param: 'labels', direction: 'input' } + Spectrum: { param: 'labels', direction: 'input' }, + Adder: { param: 'operations', direction: 'input', parser: parseOperationsString } }; /** diff --git a/src/lib/stores/graph/nodes.ts b/src/lib/stores/graph/nodes.ts index 7637fc53..36071911 100644 --- a/src/lib/stores/graph/nodes.ts +++ b/src/lib/stores/graph/nodes.ts @@ -211,7 +211,7 @@ export function updateNodeParams(id: string, params: Record): v if (node) { for (const config of getPortLabelConfigs(node.type)) { if (config.param in params) { - syncPortNamesFromLabels(id, params[config.param], config.direction); + syncPortNamesFromLabels(id, params[config.param], config.direction, config.parser); } } } diff --git a/src/lib/stores/graph/ports.ts b/src/lib/stores/graph/ports.ts index 503b9c88..5ddd916f 100644 --- a/src/lib/stores/graph/ports.ts +++ b/src/lib/stores/graph/ports.ts @@ -278,13 +278,14 @@ function parsePythonList(value: unknown): string[] | null { export function syncPortNamesFromLabels( nodeId: string, labelsValue: unknown, - direction: 'input' | 'output' + direction: 'input' | 'output', + parser?: (value: unknown) => string[] | null ): void { const currentGraph = getCurrentGraph(); const node = currentGraph.nodes.get(nodeId); if (!node) return; - const labels = parsePythonList(labelsValue); + const labels = parser ? parser(labelsValue) : parsePythonList(labelsValue); const config = getPortConfig(direction); const currentPorts = node[config.portsKey] as PortInstance[]; diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index 54eb9348..93368c75 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -19,6 +19,7 @@ export { contextMenuStore } from './contextMenu'; export { nodeUpdatesStore } from './nodeUpdates'; export { pinnedPreviewsStore } from './pinnedPreviews'; export { hoveredHandle, selectedNodeHighlight } from './hoveredHandle'; +export { portLabelsStore } from './portLabels'; // View actions (re-exports triggers and utils) export * from './viewActions'; diff --git a/src/lib/stores/portLabels.ts b/src/lib/stores/portLabels.ts new file mode 100644 index 00000000..4e8722cd --- /dev/null +++ b/src/lib/stores/portLabels.ts @@ -0,0 +1,38 @@ +/** + * Port Labels Store + * + * Controls global visibility of port labels inside nodes. + * Toggle with 'L' key. Persists to localStorage. + */ + +import { writable, get } from 'svelte/store'; +import { browser } from '$app/environment'; + +const STORAGE_KEY = 'pathview-portLabels'; + +function getInitialValue(): boolean { + if (!browser) return false; + return localStorage.getItem(STORAGE_KEY) === 'true'; +} + +const store = writable(getInitialValue()); + +// Persist to localStorage on change +store.subscribe((value) => { + if (browser) { + localStorage.setItem(STORAGE_KEY, String(value)); + } +}); + +export const portLabelsStore = { + subscribe: store.subscribe, + toggle(): void { + store.update((current) => !current); + }, + set(value: boolean): void { + store.set(value); + }, + get(): boolean { + return get(store); + } +}; diff --git a/src/lib/utils/portLabels.ts b/src/lib/utils/portLabels.ts new file mode 100644 index 00000000..67acca7c --- /dev/null +++ b/src/lib/utils/portLabels.ts @@ -0,0 +1,42 @@ +/** + * Port Labels Utility + * + * Shared logic for determining port label visibility. + * Used by both BaseNode.svelte (canvas) and SVG renderer (export). + */ + +import type { NodeInstance } from '$lib/types/nodes'; + +/** + * Get effective port label visibility for a node. + * Per-node settings override global setting. + * + * @param node - The node instance + * @param globalShowLabels - Global port labels setting (from portLabelsStore) + * @returns Object with hasVisibleInputLabels and hasVisibleOutputLabels + */ +export function getEffectivePortLabelVisibility( + node: NodeInstance, + globalShowLabels: boolean +): { inputs: boolean; outputs: boolean } { + // Per-node overrides (undefined = follow global) + const inputSetting = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalShowLabels; + const outputSetting = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalShowLabels; + + // Actual visibility: setting is ON and ports exist + return { + inputs: inputSetting && node.inputs.length > 0, + outputs: outputSetting && node.outputs.length > 0 + }; +} + +/** + * Truncate port label for display. + * + * @param name - Port name + * @param maxChars - Maximum characters (default: 5) + * @returns Truncated name + */ +export function truncatePortLabel(name: string, maxChars: number = 5): string { + return name.length > maxChars ? name.slice(0, maxChars) : name; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7f755dcc..e9de62d2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -48,6 +48,7 @@ import { triggerFitView, triggerZoomIn, triggerZoomOut, triggerPan, getViewportCenter, screenToFlow, triggerClearSelection, triggerNudge, hasAnySelection, setFitViewPadding, triggerFlyInAnimation } from '$lib/stores/viewActions'; import { nodeUpdatesStore } from '$lib/stores/nodeUpdates'; import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews'; + import { portLabelsStore } from '$lib/stores/portLabels'; import { clipboardStore } from '$lib/stores/clipboard'; import Tooltip, { tooltip } from '$lib/components/Tooltip.svelte'; import { isInputFocused } from '$lib/utils/focus'; @@ -653,6 +654,10 @@ event.preventDefault(); pinnedPreviewsStore.toggle(); return; + case 'l': + event.preventDefault(); + portLabelsStore.toggle(); + return; case 'b': event.preventDefault(); toggleNodeLibrary();