fix(settings): keep color picker panels within the viewport
This commit is contained in:
1 parent
751894d224
commit
8a9b02fa07
3 files changed
+120
-2
No files matched your search
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { hexToHsv, hsvToHex } from "@/lib/common/color";
|
import { hexToHsv, hsvToHex } from "@/lib/common/color";
|
||||||
|
import { floatingPanelPlacement } from "@/lib/common/floatingPanelPlacement";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
@@ -17,6 +18,8 @@ const emit = defineEmits<{ (e: "update:modelValue", value: string): void }>();
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
|
// 面板弹出方向:下方空间不足(会被弹窗 overflow-hidden 裁剪)时改为向上弹出
|
||||||
|
const placement = ref<"top" | "bottom">("bottom");
|
||||||
const rootRef = ref<HTMLElement | null>(null);
|
const rootRef = ref<HTMLElement | null>(null);
|
||||||
const spectrumRef = ref<HTMLElement | null>(null);
|
const spectrumRef = ref<HTMLElement | null>(null);
|
||||||
const hueRef = ref<HTMLElement | null>(null);
|
const hueRef = ref<HTMLElement | null>(null);
|
||||||
@@ -120,10 +123,40 @@ function onDocumentPointerDown(event: PointerEvent) {
|
|||||||
if (!rootRef.value.contains(event.target as Node)) open.value = false;
|
if (!rootRef.value.contains(event.target as Node)) open.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 面板整体高度的估算值(内边距 16 + 渐变区 132 + 色相条 20 + 读数行 32 + 按钮行 36 + 边框 2 = 238,向上取整留余量)
|
||||||
|
const PANEL_HEIGHT_ESTIMATE = 240;
|
||||||
|
|
||||||
|
// 向上查找会裁剪面板的祖先容器(例如弹窗的 overflow-hidden 内容区)
|
||||||
|
function findClippingAncestor(element: HTMLElement | null): HTMLElement | null {
|
||||||
|
let node = element?.parentElement ?? null;
|
||||||
|
while (node) {
|
||||||
|
const { overflow, overflowY } = getComputedStyle(node);
|
||||||
|
if (/(auto|hidden|scroll)/.test(`${overflow} ${overflowY}`)) return node;
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开前双向比较上下方剩余空间,选出不会被裁剪的弹出方向(具体规则见 floatingPanelPlacement 的单元测试)
|
||||||
|
function updatePlacement() {
|
||||||
|
const trigger = rootRef.value;
|
||||||
|
if (!trigger) return;
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
const clippingRect = findClippingAncestor(trigger)?.getBoundingClientRect();
|
||||||
|
placement.value = floatingPanelPlacement({
|
||||||
|
triggerTop: rect.top,
|
||||||
|
triggerBottom: rect.bottom,
|
||||||
|
panelHeight: PANEL_HEIGHT_ESTIMATE,
|
||||||
|
boundaryTop: clippingRect?.top ?? 0,
|
||||||
|
boundaryBottom: clippingRect?.bottom ?? window.innerHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
watch(open, (isOpen) => {
|
watch(open, (isOpen) => {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
syncFromModel();
|
syncFromModel();
|
||||||
|
updatePlacement();
|
||||||
document.addEventListener("pointerdown", onDocumentPointerDown, true);
|
document.addEventListener("pointerdown", onDocumentPointerDown, true);
|
||||||
} else {
|
} else {
|
||||||
clearPreview();
|
clearPreview();
|
||||||
@@ -140,9 +173,9 @@ onBeforeUnmount(() => {
|
|||||||
<div ref="rootRef" class="relative">
|
<div ref="rootRef" class="relative">
|
||||||
<button type="button" class="h-6 w-6 shrink-0 rounded border border-border/60 disabled:cursor-not-allowed disabled:opacity-60" :style="{ backgroundColor: props.modelValue }" :disabled="props.disabled" :aria-label="props.label" @click="open = !open" />
|
<button type="button" class="h-6 w-6 shrink-0 rounded border border-border/60 disabled:cursor-not-allowed disabled:opacity-60" :style="{ backgroundColor: props.modelValue }" :disabled="props.disabled" :aria-label="props.label" @click="open = !open" />
|
||||||
|
|
||||||
<div v-if="open" class="absolute right-0 top-full z-50 mt-1 w-[224px] rounded-lg border bg-popover p-2 shadow-lg" @pointerleave="clearPreview">
|
<div v-if="open" class="absolute right-0 z-50 w-56 rounded-lg border bg-popover p-2 shadow-lg" :class="placement === 'top' ? 'bottom-full mb-1' : 'top-full mt-1'" @pointerleave="clearPreview">
|
||||||
<!-- Saturation / value gradient for the active hue -->
|
<!-- Saturation / value gradient for the active hue -->
|
||||||
<div ref="spectrumRef" class="relative h-[132px] w-full cursor-crosshair rounded" :style="{ backgroundColor: `hsl(${hue}, 100%, 50%)` }" @pointermove="onSpectrumMove" @pointerdown.prevent="onSpectrumPick" @pointerleave="clearPreview">
|
<div ref="spectrumRef" class="relative h-33 w-full cursor-crosshair rounded" :style="{ backgroundColor: `hsl(${hue}, 100%, 50%)` }" @pointermove="onSpectrumMove" @pointerdown.prevent="onSpectrumPick" @pointerleave="clearPreview">
|
||||||
<div class="pointer-events-none absolute inset-0 rounded" style="background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0))" />
|
<div class="pointer-events-none absolute inset-0 rounded" style="background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0))" />
|
||||||
<div class="pointer-events-none absolute inset-0 rounded" style="background: linear-gradient(to top, #000, rgba(0, 0, 0, 0))" />
|
<div class="pointer-events-none absolute inset-0 rounded" style="background: linear-gradient(to top, #000, rgba(0, 0, 0, 0))" />
|
||||||
<div class="pointer-events-none absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow" :style="{ left: `${saturation * 100}%`, top: `${(1 - value) * 100}%` }" />
|
<div class="pointer-events-none absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow" :style="{ left: `${saturation * 100}%`, top: `${(1 - value) * 100}%` }" />
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
Reference in new issue
Block a user