222 lines
9.2 KiB
TypeScript
222 lines
9.2 KiB
TypeScript
/**
|
||
* 选择器识别模块
|
||
* 负责:检测表单字段是否为选择器类型(下拉、日期、级联等)
|
||
* 检测方式:
|
||
* 1. UI组件库类名匹配(快速路径)
|
||
* 2. 提示文字检测("请选择"等)
|
||
* 3. 主动点击检测:点击 input 及其单子标签父级链,对比点击前后全页面 DOM 可见性变化判断是否有弹出层
|
||
*
|
||
* 【注意】方式3 的核心机制:
|
||
* - 点击前记录全页面所有可见元素快照(offsetHeight > 0 && offsetWidth > 0)
|
||
* - 点击后对比哪些元素是新变为可见的,以此判断弹出层出现
|
||
* - 检测到弹出层后【不关闭】,保存弹出层 DOM 引用到 field.pickerDropdownElement
|
||
* - fillPickerField 步骤0 直接在已有弹出层内搜索选项,避免二次点击导致 toggle 关闭
|
||
* - 禁止使用固定的 CSS 类名选择器(如 POPUP_SELECTORS)来检测弹出层,必须走纯 DOM 差异对比
|
||
* - 这和 fillPickerField 步骤1/3 的 DOM 差异检测是同一套方法,保持一致
|
||
*/
|
||
|
||
import type { MatchedFormField, UILibPickerConfig } from "./types"
|
||
import { UI_LIB_PICKER_CONFIGS } from "./constants"
|
||
import { delay } from "~utils/delay"
|
||
|
||
/** 通过提示文字判断是否为选择器 */
|
||
function hasPickerHintText(field: MatchedFormField, lang: "zh" | "en"): boolean {
|
||
const hints = lang === "zh"
|
||
? ["请选择", "请输入并选择", "点击选择"]
|
||
: ["Please select", "Choose", "Click to select", "Pick a"]
|
||
|
||
if (field.inputElement) {
|
||
const placeholder = field.inputElement.getAttribute("placeholder") || ""
|
||
if (hints.some((h) => placeholder.includes(h))) return true
|
||
if (field.inputElement.hasAttribute("readonly")) {
|
||
let parent: Element | null = field.inputElement.parentElement
|
||
for (let i = 0; i < 3 && parent; i++) {
|
||
const cls = parent.className || ""
|
||
if (typeof cls === "string" && (
|
||
cls.includes("select") || cls.includes("picker") || cls.includes("date") ||
|
||
cls.includes("cascader") || cls.includes("dropdown") || cls.includes("calendar")
|
||
)) return true
|
||
parent = parent.parentElement
|
||
}
|
||
}
|
||
// 检查紧邻兄弟元素的文字
|
||
for (const sib of [field.inputElement.nextElementSibling, field.inputElement.previousElementSibling]) {
|
||
if (sib) {
|
||
const text = sib.textContent?.trim() || ""
|
||
if (text.length < 30 && hints.some((h) => text.includes(h))) return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
/** 检查 input 附近是否有已知 UI 组件库的选择器触发区域类名 */
|
||
function matchUILibTrigger(field: MatchedFormField): UILibPickerConfig | null {
|
||
const classSet = new Set<string>()
|
||
let el: Element | null = field.inputElement || field.labelElement
|
||
for (let i = 0; i < 5 && el; i++) {
|
||
if (el.className && typeof el.className === "string") {
|
||
el.className.trim().split(/\s+/).forEach((c) => classSet.add(c))
|
||
}
|
||
el = el.parentElement
|
||
}
|
||
if (field.buttonElement?.className && typeof field.buttonElement.className === "string") {
|
||
field.buttonElement.className.trim().split(/\s+/).forEach((c) => classSet.add(c))
|
||
}
|
||
for (const lib of UI_LIB_PICKER_CONFIGS) {
|
||
if (lib.triggerClasses.some((cls) => classSet.has(cls))) return lib
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 记录当前 body 直接子元素 + 全页面可见元素快照(用于点击前后 DOM 差异对比)
|
||
* 【注意】不使用任何固定 CSS 选择器,记录全页面所有可见元素,保证通用性
|
||
* offsetHeight/offsetWidth > 0 即视为可见,不设更高阈值(避免漏判小尺寸弹出层)
|
||
*/
|
||
function snapshotDOM(): { bodyChildren: Set<Element>; visibleEls: Set<Element> } {
|
||
const bodyChildren = new Set(Array.from(document.body.children))
|
||
// 记录所有可见的元素(offsetHeight > 0 且 offsetWidth > 0)
|
||
const visibleEls = new Set<Element>()
|
||
document.querySelectorAll("*").forEach((el) => {
|
||
const htmlEl = el as HTMLElement
|
||
if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0) visibleEls.add(el)
|
||
})
|
||
return { bodyChildren, visibleEls }
|
||
}
|
||
|
||
/** 对比 DOM 快照,检测是否有新出现的可见元素(弹出层) */
|
||
function hasNewPopup(before: ReturnType<typeof snapshotDOM>): boolean {
|
||
// 检查 body 下新增的直接子元素(offsetHeight > 0 才算有效弹出层)
|
||
for (const child of Array.from(document.body.children)) {
|
||
if (!before.bodyChildren.has(child) && (child as HTMLElement).offsetHeight > 0) return true
|
||
}
|
||
// 检查新变为可见的元素(点击前不可见,点击后可见)
|
||
const allEls = document.querySelectorAll("*")
|
||
for (const el of Array.from(allEls)) {
|
||
const htmlEl = el as HTMLElement
|
||
if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0 && !before.visibleEls.has(el)) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
/** 关闭可能打开的弹出层 */
|
||
async function dismissPopup() {
|
||
if (document.activeElement instanceof HTMLElement) {
|
||
document.activeElement.dispatchEvent(
|
||
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
|
||
)
|
||
document.activeElement.blur()
|
||
}
|
||
document.body.click()
|
||
await delay("mid")
|
||
}
|
||
|
||
/**
|
||
* 主动点击检测:点击 input 及其单子标签父级链,对比 DOM 变化判断是否有弹出层
|
||
* 如果检测到弹出层,【不关闭弹出层】,将弹出层 DOM 引用保存到 field.pickerDropdownElement,返回 true
|
||
* 点击选择后弹出层会自行关闭,无需手动 dismiss
|
||
*
|
||
* 【注意】此处不可调用 dismissPopup 关闭弹出层!原因:
|
||
* - fillPickerField 步骤0 需要直接在已有弹出层内搜索选项
|
||
* - 如果这里关闭了弹出层,步骤2 再点击会触发 toggle 导致弹出层反而不展开
|
||
* - Phoenix 等组件的弹出层用 Escape/blur/body.click 关闭不可靠,会引发状态不一致
|
||
*/
|
||
async function detectByClick(field: MatchedFormField): Promise<boolean> {
|
||
if (!field.inputElement) return false
|
||
|
||
// 收集要点击的元素:input 本身 + 单子标签父级链(最多5层)
|
||
const clickTargets: HTMLElement[] = [field.inputElement as HTMLElement]
|
||
let current: HTMLElement | null = field.inputElement as HTMLElement
|
||
for (let i = 0; i < 5 && current?.parentElement; i++) {
|
||
const parentEl = current.parentElement
|
||
if (parentEl.children.length <= 2) {
|
||
clickTargets.push(parentEl)
|
||
current = parentEl
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
|
||
// 逐个尝试点击,检测 DOM 变化
|
||
for (const target of clickTargets) {
|
||
const before = snapshotDOM()
|
||
target.click()
|
||
await delay("mid")
|
||
|
||
if (hasNewPopup(before)) {
|
||
console.log(`OfferPie: [${field.key}] 点击 ${target.tagName}.${(target.className || "").toString().split(" ")[0]} 后检测到弹出层`)
|
||
// 【注意】不关闭弹出层,保存弹出层 DOM 引用供 fillPickerField 步骤0 使用
|
||
const popupEl = findNewPopupElement(before)
|
||
if (popupEl) {
|
||
field.pickerDropdownElement = popupEl
|
||
console.log(`OfferPie: [${field.key}] 保存弹出层引用: ${popupEl.tagName}.${(popupEl.className || "").toString().split(" ")[0]} offsetHeight=${popupEl.offsetHeight}`)
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
|
||
// 没检测到弹出层,确保关闭焦点状态
|
||
await dismissPopup()
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 从 DOM 快照对比中找到新出现的弹出层元素
|
||
* 逻辑和 fillPickerField 步骤3 一致:找点击后新变为可见的最大元素
|
||
*/
|
||
function findNewPopupElement(before: ReturnType<typeof snapshotDOM>): HTMLElement | null {
|
||
// 优先:body 下新增的、有实际高度的直接子元素
|
||
for (const child of Array.from(document.body.children)) {
|
||
if (!before.bodyChildren.has(child) && (child as HTMLElement).offsetHeight > 0) {
|
||
return child as HTMLElement
|
||
}
|
||
}
|
||
// 其次:全页面找新变为可见的元素,取面积最大的那个
|
||
let best: HTMLElement | null = null
|
||
let bestArea = 0
|
||
document.querySelectorAll("*").forEach((el) => {
|
||
const htmlEl = el as HTMLElement
|
||
if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0 && !before.visibleEls.has(el)) {
|
||
const area = htmlEl.offsetHeight * htmlEl.offsetWidth
|
||
if (area > bestArea) { best = htmlEl; bestArea = area }
|
||
}
|
||
})
|
||
return best
|
||
}
|
||
|
||
/**
|
||
* 检测单个字段是否为数据选择器类型
|
||
* 优先级:1.UI组件库类名 → 2.提示文字 → 3.主动点击检测DOM变化
|
||
*/
|
||
export async function detectPickerField(field: MatchedFormField, lang: "zh" | "en"): Promise<void> {
|
||
if (field.inputType === "radio") return
|
||
|
||
// 方式1:UI 组件库类名匹配
|
||
const matchedLib = matchUILibTrigger(field)
|
||
if (matchedLib) {
|
||
console.log(`OfferPie: [${field.key}] 匹配到 UI 组件库: ${matchedLib.libName}`)
|
||
field.isPicker = true
|
||
field.inputType = "picker"
|
||
field.pickerDropdownSelector = matchedLib.optionItemClasses.map((cls) => "." + CSS.escape(cls)).join(", ")
|
||
return
|
||
}
|
||
|
||
// 方式2:提示文字检测
|
||
if (hasPickerHintText(field, lang)) {
|
||
console.log(`OfferPie: [${field.key}] 有选择器提示文字,标记为 picker`)
|
||
field.isPicker = true
|
||
field.inputType = "picker"
|
||
return
|
||
}
|
||
|
||
// 方式3:主动点击检测 DOM 变化
|
||
const detected = await detectByClick(field)
|
||
if (detected) {
|
||
console.log(`OfferPie: [${field.key}] 通过点击检测到弹出层,标记为 picker`)
|
||
field.isPicker = true
|
||
field.inputType = "picker"
|
||
return
|
||
}
|
||
}
|