feat: add vite-layers
This commit is contained in:
@@ -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**.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+29
-12
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
spawn-sync: true
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 635 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -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();
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function App() {
|
||||
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">
|
||||
<div class="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 />
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
onClick={onCopy}
|
||||
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="flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
|
||||
{props.swatch.label}
|
||||
{props.swatch.inherited ? (
|
||||
<span title="Inherited from an ancestor" class="rounded-sm bg-amber-500/15 px-1 text-[9px] normal-case text-amber-300">
|
||||
inh
|
||||
</span>
|
||||
) : null}
|
||||
</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}
|
||||
<span class="shrink-0 font-mono text-[10px] text-slate-500">
|
||||
{copied.value ? 'copied' : props.swatch.varName ? props.swatch.hex : ''}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>();
|
||||
|
||||
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 (
|
||||
<div ref={layer} class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{state.guides.x.map((gx, i) => (
|
||||
<div
|
||||
key={`x${i}`}
|
||||
class="pointer-events-auto absolute bottom-0 top-0 -ml-1 w-2 cursor-ew-resize"
|
||||
style={{ left: `${state.panX + gx * state.zoom}px` }}
|
||||
onPointerdown={(e) => begin(e, 'x', i)}
|
||||
onDblclick={() => removeGuide('x', i)}
|
||||
>
|
||||
<div class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-sky-400/80" />
|
||||
<span class="absolute left-1.5 top-1 rounded bg-sky-500 px-1 text-[10px] font-medium text-white">
|
||||
{Math.round(gx)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{state.guides.y.map((gy, i) => (
|
||||
<div
|
||||
key={`y${i}`}
|
||||
class="pointer-events-auto absolute left-0 right-0 -mt-1 h-2 cursor-ns-resize"
|
||||
style={{ top: `${state.panY + gy * state.zoom}px` }}
|
||||
onPointerdown={(e) => begin(e, 'y', i)}
|
||||
onDblclick={() => removeGuide('y', i)}
|
||||
>
|
||||
<div class="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-sky-400/80" />
|
||||
<span class="absolute left-1 top-1.5 rounded bg-sky-500 px-1 text-[10px] font-medium text-white">
|
||||
{Math.round(gy)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<aside class="flex w-72 shrink-0 flex-col gap-4 overflow-y-auto border-l border-white/10 bg-[#0e131c] p-4">
|
||||
<aside class="flex w-72 shrink-0 flex-col gap-4 overflow-y-auto border-l border-white/10 bg-[#0e131c] p-4 [&::-webkit-scrollbar-thumb]:rounded-md [&::-webkit-scrollbar-thumb]:bg-slate-400/25 [&::-webkit-scrollbar]:h-2.5 [&::-webkit-scrollbar]:w-2.5">
|
||||
{info.value ? (
|
||||
<>
|
||||
<div>
|
||||
@@ -27,6 +32,44 @@ export default function InspectorPanel() {
|
||||
<Row label="Height" value={`${info.value.height}px`} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Heading text="Layout" />
|
||||
<div class="flex items-baseline justify-between gap-2 py-0.5">
|
||||
<span class="text-[11px] text-slate-400">Display</span>
|
||||
<span
|
||||
class={[
|
||||
'ml-2 truncate rounded px-1.5 font-mono text-[11px]',
|
||||
info.value.layout.kind === 'flex'
|
||||
? 'bg-violet-500/20 text-violet-300'
|
||||
: info.value.layout.kind === 'grid'
|
||||
? 'bg-emerald-500/20 text-emerald-300'
|
||||
: 'bg-white/5 text-slate-200',
|
||||
]}
|
||||
>
|
||||
{info.value.layout.display}
|
||||
</span>
|
||||
</div>
|
||||
{info.value.layout.props.map((prop) => (
|
||||
<div
|
||||
key={prop.label}
|
||||
class="-mx-1 rounded px-1 hover:bg-white/5"
|
||||
onMouseenter={() => (state.layoutHighlight = highlightFor(prop.label))}
|
||||
onMouseleave={() => (state.layoutHighlight = 'none')}
|
||||
>
|
||||
<Row label={prop.label} value={prop.value} />
|
||||
</div>
|
||||
))}
|
||||
{info.value.layout.items.length ? (
|
||||
<div
|
||||
class="-mx-1 rounded px-1 hover:bg-white/5"
|
||||
onMouseenter={() => (state.layoutHighlight = 'items')}
|
||||
onMouseleave={() => (state.layoutHighlight = 'none')}
|
||||
>
|
||||
<Row label="Items" value={String(info.value.layout.items.length)} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Heading text="Spacing" />
|
||||
<Row label="Padding" value={info.value.padding} />
|
||||
@@ -36,12 +79,20 @@ export default function InspectorPanel() {
|
||||
|
||||
<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} />
|
||||
{info.value.typography.map((item) => (
|
||||
<StyleRow key={item.label} item={item} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
{info.value.effects.length ? (
|
||||
<section>
|
||||
<Heading text="Effects" />
|
||||
{info.value.effects.map((item) => (
|
||||
<StyleRow key={item.label} item={item} />
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{info.value.colors.length ? (
|
||||
<section>
|
||||
<Heading text="Colors" />
|
||||
@@ -70,3 +121,20 @@ function Row(props: { label: string; value: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A computed-style row that flags inherited values with an `inh` badge.
|
||||
function StyleRow(props: { item: StyleItem }) {
|
||||
return (
|
||||
<div class="flex items-baseline justify-between gap-2 py-0.5">
|
||||
<span class="flex shrink-0 items-center gap-1 text-[11px] text-slate-400">
|
||||
{props.item.label}
|
||||
{props.item.inherited ? (
|
||||
<span title="Inherited from an ancestor" class="rounded-sm bg-amber-500/15 px-1 text-[9px] text-amber-300">
|
||||
inh
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span class="ml-2 truncate font-mono text-[11px] text-slate-200">{props.item.value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { computed } from 'vue';
|
||||
import { state } from '../store';
|
||||
|
||||
// Visualizes the selected element's grid/flex structure on the canvas: track lines with line
|
||||
// numbers, shaded gaps and item outlines. Lives in viewport space and converts iframe-content
|
||||
// pixels via the current pan/zoom (same mapping as the measurement layer). Hovering the Layout
|
||||
// rows in the panel sets `state.layoutHighlight` to emphasize gaps / tracks / items.
|
||||
export default function LayoutOverlay() {
|
||||
const sx = (x: number): number => state.panX + x * state.zoom;
|
||||
const sy = (y: number): number => state.panY + y * state.zoom;
|
||||
const sl = (len: number): number => len * state.zoom;
|
||||
const last = (a: number[] | undefined): number => (a && a.length ? a[a.length - 1]! : 0);
|
||||
|
||||
// Flatten everything to plain, always-present arrays so the template maps without juggling
|
||||
// nullable getters (which neither Vapor's compiler nor TS narrowing handle gracefully).
|
||||
const decor = computed(() => {
|
||||
const layout = state.showGrid && state.selected?.layout.kind !== 'block' ? state.selected?.layout : null;
|
||||
const grid = layout?.kind === 'grid';
|
||||
return {
|
||||
gapTone: grid ? 'rgba(236,72,153,0.30)' : 'rgba(168,85,247,0.30)',
|
||||
gaps: layout?.gaps ?? [],
|
||||
items: layout?.items ?? [],
|
||||
vlines: (layout?.gridLines?.xs ?? []).map((x, i) => ({ x, n: i + 1 })),
|
||||
hlines: (layout?.gridLines?.ys ?? []).map((y, i) => ({ y, n: i + 1 })),
|
||||
x0: layout?.gridLines?.xs[0] ?? 0,
|
||||
x1: last(layout?.gridLines?.xs),
|
||||
y0: layout?.gridLines?.ys[0] ?? 0,
|
||||
y1: last(layout?.gridLines?.ys),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{decor.value.gaps.map((g, i) => (
|
||||
<div
|
||||
key={`gap${i}`}
|
||||
class="absolute"
|
||||
style={{
|
||||
left: `${sx(g.x)}px`,
|
||||
top: `${sy(g.y)}px`,
|
||||
width: `${sl(g.width)}px`,
|
||||
height: `${sl(g.height)}px`,
|
||||
background: decor.value.gapTone,
|
||||
outline: state.layoutHighlight === 'gap' ? '1px solid rgba(236,72,153,0.9)' : 'none',
|
||||
opacity: state.layoutHighlight === 'gap' ? 1 : 0.7,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{decor.value.items.map((it, i) => (
|
||||
<div
|
||||
key={`item${i}`}
|
||||
class="absolute"
|
||||
style={{
|
||||
left: `${sx(it.x)}px`,
|
||||
top: `${sy(it.y)}px`,
|
||||
width: `${sl(it.width)}px`,
|
||||
height: `${sl(it.height)}px`,
|
||||
outline: `1px dashed rgba(56,189,248,${state.layoutHighlight === 'items' ? 0.95 : 0.5})`,
|
||||
outlineOffset: '-1px',
|
||||
background: state.layoutHighlight === 'items' ? 'rgba(56,189,248,0.10)' : 'transparent',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{decor.value.vlines.map((l) => (
|
||||
<div
|
||||
key={`vx${l.n}`}
|
||||
class="absolute"
|
||||
style={{
|
||||
left: `${sx(l.x)}px`,
|
||||
top: `${sy(decor.value.y0)}px`,
|
||||
width: '1px',
|
||||
height: `${sl(decor.value.y1 - decor.value.y0)}px`,
|
||||
background: state.layoutHighlight === 'tracks' ? 'rgba(56,189,248,0.95)' : 'rgba(56,189,248,0.55)',
|
||||
}}
|
||||
>
|
||||
<span class="absolute -top-4 left-0 rounded-sm bg-sky-500 px-1 text-[9px] font-medium text-white">{l.n}</span>
|
||||
</div>
|
||||
))}
|
||||
{decor.value.hlines.map((l) => (
|
||||
<div
|
||||
key={`hy${l.n}`}
|
||||
class="absolute"
|
||||
style={{
|
||||
left: `${sx(decor.value.x0)}px`,
|
||||
top: `${sy(l.y)}px`,
|
||||
width: `${sl(decor.value.x1 - decor.value.x0)}px`,
|
||||
height: '1px',
|
||||
background: state.layoutHighlight === 'tracks' ? 'rgba(56,189,248,0.95)' : 'rgba(56,189,248,0.55)',
|
||||
}}
|
||||
>
|
||||
<span class="absolute -left-4 top-0 -translate-y-1/2 rounded-sm bg-sky-500 px-1 text-[9px] font-medium text-white">
|
||||
{l.n}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
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.
|
||||
// Draws the box-model overlay for the hovered element and a persistent outline for the
|
||||
// selected element. Lives in viewport space (constant-size badges) and converts iframe-pixel
|
||||
// coordinates to screen coordinates via the current pan/zoom. (Guides live in GuidesLayer.)
|
||||
export default function MeasureLayer() {
|
||||
const boxStyle = (b: Box) => ({
|
||||
position: 'absolute' as const,
|
||||
@@ -28,25 +28,6 @@ export default function MeasureLayer() {
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>();
|
||||
const bottomHandle = shallowRef<HTMLDivElement>();
|
||||
const cornerHandle = shallowRef<HTMLDivElement>();
|
||||
|
||||
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 (
|
||||
<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')}
|
||||
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` }}
|
||||
/>
|
||||
<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')}
|
||||
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` }}
|
||||
/>
|
||||
<div
|
||||
ref={cornerHandle}
|
||||
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')}
|
||||
style={{ left: `${right.value}px`, top: `${bottom.value}px` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function bindResize(handle: ShallowRef<HTMLDivElement | undefined>, 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 <canvas>. Clicking a ruler drops a guide at that position.
|
||||
// Zoom/pan-aware rulers drawn on <canvas>. Press a ruler and drag to pull out a guide (it
|
||||
// follows the cursor); the guide can then be moved/removed via GuidesLayer. A plain click drops
|
||||
// a guide at that point. Geometry is driven entirely by reactive store state (zoom/pan/viewport)
|
||||
// so the rulers redraw whenever the canvas is panned, zoomed or resized.
|
||||
export default function Rulers() {
|
||||
const topCanvas = ref<HTMLCanvasElement>();
|
||||
const leftCanvas = ref<HTMLCanvasElement>();
|
||||
const topCanvas = shallowRef<HTMLCanvasElement>();
|
||||
const leftCanvas = shallowRef<HTMLCanvasElement>();
|
||||
const { pixelRatio } = useDevicePixelRatio();
|
||||
|
||||
const draw = (): void => {
|
||||
if (topCanvas.value) drawAxis(topCanvas.value, 'x');
|
||||
if (leftCanvas.value) drawAxis(leftCanvas.value, 'y');
|
||||
// `flush: 'post'` so template refs are populated before the first draw.
|
||||
watchEffect(
|
||||
() => {
|
||||
const dpr = pixelRatio.value;
|
||||
const { zoom, panX, panY, viewportW, viewportH } = state;
|
||||
if (topCanvas.value && viewportW > 0) drawAxis(topCanvas.value, 'x', viewportW, zoom, panX, dpr);
|
||||
if (leftCanvas.value && viewportH > 0) drawAxis(leftCanvas.value, 'y', viewportH, zoom, panY, dpr);
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
// Pull-out gesture: press a ruler to create a guide, then drag to position it. Bound natively
|
||||
// (not via JSX) so we get real DOM PointerEvents. The guide follows the cursor until release;
|
||||
// moving/removing an existing guide is handled by GuidesLayer.
|
||||
const startCreate = (axis: 'x' | 'y', e: PointerEvent): void => {
|
||||
e.preventDefault();
|
||||
const originEl = (axis === 'x' ? topCanvas.value : leftCanvas.value)?.getBoundingClientRect();
|
||||
if (!originEl) return;
|
||||
const toPos = (ev: PointerEvent): number =>
|
||||
axis === 'x'
|
||||
? (ev.clientX - originEl.left - state.panX) / state.zoom
|
||||
: (ev.clientY - originEl.top - state.panY) / state.zoom;
|
||||
const index = addGuide(axis, toPos(e));
|
||||
const move = (ev: PointerEvent): void => updateGuide(axis, index, toPos(ev));
|
||||
const up = (): void => {
|
||||
window.removeEventListener('pointermove', move, true);
|
||||
window.removeEventListener('pointerup', up, true);
|
||||
};
|
||||
window.addEventListener('pointermove', move, true);
|
||||
window.addEventListener('pointerup', up, true);
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
useEventListener(topCanvas, 'pointerdown', (e: PointerEvent) => startCreate('x', e));
|
||||
useEventListener(leftCanvas, 'pointerdown', (e: PointerEvent) => startCreate('y', e));
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas
|
||||
ref={topCanvas}
|
||||
onClick={(e) => onTopClick(e.nativeEvent)}
|
||||
class="absolute left-0 top-0 cursor-crosshair"
|
||||
style={{ height: `${SIZE}px` }}
|
||||
<canvas ref={topCanvas} class="absolute left-0 top-0 cursor-crosshair" style={{ height: `${SIZE}px` }} />
|
||||
<canvas ref={leftCanvas} 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` }}
|
||||
/>
|
||||
<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` }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -59,22 +68,25 @@ function niceStep(zoom: number, minPx: number): number {
|
||||
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;
|
||||
function drawAxis(
|
||||
canvas: HTMLCanvasElement,
|
||||
axis: 'x' | 'y',
|
||||
length: number,
|
||||
zoom: number,
|
||||
pan: number,
|
||||
dpr: number,
|
||||
): void {
|
||||
const cssW = axis === 'x' ? length : SIZE;
|
||||
const cssH = axis === 'x' ? SIZE : length;
|
||||
|
||||
canvas.width = cssW * dpr;
|
||||
canvas.height = cssH * dpr;
|
||||
canvas.width = Math.max(1, Math.round(cssW * dpr));
|
||||
canvas.height = Math.max(1, Math.round(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.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, cssW, cssH);
|
||||
ctx.fillStyle = '#11151f';
|
||||
ctx.fillRect(0, 0, cssW, cssH);
|
||||
@@ -83,13 +95,12 @@ function drawAxis(canvas: HTMLCanvasElement, axis: 'x' | 'y'): void {
|
||||
ctx.font = '9px ui-monospace, monospace';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
const pan = axis === 'x' ? state.panX : state.panY;
|
||||
const major = niceStep(state.zoom, 56);
|
||||
const major = niceStep(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;
|
||||
const firstValue = Math.floor((0 - pan) / zoom / minor) * minor;
|
||||
for (let v = firstValue; pan + v * zoom <= length; v += minor) {
|
||||
const pos = pan + v * zoom;
|
||||
if (pos < 0) continue;
|
||||
const isMajor = Math.abs(v % major) < 0.001;
|
||||
const tick = isMajor ? SIZE * 0.6 : SIZE * 0.3;
|
||||
|
||||
@@ -1,74 +1,69 @@
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { shallowRef, watch } from 'vue';
|
||||
import { recenter, state, zoomAt } from '../store';
|
||||
import { useFrame } from '../composables/useFrame';
|
||||
import { useElementSize, useEventListener, useFrame, usePointerDrag } from '../composables';
|
||||
import LayoutOverlay from './LayoutOverlay';
|
||||
import MeasureLayer from './MeasureLayer';
|
||||
import GuidesLayer from './GuidesLayer';
|
||||
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>();
|
||||
const viewport = shallowRef<HTMLDivElement>();
|
||||
const frame = shallowRef<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());
|
||||
// Track the viewport size for centering + ruler geometry; center once it has a real size.
|
||||
const { width, height } = useElementSize(viewport);
|
||||
let centered = false;
|
||||
watch(
|
||||
[width, height],
|
||||
([w, h]) => {
|
||||
state.viewportW = w;
|
||||
state.viewportH = h;
|
||||
if (!centered && w > 0 && h > 0) {
|
||||
centered = true;
|
||||
recenter();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
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);
|
||||
};
|
||||
// Wheel = zoom about the cursor. Bound natively (not via JSX) so we can opt out of passive
|
||||
// and call preventDefault to stop the page scrolling underneath.
|
||||
useEventListener(
|
||||
viewport,
|
||||
'wheel',
|
||||
(e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = viewport.value!.getBoundingClientRect();
|
||||
zoomAt(e.deltaY < 0 ? 1.1 : 0.9, e.clientX - rect.left, e.clientY - rect.top);
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
let panning = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
// Drag the empty background to pan.
|
||||
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 */
|
||||
}
|
||||
};
|
||||
usePointerDrag(viewport, {
|
||||
onStart: (e) => {
|
||||
if (e.target !== viewport.value) return false; // only pan on the empty background
|
||||
e.preventDefault();
|
||||
originX = state.panX;
|
||||
originY = state.panY;
|
||||
},
|
||||
onMove: ({ dx, dy }) => {
|
||||
state.panX = originX + dx;
|
||||
state.panY = originY + dy;
|
||||
},
|
||||
pointerCapture: true,
|
||||
});
|
||||
|
||||
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}
|
||||
class="relative min-w-0 flex-1 cursor-grab overflow-hidden bg-[#0b0e14] bg-[radial-gradient(circle,rgba(148,163,184,0.12)_1px,transparent_1px)] [background-size:16px_16px] active:cursor-grabbing"
|
||||
>
|
||||
<div
|
||||
class="absolute left-0 top-0 origin-top-left"
|
||||
@@ -87,7 +82,9 @@ export default function Stage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LayoutOverlay />
|
||||
<MeasureLayer />
|
||||
<GuidesLayer />
|
||||
<ResizeHandles />
|
||||
{state.showRulers ? <Rulers /> : null}
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
<ToolButton label="Rotate" onClick={() => { rotateFrame(); recenter(); }} />
|
||||
<ToolButton label="Rotate" action={() => { rotateFrame(); recenter(); }} />
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<ToolButton label="−" onClick={() => setZoom(state.zoom - 0.1)} />
|
||||
<ToolButton label="−" action={() => 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="+" action={() => setZoom(state.zoom + 0.1)} />
|
||||
<ToolButton
|
||||
label="Fit"
|
||||
onClick={() => {
|
||||
action={() => {
|
||||
resetSize();
|
||||
recenter();
|
||||
}}
|
||||
@@ -55,8 +55,19 @@ export default function Toolbar() {
|
||||
|
||||
<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} />
|
||||
<ToolButton label="Rulers" active={state.showRulers} action={() => (state.showRulers = !state.showRulers)} />
|
||||
<ToolButton label="Grid" active={state.showGrid} action={() => (state.showGrid = !state.showGrid)} />
|
||||
<ToolButton
|
||||
label={state.clicksEnabled ? 'Clicks: on' : 'Clicks: off'}
|
||||
active={state.clicksEnabled}
|
||||
title={
|
||||
state.clicksEnabled
|
||||
? 'Clicking the canvas re-selects an element'
|
||||
: 'Clicks are locked — hover to inspect without misclicks'
|
||||
}
|
||||
action={() => (state.clicksEnabled = !state.clicksEnabled)}
|
||||
/>
|
||||
<ToolButton label="Clear guides" action={clearGuides} />
|
||||
|
||||
<div class="mx-1 h-5 w-px bg-white/10" />
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClick}
|
||||
title={props.title}
|
||||
onClick={props.action}
|
||||
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',
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export { useEventListener } from './useEventListener';
|
||||
export { useElementSize } from './useElementSize';
|
||||
export { useDevicePixelRatio } from './useDevicePixelRatio';
|
||||
export { usePointerDrag } from './usePointerDrag';
|
||||
export type { DragState, PointerDragOptions } from './usePointerDrag';
|
||||
export { useClipboard } from './useClipboard';
|
||||
export { useFrame } from './useFrame';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { onScopeDispose, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* Copy text to the clipboard with a transient `copied` flag for UI feedback. Resolves
|
||||
* `false` if the clipboard is unavailable (some pages block it). Mirrors VueUse's
|
||||
* `useClipboard`.
|
||||
*/
|
||||
export function useClipboard(timeout = 1200): {
|
||||
copy: (text: string) => Promise<boolean>;
|
||||
copied: Ref<boolean>;
|
||||
} {
|
||||
const copied = ref(false);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const copy = async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied.value = true;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => (copied.value = false), timeout);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
onScopeDispose(() => clearTimeout(timer));
|
||||
return { copy, copied };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { onScopeDispose, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* Reactive `window.devicePixelRatio`. Updates when the page is zoomed or moved between
|
||||
* displays of differing density (so canvas-backed UI can stay crisp). Mirrors VueUse's
|
||||
* `useDevicePixelRatio`.
|
||||
*/
|
||||
export function useDevicePixelRatio(): { pixelRatio: Ref<number> } {
|
||||
const pixelRatio = ref(1);
|
||||
let media: MediaQueryList | undefined;
|
||||
|
||||
const update = (): void => {
|
||||
pixelRatio.value = window.devicePixelRatio || 1;
|
||||
media?.removeEventListener('change', update);
|
||||
// A media query only fires for the exact ratio it was created with, so re-arm each change.
|
||||
media = window.matchMedia(`(resolution: ${pixelRatio.value}dppx)`);
|
||||
media.addEventListener('change', update, { once: true });
|
||||
};
|
||||
|
||||
update();
|
||||
onScopeDispose(() => media?.removeEventListener('change', update));
|
||||
return { pixelRatio };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { onScopeDispose, ref, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
|
||||
/**
|
||||
* Track an element's content-box size via `ResizeObserver`. Re-observes if the target ref
|
||||
* changes and disconnects on scope dispose. Mirrors VueUse's `useElementSize`.
|
||||
*/
|
||||
export function useElementSize<T extends HTMLElement = HTMLElement>(
|
||||
target: MaybeRefOrGetter<T | undefined>,
|
||||
): { width: Ref<number>; height: Ref<number> } {
|
||||
const width = ref(0);
|
||||
const height = ref(0);
|
||||
let observer: ResizeObserver | undefined;
|
||||
|
||||
const stopWatch = watch(
|
||||
() => toValue(target),
|
||||
(el) => {
|
||||
observer?.disconnect();
|
||||
observer = undefined;
|
||||
if (!el) return;
|
||||
observer = new ResizeObserver(() => {
|
||||
width.value = el.clientWidth;
|
||||
height.value = el.clientHeight;
|
||||
});
|
||||
observer.observe(el);
|
||||
width.value = el.clientWidth;
|
||||
height.value = el.clientHeight;
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
onScopeDispose(() => {
|
||||
stopWatch();
|
||||
observer?.disconnect();
|
||||
});
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { onScopeDispose, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
|
||||
/**
|
||||
* Attach a DOM event listener that is bound when the (possibly ref/getter) target becomes
|
||||
* available and torn down automatically when the owning scope disposes. Re-binds if the
|
||||
* target changes. Mirrors VueUse's `useEventListener`.
|
||||
*/
|
||||
export function useEventListener<E extends Event = Event, T extends EventTarget = EventTarget>(
|
||||
target: MaybeRefOrGetter<T | undefined>,
|
||||
type: string,
|
||||
listener: (event: E) => void,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): () => void {
|
||||
let detach = (): void => {};
|
||||
|
||||
const stopWatch = watch(
|
||||
() => toValue(target),
|
||||
(el) => {
|
||||
detach();
|
||||
if (!el) return;
|
||||
const handler = listener as EventListener;
|
||||
el.addEventListener(type, handler, options);
|
||||
detach = () => el.removeEventListener(type, handler, options);
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
const stop = (): void => {
|
||||
stopWatch();
|
||||
detach();
|
||||
detach = () => {};
|
||||
};
|
||||
|
||||
onScopeDispose(stop);
|
||||
return stop;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { onBeforeUnmount, onMounted, watch } from 'vue';
|
||||
import { markRaw, 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';
|
||||
import type { ColorSwatch, Inspection, LayoutInfo, LayoutProp, StyleItem } 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.
|
||||
@@ -16,6 +16,12 @@ export function useFrame(frameRef: Ref<HTMLIFrameElement | undefined>): {
|
||||
let win: (Window & typeof globalThis) | null = null;
|
||||
let target: Element | null = null;
|
||||
let varMap = new Map<string, string>();
|
||||
let removalObserver: MutationObserver | null = null;
|
||||
// Hover hot-path state: the last element we inspected (skip re-work while the cursor stays on
|
||||
// it) and a pending rAF id (coalesce a burst of mousemove events into one inspect per frame).
|
||||
let lastHover: Element | null = null;
|
||||
let moveRaf = 0;
|
||||
let movePoint = { x: 0, y: 0 };
|
||||
|
||||
const onLoad = (): void => {
|
||||
const frame = frameRef.value;
|
||||
@@ -32,6 +38,13 @@ export function useFrame(frameRef: Ref<HTMLIFrameElement | undefined>): {
|
||||
doc.addEventListener('mouseleave', onLeave, true);
|
||||
doc.addEventListener('click', onClick, true);
|
||||
doc.addEventListener('keydown', onKey, true);
|
||||
|
||||
// If the inspected element is removed from the frame document (e.g. a script in the
|
||||
// captured markup tears it down), there is nothing left to inspect — close the overlay.
|
||||
removalObserver = new MutationObserver(() => {
|
||||
if (target && !target.isConnected) requestExit();
|
||||
});
|
||||
removalObserver.observe(doc, { childList: true, subtree: true });
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
@@ -42,18 +55,35 @@ export function useFrame(frameRef: Ref<HTMLIFrameElement | undefined>): {
|
||||
}
|
||||
};
|
||||
|
||||
const flushMove = (): void => {
|
||||
moveRaf = 0;
|
||||
if (!doc) return;
|
||||
const el = doc.elementFromPoint(movePoint.x, movePoint.y);
|
||||
if (!el || el === lastHover) return; // same element under cursor → nothing to recompute
|
||||
lastHover = el;
|
||||
state.hover = inspect(el);
|
||||
};
|
||||
|
||||
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);
|
||||
// Coalesce a burst of mousemove events into a single inspect on the next frame.
|
||||
movePoint.x = e.clientX;
|
||||
movePoint.y = e.clientY;
|
||||
if (!moveRaf) moveRaf = requestAnimationFrame(flushMove);
|
||||
};
|
||||
|
||||
const onLeave = (): void => {
|
||||
lastHover = null;
|
||||
state.hover = null;
|
||||
};
|
||||
|
||||
const onClick = (e: MouseEvent): void => {
|
||||
if (state.tool !== 'inspect' || !doc) return;
|
||||
if (!doc) return;
|
||||
// Always swallow the click so the captured markup can't navigate (links) or trip an
|
||||
// interactive control while you analyse it. Re-selecting is opt-in (clicks are locked by
|
||||
// default to avoid misclicks); when unlocked, a click re-targets the inspector.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!state.clicksEnabled) return;
|
||||
const el = doc.elementFromPoint(e.clientX, e.clientY);
|
||||
if (el) {
|
||||
target = el;
|
||||
@@ -64,13 +94,16 @@ export function useFrame(frameRef: Ref<HTMLIFrameElement | undefined>): {
|
||||
function inspect(el: Element): Inspection {
|
||||
const w = win!;
|
||||
const cs = w.getComputedStyle(el);
|
||||
const parentCs = el.parentElement ? w.getComputedStyle(el.parentElement) : null;
|
||||
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 {
|
||||
// The inspection is an immutable snapshot replaced wholesale on each hover/click; mark it
|
||||
// raw so Vue never deep-proxies its nested box-model / arrays (hot path on every mousemove).
|
||||
return markRaw({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
id: el.id ?? '',
|
||||
classes: typeof el.className === 'string' ? el.className.trim().split(/\s+/).filter(Boolean) : [],
|
||||
@@ -80,39 +113,117 @@ export function useFrame(frameRef: Ref<HTMLIFrameElement | undefined>): {
|
||||
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),
|
||||
};
|
||||
layout: detectLayout(el, cs, parentCs, r),
|
||||
typography: collectTypography(cs, parentCs),
|
||||
effects: collectEffects(cs),
|
||||
colors: collectColors(el, cs, parentCs),
|
||||
});
|
||||
}
|
||||
|
||||
function collectColors(cs: CSSStyleDeclaration): ColorSwatch[] {
|
||||
// A computed value counts as "inherited" when it's an inheritable property and resolves to
|
||||
// the same thing on the parent — i.e. the element didn't set it itself. A heuristic (a rule
|
||||
// could coincidentally re-set the same value), but a reliable signal in practice.
|
||||
function inheritedFrom(parentCs: CSSStyleDeclaration | null, prop: string, value: string): boolean {
|
||||
return parentCs != null && parentCs.getPropertyValue(prop) === value;
|
||||
}
|
||||
|
||||
function collectColors(
|
||||
el: Element,
|
||||
cs: CSSStyleDeclaration,
|
||||
parentCs: CSSStyleDeclaration | null,
|
||||
): ColorSwatch[] {
|
||||
const swatches: ColorSwatch[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (label: string, value: string): void => {
|
||||
const add = (label: string, value: string, inheritProp?: 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 });
|
||||
swatches.push({
|
||||
label,
|
||||
color,
|
||||
hex: rgbaToHex(color),
|
||||
varName: varMap.get(colorKey(color)) ?? null,
|
||||
inherited: inheritProp ? inheritedFrom(parentCs, inheritProp, value) : false,
|
||||
});
|
||||
};
|
||||
add('Text', cs.color);
|
||||
add('Text', cs.color, 'color');
|
||||
add('Background', cs.backgroundColor);
|
||||
if (parseFloat(cs.borderTopWidth) > 0) add('Border', cs.borderTopColor);
|
||||
if (parseFloat(cs.outlineWidth) > 0) add('Outline', cs.outlineColor);
|
||||
if (cs.textDecorationLine !== 'none') add('Decoration', cs.textDecorationColor);
|
||||
add('Caret', cs.caretColor, 'caret-color');
|
||||
add('Accent', cs.accentColor, 'accent-color');
|
||||
const shadowColor = firstColor(cs.boxShadow);
|
||||
if (shadowColor) add('Shadow', shadowColor);
|
||||
if (el.namespaceURI === 'http://www.w3.org/2000/svg') {
|
||||
add('Fill', cs.fill, 'fill');
|
||||
add('Stroke', cs.stroke, 'stroke');
|
||||
}
|
||||
return swatches;
|
||||
}
|
||||
|
||||
function collectTypography(cs: CSSStyleDeclaration, parentCs: CSSStyleDeclaration | null): StyleItem[] {
|
||||
const items: StyleItem[] = [];
|
||||
const add = (label: string, prop: string, value = cs.getPropertyValue(prop)): void => {
|
||||
if (!value || value === 'normal' || value === 'none' || value === 'auto') return;
|
||||
items.push({ label, value, inherited: inheritedFrom(parentCs, prop, value) });
|
||||
};
|
||||
items.push({
|
||||
label: 'Font',
|
||||
value: cs.fontFamily.split(',')[0]?.replace(/["']/g, '').trim() || '—',
|
||||
inherited: inheritedFrom(parentCs, 'font-family', cs.fontFamily),
|
||||
});
|
||||
add('Size', 'font-size');
|
||||
add('Weight', 'font-weight');
|
||||
add('Line', 'line-height');
|
||||
add('Letter', 'letter-spacing');
|
||||
add('Align', 'text-align');
|
||||
add('Transform', 'text-transform');
|
||||
add('Style', 'font-style');
|
||||
add('Decoration', 'text-decoration-line');
|
||||
add('Whitespace', 'white-space');
|
||||
return items;
|
||||
}
|
||||
|
||||
function collectEffects(cs: CSSStyleDeclaration): StyleItem[] {
|
||||
const items: StyleItem[] = [];
|
||||
const add = (label: string, value: string, hideWhen: string[]): void => {
|
||||
if (!value || hideWhen.includes(value)) return;
|
||||
items.push({ label, value });
|
||||
};
|
||||
add('Opacity', cs.opacity, ['1']);
|
||||
add('Shadow', cs.boxShadow, ['none']);
|
||||
add('Filter', cs.filter, ['none']);
|
||||
add('Backdrop', cs.backdropFilter || cs.getPropertyValue('-webkit-backdrop-filter'), ['none', '']);
|
||||
add('Blend', cs.mixBlendMode, ['normal']);
|
||||
add('Transform', cs.transform, ['none']);
|
||||
add('Cursor', cs.cursor, ['auto']);
|
||||
return items;
|
||||
}
|
||||
|
||||
const reinspect = (): void => {
|
||||
if (target && win) state.selected = inspect(target);
|
||||
if (!target || !win) return;
|
||||
// A relayout (resize / media query) can drop the element out of flow or hide it; once it
|
||||
// no longer renders a box there is nothing to inspect, so dismiss the overlay.
|
||||
if (!target.isConnected || win.getComputedStyle(target).display === 'none') {
|
||||
requestExit();
|
||||
return;
|
||||
}
|
||||
// Boxes moved — invalidate the hover skip so the next mousemove re-inspects.
|
||||
lastHover = null;
|
||||
state.selected = inspect(target);
|
||||
};
|
||||
|
||||
function teardown(): void {
|
||||
removalObserver?.disconnect();
|
||||
removalObserver = null;
|
||||
if (moveRaf) {
|
||||
cancelAnimationFrame(moveRaf);
|
||||
moveRaf = 0;
|
||||
}
|
||||
lastHover = null;
|
||||
if (!doc) return;
|
||||
doc.removeEventListener('mousemove', onMove, true);
|
||||
doc.removeEventListener('mouseleave', onLeave, true);
|
||||
@@ -185,6 +296,183 @@ function buildVarMap(win: Window, doc: Document): Map<string, string> {
|
||||
return map;
|
||||
}
|
||||
|
||||
// Summarize the element's layout: its own container model (flex/grid flow + alignment + gap)
|
||||
// and, when it sits inside a flex/grid parent, how it places itself as an item. `parentCs` and
|
||||
// `rect` are passed in from the caller's single computed-style / bounding-rect reads.
|
||||
function detectLayout(
|
||||
el: Element,
|
||||
cs: CSSStyleDeclaration,
|
||||
parentCs: CSSStyleDeclaration | null,
|
||||
rect: DOMRect,
|
||||
): LayoutInfo {
|
||||
const display = cs.display;
|
||||
const isFlex = display === 'flex' || display === 'inline-flex';
|
||||
const isGrid = display === 'grid' || display === 'inline-grid';
|
||||
const props: LayoutProp[] = [];
|
||||
|
||||
if (isFlex) {
|
||||
props.push({ label: 'Direction', value: cs.flexDirection });
|
||||
if (cs.flexWrap !== 'nowrap') props.push({ label: 'Wrap', value: cs.flexWrap });
|
||||
props.push({ label: 'Justify', value: cs.justifyContent });
|
||||
props.push({ label: 'Align', value: cs.alignItems });
|
||||
} else if (isGrid) {
|
||||
props.push({ label: 'Columns', value: summarizeTracks(cs.gridTemplateColumns) });
|
||||
props.push({ label: 'Rows', value: summarizeTracks(cs.gridTemplateRows) });
|
||||
if (cs.gridAutoFlow !== 'row') props.push({ label: 'Auto flow', value: cs.gridAutoFlow });
|
||||
props.push({ label: 'Justify', value: cs.justifyItems });
|
||||
props.push({ label: 'Align', value: cs.alignItems });
|
||||
}
|
||||
if (isFlex || isGrid) {
|
||||
const g = formatGap(cs);
|
||||
if (g) props.push({ label: 'Gap', value: g });
|
||||
}
|
||||
|
||||
// Item placement, when this element is a child of a flex/grid container.
|
||||
if (parentCs) {
|
||||
const pd = parentCs.display;
|
||||
if (PARENT_FLEX.test(pd)) {
|
||||
if (cs.flex !== '0 1 auto') props.push({ label: 'Flex (self)', value: cs.flex });
|
||||
if (cs.alignSelf !== 'auto' && cs.alignSelf !== 'normal') {
|
||||
props.push({ label: 'Align self', value: cs.alignSelf });
|
||||
}
|
||||
} else if (PARENT_GRID.test(pd)) {
|
||||
const area = cs.gridArea;
|
||||
if (area && area !== 'auto' && area !== 'auto / auto / auto / auto') {
|
||||
props.push({ label: 'Grid area', value: area });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = isFlex || isGrid ? captureGeometry(el, cs, isGrid, rect) : EMPTY_GEOMETRY;
|
||||
return { display, kind: isFlex ? 'flex' : isGrid ? 'grid' : 'block', props, ...geometry };
|
||||
}
|
||||
|
||||
const PARENT_FLEX = /(^|-)flex$/;
|
||||
const PARENT_GRID = /(^|-)grid$/;
|
||||
|
||||
const EMPTY_GEOMETRY: Pick<LayoutInfo, 'gridLines' | 'gaps' | 'items'> = {
|
||||
gridLines: null,
|
||||
gaps: [],
|
||||
items: [],
|
||||
};
|
||||
|
||||
// Build the visual-overlay geometry (track lines, gap rects, item boxes) in iframe-content
|
||||
// pixels — the same coordinate space as the box model, so the overlay maps them with the
|
||||
// current pan/zoom exactly like the measurement layer.
|
||||
function captureGeometry(
|
||||
el: Element,
|
||||
cs: CSSStyleDeclaration,
|
||||
isGrid: boolean,
|
||||
rect: DOMRect,
|
||||
): Pick<LayoutInfo, 'gridLines' | 'gaps' | 'items'> {
|
||||
const bl = parseFloat(cs.borderLeftWidth) || 0;
|
||||
const bt = parseFloat(cs.borderTopWidth) || 0;
|
||||
const pl = parseFloat(cs.paddingLeft) || 0;
|
||||
const pt = parseFloat(cs.paddingTop) || 0;
|
||||
const contentX = rect.left + bl + pl;
|
||||
const contentY = rect.top + bt + pt;
|
||||
const contentW = rect.width - bl - (parseFloat(cs.borderRightWidth) || 0) - pl - (parseFloat(cs.paddingRight) || 0);
|
||||
const contentH = rect.height - bt - (parseFloat(cs.borderBottomWidth) || 0) - pt - (parseFloat(cs.paddingBottom) || 0);
|
||||
|
||||
const items: Box[] = [];
|
||||
const children = el.children;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const cr = children[i]!.getBoundingClientRect();
|
||||
if (cr.width === 0 && cr.height === 0) continue;
|
||||
items.push({ x: cr.left, y: cr.top, width: cr.width, height: cr.height });
|
||||
}
|
||||
|
||||
const gaps: Box[] = [];
|
||||
const colGap = parseFloat(cs.columnGap) || 0;
|
||||
const rowGap = parseFloat(cs.rowGap) || 0;
|
||||
|
||||
if (isGrid) {
|
||||
const cols = parseTracks(cs.gridTemplateColumns);
|
||||
const rows = parseTracks(cs.gridTemplateRows);
|
||||
const xs: number[] = [contentX];
|
||||
let x = contentX;
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
x += cols[i]!;
|
||||
if (i < cols.length - 1) {
|
||||
if (colGap > 0) gaps.push({ x, y: contentY, width: colGap, height: contentH });
|
||||
x += colGap;
|
||||
}
|
||||
xs.push(x);
|
||||
}
|
||||
const ys: number[] = [contentY];
|
||||
let y = contentY;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
y += rows[i]!;
|
||||
if (i < rows.length - 1) {
|
||||
if (rowGap > 0) gaps.push({ x: contentX, y, width: contentW, height: rowGap });
|
||||
y += rowGap;
|
||||
}
|
||||
ys.push(y);
|
||||
}
|
||||
return { gridLines: cols.length ? { xs, ys } : null, gaps, items };
|
||||
}
|
||||
|
||||
// Flex: shade the gaps between consecutive items along the main axis (same-line pairs).
|
||||
const isRow = cs.flexDirection.startsWith('row');
|
||||
const gapPx = isRow ? colGap : rowGap;
|
||||
if (gapPx > 0 && items.length > 1) {
|
||||
const sorted = [...items].sort((a, b) => (isRow ? a.x - b.x : a.y - b.y));
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i]!;
|
||||
const b = sorted[i + 1]!;
|
||||
if (isRow && b.x - (a.x + a.width) > 0.5 && spans(a.y, a.height, b.y, b.height)) {
|
||||
const start = a.x + a.width;
|
||||
const top = Math.min(a.y, b.y);
|
||||
gaps.push({ x: start, y: top, width: b.x - start, height: Math.max(a.y + a.height, b.y + b.height) - top });
|
||||
} else if (!isRow && b.y - (a.y + a.height) > 0.5 && spans(a.x, a.width, b.x, b.width)) {
|
||||
const start = a.y + a.height;
|
||||
const left = Math.min(a.x, b.x);
|
||||
gaps.push({ x: left, y: start, width: Math.max(a.x + a.width, b.x + b.width) - left, height: b.y - start });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { gridLines: null, gaps, items };
|
||||
}
|
||||
|
||||
// Split on whitespace that is not inside parentheses (e.g. keep `minmax(0, 1fr)` intact).
|
||||
const TRACK_SPLIT = /\s+(?![^(]*\))/;
|
||||
const COLOR_TOKEN = /(rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]{3,8})/i;
|
||||
|
||||
/** Parse a computed grid template (resolved to px) into track sizes; `none` → []. */
|
||||
function parseTracks(value: string): number[] {
|
||||
if (!value || value === 'none') return [];
|
||||
return value
|
||||
.trim()
|
||||
.split(TRACK_SPLIT)
|
||||
.map((t) => parseFloat(t))
|
||||
.filter((n) => !Number.isNaN(n));
|
||||
}
|
||||
|
||||
/** Do two 1D ranges [aStart, aStart+aLen] and [bStart, bStart+bLen] overlap? */
|
||||
function spans(aStart: number, aLen: number, bStart: number, bLen: number): boolean {
|
||||
return aStart < bStart + bLen && bStart < aStart + aLen;
|
||||
}
|
||||
|
||||
/** First color token in a CSS value (e.g. the color of a `box-shadow`), or null. */
|
||||
function firstColor(value: string): string | null {
|
||||
if (!value || value === 'none') return null;
|
||||
const m = value.match(COLOR_TOKEN);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
function summarizeTracks(value: string): string {
|
||||
if (!value || value === 'none') return 'none';
|
||||
const count = value.trim().split(TRACK_SPLIT).length;
|
||||
return `${count} × ${value}`;
|
||||
}
|
||||
|
||||
function formatGap(cs: CSSStyleDeclaration): string {
|
||||
const row = cs.rowGap === 'normal' ? '0px' : cs.rowGap;
|
||||
const col = cs.columnGap === 'normal' ? '0px' : cs.columnGap;
|
||||
if (parseFloat(row) === 0 && parseFloat(col) === 0) return '';
|
||||
return row === col ? row : `${row} ${col}`;
|
||||
}
|
||||
|
||||
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') };
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { onScopeDispose, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
|
||||
export interface DragState {
|
||||
/** Total movement since the drag started, in client pixels. */
|
||||
dx: number;
|
||||
dy: number;
|
||||
event: PointerEvent;
|
||||
}
|
||||
|
||||
export interface PointerDragOptions {
|
||||
/** Return `false` to ignore this pointerdown (e.g. wrong target). */
|
||||
onStart?: (event: PointerEvent) => boolean | void;
|
||||
onMove?: (state: DragState) => void;
|
||||
onEnd?: (state: DragState) => void;
|
||||
/** Capture the pointer on the target for the duration of the drag. */
|
||||
pointerCapture?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer-drag gesture on a (ref/getter) target: tracks pointerdown → move → up with
|
||||
* window-level move/up listeners so the drag survives the pointer leaving the element.
|
||||
* Reports cumulative deltas. All listeners are cleaned up on scope dispose.
|
||||
*/
|
||||
export function usePointerDrag<T extends HTMLElement = HTMLElement>(
|
||||
target: MaybeRefOrGetter<T | undefined>,
|
||||
options: PointerDragOptions,
|
||||
): void {
|
||||
let el: T | null = null;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let pointerId = -1;
|
||||
let dragging = false;
|
||||
|
||||
const onMove = (event: PointerEvent): void => {
|
||||
if (!dragging) return;
|
||||
options.onMove?.({ dx: event.clientX - startX, dy: event.clientY - startY, event });
|
||||
};
|
||||
|
||||
const onUp = (event: PointerEvent): void => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
window.removeEventListener('pointermove', onMove, true);
|
||||
window.removeEventListener('pointerup', onUp, true);
|
||||
if (options.pointerCapture && el) {
|
||||
try {
|
||||
el.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
options.onEnd?.({ dx: event.clientX - startX, dy: event.clientY - startY, event });
|
||||
};
|
||||
|
||||
const onDown = (event: PointerEvent): void => {
|
||||
if (options.onStart?.(event) === false) return;
|
||||
dragging = true;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
pointerId = event.pointerId;
|
||||
if (options.pointerCapture && el) {
|
||||
try {
|
||||
el.setPointerCapture(pointerId);
|
||||
} catch {
|
||||
/* capture unavailable */
|
||||
}
|
||||
}
|
||||
window.addEventListener('pointermove', onMove, true);
|
||||
window.addEventListener('pointerup', onUp, true);
|
||||
};
|
||||
|
||||
const stopWatch = watch(
|
||||
() => toValue(target),
|
||||
(next) => {
|
||||
el?.removeEventListener('pointerdown', onDown);
|
||||
el = next ?? null;
|
||||
el?.addEventListener('pointerdown', onDown);
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
onScopeDispose(() => {
|
||||
stopWatch();
|
||||
el?.removeEventListener('pointerdown', onDown);
|
||||
window.removeEventListener('pointermove', onMove, true);
|
||||
window.removeEventListener('pointerup', onUp, true);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { reactive } from 'vue';
|
||||
import type { BoxModel } from '../utils/rect';
|
||||
import type { Box, BoxModel } from '../utils/rect';
|
||||
import type { Rgba } from '../utils/color';
|
||||
import type { Capture } from '../content/capture';
|
||||
|
||||
@@ -8,6 +8,35 @@ export interface ColorSwatch {
|
||||
color: Rgba;
|
||||
hex: string;
|
||||
varName: string | null;
|
||||
/** The value is inherited from an ancestor rather than set on the element itself. */
|
||||
inherited?: boolean;
|
||||
}
|
||||
|
||||
/** A generic computed-style line shown in the panel. */
|
||||
export interface StyleItem {
|
||||
label: string;
|
||||
value: string;
|
||||
inherited?: boolean;
|
||||
}
|
||||
|
||||
export interface LayoutProp {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface LayoutInfo {
|
||||
/** Computed `display` (e.g. `flex`, `grid`, `block`, `inline-flex`). */
|
||||
display: string;
|
||||
/** Coarse classification used to tint the panel and select an overlay. */
|
||||
kind: 'flex' | 'grid' | 'block';
|
||||
/** Container properties (flow, alignment, gap) plus the element's own item placement. */
|
||||
props: LayoutProp[];
|
||||
/** Grid line positions in iframe-content pixels (for the visual overlay), or null. */
|
||||
gridLines: { xs: number[]; ys: number[] } | null;
|
||||
/** Gap rectangles (between grid tracks / flex items) in iframe-content pixels. */
|
||||
gaps: Box[];
|
||||
/** Child item border boxes in iframe-content pixels. */
|
||||
items: Box[];
|
||||
}
|
||||
|
||||
export interface Inspection {
|
||||
@@ -21,10 +50,15 @@ export interface Inspection {
|
||||
radius: string;
|
||||
padding: string;
|
||||
margin: string;
|
||||
font: { family: string; size: string; weight: string; lineHeight: string };
|
||||
layout: LayoutInfo;
|
||||
typography: StyleItem[];
|
||||
effects: StyleItem[];
|
||||
colors: ColorSwatch[];
|
||||
}
|
||||
|
||||
/** What the layout overlay should emphasize, driven by hovering panel rows. */
|
||||
export type LayoutHighlight = 'none' | 'gap' | 'tracks' | 'items';
|
||||
|
||||
export interface DevicePreset {
|
||||
label: string;
|
||||
width: number;
|
||||
@@ -53,8 +87,13 @@ interface State {
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
tool: 'inspect' | 'guides';
|
||||
showRulers: boolean;
|
||||
/** Visualize the selected element's grid/flex structure on the canvas. */
|
||||
showGrid: boolean;
|
||||
/** When false (default), clicks inside the frame never re-select — avoids misclicks. */
|
||||
clicksEnabled: boolean;
|
||||
/** Panel-driven emphasis for the layout overlay. */
|
||||
layoutHighlight: LayoutHighlight;
|
||||
guides: { x: number[]; y: number[] };
|
||||
hover: Inspection | null;
|
||||
selected: Inspection | null;
|
||||
@@ -72,8 +111,10 @@ export const state = reactive<State>({
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
tool: 'inspect',
|
||||
showRulers: true,
|
||||
showGrid: true,
|
||||
clicksEnabled: false,
|
||||
layoutHighlight: 'none',
|
||||
guides: { x: [], y: [] },
|
||||
hover: null,
|
||||
selected: null,
|
||||
@@ -100,7 +141,8 @@ export function initFromCapture(capture: Capture): void {
|
||||
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.clicksEnabled = false;
|
||||
state.layoutHighlight = 'none';
|
||||
state.guides = { x: [], y: [] };
|
||||
state.hover = null;
|
||||
state.selected = null;
|
||||
@@ -148,8 +190,19 @@ 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));
|
||||
/** Append a guide on the given axis and return its index (so a drag can keep updating it). */
|
||||
export function addGuide(axis: 'x' | 'y', position: number): number {
|
||||
return state.guides[axis].push(Math.round(position)) - 1;
|
||||
}
|
||||
|
||||
export function updateGuide(axis: 'x' | 'y', index: number, position: number): void {
|
||||
const arr = state.guides[axis];
|
||||
if (index >= 0 && index < arr.length) arr[index] = Math.round(position);
|
||||
}
|
||||
|
||||
export function removeGuide(axis: 'x' | 'y', index: number): void {
|
||||
const arr = state.guides[axis];
|
||||
if (index >= 0 && index < arr.length) arr.splice(index, 1);
|
||||
}
|
||||
|
||||
export function clearGuides(): void {
|
||||
|
||||
@@ -2,22 +2,3 @@
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user