mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
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:
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 4.9.16 - 2026-07-27
|
||||
|
||||
- 修复窗口宽度变化后右侧“历史播放记录”浮动按钮停留在原位置、无法继续贴靠右边缘的问题。
|
||||
- 收藏与历史浮动按钮现在会在窗口尺寸变化时保持各自的左右锚点;已拖拽的按钮按可拖拽区域比例保留位置。
|
||||
- 新增浮动按钮定位与窗口缩放回归测试。
|
||||
|
||||
## 4.9.15 - 2026-07-25
|
||||
|
||||
- 修复播放页点击选集“排序”导致播放器闪烁/重播:`usePlayerSettings` 在非播放器核心设置变化时复用快照引用,避免 HLS 实例被销毁重建。
|
||||
|
||||
+11
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user