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
30 changes: 30 additions & 0 deletions src/lib/components/FlowCanvas.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,36 @@
});
});

// Reroute connections when a block's measured size changes (icon-mode toggle,
// pinned-params, name-length, port-label visibility, …). The initial
// measurement after mount is recorded silently — only later changes trigger
// a recalculation.
const lastMeasuredDims = new Map<string, { w: number; h: number }>();
$effect(() => {
const changed = new Set<string>();
for (const node of nodes) {
if (node.type !== 'pathview') continue;
const w = node.measured?.width;
const h = node.measured?.height;
if (w === undefined || h === undefined) continue;
const last = lastMeasuredDims.get(node.id);
if (!last || last.w !== w || last.h !== h) {
if (last) changed.add(node.id);
lastMeasuredDims.set(node.id, { w, h });
routingStore.updateNodeBounds(node.id, {
x: node.position.x - w / 2,
y: node.position.y - h / 2,
width: w,
height: h
});
}
}
if (changed.size > 0) {
const connections = get(graphStore.connections);
routingStore.recalculateRoutesForNodes(changed, connections, getPortInfo);
}
});

// Track if we're currently syncing to prevent loops
let isSyncing = false;

Expand Down
24 changes: 20 additions & 4 deletions src/lib/components/FlowUpdater.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
import { importFile } from '$lib/schema/fileOps';
import { ALL_COMPONENT_EXTENSIONS } from '$lib/types/component';
import { GRID_SIZE } from '$lib/constants/grid';
import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews';
import { plotDataStore } from '$lib/plotting/processing/plotDataStore';
import { previewSideForRotation, extendBoundsForPreview } from '$lib/utils/previewBounds';
import type { NodeInstance } from '$lib/types/nodes';

interface Props {
pendingUpdates: string[];
Expand All @@ -33,6 +37,9 @@
return;
}

const previewsPinned = get(pinnedPreviewsStore);
const plotState = get(plotDataStore);

// Calculate bounding box of all nodes, accounting for origin
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const node of nodes) {
Expand All @@ -42,10 +49,19 @@
const origin = (node.origin as [number, number]) ?? [0.5, 0.5];
const left = node.position.x - width * origin[0];
const top = node.position.y - height * origin[1];
minX = Math.min(minX, left);
minY = Math.min(minY, top);
maxX = Math.max(maxX, left + width);
maxY = Math.max(maxY, top + height);
let bounds = { left, top, right: left + width, bottom: top + height };

// Extend bounds for pinned plot previews on recording blocks (Scope/Spectrum)
if (previewsPinned && node.type === 'pathview' && plotState.plots.has(node.id)) {
const data = node.data as NodeInstance;
const rotation = (data.params?.['_rotation'] as number) || 0;
bounds = extendBoundsForPreview(bounds, previewSideForRotation(rotation));
}

minX = Math.min(minX, bounds.left);
minY = Math.min(minY, bounds.top);
maxX = Math.max(maxX, bounds.right);
maxY = Math.max(maxY, bounds.bottom);
}

// Add some padding around the nodes themselves
Expand Down
25 changes: 25 additions & 0 deletions src/lib/components/contextMenuBuilders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ import { exportToSVG, exportToPDF } 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 { iconModeStore } from '$lib/stores/iconMode';
import { getEffectivePortLabelVisibility } from '$lib/utils/portLabels';
import { hasBlockIcon } from '$lib/components/icons/BlockIcon.svelte';
import { nodeRegistry } from '$lib/nodes';
import type { NodeInstance } from '$lib/types/nodes';

/** Divider menu item */
Expand Down Expand Up @@ -63,6 +66,27 @@ function buildPortLabelItems(nodeId: string, node: NodeInstance): MenuItemType[]
return items;
}

/** Build icon-mode toggle menu item for a node, only when an icon exists */
function buildIconModeItem(nodeId: string, node: NodeInstance): MenuItemType[] {
const typeDef = nodeRegistry.get(node.type);
const blockKey = typeDef?.blockClass ?? typeDef?.type;
if (!hasBlockIcon(blockKey)) return [];

const globalIconMode = get(iconModeStore);
const override = node.params?.['_iconMode'] as boolean | undefined;
const effective = override ?? globalIconMode;

return [
{
label: effective ? 'Show as Text' : 'Show as Icon',
icon: 'image',
action: () => historyStore.mutate(() =>
graphStore.updateNodeParams(nodeId, { _iconMode: !effective })
)
}
];
}

/** Show block code in preview dialog */
function showBlockCode(nodeId: string): void {
const node = graphStore.getNode(nodeId);
Expand Down Expand Up @@ -211,6 +235,7 @@ function buildNodeMenu(nodeId: string): MenuItemType[] {
];

items.push(...buildPortLabelItems(nodeId, node));
items.push(...buildIconModeItem(nodeId, node));

items.push(
DIVIDER,
Expand Down
1 change: 1 addition & 0 deletions src/lib/components/dialogs/KeyboardShortcutsDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
{ keys: ['+'], description: 'Zoom in' },
{ keys: ['-'], description: 'Zoom out' },
{ keys: ['L'], description: 'Port labels' },
{ keys: ['I'], description: 'Block icons' },
{ keys: ['T'], description: 'Theme' }
]
},
Expand Down
85 changes: 85 additions & 0 deletions src/lib/components/icons/BlockIcon.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<script lang="ts" module>
import { getIconDef, hasBlockIcon as registryHas } from './blocks/registry';

const svgModules = import.meta.glob('./blocks/svg/*.svg', {
query: '?raw',
import: 'default',
eager: true
}) as Record<string, string>;

const svgMap = new Map<string, string>();
for (const [path, raw] of Object.entries(svgModules)) {
const match = path.match(/\/([^/]+)\.svg$/);
if (match) svgMap.set(match[1], raw);
}

export function hasBlockIcon(blockClass: string | undefined): boolean {
return registryHas(blockClass);
}
</script>

<script lang="ts">
import IconPlot from './blocks/IconPlot.svelte';
import IconMath from './blocks/IconMath.svelte';
import IconGlyph from './blocks/IconGlyph.svelte';
import IconScope from './blocks/IconScope.svelte';
import IconSurface from './blocks/IconSurface.svelte';

interface Props {
blockClass: string | undefined;
title?: string;
}

let { blockClass, title }: Props = $props();
const def = $derived(getIconDef(blockClass));
const svgRaw = $derived(def?.kind === 'svg' ? svgMap.get(def.name) : undefined);
</script>

{#if def}
<span class="block-icon" aria-label={title} role={title ? 'img' : undefined}>
{#if def.kind === 'plot'}
<IconPlot
samples={def.samples()}
xRange={def.xRange}
yRange={def.yRange}
axes={def.axes}
markers={def.markers}
decoration={def.decoration}
/>
{:else if def.kind === 'scope'}
<IconScope
samples={def.samples()}
samples2={def.samples2?.()}
yRange={def.yRange}
gridX={def.gridX}
gridY={def.gridY}
/>
{:else if def.kind === 'surface'}
<IconSurface fn={def.fn} rows={def.rows} cols={def.cols} />
{:else if def.kind === 'math'}
<IconMath latex={def.latex} fit={def.fit} />
{:else if def.kind === 'glyph'}
<IconGlyph text={def.text} size={def.size} />
{:else if def.kind === 'svg' && svgRaw}
{@html svgRaw}
{/if}
</span>
{/if}

<style>
.block-icon {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: currentColor;
}

.block-icon :global(svg) {
width: 100%;
height: 100%;
display: block;
color: inherit;
}
</style>
38 changes: 38 additions & 0 deletions src/lib/components/icons/blocks/IconGlyph.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script lang="ts">
interface Props {
text: string;
/** Approximate fraction of viewBox height for the glyph */
size?: number;
bold?: boolean;
}

let { text, size = 0.45, bold = true }: Props = $props();

const VIEW_W = 96;
const VIEW_H = 64;
const fontSize = $derived(VIEW_H * size);
</script>

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {VIEW_W} {VIEW_H}">
<text
x={VIEW_W / 2}
y={VIEW_H / 2}
text-anchor="middle"
dominant-baseline="central"
fill="currentColor"
stroke="none"
font-family="ui-monospace, 'JetBrains Mono', 'SF Mono', Menlo, monospace"
font-size={fontSize}
font-weight={bold ? 700 : 500}
letter-spacing="-1"
>{text}</text>
</svg>

<style>
svg {
width: 100%;
height: 100%;
display: block;
color: currentColor;
}
</style>
103 changes: 103 additions & 0 deletions src/lib/components/icons/blocks/IconMath.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { loadKatex } from '$lib/utils/katexLoader';

interface Props {
latex: string;
}

let { latex }: Props = $props();
let html = $state<string>('');
let inner: HTMLSpanElement | undefined = $state();
let wrap: HTMLSpanElement | undefined = $state();
let scale = $state(1);

const MAX_SCALE = 1.6;
const MIN_SCALE = 0.4;
const PADDING_X = 6;
const PADDING_Y = 4;

async function measure() {
await tick();
if (!inner || !wrap) return;
const cw = wrap.clientWidth - 2 * PADDING_X;
const ch = wrap.clientHeight - 2 * PADDING_Y;
const w = inner.scrollWidth;
const h = inner.scrollHeight;
if (w === 0 || h === 0 || cw <= 0 || ch <= 0) return;
const fitScale = Math.min(cw / w, ch / h);
scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, fitScale));
}

onMount(async () => {
const katex = await loadKatex();
try {
html = katex.default.renderToString(latex, {
displayMode: true,
throwOnError: false,
strict: false,
output: 'html'
});
} catch {
html = latex;
}
await measure();
});

$effect(() => {
if (html) measure();
});

$effect(() => {
if (!wrap) return;
const ro = new ResizeObserver(() => measure());
ro.observe(wrap);
return () => ro.disconnect();
});
</script>

<span class="math" bind:this={wrap}>
<span class="inner" bind:this={inner} style="transform: scale({scale});">
{#if html}
{@html html}
{/if}
</span>
</span>

<style>
.math {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: currentColor;
overflow: visible;
}

.inner {
display: inline-block;
transform-origin: center;
white-space: nowrap;
color: inherit;
}

.inner :global(.katex-display) {
margin: 0;
}

.inner :global(.katex) {
font-size: 16px;
font-weight: 600;
color: inherit;
}

.inner :global(.katex .mord),
.inner :global(.katex .mop),
.inner :global(.katex .mbin),
.inner :global(.katex .mrel),
.inner :global(.katex .mathnormal),
.inner :global(.katex .mathit) {
font-weight: 600;
}
</style>
Loading
Loading