From 907f393e2f54de59475568650e310cbf9f38f599 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Mon, 27 Jul 2026 21:13:08 +0800 Subject: [PATCH] fix: keep floating buttons anchored on resize --- CHANGELOG.md | 6 ++ app-release.json | 12 ++- lib/hooks/useFloatingButtonPosition.ts | 119 ++++++++++++++++--------- lib/utils/floating-button-position.ts | 105 ++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- tests/floating-button-position.test.ts | 62 +++++++++++++ 7 files changed, 266 insertions(+), 44 deletions(-) create mode 100644 lib/utils/floating-button-position.ts create mode 100644 tests/floating-button-position.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e200b9c..15f0b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 4.9.16 - 2026-07-27 + +- 修复窗口宽度变化后右侧“历史播放记录”浮动按钮停留在原位置、无法继续贴靠右边缘的问题。 +- 收藏与历史浮动按钮现在会在窗口尺寸变化时保持各自的左右锚点;已拖拽的按钮按可拖拽区域比例保留位置。 +- 新增浮动按钮定位与窗口缩放回归测试。 + ## 4.9.15 - 2026-07-25 - 修复播放页点击选集“排序”导致播放器闪烁/重播:`usePlayerSettings` 在非播放器核心设置变化时复用快照引用,避免 HLS 实例被销毁重建。 diff --git a/app-release.json b/app-release.json index 7fc54f4..a35f19b 100644 --- a/app-release.json +++ b/app-release.json @@ -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", diff --git a/lib/hooks/useFloatingButtonPosition.ts b/lib/hooks/useFloatingButtonPosition.ts index e4e093b..6c2e24c 100644 --- a/lib/hooks/useFloatingButtonPosition.ts +++ b/lib/hooks/useFloatingButtonPosition.ts @@ -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(null); const dragStateRef = useRef(INITIAL_DRAG_STATE); const positionRef = useRef(null); + const customRatiosRef = useRef(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; - if (typeof parsed.xRatio !== 'number' || typeof parsed.yRatio !== 'number') { + const parsed = JSON.parse(raw) as Partial & { 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); diff --git a/lib/utils/floating-button-position.ts b/lib/utils/floating-button-position.ts new file mode 100644 index 0000000..d2bd63b --- /dev/null +++ b/lib/utils/floating-button-position.ts @@ -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, + ); +} diff --git a/package-lock.json b/package-lock.json index 6e48981..9b5ae3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b60ac1e..ac420d7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/floating-button-position.test.ts b/tests/floating-button-position.test.ts new file mode 100644 index 0000000..fd74e8b --- /dev/null +++ b/tests/floating-button-position.test.ts @@ -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); +});