feat: add element-inspector

- Implemented Rulers component for zoom/pan-aware rulers on canvas.
- Created Stage component to serve as a zoomable and pannable viewport for the device frame.
- Developed Toolbar component for responsive controls, including device presets and zoom functionalities.
- Introduced useFrame composable to manage iframe interactions and inspections.
- Established a reactive store to manage application state, including guides and viewport dimensions.
- Added utility functions for color parsing and box model calculations.
- Integrated Tailwind CSS for styling and improved scrollbar aesthetics.
- Implemented unit tests for color utilities and rectangle calculations.
- Configured TypeScript and Vite for the project setup.
This commit is contained in:
2026-06-05 02:45:54 +07:00
parent ee14101fc1
commit 32ed0b45f0
30 changed files with 4658 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
*.log
.DS_Store
+68
View File
@@ -0,0 +1,68 @@
# Element Inspector
A Chrome (MV3) browser extension that lets you **pick any element on a live page and study it
on a clean canvas** — its real dimensions, colors (resolved to CSS variables), spacing, radius,
typography, plus rulers and live responsive resizing.
It's like the DevTools "inspect" cursor, but instead of staying buried in the page it lifts the
selected block out onto an isolated stage where you can measure and stress-test it.
## Features
1. **Activate** — click the toolbar icon or press `Alt+Shift+E`.
2. **Pick** — a DevTools-style cursor highlights elements on hover; click to select one.
3. **Isolate** — the page is hidden and the selected block is rendered, centered, on a canvas.
4. **Inspect** — hover any part to see its box model (margin/border/padding/content), dimensions,
colors (shown as `var(--name)` when they match a CSS custom property, with the hex), border
radius, spacing and typography.
5. **Measure** — zoom/pan the canvas, toggle rulers, and click a ruler to drop a guide.
6. **Responsive** — resize the frame with the drag handles, the W×H inputs, or the device presets.
Because the block is rendered in a real iframe carrying the page's stylesheets, resizing
**re-fires the site's actual media queries**. "Fit" resets it.
Press `Esc` (or "Close") to dismiss and return to the page — nothing on the page is modified.
## How it works
- The **background worker** relays the toolbar click / shortcut to the active tab.
- The **content script** mounts the UI into a **Shadow DOM** so the page can't style it and it
can't leak styles into the page. The UI is built with **Vue (Vapor mode) authored in JSX/TSX**
via [`vue-jsx-vapor`](https://vuejsx.dev/), styled with **Tailwind v4** (compiled CSS is adopted
into the shadow root).
- The isolated block is rendered in a same-origin `srcdoc` **iframe** that copies the page's
`<style>`/`<link>` tags, `<base href>`, `:root` custom properties and the element's ancestor
chain (as `display:contents` wrappers, so selectors/inheritance match without the ancestors'
layout distorting the block).
## Tech stack
Vite + [`vite-plugin-web-extension`](https://vite-plugin-web-extension.aklinker1.io/), Vue
`3.6` (Vapor), `vue-jsx-vapor`, Tailwind v4, TypeScript, Vitest.
## Develop
```bash
pnpm install
pnpm dev # build + watch into dist/
pnpm build # type-check + production build into dist/
pnpm test # unit tests (color + geometry utils)
pnpm typecheck # tsc --noEmit
```
Then load it in Chrome:
1. Open `chrome://extensions`, enable **Developer mode**.
2. **Load unpacked** → select the `dist/` folder.
3. Open any site, click the extension icon (or `Alt+Shift+E`), and pick an element.
> Re-run `pnpm build` (or keep `pnpm dev` running) and hit the reload icon on the extension card
> after changes.
## Known limitations
- **Strict CSP pages**: a `srcdoc` iframe inherits the page's Content-Security-Policy, so on sites
that forbid inline styles the isolated block may render imperfectly.
- Chrome/Chromium only for now. The plugin's manifest templating makes a Firefox build a feasible
follow-up.
- Styles that depend on very deep ancestor context or `:nth-child` among original siblings may
differ slightly, since only the direct ancestor chain is reconstructed.
+31
View File
@@ -0,0 +1,31 @@
{
"manifest_version": 3,
"name": "Element Inspector",
"version": "0.0.0",
"description": "Isolate any page element on a clean canvas and inspect its sizes, colors, spacing and responsive behavior.",
"action": {
"default_title": "Inspect element (Alt+Shift+E)"
},
"background": {
"service_worker": "src/background.ts",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/content/main.ts"],
"run_at": "document_idle",
"all_frames": false
}
],
"permissions": ["activeTab"],
"commands": {
"activate-picker": {
"suggested_key": {
"default": "Alt+Shift+E",
"mac": "Alt+Shift+E"
},
"description": "Pick an element to inspect"
}
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "element-inspector",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Browser extension to isolate any page element on a clean canvas and inspect its sizes, colors, spacing and responsive behavior.",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"vue": "3.6.0-beta.13"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@types/chrome": "^0.1.42",
"@types/node": "^25.9.1",
"@vue/tsconfig": "^0.9.1",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.3",
"vite": "^8.0.13",
"vite-plugin-web-extension": "^4.5.1",
"vitest": "^4.1.8",
"vue-jsx-vapor": "^3.2.14"
}
}
+2908
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
import { ACTIVATE_MESSAGE } from './shared/messages';
// The toolbar icon has no popup, so clicking it fires `action.onClicked`. We relay an
// "activate" message to the content script in the active tab, which starts the picker.
chrome.action.onClicked.addListener((tab) => {
if (tab.id != null) activate(tab.id);
});
// Keyboard shortcut (Alt+Shift+E by default) declared under `commands` in the manifest.
chrome.commands.onCommand.addListener(async (command) => {
if (command !== 'activate-picker') return;
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.id != null) activate(tab.id);
});
function activate(tabId: number): void {
chrome.tabs.sendMessage(tabId, { type: ACTIVATE_MESSAGE }).catch(() => {
// No content script on this page (e.g. chrome://, the Web Store, a PDF viewer).
// Nothing we can do there — fail silently.
});
}
+108
View File
@@ -0,0 +1,108 @@
// Snapshot a selected element into a self-contained HTML document for the canvas iframe.
//
// Fidelity strategy:
// - Copy every <style>/<link rel=stylesheet> from the page <head> so the page's real CSS
// (including media queries) applies — resizing the iframe re-fires them.
// - Set <base href> so relative URLs (images, fonts, @import) resolve.
// - Carry the <html>/<body> classes + all :root custom properties, so theme variables and
// body-scoped selectors keep working.
// - Rebuild the element's ancestor chain (tag + id + class) as `display:contents` wrappers,
// so descendant-combinator selectors and inherited styles match without the ancestors'
// own layout boxes distorting the isolated block.
export const TARGET_ATTR = 'data-ei-target';
export interface Capture {
srcdoc: string;
tag: string;
naturalWidth: number;
naturalHeight: number;
}
export function captureElement(el: Element): Capture {
const rect = el.getBoundingClientRect();
const docEl = document.documentElement;
const lang = docEl.getAttribute('lang');
const dir = docEl.getAttribute('dir');
const htmlClass = docEl.getAttribute('class');
const bodyClass = document.body?.getAttribute('class') ?? null;
const srcdoc = `<!doctype html>
<html${attr('lang', lang)}${attr('dir', dir)}${attr('class', htmlClass)}>
<head>
<meta charset="utf-8">
<base href="${escapeAttr(document.baseURI)}">
${collectHead()}
${collectRootVars()}
<style id="__ei_reset">
html,body{margin:0!important;padding:0!important;background:transparent!important;}
body{box-sizing:border-box!important;min-height:100vh!important;padding:32px!important;
display:flex!important;align-items:center!important;justify-content:center!important;}
[${TARGET_ATTR}]{flex:0 0 auto!important;}
</style>
</head>
<body${attr('class', bodyClass)}>
${buildAncestorChain(el)}
</body>
</html>`;
return {
srcdoc,
tag: el.tagName.toLowerCase(),
naturalWidth: Math.round(rect.width),
naturalHeight: Math.round(rect.height),
};
}
function collectHead(): string {
const nodes = document.querySelectorAll('style, link[rel~="stylesheet"]');
return Array.from(nodes)
.map((node) => node.outerHTML)
.join('\n');
}
function collectRootVars(): string {
const cs = getComputedStyle(document.documentElement);
let decls = '';
for (let i = 0; i < cs.length; i++) {
const prop = cs.item(i);
if (!prop.startsWith('--')) continue;
const value = cs.getPropertyValue(prop);
// A stray `}` in a value would break the rule; such values are vanishingly rare.
if (value && !value.includes('}')) decls += `${prop}:${value};`;
}
return decls ? `<style id="__ei_rootvars">:root{${decls}}</style>` : '';
}
function buildAncestorChain(el: Element): string {
const clone = el.cloneNode(true) as Element;
clone.setAttribute(TARGET_ATTR, '');
let html = clone.outerHTML;
let node = el.parentElement;
while (node && node !== document.body && node !== document.documentElement) {
const tag = node.tagName.toLowerCase();
html = `${openWrapper(node, tag)}${html}</${tag}>`;
node = node.parentElement;
}
return html;
}
function openWrapper(el: Element, tag: string): string {
// `display:contents` keeps the wrapper in the tree (for selector matching + inheritance)
// but removes its own box so parent flex/grid/padding don't distort the block.
return `<${tag}${attr('id', el.id || null)}${attr('class', el.getAttribute('class'))} style="display:contents!important;">`;
}
function attr(name: string, value: string | null): string {
return value ? ` ${name}="${escapeAttr(value)}"` : '';
}
function escapeAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
+81
View File
@@ -0,0 +1,81 @@
import { ACTIVATE_MESSAGE } from '../shared/messages';
import { startPicker } from './picker';
import type { PickerHandle } from './picker';
import { captureElement } from './capture';
import { createCanvasApp } from '../ui/mount';
import type { CanvasApp } from '../ui/mount';
import cssText from '../ui/styles/style.css?inline';
// Content-script entry. Stays dormant until the background worker sends an "activate"
// message (toolbar click / shortcut). All UI lives in a Shadow DOM host so it can't be
// styled by — or leak styles into — the page.
type Mode = 'idle' | 'picking' | 'canvas';
let mode: Mode = 'idle';
let host: HTMLElement | null = null;
let shadow: ShadowRoot | null = null;
let picker: PickerHandle | null = null;
let canvas: CanvasApp | null = null;
chrome.runtime.onMessage.addListener((message) => {
if (message?.type === ACTIVATE_MESSAGE) activate();
});
function activate(): void {
if (mode === 'picking') return;
if (mode === 'canvas') deactivate();
startPicking();
}
function startPicking(): void {
const root = ensureHost();
// Let `elementFromPoint` reach the page underneath while picking.
host!.style.pointerEvents = 'none';
mode = 'picking';
picker = startPicker(root, onPicked, deactivate);
}
function onPicked(el: Element): void {
picker = null;
const capture = captureElement(el);
const root = ensureHost();
host!.style.pointerEvents = 'auto';
mode = 'canvas';
canvas = createCanvasApp(root, capture, deactivate);
}
function ensureHost(): ShadowRoot {
if (shadow) return shadow;
host = document.createElement('div');
host.id = 'element-inspector-root';
host.style.cssText = 'all:initial; position:fixed; inset:0; z-index:2147483647;';
shadow = host.attachShadow({ mode: 'open' });
const sheet = new CSSStyleSheet();
// Tailwind v4 emits theme variables on `:root`, which won't match inside a shadow root.
sheet.replaceSync(cssText.replace(/:root\b/g, ':host'));
shadow.adoptedStyleSheets = [sheet];
document.documentElement.appendChild(host);
return shadow;
}
function deactivate(): void {
if (mode === 'idle') return;
mode = 'idle';
if (picker) {
const active = picker;
picker = null;
active.cancel();
}
if (canvas) {
canvas.unmount();
canvas = null;
}
if (host) {
host.remove();
host = null;
shadow = null;
}
}
+118
View File
@@ -0,0 +1,118 @@
// DevTools-style element picker. Draws a highlight box + label inside the extension's
// shadow root and resolves with the clicked element. Uses capture-phase listeners so it
// beats the page's own handlers (links won't navigate, buttons won't fire).
export interface PickerHandle {
cancel: () => void;
}
const HIGHLIGHT_STYLE =
'position:fixed;z-index:2147483646;pointer-events:none;box-sizing:border-box;' +
'border:2px solid #3b82f6;background:rgba(59,130,246,0.16);' +
'box-shadow:0 0 0 1px rgba(255,255,255,0.5);border-radius:2px;' +
'transition:left 60ms ease,top 60ms ease,width 60ms ease,height 60ms ease;display:none;';
const LABEL_STYLE =
'position:fixed;z-index:2147483647;pointer-events:none;display:none;' +
'background:#1e293b;color:#f8fafc;font:600 11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;' +
'padding:3px 7px;border-radius:5px;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,0.35);';
export function startPicker(
root: ShadowRoot,
onPick: (el: Element) => void,
onCancel: () => void,
): PickerHandle {
const highlight = document.createElement('div');
highlight.style.cssText = HIGHLIGHT_STYLE;
const label = document.createElement('div');
label.style.cssText = LABEL_STYLE;
root.append(highlight, label);
let current: Element | null = null;
let done = false;
const place = (el: Element): void => {
const r = el.getBoundingClientRect();
highlight.style.display = 'block';
highlight.style.left = `${r.left}px`;
highlight.style.top = `${r.top}px`;
highlight.style.width = `${r.width}px`;
highlight.style.height = `${r.height}px`;
label.textContent = describe(el, r);
label.style.display = 'block';
const above = r.top - 26;
label.style.left = `${Math.max(2, Math.min(r.left, window.innerWidth - label.offsetWidth - 4))}px`;
label.style.top = `${above >= 2 ? above : r.bottom + 6}px`;
};
const onMove = (e: MouseEvent): void => {
const el = document.elementFromPoint(e.clientX, e.clientY);
if (!el || el === current) return;
current = el;
place(el);
};
const onScroll = (): void => {
if (current) place(current);
};
const swallow = (e: Event): void => {
e.preventDefault();
e.stopImmediatePropagation();
};
const onClick = (e: MouseEvent): void => {
swallow(e);
const el = current ?? document.elementFromPoint(e.clientX, e.clientY);
if (el) finish(() => onPick(el));
};
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
swallow(e);
finish(onCancel);
}
};
function finish(cb: () => void): void {
if (done) return;
done = true;
cleanup();
cb();
}
function cleanup(): void {
window.removeEventListener('mousemove', onMove, true);
window.removeEventListener('mousedown', swallow, true);
window.removeEventListener('mouseup', swallow, true);
window.removeEventListener('click', onClick, true);
window.removeEventListener('contextmenu', swallow, true);
window.removeEventListener('keydown', onKey, true);
window.removeEventListener('scroll', onScroll, true);
highlight.remove();
label.remove();
}
window.addEventListener('mousemove', onMove, true);
window.addEventListener('mousedown', swallow, true);
window.addEventListener('mouseup', swallow, true);
window.addEventListener('click', onClick, true);
window.addEventListener('contextmenu', swallow, true);
window.addEventListener('keydown', onKey, true);
window.addEventListener('scroll', onScroll, true);
return {
cancel: () => finish(onCancel),
};
}
function describe(el: Element, r: DOMRect): string {
const tag = el.tagName.toLowerCase();
const id = el.id ? `#${el.id}` : '';
let cls = '';
if (typeof el.className === 'string' && el.className.trim()) {
cls = '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.');
}
return `${tag}${id}${cls} ${Math.round(r.width)}×${Math.round(r.height)}`;
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="chrome" />
+6
View File
@@ -0,0 +1,6 @@
// Message type exchanged between the background worker and the content script.
export const ACTIVATE_MESSAGE = 'element-inspector:activate' as const;
export interface ActivateMessage {
type: typeof ACTIVATE_MESSAGE;
}
+27
View File
@@ -0,0 +1,27 @@
import { onBeforeUnmount, onMounted } from 'vue';
import { requestExit } from './store';
import Toolbar from './components/Toolbar';
import Stage from './components/Stage';
import InspectorPanel from './components/InspectorPanel';
// Root of the canvas overlay. Fills the shadow-root host (fixed, full-viewport).
export default function App() {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
e.preventDefault();
requestExit();
}
};
onMounted(() => window.addEventListener('keydown', onKey, true));
onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true));
return (
<div class="ei-root flex h-full w-full flex-col overflow-hidden bg-[#0b0e14] font-sans text-[13px] text-slate-200 antialiased">
<Toolbar />
<div class="flex min-h-0 flex-1">
<Stage />
<InspectorPanel />
</div>
</div>
);
}
@@ -0,0 +1,30 @@
import type { ColorSwatch as Swatch } from '../store';
// A single color row: swatch + label + value (CSS variable name when resolved, else hex).
// Clicking copies `var(--name)` or the hex to the clipboard.
export default function ColorSwatch(props: { swatch: Swatch }) {
const copy = (): void => {
const text = props.swatch.varName ? `var(${props.swatch.varName})` : props.swatch.hex;
navigator.clipboard?.writeText(text).catch(() => {
/* clipboard may be blocked on some pages */
});
};
return (
<button
type="button"
onClick={copy}
title="Copy"
class="flex w-full items-center gap-2 rounded px-1.5 py-1 text-left hover:bg-white/5"
>
<span class="h-6 w-6 shrink-0 rounded border border-white/15" style={{ background: props.swatch.hex }} />
<span class="min-w-0 flex-1">
<span class="block text-[10px] uppercase tracking-wide text-slate-500">{props.swatch.label}</span>
<span class="block truncate font-mono text-[11px] text-slate-200">
{props.swatch.varName ?? props.swatch.hex}
</span>
</span>
{props.swatch.varName ? <span class="shrink-0 font-mono text-[10px] text-slate-500">{props.swatch.hex}</span> : null}
</button>
);
}
@@ -0,0 +1,28 @@
import { DEVICE_PRESETS, recenter, setDevice, state } from '../store';
// Quick responsive-width presets. Picking one resizes the frame and re-centers it.
export default function DevicePresets() {
return (
<div class="flex items-center gap-1">
{DEVICE_PRESETS.map((preset) => {
const active = state.frameWidth === preset.width && state.frameHeight === preset.height;
return (
<button
key={preset.label}
type="button"
onClick={() => {
setDevice(preset);
recenter();
}}
class={[
'rounded px-2 py-1 font-mono text-[11px] transition-colors',
active ? 'bg-sky-600 text-white' : 'bg-white/5 text-slate-300 hover:bg-white/10',
]}
>
{preset.label}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,72 @@
import { computed } from 'vue';
import { state } from '../store';
import ColorSwatch from './ColorSwatch';
// Right sidebar. Shows the hovered element's metrics (falling back to the selected one):
// dimensions, spacing/radius, typography and colors (resolved to CSS variables when possible).
export default function InspectorPanel() {
const info = computed(() => state.hover ?? state.selected);
return (
<aside class="flex w-72 shrink-0 flex-col gap-4 overflow-y-auto border-l border-white/10 bg-[#0e131c] p-4">
{info.value ? (
<>
<div>
<div class="font-mono text-sm text-sky-300">
{info.value.tag}
{info.value.id ? <span class="text-slate-500">#{info.value.id}</span> : null}
</div>
{info.value.classes.length ? (
<div class="mt-0.5 truncate font-mono text-[11px] text-slate-500">.{info.value.classes.join('.')}</div>
) : null}
</div>
<section>
<Heading text="Size" />
<Row label="Width" value={`${info.value.width}px`} />
<Row label="Height" value={`${info.value.height}px`} />
</section>
<section>
<Heading text="Spacing" />
<Row label="Padding" value={info.value.padding} />
<Row label="Margin" value={info.value.margin} />
<Row label="Radius" value={info.value.radius} />
</section>
<section>
<Heading text="Typography" />
<Row label="Font" value={info.value.font.family || '—'} />
<Row label="Size" value={info.value.font.size} />
<Row label="Weight" value={info.value.font.weight} />
<Row label="Line" value={info.value.font.lineHeight} />
</section>
{info.value.colors.length ? (
<section>
<Heading text="Colors" />
{info.value.colors.map((swatch) => (
<ColorSwatch key={swatch.label + swatch.hex} swatch={swatch} />
))}
</section>
) : null}
</>
) : (
<p class="text-[12px] text-slate-500">Hover an element on the canvas to inspect it.</p>
)}
</aside>
);
}
function Heading(props: { text: string }) {
return <h3 class="mb-1.5 text-[10px] font-semibold uppercase tracking-widest text-slate-500">{props.text}</h3>;
}
function Row(props: { label: string; value: string }) {
return (
<div class="flex items-baseline justify-between gap-2 py-0.5">
<span class="text-[11px] text-slate-400">{props.label}</span>
<span class="ml-2 truncate font-mono text-[11px] text-slate-200">{props.value}</span>
</div>
);
}
@@ -0,0 +1,65 @@
import { state } from '../store';
import type { Box } from '../../utils/rect';
// Draws the box-model overlay for the hovered element, a persistent outline for the
// selected element, and any guides. Lives in viewport space (constant-size badges) and
// converts iframe-pixel coordinates to screen coordinates via the current pan/zoom.
export default function MeasureLayer() {
const boxStyle = (b: Box) => ({
position: 'absolute' as const,
left: `${state.panX + b.x * state.zoom}px`,
top: `${state.panY + b.y * state.zoom}px`,
width: `${b.width * state.zoom}px`,
height: `${b.height * state.zoom}px`,
});
return (
<div class="pointer-events-none absolute inset-0 overflow-hidden">
{state.hover ? (
<>
<div style={{ ...boxStyle(state.hover.box.margin), background: 'rgba(246,160,92,0.40)' }} />
<div style={{ ...boxStyle(state.hover.box.border), background: 'rgba(247,205,128,0.45)' }} />
<div style={{ ...boxStyle(state.hover.box.padding), background: 'rgba(125,206,160,0.40)' }} />
<div style={{ ...boxStyle(state.hover.box.content), background: 'rgba(116,178,255,0.40)' }} />
<Badge box={state.hover.box.border} text={`${state.hover.width} × ${state.hover.height}`} tone="sky" />
</>
) : null}
{state.selected ? (
<div style={{ ...boxStyle(state.selected.box.border), outline: '2px solid #3b82f6', outlineOffset: '-1px' }} />
) : null}
{state.guides.x.map((gx) => (
<div
key={`x${gx}`}
class="absolute bottom-0 top-0 w-px bg-sky-400/80"
style={{ left: `${state.panX + gx * state.zoom}px` }}
>
<span class="absolute left-1 top-1 rounded bg-sky-500 px-1 text-[10px] font-medium text-white">{gx}</span>
</div>
))}
{state.guides.y.map((gy) => (
<div
key={`y${gy}`}
class="absolute left-0 right-0 h-px bg-sky-400/80"
style={{ top: `${state.panY + gy * state.zoom}px` }}
>
<span class="absolute left-1 top-1 rounded bg-sky-500 px-1 text-[10px] font-medium text-white">{gy}</span>
</div>
))}
</div>
);
}
function Badge(props: { box: Box; text: string; tone: 'sky' }) {
const left = state.panX + props.box.x * state.zoom;
const top = state.panY + props.box.y * state.zoom;
return (
<div
class="absolute -translate-y-full rounded bg-sky-600 px-1.5 py-0.5 text-[10px] font-semibold text-white shadow"
style={{ left: `${Math.max(0, left)}px`, top: `${Math.max(14, top)}px` }}
>
{props.text}
</div>
);
}
@@ -0,0 +1,53 @@
import { setFrameSize, state } from '../store';
type Mode = 'r' | 'b' | 'rb';
// Drag handles on the right / bottom / corner of the frame to resize it (top-left anchored),
// which re-fires the page's media queries inside the iframe. Positioned in viewport space.
export default function ResizeHandles() {
const startDrag = (e: PointerEvent, mode: Mode): void => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const startW = state.frameWidth;
const startH = state.frameHeight;
const move = (ev: PointerEvent): void => {
const dw = (ev.clientX - startX) / state.zoom;
const dh = (ev.clientY - startY) / state.zoom;
setFrameSize(mode === 'b' ? startW : startW + dw, mode === 'r' ? startH : startH + dh);
};
const up = (): void => {
window.removeEventListener('pointermove', move, true);
window.removeEventListener('pointerup', up, true);
};
window.addEventListener('pointermove', move, true);
window.addEventListener('pointerup', up, true);
};
const right = state.panX + state.frameWidth * state.zoom;
const bottom = state.panY + state.frameHeight * state.zoom;
const midX = state.panX + (state.frameWidth * state.zoom) / 2;
const midY = state.panY + (state.frameHeight * state.zoom) / 2;
return (
<div class="pointer-events-none absolute inset-0">
<div
class="pointer-events-auto absolute h-7 w-1.5 -translate-x-1/2 cursor-ew-resize rounded-full bg-sky-500/80 hover:bg-sky-400"
style={{ left: `${right}px`, top: `${midY - 14}px` }}
onPointerdown={(e) => startDrag(e, 'r')}
/>
<div
class="pointer-events-auto absolute h-1.5 w-7 -translate-y-1/2 cursor-ns-resize rounded-full bg-sky-500/80 hover:bg-sky-400"
style={{ left: `${midX - 14}px`, top: `${bottom}px` }}
onPointerdown={(e) => startDrag(e, 'b')}
/>
<div
class="pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize rounded-sm border border-white/40 bg-sky-500 hover:bg-sky-400"
style={{ left: `${right}px`, top: `${bottom}px` }}
onPointerdown={(e) => startDrag(e, 'rb')}
/>
</div>
);
}
@@ -0,0 +1,119 @@
import { onBeforeUnmount, onMounted, ref, watchEffect } from 'vue';
import { addGuide, state } from '../store';
const SIZE = 20;
const STEPS = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000];
// Zoom/pan-aware rulers drawn on <canvas>. Clicking a ruler drops a guide at that position.
export default function Rulers() {
const topCanvas = ref<HTMLCanvasElement>();
const leftCanvas = ref<HTMLCanvasElement>();
const draw = (): void => {
if (topCanvas.value) drawAxis(topCanvas.value, 'x');
if (leftCanvas.value) drawAxis(leftCanvas.value, 'y');
};
let stop: (() => void) | undefined;
onMounted(() => {
stop = watchEffect(draw);
window.addEventListener('resize', draw);
});
onBeforeUnmount(() => {
stop?.();
window.removeEventListener('resize', draw);
});
const onTopClick = (e: MouseEvent): void => {
const offset = e.clientX - (topCanvas.value?.getBoundingClientRect().left ?? 0);
addGuide('x', (offset - state.panX) / state.zoom);
};
const onLeftClick = (e: MouseEvent): void => {
const offset = e.clientY - (leftCanvas.value?.getBoundingClientRect().top ?? 0);
addGuide('y', (offset - state.panY) / state.zoom);
};
return (
<>
<canvas
ref={topCanvas}
onClick={(e) => onTopClick(e.nativeEvent)}
class="absolute left-0 top-0 cursor-crosshair"
style={{ height: `${SIZE}px` }}
/>
<canvas
ref={leftCanvas}
onClick={(e) => onLeftClick(e.nativeEvent)}
class="absolute left-0 top-0 cursor-crosshair"
style={{ width: `${SIZE}px` }}
/>
<div class="absolute left-0 top-0 border-b border-r border-white/10 bg-[#11151f]" style={{ width: `${SIZE}px`, height: `${SIZE}px` }} />
</>
);
}
function niceStep(zoom: number, minPx: number): number {
for (const step of STEPS) {
if (step * zoom >= minPx) return step;
}
return STEPS[STEPS.length - 1]!;
}
function drawAxis(canvas: HTMLCanvasElement, axis: 'x' | 'y'): void {
const parent = canvas.parentElement;
if (!parent) return;
const dpr = window.devicePixelRatio || 1;
const length = axis === 'x' ? parent.clientWidth : parent.clientHeight;
const cssW = axis === 'x' ? length : SIZE;
const cssH = axis === 'x' ? SIZE : length;
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cssW, cssH);
ctx.fillStyle = '#11151f';
ctx.fillRect(0, 0, cssW, cssH);
ctx.strokeStyle = 'rgba(148,163,184,0.35)';
ctx.fillStyle = 'rgba(148,163,184,0.85)';
ctx.font = '9px ui-monospace, monospace';
ctx.lineWidth = 1;
const pan = axis === 'x' ? state.panX : state.panY;
const major = niceStep(state.zoom, 56);
const minor = major / (major % 5 === 0 ? 5 : 4);
const firstValue = Math.floor((0 - pan) / state.zoom / minor) * minor;
for (let v = firstValue; pan + v * state.zoom <= length; v += minor) {
const pos = pan + v * state.zoom;
if (pos < 0) continue;
const isMajor = Math.abs(v % major) < 0.001;
const tick = isMajor ? SIZE * 0.6 : SIZE * 0.3;
ctx.beginPath();
if (axis === 'x') {
ctx.moveTo(pos + 0.5, SIZE);
ctx.lineTo(pos + 0.5, SIZE - tick);
} else {
ctx.moveTo(SIZE, pos + 0.5);
ctx.lineTo(SIZE - tick, pos + 0.5);
}
ctx.stroke();
if (isMajor) {
const label = String(Math.round(v));
if (axis === 'x') {
ctx.fillText(label, pos + 2, 9);
} else {
ctx.save();
ctx.translate(9, pos - 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText(label, 0, 0);
ctx.restore();
}
}
}
}
@@ -0,0 +1,95 @@
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { recenter, state, zoomAt } from '../store';
import { useFrame } from '../composables/useFrame';
import MeasureLayer from './MeasureLayer';
import ResizeHandles from './ResizeHandles';
import Rulers from './Rulers';
// The canvas: a pannable/zoomable viewport holding the device frame (an iframe with the
// isolated element), plus the measurement overlay, resize handles and rulers.
export default function Stage() {
const viewport = ref<HTMLDivElement>();
const frame = ref<HTMLIFrameElement>();
useFrame(frame);
let observer: ResizeObserver | undefined;
onMounted(() => {
const el = viewport.value;
if (!el) return;
state.viewportW = el.clientWidth;
state.viewportH = el.clientHeight;
recenter();
observer = new ResizeObserver(() => {
state.viewportW = el.clientWidth;
state.viewportH = el.clientHeight;
});
observer.observe(el);
});
onBeforeUnmount(() => observer?.disconnect());
const onWheel = (e: WheelEvent): void => {
e.preventDefault();
const rect = viewport.value!.getBoundingClientRect();
zoomAt(e.deltaY < 0 ? 1.1 : 0.9, e.clientX - rect.left, e.clientY - rect.top);
};
let panning = false;
let startX = 0;
let startY = 0;
let originX = 0;
let originY = 0;
const onPointerdown = (e: PointerEvent): void => {
if (e.target !== viewport.value) return; // only pan on the empty background
panning = true;
startX = e.clientX;
startY = e.clientY;
originX = state.panX;
originY = state.panY;
viewport.value!.setPointerCapture(e.pointerId);
};
const onPointermove = (e: PointerEvent): void => {
if (!panning) return;
state.panX = originX + (e.clientX - startX);
state.panY = originY + (e.clientY - startY);
};
const onPointerup = (e: PointerEvent): void => {
panning = false;
try {
viewport.value!.releasePointerCapture(e.pointerId);
} catch {
/* pointer already released */
}
};
return (
<div
ref={viewport}
class="ei-grid relative min-w-0 flex-1 cursor-grab overflow-hidden active:cursor-grabbing"
onWheel={(e) => onWheel(e.nativeEvent)}
onPointerdown={onPointerdown}
onPointermove={onPointermove}
onPointerup={onPointerup}
>
<div
class="absolute left-0 top-0 origin-top-left"
style={{
transform: `translate(${state.panX}px, ${state.panY}px) scale(${state.zoom})`,
width: `${state.frameWidth}px`,
height: `${state.frameHeight}px`,
}}
>
<iframe
ref={frame}
title="Isolated element"
sandbox="allow-same-origin"
class="block h-full w-full border-0 bg-white"
style={{ boxShadow: '0 0 0 1px rgba(148,163,184,0.45)' }}
/>
</div>
<MeasureLayer />
<ResizeHandles />
{state.showRulers ? <Rulers /> : null}
</div>
);
}
@@ -0,0 +1,88 @@
import { clearGuides, recenter, requestExit, resetSize, rotateFrame, setFrameSize, setZoom, state } from '../store';
import DevicePresets from './DevicePresets';
// Top bar: identity, responsive controls (presets + W×H inputs + rotate), and view controls
// (zoom, rulers, guides, reset, close).
export default function Toolbar() {
const onWidth = (value: string): void => {
const v = Number(value);
if (v > 0) setFrameSize(v, state.frameHeight);
};
const onHeight = (value: string): void => {
const v = Number(value);
if (v > 0) setFrameSize(state.frameWidth, v);
};
return (
<header class="flex h-11 shrink-0 items-center gap-3 border-b border-white/10 bg-[#11151f] px-3">
<div class="flex items-center gap-2">
<span class="text-[13px] font-semibold text-slate-100">Element Inspector</span>
<span class="rounded bg-sky-600/20 px-1.5 py-0.5 font-mono text-[11px] text-sky-300">{state.tag}</span>
</div>
<div class="mx-1 h-5 w-px bg-white/10" />
<DevicePresets />
<div class="flex items-center gap-1 font-mono text-[11px] text-slate-300">
<input
type="number"
value={state.frameWidth}
onInput={(e) => onWidth(e.currentTarget.value)}
class="w-16 rounded bg-white/5 px-1.5 py-1 text-center outline-none focus:bg-white/10"
/>
<span class="text-slate-500">×</span>
<input
type="number"
value={state.frameHeight}
onInput={(e) => onHeight(e.currentTarget.value)}
class="w-16 rounded bg-white/5 px-1.5 py-1 text-center outline-none focus:bg-white/10"
/>
</div>
<ToolButton label="Rotate" onClick={() => { rotateFrame(); recenter(); }} />
<div class="ml-auto flex items-center gap-1">
<ToolButton label="" onClick={() => setZoom(state.zoom - 0.1)} />
<span class="w-12 text-center font-mono text-[11px] text-slate-300">{Math.round(state.zoom * 100)}%</span>
<ToolButton label="+" onClick={() => setZoom(state.zoom + 0.1)} />
<ToolButton
label="Fit"
onClick={() => {
resetSize();
recenter();
}}
/>
<div class="mx-1 h-5 w-px bg-white/10" />
<ToolButton label="Rulers" active={state.showRulers} onClick={() => (state.showRulers = !state.showRulers)} />
<ToolButton label="Clear guides" onClick={clearGuides} />
<div class="mx-1 h-5 w-px bg-white/10" />
<button
type="button"
onClick={requestExit}
class="rounded bg-white/5 px-2.5 py-1 text-[12px] text-slate-200 hover:bg-rose-600/80 hover:text-white"
>
Close (Esc)
</button>
</div>
</header>
);
}
function ToolButton(props: { label: string; active?: boolean; onClick: () => void }) {
return (
<button
type="button"
onClick={props.onClick}
class={[
'rounded px-2 py-1 text-[12px] transition-colors',
props.active ? 'bg-sky-600 text-white' : 'bg-white/5 text-slate-300 hover:bg-white/10',
]}
>
{props.label}
</button>
);
}
@@ -0,0 +1,197 @@
import { onBeforeUnmount, onMounted, watch } from 'vue';
import type { Ref } from 'vue';
import { TARGET_ATTR } from '../../content/capture';
import { computeBoxModel } from '../../utils/rect';
import type { Box, Edges } from '../../utils/rect';
import { colorKey, isTransparent, parseColor, rgbaToHex } from '../../utils/color';
import { requestExit, state } from '../store';
import type { ColorSwatch, Inspection } from '../store';
// Owns the canvas iframe: writes the captured srcdoc, then reads layout/styles back out of
// the (same-origin) iframe document to drive the inspector overlays.
export function useFrame(frameRef: Ref<HTMLIFrameElement | undefined>): {
reinspect: () => void;
} {
let doc: Document | null = null;
let win: (Window & typeof globalThis) | null = null;
let target: Element | null = null;
let varMap = new Map<string, string>();
const onLoad = (): void => {
const frame = frameRef.value;
if (!frame) return;
doc = frame.contentDocument;
win = frame.contentWindow as (Window & typeof globalThis) | null;
if (!doc || !win) return;
varMap = buildVarMap(win, doc);
target = doc.querySelector(`[${TARGET_ATTR}]`);
if (target) state.selected = inspect(target);
doc.addEventListener('mousemove', onMove, true);
doc.addEventListener('mouseleave', onLeave, true);
doc.addEventListener('click', onClick, true);
doc.addEventListener('keydown', onKey, true);
};
const onKey = (e: KeyboardEvent): void => {
// ESC works even when focus is inside the iframe.
if (e.key === 'Escape') {
e.preventDefault();
requestExit();
}
};
const onMove = (e: MouseEvent): void => {
if (state.tool !== 'inspect' || !doc) return;
const el = doc.elementFromPoint(e.clientX, e.clientY);
if (el) state.hover = inspect(el);
};
const onLeave = (): void => {
state.hover = null;
};
const onClick = (e: MouseEvent): void => {
if (state.tool !== 'inspect' || !doc) return;
const el = doc.elementFromPoint(e.clientX, e.clientY);
if (el) {
target = el;
state.selected = inspect(el);
}
};
function inspect(el: Element): Inspection {
const w = win!;
const cs = w.getComputedStyle(el);
const r = el.getBoundingClientRect();
const borderBox: Box = { x: r.left, y: r.top, width: r.width, height: r.height };
const padding = edges(cs, 'padding', '');
const border = edges(cs, 'border', '-width');
const margin = edges(cs, 'margin', '');
return {
tag: el.tagName.toLowerCase(),
id: el.id ?? '',
classes: typeof el.className === 'string' ? el.className.trim().split(/\s+/).filter(Boolean) : [],
box: computeBoxModel(borderBox, padding, border, margin),
width: Math.round(r.width),
height: Math.round(r.height),
radius: cs.borderRadius || '0px',
padding: shorthand(padding),
margin: shorthand(margin),
font: {
family: cs.fontFamily.split(',')[0]?.replace(/["']/g, '').trim() ?? '',
size: cs.fontSize,
weight: cs.fontWeight,
lineHeight: cs.lineHeight,
},
colors: collectColors(cs),
};
}
function collectColors(cs: CSSStyleDeclaration): ColorSwatch[] {
const swatches: ColorSwatch[] = [];
const seen = new Set<string>();
const add = (label: string, value: string): void => {
const color = parseColor(value);
if (!color || isTransparent(color)) return;
const key = label + colorKey(color);
if (seen.has(key)) return;
seen.add(key);
swatches.push({ label, color, hex: rgbaToHex(color), varName: varMap.get(colorKey(color)) ?? null });
};
add('Text', cs.color);
add('Background', cs.backgroundColor);
if (parseFloat(cs.borderTopWidth) > 0) add('Border', cs.borderTopColor);
if (parseFloat(cs.outlineWidth) > 0) add('Outline', cs.outlineColor);
return swatches;
}
const reinspect = (): void => {
if (target && win) state.selected = inspect(target);
};
function teardown(): void {
if (!doc) return;
doc.removeEventListener('mousemove', onMove, true);
doc.removeEventListener('mouseleave', onLeave, true);
doc.removeEventListener('click', onClick, true);
doc.removeEventListener('keydown', onKey, true);
doc = null;
win = null;
}
const load = (): void => {
const frame = frameRef.value;
if (!frame) return;
teardown();
frame.srcdoc = state.srcdoc;
};
onMounted(() => {
const frame = frameRef.value;
if (!frame) return;
frame.addEventListener('load', onLoad);
if (state.srcdoc) load();
});
// Re-render on a new capture (kept for future "re-pick without exiting").
watch(
() => state.srcdoc,
() => load(),
);
// Resizing the frame re-fires the page's media queries; recompute boxes after relayout.
watch(
() => [state.frameWidth, state.frameHeight],
() => requestAnimationFrame(reinspect),
);
onBeforeUnmount(() => {
teardown();
frameRef.value?.removeEventListener('load', onLoad);
});
return { reinspect };
}
function buildVarMap(win: Window, doc: Document): Map<string, string> {
const map = new Map<string, string>();
const cs = win.getComputedStyle(doc.documentElement);
const probe = doc.createElement('span');
probe.style.display = 'none';
doc.body.appendChild(probe);
// Sentinel trick: invalid `color` assignments are rejected, leaving the sentinel in place,
// which lets us tell real colors from non-color custom properties (e.g. `--gap: 8px`).
const sentinel = 'rgb(1, 2, 3)';
for (let i = 0; i < cs.length; i++) {
const prop = cs.item(i);
if (!prop.startsWith('--')) continue;
const raw = cs.getPropertyValue(prop).trim();
if (!raw) continue;
probe.style.color = sentinel;
probe.style.color = raw;
const resolved = win.getComputedStyle(probe).color;
if (resolved === sentinel) continue;
const color = parseColor(resolved);
if (!color || isTransparent(color)) continue;
const key = colorKey(color);
if (!map.has(key)) map.set(key, prop);
}
probe.remove();
return map;
}
function edges(cs: CSSStyleDeclaration, prefix: string, suffix: string): Edges {
const get = (side: string): number => parseFloat(cs.getPropertyValue(`${prefix}-${side}${suffix}`)) || 0;
return { top: get('top'), right: get('right'), bottom: get('bottom'), left: get('left') };
}
function shorthand(e: Edges): string {
const v = [e.top, e.right, e.bottom, e.left].map((n) => Math.round(n));
if (v.every((n) => n === v[0])) return `${v[0]}px`;
return v.map((n) => `${n}px`).join(' ');
}
+29
View File
@@ -0,0 +1,29 @@
import { createVaporApp } from 'vue';
import type { App as VaporApp } from 'vue';
import App from './App';
import { initFromCapture, onExit } from './store';
import type { Capture } from '../content/capture';
export interface CanvasApp {
unmount: () => void;
}
// Mount the Vue Vapor canvas app into the shadow root for a freshly captured element.
export function createCanvasApp(root: ShadowRoot, capture: Capture, onClose: () => void): CanvasApp {
initFromCapture(capture);
onExit(onClose);
const mountEl = document.createElement('div');
mountEl.style.cssText = 'position:fixed;inset:0;z-index:2147483647;';
root.appendChild(mountEl);
const app: VaporApp = createVaporApp(App);
app.mount(mountEl);
return {
unmount() {
app.unmount();
mountEl.remove();
},
};
}
+157
View File
@@ -0,0 +1,157 @@
import { reactive } from 'vue';
import type { BoxModel } from '../utils/rect';
import type { Rgba } from '../utils/color';
import type { Capture } from '../content/capture';
export interface ColorSwatch {
label: string;
color: Rgba;
hex: string;
varName: string | null;
}
export interface Inspection {
tag: string;
id: string;
classes: string[];
/** Box-model rects in iframe-content pixels. */
box: BoxModel;
width: number;
height: number;
radius: string;
padding: string;
margin: string;
font: { family: string; size: string; weight: string; lineHeight: string };
colors: ColorSwatch[];
}
export interface DevicePreset {
label: string;
width: number;
height: number;
}
export const DEVICE_PRESETS: DevicePreset[] = [
{ label: '320', width: 320, height: 568 },
{ label: '375', width: 375, height: 667 },
{ label: '768', width: 768, height: 1024 },
{ label: '1024', width: 1024, height: 768 },
{ label: '1440', width: 1440, height: 900 },
];
export const MIN_ZOOM = 0.1;
export const MAX_ZOOM = 4;
const MIN_FRAME = 80;
interface State {
srcdoc: string;
tag: string;
naturalWidth: number;
naturalHeight: number;
frameWidth: number;
frameHeight: number;
zoom: number;
panX: number;
panY: number;
tool: 'inspect' | 'guides';
showRulers: boolean;
guides: { x: number[]; y: number[] };
hover: Inspection | null;
selected: Inspection | null;
viewportW: number;
viewportH: number;
}
export const state = reactive<State>({
srcdoc: '',
tag: '',
naturalWidth: 0,
naturalHeight: 0,
frameWidth: 0,
frameHeight: 0,
zoom: 1,
panX: 0,
panY: 0,
tool: 'inspect',
showRulers: true,
guides: { x: [], y: [] },
hover: null,
selected: null,
viewportW: 0,
viewportH: 0,
});
const clamp = (n: number, min: number, max: number): number => Math.max(min, Math.min(max, n));
let exitHandler: (() => void) | null = null;
export function onExit(fn: () => void): void {
exitHandler = fn;
}
export function requestExit(): void {
exitHandler?.();
}
export function initFromCapture(capture: Capture): void {
state.srcdoc = capture.srcdoc;
state.tag = capture.tag;
state.naturalWidth = capture.naturalWidth;
state.naturalHeight = capture.naturalHeight;
// Give the frame breathing room around the natural-sized block.
state.frameWidth = Math.max(MIN_FRAME, capture.naturalWidth + 64);
state.frameHeight = Math.max(MIN_FRAME, capture.naturalHeight + 64);
state.zoom = 1;
state.tool = 'inspect';
state.guides = { x: [], y: [] };
state.hover = null;
state.selected = null;
}
export function setFrameSize(width: number, height: number): void {
state.frameWidth = Math.max(MIN_FRAME, Math.round(width));
state.frameHeight = Math.max(MIN_FRAME, Math.round(height));
}
export function setDevice(preset: DevicePreset): void {
setFrameSize(preset.width, preset.height);
}
export function rotateFrame(): void {
setFrameSize(state.frameHeight, state.frameWidth);
}
export function resetSize(): void {
setFrameSize(state.naturalWidth + 64, state.naturalHeight + 64);
state.zoom = 1;
}
export function setZoom(zoom: number): void {
state.zoom = clamp(zoom, MIN_ZOOM, MAX_ZOOM);
}
/** Zoom by a factor while keeping the viewport point (cx, cy) anchored. */
export function zoomAt(factor: number, cx: number, cy: number): void {
const next = clamp(state.zoom * factor, MIN_ZOOM, MAX_ZOOM);
const ratio = next / state.zoom;
state.panX = cx - (cx - state.panX) * ratio;
state.panY = cy - (cy - state.panY) * ratio;
state.zoom = next;
}
/** Center the frame within a viewport of the given size. */
export function centerIn(viewportWidth: number, viewportHeight: number): void {
state.panX = Math.round((viewportWidth - state.frameWidth * state.zoom) / 2);
state.panY = Math.round((viewportHeight - state.frameHeight * state.zoom) / 2);
}
/** Re-center using the last known viewport size (tracked by the Stage). */
export function recenter(): void {
if (state.viewportW && state.viewportH) centerIn(state.viewportW, state.viewportH);
}
export function addGuide(axis: 'x' | 'y', position: number): void {
state.guides[axis].push(Math.round(position));
}
export function clearGuides(): void {
state.guides = { x: [], y: [] };
}
+23
View File
@@ -0,0 +1,23 @@
@import 'tailwindcss';
/* Scan the JSX components for utility classes. */
@source '../../**/*.{ts,tsx}';
/* Dotted canvas background for the stage. */
@layer components {
.ei-grid {
background-color: #0b0e14;
background-image: radial-gradient(circle, rgba(148, 163, 184, 0.12) 1px, transparent 1px);
background-size: 16px 16px;
}
}
/* Compact scrollbars inside the overlay. */
.ei-root *::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.ei-root *::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.25);
border-radius: 6px;
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { colorKey, formatColor, isTransparent, parseColor, parseHex, parseRgb, rgbaToHex } from './color';
describe('parseRgb', () => {
it('parses comma rgb', () => {
expect(parseRgb('rgb(255, 0, 128)')).toEqual({ r: 255, g: 0, b: 128, a: 1 });
});
it('parses rgba with alpha', () => {
expect(parseRgb('rgba(0, 0, 0, 0.5)')).toEqual({ r: 0, g: 0, b: 0, a: 0.5 });
});
it('parses space syntax with slash alpha', () => {
expect(parseRgb('rgb(10 20 30 / 0.4)')).toEqual({ r: 10, g: 20, b: 30, a: 0.4 });
});
it('returns null for non-rgb', () => {
expect(parseRgb('#fff')).toBeNull();
});
});
describe('parseHex', () => {
it('expands shorthand', () => {
expect(parseHex('#abc')).toEqual({ r: 0xaa, g: 0xbb, b: 0xcc, a: 1 });
});
it('parses 8-digit hex with alpha', () => {
expect(parseHex('#ff000080')).toEqual({ r: 255, g: 0, b: 0, a: 128 / 255 });
});
it('rejects bad hex', () => {
expect(parseHex('#xyz')).toBeNull();
});
});
describe('parseColor', () => {
it('handles both rgb and hex', () => {
expect(parseColor('rgb(1, 2, 3)')).toEqual({ r: 1, g: 2, b: 3, a: 1 });
expect(parseColor('#010203')).toEqual({ r: 1, g: 2, b: 3, a: 1 });
});
});
describe('rgbaToHex', () => {
it('drops alpha when opaque', () => {
expect(rgbaToHex({ r: 255, g: 0, b: 128, a: 1 })).toBe('#ff0080');
});
it('appends alpha byte when translucent', () => {
expect(rgbaToHex({ r: 0, g: 0, b: 0, a: 0.5 })).toBe('#00000080');
});
});
describe('colorKey', () => {
it('is stable and rounds alpha', () => {
expect(colorKey({ r: 1, g: 2, b: 3, a: 0.333 })).toBe('1,2,3,33');
});
});
describe('isTransparent / formatColor', () => {
it('detects fully transparent', () => {
expect(isTransparent({ r: 0, g: 0, b: 0, a: 0 })).toBe(true);
expect(isTransparent({ r: 0, g: 0, b: 0, a: 0.1 })).toBe(false);
});
it('formats opaque as hex and translucent as rgba', () => {
expect(formatColor({ r: 255, g: 255, b: 255, a: 1 })).toBe('#ffffff');
expect(formatColor({ r: 255, g: 0, b: 0, a: 0.5 })).toBe('rgba(255, 0, 0, 0.5)');
});
});
+88
View File
@@ -0,0 +1,88 @@
// Pure color helpers. Everything here is DOM-free so it can be unit-tested directly.
// Resolving named colors / CSS-variable values to rgb is done with a DOM probe elsewhere
// (see `useFrame`), which then feeds the resulting `rgb()` strings into `parseColor`.
export interface Rgba {
r: number;
g: number;
b: number;
a: number;
}
const clampByte = (n: number): number => Math.max(0, Math.min(255, Math.round(n)));
const clampAlpha = (n: number): number => Math.max(0, Math.min(1, n));
function toNumber(value: string): number {
const trimmed = value.trim();
if (trimmed.endsWith('%')) return (parseFloat(trimmed) / 100) * 255;
return parseFloat(trimmed);
}
/** Parse `rgb(...)` / `rgba(...)` in both comma and space (`r g b / a`) syntaxes. */
export function parseRgb(input: string): Rgba | null {
const match = input.trim().match(/^rgba?\(([^)]+)\)$/i);
if (!match || match[1] == null) return null;
const parts = match[1].split(/[\s,/]+/).filter(Boolean);
if (parts.length < 3) return null;
const r = toNumber(parts[0]!);
const g = toNumber(parts[1]!);
const b = toNumber(parts[2]!);
if ([r, g, b].some(Number.isNaN)) return null;
let a = 1;
if (parts[3] != null) {
a = parts[3].endsWith('%') ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]);
if (Number.isNaN(a)) a = 1;
}
return { r: clampByte(r), g: clampByte(g), b: clampByte(b), a: clampAlpha(a) };
}
/** Parse `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`. */
export function parseHex(input: string): Rgba | null {
const match = input.trim().match(/^#([0-9a-f]{3,8})$/i);
if (!match || match[1] == null) return null;
let hex = match[1];
if (hex.length === 3 || hex.length === 4) {
hex = hex
.split('')
.map((c) => c + c)
.join('');
}
if (hex.length !== 6 && hex.length !== 8) return null;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a: clampAlpha(a) };
}
/** Parse a color from the common notations browsers return from `getComputedStyle`. */
export function parseColor(input: string): Rgba | null {
return parseRgb(input) ?? parseHex(input);
}
const byteToHex = (n: number): string => clampByte(n).toString(16).padStart(2, '0');
/** Serialize to `#rrggbb`, or `#rrggbbaa` when not fully opaque. */
export function rgbaToHex({ r, g, b, a }: Rgba): string {
const base = `#${byteToHex(r)}${byteToHex(g)}${byteToHex(b)}`;
return a >= 1 ? base : base + byteToHex(a * 255);
}
/** A stable key for de-duplicating colors in a Map. */
export function colorKey(color: Rgba): string {
return `${color.r},${color.g},${color.b},${Math.round(color.a * 100)}`;
}
/** Treat fully transparent colors as "no color". */
export function isTransparent(color: Rgba): boolean {
return color.a === 0;
}
/** Human-friendly label: hex when opaque, otherwise the rgba() form. */
export function formatColor(color: Rgba): string {
if (color.a >= 1) return rgbaToHex(color);
return `rgba(${color.r}, ${color.g}, ${color.b}, ${Number(color.a.toFixed(2))})`;
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { computeBoxModel, gap, roundPx } from './rect';
describe('computeBoxModel', () => {
const borderBox = { x: 100, y: 100, width: 200, height: 80 };
const model = computeBoxModel(
borderBox,
{ top: 10, right: 10, bottom: 10, left: 10 },
{ top: 2, right: 2, bottom: 2, left: 2 },
{ top: 20, right: 20, bottom: 20, left: 20 },
);
it('expands the margin box outward', () => {
expect(model.margin).toEqual({ x: 80, y: 80, width: 240, height: 120 });
});
it('keeps the border box as given', () => {
expect(model.border).toEqual(borderBox);
});
it('insets the padding box by the border width', () => {
expect(model.padding).toEqual({ x: 102, y: 102, width: 196, height: 76 });
});
it('insets the content box by border + padding', () => {
expect(model.content).toEqual({ x: 112, y: 112, width: 176, height: 56 });
});
});
describe('gap', () => {
it('measures the horizontal gap between two separated boxes', () => {
const a = { x: 0, y: 0, width: 50, height: 50 };
const b = { x: 80, y: 0, width: 50, height: 50 };
expect(gap(a, b)).toEqual({ dx: 30, dy: 0 });
});
it('reports zero on an overlapping axis', () => {
const a = { x: 0, y: 0, width: 100, height: 100 };
const b = { x: 20, y: 20, width: 40, height: 40 };
expect(gap(a, b)).toEqual({ dx: 0, dy: 0 });
});
});
describe('roundPx', () => {
it('rounds to two decimals', () => {
expect(roundPx(12.3456)).toBe(12.35);
expect(roundPx(10)).toBe(10);
});
});
+60
View File
@@ -0,0 +1,60 @@
// Pure geometry helpers for the box-model overlay and distance measuring. DOM-free.
export interface Box {
x: number;
y: number;
width: number;
height: number;
}
export interface Edges {
top: number;
right: number;
bottom: number;
left: number;
}
export interface BoxModel {
margin: Box;
border: Box;
padding: Box;
content: Box;
}
/**
* Derive the four nested boxes of the CSS box model from the border box
* (what `getBoundingClientRect` returns) plus the computed edge widths.
*/
export function computeBoxModel(borderBox: Box, padding: Edges, border: Edges, margin: Edges): BoxModel {
const marginBox: Box = {
x: borderBox.x - margin.left,
y: borderBox.y - margin.top,
width: borderBox.width + margin.left + margin.right,
height: borderBox.height + margin.top + margin.bottom,
};
const paddingBox: Box = {
x: borderBox.x + border.left,
y: borderBox.y + border.top,
width: borderBox.width - border.left - border.right,
height: borderBox.height - border.top - border.bottom,
};
const contentBox: Box = {
x: paddingBox.x + padding.left,
y: paddingBox.y + padding.top,
width: paddingBox.width - padding.left - padding.right,
height: paddingBox.height - padding.top - padding.bottom,
};
return { margin: marginBox, border: { ...borderBox }, padding: paddingBox, content: contentBox };
}
/** Axis-aligned gap between two boxes (0 on an axis where they overlap). */
export function gap(a: Box, b: Box): { dx: number; dy: number } {
const dx = b.x > a.x + a.width ? b.x - (a.x + a.width) : a.x > b.x + b.width ? a.x - (b.x + b.width) : 0;
const dy = b.y > a.y + a.height ? b.y - (a.y + a.height) : a.y > b.y + b.height ? a.y - (b.y + b.height) : 0;
return { dx: Math.round(dx), dy: Math.round(dy) };
}
/** Round a px value to at most 2 decimals, dropping a trailing `.0`. */
export function roundPx(n: number): number {
return Math.round(n * 100) / 100;
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"jsxImportSource": "vue-jsx-vapor",
"types": ["chrome", "node", "vite/client"],
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src", "vite.config.ts", "*.d.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import VueJsxVapor from 'vue-jsx-vapor/vite';
import tailwindcss from '@tailwindcss/vite';
import webExtension from 'vite-plugin-web-extension';
// Element Inspector — Chrome MV3 extension.
// JSX is compiled to Vue Vapor (no interop); Tailwind v4 compiles the overlay styles,
// which are injected into a Shadow DOM at runtime. `webExtension` wires the manifest
// entrypoints (background + content script) into the build.
export default defineConfig({
plugins: [
VueJsxVapor(),
tailwindcss(),
webExtension({
manifest: 'manifest.json',
browser: 'chrome',
// Just build/watch into dist/ — we load it unpacked ourselves rather than
// auto-launching a browser via web-ext.
disableAutoLaunch: true,
}),
],
});