Merge pull request #221 from KuekHaoYang/agent/fix-issue-220-floating-button-resize

fix: keep floating buttons anchored on resize
This commit is contained in:
Kuek Hao Yang
2026-07-27 21:19:51 +08:00
committed by GitHub
7 changed files with 266 additions and 44 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 4.9.16 - 2026-07-27
- 修复窗口宽度变化后右侧“历史播放记录”浮动按钮停留在原位置、无法继续贴靠右边缘的问题。
- 收藏与历史浮动按钮现在会在窗口尺寸变化时保持各自的左右锚点;已拖拽的按钮按可拖拽区域比例保留位置。
- 新增浮动按钮定位与窗口缩放回归测试。
## 4.9.15 - 2026-07-25
- 修复播放页点击选集“排序”导致播放器闪烁/重播:`usePlayerSettings` 在非播放器核心设置变化时复用快照引用,避免 HLS 实例被销毁重建。
+11 -1
View File
@@ -4,8 +4,18 @@
"name": "KVideo",
"branch": "main"
},
"currentVersion": "4.9.15",
"currentVersion": "4.9.16",
"releases": [
{
"version": "4.9.16",
"publishedAt": "2026-07-27",
"title": "修复浮动按钮窗口缩放吸附",
"notes": [
"修复窗口宽度变化后右侧“历史播放记录”按钮停留在原位置、无法继续贴靠右边缘的问题。",
"收藏与历史浮动按钮现在会在窗口尺寸变化时保持各自的左右锚点;已拖拽的按钮按可拖拽区域比例保留位置。",
"新增浮动按钮定位与窗口缩放回归测试。"
]
},
{
"version": "4.9.15",
"publishedAt": "2026-07-25",
+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,
);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kvideo",
"version": "4.9.15",
"version": "4.9.16",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "kvideo",
"version": "4.9.15",
"version": "4.9.16",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.9.15",
"version": "4.9.16",
"private": true,
"scripts": {
"dev": "node scripts/next-with-lan-access.mjs dev",
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getDefaultFloatingButtonPosition,
getFloatingButtonRatios,
getPositionFromFloatingButtonRatios,
} from '@/lib/utils/floating-button-position';
const buttonSize = 56;
const margin = 16;
test('default right-anchored buttons follow the right edge after a viewport resize', () => {
const initial = getDefaultFloatingButtonPosition(
{ width: 1280, height: 720 },
'right',
0.5,
buttonSize,
margin,
);
const resized = getDefaultFloatingButtonPosition(
{ width: 1920, height: 720 },
'right',
0.5,
buttonSize,
margin,
);
assert.equal(initial.x, 1208);
assert.equal(resized.x, 1848);
assert.equal(1280 - initial.x - buttonSize, margin);
assert.equal(1920 - resized.x - buttonSize, margin);
});
test('default left-anchored buttons remain attached to the left edge', () => {
const resized = getDefaultFloatingButtonPosition(
{ width: 1920, height: 900 },
'left',
0.5,
buttonSize,
margin,
);
assert.equal(resized.x, margin);
assert.equal(resized.y, 422);
});
test('custom positions preserve their relative draggable-area placement on resize', () => {
const initialViewport = { width: 1280, height: 720 };
const initialPosition = { x: 1208, y: 332 };
const ratios = getFloatingButtonRatios(initialPosition, initialViewport, buttonSize, margin);
const resized = getPositionFromFloatingButtonRatios(
ratios,
{ width: 1920, height: 900 },
buttonSize,
margin,
);
assert.equal(ratios.xRatio, 1);
assert.equal(resized.x, 1848);
assert.equal(resized.y, 422);
});