feat(desktop): add element picker state machine with shadow-dom overlay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-29 13:26:40 +08:00
parent 5083adc9f9
commit 31359a9cd2
2 changed files with 70 additions and 0 deletions

View File

@ -0,0 +1,29 @@
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { createPicker } from './picker'
beforeEach(() => { document.body.innerHTML = `<main><section><h1 id="t">A</h1></section></main>` })
describe('createPicker', () => {
it('selects an element and exposes current selection', () => {
const onSelect = vi.fn()
const picker = createPicker({ onSelect })
picker.enter()
picker.hover(document.getElementById('t')!)
picker.select()
expect(picker.current()?.id).toBe('t')
expect(onSelect).toHaveBeenCalledWith(document.getElementById('t'))
})
it('climb/descend move the current selection', () => {
const picker = createPicker({ onSelect: vi.fn() })
picker.enter(); picker.hover(document.getElementById('t')!); picker.select()
picker.climb()
expect(picker.current()?.tagName.toLowerCase()).toBe('section')
picker.descend()
expect(picker.current()?.id).toBe('t')
})
it('exit clears overlay and selection', () => {
const picker = createPicker({ onSelect: vi.fn() })
picker.enter(); picker.hover(document.getElementById('t')!); picker.select(); picker.exit()
expect(picker.current()).toBeNull()
})
})

View File

@ -0,0 +1,41 @@
import { climb as climbEl, descend as descendEl } from './treeNav'
type PickerDeps = { onSelect: (el: Element) => void }
export function createPicker(deps: PickerDeps) {
let active = false
let current: Element | null = null
let hovered: Element | null = null
// Shadow DOM 浮层宿主:避免被页面 CSS 影响、抗 CSP 内联限制
const host = document.createElement('div')
host.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483647'
const shadow = host.attachShadow({ mode: 'open' })
const box = document.createElement('div')
box.style.cssText = 'position:fixed;border:2px solid #2f7bff;background:rgba(47,123,255,.12);pointer-events:none'
box.hidden = true
shadow.appendChild(box)
const draw = (el: Element | null) => {
if (!el) { box.hidden = true; return }
const r = el.getBoundingClientRect()
box.hidden = false
box.style.left = `${r.left}px`; box.style.top = `${r.top}px`
box.style.width = `${r.width}px`; box.style.height = `${r.height}px`
}
const setCurrent = (el: Element) => { current = el; draw(el); deps.onSelect(el) }
return {
enter() {
active = true; current = null; hovered = null
if (!host.isConnected) document.documentElement.appendChild(host)
},
hover(el: Element) { if (!active || current) return; hovered = el; draw(el) },
select() { if (hovered) setCurrent(hovered) },
climb() { if (current) { const p = climbEl(current); if (p) { current = p; draw(p) } } },
descend() { if (current) { const c = descendEl(current); if (c) { current = c; draw(c) } } },
current() { return current },
exit() { active = false; current = null; hovered = null; box.hidden = true; host.remove() },
}
}