fix(settings): keep color picker panels within the viewport

This commit is contained in:
dadong authored and GitHub committed 2026-08-20 21:37:34 +08:00
1 parent 751894d224
commit 8a9b02fa07
3 files changed
+120 -2

No files matched your search

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { floatingPanelPlacement } from "@/lib/common/floatingPanelPlacement";
// 面板高度取 ColorSpectrumPicker 的估算值,gap 默认 4,完整放下需要 244
const PANEL_HEIGHT = 240;
function placementAt(triggerTop: number, triggerBottom: number, boundaryTop = 0, boundaryBottom = 800): "top" | "bottom" {
return floatingPanelPlacement({
triggerTop,
triggerBottom,
panelHeight: PANEL_HEIGHT,
boundaryTop,
boundaryBottom,
});
}
describe("floating panel placement", () => {
it("pops downward when the space below fits the panel", () => {
// 下方剩余 320,大于 252 + 4
expect(placementAt(100, 124)).toBe("bottom");
});
it("keeps popping downward when the space below exactly equals panel plus gap", () => {
// 下方刚好 244 = 240 + 4,边界值应向下
expect(placementAt(544, 568, 0, 812)).toBe("bottom");
});
it("flips upward when below is too tight but above fits", () => {
// 下方 216 不够,上方 560 足够
expect(placementAt(560, 584)).toBe("top");
});
it("flips upward when the space above exactly equals panel plus gap", () => {
// 上方刚好 244 = 240 + 4,边界值应向上
expect(placementAt(244, 268, 0, 500)).toBe("top");
});
it("stays downward when neither side fits and below has more space", () => {
// 下方 226 > 上方 150,两侧都不够时选下方
expect(placementAt(150, 174, 0, 400)).toBe("bottom");
});
it("flips upward when neither side fits and above has more space", () => {
// 下方 126 < 上方 250,两侧都不够时选上方
expect(placementAt(250, 274, 0, 400)).toBe("top");
});
it("respects a non-zero boundary top offset", () => {
// 裁剪容器顶部在 200,触发点上方只有 100,下方 476 足够
expect(placementAt(300, 324, 200, 800)).toBe("bottom");
// 下方不够且上方不够时,空间更大的一侧获胜(上方 160 > 下方 116)
expect(placementAt(360, 384, 200, 500)).toBe("top");
});
});
@@ -0,0 +1,31 @@
export interface FloatingPanelPlacementOptions {
/** 触发元素的顶部坐标(与 boundary 同一坐标系) */
triggerTop: number;
/** 触发元素的底部坐标(与 boundary 同一坐标系) */
triggerBottom: number;
/** 浮动面板的整体高度 */
panelHeight: number;
/** 可用区域的顶部边界(例如裁剪容器的顶部或视口顶部) */
boundaryTop: number;
/** 可用区域的底部边界(例如裁剪容器的底部或视口底部) */
boundaryBottom: number;
/** 触发元素与面板之间的间距 */
gap?: number;
}
/**
* 根据触发点上方的可用空间决定浮动面板的弹出方向。
* 优先向下弹出;下方放不下且上方足够时向上弹出;
* 两侧都放不下时选择空间更大的一侧,尽量减少被裁剪的面积。
*/
export function floatingPanelPlacement(options: FloatingPanelPlacementOptions): "top" | "bottom" {
const gap = options.gap ?? 4;
const spaceBelow = options.boundaryBottom - options.triggerBottom;
const spaceAbove = options.triggerTop - options.boundaryTop;
// 下方空间刚好能放下整个面板时保持默认的向下弹出
if (spaceBelow >= gap + options.panelHeight) return "bottom";
// 下方不够、上方足够时翻转向上弹出
if (spaceAbove >= gap + options.panelHeight) return "top";
// 两侧都无法完整放下时,选择空间更大的一侧
return spaceBelow >= spaceAbove ? "bottom" : "top";
}