From 31359a9cd2bd5544fb9eb8fb849c71c8e264b97a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 29 May 2026 13:26:40 +0800 Subject: [PATCH] feat(desktop): add element picker state machine with shadow-dom overlay Co-Authored-By: Claude Sonnet 4.6 --- desktop/src/preview-agent/picker.test.ts | 29 +++++++++++++++++ desktop/src/preview-agent/picker.ts | 41 ++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 desktop/src/preview-agent/picker.test.ts create mode 100644 desktop/src/preview-agent/picker.ts diff --git a/desktop/src/preview-agent/picker.test.ts b/desktop/src/preview-agent/picker.test.ts new file mode 100644 index 00000000..58a4f09b --- /dev/null +++ b/desktop/src/preview-agent/picker.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { createPicker } from './picker' + +beforeEach(() => { document.body.innerHTML = `

A

` }) + +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() + }) +}) diff --git a/desktop/src/preview-agent/picker.ts b/desktop/src/preview-agent/picker.ts new file mode 100644 index 00000000..9970481d --- /dev/null +++ b/desktop/src/preview-agent/picker.ts @@ -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() }, + } +}