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
54 changes: 37 additions & 17 deletions apps/web/src/layout/ColumnResizeHandle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,19 @@ import {

import { settleStripCamera } from "../motion/stripCamera";
import {
arrangeLayoutWorkspaces,
LAYOUT_COLUMN_WIDTH_PRESETS,
LAYOUT_MIN_COLUMN_WIDTH,
LAYOUT_MIN_PANE_SIZE,
LAYOUT_READABLE_COLUMN_PX,
findLayoutPane,
resizeLayoutColumn,
resizeLayoutColumnEdge,
resizeLayoutPane,
type LayoutColumn,
type LayoutPaneSlot,
type LayoutState,
type LayoutWorkspace,
} from "./layoutModel";
import { getLayoutBehavior } from "./layoutBehavior";
import { saveLayoutNow, useLayoutStore } from "./layoutStore";

/** Handles fill the gap between panes (the chrome gutter), centered on the boundary. */
Expand Down Expand Up @@ -94,8 +94,9 @@ type ActiveDrag =
columnId: string;
/** Where the column starts, in arranged widths from the strip's left edge. */
columnLeft: number;
/** The width its workspace is drawn against, in px: the unit column shares use. */
/** The strip's visible width in px: the unit column shares use. */
unitWidth: number;
drawnUnitWidth: number;
contentLeft: number;
scrollLeft: number;
bounds: { min: number; max: number };
Expand Down Expand Up @@ -137,13 +138,14 @@ export function LayoutResizeHandles({
stripWidth: number;
unitWidth: number;
scrollerRef: RefObject<HTMLDivElement | null>;
onPreview: (layout: LayoutState | null) => void;
onPreview: (preview: { layout: LayoutState; heldStripPx: number } | null) => void;
}) {
const [drag, setDrag] = useState<ActiveDrag | null>(null);
const dragRef = useRef<ActiveDrag | null>(null);
const baseLayoutRef = useRef<LayoutState>(layout);
const frameRef = useRef<number | null>(null);
const pointerRef = useRef({ x: 0, y: 0, scrollLeft: 0 });
const heldStripPxRef = useRef(0);

const cancelFrame = () => {
if (frameRef.current === null) return;
Expand Down Expand Up @@ -171,10 +173,13 @@ export function LayoutResizeHandles({
// release does, so the preview never jumps between fitting and scrolling.
const applyDrag = (base: LayoutState, active: ActiveDrag): LayoutState =>
active.kind === "column"
? arrangeLayoutWorkspaces(
? resizeLayoutColumnEdge(
base,
resizeLayoutColumn(base, active.columnId, active.value),
active.columnId,
active.value,
getLayoutBehavior().whenNoRoom,
active.unitWidth,
active.drawnUnitWidth,
)
: resizeLayoutPane(base, active.paneId, active.value);

Expand All @@ -183,12 +188,16 @@ export function LayoutResizeHandles({
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
baseLayoutRef.current = layout;
heldStripPxRef.current = scrollerRef.current?.scrollWidth ?? 0;
pointerRef.current = {
x: event.clientX,
y: event.clientY,
scrollLeft: scrollerRef.current?.scrollLeft ?? 0,
};
updateDrag(active);
if (active.kind === "column") {
onPreview({ layout: applyDrag(layout, active), heldStripPx: heldStripPxRef.current });
}
};

const dragAtPointer = (active: ActiveDrag, event: PointerEvent<HTMLDivElement>): ActiveDrag => {
Expand Down Expand Up @@ -222,7 +231,10 @@ export function LayoutResizeHandles({
const latest = dragRef.current;
if (!latest) return;
setDrag(latest);
onPreview(applyDrag(baseLayoutRef.current, latest));
onPreview({
layout: applyDrag(baseLayoutRef.current, latest),
heldStripPx: heldStripPxRef.current,
});
});
};

Expand All @@ -234,7 +246,12 @@ export function LayoutResizeHandles({
onPreview(null);
const store = useLayoutStore.getState();
if (active.kind === "column") {
store.resizeColumnEdge(active.columnId, active.value, active.unitWidth);
store.resizeColumnEdge(
active.columnId,
active.value,
active.unitWidth,
active.drawnUnitWidth,
);
} else store.resizePane(active.paneId, active.value);
saveLayoutNow();
};
Expand All @@ -260,6 +277,7 @@ export function LayoutResizeHandles({
settleStripCamera();
const rect = scroller.getBoundingClientRect();
return {
width: scroller.clientWidth,
height: scroller.clientHeight,
contentLeft: rect.left - scroller.scrollLeft,
contentTop: rect.top,
Expand All @@ -279,9 +297,9 @@ export function LayoutResizeHandles({
};
});
// No wider than the strip, so a column can always be wholly on screen.
const columnBounds = () => ({
...edgeBounds(MIN_COLUMN_PX, unitWidth, null, LAYOUT_MIN_COLUMN_WIDTH),
max: (scrollerRef.current?.clientWidth ?? unitWidth) / unitWidth,
const columnBounds = (unit: number) => ({
...edgeBounds(MIN_COLUMN_PX, unit, null, LAYOUT_MIN_COLUMN_WIDTH),
max: (scrollerRef.current?.clientWidth ?? unit) / unit,
});
const paneBounds = (panes: LayoutColumn["panes"], index: number, heightPx: number) =>
edgeBounds(
Expand Down Expand Up @@ -372,16 +390,18 @@ export function LayoutResizeHandles({
}}
onPointerDown={(event) => {
const metrics = measure();
if (!metrics) return;
if (!metrics || metrics.width <= 0) return;
const scale = unitWidth / metrics.width;
startDrag(event, {
kind: "column",
columnId: column.id,
columnLeft: thisColumnLeft,
unitWidth,
columnLeft: thisColumnLeft * scale,
unitWidth: metrics.width,
drawnUnitWidth: unitWidth,
contentLeft: metrics.contentLeft,
scrollLeft: scrollerRef.current?.scrollLeft ?? 0,
bounds: columnBounds(),
value: width,
bounds: columnBounds(metrics.width),
value: width * scale,
snapped: null,
});
}}
Expand All @@ -395,7 +415,7 @@ export function LayoutResizeHandles({
const step = stepPreset(width, event.key === "ArrowRight" ? 1 : -1);
useLayoutStore
.getState()
.resizeColumnEdge(column.id, clampToBounds(step, columnBounds()), unitWidth);
.resizeColumnEdge(column.id, clampToBounds(step, columnBounds(unitWidth)), unitWidth);
}}
/>,
);
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/layout/SurfaceHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -522,8 +522,8 @@ export function LayoutSurfaceHost({
pagePlane?: React.ReactNode;
}) {
// A resize drag previews here and commits to the store once on release.
const [preview, setPreview] = useState<LayoutState | null>(null);
const shown = preview ?? layout;
const [preview, setPreview] = useState<{ layout: LayoutState; heldStripPx: number } | null>(null);
const shown = preview?.layout ?? layout;
const workspace = selectFocusedWorkspace(shown);
const narrow = useMediaQuery("max-md");
useSyncLayoutBehavior();
Expand Down Expand Up @@ -1402,6 +1402,13 @@ export function LayoutSurfaceHost({
/>
) : null}
</div>
{preview ? (
<div
aria-hidden
className="pointer-events-none absolute top-0 left-0 h-px"
style={{ width: preview.heldStripPx }}
/>
) : null}
</div>
<ThreadSurfaceServices />
{pagePlane}
Expand Down
93 changes: 93 additions & 0 deletions apps/web/src/layout/layoutModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ import {
openLayoutTab,
placeLayoutTabs,
replaceLayoutTabContent,
resizeLayoutColumnEdge,
selectFocusedTab,
selectLayoutPaneSlots,
showLayoutContent,
type LayoutState,
type LayoutTab,
type LayoutTabContent,
} from "./layoutModel";
import type { DraftId } from "../composerDraftStore";
import type { ThreadRouteTarget } from "../threadRoutes";
import type { LayoutWhenNoRoom } from "./layoutBehavior";

const environmentId = EnvironmentId.make("local");
const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread"));
Expand Down Expand Up @@ -130,6 +133,26 @@ function shapeOf(state: LayoutState): Shape[] {
);
}

const column = (id: string, width: number) => ({
id,
width,
panes: [{ id: `${id}-pane`, tabs: [], activeTabId: null, size: 1 }],
});

function drawnRightEdges(
state: LayoutState,
viewportWidth: number,
whenNoRoom: LayoutWhenNoRoom = "shrink-then-scroll",
): number[] {
const { slots, stripWidth } = selectLayoutPaneSlots(state.workspaces[0]!, {
width: viewportWidth,
whenNoRoom,
});
return slots.map((slot) =>
Math.round((slot.rect.left + slot.rect.width) * stripWidth * viewportWidth),
);
}

describe("showLayoutContent", () => {
it("opens a thread in place of the ghost chat whose side panel has focus, not in the panel", () => {
const withPanel = openLayoutTab(showLayoutContent(createLayoutState(), chat), diff, {
Expand Down Expand Up @@ -345,3 +368,73 @@ describe("replaceLayoutTabContent", () => {
expect(replaceLayoutTabContent(before, "closed-tab", chat)).toBe(before);
});
});

describe("resizeLayoutColumnEdge", () => {
it("keeps the dragged edge under the pointer when the strip was arranged wider than the window", () => {
const viewportWidth = 1000;
const base: LayoutState = {
focusedWorkspaceId: "workspace",
workspaces: [
{
id: "workspace",
name: null,
focusedPaneId: null,
cameraWidth: 1200,
columns: [column("chat", 0.5), column("terminal", 0.55)],
},
],
};
const { unitWidth } = selectLayoutPaneSlots(base.workspaces[0]!, {
width: viewportWidth,
whenNoRoom: "shrink-then-scroll",
});
expect(drawnRightEdges(base, viewportWidth)).toEqual([600, 1260]);

const pointers = Array.from({ length: 41 }, (_, index) => 300 + index * 10);
const drawn = pointers.map((pointer) =>
drawnRightEdges(
resizeLayoutColumnEdge(
base,
"chat",
pointer / viewportWidth,
"shrink-then-scroll",
viewportWidth,
unitWidth,
),
viewportWidth,
),
);
expect(drawn).toEqual(pointers.map((pointer) => [pointer, pointer + 660]));
});

it("lands the dragged edge where it is released when columns always shrink to fit", () => {
const viewportWidth = 1000;
const base: LayoutState = {
focusedWorkspaceId: "workspace",
workspaces: [
{
id: "workspace",
name: null,
focusedPaneId: null,
cameraWidth: viewportWidth,
columns: [column("chat", 0.4), column("diff", 0.4), column("terminal", 0.4)],
},
],
};
const { unitWidth } = selectLayoutPaneSlots(base.workspaces[0]!, {
width: viewportWidth,
whenNoRoom: "always-shrink",
});
expect(drawnRightEdges(base, viewportWidth, "always-shrink")).toEqual([333, 667, 1000]);

const released = resizeLayoutColumnEdge(
base,
"diff",
(600 - 1000 / 3) / viewportWidth,
"always-shrink",
viewportWidth,
unitWidth,
);
expect(drawnRightEdges(released, viewportWidth, "always-shrink")).toEqual([333, 600, 1000]);
});
});
55 changes: 55 additions & 0 deletions apps/web/src/layout/layoutModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1786,6 +1786,61 @@ export function resizeLayoutColumn(
});
}

export function resizeLayoutColumnEdge(
state: LayoutState,
columnId: string,
width: number,
mode: LayoutWhenNoRoom,
cameraWidth: number,
drawnUnitWidth = cameraWidth,
): LayoutState {
const scale = drawnUnitWidth / cameraWidth;
const rebased =
scale === 1
? state
: mapWorkspaces(state, (workspace) =>
workspace.columns.some((column) => column.id === columnId)
? {
...workspace,
columns: workspace.columns.map((column) => ({
...column,
width: column.width * scale,
})),
}
: workspace,
);
const workspace = rebased.workspaces.find((candidate) =>
candidate.columns.some((column) => column.id === columnId),
);
const index = workspace?.columns.findIndex((column) => column.id === columnId) ?? -1;
const neighbor = workspace?.columns[index + 1];
if (mode !== "always-shrink" || !workspace || !neighbor || !Number.isFinite(width)) {
return arrangeLayoutWorkspaces(
state,
resizeLayoutColumn(rebased, columnId, width),
cameraWidth,
);
}
const pair = workspace.columns[index]!.width + neighbor.width;
const floor = Math.min(LAYOUT_MIN_COLUMN_WIDTH, pair / 2);
const resized = Math.min(pair - floor, Math.max(floor, width));
const shared = mapWorkspaces(rebased, (candidate) =>
candidate === workspace
? {
...candidate,
columns: candidate.columns.map((column, columnIndex) =>
columnIndex === index
? { ...column, width: resized }
: columnIndex === index + 1
? { ...column, width: pair - resized }
: column,
),
}
: candidate,
);
return arrangeLayoutWorkspaces(state, shared, cameraWidth);
}

/** Step to the next width preset, wrapping from 2/3 back to 1/3. A lone column keeps filling the window. */
export function cycleLayoutColumnWidth(state: LayoutState, columnId: string): LayoutState {
const workspace = state.workspaces.find((candidate) =>
Expand Down
Loading
Loading