diff --git a/element-inspector/README.md b/element-inspector/README.md
index a283527..5aa6594 100644
--- a/element-inspector/README.md
+++ b/element-inspector/README.md
@@ -47,8 +47,14 @@ 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
+pnpm icons # regenerate the extension icons (PNG sizes + SVG source)
```
+The toolbar icon sizes are generated from the source artwork `src/assets/logo.png` by
+[`scripts/generate-icons.mjs`](scripts/generate-icons.mjs) (pure Node — decodes the PNG and
+area-downsamples it). They are written to `public/icons/`, which Vite copies into `dist/` where
+the manifest references them. To change the logo, replace `src/assets/logo.png` and run `pnpm icons`.
+
Then load it in Chrome:
1. Open `chrome://extensions`, enable **Developer mode**.
diff --git a/element-inspector/manifest.json b/element-inspector/manifest.json
index fff6f4d..4df8c21 100644
--- a/element-inspector/manifest.json
+++ b/element-inspector/manifest.json
@@ -1,10 +1,22 @@
{
"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.",
+ "version": "1.0.0",
+ "description": "Isolate any page element on a clean canvas: box model, colors, inherited styles, grid/flex layout and responsive testing.",
+ "icons": {
+ "16": "icons/icon-16.png",
+ "32": "icons/icon-32.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ },
"action": {
- "default_title": "Inspect element (Alt+Shift+E)"
+ "default_title": "Inspect element (Alt+Shift+E)",
+ "default_icon": {
+ "16": "icons/icon-16.png",
+ "32": "icons/icon-32.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ }
},
"background": {
"service_worker": "src/background.ts",
diff --git a/element-inspector/package.json b/element-inspector/package.json
index 21a8af2..e2ba50e 100644
--- a/element-inspector/package.json
+++ b/element-inspector/package.json
@@ -1,15 +1,16 @@
{
"name": "element-inspector",
"private": true,
- "version": "0.0.0",
+ "version": "1.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.",
+ "description": "Browser extension to isolate any page element on a clean canvas and inspect its box model, colors, inherited styles, grid/flex layout and responsive behavior.",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run",
- "test:watch": "vitest"
+ "test:watch": "vitest",
+ "icons": "node scripts/generate-icons.mjs"
},
"dependencies": {
"vue": "3.6.0-beta.13"
@@ -21,7 +22,7 @@
"@vue/tsconfig": "^0.9.1",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.3",
- "vite": "^8.0.13",
+ "vite": "^8.0.16",
"vite-plugin-web-extension": "^4.5.1",
"vitest": "^4.1.8",
"vue-jsx-vapor": "^3.2.14"
diff --git a/element-inspector/pnpm-lock.yaml b/element-inspector/pnpm-lock.yaml
index 6f6aece..8cc4592 100644
--- a/element-inspector/pnpm-lock.yaml
+++ b/element-inspector/pnpm-lock.yaml
@@ -31,7 +31,7 @@ importers:
specifier: ~6.0.3
version: 6.0.3
vite:
- specifier: ^8.0.13
+ specifier: ^8.0.16
version: 8.0.16(@types/node@25.9.1)(jiti@2.7.0)(yaml@2.9.0)
vite-plugin-web-extension:
specifier: ^4.5.1
@@ -164,36 +164,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.3':
resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.3':
resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.3':
resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.3':
resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.3':
resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
@@ -262,24 +268,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
@@ -407,21 +417,25 @@ packages:
resolution: {integrity: sha512-nQ5+lm7sCscWlh7FpAZyatHChfhyUjPtqlpXrUq/zGilHq57Ls4OLxoI6krPPTUpxMR6GaqhJjozgoniy1CzZQ==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@vue-jsx-vapor/compiler-rs-linux-arm64-musl@3.2.14':
resolution: {integrity: sha512-SCjl5dlnH7HKuaCdwvJZ/Z1L7AYw+R6Bheruvf2HDfyzabNMn4ovW2LLZ3rSBrzciTlkfH34RDJBqHr/GV8Q2A==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@vue-jsx-vapor/compiler-rs-linux-x64-gnu@3.2.14':
resolution: {integrity: sha512-mD8Ooy11xGucEf/RClUNBP9iQyQKw5kLCCQZ3+dX/Q+V12OSYkrt/4Bz1quCeoink0uIz7FxpQay1/o2qbFo4Q==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@vue-jsx-vapor/compiler-rs-linux-x64-musl@3.2.14':
resolution: {integrity: sha512-hoeRrCtJTMHAQCMYn3NZbiV69efaX/0PHPg54SmWgCXxkXvl05AKHli9QpOERWmG4DYRo8APLCs9FvLe7Lyp9g==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@vue-jsx-vapor/compiler-rs-wasm32-wasi@3.2.14':
resolution: {integrity: sha512-m17CyZXe0t72xc3EwSMIRavxeW9ZnOIOjMzRvWLxnUHZr4cH5nrrG/QhO4wul1gqpMWQrQkQMdUKclALNPFt4A==}
@@ -981,24 +995,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -1072,9 +1090,8 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
- obug@2.1.2:
- resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==}
- engines: {node: '>=12.20.0'}
+ obug@2.1.1:
+ resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
@@ -1178,8 +1195,8 @@ packages:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
- semver@7.8.2:
- resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==}
+ semver@7.8.1:
+ resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
engines: {node: '>=10'}
hasBin: true
@@ -2442,7 +2459,7 @@ snapshots:
dependencies:
growly: 1.3.0
is-wsl: 2.2.0
- semver: 7.8.2
+ semver: 7.8.1
shellwords: 0.1.1
uuid: 8.3.2
which: 2.0.2
@@ -2451,7 +2468,7 @@ snapshots:
dependencies:
boolbase: 1.0.0
- obug@2.1.2: {}
+ obug@2.1.1: {}
on-exit-leak-free@2.1.2: {}
@@ -2462,7 +2479,7 @@ snapshots:
ky: 1.14.3
registry-auth-token: 5.1.1
registry-url: 6.0.1
- semver: 7.8.2
+ semver: 7.8.1
pako@1.0.11: {}
@@ -2578,7 +2595,7 @@ snapshots:
sax@1.6.0: {}
- semver@7.8.2: {}
+ semver@7.8.1: {}
set-value@4.1.0:
dependencies:
@@ -2722,7 +2739,7 @@ snapshots:
is-npm: 6.1.0
latest-version: 9.0.0
pupa: 3.3.0
- semver: 7.8.2
+ semver: 7.8.1
xdg-basedir: 5.1.0
util-deprecate@1.0.2: {}
@@ -2783,7 +2800,7 @@ snapshots:
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
- obug: 2.1.2
+ obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
diff --git a/element-inspector/pnpm-workspace.yaml b/element-inspector/pnpm-workspace.yaml
new file mode 100644
index 0000000..d20de61
--- /dev/null
+++ b/element-inspector/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+allowBuilds:
+ spawn-sync: true
diff --git a/element-inspector/public/icons/icon-128.png b/element-inspector/public/icons/icon-128.png
new file mode 100644
index 0000000..dcdbfdd
Binary files /dev/null and b/element-inspector/public/icons/icon-128.png differ
diff --git a/element-inspector/public/icons/icon-16.png b/element-inspector/public/icons/icon-16.png
new file mode 100644
index 0000000..5ccdb1a
Binary files /dev/null and b/element-inspector/public/icons/icon-16.png differ
diff --git a/element-inspector/public/icons/icon-32.png b/element-inspector/public/icons/icon-32.png
new file mode 100644
index 0000000..6dfeb9c
Binary files /dev/null and b/element-inspector/public/icons/icon-32.png differ
diff --git a/element-inspector/public/icons/icon-48.png b/element-inspector/public/icons/icon-48.png
new file mode 100644
index 0000000..ccc7a07
Binary files /dev/null and b/element-inspector/public/icons/icon-48.png differ
diff --git a/element-inspector/src/assets/logo.png b/element-inspector/src/assets/logo.png
new file mode 100644
index 0000000..c6b141e
Binary files /dev/null and b/element-inspector/src/assets/logo.png differ
diff --git a/element-inspector/src/content/main.ts b/element-inspector/src/content/main.ts
index 3a3634d..c2a8f4f 100644
--- a/element-inspector/src/content/main.ts
+++ b/element-inspector/src/content/main.ts
@@ -64,14 +64,25 @@ function ensureHost(): ShadowRoot {
function deactivate(): void {
if (mode === 'idle') return;
mode = 'idle';
+ // Each teardown step is isolated so a throw in one (e.g. during Vapor unmount) can't
+ // strand the overlay on the page — the host is always removed.
if (picker) {
const active = picker;
picker = null;
- active.cancel();
+ try {
+ active.cancel();
+ } catch {
+ /* picker already torn down */
+ }
}
if (canvas) {
- canvas.unmount();
+ const active = canvas;
canvas = null;
+ try {
+ active.unmount();
+ } catch {
+ /* app already unmounted */
+ }
}
if (host) {
host.remove();
diff --git a/element-inspector/src/ui/App.tsx b/element-inspector/src/ui/App.tsx
index a07d657..cb7e352 100644
--- a/element-inspector/src/ui/App.tsx
+++ b/element-inspector/src/ui/App.tsx
@@ -16,7 +16,7 @@ export default function App() {
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
index cc8539e..a77f7d8 100644
--- a/element-inspector/src/ui/components/ColorSwatch.tsx
+++ b/element-inspector/src/ui/components/ColorSwatch.tsx
@@ -1,30 +1,38 @@
import type { ColorSwatch as Swatch } from '../store';
+import { useClipboard } from '../composables';
// 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 */
- });
+ const { copy, copied } = useClipboard();
+ const onCopy = (): void => {
+ void copy(props.swatch.varName ? `var(${props.swatch.varName})` : props.swatch.hex);
};
return (
);
}
diff --git a/element-inspector/src/ui/components/GuidesLayer.tsx b/element-inspector/src/ui/components/GuidesLayer.tsx
new file mode 100644
index 0000000..e04a2ab
--- /dev/null
+++ b/element-inspector/src/ui/components/GuidesLayer.tsx
@@ -0,0 +1,73 @@
+import { shallowRef } from 'vue';
+import { removeGuide, state, updateGuide } from '../store';
+
+// The ruler band size (must match Rulers' SIZE). Dropping a guide back over its ruler removes it.
+const RULER = 20;
+
+// Interactive guides drawn over the canvas. Each guide is a thin draggable strip: drag to
+// reposition, drag back onto its ruler (or double-click) to remove. New guides are pulled out
+// of the rulers (see Rulers.tsx). Positions are stored in iframe-content pixels and mapped to
+// the viewport via the current pan/zoom.
+export default function GuidesLayer() {
+ const layer = shallowRef
();
+
+ const startMove = (axis: 'x' | 'y', index: number): void => {
+ const rect = layer.value?.getBoundingClientRect();
+ if (!rect) return;
+ const move = (ev: PointerEvent): void => {
+ const pos =
+ axis === 'x'
+ ? (ev.clientX - rect.left - state.panX) / state.zoom
+ : (ev.clientY - rect.top - state.panY) / state.zoom;
+ updateGuide(axis, index, pos);
+ };
+ const up = (ev: PointerEvent): void => {
+ window.removeEventListener('pointermove', move, true);
+ window.removeEventListener('pointerup', up, true);
+ // Released over the originating ruler band → remove the guide.
+ const overRuler = axis === 'x' ? ev.clientY - rect.top < RULER : ev.clientX - rect.left < RULER;
+ if (overRuler) removeGuide(axis, index);
+ };
+ window.addEventListener('pointermove', move, true);
+ window.addEventListener('pointerup', up, true);
+ };
+
+ const begin = (e: { preventDefault(): void; stopPropagation(): void }, axis: 'x' | 'y', index: number): void => {
+ e.preventDefault();
+ e.stopPropagation();
+ startMove(axis, index);
+ };
+
+ return (
+
+ {state.guides.x.map((gx, i) => (
+
begin(e, 'x', i)}
+ onDblclick={() => removeGuide('x', i)}
+ >
+
+
+ {Math.round(gx)}
+
+
+ ))}
+ {state.guides.y.map((gy, i) => (
+
begin(e, 'y', i)}
+ onDblclick={() => removeGuide('y', i)}
+ >
+
+
+ {Math.round(gy)}
+
+
+ ))}
+
+ );
+}
diff --git a/element-inspector/src/ui/components/InspectorPanel.tsx b/element-inspector/src/ui/components/InspectorPanel.tsx
index db611f1..0ab0825 100644
--- a/element-inspector/src/ui/components/InspectorPanel.tsx
+++ b/element-inspector/src/ui/components/InspectorPanel.tsx
@@ -1,14 +1,19 @@
import { computed } from 'vue';
import { state } from '../store';
+import type { LayoutHighlight, StyleItem } 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).
+// dimensions, layout (with hover-to-highlight on the canvas), spacing, typography, effects and
+// colors — inherited values are flagged, and colors resolve to CSS variables when possible.
export default function InspectorPanel() {
const info = computed(() => state.hover ?? state.selected);
+ const highlightFor = (label: string): LayoutHighlight =>
+ label === 'Gap' ? 'gap' : label === 'Columns' || label === 'Rows' ? 'tracks' : 'none';
+
return (
-
);
}
diff --git a/element-inspector/src/ui/components/ResizeHandles.tsx b/element-inspector/src/ui/components/ResizeHandles.tsx
index 2e06c49..bf3fa62 100644
--- a/element-inspector/src/ui/components/ResizeHandles.tsx
+++ b/element-inspector/src/ui/components/ResizeHandles.tsx
@@ -1,53 +1,62 @@
+import { computed, shallowRef } from 'vue';
+import type { ShallowRef } from 'vue';
import { setFrameSize, state } from '../store';
+import { usePointerDrag } from '../composables';
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.
+// which re-fires the page's media queries inside the iframe. Positioned in viewport space —
+// every coordinate reads reactive store state so the handles track pan/zoom/size live.
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 rightHandle = shallowRef
();
+ const bottomHandle = shallowRef();
+ const cornerHandle = shallowRef();
- 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 = computed(() => state.panX + state.frameWidth * state.zoom);
+ const bottom = computed(() => state.panY + state.frameHeight * state.zoom);
+ const midX = computed(() => state.panX + (state.frameWidth * state.zoom) / 2);
+ const midY = computed(() => state.panY + (state.frameHeight * state.zoom) / 2);
- 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;
+ bindResize(rightHandle, 'r');
+ bindResize(bottomHandle, 'b');
+ bindResize(cornerHandle, 'rb');
return (
startDrag(e, 'r')}
+ ref={rightHandle}
+ class="pointer-events-auto absolute h-7 w-1.5 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize rounded-full bg-sky-500/80 hover:bg-sky-400"
+ style={{ left: `${right.value}px`, top: `${midY.value}px` }}
/>
startDrag(e, 'b')}
+ ref={bottomHandle}
+ class="pointer-events-auto absolute h-1.5 w-7 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize rounded-full bg-sky-500/80 hover:bg-sky-400"
+ style={{ left: `${midX.value}px`, top: `${bottom.value}px` }}
/>
startDrag(e, 'rb')}
+ style={{ left: `${right.value}px`, top: `${bottom.value}px` }}
/>
);
}
+
+function bindResize(handle: ShallowRef
, mode: Mode): void {
+ let startW = 0;
+ let startH = 0;
+ usePointerDrag(handle, {
+ onStart: (e) => {
+ e.preventDefault();
+ startW = state.frameWidth;
+ startH = state.frameHeight;
+ },
+ onMove: ({ dx, dy }) => {
+ const width = mode === 'b' ? startW : startW + dx / state.zoom;
+ const height = mode === 'r' ? startH : startH + dy / state.zoom;
+ setFrameSize(width, height);
+ },
+ pointerCapture: true,
+ });
+}
diff --git a/element-inspector/src/ui/components/Rulers.tsx b/element-inspector/src/ui/components/Rulers.tsx
index b3e65c5..79e9c9a 100644
--- a/element-inspector/src/ui/components/Rulers.tsx
+++ b/element-inspector/src/ui/components/Rulers.tsx
@@ -1,53 +1,62 @@
-import { onBeforeUnmount, onMounted, ref, watchEffect } from 'vue';
-import { addGuide, state } from '../store';
+import { shallowRef, watchEffect } from 'vue';
+import { addGuide, state, updateGuide } from '../store';
+import { useDevicePixelRatio, useEventListener } from '../composables';
const SIZE = 20;
const STEPS = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000];
-// Zoom/pan-aware rulers drawn on
diff --git a/element-inspector/src/ui/components/Toolbar.tsx b/element-inspector/src/ui/components/Toolbar.tsx
index b7b6d8b..0fb9c17 100644
--- a/element-inspector/src/ui/components/Toolbar.tsx
+++ b/element-inspector/src/ui/components/Toolbar.tsx
@@ -39,15 +39,15 @@ export default function Toolbar() {
class="w-16 rounded bg-white/5 px-1.5 py-1 text-center outline-none focus:bg-white/10"
/>
-
{ rotateFrame(); recenter(); }} />
+ { rotateFrame(); recenter(); }} />
-
setZoom(state.zoom - 0.1)} />
+ setZoom(state.zoom - 0.1)} />
{Math.round(state.zoom * 100)}%
- setZoom(state.zoom + 0.1)} />
+ setZoom(state.zoom + 0.1)} />
{
+ action={() => {
resetSize();
recenter();
}}
@@ -55,8 +55,19 @@ export default function Toolbar() {
- (state.showRulers = !state.showRulers)} />
-
+ (state.showRulers = !state.showRulers)} />
+ (state.showGrid = !state.showGrid)} />
+ (state.clicksEnabled = !state.clicksEnabled)}
+ />
+
@@ -72,11 +83,16 @@ export default function Toolbar() {
);
}
-function ToolButton(props: { label: string; active?: boolean; onClick: () => void }) {
+// NOTE: the callback prop is `action`, not `onClick`. An `on*`-named prop on a *component* is
+// treated by Vue as an event-listener binding (it never lands in `props`), so a function-
+// component would read `props.onClick` as `undefined`. A plain prop name is passed through
+// normally; we then bind it to the inner DOM button's real `onClick`.
+function ToolButton(props: { label: string; active?: boolean; title?: string; action: () => void }) {
return (