playground

Floating Popup 定位模型

划词工具条、AI 解释卡、批注气泡——任何「围绕一段选中内容弹出的浮层」都要回答同一个问题:放哪里?难的不是「放右边」,而是几条约束要同时成立:不被视口裁切、与选区留呼吸间距、 与边缘留安全边距,popup 大到四个方向都塞不下时还得优雅退化,而不是糊在用户刚选中的字上。

本页把它抽象成三个矩形的纯几何关系:FloatingPopup 围绕 SelectedRect(多行选区取包围盒)在 ContainerRect(通常是 viewport)内按 右 → 下 → 左 → 上 择位,交叉轴居中、越界时滑动,永不越出安全区;实在放不下就取「遮挡选区面积最小」的位置。 整个模型是一个零依赖纯函数(position.ts),下面两个案例分别验证「任意指定容器」和「真实 viewport + 文本选择」。

源码npx skills add hifizz/skills --skill floating-popup-position

案例 1 · 指定 ContainerRect 的沙箱点击 / 拖动移动选区,右下角手柄改选区大小

ContainerRect 0 × 520
SelectedRect

放置方向: · 四边都放不下 → 取遮挡选区最小的兜底位(遮挡 0%)

popup 预设:

案例 2 · container = 浏览器 viewport真实文本选择

气泡尺寸:

选中这段文字试试。松开鼠标后,气泡会围绕选区的包围盒出现:优先放在右侧,右侧放不下换下方,然后是左侧、上方。这里的 ContainerRect 就是浏览器 viewport —— 无论怎么选,气泡都不会被窗口边缘裁掉,且与边缘保持 12px 的安全边距。

跨段落拖一个多行选区也可以:多行选区的若干矩形会被合并成一个 bounding rect 参与计算,气泡围绕这个整体定位。选中之后不要取消,滚动页面 —— 选区在 viewport 里的位置变了,气泡会实时重新择位,靠近窗口底部时你能看到它从下方翻到上方。

把上面的开关切到「大卡片」,再选中一小段文字并把窗口调矮:当四个方向都塞不下时,模型进入 fallback,在四个 clamp 进安全区的候选里挑遮挡选区面积最小的那个 —— 宁可盖住别处,尽量露出你选中的内容。

定位规则

  • 输入anchor(SelectedRect,多行选区取 bounding rect)、popup 尺寸、container(ContainerRect)、 gap(默认 4)、safePadding(默认 12)。
  • 择位按 右 → 下 → 左 → 上 逐个尝试,第一个能干净放下的方向即采用;交叉轴对选区居中, 越界时沿交叉轴滑动,不算失败。
  • 硬约束popup 永不越出 container 内缩 safePadding 的安全区,不会被裁切 (popup 比安全区还大时贴左上角,属于无解情形)。
  • 兜底四边都放不下时,把各方向候选 clamp 进安全区,选遮挡选区面积最小的一个 —— 宁可盖住别处,尽量不遮住用户可见选区。

Usage

import { computePopupPosition } from "./position";

const { left, top, side, fallback } = computePopupPosition(
  selectionRect,                     // anchor:选区包围盒(DOMRect 即可)
  { width: 240, height: 44 },        // popup 尺寸
  { left: 0, top: 0,                 // container:这里用 viewport
    width: innerWidth, height: innerHeight },
  { safePadding: 12, gap: 4 },       // 可选,默认即这两个值
);
// => position: fixed; left/top 直接可用;side/fallback 供动画与调试