Skip to content

Commit 1dd1020

Browse files
authored
fix: ability to resize editor panels horizontally (#1500)
1 parent 42e697e commit 1dd1020

File tree

2 files changed

+403
-26
lines changed

2 files changed

+403
-26
lines changed
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
<script lang="ts">
2+
import type { Snippet } from 'svelte';
3+
import { onDestroy, onMount } from 'svelte';
4+
import { PersistedState } from 'runed';
5+
import { DoubleArrowLeftIcon, DoubleArrowRightIcon } from '$lib/icons';
6+
7+
interface Props {
8+
first: Snippet;
9+
second: Snippet;
10+
size?: number | null;
11+
minSize?: number;
12+
minSecondSize?: number;
13+
defaultRatio?: number;
14+
allowCollapse?: boolean;
15+
collapseThreshold?: number;
16+
handleSize?: number;
17+
class?: string;
18+
firstClass?: string;
19+
secondClass?: string;
20+
handleClass?: string;
21+
ariaLabel?: string;
22+
persistKey?: string;
23+
persistStorage?: 'local' | 'session';
24+
onResizeEnd?: () => void;
25+
}
26+
27+
let {
28+
first,
29+
second,
30+
size = $bindable<number | null>(null),
31+
minSize = 240,
32+
minSecondSize = 240,
33+
defaultRatio = 0.5,
34+
allowCollapse = true,
35+
collapseThreshold = 28,
36+
handleSize = 8,
37+
class: className = '',
38+
firstClass = '',
39+
secondClass = '',
40+
handleClass = '',
41+
ariaLabel = 'Resize panels',
42+
persistKey,
43+
persistStorage = 'session',
44+
onResizeEnd = () => {}
45+
}: Props = $props();
46+
47+
let containerRef = $state<HTMLDivElement | null>(null);
48+
let isResizing = $state(false);
49+
let collapsedSide = $state<'first' | 'second' | null>(null);
50+
let lastSize = minSize;
51+
let persistedState = $state<PersistedState<number | null> | null>(null);
52+
let persistedKey = $state<string | null>(null);
53+
54+
let resizeStartX = 0;
55+
let resizeStartWidth = 0;
56+
57+
const clampSize = (value: number, minValue: number, maxValue: number) => Math.min(Math.max(value, minValue), maxValue);
58+
59+
const getAvailableWidth = () => {
60+
if (!containerRef) return 0;
61+
return Math.max(0, containerRef.getBoundingClientRect().width - handleSize);
62+
};
63+
64+
const getMaxNormalSize = () => Math.max(0, getAvailableWidth() - minSecondSize);
65+
66+
const ensureInitialSize = () => {
67+
if (!containerRef || size !== null) return;
68+
const available = getAvailableWidth();
69+
if (available <= 0) return;
70+
const maxNormal = getMaxNormalSize();
71+
const safeMin = Math.min(minSize, maxNormal);
72+
const initialSize = clampSize(Math.round(available * defaultRatio), safeMin, maxNormal);
73+
size = initialSize;
74+
lastSize = initialSize;
75+
};
76+
77+
const commitSize = () => {
78+
if (!persistedState || size === null) return;
79+
persistedState.current = size;
80+
};
81+
82+
const applySize = (nextSize: number, persist = false) => {
83+
const available = getAvailableWidth();
84+
if (available <= 0) return;
85+
const maxNormal = getMaxNormalSize();
86+
const safeMin = Math.min(minSize, maxNormal);
87+
88+
if (allowCollapse && nextSize <= collapseThreshold) {
89+
collapsedSide = 'first';
90+
size = 0;
91+
if (persist) commitSize();
92+
return;
93+
}
94+
if (allowCollapse && nextSize >= available - collapseThreshold) {
95+
collapsedSide = 'second';
96+
size = available;
97+
if (persist) commitSize();
98+
return;
99+
}
100+
101+
collapsedSide = null;
102+
const clamped = clampSize(nextSize, safeMin, maxNormal);
103+
size = clamped;
104+
lastSize = clamped;
105+
if (persist) commitSize();
106+
};
107+
108+
const handleMove = (event: PointerEvent) => {
109+
if (!isResizing) return;
110+
applySize(resizeStartWidth + (event.clientX - resizeStartX));
111+
};
112+
113+
const stopResize = () => {
114+
if (!isResizing) return;
115+
isResizing = false;
116+
window.removeEventListener('pointermove', handleMove);
117+
window.removeEventListener('pointerup', stopResize);
118+
document.body.style.cursor = '';
119+
document.body.style.userSelect = '';
120+
commitSize();
121+
onResizeEnd();
122+
};
123+
124+
const handleWindowResize = () => {
125+
if (isResizing) return;
126+
if (size === null) {
127+
ensureInitialSize();
128+
if (size === null) return;
129+
}
130+
applySize(size);
131+
};
132+
133+
function startResize(event: PointerEvent) {
134+
if (!containerRef) return;
135+
ensureInitialSize();
136+
resizeStartX = event.clientX;
137+
resizeStartWidth = size ?? 0;
138+
isResizing = true;
139+
window.addEventListener('pointermove', handleMove);
140+
window.addEventListener('pointerup', stopResize);
141+
document.body.style.cursor = 'col-resize';
142+
document.body.style.userSelect = 'none';
143+
event.preventDefault();
144+
}
145+
146+
function restoreCollapsed() {
147+
const maxNormal = getMaxNormalSize();
148+
const safeMin = Math.min(minSize, maxNormal);
149+
const restored = clampSize(lastSize || safeMin, safeMin, maxNormal);
150+
collapsedSide = null;
151+
size = restored;
152+
lastSize = restored;
153+
commitSize();
154+
onResizeEnd();
155+
}
156+
157+
$effect(() => {
158+
if (!persistKey) {
159+
persistedState = null;
160+
persistedKey = null;
161+
return;
162+
}
163+
if (persistedKey === persistKey && persistedState) return;
164+
persistedState = new PersistedState<number | null>(persistKey, size ?? null, {
165+
storage: persistStorage,
166+
syncTabs: false
167+
});
168+
persistedKey = persistKey;
169+
const stored = persistedState.current;
170+
if (stored !== null && stored !== undefined) {
171+
size = stored;
172+
}
173+
});
174+
175+
$effect(() => {
176+
if (!containerRef || size === null || isResizing) return;
177+
applySize(size, false);
178+
});
179+
180+
onMount(() => {
181+
ensureInitialSize();
182+
window.addEventListener('resize', handleWindowResize);
183+
return () => window.removeEventListener('resize', handleWindowResize);
184+
});
185+
186+
onDestroy(() => {
187+
window.removeEventListener('pointermove', handleMove);
188+
window.removeEventListener('pointerup', stopResize);
189+
});
190+
</script>
191+
192+
<div bind:this={containerRef} class={`flex min-h-0 min-w-0 ${className}`}>
193+
<div
194+
class={`min-h-0 min-w-0 flex-none overflow-hidden ${firstClass}`}
195+
style={`width: ${size ?? 0}px;`}
196+
aria-hidden={collapsedSide === 'first'}
197+
>
198+
{#if collapsedSide !== 'first'}
199+
{@render first()}
200+
{/if}
201+
</div>
202+
203+
<div
204+
role="separator"
205+
aria-orientation="vertical"
206+
aria-label={ariaLabel}
207+
class={`group relative z-20 flex shrink-0 cursor-col-resize items-stretch justify-center overflow-visible ${handleClass}`}
208+
style={`width: ${handleSize}px;`}
209+
onpointerdown={startResize}
210+
>
211+
<div class="bg-border group-hover:bg-primary/50 my-2 w-0.5 rounded-full transition-colors"></div>
212+
{#if collapsedSide}
213+
<button
214+
class="bg-background border-border text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-0 z-10 m-auto flex size-6 items-center justify-center rounded-full border shadow-sm focus-visible:ring-2 focus-visible:outline-none"
215+
onclick={(event) => {
216+
event.stopPropagation();
217+
restoreCollapsed();
218+
}}
219+
onpointerdown={(event) => event.stopPropagation()}
220+
aria-label={collapsedSide === 'first' ? 'Show left panel' : 'Show right panel'}
221+
title={collapsedSide === 'first' ? 'Show left panel' : 'Show right panel'}
222+
type="button"
223+
>
224+
{#if collapsedSide === 'first'}
225+
<DoubleArrowRightIcon class="size-4" />
226+
{:else}
227+
<DoubleArrowLeftIcon class="size-4" />
228+
{/if}
229+
</button>
230+
{/if}
231+
</div>
232+
233+
<div
234+
class={`min-h-0 min-w-0 flex-1 overflow-hidden ${secondClass}`}
235+
style={collapsedSide === 'second' ? 'flex: 0 0 0px; width: 0px;' : ''}
236+
aria-hidden={collapsedSide === 'second'}
237+
>
238+
{#if collapsedSide !== 'second'}
239+
{@render second()}
240+
{/if}
241+
</div>
242+
</div>

0 commit comments

Comments
 (0)