diff --git a/app/chrome-extension/common/message-types.ts b/app/chrome-extension/common/message-types.ts index 2037dc5..e95618a 100644 --- a/app/chrome-extension/common/message-types.ts +++ b/app/chrome-extension/common/message-types.ts @@ -51,6 +51,9 @@ export const BACKGROUND_MESSAGE_TYPES = { ELEMENT_MARKER_UPDATE: 'element_marker_update', ELEMENT_MARKER_DELETE: 'element_marker_delete', ELEMENT_MARKER_VALIDATE: 'element_marker_validate', + // Web editor (in-page visual editing) + WEB_EDITOR_TOGGLE: 'web_editor_toggle', + WEB_EDITOR_APPLY: 'web_editor_apply', } as const; // Offscreen message types diff --git a/app/chrome-extension/entrypoints/background/index.ts b/app/chrome-extension/entrypoints/background/index.ts index 0c76b58..bb09359 100644 --- a/app/chrome-extension/entrypoints/background/index.ts +++ b/app/chrome-extension/entrypoints/background/index.ts @@ -7,6 +7,7 @@ import { initStorageManagerListener } from './storage-manager'; import { cleanupModelCache } from '@/utils/semantic-similarity-engine'; import { initRecordReplayListeners } from './record-replay'; import { initElementMarkerListeners } from './element-marker'; +import { initWebEditorListeners } from './web-editor'; /** * Background script entry point @@ -21,6 +22,8 @@ export default defineBackground(() => { initRecordReplayListeners(); // Element marker: context menu + CRUD listeners initElementMarkerListeners(); + // Web editor: toggle edit-mode overlay + initWebEditorListeners(); // Conditionally initialize semantic similarity engine if model cache exists initializeSemanticEngineIfCached() diff --git a/app/chrome-extension/entrypoints/background/web-editor/index.ts b/app/chrome-extension/entrypoints/background/web-editor/index.ts new file mode 100644 index 0000000..105d8e9 --- /dev/null +++ b/app/chrome-extension/entrypoints/background/web-editor/index.ts @@ -0,0 +1,333 @@ +import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; + +const CONTEXT_MENU_ID = 'web_editor_toggle'; +const COMMAND_KEY = 'toggle_web_editor'; +const DEFAULT_NATIVE_SERVER_PORT = 12306; + +type WebEditorInstructionType = 'update_text' | 'update_style'; + +interface WebEditorFingerprint { + tag: string; + id?: string; + classes: string[]; + text?: string; +} + +interface WebEditorApplyPayload { + pageUrl: string; + targetFile?: string; + fingerprint: WebEditorFingerprint; + techStackHint?: string[]; + instruction: { + type: WebEditorInstructionType; + description: string; + text?: string; + style?: Record; + }; +} + +function normalizeString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((item) => normalizeString(item)).filter(Boolean); +} + +function normalizeStyleMap(value: unknown): Record | undefined { + if (!value || typeof value !== 'object') return undefined; + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + const key = normalizeString(k).trim(); + const val = normalizeString(v).trim(); + if (!key || !val) continue; + out[key] = val; + } + return Object.keys(out).length ? out : undefined; +} + +function normalizeApplyPayload(raw: unknown): WebEditorApplyPayload { + const obj = (raw && typeof raw === 'object' ? raw : {}) as Record; + const pageUrl = normalizeString(obj.pageUrl).trim(); + const targetFile = normalizeString(obj.targetFile).trim() || undefined; + const techStackHint = normalizeStringArray(obj.techStackHint); + + const fingerprintRaw = ( + obj.fingerprint && typeof obj.fingerprint === 'object' ? obj.fingerprint : {} + ) as Record; + const fingerprint: WebEditorFingerprint = { + tag: normalizeString(fingerprintRaw.tag).trim() || 'unknown', + id: normalizeString(fingerprintRaw.id).trim() || undefined, + classes: normalizeStringArray(fingerprintRaw.classes), + text: normalizeString(fingerprintRaw.text).trim() || undefined, + }; + + const instructionRaw = ( + obj.instruction && typeof obj.instruction === 'object' ? obj.instruction : {} + ) as Record; + const type = normalizeString(instructionRaw.type).trim() as WebEditorInstructionType; + if (type !== 'update_text' && type !== 'update_style') { + throw new Error('Invalid instruction.type'); + } + + const instruction = { + type, + description: normalizeString(instructionRaw.description).trim() || '', + text: normalizeString(instructionRaw.text).trim() || undefined, + style: normalizeStyleMap(instructionRaw.style), + }; + + if (!pageUrl) { + throw new Error('pageUrl is required'); + } + if (!instruction.description) { + throw new Error('instruction.description is required'); + } + + return { + pageUrl, + targetFile, + fingerprint, + techStackHint: techStackHint.length ? techStackHint : undefined, + instruction, + }; +} + +function buildAgentPrompt(payload: WebEditorApplyPayload): string { + const lines: string[] = []; + lines.push('You are a senior frontend engineer working in a local codebase.'); + lines.push( + 'Goal: persist a visual edit from the browser into the source code with minimal changes.', + ); + lines.push(''); + lines.push(`Page URL: ${payload.pageUrl}`); + lines.push(''); + lines.push('Selected element fingerprint (best-effort):'); + lines.push(`- tag: ${payload.fingerprint.tag}`); + if (payload.fingerprint.id) lines.push(`- id: ${payload.fingerprint.id}`); + if (payload.fingerprint.classes?.length) + lines.push(`- classes: ${payload.fingerprint.classes.join(' ')}`); + if (payload.fingerprint.text) lines.push(`- text: ${payload.fingerprint.text}`); + lines.push(''); + + if (payload.targetFile) { + lines.push(`Target file (best-effort): ${payload.targetFile}`); + lines.push( + 'If this path is invalid or points to node_modules, ignore it and locate by fingerprint instead.', + ); + lines.push(''); + } + + if (payload.techStackHint?.length) { + lines.push(`Tech hints: ${payload.techStackHint.join(', ')}`); + lines.push(''); + } + + lines.push('Requested change:'); + lines.push(`- type: ${payload.instruction.type}`); + lines.push(`- description: ${payload.instruction.description}`); + if (payload.instruction.type === 'update_text' && payload.instruction.text) { + lines.push(`- new text: ${JSON.stringify(payload.instruction.text)}`); + } + if (payload.instruction.type === 'update_style' && payload.instruction.style) { + lines.push(`- style map: ${JSON.stringify(payload.instruction.style, null, 2)}`); + } + lines.push(''); + + lines.push('How to locate the code to change:'); + if (payload.targetFile) { + lines.push( + `1) Open ${payload.targetFile} and locate the element by matching classes/text/fingerprint.`, + ); + lines.push( + '2) If not found, fall back to repo-wide search using the fingerprint text/classes.', + ); + } else { + lines.push( + '1) Use repo-wide search (rg) with the fingerprint text/classes to find the component.', + ); + } + lines.push(''); + + lines.push('Constraints:'); + lines.push('- Prefer the smallest safe edit.'); + lines.push('- If Tailwind is used, prefer updating className instead of adding inline styles.'); + lines.push('- If CSS Modules / styled-components are used, update the correct styling source.'); + lines.push('- Do not change unrelated behavior.'); + lines.push(''); + + lines.push( + 'Output: apply the change in the repo, then reply with a short summary of what you changed.', + ); + return lines.join('\n'); +} + +async function ensureContextMenu(): Promise { + try { + if (!(chrome as any).contextMenus?.create) return; + try { + await chrome.contextMenus.remove(CONTEXT_MENU_ID); + } catch {} + await chrome.contextMenus.create({ + id: CONTEXT_MENU_ID, + title: '切换网页编辑模式', + contexts: ['all'], + }); + } catch (error) { + console.warn('[WebEditor] Failed to ensure context menu:', error); + } +} + +async function ensureEditorInjected(tabId: number): Promise { + try { + const pong: any = await chrome.tabs.sendMessage( + tabId, + { action: 'web_editor_ping' } as any, + { frameId: 0 } as any, + ); + if (pong?.status === 'pong') return; + } catch { + // Fallthrough to executeScript + } + + try { + await chrome.scripting.executeScript({ + target: { tabId }, + files: ['inject-scripts/web-editor.js'], + world: 'ISOLATED', + } as any); + } catch (error) { + console.warn('[WebEditor] Failed to inject editor script:', error); + } +} + +async function toggleEditorInTab(tabId: number): Promise<{ active?: boolean }> { + await ensureEditorInjected(tabId); + try { + const resp: any = await chrome.tabs.sendMessage( + tabId, + { action: 'web_editor_toggle' } as any, + { frameId: 0 } as any, + ); + return { active: typeof resp?.active === 'boolean' ? resp.active : undefined }; + } catch (error) { + console.warn('[WebEditor] Failed to toggle editor in tab:', error); + return {}; + } +} + +async function getActiveTabId(): Promise { + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs?.[0]?.id; + return typeof tabId === 'number' ? tabId : null; + } catch { + return null; + } +} + +export function initWebEditorListeners(): void { + ensureContextMenu().catch(() => {}); + + if ((chrome as any).contextMenus?.onClicked?.addListener) { + chrome.contextMenus.onClicked.addListener(async (info, tab) => { + try { + if (info.menuItemId !== CONTEXT_MENU_ID) return; + const tabId = tab?.id; + if (typeof tabId !== 'number') return; + await toggleEditorInTab(tabId); + } catch {} + }); + } + + chrome.commands.onCommand.addListener(async (command) => { + try { + if (command !== COMMAND_KEY) return; + const tabId = await getActiveTabId(); + if (typeof tabId !== 'number') return; + await toggleEditorInTab(tabId); + } catch {} + }); + + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + try { + if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TOGGLE) { + getActiveTabId() + .then(async (tabId) => { + if (typeof tabId !== 'number') return sendResponse({ success: false }); + const result = await toggleEditorInTab(tabId); + sendResponse({ success: true, ...result }); + }) + .catch(() => sendResponse({ success: false })); + return true; + } + if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY) { + const payload = normalizeApplyPayload(message.payload); + (async () => { + const senderTabId = (_sender as any)?.tab?.id; + const sessionId = + typeof senderTabId === 'number' ? `web-editor-${senderTabId}` : 'web-editor'; + + const stored = await chrome.storage.local.get([ + 'nativeServerPort', + 'agent-selected-project-id', + 'agent-project-root-override', + ]); + const portRaw = stored?.nativeServerPort; + const port = Number.isFinite(Number(portRaw)) + ? Number(portRaw) + : DEFAULT_NATIVE_SERVER_PORT; + + const projectId = normalizeString(stored?.['agent-selected-project-id']).trim() || ''; + const projectRootOverride = + normalizeString(stored?.['agent-project-root-override']).trim() || ''; + + if (!projectId && !projectRootOverride) { + return sendResponse({ + success: false, + error: + 'No Agent project selected. Open Side Panel → 智能助手 and select/create a project first.', + }); + } + + const instruction = buildAgentPrompt(payload); + const url = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/act`; + + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + instruction, + projectId: projectId || undefined, + projectRoot: projectRootOverride || undefined, + }), + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + return sendResponse({ + success: false, + error: text || `HTTP ${resp.status}`, + }); + } + + const json: any = await resp.json().catch(() => ({})); + return sendResponse({ success: true, requestId: json?.requestId }); + })().catch((error) => { + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + }); + }); + return true; + } + } catch (error) { + sendResponse({ + success: false, + error: String(error instanceof Error ? error.message : error), + }); + } + return false; + }); +} diff --git a/app/chrome-extension/entrypoints/popup/App.vue b/app/chrome-extension/entrypoints/popup/App.vue index 4d2d65a..2901e79 100644 --- a/app/chrome-extension/entrypoints/popup/App.vue +++ b/app/chrome-extension/entrypoints/popup/App.vue @@ -247,6 +247,21 @@ +
+

网页编辑器

+
+
+ +
+
+
+ { + const GLOBAL_KEY = '__MCP_WEB_EDITOR__'; + if (window[GLOBAL_KEY]) return; + + const IS_MAIN = window === window.top; + const COLORS = { + hover: '#3b82f6', // blue-500 + selected: '#22c55e', // green-500 + backdrop: 'rgba(15, 23, 42, 0.15)', // slate-900 @ 15% + }; + + const clamp = (v, min, max) => Math.min(max, Math.max(min, v)); + + const normalizeTextSnippet = (value, maxLen) => { + return String(value || '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLen || 80); + }; + + const containsPoint = (rect, x, y) => { + if (!rect) return false; + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + }; + + const getElementLabel = (el) => { + if (!(el instanceof Element)) return ''; + const tag = String(el.tagName || '').toLowerCase(); + const id = el.id ? `#${el.id}` : ''; + const classes = + el.classList && el.classList.length + ? `.${Array.from(el.classList).slice(0, 3).join('.')}` + : ''; + return `${tag}${id}${classes}`; + }; + + const detectTailwind = (classes) => { + try { + const patterns = [ + /^bg-/, + /^text-/, + /^p[trblxy]?-/, + /^m[trblxy]?-/, + /^flex$/, + /^grid$/, + /^items-/, + /^justify-/, + /^gap-/, + /^rounded/, + /^shadow/, + /^border/, + ]; + for (const cls of classes || []) { + if (patterns.some((p) => p.test(cls))) return true; + } + } catch {} + return false; + }; + + const findReactFileFromFiber = (fiber) => { + try { + let current = fiber; + for (let i = 0; i < 40 && current; i++) { + const src = current._debugSource; + if (src && src.fileName && typeof src.fileName === 'string') return src.fileName; + const owner = current._debugOwner; + const ownerSrc = owner && owner._debugSource; + if (ownerSrc && ownerSrc.fileName && typeof ownerSrc.fileName === 'string') + return ownerSrc.fileName; + current = current.return; + } + } catch {} + return ''; + }; + + const findReactSourceFile = (el) => { + try { + let node = el; + for (let depth = 0; depth < 15 && node; depth++) { + const keys = Object.keys(node); + for (const k of keys) { + if (k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')) { + const fiber = node[k]; + const found = findReactFileFromFiber(fiber); + if (found) return found; + } + } + node = node.parentElement; + } + } catch {} + return ''; + }; + + const findVueSourceFile = (el) => { + try { + let node = el; + for (let depth = 0; depth < 15 && node; depth++) { + const inst = node.__vueParentComponent; + if (inst && inst.type && inst.type.__file) return String(inst.type.__file); + node = node.parentElement; + } + } catch {} + return ''; + }; + + const resolveTargetFile = (el) => { + try { + let node = el; + for (let depth = 0; depth < 20 && node; depth++) { + const reactFile = findReactSourceFile(node); + if (reactFile && !reactFile.includes('node_modules')) return reactFile; + const vueFile = findVueSourceFile(node); + if (vueFile && !vueFile.includes('node_modules')) return vueFile; + node = node.parentElement; + } + } catch {} + return ''; + }; + + const findMeaningfulElement = (el, clientX, clientY) => { + try { + let current = el instanceof Element ? el : null; + for (let i = 0; i < 8 && current; i++) { + const tag = String(current.tagName || '').toUpperCase(); + if (tag === 'HTML' || tag === 'BODY') { + const deeper = document.elementFromPoint(clientX, clientY); + if (deeper && deeper !== current && deeper instanceof Element) { + current = deeper; + continue; + } + return current; + } + + let style; + try { + style = window.getComputedStyle(current); + } catch { + return current; + } + + const bg = String(style.backgroundColor || '').toLowerCase(); + const isTransparentBg = bg === 'transparent' || bg === 'rgba(0, 0, 0, 0)'; + const borderWidth = [ + style.borderTopWidth, + style.borderRightWidth, + style.borderBottomWidth, + style.borderLeftWidth, + ] + .map((x) => String(x || '0px')) + .join(','); + const hasBorder = borderWidth !== '0px,0px,0px,0px'; + + if (!isTransparentBg || hasBorder) return current; + + const rect = current.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0) return current; + + let bestChild = null; + let bestArea = Infinity; + const children = Array.from(current.children || []); + for (const child of children) { + if (!(child instanceof Element)) continue; + const r = child.getBoundingClientRect(); + if (!r || r.width <= 0 || r.height <= 0) continue; + if (!containsPoint(r, clientX, clientY)) continue; + const area = r.width * r.height; + if (area < bestArea) { + bestArea = area; + bestChild = child; + } + } + + if (!bestChild) return current; + + const childRect = bestChild.getBoundingClientRect(); + const sameSize = + Math.abs(rect.width - childRect.width) < 2 && + Math.abs(rect.height - childRect.height) < 2; + if (!sameSize) return current; + + current = bestChild; + } + } catch {} + return el instanceof Element ? el : null; + }; + + const createToastHost = () => { + const host = document.createElement('div'); + Object.assign(host.style, { + position: 'fixed', + left: '12px', + bottom: '12px', + zIndex: 2147483647, + display: 'flex', + flexDirection: 'column', + gap: '8px', + pointerEvents: 'none', + }); + return host; + }; + + const showToast = (state, message, kind) => { + try { + if (!state.toastHost) return; + const item = document.createElement('div'); + const bg = + kind === 'error' + ? 'rgba(220, 38, 38, 0.92)' + : kind === 'success' + ? 'rgba(22, 163, 74, 0.92)' + : 'rgba(15, 23, 42, 0.92)'; + Object.assign(item.style, { + background: bg, + color: '#fff', + padding: '8px 10px', + borderRadius: '10px', + fontSize: '12px', + fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial', + boxShadow: '0 6px 18px rgba(0,0,0,0.22)', + maxWidth: '340px', + lineHeight: '1.35', + }); + item.textContent = String(message || ''); + state.toastHost.appendChild(item); + setTimeout(() => { + try { + item.remove(); + } catch {} + }, 2800); + } catch {} + }; + + const buildStyleMapFromInput = (raw) => { + const out = {}; + const text = String(raw || '').trim(); + if (!text) return out; + const parts = text + .split(';') + .map((s) => s.trim()) + .filter(Boolean); + for (const part of parts) { + const idx = part.indexOf(':'); + if (idx <= 0) continue; + const key = part.slice(0, idx).trim(); + const value = part.slice(idx + 1).trim(); + if (!key || !value) continue; + out[key] = value; + } + return out; + }; + + const applyInlineStyleMap = (el, styles) => { + try { + if (!(el instanceof Element)) return; + const entries = Object.entries(styles || {}); + for (const [key, value] of entries) { + if (!key || !value) continue; + try { + el.style.setProperty(key, value); + } catch {} + } + } catch {} + }; + + const state = { + active: false, + root: null, + canvas: null, + ctx: null, + raf: 0, + dpr: 1, + viewport: { w: 0, h: 0 }, + hoveredEl: null, + selectedEl: null, + hoverRect: null, + selectedRect: null, + toolbar: null, + toastHost: null, + inputText: '', + inputStyle: '', + lastPointer: { x: 0, y: 0 }, + }; + + const ensureCanvas = () => { + if (!state.canvas || !state.ctx) return; + const dpr = window.devicePixelRatio || 1; + const w = Math.max(1, window.innerWidth || document.documentElement.clientWidth || 1); + const h = Math.max(1, window.innerHeight || document.documentElement.clientHeight || 1); + if (state.viewport.w === w && state.viewport.h === h && Math.abs(state.dpr - dpr) < 0.01) + return; + state.dpr = dpr; + state.viewport = { w, h }; + state.canvas.width = Math.round(w * dpr); + state.canvas.height = Math.round(h * dpr); + state.canvas.style.width = `${w}px`; + state.canvas.style.height = `${h}px`; + state.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + + const drawRect = (rect, color, dashed) => { + if (!rect || !state.ctx) return; + const ctx = state.ctx; + const x = Math.round(rect.left) + 0.5; + const y = Math.round(rect.top) + 0.5; + const w = Math.max(0, Math.round(rect.width)); + const h = Math.max(0, Math.round(rect.height)); + if (w <= 0 || h <= 0) return; + ctx.save(); + ctx.lineWidth = 2; + ctx.strokeStyle = color; + ctx.fillStyle = `${color}22`; + if (dashed) ctx.setLineDash([6, 4]); + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + }; + + const draw = () => { + if (!state.active || !state.ctx) return; + ensureCanvas(); + const ctx = state.ctx; + ctx.clearRect(0, 0, state.viewport.w, state.viewport.h); + + // Keep selected rect fresh in case HMR/layout changes. + try { + if (state.selectedEl && state.selectedEl instanceof Element) { + state.selectedRect = state.selectedEl.getBoundingClientRect(); + } + } catch {} + try { + if (state.hoveredEl && state.hoveredEl instanceof Element) { + state.hoverRect = state.hoveredEl.getBoundingClientRect(); + } + } catch {} + + drawRect(state.hoverRect, COLORS.hover, true); + drawRect(state.selectedRect, COLORS.selected, false); + + // Keep toolbar anchored to the selected element. + positionToolbar(); + }; + + const tick = () => { + if (!state.active) return; + draw(); + state.raf = requestAnimationFrame(tick); + }; + + const isInToolbar = (target) => { + try { + if (!target || !(target instanceof Node)) return false; + if (!state.toolbar) return false; + return state.toolbar.contains(target); + } catch { + return false; + } + }; + + const updateHover = (el, clientX, clientY) => { + const picked = findMeaningfulElement(el, clientX, clientY); + state.hoveredEl = picked; + try { + state.hoverRect = picked ? picked.getBoundingClientRect() : null; + } catch { + state.hoverRect = null; + } + }; + + const positionToolbar = () => { + try { + if (!state.toolbar || !state.selectedRect) return; + const pad = 10; + const maxW = 420; + const rect = state.selectedRect; + const preferredLeft = clamp( + Math.round(rect.left), + pad, + Math.max(pad, window.innerWidth - maxW - pad), + ); + const preferredTop = Math.round(rect.top - 12); + const top = preferredTop < 80 ? Math.round(rect.bottom + 12) : preferredTop; + Object.assign(state.toolbar.style, { + left: `${preferredLeft}px`, + top: `${clamp(top, pad, Math.max(pad, window.innerHeight - 180))}px`, + }); + } catch {} + }; + + const updateToolbarHeader = () => { + try { + if (!state.toolbar) return; + const label = state.toolbar.querySelector('[data-role="label"]'); + if (!label) return; + label.textContent = state.selectedEl ? getElementLabel(state.selectedEl) : 'No selection'; + } catch {} + }; + + const buildApplyPayload = (instruction) => { + const el = state.selectedEl; + const tag = el && el.tagName ? String(el.tagName || '').toLowerCase() : 'unknown'; + const id = el && el.id ? String(el.id) : undefined; + const classes = el && el.classList ? Array.from(el.classList).slice(0, 24) : []; + const text = normalizeTextSnippet(el ? el.textContent : '', 96); + const fingerprint = { tag, id, classes, text }; + const targetFile = el ? resolveTargetFile(el) : ''; + const hints = []; + try { + if (el) { + const r = findReactSourceFile(el); + const v = findVueSourceFile(el); + if (r) hints.push('React'); + if (v) hints.push('Vue'); + } + if (detectTailwind(classes)) hints.push('Tailwind'); + } catch {} + return { + pageUrl: String(location && location.href ? location.href : ''), + targetFile: targetFile || undefined, + fingerprint, + techStackHint: hints.length ? hints : undefined, + instruction, + }; + }; + + const onMouseMove = (e) => { + if (!state.active) return; + if (isInToolbar(e.target)) return; + state.lastPointer = { x: e.clientX, y: e.clientY }; + const el = e.target instanceof Element ? e.target : null; + if (!el) return; + updateHover(el, e.clientX, e.clientY); + }; + + const onClick = (e) => { + if (!state.active) return; + if (isInToolbar(e.target)) return; + try { + e.preventDefault(); + e.stopPropagation(); + } catch {} + const el = state.hoveredEl; + if (!el) return; + state.selectedEl = el; + try { + state.selectedRect = el.getBoundingClientRect(); + } catch { + state.selectedRect = null; + } + updateToolbarHeader(); + positionToolbar(); + }; + + const intercept = (e) => { + if (!state.active) return; + if (isInToolbar(e.target)) return; + // Allow scroll/wheel to keep navigation usable in edit mode. + if (e.type === 'wheel') return; + try { + e.preventDefault(); + e.stopPropagation(); + } catch {} + }; + + const onKeyDown = (e) => { + if (!state.active) return; + if (isInToolbar(e.target)) return; + if (e.key === 'Escape') { + try { + e.preventDefault(); + e.stopPropagation(); + } catch {} + stop(); + return; + } + }; + + const buildToolbar = () => { + const box = document.createElement('div'); + state.toolbar = box; + Object.assign(box.style, { + position: 'fixed', + left: '12px', + top: '12px', + zIndex: 2147483647, + pointerEvents: 'auto', + width: 'min(420px, calc(100vw - 24px))', + background: 'rgba(255,255,255,0.96)', + border: '1px solid rgba(148, 163, 184, 0.6)', + borderRadius: '12px', + boxShadow: '0 10px 30px rgba(0,0,0,0.18)', + fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial', + color: '#0f172a', + overflow: 'hidden', + }); + + const header = document.createElement('div'); + Object.assign(header.style, { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '10px', + padding: '10px 12px', + background: 'rgba(248,250,252,0.9)', + borderBottom: '1px solid rgba(148, 163, 184, 0.35)', + }); + const label = document.createElement('div'); + label.setAttribute('data-role', 'label'); + Object.assign(label.style, { + fontSize: '12px', + fontWeight: '600', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + maxWidth: '280px', + }); + label.textContent = 'Select an element'; + + const btnExit = document.createElement('button'); + btnExit.textContent = 'Exit (Esc)'; + Object.assign(btnExit.style, { + fontSize: '12px', + padding: '6px 10px', + borderRadius: '10px', + border: '1px solid rgba(148,163,184,0.6)', + background: '#fff', + cursor: 'pointer', + }); + btnExit.addEventListener('click', () => stop()); + + header.appendChild(label); + header.appendChild(btnExit); + + const body = document.createElement('div'); + Object.assign(body.style, { + padding: '10px 12px 12px', + display: 'flex', + flexDirection: 'column', + gap: '10px', + }); + + const mkRow = (titleText) => { + const row = document.createElement('div'); + Object.assign(row.style, { + display: 'flex', + flexDirection: 'column', + gap: '6px', + }); + const title = document.createElement('div'); + title.textContent = titleText; + Object.assign(title.style, { fontSize: '12px', fontWeight: '600', color: '#334155' }); + row.appendChild(title); + return { row, title }; + }; + + const mkActions = () => { + const actions = document.createElement('div'); + Object.assign(actions.style, { + display: 'flex', + gap: '8px', + alignItems: 'center', + flexWrap: 'wrap', + }); + return actions; + }; + + const mkButton = (text, variant) => { + const btn = document.createElement('button'); + btn.textContent = text; + const bg = variant === 'primary' ? '#0f172a' : '#fff'; + const color = variant === 'primary' ? '#fff' : '#0f172a'; + Object.assign(btn.style, { + fontSize: '12px', + padding: '7px 10px', + borderRadius: '10px', + border: '1px solid rgba(148,163,184,0.6)', + background: bg, + color, + cursor: 'pointer', + }); + return btn; + }; + + // Text edit + const textRow = mkRow('Text'); + const textInput = document.createElement('input'); + textInput.type = 'text'; + textInput.placeholder = 'New text…'; + Object.assign(textInput.style, { + width: '100%', + padding: '8px 10px', + borderRadius: '10px', + border: '1px solid rgba(148,163,184,0.6)', + fontSize: '12px', + outline: 'none', + }); + textInput.addEventListener('input', () => { + state.inputText = textInput.value; + }); + const textActions = mkActions(); + const btnApplyText = mkButton('Apply (DOM)', 'secondary'); + btnApplyText.addEventListener('click', () => { + if (!state.selectedEl) return showToast(state, 'No selection', 'error'); + const v = String(state.inputText || '').trim(); + if (!v) return showToast(state, 'Text is empty', 'error'); + try { + state.selectedEl.textContent = v; + showToast(state, 'Text applied (DOM)', 'success'); + } catch { + showToast(state, 'Failed to apply text', 'error'); + } + }); + const btnSyncText = mkButton('Sync to Code', 'primary'); + btnSyncText.addEventListener('click', async () => { + if (!state.selectedEl) return showToast(state, 'No selection', 'error'); + const v = String(state.inputText || '').trim(); + if (!v) return showToast(state, 'Text is empty', 'error'); + const payload = buildApplyPayload({ + type: 'update_text', + description: `Set the element text to: ${JSON.stringify(v)}`, + text: v, + }); + try { + const resp = await chrome.runtime.sendMessage({ type: 'web_editor_apply', payload }); + if (resp && resp.success) { + showToast(state, `Agent accepted (requestId=${resp.requestId || 'n/a'})`, 'success'); + } else { + showToast(state, resp?.error || 'Agent request failed', 'error'); + } + } catch (err) { + showToast(state, String(err && err.message ? err.message : err), 'error'); + } + }); + textActions.appendChild(btnApplyText); + textActions.appendChild(btnSyncText); + textRow.row.appendChild(textInput); + textRow.row.appendChild(textActions); + + // Style edit + const styleRow = mkRow('Style (CSS declarations)'); + const styleInput = document.createElement('input'); + styleInput.type = 'text'; + styleInput.placeholder = 'e.g. background-color: #f3f4f6; padding: 12px'; + Object.assign(styleInput.style, { + width: '100%', + padding: '8px 10px', + borderRadius: '10px', + border: '1px solid rgba(148,163,184,0.6)', + fontSize: '12px', + outline: 'none', + }); + styleInput.addEventListener('input', () => { + state.inputStyle = styleInput.value; + }); + const styleActions = mkActions(); + const btnApplyStyle = mkButton('Apply (DOM)', 'secondary'); + btnApplyStyle.addEventListener('click', () => { + if (!state.selectedEl) return showToast(state, 'No selection', 'error'); + const map = buildStyleMapFromInput(state.inputStyle); + const keys = Object.keys(map); + if (!keys.length) return showToast(state, 'No valid declarations', 'error'); + applyInlineStyleMap(state.selectedEl, map); + showToast(state, 'Style applied (DOM)', 'success'); + }); + const btnSyncStyle = mkButton('Sync to Code', 'primary'); + btnSyncStyle.addEventListener('click', async () => { + if (!state.selectedEl) return showToast(state, 'No selection', 'error'); + const map = buildStyleMapFromInput(state.inputStyle); + const keys = Object.keys(map); + if (!keys.length) return showToast(state, 'No valid declarations', 'error'); + const decl = keys.map((k) => `${k}: ${map[k]}`).join('; '); + const payload = buildApplyPayload({ + type: 'update_style', + description: `Apply CSS declarations: ${decl}`, + style: map, + }); + try { + const resp = await chrome.runtime.sendMessage({ type: 'web_editor_apply', payload }); + if (resp && resp.success) { + showToast(state, `Agent accepted (requestId=${resp.requestId || 'n/a'})`, 'success'); + } else { + showToast(state, resp?.error || 'Agent request failed', 'error'); + } + } catch (err) { + showToast(state, String(err && err.message ? err.message : err), 'error'); + } + }); + styleActions.appendChild(btnApplyStyle); + styleActions.appendChild(btnSyncStyle); + styleRow.row.appendChild(styleInput); + styleRow.row.appendChild(styleActions); + + body.appendChild(textRow.row); + body.appendChild(styleRow.row); + box.appendChild(header); + box.appendChild(body); + return box; + }; + + const start = () => { + if (!IS_MAIN) return; + if (state.active) return; + state.active = true; + + const root = document.createElement('div'); + state.root = root; + root.id = '__mcp_web_editor_root'; + Object.assign(root.style, { + position: 'fixed', + inset: '0', + zIndex: 2147483647, + pointerEvents: 'none', + }); + + const canvas = document.createElement('canvas'); + state.canvas = canvas; + Object.assign(canvas.style, { + position: 'fixed', + inset: '0', + width: '100%', + height: '100%', + pointerEvents: 'none', + }); + root.appendChild(canvas); + + try { + const ctx = canvas.getContext('2d'); + state.ctx = ctx; + } catch { + state.ctx = null; + } + + const toolbar = buildToolbar(); + root.appendChild(toolbar); + + const toastHost = createToastHost(); + state.toastHost = toastHost; + root.appendChild(toastHost); + + document.documentElement.appendChild(root); + + document.addEventListener('mousemove', onMouseMove, { capture: true, passive: true }); + document.addEventListener('click', onClick, true); + document.addEventListener('mousedown', intercept, true); + document.addEventListener('mouseup', intercept, true); + document.addEventListener('dblclick', intercept, true); + document.addEventListener('contextmenu', intercept, true); + document.addEventListener('submit', intercept, true); + document.addEventListener('keydown', onKeyDown, true); + + // Visual cue + showToast(state, 'Web Editor: ON (Esc to exit)', 'info'); + + // Start RAF + state.raf = requestAnimationFrame(tick); + }; + + const stop = () => { + if (!IS_MAIN) return; + if (!state.active) return; + state.active = false; + + try { + if (state.raf) cancelAnimationFrame(state.raf); + } catch {} + state.raf = 0; + + try { + document.removeEventListener('mousemove', onMouseMove, true); + document.removeEventListener('click', onClick, true); + document.removeEventListener('mousedown', intercept, true); + document.removeEventListener('mouseup', intercept, true); + document.removeEventListener('dblclick', intercept, true); + document.removeEventListener('contextmenu', intercept, true); + document.removeEventListener('submit', intercept, true); + document.removeEventListener('keydown', onKeyDown, true); + } catch {} + + try { + state.root && state.root.remove(); + } catch {} + + state.root = null; + state.canvas = null; + state.ctx = null; + state.hoveredEl = null; + state.selectedEl = null; + state.hoverRect = null; + state.selectedRect = null; + state.toolbar = null; + state.toastHost = null; + state.inputText = ''; + state.inputStyle = ''; + }; + + const toggle = () => { + if (!IS_MAIN) return false; + if (state.active) { + stop(); + return false; + } + start(); + return true; + }; + + // Expose minimal API for debugging + window[GLOBAL_KEY] = { + start, + stop, + toggle, + getState: () => ({ active: state.active }), + }; + + // Message handler (background -> tab) + chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + try { + if (!IS_MAIN) return false; + if (request && request.action === 'web_editor_ping') { + sendResponse({ status: 'pong' }); + return false; + } + if (request && request.action === 'web_editor_toggle') { + const active = toggle(); + sendResponse({ active }); + return true; + } + if (request && request.action === 'web_editor_start') { + start(); + sendResponse({ active: true }); + return true; + } + if (request && request.action === 'web_editor_stop') { + stop(); + sendResponse({ active: false }); + return true; + } + } catch (e) { + try { + sendResponse({ success: false, error: String(e && e.message ? e.message : e) }); + } catch {} + return true; + } + return false; + }); +})(); diff --git a/app/chrome-extension/wxt.config.ts b/app/chrome-extension/wxt.config.ts index df03e44..a3d171b 100644 --- a/app/chrome-extension/wxt.config.ts +++ b/app/chrome-extension/wxt.config.ts @@ -88,6 +88,10 @@ export default defineConfig({ suggested_key: { default: 'Ctrl+Shift+O' }, description: 'Open workflow sidepanel', }, + toggle_web_editor: { + suggested_key: { default: 'Ctrl+Shift+E', mac: 'Command+Shift+E' }, + description: 'Toggle Web Editor mode', + }, }, web_accessible_resources: [ { diff --git a/editor-plan.md b/editor-plan.md new file mode 100644 index 0000000..8258dc5 --- /dev/null +++ b/editor-plan.md @@ -0,0 +1,179 @@ +# Web Visual Editor (Chrome Extension) — Implementation Plan + +## 0) Goal + +Build a **visual in-page editor** for local dev pages (e.g. `localhost`) that: + +- Enters/exits **Edit Mode** via **keyboard shortcut**, **right-click context menu**, or a **Popup button**. +- Lets the user **hover + click to select** an element with a high-performance overlay. +- Allows **quick visual edits** (v1: text + inline styles) with optimistic DOM updates. +- Sends a structured “intent payload” to the existing **AgentChat** backend so the AI Agent can **persist changes into source code**. + +Non-goals (v1): + +- Full Figma/Webflow-grade layout editing, multi-select, constraints, snapping, history/undo. +- Cross-origin iframe editing (same-origin can be added later). +- Perfect framework source mapping in production builds (dev-mode metadata first). + +## 1) Context Scan (Evidence-Based) + +### 1.1 Similar patterns we will reuse + +1. **Record/Replay triggers** (hotkeys + context menus) + +- `app/chrome-extension/wxt.config.ts` (manifest `commands`) +- `app/chrome-extension/entrypoints/background/record-replay/index.ts` (`chrome.commands` + `chrome.contextMenus`) + +2. **Injected overlay + selection** + +- `app/chrome-extension/inject-scripts/accessibility-tree-helper.js` (`rr_picker_start` overlay + capture listeners) + +3. **Background-driven injection + context menu** + +- `app/chrome-extension/entrypoints/background/element-marker/index.ts` (idempotent inject + overlay control) + +### 1.2 Integration points (data-path) + +- **Popup (Vue)** → `chrome.runtime.sendMessage` → **Background** +- **Background** → `chrome.scripting.executeScript` → **Injected Script** (ISOLATED world) +- **Injected Script** → `chrome.runtime.sendMessage` → **Background** +- **Background** → HTTP `POST /agent/chat/:sessionId/act` → **Native Server** → **AgentChatService** → **Engine** (Codex/Claude/…) + +### 1.3 Tech stack + conventions + +- Extension: Vue 3 + WXT + TS, injected scripts are plain JS under `app/chrome-extension/inject-scripts/`. +- Backend: Fastify (native server), AgentChat already exists. +- Formatting: Prettier + ESLint. +- Tests: Jest exists in `app/native-server` (extension has no automated tests today). + +## 2) Key Questions (Prioritized) + +High: + +- What is the **minimum set of editing operations** to ship v1 end-to-end (visual edit → AI persists)? +- What is the **source localization contract** we can reliably provide (React/Vue dev metadata vs fallback fingerprint)? +- How do we ensure **Edit Mode does not trigger page actions** (navigation/form submit) while keeping scrolling usable? + +Medium: + +- Should we open the Side Panel (AgentChat) automatically after “Sync to Code”? +- How should we store per-tab editor state (ephemeral per page load vs persisted)? + +Low: + +- Cross-frame (same-origin iframe) support. +- Advanced overlay rendering (guides, spacing, multi-rect). + +## 3) Target Contract (v1) + +### 3.1 Background messages + +- `BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TOGGLE` + - Called from Popup / commands / context menu + - Effect: inject editor script if needed, then toggle edit mode on the active tab + +- `BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY` + - Called from injected script when the user clicks “Sync to Code” + - Effect: build an AgentChat prompt and call `/agent/chat/:sessionId/act` + +### 3.2 Injected script messages (tab → injected) + +- `action: "web_editor_ping"` → `{ status: "pong" }` +- `action: "web_editor_toggle"` → `{ active: boolean }` +- (optional) `action: "web_editor_start" | "web_editor_stop"` + +### 3.3 Apply payload schema (injected → background) + +```ts +type WebEditorInstructionType = 'update_text' | 'update_style'; + +interface WebEditorFingerprint { + tag: string; + id?: string; + classes: string[]; + text?: string; // short snippet +} + +interface WebEditorApplyPayload { + pageUrl: string; + targetFile?: string; // best-effort (React/Vue dev metadata) + fingerprint: WebEditorFingerprint; + techStackHint?: string[]; + instruction: { + type: WebEditorInstructionType; + description: string; + text?: string; + style?: Record; + }; +} +``` + +### 3.4 Prompt template (background → AgentChat) + +The prompt MUST be deterministic, explicit, and tool-friendly: + +- If `targetFile` is available: instruct to edit that file (and related CSS modules if needed). +- Else: instruct to `rg` search using `fingerprint.text` and/or stable classes. +- Prefer Tailwind class edits if Tailwind is detected; otherwise update CSS module / inline styles. +- Apply the requested change and keep other behavior unchanged. + +## 4) Milestones & Tasks (Checklist) + +### M1 — Edit Mode activation (toggle) + +- [x] Add manifest command: `toggle_web_editor` (suggested key: `Ctrl+Shift+E`) +- [x] Add background module `web-editor` with: + - [x] context menu item “Toggle Web Editor” + - [x] `chrome.commands` listener for `toggle_web_editor` + - [x] runtime message handler for Popup toggle + - [x] idempotent injection + `web_editor_toggle` tab message +- [x] Add Popup section with a “Toggle Web Editor” button + +### M2 — In-page overlay + selection (visual layer) + +- [x] Add `inject-scripts/web-editor.js`: + - [x] High-perf overlay using a fixed `` (DPR-aware) + RAF loop + - [x] Hover highlight + click-to-select + - [x] Smart selection heuristics (skip transparent wrappers when appropriate) + - [x] Capture-phase event interception (prevent page actions while editing) + - [x] Exit via `Esc` + +### M3 — Basic visual editing (v1) + +- [x] Floating toolbar for selected element +- [x] Text edit (optimistic `textContent` update) +- [x] Inline style edit (CSS declarations → `style.setProperty`) + +### M4 — Source localization (best-effort) + +- [x] React dev metadata detection (Fiber `_debugSource.fileName`) +- [x] Vue 3 dev metadata detection (`instance.type.__file`) +- [x] `node_modules` escape: ignore and climb DOM parents +- [x] Fallback fingerprint capture (tag/id/classes/text snippet) + +### M5 — Agent bridge (persist to code) + +- [x] Read Agent project selection from `chrome.storage.local`: + - `agent-selected-project-id` + - `agent-project-root-override` +- [x] Background calls `POST /agent/chat/:sessionId/act` with `{ instruction, projectId, projectRoot }` +- [x] Injected UI shows success/error toast with returned `requestId` + +### M6 — Verification + +- [ ] `pnpm -r lint` (or scoped package lint) (blocked by pre-existing errors) +- [ ] `pnpm --filter mcp-chrome-bridge test` (native server regression) (currently failing in repo) +- [ ] Manual smoke: + - Toggle Edit Mode via popup / context menu / hotkey + - Select element, edit text/style, “Sync to Code” + - Confirm AgentChat receives request (sidepanel) and code changes trigger HMR + +## 5) Risks & Mitigations (v1) + +- **React/Vue metadata missing in prod** → fallback fingerprint + `rg` search prompt. +- **Overlay intercepts too much** → allow scroll/wheel; allow toolbar interactions. +- **CORS / server unavailable** → do HTTP from background + show actionable error. + +## 6) Progress Log + +2025-12-17: Implemented M1–M5 end-to-end. Repo-wide lint/typecheck/tests are currently failing due to unrelated pre-existing issues; manual smoke test is the recommended verification step for this feature. diff --git a/editor.md b/editor.md new file mode 100644 index 0000000..1f32764 --- /dev/null +++ b/editor.md @@ -0,0 +1,227 @@ +# 技术白皮书:AI 驱动的 Web 前端可视化工作台 + +**Project Codename**: Localhost AR Workbench +**版本**: V3.0 (Final Architecture) +**核心理念**: + +1. **AR 架构**: 视觉层与逻辑层解耦,确保极致性能。 +2. **文件粒度定位**: 利用 LLM 的语义理解能力,降低定位的工程复杂度。 +3. **Lazy AI**: 本地算法优先,云端 AI 兜底。 + +--- + +## 1. 原始需求与设计意图 (Intent & Context) + +### 1.1 原始问题 (The Problem) + +- **割裂的开发体验**: 开发者在 Chrome DevTools 调样式,然后去 IDE 找文件、改代码,来回切换,上下文丢失。 +- **Cursor 的局限**: Cursor 虽然强大,但它是基于文本(Code)的,无法直观地调整视觉属性(如 Margin, Flex 布局,阴影),也无法在运行时看到真实数据渲染的效果。 +- **现有插件的痛点**: 类似 VisBug 的工具只能临时修改 DOM,刷新即逝,无法持久化到源码;且在 React/Vue 复杂页面中性能极差,交互手感像“玩具”。 + +### 1.2 产品目标 (The Solution) + +构建一个**浏览器插件**,作为覆盖层(Overlay)运行在本地开发环境(Localhost)上。 + +- **视觉编辑**: 用户可以直接拖拽组件、修改样式、编辑文案。 +- **代码落地**: 所有操作通过 AI Agent 自动逆向修改本地源代码,并触发 HMR(热更新)。 +- **体验对标**: 达到 Figma/Webflow 级别的流畅度(60FPS)和智能度。 + +--- + +## 2. 核心架构设计 (System Architecture) + +系统采用 **“三层分离”** 架构,解决性能与逻辑的冲突。 + +### 2.1 Layer 1: 宿主层 (The Host) + +- **内容**: 用户原本运行的网页 (React/Vue/HTML)。 +- **状态**: 被“冻结”。在编辑模式下,宿主页面的原生事件被拦截,DOM 仅作为渲染结果的提供者。 + +### 2.2 Layer 2: 视觉层 (The Visual Overlay) + +- **技术栈**: **Canvas (Pixi.js / Konva)**。 +- **决策原因**: + - **性能**: 在万级节点的页面上(如淘宝),用 DOM 做高亮框会触发 Layout Thrashing (布局抖动),导致卡顿。Canvas 绘制开销与 DOM 数量无关,稳定 60FPS。 + - **隔离**: 视觉元素(标尺、蓝框、残影)不会污染宿主页面的 CSS 继承关系。 + - **能力**: 方便绘制非矩形元素、距离标注红线等设计软件特有的 UI。 +- **职责**: 只负责“画画”,不处理逻辑。 + +### 2.3 Layer 3: 逻辑与控制层 (The Brain) + +- **技术栈**: Native JavaScript (Injected Script)。 +- **职责**: + - **定位**: 算出鼠标点到了哪个文件。 + - **交互**: 计算拖拽插入点、去噪算法。 + - **通信**: 将意图打包发给本地 Server。 + +--- + +## 3. 关键技术实现细节 (Deep Dive) + +### 3.1 定位策略:文件粒度 (File-Level Localization) + +**决策变更**: 从“精确行号”降级为“文件粒度”。 +**决策原因**: + +1. 获取精确行号(SourceMap)成本高、不稳定(Vue SFC 编译后行号偏移)。 +2. LLM 具备极强的语义搜索能力,只要给它文件内容和 DOM 上下文,它能自己找到代码位置。 + +**实现算法**: +插件遍历选中元素及其父级,寻找框架元数据: + +- **React 场景**: + - 检查 DOM 属性 `__reactFiber$xxxx`。 + - 遍历 Fiber 树:`fiber._debugSource.fileName`。 +- **Vue 3 场景**: + - 检查 DOM 属性 `__vueParentComponent`。 + - 获取 `instance.type.__file`。 +- **Node Modules 处理**: + - **逻辑**: 如果路径包含 `node_modules`,视为无效路径,继续向上遍历父节点。 + - **目的**: 用户点击 Antd Button,我们希望定位到调用 Button 的 `App.tsx`,而不是 Antd 的源码包。 +- **兜底策略 (纯 HTML/老项目)**: + - 如果找不到路径,提取 **DOM 指纹**(TagName + ID + Class + 关键文本)。 + - Payload: `{ type: "fingerprint_search", snippet: "..." }`。让后端用 `ripgrep` 全局搜。 + +### 3.2 智能选择与去噪 (Smart Selection Heuristics) + +**问题**: 网页中充斥着用于布局的 Wrapper Div(无背景、无边框),用户很难选中真正的“卡片”。 +**技术实现**: + +1. **穿透规则 (Passthrough Rule)**: + - 当 `mousemove` 命中元素 `el` 时,检查: + ```javascript + const style = getComputedStyle(el); + const isTransparent = + style.backgroundColor === 'rgba(0, 0, 0, 0)' && style.borderWidth === '0px'; + const isSameSize = Math.abs(elRect.width - childRect.width) < 2; + ``` + - 如果满足条件,**忽略当前元素**,递归检查其子元素,直到找到“实体”。 +2. **组件识别**: + - 利用 DOM 结构哈希,识别页面中重复的结构。 + - 交互:选中一个 Item,Canvas 用虚线框出其他所有同类 Item。 + +### 3.3 事件接管与隔离 (Event Interception) + +**问题**: 编辑模式下,点击“提交”按钮不应触发 Form 提交。 +**技术实现**: + +1. **捕获阶段拦截 (Capture Phase)**: + ```javascript + window.addEventListener( + 'click', + (e) => { + if (!isEditingMode) return; + e.stopPropagation(); // 阻止冒泡 + e.preventDefault(); // 阻止默认行为 + // 执行选中逻辑 + }, + true, + ); // true = 开启捕获 + ``` +2. **Shadow DOM 穿透**: + - 原生 `elementFromPoint` 遇到 Shadow Root 会停止。 + - **解决方案**: 使用 `event.composedPath()` 获取完整的冒泡路径,取路径中第一个非 ShadowRoot 的节点。 + +### 3.4 React/Vue 重渲染与一致性 (Optimistic UI) + +**问题**: 用户拖拽修改了 DOM,React 状态更新触发重渲染,DOM 被重置,导致“闪烁”。 +**技术实现**: + +1. **样式修改 (Style)**: + - 直接修改 DOM `style` 属性。React 重渲染虽会覆盖,但 HMR 通常紧随其后,闪烁可接受。 +2. **结构修改 (Layout/Drag) - 幽灵模式**: + - **严禁**直接操作真实 DOM (如 `insertBefore`),这会导致 React 崩溃。 + - **策略**: + - 拖拽时:Canvas 绘制“幽灵节点”跟随。 + - 松手时:**保持 Canvas 上的幽灵节点显示**,真实 DOM 不动。 + - 生效时:AI 修改源码 -> HMR 触发 -> 真实 DOM 变动 -> 移除 Canvas 幽灵。 + +### 3.5 坐标系统 (The Coordinate System) + +**问题**: 页面 Zoom、High DPI 屏幕、Transform 导致 Canvas 对不齐。 +**技术实现**: + +- 统一坐标公式: + ```javascript + CanvasX = (ClientX + Window.scrollX) * window.devicePixelRatio; + CanvasY = (ClientY + Window.scrollY) * window.devicePixelRatio; + ``` +- **Iframe 处理**: + - 同源:脚本注入 Iframe 内部。 + - 跨域:UI 提示不可编辑。 + +--- + +## 4. 后端 Agent 执行逻辑 (The Executor) + +**核心变更**: 不再依赖复杂的 AST 分析脚本,完全依赖 LLM。 + +### 4.1 数据协议 (The Payload) + +前端发送给本地 Server 的 JSON 包: + +```json +{ + "projectRoot": "/Users/dev/my-app", + "targetFile": "src/components/UserProfile.tsx", // 优先使用文件路径 + "fallbackFingerprint": { + // 如果没有文件路径,用这个搜 + "tag": "div", + "classes": ["card", "p-4"], + "text": "User Settings" + }, + "instruction": { + "type": "update_style", + "description": "Change background color to soft gray (#f3f4f6)" + }, + "techStackHint": ["Tailwind", "React"] // 前端探测到的技术栈 +} +``` + +### 4.2 Agent 工作流 (The AI Workflow) + +1. **定位**: + - 有 `targetFile` -> 直接读取文件内容。 + - 无 `targetFile` -> 使用 `ripgrep` 搜索 `fallbackFingerprint.text` 或 `classes` 找到文件。 +2. **Prompt 构建**: + > "你是一个前端专家。我将给你一个 React 组件文件。 + > **任务**: 找到包含文本 'User Settings' 且类名为 'card' 的 `div`。 + > **修改**: 将其背景色改为软灰色。 + > **约束**: 检测文件是否使用 Tailwind。如果是,添加 `bg-gray-100`;如果是 CSS Modules,修改对应 .css 文件;如果是内联样式,修改 style 属性。 + > **输出**: 仅输出修改后的文件代码。" +3. **写入**: 覆盖原文件 -> 触发 Webpack/Vite HMR。 + +--- + +## 5. 开发路线图 (Implementation Roadmap) + +### Phase 1: 视觉与交互原型 (The "Lens") + +- **目标**: 实现不卡顿的高亮与选中。 +- **任务**: + 1. 搭建 Shadow DOM + Canvas 基础架构。 + 2. 实现坐标转换与 `requestAnimationFrame` 渲染循环。 + 3. 实现“去噪算法”,确保鼠标能精准吸附到卡片而非容器。 + 4. 实现事件拦截,确保点击页面不跳转。 + +### Phase 2: 定位与通信 (The "Bridge") + +- **目标**: 打通 浏览器 -> 本地文件系统 的链路。 +- **任务**: + 1. 编写 `getFileSource` 算法 (React Fiber / Vue Instance 探测)。 + 2. 搭建本地 Node.js Server + WebSocket。 + 3. 实现“Node Modules 跳出”逻辑。 + +### Phase 3: AI 闭环 (The "Brain") + +- **目标**: 实现样式与内容的真实修改。 +- **任务**: + 1. 接入 LLM API (OpenAI/Claude)。 + 2. 设计并调优 Prompt,专门处理 Tailwind、CSS-in-JS 等不同场景。 + 3. 实现 HMR 后的 Canvas 状态同步 (避免 HMR 时高亮框错位)。 + +--- + +## 6. 总结 (Conclusion) + +本项目通过 **“文件粒度定位”** 大幅降低了工程落地的复杂度,通过 **“Canvas 渲染层”** 解决了前端插件常见的性能瓶颈,通过 **“Lazy AI”** 策略平衡了成本与智能。这套架构在理论上完全可行,且能够提供远超现有开源工具的商业级体验。