Skip to content

Commit acefdce

Browse files
nedtwiggclaude
andcommitted
Bottom-anchor the Alt hint and copy popup on drag-up
Drag-up used to compute `top` by subtracting a per-element height estimate, so the popup (~32px est) sat 12px closer to the selection than the hint (~44px est). Now both elements anchor their BOTTOM edge via CSS `bottom`, so heights cancel out and the popup lands exactly where the hint was. Symmetric with drag-down, which already top-anchors via a height-free formula. Also shift the drag-up anchor up by one full cell, so the row adjacent to the selection stays visible on both sides — matching the +2-row offset on the drag-down top-anchored path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c1acd02 commit acefdce

2 files changed

Lines changed: 44 additions & 49 deletions

File tree

lib/src/components/SelectionOverlay.tsx

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -140,34 +140,32 @@ export function SelectionOverlay({ terminalId }: Props) {
140140

141141
// Mid-drag hint. Placed outside the selection on the side opposite the
142142
// drag direction: below when the user drags down, above when they drag up.
143-
// When the preferred side would clip the viewport, clamp to the viewport
144-
// edge on the SAME side — never flip sides, because that puts the hint
145-
// inside the selection and causes it to bounce as the mouse jitters near
146-
// the edge. Shown only while the user is dragging (spec §3.3).
147-
const HINT_EST_HEIGHT = 44;
148-
let hint: { left: number; top: number } | null = null;
143+
// Drag-down anchors by `top` (top edge aligned with where we want the
144+
// near-selection edge); drag-up anchors by `bottom` so the near-selection
145+
// edge lines up regardless of element height — this keeps the hint and
146+
// the copy popup visually coincident, since the popup uses the same
147+
// anchoring rules. Shown only while the user is dragging (spec §3.3).
148+
let hint: { left: number; top?: number; bottom?: number } | null = null;
149149
if (selection.dragging) {
150150
const endViewportRow = selection.endRow - dims.viewportY;
151151
if (endViewportRow >= 0 && endViewportRow < dims.rows) {
152152
const draggedDown = selection.endRow >= selection.startRow;
153-
// Leave one full cell of gap between the selection and the hint so
154-
// the next-to-be-selected line stays visible on both sides. The
155-
// drag-up side already feels like "2 lines above" because the
156-
// hint's own height (~44px) extends it away from the selection;
157-
// matching that on the drag-down side means skipping one extra row.
158-
const top = draggedDown
159-
? Math.min(
160-
gridTop + (endViewportRow + 2) * cellHeight + 4,
161-
dims.elementHeight - HINT_EST_HEIGHT - 4,
162-
)
163-
: Math.max(
164-
gridTop + endViewportRow * cellHeight - HINT_EST_HEIGHT - 4,
165-
4,
166-
);
167-
hint = {
168-
left: Math.min(dims.elementWidth - 180, Math.max(4, gridLeft + selection.endCol * cellWidth)),
169-
top,
170-
};
153+
const left = Math.min(dims.elementWidth - 180, Math.max(4, gridLeft + selection.endCol * cellWidth));
154+
if (draggedDown) {
155+
const top = Math.min(
156+
gridTop + (endViewportRow + 2) * cellHeight + 4,
157+
dims.elementHeight - 24,
158+
);
159+
hint = { left, top };
160+
} else {
161+
// Anchor the element's bottom edge one full cell above the
162+
// selection — symmetric with the drag-down +2-row offset — so the
163+
// row adjacent to the selection stays visible. Clamp the anchor y
164+
// so there's at least ~24px of room above it for the hint to
165+
// render inside the viewport.
166+
const y = Math.max(gridTop + (endViewportRow - 1) * cellHeight - 4, 28);
167+
hint = { left, bottom: dims.elementHeight - y };
168+
}
171169
}
172170
}
173171

@@ -193,7 +191,7 @@ export function SelectionOverlay({ terminalId }: Props) {
193191
{hint && (
194192
<div
195193
className="pointer-events-none absolute rounded border border-border bg-surface-raised px-1.5 py-0.5 text-xs text-muted shadow-sm"
196-
style={{ left: hint.left, top: hint.top }}
194+
style={{ left: hint.left, top: hint.top, bottom: hint.bottom }}
197195
>
198196
<div>Hold {IS_MAC ? 'Opt' : 'Alt'} for block selection</div>
199197
{state.hintToken && (

lib/src/components/SelectionPopup.tsx

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function SelectionPopup({ terminalId }: Props) {
2727
const selection = state.selection;
2828
const shouldRender = !!selection && !selection.dragging;
2929

30-
const [anchor, setAnchor] = useState<{ left: number; top: number } | null>(null);
30+
const [anchor, setAnchor] = useState<{ left: number; top?: number; bottom?: number } | null>(null);
3131

3232
useLayoutEffect(() => {
3333
if (!shouldRender || !selection) {
@@ -41,30 +41,26 @@ export function SelectionPopup({ terminalId }: Props) {
4141
const { cellWidth, cellHeight, gridLeft, gridTop } = dims;
4242
const endViewportRow = selection.endRow - dims.viewportY;
4343
const endRow = Math.max(0, Math.min(dims.rows - 1, endViewportRow));
44-
// Place the popup outside the selection on the side opposite the drag
45-
// direction. When the preferred side would clip the viewport, clamp to
46-
// the viewport edge on the SAME side — never flip, because that puts
47-
// the popup inside the selection and causes it to bounce with mouse
48-
// jitter at the edge.
49-
const POPUP_EST_HEIGHT = 32;
44+
// Place the popup on the side opposite the drag direction, matching
45+
// exactly where the Alt hint sat. Drag-down anchors by `top`, drag-up
46+
// anchors by `bottom` — that way both elements have their near-
47+
// selection edge at the same y regardless of their heights. Without
48+
// this, the popup (shorter than the hint) would appear closer to the
49+
// selection than the hint did on drag-up.
5050
const draggedDown = selection.endRow >= selection.startRow;
51-
// Leave one full cell of gap between the selection and the popup so
52-
// the line adjacent to the selection stays visible. Matches the
53-
// visual weight of the above-side where the popup's own height
54-
// naturally extends it away from the selection.
55-
const top = draggedDown
56-
? Math.min(
57-
gridTop + (endRow + 2) * cellHeight + 4,
58-
dims.elementHeight - POPUP_EST_HEIGHT - 4,
59-
)
60-
: Math.max(
61-
gridTop + endRow * cellHeight - POPUP_EST_HEIGHT - 4,
62-
4,
63-
);
64-
setAnchor({
65-
left: Math.min(dims.elementWidth - 300, Math.max(0, gridLeft + selection.endCol * cellWidth)),
66-
top,
67-
});
51+
const left = Math.min(dims.elementWidth - 300, Math.max(0, gridLeft + selection.endCol * cellWidth));
52+
if (draggedDown) {
53+
const top = Math.min(
54+
gridTop + (endRow + 2) * cellHeight + 4,
55+
dims.elementHeight - 24,
56+
);
57+
setAnchor({ left, top });
58+
} else {
59+
// Bottom-anchored one full cell above the selection — symmetric with
60+
// the drag-down +2-row offset on the top-anchored side.
61+
const y = Math.max(gridTop + (endRow - 1) * cellHeight - 4, 28);
62+
setAnchor({ left, bottom: dims.elementHeight - y });
63+
}
6864
}, [terminalId, shouldRender, selection]);
6965

7066
useEffect(() => {
@@ -103,6 +99,7 @@ export function SelectionPopup({ terminalId }: Props) {
10399
position: 'absolute',
104100
left: anchor.left,
105101
top: anchor.top,
102+
bottom: anchor.bottom,
106103
zIndex: 20,
107104
};
108105

0 commit comments

Comments
 (0)