@stsgs1980/fab-inspector 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -0
- package/api-source-route.ts +60 -0
- package/box-model-section.tsx +97 -0
- package/cli/init.mjs +144 -0
- package/highlight-overlay.tsx +23 -0
- package/index.ts +10 -0
- package/inspector-fab.tsx +63 -0
- package/inspector-panel.tsx +190 -0
- package/package.json +56 -0
- package/panel-sections.tsx +207 -0
- package/plugins/data-src-plugin.ts +110 -0
- package/select-element-fab.tsx +59 -0
- package/types.ts +45 -0
- package/use-element-inspector.ts +264 -0
- package/use-panel-drag.ts +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# FabInspector
|
|
2
|
+
|
|
3
|
+
Визуальный инспектор элементов для Next.js dev mode. Плавающая FAB-кнопка с draggable панелью в GitHub Dark Theme, подсветкой синтаксиса и box-model визуализацией.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Что делает
|
|
10
|
+
|
|
11
|
+
При клике на любой DOM-элемент показывает:
|
|
12
|
+
|
|
13
|
+
- Источник: файл и строка через `data-src` атрибут
|
|
14
|
+
- CSS-классы, текст, CSS Path, HTML-код
|
|
15
|
+
- Вычисленные стили (размер, шрифт, цвет)
|
|
16
|
+
- Box Model: визуальная схема margin / border / padding / content
|
|
17
|
+
- Сниппет исходного кода с подсветкой синтаксиса
|
|
18
|
+
- Copy task context: структурированный промпт (файл + тег + текст)
|
|
19
|
+
- Copy file:line в буфер обмена
|
|
20
|
+
- Quick-copy в свёрнутой панели (тег + классы + текст)
|
|
21
|
+
|
|
22
|
+
Панель по умолчанию свёрнута (только заголовок), раскрывается по клику на шеврон. Перетаскивается за заголовок.
|
|
23
|
+
|
|
24
|
+
## Установка
|
|
25
|
+
|
|
26
|
+
### npm / bun
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bun add @stsgs1980/fab-inspector -D
|
|
30
|
+
# или
|
|
31
|
+
npm install @stsgs1980/fab-inspector -D
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Peer dependencies (если ещё не стоят):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bun add framer-motion react-syntax-highlighter
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Подключение (одна команда)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx @stsgs1980/fab-inspector init
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
CLI автоматически:
|
|
47
|
+
- Создаёт `src/app/api/source/route.ts` (re-export GET из пакета)
|
|
48
|
+
- Добавляет `import { SelectElementFab } from '@stsgs1980/fab-inspector'` в `src/app/layout.tsx`
|
|
49
|
+
- Вставляет `<SelectElementFab />` перед `</body>`
|
|
50
|
+
- Проверяет наличие peer dependencies
|
|
51
|
+
|
|
52
|
+
Идемпотентно — безопасно запускать сколько угодно раз.
|
|
53
|
+
|
|
54
|
+
### Ручная установка
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
// src/app/layout.tsx:
|
|
58
|
+
import { SelectElementFab } from '@stsgs1980/fab-inspector';
|
|
59
|
+
// В JSX перед </body>:
|
|
60
|
+
<SelectElementFab />
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// src/app/api/source/route.ts:
|
|
65
|
+
export { GET } from '@stsgs1980/fab-inspector/api/source';
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Обновление
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
bun update @stsgs1980/fab-inspector
|
|
72
|
+
# или
|
|
73
|
+
npm update @stsgs1980/fab-inspector
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Использование
|
|
77
|
+
|
|
78
|
+
### Режим инспекции
|
|
79
|
+
|
|
80
|
+
- FAB-кнопка в правом нижнем углу включает режим инспекции
|
|
81
|
+
- Клик по элементу показывает свёрнутую панель (тег, ID, файл:строка)
|
|
82
|
+
- Шеврон раскрывает все секции
|
|
83
|
+
- Esc закрывает инспектор
|
|
84
|
+
- Панель перетаскивается за заголовок
|
|
85
|
+
|
|
86
|
+
### Кнопки в заголовке панели
|
|
87
|
+
|
|
88
|
+
| Кнопка | Действие | Доступна без `data-src` |
|
|
89
|
+
|--------|----------|-------------------------|
|
|
90
|
+
| Документ | Copy task context (файл, тег, текст) | нет |
|
|
91
|
+
| Два прямоугольника | Copy file:line | нет |
|
|
92
|
+
| Копировать | Quick-copy (тег + классы + текст) | **да** |
|
|
93
|
+
| Шеврон | Свернуть / развернуть секции | да |
|
|
94
|
+
| x | Закрыть панель | да |
|
|
95
|
+
|
|
96
|
+
### data-src атрибут
|
|
97
|
+
|
|
98
|
+
Для отображения источника добавьте `data-src` на JSX-элементы:
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
<h1 data-src="src/components/sections/hero.tsx:12">Заголовок</h1>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Инспектор поднимается по DOM-дереву и найдёт ближайший `data-src`.
|
|
105
|
+
|
|
106
|
+
> **Next.js 16 note:** Авто-проставление `data-src` через Turbopack plugin в Next.js 16 больше не работает (API `experimental.turbo.plugins` удалён). Проставляйте `data-src` вручную на ключевых элементах. SWC plugin для авто-разметки — в планах.
|
|
107
|
+
|
|
108
|
+
## Конфигурация
|
|
109
|
+
|
|
110
|
+
### Зависимости (peerDependencies)
|
|
111
|
+
|
|
112
|
+
| Пакет | Версия |
|
|
113
|
+
|-------|--------|
|
|
114
|
+
| `framer-motion` | >= 11.0 |
|
|
115
|
+
| `react-syntax-highlighter` | >= 15.0 |
|
|
116
|
+
| `next` | >= 15.0 |
|
|
117
|
+
| `react` | >= 19.0 |
|
|
118
|
+
|
|
119
|
+
### Production safety
|
|
120
|
+
|
|
121
|
+
- Пакет ставится в `devDependencies` — в production-bundle не попадает
|
|
122
|
+
- Компонент `SelectElementFab` имеет dev-only guard: `if (process.env.NODE_ENV !== 'development') return null`
|
|
123
|
+
- `sideEffects: false` — tree-shaking вырезает неиспользуемый код
|
|
124
|
+
|
|
125
|
+
### API-роут `/api/source`
|
|
126
|
+
|
|
127
|
+
Создаётся CLI `init` автоматически. Принимает `file`, `line`, `ctx` (контекст строк). Файл валидируется по белому списку директорий (`src/components/`, `src/app/`, `src/content/`, `src/hooks/`, `src/lib/`).
|
|
128
|
+
|
|
129
|
+
## Структура модуля
|
|
130
|
+
|
|
131
|
+
- `index.ts` — Barrel export
|
|
132
|
+
- `types.ts` — Интерфейсы (ElementInfo, SourceInfo, BoxModel, SnippetData)
|
|
133
|
+
- `select-element-fab.tsx` — Корневой composer
|
|
134
|
+
- `inspector-fab.tsx` — FAB-кнопка
|
|
135
|
+
- `inspector-panel.tsx` — Draggable сворачиваемая панель
|
|
136
|
+
- `highlight-overlay.tsx` — Подсветка элемента
|
|
137
|
+
- `panel-sections.tsx` — Секции панели (Source, Classes, Text, CSS Path, HTML, Styles, Snippet)
|
|
138
|
+
- `box-model-section.tsx` — Box Model диаграмма
|
|
139
|
+
- `use-element-inspector.ts` — Хук инспекции
|
|
140
|
+
- `use-panel-drag.ts` — Хук перетаскивания
|
|
141
|
+
- `api-source-route.ts` — GET handler для `/api/source`
|
|
142
|
+
- `cli/init.mjs` — CLI `npx @stsgs1980/fab-inspector init`
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Шаблон API-роута для установки.
|
|
2
|
+
// В проекте-потребителе создайте src/app/api/source/route.ts:
|
|
3
|
+
// export { GET } from '@stsgs1980/fab-inspector/api/source';
|
|
4
|
+
// Либо, если модуль подключён как локальный путь:
|
|
5
|
+
// export { GET } from '@/components/inspector/api-source-route';
|
|
6
|
+
|
|
7
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
8
|
+
import { readFile } from 'fs/promises';
|
|
9
|
+
import { resolve } from 'path';
|
|
10
|
+
|
|
11
|
+
// process.cwd() в Next.js = корень проекта-потребителя.
|
|
12
|
+
// Никаких '..' — иначе путь уходит за пределы проекта и isAllowed() всегда false.
|
|
13
|
+
const PROJECT_ROOT = process.cwd();
|
|
14
|
+
const ALLOWED_PREFIXES = [
|
|
15
|
+
resolve(PROJECT_ROOT, 'src/components/'),
|
|
16
|
+
resolve(PROJECT_ROOT, 'src/app/'),
|
|
17
|
+
resolve(PROJECT_ROOT, 'src/content/'),
|
|
18
|
+
resolve(PROJECT_ROOT, 'src/hooks/'),
|
|
19
|
+
resolve(PROJECT_ROOT, 'src/lib/'),
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function isAllowed(filePath: string): boolean {
|
|
23
|
+
const resolved = resolve(PROJECT_ROOT, filePath);
|
|
24
|
+
return ALLOWED_PREFIXES.some((prefix) => resolved.startsWith(prefix));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function GET(request: NextRequest) {
|
|
28
|
+
const { searchParams } = new URL(request.url);
|
|
29
|
+
const file = searchParams.get('file');
|
|
30
|
+
const line = parseInt(searchParams.get('line') || '1', 10);
|
|
31
|
+
const ctx = parseInt(searchParams.get('ctx') || '8', 10);
|
|
32
|
+
|
|
33
|
+
if (!file || !isAllowed(file)) {
|
|
34
|
+
return NextResponse.json({ error: 'File not allowed' }, { status: 403 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const filePath = resolve(PROJECT_ROOT, file);
|
|
39
|
+
const content = await readFile(filePath, 'utf-8');
|
|
40
|
+
const lines = content.split('\n');
|
|
41
|
+
|
|
42
|
+
const start = Math.max(0, line - 1 - ctx);
|
|
43
|
+
const end = Math.min(lines.length, line - 1 + ctx + 1);
|
|
44
|
+
const snippet = lines.slice(start, end);
|
|
45
|
+
const startLine = start + 1;
|
|
46
|
+
|
|
47
|
+
return NextResponse.json({
|
|
48
|
+
file,
|
|
49
|
+
line,
|
|
50
|
+
totalLines: lines.length,
|
|
51
|
+
snippet: {
|
|
52
|
+
startLine,
|
|
53
|
+
lines: snippet,
|
|
54
|
+
highlightLine: line,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
} catch {
|
|
58
|
+
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { BoxModel } from './types';
|
|
2
|
+
|
|
3
|
+
function fmt(val: string): string {
|
|
4
|
+
if (val === '0px') return '0';
|
|
5
|
+
return val;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function BoxRow({
|
|
9
|
+
top,
|
|
10
|
+
right,
|
|
11
|
+
bottom,
|
|
12
|
+
left,
|
|
13
|
+
color,
|
|
14
|
+
bg,
|
|
15
|
+
label,
|
|
16
|
+
inner,
|
|
17
|
+
}: {
|
|
18
|
+
top: string; right: string; bottom: string; left: string;
|
|
19
|
+
color: string; bg: string; label: string;
|
|
20
|
+
inner: React.ReactNode;
|
|
21
|
+
}) {
|
|
22
|
+
const allSame = top === right && right === bottom && bottom === left;
|
|
23
|
+
const display = allSame ? fmt(top) : `${fmt(top)} ${fmt(right)} ${fmt(bottom)} ${fmt(left)}`;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex items-center gap-2 text-[10px] font-mono">
|
|
27
|
+
<span className="w-12 text-right text-[#8B949E] flex-shrink-0">{label}</span>
|
|
28
|
+
<div className="flex-1">
|
|
29
|
+
<div
|
|
30
|
+
className="border text-center py-1 text-[#E6EDF3] rounded-sm"
|
|
31
|
+
style={{ borderColor: color, backgroundColor: bg }}
|
|
32
|
+
>
|
|
33
|
+
{allSame ? (
|
|
34
|
+
<span>{display}</span>
|
|
35
|
+
) : (
|
|
36
|
+
<div className="flex justify-between px-1">
|
|
37
|
+
<span>{fmt(top)}</span>
|
|
38
|
+
<span>{fmt(right)}</span>
|
|
39
|
+
<span>{fmt(bottom)}</span>
|
|
40
|
+
<span>{fmt(left)}</span>
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
<div className="mt-1">{inner}</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function BoxModelSection({ boxModel }: { boxModel: BoxModel }) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="px-4 py-2.5 border-b border-[#30363D]">
|
|
53
|
+
<div className="text-[11px] font-semibold text-[#8B949E] uppercase tracking-wider mb-2">
|
|
54
|
+
Box Model
|
|
55
|
+
</div>
|
|
56
|
+
<div className="flex flex-col gap-1.5">
|
|
57
|
+
<BoxRow
|
|
58
|
+
top={boxModel.marginTop}
|
|
59
|
+
right={boxModel.marginRight}
|
|
60
|
+
bottom={boxModel.marginBottom}
|
|
61
|
+
left={boxModel.marginLeft}
|
|
62
|
+
color="#D29922"
|
|
63
|
+
bg="rgba(210, 153, 34, 0.06)"
|
|
64
|
+
label="margin"
|
|
65
|
+
inner={
|
|
66
|
+
<BoxRow
|
|
67
|
+
top={boxModel.borderTop}
|
|
68
|
+
right={boxModel.borderRight}
|
|
69
|
+
bottom={boxModel.borderBottom}
|
|
70
|
+
left={boxModel.borderLeft}
|
|
71
|
+
color="#58A6FF"
|
|
72
|
+
bg="rgba(88, 166, 255, 0.06)"
|
|
73
|
+
label="border"
|
|
74
|
+
inner={
|
|
75
|
+
<BoxRow
|
|
76
|
+
top={boxModel.paddingTop}
|
|
77
|
+
right={boxModel.paddingRight}
|
|
78
|
+
bottom={boxModel.paddingBottom}
|
|
79
|
+
left={boxModel.paddingLeft}
|
|
80
|
+
color="#3FB950"
|
|
81
|
+
bg="rgba(63, 185, 80, 0.06)"
|
|
82
|
+
label="padding"
|
|
83
|
+
inner={
|
|
84
|
+
<div className="text-center py-1 bg-[#0D1117] rounded-sm text-[#E6EDF3]">
|
|
85
|
+
<div className="text-[9px] text-[#6E7681]">content</div>
|
|
86
|
+
<div>{fmt(boxModel.width)} x {fmt(boxModel.height)}</div>
|
|
87
|
+
</div>
|
|
88
|
+
}
|
|
89
|
+
/>
|
|
90
|
+
}
|
|
91
|
+
/>
|
|
92
|
+
}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
package/cli/init.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @stsgs1980/fab-inspector init
|
|
4
|
+
*
|
|
5
|
+
* Автоподключение инспектора в Next.js App Router проект.
|
|
6
|
+
* Создаёт API-роут /api/source и вставляет импорт + JSX в layout.tsx.
|
|
7
|
+
* Идемпотентно: безопасно запускать многократно.
|
|
8
|
+
*
|
|
9
|
+
* Использование:
|
|
10
|
+
* bun add @stsgs1980/fab-inspector -D
|
|
11
|
+
* npx @stsgs1980/fab-inspector init
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
14
|
+
import { resolve } from 'node:path';
|
|
15
|
+
|
|
16
|
+
const PKG = '@stsgs1980/fab-inspector';
|
|
17
|
+
const IMPORT_LINE = `import { SelectElementFab } from '${PKG}';`;
|
|
18
|
+
const ROUTE_REL = 'src/app/api/source/route.ts';
|
|
19
|
+
const ROUTE_CONTENT = `// FabInspector source endpoint.
|
|
20
|
+
// Handler живёт внутри npm-пакета — этот файл просто реэкспортирует его.
|
|
21
|
+
// При обновлении пакета ничего менять тут не надо.
|
|
22
|
+
export { GET } from '${PKG}/api/source';
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
const LAYOUT_CANDIDATES = [
|
|
26
|
+
'src/app/layout.tsx',
|
|
27
|
+
'src/app/layout.ts',
|
|
28
|
+
'src/app/page.tsx',
|
|
29
|
+
'src/app/page.ts',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function findLayout(cwd) {
|
|
33
|
+
for (const f of LAYOUT_CANDIDATES) {
|
|
34
|
+
const p = resolve(cwd, f);
|
|
35
|
+
if (existsSync(p)) return p;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ensureImport(code) {
|
|
41
|
+
if (code.includes('SelectElementFab')) return { code, added: false };
|
|
42
|
+
const lines = code.split('\n');
|
|
43
|
+
let lastImportIdx = -1;
|
|
44
|
+
for (let i = 0; i < lines.length; i++) {
|
|
45
|
+
if (/^import\s/.test(lines[i])) lastImportIdx = i;
|
|
46
|
+
}
|
|
47
|
+
if (lastImportIdx === -1) {
|
|
48
|
+
return { code: IMPORT_LINE + '\n' + code, added: true };
|
|
49
|
+
}
|
|
50
|
+
lines.splice(lastImportIdx + 1, 0, IMPORT_LINE);
|
|
51
|
+
return { code: lines.join('\n'), added: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ensureJsx(code) {
|
|
55
|
+
if (code.includes('<SelectElementFab')) return { code, added: false };
|
|
56
|
+
|
|
57
|
+
// </body> — стандартный случай для App Router root layout
|
|
58
|
+
const bodyMatch = code.match(/^(\s*)<\/body>/m);
|
|
59
|
+
if (bodyMatch) {
|
|
60
|
+
const indent = bodyMatch[1];
|
|
61
|
+
const insertion = `${indent} <SelectElementFab />\n${indent}</body>`;
|
|
62
|
+
return { code: code.replace(/^(\s*)<\/body>/m, insertion), added: true };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// </html> — если почему-то нет body
|
|
66
|
+
const htmlMatch = code.match(/^(\s*)<\/html>/m);
|
|
67
|
+
if (htmlMatch) {
|
|
68
|
+
const indent = htmlMatch[1];
|
|
69
|
+
const insertion = `${indent} <SelectElementFab />\n${indent}</html>`;
|
|
70
|
+
return { code: code.replace(/^(\s*)<\/html>/m, insertion), added: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { code, added: false };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ensureRoute(cwd) {
|
|
77
|
+
const p = resolve(cwd, ROUTE_REL);
|
|
78
|
+
if (existsSync(p)) return false;
|
|
79
|
+
mkdirSync(resolve(cwd, 'src/app/api/source'), { recursive: true });
|
|
80
|
+
writeFileSync(p, ROUTE_CONTENT);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function detectMissingPeers(cwd) {
|
|
85
|
+
const pkgPath = resolve(cwd, 'package.json');
|
|
86
|
+
if (!existsSync(pkgPath)) return [];
|
|
87
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
88
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
89
|
+
const peers = ['framer-motion', 'react-syntax-highlighter'];
|
|
90
|
+
return peers.filter((p) => !deps[p]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function main() {
|
|
94
|
+
const cwd = process.cwd();
|
|
95
|
+
console.log('');
|
|
96
|
+
console.log(' @stsgs1980/fab-inspector — init');
|
|
97
|
+
console.log(' --------------------------------');
|
|
98
|
+
|
|
99
|
+
const layout = findLayout(cwd);
|
|
100
|
+
if (!layout) {
|
|
101
|
+
console.error(' X No src/app/layout.tsx or src/app/page.tsx found.');
|
|
102
|
+
console.error(' Run from the root of a Next.js App Router project.');
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const relLayout = layout.replace(cwd + '/', '');
|
|
107
|
+
let code = readFileSync(layout, 'utf-8');
|
|
108
|
+
|
|
109
|
+
const importRes = ensureImport(code);
|
|
110
|
+
code = importRes.code;
|
|
111
|
+
const jsxRes = ensureJsx(code);
|
|
112
|
+
code = jsxRes.code;
|
|
113
|
+
|
|
114
|
+
if (importRes.added || jsxRes.added) {
|
|
115
|
+
writeFileSync(layout, code);
|
|
116
|
+
console.log(` + Updated ${relLayout}`);
|
|
117
|
+
} else {
|
|
118
|
+
console.log(` ok Already wired: ${relLayout}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const created = ensureRoute(cwd);
|
|
122
|
+
console.log(created
|
|
123
|
+
? ` + Created ${ROUTE_REL}`
|
|
124
|
+
: ` ok Already exists: ${ROUTE_REL}`);
|
|
125
|
+
|
|
126
|
+
const missing = detectMissingPeers(cwd);
|
|
127
|
+
console.log('');
|
|
128
|
+
if (missing.length) {
|
|
129
|
+
console.log(' ! Peer dependencies missing:');
|
|
130
|
+
missing.forEach((p) => console.log(` - ${p}`));
|
|
131
|
+
console.log(' Install them:');
|
|
132
|
+
console.log(` bun add ${missing.join(' ')}`);
|
|
133
|
+
console.log(' (or: npm i ' + missing.join(' ') + ' -D)');
|
|
134
|
+
} else {
|
|
135
|
+
console.log(' ok Peer dependencies installed');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.log('');
|
|
139
|
+
console.log(' Start dev server — FAB appears bottom-right.');
|
|
140
|
+
console.log(' Press Esc to close inspector.');
|
|
141
|
+
console.log('');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
main();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function HighlightOverlay({
|
|
2
|
+
highlightBox,
|
|
3
|
+
}: {
|
|
4
|
+
highlightBox: DOMRect;
|
|
5
|
+
}) {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
data-se-highlight
|
|
9
|
+
className="fixed pointer-events-none z-[90]"
|
|
10
|
+
style={{
|
|
11
|
+
top: highlightBox.top,
|
|
12
|
+
left: highlightBox.left,
|
|
13
|
+
width: highlightBox.width,
|
|
14
|
+
height: highlightBox.height,
|
|
15
|
+
border: '1px dashed #58A6FF',
|
|
16
|
+
backgroundColor: 'rgba(56, 139, 253, 0.06)',
|
|
17
|
+
borderRadius: '3px',
|
|
18
|
+
transition: 'all 0.1s ease-out',
|
|
19
|
+
}}
|
|
20
|
+
aria-hidden="true"
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
2
|
+
|
|
3
|
+
export function InspectorFab({
|
|
4
|
+
active,
|
|
5
|
+
onToggle,
|
|
6
|
+
showTooltip,
|
|
7
|
+
}: {
|
|
8
|
+
active: boolean;
|
|
9
|
+
onToggle: () => void;
|
|
10
|
+
showTooltip: boolean;
|
|
11
|
+
}) {
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
<motion.button
|
|
15
|
+
data-se-fab
|
|
16
|
+
onClick={onToggle}
|
|
17
|
+
className={`
|
|
18
|
+
fixed bottom-6 right-6 z-[90] w-12 h-12 rounded-full
|
|
19
|
+
flex items-center justify-center
|
|
20
|
+
shadow-lg transition-all duration-200 cursor-pointer
|
|
21
|
+
focus:outline-none focus-visible:ring-2 focus-visible:ring-[#58A6FF] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0D1117]
|
|
22
|
+
${active
|
|
23
|
+
? 'bg-[#1F6FEB] text-[#F0F6FC] shadow-[#1F6FEB]/30'
|
|
24
|
+
: 'bg-[#21262D] text-[#E6EDF3]/80 hover:bg-[#30363D] hover:text-[#E6EDF3] shadow-black/40'
|
|
25
|
+
}
|
|
26
|
+
`}
|
|
27
|
+
whileHover={{ scale: 1.08 }}
|
|
28
|
+
whileTap={{ scale: 0.95 }}
|
|
29
|
+
aria-label={active ? 'Закрыть инспектор элементов' : 'Открыть инспектор элементов'}
|
|
30
|
+
title={active ? 'Закрыть инспектор (Esc)' : 'Инспектор элементов'}
|
|
31
|
+
>
|
|
32
|
+
{active ? (
|
|
33
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
34
|
+
<path d="M18 6 6 18" />
|
|
35
|
+
<path d="m6 6 12 12" />
|
|
36
|
+
</svg>
|
|
37
|
+
) : (
|
|
38
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
39
|
+
<path d="m19 19-14-14" />
|
|
40
|
+
<path d="m5 5 14 0" />
|
|
41
|
+
<path d="m5 5 0 14" />
|
|
42
|
+
</svg>
|
|
43
|
+
)}
|
|
44
|
+
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-[#58A6FF] text-[#0D1117] text-[9px] font-bold flex items-center justify-center leading-none select-none">
|
|
45
|
+
3.4.1
|
|
46
|
+
</span>
|
|
47
|
+
</motion.button>
|
|
48
|
+
|
|
49
|
+
<AnimatePresence>
|
|
50
|
+
{showTooltip && (
|
|
51
|
+
<motion.div
|
|
52
|
+
initial={{ opacity: 0, y: 8 }}
|
|
53
|
+
animate={{ opacity: 1, y: 0 }}
|
|
54
|
+
exit={{ opacity: 0, y: 8 }}
|
|
55
|
+
className="fixed bottom-20 right-6 z-[90] bg-[#21262D] text-[#E6EDF3] text-xs px-3 py-1.5 rounded-lg shadow-lg border border-[#30363D] whitespace-nowrap pointer-events-none"
|
|
56
|
+
>
|
|
57
|
+
Кликните на элемент для инспекции
|
|
58
|
+
</motion.div>
|
|
59
|
+
)}
|
|
60
|
+
</AnimatePresence>
|
|
61
|
+
</>
|
|
62
|
+
);
|
|
63
|
+
}
|