Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
839a443
fix: clean up recordingData subscriptions in BlockPropertiesDialog
milanofthe May 9, 2026
f3538b9
fix: clean up registry/path subscriptions in EventsPanel
milanofthe May 9, 2026
738bd3e
fix: clean up registry subscription in NodeLibrary
milanofthe May 9, 2026
2c6c134
fix: clean up toolboxes subscription in ToolboxManagerDialog
milanofthe May 9, 2026
c0a0d99
fix: clean up subsystemTree subscription in +page.svelte
milanofthe May 9, 2026
b1d0437
perf: append in place in mergeStreamingResult to avoid quadratic real…
milanofthe May 9, 2026
e0e2b6e
perf: replace JSON.stringify diff with shallow-equal helpers in FlowC…
milanofthe May 9, 2026
6bdef82
perf: replace JSON.parse(JSON.stringify(...)) with structuredClone
milanofthe May 9, 2026
b1e1a87
refactor: extract createHoverDetail action shared by NodeLibrary and …
milanofthe May 9, 2026
a820167
refactor: extract DialogShell and migrate ConfirmationModal, Keyboard…
milanofthe May 9, 2026
fb904b4
refactor: migrate remaining dialogs (PlotOptions, EventProperties, Bl…
milanofthe May 9, 2026
bfd1e7f
refactor: extract AbstractBackend base class for state and callback p…
milanofthe May 9, 2026
41e9dc2
perf: lazy-load and async-decode WelcomeModal example screenshots
milanofthe May 9, 2026
6241544
perf: cap inlineMath render cache at 500 entries with LRU eviction
milanofthe May 9, 2026
75d6c35
refactor: consolidate routing/dimension magic constants into routing/…
milanofthe May 9, 2026
f43302c
refactor: extract applyWaypointUpdate helper to deduplicate routing w…
milanofthe May 9, 2026
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
123 changes: 123 additions & 0 deletions src/lib/actions/hoverDetail.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Reusable hover-detail state machine.
*
* Drives a "detail column" that appears next to a list when the user hovers
* an item. Brushing past tiles on the way to the detail column shouldn't
* flip the content, so we delay open/switch/close transitions.
*
* Used by NodeLibrary and EventsPanel — keep the timing identical so both
* panels feel the same.
*/

const DEFAULT_OPEN_DELAY = 250;
const DEFAULT_SWITCH_DELAY = 200;
const DEFAULT_CLOSE_DELAY = 120;

export interface HoverDetailOptions<T> {
/** Called whenever the visible detail item changes (including to null). */
onChange?: (item: T | null) => void;
/** Called when the detail column should appear or disappear. */
onVisibleChange?: (visible: boolean) => void;
openDelay?: number;
switchDelay?: number;
closeDelay?: number;
}

export function createHoverDetail<T>(opts: HoverDetailOptions<T> = {}) {
const openDelay = opts.openDelay ?? DEFAULT_OPEN_DELAY;
const switchDelay = opts.switchDelay ?? DEFAULT_SWITCH_DELAY;
const closeDelay = opts.closeDelay ?? DEFAULT_CLOSE_DELAY;

let hoveredItem = $state<T | null>(null);
let openTimer: ReturnType<typeof setTimeout> | null = null;
let switchTimer: ReturnType<typeof setTimeout> | null = null;
let closeTimer: ReturnType<typeof setTimeout> | null = null;

function clearOpenTimer() {
if (openTimer !== null) {
clearTimeout(openTimer);
openTimer = null;
}
}
function clearSwitchTimer() {
if (switchTimer !== null) {
clearTimeout(switchTimer);
switchTimer = null;
}
}
function clearCloseTimer() {
if (closeTimer !== null) {
clearTimeout(closeTimer);
closeTimer = null;
}
}
function clearAll() {
clearOpenTimer();
clearSwitchTimer();
clearCloseTimer();
}

function handleEnter(item: T) {
clearCloseTimer();
if (hoveredItem === item) {
clearSwitchTimer();
return;
}
if (hoveredItem !== null) {
clearSwitchTimer();
switchTimer = setTimeout(() => {
switchTimer = null;
hoveredItem = item;
opts.onChange?.(item);
}, switchDelay);
return;
}
clearOpenTimer();
openTimer = setTimeout(() => {
openTimer = null;
hoveredItem = item;
opts.onChange?.(item);
opts.onVisibleChange?.(true);
}, openDelay);
}

function handleLeave() {
clearOpenTimer();
clearSwitchTimer();
if (hoveredItem === null) return;
clearCloseTimer();
closeTimer = setTimeout(() => {
closeTimer = null;
hoveredItem = null;
opts.onChange?.(null);
opts.onVisibleChange?.(false);
}, closeDelay);
}

function hideNow() {
clearAll();
const wasShown = hoveredItem !== null;
hoveredItem = null;
if (wasShown) {
opts.onChange?.(null);
opts.onVisibleChange?.(false);
}
}

function keepAlive() {
clearCloseTimer();
clearSwitchTimer();
}

return {
get hovered() {
return hoveredItem;
},
handleEnter,
handleLeave,
hideNow,
keepAlive,
dismiss: handleLeave,
cleanup: clearAll
};
}
90 changes: 37 additions & 53 deletions src/lib/components/ConfirmationModal.svelte
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { onDestroy } from 'svelte';
import { confirmationStore, type ConfirmationOptions } from '$lib/stores/confirmation';
import Icon from '$lib/components/icons/Icon.svelte';
import DialogShell from '$lib/components/dialogs/shared/DialogShell.svelte';

let state = $state<{
open: boolean;
options: ConfirmationOptions | null;
}>({ open: false, options: null });

confirmationStore.subscribe((s) => {
const unsubscribe = confirmationStore.subscribe((s) => {
state = { open: s.open, options: s.options };
});
onDestroy(unsubscribe);

function handleConfirm() {
confirmationStore.confirm();
Expand All @@ -21,64 +22,47 @@
confirmationStore.cancel();
}

function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
handleCancel();
}
}

function handleKeydown(event: KeyboardEvent) {
if (!state.open) return;
if (event.key === 'Escape') {
handleCancel();
} else if (event.key === 'Enter') {
function handleEnterKey(event: KeyboardEvent) {
if (state.open && event.key === 'Enter') {
handleConfirm();
}
}
</script>

<svelte:window onkeydown={handleKeydown} />

{#if state.open && state.options}
<div
class="dialog-backdrop"
onclick={handleBackdropClick}
transition:fade={{ duration: 150 }}
role="presentation"
>
<div
class="confirmation-dialog glass-panel"
transition:scale={{ start: 0.95, duration: 150, easing: cubicOut }}
role="alertdialog"
aria-modal="true"
aria-labelledby="confirmation-title"
aria-describedby="confirmation-message"
>
<div class="dialog-header">
<span id="confirmation-title">{state.options.title}</span>
<button class="icon-btn" onclick={handleCancel} aria-label="Close">
<Icon name="x" size={16} />
</button>
</div>

<div class="dialog-body">
<p id="confirmation-message">{state.options.message}</p>
</div>

<div class="dialog-actions">
<button class="ghost" onclick={handleCancel}>
{state.options.cancelText}
</button>
<button onclick={handleConfirm}>
{state.options.confirmText}
</button>
</div>
<svelte:window onkeydown={handleEnterKey} />

<DialogShell
open={state.open && state.options !== null}
onClose={handleCancel}
ariaLabelledby="confirmation-title"
role="alertdialog"
dialogClass="confirmation-dialog glass-panel"
>
{#if state.options}
<div class="dialog-header">
<span id="confirmation-title">{state.options.title}</span>
<button class="icon-btn" onclick={handleCancel} aria-label="Close">
<Icon name="x" size={16} />
</button>
</div>

<div class="dialog-body">
<p id="confirmation-message">{state.options.message}</p>
</div>

<div class="dialog-actions">
<button class="ghost" onclick={handleCancel}>
{state.options.cancelText}
</button>
<button onclick={handleConfirm}>
{state.options.confirmText}
</button>
</div>
</div>
{/if}
{/if}
</DialogShell>

<style>
.confirmation-dialog {
:global(.confirmation-dialog) {
width: 90%;
max-width: 320px;
display: flex;
Expand Down
14 changes: 8 additions & 6 deletions src/lib/components/FlowCanvas.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
import { nodeRegistry } from '$lib/nodes';
import { NODE_TYPES } from '$lib/constants/nodeTypes';
import { GRID_SIZE, SNAP_GRID, BACKGROUND_GAP } from '$lib/constants/grid';
import { DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT } from '$lib/constants/dimensions';
import { shallowEqualArray, shallowEqualRecord } from '$lib/utils/shallowEqual';
import type { NodeInstance, Connection, Annotation } from '$lib/nodes/types';
import type { EventInstance } from '$lib/events/types';

Expand Down Expand Up @@ -273,8 +275,8 @@
if (portIndex >= ports.length) return null;

const rotation = (nodeData.params?.['_rotation'] as number) || 0;
const width = node.measured?.width ?? node.width ?? 80;
const height = node.measured?.height ?? node.height ?? 40;
const width = node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH;
const height = node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT;

// Calculate port offset from center based on rotation
const portCount = ports.length;
Expand Down Expand Up @@ -547,8 +549,8 @@
}
if (currentData.name !== gn.name) return true;
if (currentData.color !== gn.color) return true;
if (JSON.stringify(currentData.params) !== JSON.stringify(gn.params)) return true;
if (JSON.stringify(currentData.pinnedParams) !== JSON.stringify(gn.pinnedParams)) return true;
if (!shallowEqualRecord(currentData.params, gn.params)) return true;
if (!shallowEqualArray(currentData.pinnedParams, gn.pinnedParams)) return true;
return false;
});

Expand Down Expand Up @@ -738,8 +740,8 @@
changedNodeIds.add(node.id);

// Incrementally update grid obstacle for this node
const width = node.measured?.width ?? node.width ?? 80;
const height = node.measured?.height ?? node.height ?? 40;
const width = node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH;
const height = node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT;
routingStore.updateNodeBounds(node.id, {
x: snappedX - width / 2,
y: snappedY - height / 2,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/components/WelcomeModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@
<img
src="{base}/examples/screenshots/{example.basename}-{isDark ? 'dark' : 'light'}.png"
alt="{example.name} preview"
loading="lazy"
decoding="async"
onerror={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
</div>
Expand Down
48 changes: 19 additions & 29 deletions src/lib/components/dialogs/BlockPropertiesDialog.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { fade, scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { graphStore } from '$lib/stores/graph';
import { historyStore } from '$lib/stores/history';
import { nodeDialogStore, closeNodeDialog } from '$lib/stores/nodeDialog';
Expand All @@ -15,6 +13,7 @@
import { paramInput } from '$lib/actions/paramInput';
import { loadCodeMirrorModules, createEditorExtensions, type CodeMirrorModules } from '$lib/utils/codemirror';
import ColorPicker from './shared/ColorPicker.svelte';
import DialogShell from './shared/DialogShell.svelte';
import DocumentationSection from './shared/DocumentationSection.svelte';
import Icon from '$lib/components/icons/Icon.svelte';
import { DEFAULT_NODE_COLOR } from '$lib/utils/colors';
Expand Down Expand Up @@ -76,6 +75,7 @@
unsubscribeDialog();
unsubscribeNodes();
unsubscribeTheme();
recordingData.destroy();
destroyEditor();
});

Expand Down Expand Up @@ -230,27 +230,18 @@
return String(value);
}

// Handle backdrop click
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
closeNodeDialog();
}
}

// Handle escape key
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeNodeDialog();
}
}
</script>

<svelte:window onkeydown={handleKeydown} />

{#if nodeId && node && typeDef}
<div class="dialog-backdrop" onclick={handleBackdropClick} transition:fade={{ duration: 150 }} role="presentation">
<div class="properties-dialog glass-panel" data-tour="dialog-properties" style="--node-color: {currentColor}" transition:scale={{ start: 0.95, duration: 150, easing: cubicOut }} role="dialog" tabindex="-1" aria-labelledby="dialog-title">
<div class="dialog-header">
<DialogShell
open={!!(nodeId && node && typeDef)}
onClose={closeNodeDialog}
ariaLabelledby="dialog-title"
dataTour="dialog-properties"
dialogClass="properties-dialog glass-panel"
dialogStyle="--node-color: {currentColor};"
>
{#if nodeId && node && typeDef}
<div class="dialog-header">
{#if showCode}
<span id="dialog-title">Python Code</span>
{:else}
Expand Down Expand Up @@ -399,14 +390,13 @@
{/if}
</div>

{#if !showCode}
<div class="dialog-footer">
<span class="hint">R rotate · X flip horizontal · Y flip vertical</span>
</div>
{/if}
</div>
</div>
{/if}
{#if !showCode}
<div class="dialog-footer">
<span class="hint">R rotate · X flip horizontal · Y flip vertical</span>
</div>
{/if}
{/if}
</DialogShell>

<style>
/* Uses global .properties-dialog styles from app.css */
Expand Down
Loading
Loading