Skip to content

Commit 1b08f07

Browse files
authored
Merge pull request #283 from pathsim/feature/library-detail-expansion
Library detail panel on hover
2 parents 4d161db + fc5097a commit 1b08f07

12 files changed

Lines changed: 825 additions & 86 deletions

File tree

src/lib/components/ResizablePanel.svelte

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@
2626
toolbar?: import('svelte').Snippet;
2727
footer?: import('svelte').Snippet;
2828
children?: import('svelte').Snippet;
29+
/** Optional second column rendered next to the main content. When set
30+
* AND `rightColumnActive` is true, the panel body splits into two
31+
* columns; otherwise the panel layout is unchanged. */
32+
rightColumn?: import('svelte').Snippet;
33+
/** Controls whether the right column is currently rendered. Lets the
34+
* parent define the snippet up front but defer the layout split until
35+
* there's something to show (so the column doesn't eat panel width
36+
* while empty). Defaults to true when a `rightColumn` is supplied. */
37+
rightColumnActive?: boolean;
38+
/** Width of the right column in px. */
39+
rightColumnWidth?: number;
2940
}
3041
3142
let {
@@ -46,9 +57,14 @@
4657
actions,
4758
toolbar,
4859
footer,
49-
children
60+
children,
61+
rightColumn,
62+
rightColumnActive = true,
63+
rightColumnWidth = 320
5064
}: Props = $props();
5165
66+
const showRightColumn = $derived(!!rightColumn && rightColumnActive);
67+
5268
// Calculate dynamic max height for bottom panels (viewport - nav bar - gaps)
5369
function getEffectiveMaxHeight(): number {
5470
if (maxHeight !== undefined) {
@@ -228,8 +244,15 @@
228244
{@render toolbar()}
229245
</div>
230246
{/if}
231-
<div class="panel-content">
232-
{@render children?.()}
247+
<div class="panel-body" class:split={showRightColumn}>
248+
<div class="panel-content">
249+
{@render children?.()}
250+
</div>
251+
{#if showRightColumn}
252+
<div class="panel-right" style="width: {rightColumnWidth}px;">
253+
{@render rightColumn!()}
254+
</div>
255+
{/if}
233256
</div>
234257
{#if footer}
235258
<div class="panel-footer">
@@ -342,6 +365,20 @@
342365
border-bottom: 1px solid var(--border);
343366
}
344367
368+
/* When the panel has no second column, panel-body is layout-transparent,
369+
* so children sit in resizable-panel's column flex exactly like before. */
370+
.panel-body {
371+
display: contents;
372+
}
373+
374+
.panel-body.split {
375+
display: flex;
376+
flex-direction: row;
377+
flex: 1;
378+
min-height: 0;
379+
overflow: hidden;
380+
}
381+
345382
.panel-content {
346383
display: flex;
347384
flex-direction: column;
@@ -350,6 +387,21 @@
350387
min-height: 0;
351388
}
352389
390+
.panel-body.split .panel-content {
391+
min-width: 0;
392+
}
393+
394+
.panel-right {
395+
flex-shrink: 0;
396+
width: 320px;
397+
min-width: 0;
398+
display: flex;
399+
flex-direction: column;
400+
overflow: hidden;
401+
border-left: 1px solid var(--border);
402+
background: var(--surface);
403+
}
404+
353405
.panel-footer {
354406
flex-shrink: 0;
355407
background: var(--surface-raised);

src/lib/components/dialogs/shared/DocumentationSection.svelte

Lines changed: 76 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script lang="ts">
22
import { slide } from 'svelte/transition';
3-
import { onDestroy } from 'svelte';
3+
import { onDestroy, untrack } from 'svelte';
44
import {
55
renderDocstring,
66
transformDefinitionListsToTables,
@@ -17,37 +17,49 @@
1717
docstring?: string | undefined;
1818
// Pre-rendered HTML (display directly)
1919
docstringHtml?: string | undefined;
20+
// When true, render the docs immediately and hide the toggle button.
21+
alwaysExpanded?: boolean;
2022
}
2123
22-
let { docstring, docstringHtml }: Props = $props();
24+
let { docstring, docstringHtml, alwaysExpanded = false }: Props = $props();
2325
24-
let expanded = $state(false);
26+
// alwaysExpanded is a static prop in practice; capture once for the
27+
// initial expanded state. Subsequent changes are handled by the reset
28+
// effect below.
29+
let expanded = $state(untrack(() => alwaysExpanded));
2530
let renderedDocs = $state<string>('');
2631
let loading = $state(false);
2732
let container: HTMLDivElement | undefined = $state();
2833
2934
// Check if we have any documentation to show
3035
const hasDocumentation = $derived(!!docstring || !!docstringHtml);
3136
37+
// Generation counter: each loadDocs invocation gets a unique id; if a
38+
// newer load starts while an older one is still in flight, the older
39+
// resolution must not overwrite the newer's result.
40+
let loadGen = 0;
41+
42+
async function loadDocs() {
43+
if (renderedDocs) return;
44+
const html = docstringHtml || docstring;
45+
if (!html) return;
46+
const gen = ++loadGen;
47+
loading = true;
48+
try {
49+
const result = await renderDocstring(html);
50+
if (gen !== loadGen) return; // superseded by a newer load
51+
renderedDocs = result;
52+
} catch (e) {
53+
if (gen !== loadGen) return;
54+
console.error('Failed to render docstring:', e);
55+
renderedDocs = '<p class="docs-error">Failed to render documentation.</p>';
56+
}
57+
if (gen === loadGen) loading = false;
58+
}
59+
3260
async function toggle() {
3361
expanded = !expanded;
34-
35-
// Load and render if expanding and not already loaded
36-
if (expanded && !renderedDocs) {
37-
const html = docstringHtml || docstring;
38-
if (!html) return;
39-
40-
loading = true;
41-
try {
42-
// renderDocstring handles both raw docstrings and pre-rendered HTML
43-
// It applies KaTeX rendering to any .math elements
44-
renderedDocs = await renderDocstring(html);
45-
} catch (e) {
46-
console.error('Failed to render docstring:', e);
47-
renderedDocs = '<p class="docs-error">Failed to render documentation.</p>';
48-
}
49-
loading = false;
50-
}
62+
if (expanded) await loadDocs();
5163
}
5264
5365
// Apply DOM transformations after rendered HTML is inserted
@@ -69,15 +81,21 @@
6981
}
7082
});
7183
72-
// Reset state when docstring changes
84+
// Reset state when docstring changes. In alwaysExpanded mode the toggle
85+
// state stays true and we re-load the new content. The loadDocs call
86+
// must be untracked — it reads renderedDocs internally, and without
87+
// untrack the subsequent write to renderedDocs would re-trigger this
88+
// effect in an infinite loop.
7389
$effect(() => {
74-
// Track both props
7590
const _ = docstring || docstringHtml;
7691
if (_) {
77-
// Reset when content changes
7892
cleanupCodeBlocks();
7993
renderedDocs = '';
80-
expanded = false;
94+
if (alwaysExpanded) {
95+
untrack(() => loadDocs());
96+
} else {
97+
expanded = false;
98+
}
8199
}
82100
});
83101
@@ -92,21 +110,33 @@
92110
</svelte:head>
93111

94112
{#if hasDocumentation}
95-
<div class="docs-section">
96-
<button class="docs-toggle" onclick={toggle}>
97-
<span class="toggle-icon" class:expanded>
98-
<Icon name="chevron-right" size={12} />
99-
</span>
100-
Documentation
101-
</button>
113+
<div class="docs-section" class:always-expanded={alwaysExpanded}>
114+
{#if !alwaysExpanded}
115+
<button class="docs-toggle" onclick={toggle}>
116+
<span class="toggle-icon" class:expanded>
117+
<Icon name="chevron-right" size={12} />
118+
</span>
119+
Documentation
120+
</button>
121+
{/if}
102122
{#if expanded}
103-
<div class="docs-content" transition:slide={{ duration: 200 }} bind:this={container}>
104-
{#if loading}
105-
<div class="docs-loading">Loading documentation...</div>
106-
{:else}
107-
{@html renderedDocs}
108-
{/if}
109-
</div>
123+
{#if alwaysExpanded}
124+
<div class="docs-content" bind:this={container}>
125+
{#if loading}
126+
<div class="docs-loading">Loading documentation...</div>
127+
{:else}
128+
{@html renderedDocs}
129+
{/if}
130+
</div>
131+
{:else}
132+
<div class="docs-content" transition:slide={{ duration: 200 }} bind:this={container}>
133+
{#if loading}
134+
<div class="docs-loading">Loading documentation...</div>
135+
{:else}
136+
{@html renderedDocs}
137+
{/if}
138+
</div>
139+
{/if}
110140
{/if}
111141
</div>
112142
{/if}
@@ -122,6 +152,14 @@
122152
padding-right: var(--space-md);
123153
}
124154
155+
/* Always-expanded uses no border / no negative margins so it can sit
156+
* inside the detail column without bleeding past container edges. */
157+
.docs-section.always-expanded {
158+
border-top: none;
159+
padding: 0;
160+
margin: 0;
161+
}
162+
125163
.docs-toggle {
126164
display: flex;
127165
align-items: center;

src/lib/components/icons/BlockIcon.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
{:else if def.kind === 'surface'}
5858
<IconSurface fn={def.fn} rows={def.rows} cols={def.cols} />
5959
{:else if def.kind === 'math'}
60-
<IconMath latex={def.latex} fit={def.fit} />
60+
<IconMath latex={def.latex} />
6161
{:else if def.kind === 'glyph'}
6262
<IconGlyph text={def.text} size={def.size} />
6363
{:else if def.kind === 'svg' && svgRaw}

src/lib/components/icons/blocks/registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export type IconDef =
1818
| { kind: 'plot'; samples: () => Sample[]; xRange?: [number, number]; yRange?: [number, number]; axes?: AxesMode; markers?: boolean; decoration?: 'arrow-up' | 'arrow-down' }
1919
| { kind: 'scope'; samples: () => Sample[]; samples2?: () => Sample[]; yRange?: [number, number]; gridX?: number; gridY?: number }
2020
| { kind: 'surface'; fn?: (u: number, v: number) => number; rows?: number; cols?: number }
21-
| { kind: 'math'; latex: string; fit?: number }
21+
| { kind: 'math'; latex: string }
2222
| { kind: 'glyph'; text: string; size?: number }
2323
| { kind: 'svg'; name: string };
2424

0 commit comments

Comments
 (0)