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:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2908
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
});
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="chrome" />
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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))})`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user