mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-09-24 23:24:04 +08:00
feat: web editor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
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<string, string> | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number | null> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -247,6 +247,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">网页编辑器</h2>
|
||||
<div class="rr-grid">
|
||||
<div class="rr-controls" style="width: 100%">
|
||||
<button
|
||||
class="semantic-engine-button"
|
||||
@click="toggleWebEditor"
|
||||
title="在当前页面切换编辑模式(可用快捷键 Ctrl/Command + Shift + E)"
|
||||
>
|
||||
切换编辑模式
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Cache Management Section -->
|
||||
<ModelCacheManagement
|
||||
:cache-stats="cacheStats"
|
||||
@@ -584,6 +599,14 @@ async function openElementMarkerSidepanel() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleWebEditor() {
|
||||
try {
|
||||
await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TOGGLE });
|
||||
} catch (error) {
|
||||
console.warn('切换网页编辑模式失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function openBuilderWindow(flowId?: string, focusNodeId?: string) {
|
||||
const url = new URL(chrome.runtime.getURL('builder.html'));
|
||||
if (flowId) url.searchParams.set('flowId', flowId);
|
||||
|
||||
@@ -0,0 +1,848 @@
|
||||
/* eslint-disable */
|
||||
|
||||
(() => {
|
||||
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;
|
||||
});
|
||||
})();
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
+179
@@ -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<string, string>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 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 `<canvas>` (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.
|
||||
@@ -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”** 策略平衡了成本与智能。这套架构在理论上完全可行,且能够提供远超现有开源工具的商业级体验。
|
||||
Reference in New Issue
Block a user