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
+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: [] };
}