hangwin--mcp-chrome
849 行
25 KiB
JavaScript
849 行
25 KiB
JavaScript
/* 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;
|
|
});
|
|
})();
|