mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
f395f58406
Extension changes for the visual CSS inspector: - inspector.js: element picker with hover highlight, CSS selector generation, basic mode fallback (getComputedStyle + CSSOM), page alteration handlers - inspector.css: picker overlay styles (blue highlight + tooltip) - background.js: inspector message routing (picker <-> server <-> sidepanel) - sidepanel: Inspector tab with box model viz (gstack palette), matched rules with specificity badges, computed styles, click-to-edit quick edit, Send to Agent/Code button, empty/loading/error states Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
460 lines
15 KiB
JavaScript
460 lines
15 KiB
JavaScript
/**
|
|
* gstack browse — CSS Inspector content script
|
|
*
|
|
* Dynamically injected via chrome.scripting.executeScript.
|
|
* Provides element picker, selector generation, basic computed style capture,
|
|
* and page alteration handlers for agent-pushed CSS changes.
|
|
*/
|
|
|
|
(() => {
|
|
// Guard against double-injection
|
|
if (window.__gstackInspectorActive) return;
|
|
window.__gstackInspectorActive = true;
|
|
|
|
// ─── State ──────────────────────────────────────────────────────
|
|
let pickerActive = false;
|
|
let highlightEl = null;
|
|
let tooltipEl = null;
|
|
let lastPickTime = 0;
|
|
const PICK_DEBOUNCE_MS = 200;
|
|
|
|
// Track original inline styles for resetAll
|
|
const originalStyles = new Map(); // element -> Map<property, value>
|
|
const injectedStyleIds = new Set();
|
|
|
|
// ─── Highlight Overlay ──────────────────────────────────────────
|
|
|
|
function createHighlight() {
|
|
if (highlightEl) return;
|
|
|
|
highlightEl = document.createElement('div');
|
|
highlightEl.id = 'gstack-inspector-highlight';
|
|
highlightEl.style.cssText = `
|
|
position: fixed;
|
|
pointer-events: none;
|
|
z-index: 2147483647;
|
|
background: rgba(59, 130, 246, 0.15);
|
|
border: 2px solid rgba(59, 130, 246, 0.6);
|
|
border-radius: 2px;
|
|
transition: top 50ms, left 50ms, width 50ms, height 50ms;
|
|
`;
|
|
document.documentElement.appendChild(highlightEl);
|
|
|
|
tooltipEl = document.createElement('div');
|
|
tooltipEl.id = 'gstack-inspector-tooltip';
|
|
tooltipEl.style.cssText = `
|
|
position: fixed;
|
|
pointer-events: none;
|
|
z-index: 2147483647;
|
|
background: #27272A;
|
|
color: #e0e0e0;
|
|
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
|
font-size: 11px;
|
|
padding: 3px 8px;
|
|
border-radius: 4px;
|
|
white-space: nowrap;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
|
display: none;
|
|
`;
|
|
document.documentElement.appendChild(tooltipEl);
|
|
}
|
|
|
|
function removeHighlight() {
|
|
if (highlightEl) { highlightEl.remove(); highlightEl = null; }
|
|
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
|
|
}
|
|
|
|
function updateHighlight(el) {
|
|
if (!highlightEl || !tooltipEl) return;
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
highlightEl.style.top = rect.top + 'px';
|
|
highlightEl.style.left = rect.left + 'px';
|
|
highlightEl.style.width = rect.width + 'px';
|
|
highlightEl.style.height = rect.height + 'px';
|
|
highlightEl.style.display = 'block';
|
|
|
|
// Build tooltip text: <tag> .classes WxH
|
|
const tag = el.tagName.toLowerCase();
|
|
const classes = el.className && typeof el.className === 'string'
|
|
? '.' + el.className.trim().split(/\s+/).join('.')
|
|
: '';
|
|
const dims = `${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
|
tooltipEl.textContent = `<${tag}> ${classes} ${dims}`.trim();
|
|
|
|
// Position tooltip above element, or below if no room
|
|
const tooltipHeight = 24;
|
|
const gap = 6;
|
|
let tooltipTop = rect.top - tooltipHeight - gap;
|
|
if (tooltipTop < 4) tooltipTop = rect.bottom + gap;
|
|
let tooltipLeft = rect.left;
|
|
if (tooltipLeft < 4) tooltipLeft = 4;
|
|
|
|
tooltipEl.style.top = tooltipTop + 'px';
|
|
tooltipEl.style.left = tooltipLeft + 'px';
|
|
tooltipEl.style.display = 'block';
|
|
}
|
|
|
|
// ─── Selector Generation ────────────────────────────────────────
|
|
|
|
function buildSelector(el) {
|
|
// If element has an id, use it directly
|
|
if (el.id) {
|
|
const sel = '#' + CSS.escape(el.id);
|
|
if (isUnique(sel)) return sel;
|
|
}
|
|
|
|
// Build path from element up to nearest ancestor with id or body
|
|
const parts = [];
|
|
let current = el;
|
|
|
|
while (current && current !== document.body && current !== document.documentElement) {
|
|
let part = current.tagName.toLowerCase();
|
|
|
|
// If current has an id, use it and stop
|
|
if (current.id) {
|
|
part = '#' + CSS.escape(current.id);
|
|
parts.unshift(part);
|
|
break;
|
|
}
|
|
|
|
// Add classes
|
|
if (current.className && typeof current.className === 'string') {
|
|
const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0);
|
|
if (classes.length > 0) {
|
|
part += '.' + classes.map(c => CSS.escape(c)).join('.');
|
|
}
|
|
}
|
|
|
|
// Add nth-child if needed to disambiguate
|
|
const parent = current.parentElement;
|
|
if (parent) {
|
|
const siblings = Array.from(parent.children).filter(
|
|
s => s.tagName === current.tagName
|
|
);
|
|
if (siblings.length > 1) {
|
|
const idx = siblings.indexOf(current) + 1;
|
|
part += `:nth-child(${Array.from(parent.children).indexOf(current) + 1})`;
|
|
}
|
|
}
|
|
|
|
parts.unshift(part);
|
|
current = current.parentElement;
|
|
}
|
|
|
|
// If we didn't reach an id, prepend body
|
|
if (parts.length > 0 && !parts[0].startsWith('#')) {
|
|
// Don't prepend body, just use the path as-is
|
|
}
|
|
|
|
const selector = parts.join(' > ');
|
|
|
|
// Verify uniqueness
|
|
if (isUnique(selector)) return selector;
|
|
|
|
// Fallback: add nth-child at each level until unique
|
|
return selector;
|
|
}
|
|
|
|
function isUnique(selector) {
|
|
try {
|
|
return document.querySelectorAll(selector).length === 1;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ─── Basic Mode Data Capture ────────────────────────────────────
|
|
|
|
const KEY_PROPERTIES = [
|
|
'display', 'position', 'top', 'right', 'bottom', 'left',
|
|
'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height',
|
|
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
|
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
|
|
'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style',
|
|
'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
|
|
'color', 'background-color', 'background-image',
|
|
'font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing',
|
|
'text-align', 'text-decoration', 'text-transform',
|
|
'overflow', 'overflow-x', 'overflow-y',
|
|
'opacity', 'z-index',
|
|
'flex-direction', 'justify-content', 'align-items', 'flex-wrap', 'gap',
|
|
'grid-template-columns', 'grid-template-rows',
|
|
'box-shadow', 'border-radius',
|
|
'transition', 'transform',
|
|
];
|
|
|
|
function captureBasicData(el) {
|
|
const computed = getComputedStyle(el);
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
// Capture key computed properties
|
|
const computedStyles = {};
|
|
for (const prop of KEY_PROPERTIES) {
|
|
computedStyles[prop] = computed.getPropertyValue(prop);
|
|
}
|
|
|
|
// Box model from computed
|
|
const boxModel = {
|
|
content: { width: rect.width, height: rect.height },
|
|
padding: {
|
|
top: parseFloat(computed.paddingTop) || 0,
|
|
right: parseFloat(computed.paddingRight) || 0,
|
|
bottom: parseFloat(computed.paddingBottom) || 0,
|
|
left: parseFloat(computed.paddingLeft) || 0,
|
|
},
|
|
border: {
|
|
top: parseFloat(computed.borderTopWidth) || 0,
|
|
right: parseFloat(computed.borderRightWidth) || 0,
|
|
bottom: parseFloat(computed.borderBottomWidth) || 0,
|
|
left: parseFloat(computed.borderLeftWidth) || 0,
|
|
},
|
|
margin: {
|
|
top: parseFloat(computed.marginTop) || 0,
|
|
right: parseFloat(computed.marginRight) || 0,
|
|
bottom: parseFloat(computed.marginBottom) || 0,
|
|
left: parseFloat(computed.marginLeft) || 0,
|
|
},
|
|
};
|
|
|
|
// Matched CSS rules via CSSOM (same-origin only)
|
|
const matchedRules = [];
|
|
try {
|
|
for (const sheet of document.styleSheets) {
|
|
try {
|
|
const rules = sheet.cssRules || sheet.rules;
|
|
if (!rules) continue;
|
|
for (const rule of rules) {
|
|
if (rule.type !== CSSRule.STYLE_RULE) continue;
|
|
try {
|
|
if (el.matches(rule.selectorText)) {
|
|
const properties = [];
|
|
for (let i = 0; i < rule.style.length; i++) {
|
|
const prop = rule.style[i];
|
|
properties.push({
|
|
name: prop,
|
|
value: rule.style.getPropertyValue(prop),
|
|
priority: rule.style.getPropertyPriority(prop),
|
|
});
|
|
}
|
|
matchedRules.push({
|
|
selector: rule.selectorText,
|
|
properties,
|
|
source: sheet.href || 'inline',
|
|
});
|
|
}
|
|
} catch { /* skip rules that can't be matched */ }
|
|
}
|
|
} catch { /* cross-origin sheet — silently skip */ }
|
|
}
|
|
} catch { /* CSSOM not available */ }
|
|
|
|
return { computedStyles, boxModel, matchedRules };
|
|
}
|
|
|
|
// ─── Picker Event Handlers ──────────────────────────────────────
|
|
|
|
function onMouseMove(e) {
|
|
if (!pickerActive) return;
|
|
// Ignore our own overlay elements
|
|
const target = e.target;
|
|
if (target === highlightEl || target === tooltipEl) return;
|
|
if (target.id === 'gstack-inspector-highlight' || target.id === 'gstack-inspector-tooltip') return;
|
|
|
|
updateHighlight(target);
|
|
}
|
|
|
|
function onClick(e) {
|
|
if (!pickerActive) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
|
|
// Debounce
|
|
const now = Date.now();
|
|
if (now - lastPickTime < PICK_DEBOUNCE_MS) return;
|
|
lastPickTime = now;
|
|
|
|
const target = e.target;
|
|
if (target === highlightEl || target === tooltipEl) return;
|
|
if (target.id === 'gstack-inspector-highlight' || target.id === 'gstack-inspector-tooltip') return;
|
|
|
|
const selector = buildSelector(target);
|
|
const basicData = captureBasicData(target);
|
|
|
|
// Frame detection
|
|
const frameInfo = {};
|
|
if (window !== window.top) {
|
|
try {
|
|
frameInfo.frameSrc = window.location.href;
|
|
frameInfo.frameName = window.name || null;
|
|
} catch { /* cross-origin frame */ }
|
|
}
|
|
|
|
chrome.runtime.sendMessage({
|
|
type: 'elementPicked',
|
|
selector,
|
|
tagName: target.tagName.toLowerCase(),
|
|
classes: target.className && typeof target.className === 'string'
|
|
? target.className.trim().split(/\s+/).filter(c => c.length > 0)
|
|
: [],
|
|
id: target.id || null,
|
|
dimensions: {
|
|
width: Math.round(target.getBoundingClientRect().width),
|
|
height: Math.round(target.getBoundingClientRect().height),
|
|
},
|
|
basicData,
|
|
...frameInfo,
|
|
});
|
|
|
|
// Keep highlight on the picked element
|
|
}
|
|
|
|
function onKeyDown(e) {
|
|
if (!pickerActive) return;
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
stopPicker();
|
|
chrome.runtime.sendMessage({ type: 'pickerCancelled' });
|
|
}
|
|
}
|
|
|
|
// ─── Picker Start/Stop ──────────────────────────────────────────
|
|
|
|
function startPicker() {
|
|
if (pickerActive) return;
|
|
pickerActive = true;
|
|
createHighlight();
|
|
document.addEventListener('mousemove', onMouseMove, true);
|
|
document.addEventListener('click', onClick, true);
|
|
document.addEventListener('keydown', onKeyDown, true);
|
|
}
|
|
|
|
function stopPicker() {
|
|
if (!pickerActive) return;
|
|
pickerActive = false;
|
|
removeHighlight();
|
|
document.removeEventListener('mousemove', onMouseMove, true);
|
|
document.removeEventListener('click', onClick, true);
|
|
document.removeEventListener('keydown', onKeyDown, true);
|
|
}
|
|
|
|
// ─── Page Alteration Handlers ───────────────────────────────────
|
|
|
|
function findElement(selector) {
|
|
try {
|
|
return document.querySelector(selector);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function applyStyle(selector, property, value) {
|
|
// Validate property name: alphanumeric + hyphens only
|
|
if (!/^[a-zA-Z-]+$/.test(property)) return { error: 'Invalid property name' };
|
|
|
|
const el = findElement(selector);
|
|
if (!el) return { error: 'Element not found' };
|
|
|
|
// Track original value for resetAll
|
|
if (!originalStyles.has(el)) {
|
|
originalStyles.set(el, new Map());
|
|
}
|
|
const origMap = originalStyles.get(el);
|
|
if (!origMap.has(property)) {
|
|
origMap.set(property, el.style.getPropertyValue(property));
|
|
}
|
|
|
|
el.style.setProperty(property, value, 'important');
|
|
return { ok: true };
|
|
}
|
|
|
|
function toggleClass(selector, className, action) {
|
|
const el = findElement(selector);
|
|
if (!el) return { error: 'Element not found' };
|
|
|
|
if (action === 'add') {
|
|
el.classList.add(className);
|
|
} else if (action === 'remove') {
|
|
el.classList.remove(className);
|
|
} else {
|
|
el.classList.toggle(className);
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
function injectCSS(id, css) {
|
|
const styleId = `gstack-inject-${id}`;
|
|
let styleEl = document.getElementById(styleId);
|
|
if (!styleEl) {
|
|
styleEl = document.createElement('style');
|
|
styleEl.id = styleId;
|
|
document.head.appendChild(styleEl);
|
|
}
|
|
styleEl.textContent = css;
|
|
injectedStyleIds.add(styleId);
|
|
return { ok: true };
|
|
}
|
|
|
|
function resetAll() {
|
|
// Restore original inline styles
|
|
for (const [el, propMap] of originalStyles) {
|
|
for (const [prop, origVal] of propMap) {
|
|
if (origVal) {
|
|
el.style.setProperty(prop, origVal);
|
|
} else {
|
|
el.style.removeProperty(prop);
|
|
}
|
|
}
|
|
}
|
|
originalStyles.clear();
|
|
|
|
// Remove injected style elements
|
|
for (const id of injectedStyleIds) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.remove();
|
|
}
|
|
injectedStyleIds.clear();
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
// ─── Message Listener ──────────────────────────────────────────
|
|
|
|
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
if (msg.type === 'startPicker') {
|
|
startPicker();
|
|
sendResponse({ ok: true });
|
|
return;
|
|
}
|
|
if (msg.type === 'stopPicker') {
|
|
stopPicker();
|
|
sendResponse({ ok: true });
|
|
return;
|
|
}
|
|
if (msg.type === 'applyStyle') {
|
|
const result = applyStyle(msg.selector, msg.property, msg.value);
|
|
sendResponse(result);
|
|
return;
|
|
}
|
|
if (msg.type === 'toggleClass') {
|
|
const result = toggleClass(msg.selector, msg.className, msg.action);
|
|
sendResponse(result);
|
|
return;
|
|
}
|
|
if (msg.type === 'injectCSS') {
|
|
const result = injectCSS(msg.id, msg.css);
|
|
sendResponse(result);
|
|
return;
|
|
}
|
|
if (msg.type === 'resetAll') {
|
|
const result = resetAll();
|
|
sendResponse(result);
|
|
return;
|
|
}
|
|
});
|
|
})();
|