fix: keep floating buttons anchored on resize

This commit is contained in:
kuekhaoyang
2026-07-27 21:13:08 +08:00
parent 1179148031
commit 907f393e2f
7 changed files with 266 additions and 44 deletions
+79 -40
View File
@@ -2,18 +2,23 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { profiledKey } from '@/lib/utils/profile-storage';
import {
clampFloatingButtonPosition,
getDefaultFloatingButtonPosition,
getFloatingButtonRatios,
getPositionFromFloatingButtonRatios,
type FloatingAnchor,
type FloatingButtonPosition,
type FloatingButtonRatios,
type FloatingButtonViewport,
} from '@/lib/utils/floating-button-position';
type FloatingAnchor = 'left' | 'right';
interface FloatingButtonPosition {
x: number;
y: number;
interface StoredFloatingPosition extends FloatingButtonRatios {
/** Version 2 ratios are measured within the draggable area, not the viewport. */
version?: 2;
}
interface StoredFloatingPosition {
xRatio: number;
yRatio: number;
}
const CURRENT_STORAGE_VERSION = 2;
interface UseFloatingButtonPositionOptions {
storageKey: string;
@@ -45,10 +50,6 @@ const INITIAL_DRAG_STATE: DragState = {
offsetY: 0,
};
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
export function useFloatingButtonPosition({
storageKey,
defaultAnchor,
@@ -59,32 +60,44 @@ export function useFloatingButtonPosition({
const [position, setPosition] = useState<FloatingButtonPosition | null>(null);
const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
const positionRef = useRef<FloatingButtonPosition | null>(null);
const customRatiosRef = useRef<FloatingButtonRatios | null>(null);
const pointerUpHandlerRef = useRef<(event: PointerEvent) => void>(() => undefined);
const suppressClickRef = useRef(false);
const clampPosition = useCallback((x: number, y: number, width: number, height: number) => ({
x: clamp(x, margin, Math.max(margin, width - buttonSize - margin)),
y: clamp(y, margin, Math.max(margin, height - buttonSize - margin)),
...clampFloatingButtonPosition(
{ x, y },
{ width, height },
buttonSize,
margin,
),
}), [buttonSize, margin]);
const getDefaultPosition = useCallback((width: number, height: number) => {
const x = defaultAnchor === 'left'
? margin
: Math.max(margin, width - buttonSize - margin);
const centeredY = height * defaultYRatio - buttonSize / 2;
return clampPosition(x, centeredY, width, height);
}, [buttonSize, clampPosition, defaultAnchor, defaultYRatio, margin]);
return getDefaultFloatingButtonPosition(
{ width, height },
defaultAnchor,
defaultYRatio,
buttonSize,
margin,
);
}, [buttonSize, defaultAnchor, defaultYRatio, margin]);
const persistPosition = useCallback((nextPosition: FloatingButtonPosition) => {
if (typeof window === 'undefined') return;
const viewport: FloatingButtonViewport = {
width: window.innerWidth,
height: window.innerHeight,
};
const ratios = getFloatingButtonRatios(nextPosition, viewport, buttonSize, margin);
const payload: StoredFloatingPosition = {
xRatio: nextPosition.x / window.innerWidth,
yRatio: nextPosition.y / window.innerHeight,
version: CURRENT_STORAGE_VERSION,
...ratios,
};
localStorage.setItem(profiledKey(storageKey), JSON.stringify(payload));
}, [storageKey]);
}, [buttonSize, margin, storageKey]);
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -93,6 +106,7 @@ export function useFloatingButtonPosition({
const width = window.innerWidth;
const height = window.innerHeight;
const fallbackPosition = getDefaultPosition(width, height);
customRatiosRef.current = null;
try {
const raw = localStorage.getItem(profiledKey(storageKey));
@@ -102,20 +116,30 @@ export function useFloatingButtonPosition({
return;
}
const parsed = JSON.parse(raw) as Partial<StoredFloatingPosition>;
if (typeof parsed.xRatio !== 'number' || typeof parsed.yRatio !== 'number') {
const parsed = JSON.parse(raw) as Partial<StoredFloatingPosition> & { version?: number };
if (
typeof parsed.xRatio !== 'number' ||
!Number.isFinite(parsed.xRatio) ||
typeof parsed.yRatio !== 'number' ||
!Number.isFinite(parsed.yRatio) ||
(parsed.version !== undefined && parsed.version !== CURRENT_STORAGE_VERSION)
) {
positionRef.current = fallbackPosition;
setPosition(fallbackPosition);
return;
}
const nextPosition = clampPosition(
parsed.xRatio * width,
parsed.yRatio * height,
width,
height
);
const viewport = { width, height };
const nextPosition = parsed.version === CURRENT_STORAGE_VERSION
? getPositionFromFloatingButtonRatios(
{ xRatio: parsed.xRatio, yRatio: parsed.yRatio },
viewport,
buttonSize,
margin,
)
: clampPosition(parsed.xRatio * width, parsed.yRatio * height, width, height);
customRatiosRef.current = getFloatingButtonRatios(nextPosition, viewport, buttonSize, margin);
positionRef.current = nextPosition;
setPosition(nextPosition);
} catch {
@@ -129,9 +153,14 @@ export function useFloatingButtonPosition({
const handleResize = () => {
const width = window.innerWidth;
const height = window.innerHeight;
const fallbackPosition = getDefaultPosition(width, height);
const basePosition = positionRef.current || fallbackPosition;
const nextPosition = clampPosition(basePosition.x, basePosition.y, width, height);
const nextPosition = customRatiosRef.current
? getPositionFromFloatingButtonRatios(
customRatiosRef.current,
{ width, height },
buttonSize,
margin,
)
: getDefaultPosition(width, height);
positionRef.current = nextPosition;
setPosition(nextPosition);
@@ -139,7 +168,7 @@ export function useFloatingButtonPosition({
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [clampPosition, getDefaultPosition, storageKey]);
}, [buttonSize, clampPosition, getDefaultPosition, margin, storageKey]);
const finishDrag = useCallback(() => {
const dragState = dragStateRef.current;
@@ -180,9 +209,15 @@ export function useFloatingButtonPosition({
window.innerHeight
);
customRatiosRef.current = getFloatingButtonRatios(
nextPosition,
{ width: window.innerWidth, height: window.innerHeight },
buttonSize,
margin,
);
positionRef.current = nextPosition;
setPosition(nextPosition);
}, [clampPosition]);
}, [buttonSize, clampPosition, margin]);
const handlePointerUp = useCallback((event: PointerEvent) => {
const dragState = dragStateRef.current;
@@ -193,10 +228,14 @@ export function useFloatingButtonPosition({
finishDrag();
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
window.removeEventListener('pointerup', pointerUpHandlerRef.current);
window.removeEventListener('pointercancel', pointerUpHandlerRef.current);
}, [finishDrag, handlePointerMove]);
useEffect(() => {
pointerUpHandlerRef.current = handlePointerUp;
}, [handlePointerUp]);
useEffect(() => {
return () => {
window.removeEventListener('pointermove', handlePointerMove);
+105
View File
@@ -0,0 +1,105 @@
export type FloatingAnchor = 'left' | 'right';
export interface FloatingButtonPosition {
x: number;
y: number;
}
export interface FloatingButtonViewport {
width: number;
height: number;
}
export interface FloatingButtonRatios {
xRatio: number;
yRatio: number;
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function getTravelDistance(size: number, buttonSize: number, margin: number) {
return Math.max(0, size - buttonSize - margin * 2);
}
export function clampFloatingButtonPosition(
position: FloatingButtonPosition,
viewport: FloatingButtonViewport,
buttonSize = 56,
margin = 16,
): FloatingButtonPosition {
return {
x: clamp(
position.x,
margin,
Math.max(margin, viewport.width - buttonSize - margin),
),
y: clamp(
position.y,
margin,
Math.max(margin, viewport.height - buttonSize - margin),
),
};
}
export function getDefaultFloatingButtonPosition(
viewport: FloatingButtonViewport,
anchor: FloatingAnchor,
defaultYRatio = 0.5,
buttonSize = 56,
margin = 16,
): FloatingButtonPosition {
const x = anchor === 'left'
? margin
: Math.max(margin, viewport.width - buttonSize - margin);
const centeredY = viewport.height * defaultYRatio - buttonSize / 2;
return clampFloatingButtonPosition(
{ x, y: centeredY },
viewport,
buttonSize,
margin,
);
}
/**
* Convert a pixel position to a ratio within the draggable area. Using the
* available travel distance keeps a button placed on an edge attached to
* that edge when the viewport is resized.
*/
export function getFloatingButtonRatios(
position: FloatingButtonPosition,
viewport: FloatingButtonViewport,
buttonSize = 56,
margin = 16,
): FloatingButtonRatios {
const clamped = clampFloatingButtonPosition(position, viewport, buttonSize, margin);
const travelX = getTravelDistance(viewport.width, buttonSize, margin);
const travelY = getTravelDistance(viewport.height, buttonSize, margin);
return {
xRatio: travelX === 0 ? 0 : clamp((clamped.x - margin) / travelX, 0, 1),
yRatio: travelY === 0 ? 0 : clamp((clamped.y - margin) / travelY, 0, 1),
};
}
export function getPositionFromFloatingButtonRatios(
ratios: FloatingButtonRatios,
viewport: FloatingButtonViewport,
buttonSize = 56,
margin = 16,
): FloatingButtonPosition {
const travelX = getTravelDistance(viewport.width, buttonSize, margin);
const travelY = getTravelDistance(viewport.height, buttonSize, margin);
return clampFloatingButtonPosition(
{
x: margin + clamp(ratios.xRatio, 0, 1) * travelX,
y: margin + clamp(ratios.yRatio, 0, 1) * travelY,
},
viewport,
buttonSize,
margin,
);
}