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
@@ -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(' ');
}