From 8a9b02fa07f7525bf890212c57cc0a9de950783f Mon Sep 17 00:00:00 2001 From: dadong <102886401+mr-dadong@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:37:34 +0800 Subject: [PATCH] fix(settings): keep color picker panels within the viewport --- .../src/components/ui/ColorSpectrumPicker.vue | 37 ++++++++++++- .../common/floatingPanelPlacement.spec.ts | 54 +++++++++++++++++++ .../src/lib/common/floatingPanelPlacement.ts | 31 +++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/common/floatingPanelPlacement.spec.ts create mode 100644 apps/desktop/src/lib/common/floatingPanelPlacement.ts diff --git a/apps/desktop/src/components/ui/ColorSpectrumPicker.vue b/apps/desktop/src/components/ui/ColorSpectrumPicker.vue index 6554a9ee07..48a6db140d 100644 --- a/apps/desktop/src/components/ui/ColorSpectrumPicker.vue +++ b/apps/desktop/src/components/ui/ColorSpectrumPicker.vue @@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; import { Button } from "@/components/ui/button"; import { hexToHsv, hsvToHex } from "@/lib/common/color"; +import { floatingPanelPlacement } from "@/lib/common/floatingPanelPlacement"; interface Props { modelValue: string; @@ -17,6 +18,8 @@ const emit = defineEmits<{ (e: "update:modelValue", value: string): void }>(); const { t } = useI18n(); const open = ref(false); +// 面板弹出方向:下方空间不足(会被弹窗 overflow-hidden 裁剪)时改为向上弹出 +const placement = ref<"top" | "bottom">("bottom"); const rootRef = ref(null); const spectrumRef = ref(null); const hueRef = ref(null); @@ -120,10 +123,40 @@ function onDocumentPointerDown(event: PointerEvent) { 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) => { if (typeof document === "undefined") return; if (isOpen) { syncFromModel(); + updatePlacement(); document.addEventListener("pointerdown", onDocumentPointerDown, true); } else { clearPreview(); @@ -140,9 +173,9 @@ onBeforeUnmount(() => {