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..dc1ed488 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -25,6 +25,7 @@ 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'; /** Divider menu item */ const DIVIDER: MenuItemType = { label: '', action: () => {}, divider: true }; @@ -73,7 +74,13 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { // Interface blocks have limited options if (isInterface) { - return [ + const globalLabels = get(portLabelsStore); + const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; + const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + const hasInputs = node.inputs && node.inputs.length > 0; + const hasOutputs = node.outputs && node.outputs.length > 0; + + const items: MenuItemType[] = [ { label: 'Properties', icon: 'settings', @@ -84,19 +91,52 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { label: 'Exit Subsystem', icon: 'exit', action: () => graphStore.drillUp() - }, + } + ]; + + if (hasInputs || hasOutputs) { + items.push(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 }) + ) + }); + } + } + + items.push( DIVIDER, { label: 'View Code', icon: 'braces', action: () => showBlockCode(nodeId) } - ]; + ); + + return items; } // Subsystem blocks get "Enter" option if (isSubsystem) { - return [ + const globalLabels = get(portLabelsStore); + const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; + const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + const hasInputs = node.inputs && node.inputs.length > 0; + const hasOutputs = node.outputs && node.outputs.length > 0; + + const items: MenuItemType[] = [ { label: 'Properties', icon: 'settings', @@ -107,7 +147,32 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { icon: 'enter', shortcut: 'Dbl-click', action: () => graphStore.drillDown(nodeId) - }, + } + ]; + + if (hasInputs || hasOutputs) { + items.push(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 }) + ) + }); + } + } + + items.push( DIVIDER, { label: 'View Code', @@ -145,13 +210,22 @@ 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) const isRecordingNode = node.type === 'Scope' || node.type === 'Spectrum'; const dataSource = node.type === 'Scope' ? 'scope' : 'spectrum'; + // Per-node port label visibility (undefined = follow global) + const globalLabels = get(portLabelsStore); + const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; + const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + const hasInputs = node.inputs && node.inputs.length > 0; + const hasOutputs = node.outputs && node.outputs.length > 0; + // Regular blocks const items: MenuItemType[] = [ { @@ -159,14 +233,39 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { icon: 'settings', shortcut: 'Dbl-click', action: () => openNodeDialog(nodeId) - }, + } + ]; + + if (hasInputs || hasOutputs) { + items.push(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 }) + ) + }); + } + } + + 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..e14dac84 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,44 @@ 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); - } - 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)); + // Grid template for port labels layout (computed in JS instead of CSS selectors) + const gridTemplate = $derived(() => { + if (!showPortLabels) return { columns: undefined, rows: undefined }; + + const labelSize = `${PORT_LABEL.columnWidth}px`; + + if (isVertical) { + // Vertical layout: rows for input/output labels + if (hasVisibleInputLabels && hasVisibleOutputLabels) { + // Both: [input-labels] [content] [output-labels] + return { columns: undefined, rows: `${labelSize} 1fr ${labelSize}` }; + } else if (hasVisibleInputLabels) { + // Input only: rotation 1 = top, rotation 3 = bottom + return { columns: undefined, rows: rotation === 1 ? `${labelSize} 1fr` : `1fr ${labelSize}` }; + } else if (hasVisibleOutputLabels) { + // Output only: rotation 1 = bottom, rotation 3 = top + return { columns: undefined, rows: rotation === 1 ? `1fr ${labelSize}` : `${labelSize} 1fr` }; + } + } else { + // Horizontal layout: columns for input/output labels + if (hasVisibleInputLabels && hasVisibleOutputLabels) { + // Both: [input-labels] [content] [output-labels] + return { columns: `${labelSize} 1fr ${labelSize}`, rows: undefined }; + } else if (hasVisibleInputLabels) { + // Input only: rotation 0 = left, rotation 2 = right + return { columns: rotation === 0 ? `${labelSize} 1fr` : `1fr ${labelSize}`, rows: undefined }; + } else if (hasVisibleOutputLabels) { + // Output only: rotation 0 = right, rotation 2 = left + return { columns: rotation === 0 ? `1fr ${labelSize}` : `${labelSize} 1fr`, rows: undefined }; } } - return nodeDimensions.height; + return { columns: undefined, rows: undefined }; }); // Check if this is a Subsystem or Interface node (using shapes utility) @@ -379,8 +405,11 @@ class:vertical={isVertical} class:preview-hovered={showPreview} class:subsystem-type={isSubsystemType} + class:show-labels={showPortLabels} + class:has-inputs={hasVisibleInputLabels} + class:has-outputs={hasVisibleOutputLabels} 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 +429,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 +570,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 +604,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 +633,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 +705,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 +739,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 +965,158 @@ 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; + } + + /* Horizontal layout: grid column placement - rotation 0 (inputs left, outputs right) */ + /* Both labels: input=1, content=2, output=3 */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .node-inner { grid-column: 2; } + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-column: 3; } + /* Input labels only: input=1, content=2 */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-column: 2; } + /* Output labels only: content=1, output=2 */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-column: 2; } + + /* Horizontal layout: grid column placement - rotation 2 (inputs right, outputs left) */ + /* Both labels: output=1, content=2, input=3 */ + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .node-inner { grid-column: 2; } + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-column: 3; } + /* Output labels only (left side): output=1, content=2 */ + .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-column: 2; } + /* Input labels only (right side): content=1, input=2 */ + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-column: 2; } + + /* Horizontal borders */ + .node.show-labels:not(.vertical) .node-clip > .port-labels-input { grid-row: 1; border-right: 1px solid var(--border); } + .node.show-labels:not(.vertical) .node-clip > .node-inner { grid-row: 1; } + .node.show-labels:not(.vertical) .node-clip > .port-labels-output { grid-row: 1; border-left: 1px solid var(--border); } + /* Rotation 2: swap borders */ + .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-input { border-right: none; border-left: 1px solid var(--border); } + .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-output { border-left: none; border-right: 1px solid var(--border); } + + /* Vertical layout: grid row placement - rotation 1 (inputs top, outputs bottom) */ + /* Both labels: input=1, content=2, output=3 */ + .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-row: 1; } + .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .node-inner { grid-row: 2; } + .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-row: 3; } + /* Input labels only: input=1, content=2 */ + .node.show-labels.vertical[data-rotation="1"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-row: 1; } + .node.show-labels.vertical[data-rotation="1"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-row: 2; } + /* Output labels only: content=1, output=2 */ + .node.show-labels.vertical[data-rotation="1"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-row: 1; } + .node.show-labels.vertical[data-rotation="1"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-row: 2; } + + /* Vertical layout: grid row placement - rotation 3 (inputs bottom, outputs top) */ + /* Both labels: output=1, content=2, input=3 */ + .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-row: 1; } + .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .node-inner { grid-row: 2; } + .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-row: 3; } + /* Output labels only (top): output=1, content=2 */ + .node.show-labels.vertical[data-rotation="3"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-row: 1; } + .node.show-labels.vertical[data-rotation="3"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-row: 2; } + /* Input labels only (bottom): content=1, input=2 */ + .node.show-labels.vertical[data-rotation="3"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-row: 1; } + .node.show-labels.vertical[data-rotation="3"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-row: 2; } + + /* Vertical borders */ + .node.show-labels.vertical .node-clip > .port-labels-input { grid-column: 1; border-bottom: 1px solid var(--border); } + .node.show-labels.vertical .node-clip > .node-inner { grid-column: 1; } + .node.show-labels.vertical .node-clip > .port-labels-output { grid-column: 1; border-top: 1px solid var(--border); } + /* Rotation 3: swap borders */ + .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-input { border-bottom: none; border-top: 1px solid var(--border); } + .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-output { border-top: none; border-bottom: 1px solid var(--border); } + + /* 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/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/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();