划词工具条、AI 解释卡、批注气泡——任何「围绕一段选中内容弹出的浮层」都要回答同一个问题:放哪里?难的不是「放右边」,而是几条约束要同时成立:不被视口裁切、与选区留呼吸间距、 与边缘留安全边距,popup 大到四个方向都塞不下时还得优雅退化,而不是糊在用户刚选中的字上。
本页把它抽象成三个矩形的纯几何关系:FloatingPopup 围绕 SelectedRect(多行选区取包围盒)在 ContainerRect(通常是 viewport)内按 右 → 下 → 左 → 上 择位,交叉轴居中、越界时滑动,永不越出安全区;实在放不下就取「遮挡选区面积最小」的位置。 整个模型是一个零依赖纯函数(position.ts),下面两个案例分别验证「任意指定容器」和「真实 viewport + 文本选择」。
npx skills add hifizz/skills --skill floating-popup-position放置方向:下 · 四边都放不下 → 取遮挡选区最小的兜底位(遮挡 0%)
选中这段文字试试。松开鼠标后,气泡会围绕选区的包围盒出现:优先放在右侧,右侧放不下换下方,然后是左侧、上方。这里的 ContainerRect 就是浏览器 viewport —— 无论怎么选,气泡都不会被窗口边缘裁掉,且与边缘保持 12px 的安全边距。
跨段落拖一个多行选区也可以:多行选区的若干矩形会被合并成一个 bounding rect 参与计算,气泡围绕这个整体定位。选中之后不要取消,滚动页面 —— 选区在 viewport 里的位置变了,气泡会实时重新择位,靠近窗口底部时你能看到它从下方翻到上方。
把上面的开关切到「大卡片」,再选中一小段文字并把窗口调矮:当四个方向都塞不下时,模型进入 fallback,在四个 clamp 进安全区的候选里挑遮挡选区面积最小的那个 —— 宁可盖住别处,尽量露出你选中的内容。
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 供动画与调试