From 32ed0b45f0be46bd186ec5033d5dae7656526bda Mon Sep 17 00:00:00 2001 From: robonen Date: Fri, 5 Jun 2026 02:45:54 +0700 Subject: [PATCH] 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. --- element-inspector/.gitignore | 4 + element-inspector/README.md | 68 + element-inspector/manifest.json | 31 + element-inspector/package.json | 29 + element-inspector/pnpm-lock.yaml | 2908 +++++++++++++++++ element-inspector/src/background.ts | 21 + element-inspector/src/content/capture.ts | 108 + element-inspector/src/content/main.ts | 81 + element-inspector/src/content/picker.ts | 118 + element-inspector/src/env.d.ts | 2 + element-inspector/src/shared/messages.ts | 6 + element-inspector/src/ui/App.tsx | 27 + .../src/ui/components/ColorSwatch.tsx | 30 + .../src/ui/components/DevicePresets.tsx | 28 + .../src/ui/components/InspectorPanel.tsx | 72 + .../src/ui/components/MeasureLayer.tsx | 65 + .../src/ui/components/ResizeHandles.tsx | 53 + .../src/ui/components/Rulers.tsx | 119 + element-inspector/src/ui/components/Stage.tsx | 95 + .../src/ui/components/Toolbar.tsx | 88 + .../src/ui/composables/useFrame.ts | 197 ++ element-inspector/src/ui/mount.ts | 29 + element-inspector/src/ui/store.ts | 157 + element-inspector/src/ui/styles/style.css | 23 + element-inspector/src/utils/color.test.ts | 69 + element-inspector/src/utils/color.ts | 88 + element-inspector/src/utils/rect.test.ts | 49 + element-inspector/src/utils/rect.ts | 60 + element-inspector/tsconfig.json | 11 + element-inspector/vite.config.ts | 22 + 30 files changed, 4658 insertions(+) create mode 100644 element-inspector/.gitignore create mode 100644 element-inspector/README.md create mode 100644 element-inspector/manifest.json create mode 100644 element-inspector/package.json create mode 100644 element-inspector/pnpm-lock.yaml create mode 100644 element-inspector/src/background.ts create mode 100644 element-inspector/src/content/capture.ts create mode 100644 element-inspector/src/content/main.ts create mode 100644 element-inspector/src/content/picker.ts create mode 100644 element-inspector/src/env.d.ts create mode 100644 element-inspector/src/shared/messages.ts create mode 100644 element-inspector/src/ui/App.tsx create mode 100644 element-inspector/src/ui/components/ColorSwatch.tsx create mode 100644 element-inspector/src/ui/components/DevicePresets.tsx create mode 100644 element-inspector/src/ui/components/InspectorPanel.tsx create mode 100644 element-inspector/src/ui/components/MeasureLayer.tsx create mode 100644 element-inspector/src/ui/components/ResizeHandles.tsx create mode 100644 element-inspector/src/ui/components/Rulers.tsx create mode 100644 element-inspector/src/ui/components/Stage.tsx create mode 100644 element-inspector/src/ui/components/Toolbar.tsx create mode 100644 element-inspector/src/ui/composables/useFrame.ts create mode 100644 element-inspector/src/ui/mount.ts create mode 100644 element-inspector/src/ui/store.ts create mode 100644 element-inspector/src/ui/styles/style.css create mode 100644 element-inspector/src/utils/color.test.ts create mode 100644 element-inspector/src/utils/color.ts create mode 100644 element-inspector/src/utils/rect.test.ts create mode 100644 element-inspector/src/utils/rect.ts create mode 100644 element-inspector/tsconfig.json create mode 100644 element-inspector/vite.config.ts diff --git a/element-inspector/.gitignore b/element-inspector/.gitignore new file mode 100644 index 0000000..7535211 --- /dev/null +++ b/element-inspector/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.log +.DS_Store diff --git a/element-inspector/README.md b/element-inspector/README.md new file mode 100644 index 0000000..a283527 --- /dev/null +++ b/element-inspector/README.md @@ -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 + ` + + +${buildAncestorChain(el)} + +`; + + 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 ? `` : ''; +} + +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}`; + 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, '>'); +} diff --git a/element-inspector/src/content/main.ts b/element-inspector/src/content/main.ts new file mode 100644 index 0000000..3a3634d --- /dev/null +++ b/element-inspector/src/content/main.ts @@ -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; + } +} diff --git a/element-inspector/src/content/picker.ts b/element-inspector/src/content/picker.ts new file mode 100644 index 0000000..b70acc5 --- /dev/null +++ b/element-inspector/src/content/picker.ts @@ -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)}`; +} diff --git a/element-inspector/src/env.d.ts b/element-inspector/src/env.d.ts new file mode 100644 index 0000000..f4fcd31 --- /dev/null +++ b/element-inspector/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/element-inspector/src/shared/messages.ts b/element-inspector/src/shared/messages.ts new file mode 100644 index 0000000..a013f56 --- /dev/null +++ b/element-inspector/src/shared/messages.ts @@ -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; +} diff --git a/element-inspector/src/ui/App.tsx b/element-inspector/src/ui/App.tsx new file mode 100644 index 0000000..a07d657 --- /dev/null +++ b/element-inspector/src/ui/App.tsx @@ -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 ( +
+ +
+ + +
+
+ ); +} diff --git a/element-inspector/src/ui/components/ColorSwatch.tsx b/element-inspector/src/ui/components/ColorSwatch.tsx new file mode 100644 index 0000000..cc8539e --- /dev/null +++ b/element-inspector/src/ui/components/ColorSwatch.tsx @@ -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 ( + + ); +} diff --git a/element-inspector/src/ui/components/DevicePresets.tsx b/element-inspector/src/ui/components/DevicePresets.tsx new file mode 100644 index 0000000..8c945ce --- /dev/null +++ b/element-inspector/src/ui/components/DevicePresets.tsx @@ -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 ( +
+ {DEVICE_PRESETS.map((preset) => { + const active = state.frameWidth === preset.width && state.frameHeight === preset.height; + return ( + + ); + })} +
+ ); +} diff --git a/element-inspector/src/ui/components/InspectorPanel.tsx b/element-inspector/src/ui/components/InspectorPanel.tsx new file mode 100644 index 0000000..db611f1 --- /dev/null +++ b/element-inspector/src/ui/components/InspectorPanel.tsx @@ -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 ( + + ); +} + +function Heading(props: { text: string }) { + return

{props.text}

; +} + +function Row(props: { label: string; value: string }) { + return ( +
+ {props.label} + {props.value} +
+ ); +} diff --git a/element-inspector/src/ui/components/MeasureLayer.tsx b/element-inspector/src/ui/components/MeasureLayer.tsx new file mode 100644 index 0000000..794f192 --- /dev/null +++ b/element-inspector/src/ui/components/MeasureLayer.tsx @@ -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 ( +
+ {state.hover ? ( + <> +
+
+
+
+ + + ) : null} + + {state.selected ? ( +
+ ) : null} + + {state.guides.x.map((gx) => ( +
+ {gx} +
+ ))} + {state.guides.y.map((gy) => ( +
+ {gy} +
+ ))} +
+ ); +} + +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 ( +
+ {props.text} +
+ ); +} diff --git a/element-inspector/src/ui/components/ResizeHandles.tsx b/element-inspector/src/ui/components/ResizeHandles.tsx new file mode 100644 index 0000000..2e06c49 --- /dev/null +++ b/element-inspector/src/ui/components/ResizeHandles.tsx @@ -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 ( +
+
startDrag(e, 'r')} + /> +
startDrag(e, 'b')} + /> +
startDrag(e, 'rb')} + /> +
+ ); +} diff --git a/element-inspector/src/ui/components/Rulers.tsx b/element-inspector/src/ui/components/Rulers.tsx new file mode 100644 index 0000000..b3e65c5 --- /dev/null +++ b/element-inspector/src/ui/components/Rulers.tsx @@ -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 . Clicking a ruler drops a guide at that position. +export default function Rulers() { + const topCanvas = ref(); + const leftCanvas = ref(); + + 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 ( + <> + onTopClick(e.nativeEvent)} + class="absolute left-0 top-0 cursor-crosshair" + style={{ height: `${SIZE}px` }} + /> + onLeftClick(e.nativeEvent)} + class="absolute left-0 top-0 cursor-crosshair" + style={{ width: `${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(); + } + } + } +} diff --git a/element-inspector/src/ui/components/Stage.tsx b/element-inspector/src/ui/components/Stage.tsx new file mode 100644 index 0000000..da71633 --- /dev/null +++ b/element-inspector/src/ui/components/Stage.tsx @@ -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(); + const frame = ref(); + 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 ( +
onWheel(e.nativeEvent)} + onPointerdown={onPointerdown} + onPointermove={onPointermove} + onPointerup={onPointerup} + > +
+