Skip to content

Commit 36e046c

Browse files
fix: compensate toolbar position on toggle to keep cursor under switch (#164)
When toggling the toolbar's enabled state, the expandable buttons cause a width change that shifts the toggle switch out from under the cursor. This adds position compensation that offsets the toolbar horizontally by the expandable buttons width, clamped to viewport bounds. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 34d22d5 commit 36e046c

File tree

1 file changed

+171
-108
lines changed
  • packages/react-grab/src/components/toolbar

1 file changed

+171
-108
lines changed

packages/react-grab/src/components/toolbar/index.tsx

Lines changed: 171 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ interface ToolbarProps {
6161

6262
export const Toolbar: Component<ToolbarProps> = (props) => {
6363
let containerRef: HTMLDivElement | undefined;
64+
let expandableButtonsRef: HTMLDivElement | undefined;
6465
let unfreezeUpdatesCallback: (() => void) | null = null;
66+
let lastKnownExpandableWidth = 0;
6567

6668
const [isVisible, setIsVisible] = createSignal(false);
6769
const [isCollapsed, setIsCollapsed] = createSignal(false);
@@ -83,6 +85,7 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
8385
const [isToggleTooltipVisible, setIsToggleTooltipVisible] =
8486
createSignal(false);
8587
const [isShakeTooltipVisible, setIsShakeTooltipVisible] = createSignal(false);
88+
const [isToggleAnimating, setIsToggleAnimating] = createSignal(false);
8689

8790
const tooltipPosition = () => (snapEdge() === "top" ? "bottom" : "top");
8891

@@ -439,9 +442,73 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
439442
}, TOOLBAR_COLLAPSE_ANIMATION_DURATION_MS);
440443
});
441444

442-
const handleToggleEnabled = createDragAwareHandler(() =>
443-
props.onToggleEnabled?.(),
444-
);
445+
const handleToggleEnabled = createDragAwareHandler(() => {
446+
const isCurrentlyEnabled = Boolean(props.enabled);
447+
const edge = snapEdge();
448+
const preTogglePosition = position();
449+
const expandableWidth = lastKnownExpandableWidth;
450+
const shouldCompensatePosition = expandableWidth > 0 && edge !== "left";
451+
452+
if (shouldCompensatePosition) {
453+
setIsToggleAnimating(true);
454+
}
455+
456+
props.onToggleEnabled?.();
457+
458+
if (expandableWidth > 0) {
459+
const widthChange = isCurrentlyEnabled ? -expandableWidth : expandableWidth;
460+
expandedDimensions = {
461+
width: expandedDimensions.width + widthChange,
462+
height: expandedDimensions.height,
463+
};
464+
}
465+
466+
if (shouldCompensatePosition) {
467+
const viewport = getVisualViewport();
468+
const positionOffset = isCurrentlyEnabled ? expandableWidth : -expandableWidth;
469+
const clampMin = viewport.offsetLeft + TOOLBAR_SNAP_MARGIN_PX;
470+
const clampMax = viewport.offsetLeft + viewport.width - expandedDimensions.width - TOOLBAR_SNAP_MARGIN_PX;
471+
const compensatedX = clampToViewport(
472+
preTogglePosition.x + positionOffset,
473+
clampMin,
474+
clampMax,
475+
);
476+
477+
setPosition({ x: compensatedX, y: preTogglePosition.y });
478+
479+
clearTimeout(toggleAnimationTimeout);
480+
toggleAnimationTimeout = setTimeout(() => {
481+
setIsToggleAnimating(false);
482+
const newRatio = getRatioFromPosition(
483+
edge,
484+
position().x,
485+
position().y,
486+
expandedDimensions.width,
487+
expandedDimensions.height,
488+
);
489+
setPositionRatio(newRatio);
490+
saveAndNotify({
491+
edge,
492+
ratio: newRatio,
493+
collapsed: isCollapsed(),
494+
enabled: !isCurrentlyEnabled,
495+
});
496+
}, TOOLBAR_COLLAPSE_ANIMATION_DURATION_MS);
497+
} else if (!isCurrentlyEnabled && lastKnownExpandableWidth === 0) {
498+
// HACK: When toolbar mounts disabled, expandable buttons are hidden (grid-cols-[0fr])
499+
// so we can't measure their width. Learn it after the first enable animation completes.
500+
clearTimeout(toggleAnimationTimeout);
501+
toggleAnimationTimeout = setTimeout(() => {
502+
if (expandableButtonsRef) {
503+
lastKnownExpandableWidth = expandableButtonsRef.offsetWidth;
504+
}
505+
const rect = containerRef?.getBoundingClientRect();
506+
if (rect) {
507+
expandedDimensions = { width: rect.width, height: rect.height };
508+
}
509+
}, TOOLBAR_COLLAPSE_ANIMATION_DURATION_MS);
510+
}
511+
});
445512

446513
const getSnapPosition = (
447514
currentX: number,
@@ -725,6 +792,7 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
725792
let resizeTimeout: ReturnType<typeof setTimeout> | undefined;
726793
let collapseAnimationTimeout: ReturnType<typeof setTimeout> | undefined;
727794
let snapAnimationTimeout: ReturnType<typeof setTimeout> | undefined;
795+
let toggleAnimationTimeout: ReturnType<typeof setTimeout> | undefined;
728796

729797
const handleResize = () => {
730798
if (isDragging()) return;
@@ -815,10 +883,14 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
815883
setPosition(defaultPosition);
816884
}
817885

886+
if (props.enabled && expandableButtonsRef) {
887+
lastKnownExpandableWidth = expandableButtonsRef.offsetWidth;
888+
}
889+
818890
if (props.onSubscribeToStateChanges) {
819891
const unsubscribe = props.onSubscribeToStateChanges(
820892
(state: ToolbarState) => {
821-
if (isCollapseAnimating()) return;
893+
if (isCollapseAnimating() || isToggleAnimating()) return;
822894

823895
const rect = containerRef?.getBoundingClientRect();
824896
if (!rect) return;
@@ -835,18 +907,14 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
835907
calculateExpandedPositionFromCollapsed(collapsedPos, state.edge);
836908
setPosition(newPos);
837909
setPositionRatio(newRatio);
838-
if (collapseAnimationTimeout) {
839-
clearTimeout(collapseAnimationTimeout);
840-
}
910+
clearTimeout(collapseAnimationTimeout);
841911
collapseAnimationTimeout = setTimeout(() => {
842912
setIsCollapseAnimating(false);
843913
}, TOOLBAR_COLLAPSE_ANIMATION_DURATION_MS);
844914
} else {
845915
if (didCollapsedChange) {
846916
setIsCollapseAnimating(true);
847-
if (collapseAnimationTimeout) {
848-
clearTimeout(collapseAnimationTimeout);
849-
}
917+
clearTimeout(collapseAnimationTimeout);
850918
collapseAnimationTimeout = setTimeout(() => {
851919
setIsCollapseAnimating(false);
852920
}, TOOLBAR_COLLAPSE_ANIMATION_DURATION_MS);
@@ -886,18 +954,11 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
886954
window.visualViewport?.removeEventListener("scroll", handleResize);
887955
window.removeEventListener("pointermove", handleWindowPointerMove);
888956
window.removeEventListener("pointerup", handleWindowPointerUp);
889-
if (resizeTimeout) {
890-
clearTimeout(resizeTimeout);
891-
}
892-
if (collapseAnimationTimeout) {
893-
clearTimeout(collapseAnimationTimeout);
894-
}
895-
if (shakeTooltipTimeout) {
896-
clearTimeout(shakeTooltipTimeout);
897-
}
898-
if (snapAnimationTimeout) {
899-
clearTimeout(snapAnimationTimeout);
900-
}
957+
clearTimeout(resizeTimeout);
958+
clearTimeout(collapseAnimationTimeout);
959+
clearTimeout(shakeTooltipTimeout);
960+
clearTimeout(snapAnimationTimeout);
961+
clearTimeout(toggleAnimationTimeout);
901962
unfreezeUpdatesCallback?.();
902963
});
903964

@@ -923,7 +984,7 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
923984
if (isSnapping()) {
924985
return "transition-[transform,opacity] duration-300 ease-out";
925986
}
926-
if (isCollapseAnimating()) {
987+
if (isCollapseAnimating() || isToggleAnimating()) {
927988
return "transition-[transform,opacity] duration-150 ease-out";
928989
}
929990
return "transition-opacity duration-300 ease-out";
@@ -1013,92 +1074,94 @@ export const Toolbar: Component<ToolbarProps> = (props) => {
10131074
)}
10141075
>
10151076
<div class="flex items-center min-w-0">
1016-
<div
1017-
class={cn(
1018-
"grid transition-all duration-150 ease-out",
1019-
props.enabled
1020-
? "grid-cols-[1fr] opacity-100"
1021-
: "grid-cols-[0fr] opacity-0",
1022-
)}
1023-
>
1024-
<div class="relative overflow-visible min-w-0">
1025-
{/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */}
1026-
<button
1027-
data-react-grab-ignore-events
1028-
data-react-grab-toolbar-toggle
1029-
class="contain-layout flex items-center justify-center cursor-pointer interactive-scale touch-hitbox mr-1.5"
1030-
on:pointerdown={(event) => {
1031-
stopEventPropagation(event);
1032-
handlePointerDown(event);
1033-
}}
1034-
on:mousedown={stopEventPropagation}
1035-
onClick={(event) => {
1036-
setIsSelectTooltipVisible(false);
1037-
handleToggle(event);
1038-
}}
1039-
{...createFreezeHandlers(setIsSelectTooltipVisible)}
1040-
>
1041-
<IconSelect
1042-
size={14}
1043-
class={cn(
1044-
"transition-colors",
1045-
getToolbarIconColor(
1046-
Boolean(props.isActive) && !props.isCommentMode,
1047-
Boolean(props.isCommentMode),
1048-
),
1049-
)}
1050-
/>
1051-
</button>
1052-
<Tooltip
1053-
visible={isSelectTooltipVisible() && !isCollapsed()}
1054-
position={tooltipPosition()}
1055-
>
1056-
Select
1057-
</Tooltip>
1077+
<div ref={expandableButtonsRef} class="flex items-center">
1078+
<div
1079+
class={cn(
1080+
"grid transition-all duration-150 ease-out",
1081+
props.enabled
1082+
? "grid-cols-[1fr] opacity-100"
1083+
: "grid-cols-[0fr] opacity-0",
1084+
)}
1085+
>
1086+
<div class="relative overflow-visible min-w-0">
1087+
{/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */}
1088+
<button
1089+
data-react-grab-ignore-events
1090+
data-react-grab-toolbar-toggle
1091+
class="contain-layout flex items-center justify-center cursor-pointer interactive-scale touch-hitbox mr-1.5"
1092+
on:pointerdown={(event) => {
1093+
stopEventPropagation(event);
1094+
handlePointerDown(event);
1095+
}}
1096+
on:mousedown={stopEventPropagation}
1097+
onClick={(event) => {
1098+
setIsSelectTooltipVisible(false);
1099+
handleToggle(event);
1100+
}}
1101+
{...createFreezeHandlers(setIsSelectTooltipVisible)}
1102+
>
1103+
<IconSelect
1104+
size={14}
1105+
class={cn(
1106+
"transition-colors",
1107+
getToolbarIconColor(
1108+
Boolean(props.isActive) && !props.isCommentMode,
1109+
Boolean(props.isCommentMode),
1110+
),
1111+
)}
1112+
/>
1113+
</button>
1114+
<Tooltip
1115+
visible={isSelectTooltipVisible() && !isCollapsed()}
1116+
position={tooltipPosition()}
1117+
>
1118+
Select
1119+
</Tooltip>
1120+
</div>
10581121
</div>
1059-
</div>
1060-
<div
1061-
class={cn(
1062-
"grid transition-all duration-150 ease-out",
1063-
props.enabled
1064-
? "grid-cols-[1fr] opacity-100"
1065-
: "grid-cols-[0fr] opacity-0",
1066-
)}
1067-
>
1068-
<div class="relative overflow-visible min-w-0">
1069-
{/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */}
1070-
<button
1071-
data-react-grab-ignore-events
1072-
data-react-grab-toolbar-comment
1073-
class="contain-layout flex items-center justify-center cursor-pointer interactive-scale touch-hitbox mr-1.5"
1074-
on:pointerdown={(event) => {
1075-
stopEventPropagation(event);
1076-
handlePointerDown(event);
1077-
}}
1078-
on:mousedown={stopEventPropagation}
1079-
onClick={(event) => {
1080-
setIsCommentTooltipVisible(false);
1081-
handleComment(event);
1082-
}}
1083-
{...createFreezeHandlers(setIsCommentTooltipVisible)}
1084-
>
1085-
<IconComment
1086-
size={14}
1087-
class={cn(
1088-
"transition-colors",
1089-
getToolbarIconColor(
1090-
Boolean(props.isCommentMode),
1091-
Boolean(props.isActive) && !props.isCommentMode,
1092-
),
1093-
)}
1094-
/>
1095-
</button>
1096-
<Tooltip
1097-
visible={isCommentTooltipVisible() && !isCollapsed()}
1098-
position={tooltipPosition()}
1099-
>
1100-
Comment
1101-
</Tooltip>
1122+
<div
1123+
class={cn(
1124+
"grid transition-all duration-150 ease-out",
1125+
props.enabled
1126+
? "grid-cols-[1fr] opacity-100"
1127+
: "grid-cols-[0fr] opacity-0",
1128+
)}
1129+
>
1130+
<div class="relative overflow-visible min-w-0">
1131+
{/* HACK: Native events with stopImmediatePropagation prevent page-level dropdowns from closing */}
1132+
<button
1133+
data-react-grab-ignore-events
1134+
data-react-grab-toolbar-comment
1135+
class="contain-layout flex items-center justify-center cursor-pointer interactive-scale touch-hitbox mr-1.5"
1136+
on:pointerdown={(event) => {
1137+
stopEventPropagation(event);
1138+
handlePointerDown(event);
1139+
}}
1140+
on:mousedown={stopEventPropagation}
1141+
onClick={(event) => {
1142+
setIsCommentTooltipVisible(false);
1143+
handleComment(event);
1144+
}}
1145+
{...createFreezeHandlers(setIsCommentTooltipVisible)}
1146+
>
1147+
<IconComment
1148+
size={14}
1149+
class={cn(
1150+
"transition-colors",
1151+
getToolbarIconColor(
1152+
Boolean(props.isCommentMode),
1153+
Boolean(props.isActive) && !props.isCommentMode,
1154+
),
1155+
)}
1156+
/>
1157+
</button>
1158+
<Tooltip
1159+
visible={isCommentTooltipVisible() && !isCollapsed()}
1160+
position={tooltipPosition()}
1161+
>
1162+
Comment
1163+
</Tooltip>
1164+
</div>
11021165
</div>
11031166
</div>
11041167
<div class="relative shrink-0 overflow-visible">

0 commit comments

Comments
 (0)