Skip to content

Commit e10ce03

Browse files
authored
Merge pull request #13314 from gitbutlerapp/continue-resizers-refactoring
Refactor desktop resizers to require SashLayer
2 parents d25fbf9 + 92ca47d commit e10ce03

4 files changed

Lines changed: 114 additions & 134 deletions

File tree

apps/desktop/src/components/codegen/CodegenSidebar.svelte

Lines changed: 0 additions & 71 deletions
This file was deleted.

apps/desktop/src/components/irc/IrcChat.svelte

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import IrcChannel from "$components/irc/IrcChannel.svelte";
33
import IrcChannels from "$components/irc/IrcChannels.svelte";
44
import Resizer from "$components/shared/Resizer.svelte";
5+
import SashLayer from "$components/shared/SashLayer.svelte";
56
import { UI_STATE } from "$lib/state/uiState.svelte";
67
import { inject } from "@gitbutler/core/context";
78
@@ -21,30 +22,32 @@
2122
let sidebarEl = $state<HTMLDivElement>();
2223
</script>
2324

24-
<div class="irc">
25-
<div class="sidebar" bind:this={sidebarEl}>
26-
<IrcChannels {projectId} />
27-
{#if sidebarEl}
28-
<Resizer
29-
viewport={sidebarEl}
30-
direction="right"
31-
defaultValue={10}
32-
minWidth={8}
33-
maxWidth={24}
34-
persistId="irc-sidebar-width"
35-
/>
36-
{/if}
25+
<SashLayer>
26+
<div class="irc">
27+
<div class="sidebar" bind:this={sidebarEl}>
28+
<IrcChannels {projectId} />
29+
{#if sidebarEl}
30+
<Resizer
31+
viewport={sidebarEl}
32+
direction="right"
33+
defaultValue={10}
34+
minWidth={8}
35+
maxWidth={24}
36+
persistId="irc-sidebar-width"
37+
/>
38+
{/if}
39+
</div>
40+
<div class="right">
41+
{#if currentName === "*" || !currentName}
42+
<IrcChannel {projectId} type="server" {headerActions} />
43+
{:else if currentName.startsWith("#")}
44+
<IrcChannel {projectId} type="group" channel={currentName} autojoin {headerActions} />
45+
{:else}
46+
<IrcChannel {projectId} type="private" nick={currentName} {headerActions} />
47+
{/if}
48+
</div>
3749
</div>
38-
<div class="right">
39-
{#if currentName === "*" || !currentName}
40-
<IrcChannel {projectId} type="server" {headerActions} />
41-
{:else if currentName.startsWith("#")}
42-
<IrcChannel {projectId} type="group" channel={currentName} autojoin {headerActions} />
43-
{:else}
44-
<IrcChannel {projectId} type="private" nick={currentName} {headerActions} />
45-
{/if}
46-
</div>
47-
</div>
50+
</SashLayer>
4851

4952
<style lang="postcss">
5053
.irc {

apps/desktop/src/components/shared/Resizer.svelte

Lines changed: 28 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,14 @@
8989
9090
const resizerId = Symbol();
9191
92-
// When a SashLayer ancestor is present the resizer teleports into its
93-
// sash-container so it escapes overflow:hidden on pane wrappers. The
94-
// SashLayer must be scoped to the same scroll context as the viewport
95-
// so that getBoundingClientRect differences remain scroll-invariant.
96-
const layerCtx = getContext<SashLayerContext | undefined>(SASH_LAYER);
97-
let inLayer = $state(false);
92+
// Resizer requires a SashLayer ancestor and always renders in that overlay.
93+
// Keep SashLayer in the same scroll context as the viewport so
94+
// getBoundingClientRect differences remain scroll-invariant.
95+
const layerCtxMaybe = getContext<SashLayerContext | undefined>(SASH_LAYER);
96+
if (!layerCtxMaybe) {
97+
throw new Error("Resizer must be used inside <SashLayer>.");
98+
}
99+
const layerCtx = layerCtxMaybe;
98100
99101
let initial = 0;
100102
let isResizing = $state(false);
@@ -167,7 +169,7 @@
167169
lastShiftSyncedValue = undefined;
168170
169171
if (pausedAutoLayout) {
170-
layerCtx?.setAutoLayoutPaused(false);
172+
layerCtx.setAutoLayoutPaused(false);
171173
pausedAutoLayout = false;
172174
}
173175
}
@@ -196,8 +198,8 @@
196198
parseFloat(orientation === "horizontal" ? resizerDiv.style.left : resizerDiv.style.top) ||
197199
0;
198200
}
199-
if (inLayer && !resizeGroup && !pausedAutoLayout) {
200-
layerCtx?.setAutoLayoutPaused(true);
201+
if (!resizeGroup && !pausedAutoLayout) {
202+
layerCtx.setAutoLayoutPaused(true);
201203
pausedAutoLayout = true;
202204
}
203205
@@ -249,11 +251,10 @@
249251
const { newValue, overflow } = applyLimits(offsetRem);
250252
251253
if (newValue !== undefined && !passive && !disabled) {
252-
// Fast path when the handle lives in a SashLayer and is NOT part of a
253-
// resize group: move the sash div by the pointer delta (pure arithmetic,
254-
// zero getBoundingClientRect calls during drag). Geometry is re-synced
254+
// Fast path for direct drag feedback: move sash by pointer delta
255+
// (no getBoundingClientRect calls during drag). Geometry is re-synced
255256
// once on mouse-up via requestLayout.
256-
if (inLayer && !resizeGroup && resizerDiv) {
257+
if (resizerDiv) {
257258
const dx = move.clientX - lastDragClientX;
258259
const dy = move.clientY - lastDragClientY;
259260
if (orientation === "horizontal") {
@@ -319,7 +320,7 @@
319320
processPointerMove();
320321
cleanupPointerDragState();
321322
// Re-sync sash to exact geometry once at the end of drag.
322-
if (inLayer) layerCtx?.requestLayout();
323+
layerCtx.requestLayout();
323324
isResizing = false;
324325
onResizing?.(false);
325326
}
@@ -406,15 +407,15 @@
406407
value.set(newValue);
407408
updateDom(newValue, true);
408409
reportWidth(newValue);
409-
if (inLayer) layerCtx?.requestLayout();
410+
layerCtx.requestLayout();
410411
}
411412
412413
$effect(() => {
413414
if (resizeGroup && order !== undefined) {
414415
// It's important we do not make use of maxValue in the resize
415416
// manager, and in this effect. It changes with the value of
416417
// neighbors and would make this effect trigger constantly.
417-
return resizeGroup?.register({
418+
return resizeGroup.register({
418419
resizerId,
419420
getValue,
420421
setValue,
@@ -445,14 +446,14 @@
445446
}
446447
});
447448
448-
// Teleportation effect: move the resizer div into the SashLayer overlay so
449-
// it is never clipped by overflow:hidden on pane containers. Position is
449+
// Overlay effect: move the resizer div into the SashLayer overlay so it is
450+
// never clipped by overflow:hidden on pane containers. Position is
450451
// kept in sync via ResizeObserver + window resize + shared per-layer
451452
// scheduler notifications. No scroll tracking is needed — the SashLayer is
452453
// always scoped inside the same scroll container as the viewport, so
453454
// getBoundingClientRect differences are scroll-invariant.
454455
$effect(() => {
455-
const container = layerCtx?.container;
456+
const container = layerCtx.container;
456457
const div = resizerDiv;
457458
const vp = viewport;
458459
// Snapshot these so updatePosition closes over stable values. They are
@@ -476,7 +477,6 @@
476477
? parseFloat(vpStyle?.marginRight || "0") || 0
477478
: 0;
478479
479-
inLayer = true;
480480
c.appendChild(d);
481481
482482
function updatePosition(containerRect?: DOMRectReadOnly) {
@@ -520,10 +520,9 @@
520520
521521
return () => {
522522
if (pausedAutoLayout) {
523-
layerCtx?.setAutoLayoutPaused(false);
523+
layerCtx.setAutoLayoutPaused(false);
524524
pausedAutoLayout = false;
525525
}
526-
inLayer = false;
527526
unobserveLayoutTarget?.();
528527
unsubscribeLayout?.();
529528
d.remove();
@@ -543,7 +542,6 @@
543542
{onclick}
544543
class:disabled
545544
class="resizer"
546-
class:in-layer={inLayer}
547545
class:is-resizing={isResizing}
548546
class:vertical={orientation === "vertical"}
549547
class:horizontal={orientation === "horizontal"}
@@ -560,8 +558,9 @@
560558
--resizer-cursor: default;
561559
position: absolute;
562560
outline: none;
563-
/* background-color: rgba(255, 0, 0, 0.345); */
564561
cursor: var(--resizer-cursor);
562+
/* background-color: rgba(255, 0, 0, 0.345); */
563+
pointer-events: initial;
565564
566565
&.horizontal {
567566
--resizer-cursor: col-resize;
@@ -577,24 +576,23 @@
577576
height: var(--resizer-thickness);
578577
}
579578
580-
/* Non-layer fallback positioning: when not teleported into SashLayer,
581-
anchor to the requested viewport edge via directional classes. */
582-
&.horizontal.right:not(.in-layer) {
579+
/* Anchor-to-edge defaults before the first layout pass. */
580+
&.horizontal.right {
583581
right: 0;
584582
left: auto;
585583
}
586584
587-
&.horizontal.left:not(.in-layer) {
585+
&.horizontal.left {
588586
right: auto;
589587
left: 0;
590588
}
591589
592-
&.vertical.down:not(.in-layer) {
590+
&.vertical.down {
593591
top: auto;
594592
bottom: 0;
595593
}
596594
597-
&.vertical.up:not(.in-layer) {
595+
&.vertical.up {
598596
top: 0;
599597
bottom: auto;
600598
}
@@ -603,15 +601,5 @@
603601
pointer-events: none;
604602
--resizer-cursor: default;
605603
}
606-
607-
/* When teleported into the SashLayer overlay the container has
608-
pointer-events:none, so individual resizers must opt back in. */
609-
&.in-layer {
610-
pointer-events: initial;
611-
}
612-
613-
&.in-layer.disabled {
614-
pointer-events: none;
615-
}
616604
}
617605
</style>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import Resizer from "$components/shared/Resizer.svelte";
2+
import { RESIZE_SYNC, ResizeSync } from "$lib/floating/resizeSync";
3+
import { SASH_LAYER, type SashLayerContext } from "$lib/sash/sashLayer";
4+
import { SETTINGS } from "$lib/settings/userSettings";
5+
import { render, waitFor } from "@testing-library/svelte";
6+
import { writable } from "svelte/store";
7+
import { describe, expect, test, vi } from "vitest";
8+
9+
function baseContext(): Map<any, any> {
10+
return new Map<any, any>([
11+
[SETTINGS._key, writable({ zoom: 1 } as any)],
12+
[RESIZE_SYNC._key, new ResizeSync()],
13+
]);
14+
}
15+
16+
function baseProps() {
17+
const viewport = document.createElement("div");
18+
viewport.style.width = "300px";
19+
viewport.style.height = "200px";
20+
return {
21+
defaultValue: 20,
22+
viewport,
23+
direction: "right" as const,
24+
};
25+
}
26+
27+
describe("Resizer", () => {
28+
test("throws when mounted without SashLayer context", () => {
29+
expect(() => {
30+
render(Resizer, {
31+
props: baseProps(),
32+
context: baseContext(),
33+
});
34+
}).toThrow("Resizer must be used inside <SashLayer>.");
35+
});
36+
37+
test("mounts when SashLayer context is provided", async () => {
38+
const container = document.createElement("div");
39+
document.body.appendChild(container);
40+
const layerCtx: SashLayerContext = {
41+
container,
42+
requestLayout: vi.fn(),
43+
subscribeLayout: () => () => {},
44+
observeLayoutTarget: () => () => {},
45+
setAutoLayoutPaused: vi.fn(),
46+
};
47+
48+
const context = baseContext();
49+
context.set(SASH_LAYER, layerCtx);
50+
51+
const { unmount } = render(Resizer, {
52+
props: baseProps(),
53+
context,
54+
});
55+
56+
await waitFor(() => expect(container.querySelector(".resizer")).toBeInTheDocument());
57+
unmount();
58+
container.remove();
59+
});
60+
});

0 commit comments

Comments
 (0)