@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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import type { ElementInfo, SnippetData } from './types';
|
|
4
|
+
import {
|
|
5
|
+
SourceSection,
|
|
6
|
+
ClassesSection,
|
|
7
|
+
TextSection,
|
|
8
|
+
CssPathSection,
|
|
9
|
+
HtmlSection,
|
|
10
|
+
StylesSection,
|
|
11
|
+
SnippetSection,
|
|
12
|
+
} from './panel-sections';
|
|
13
|
+
import { BoxModelSection } from './box-model-section';
|
|
14
|
+
|
|
15
|
+
export function InspectorPanel({
|
|
16
|
+
elementInfo,
|
|
17
|
+
panelPos,
|
|
18
|
+
isDragging,
|
|
19
|
+
onDragStart,
|
|
20
|
+
onClose,
|
|
21
|
+
snippet,
|
|
22
|
+
snippetLoading,
|
|
23
|
+
}: {
|
|
24
|
+
elementInfo: ElementInfo;
|
|
25
|
+
panelPos: { x: number; y: number };
|
|
26
|
+
isDragging: boolean;
|
|
27
|
+
onDragStart: (e: React.MouseEvent) => void;
|
|
28
|
+
onClose: () => void;
|
|
29
|
+
snippet: SnippetData | null;
|
|
30
|
+
snippetLoading: boolean;
|
|
31
|
+
}) {
|
|
32
|
+
const [expanded, setExpanded] = useState(false);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<motion.div
|
|
36
|
+
ref={(node) => {
|
|
37
|
+
if (node) {
|
|
38
|
+
(node as unknown as HTMLElement).setAttribute('data-se-panel', '');
|
|
39
|
+
}
|
|
40
|
+
}}
|
|
41
|
+
initial={{ opacity: 0, scale: 0.95, y: 4 }}
|
|
42
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
43
|
+
exit={{ opacity: 0, scale: 0.95, y: 4 }}
|
|
44
|
+
transition={{ duration: 0.15 }}
|
|
45
|
+
className={`fixed z-[95] bg-[#161B22] rounded-lg shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-[#30363D] overflow-hidden${isDragging ? '' : ' cursor-default'}`}
|
|
46
|
+
style={{
|
|
47
|
+
top: panelPos.y,
|
|
48
|
+
left: panelPos.x,
|
|
49
|
+
width: 400,
|
|
50
|
+
maxHeight: 'calc(100vh - 32px)',
|
|
51
|
+
userSelect: isDragging ? 'none' : 'auto',
|
|
52
|
+
}}
|
|
53
|
+
onClick={(e) => e.stopPropagation()}
|
|
54
|
+
>
|
|
55
|
+
{/* Header (drag handle) */}
|
|
56
|
+
<div
|
|
57
|
+
onMouseDown={onDragStart}
|
|
58
|
+
className={`flex items-center justify-between px-4 py-2.5 bg-[#1C2128] border-b border-[#30363D] select-none${isDragging ? ' cursor-grabbing' : ' cursor-grab'}`}
|
|
59
|
+
>
|
|
60
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
61
|
+
<span className="inline-flex items-center justify-center w-5 h-5 rounded bg-[#1F6FEB] text-[#F0F6FC] text-[10px] font-bold flex-shrink-0">
|
|
62
|
+
</>
|
|
63
|
+
</span>
|
|
64
|
+
<span className="text-sm font-semibold text-[#E6EDF3] truncate">
|
|
65
|
+
{elementInfo.tag}
|
|
66
|
+
{elementInfo.id && <span className="text-[#58A6FF]">#{elementInfo.id}</span>}
|
|
67
|
+
</span>
|
|
68
|
+
{elementInfo.source && (
|
|
69
|
+
<span className="text-[11px] text-[#6E7681] font-mono truncate hidden sm:inline">
|
|
70
|
+
{elementInfo.source.file.split('/').pop()}:{elementInfo.source.line}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
<div className="flex items-center gap-0.5 flex-shrink-0">
|
|
75
|
+
{elementInfo.source && (
|
|
76
|
+
<button
|
|
77
|
+
onClick={() => {
|
|
78
|
+
const lines = [
|
|
79
|
+
elementInfo.source ? `File: ${elementInfo.source.file}:${elementInfo.source.line}` : '',
|
|
80
|
+
`Tag: <${elementInfo.tag}${elementInfo.id ? `#${elementInfo.id}` : ''}>`,
|
|
81
|
+
elementInfo.text ? `Text: "${elementInfo.text}"` : '',
|
|
82
|
+
].filter(Boolean).join('\n');
|
|
83
|
+
navigator.clipboard.writeText(lines).catch(() => {});
|
|
84
|
+
}}
|
|
85
|
+
className="p-1.5 rounded hover:bg-[#21262D] text-[#8B949E] hover:text-[#58A6FF] transition-colors cursor-pointer"
|
|
86
|
+
aria-label="Copy task context"
|
|
87
|
+
title="Copy task context (file, tag, text)"
|
|
88
|
+
>
|
|
89
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
90
|
+
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
|
91
|
+
<polyline points="14 2 14 8 20 8" />
|
|
92
|
+
<line x1="16" y1="13" x2="8" y2="13" />
|
|
93
|
+
<line x1="16" y1="17" x2="8" y2="17" />
|
|
94
|
+
<line x1="10" y1="9" x2="8" y2="9" />
|
|
95
|
+
</svg>
|
|
96
|
+
</button>
|
|
97
|
+
)}
|
|
98
|
+
{elementInfo.source && (
|
|
99
|
+
<button
|
|
100
|
+
onClick={() =>
|
|
101
|
+
navigator.clipboard
|
|
102
|
+
.writeText(`${elementInfo.source!.file}:${elementInfo.source!.line}`)
|
|
103
|
+
.catch(() => {})
|
|
104
|
+
}
|
|
105
|
+
className="p-1.5 rounded hover:bg-[#21262D] text-[#8B949E] hover:text-[#58A6FF] transition-colors cursor-pointer"
|
|
106
|
+
aria-label="Copy file path"
|
|
107
|
+
title="Copy file:line"
|
|
108
|
+
>
|
|
109
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
110
|
+
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
|
111
|
+
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
|
112
|
+
</svg>
|
|
113
|
+
</button>
|
|
114
|
+
)}
|
|
115
|
+
{/* Always-visible quick copy: tag + id + classes + text.
|
|
116
|
+
Работает без data-src — можно скопировать базу из свёрнутой панели. */}
|
|
117
|
+
<button
|
|
118
|
+
onClick={() => {
|
|
119
|
+
const parts = [
|
|
120
|
+
`<${elementInfo.tag}${elementInfo.id ? `#${elementInfo.id}` : ''}>`,
|
|
121
|
+
elementInfo.classes ? elementInfo.classes : '',
|
|
122
|
+
elementInfo.text ? `"${elementInfo.text}"` : '',
|
|
123
|
+
].filter(Boolean);
|
|
124
|
+
navigator.clipboard.writeText(parts.join('\n')).catch(() => {});
|
|
125
|
+
}}
|
|
126
|
+
className="p-1.5 rounded hover:bg-[#21262D] text-[#8B949E] hover:text-[#58A6FF] transition-colors cursor-pointer"
|
|
127
|
+
aria-label="Скопировать информацию об элементе"
|
|
128
|
+
title="Копировать (тег + классы + текст)"
|
|
129
|
+
>
|
|
130
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
131
|
+
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
|
132
|
+
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
|
133
|
+
</svg>
|
|
134
|
+
</button>
|
|
135
|
+
<div className="w-px h-4 bg-[#30363D] mx-0.5" />
|
|
136
|
+
<button
|
|
137
|
+
onClick={() => setExpanded((v) => !v)}
|
|
138
|
+
className="p-1.5 rounded hover:bg-[#21262D] text-[#8B949E] hover:text-[#E6EDF3] transition-colors cursor-pointer"
|
|
139
|
+
aria-label={expanded ? 'Свернуть детали' : 'Развернуть детали'}
|
|
140
|
+
title={expanded ? 'Свернуть' : 'Детали'}
|
|
141
|
+
>
|
|
142
|
+
<svg
|
|
143
|
+
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
|
144
|
+
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
|
145
|
+
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
|
|
146
|
+
>
|
|
147
|
+
<polyline points="6 9 12 15 18 9" />
|
|
148
|
+
</svg>
|
|
149
|
+
</button>
|
|
150
|
+
<button
|
|
151
|
+
onClick={onClose}
|
|
152
|
+
className="p-1.5 rounded hover:bg-[#DA3633]/20 text-[#8B949E] hover:text-[#F85149] transition-colors cursor-pointer"
|
|
153
|
+
aria-label="Закрыть панель"
|
|
154
|
+
>
|
|
155
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
156
|
+
<path d="M18 6 6 18" />
|
|
157
|
+
<path d="m6 6 12 12" />
|
|
158
|
+
</svg>
|
|
159
|
+
</button>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{/* Collapsible content */}
|
|
164
|
+
<AnimatePresence initial={false}>
|
|
165
|
+
{expanded && (
|
|
166
|
+
<motion.div
|
|
167
|
+
initial={{ height: 0, opacity: 0 }}
|
|
168
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
169
|
+
exit={{ height: 0, opacity: 0 }}
|
|
170
|
+
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
|
171
|
+
className="overflow-hidden"
|
|
172
|
+
>
|
|
173
|
+
<div className="overflow-y-auto custom-scrollbar" style={{ maxHeight: 'calc(100vh - 120px)' }}>
|
|
174
|
+
{elementInfo.source && <SourceSection source={elementInfo.source} />}
|
|
175
|
+
{elementInfo.classes && <ClassesSection classes={elementInfo.classes} />}
|
|
176
|
+
{elementInfo.text && <TextSection text={elementInfo.text} />}
|
|
177
|
+
{elementInfo.cssPath && <CssPathSection cssPath={elementInfo.cssPath} />}
|
|
178
|
+
<HtmlSection outerHTML={elementInfo.outerHTML} />
|
|
179
|
+
<StylesSection styles={elementInfo.computedStyles} />
|
|
180
|
+
{elementInfo.boxModel && <BoxModelSection boxModel={elementInfo.boxModel} />}
|
|
181
|
+
{elementInfo.source && (
|
|
182
|
+
<SnippetSection source={elementInfo.source} snippet={snippet} snippetLoading={snippetLoading} />
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
</motion.div>
|
|
186
|
+
)}
|
|
187
|
+
</AnimatePresence>
|
|
188
|
+
</motion.div>
|
|
189
|
+
);
|
|
190
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stsgs1980/fab-inspector",
|
|
3
|
+
"version": "3.4.1",
|
|
4
|
+
"description": "Visual element inspector for Next.js dev mode — GitHub Dark themed, with SyntaxHighlighter and Box-model visualization.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "STS",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/stsgs1980/FabInspector.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"inspector",
|
|
13
|
+
"devtools",
|
|
14
|
+
"nextjs",
|
|
15
|
+
"react",
|
|
16
|
+
"element-inspector",
|
|
17
|
+
"dom-inspector",
|
|
18
|
+
"box-model",
|
|
19
|
+
"github-dark"
|
|
20
|
+
],
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"framer-motion": ">=11.0.0",
|
|
23
|
+
"next": ">=15.0.0",
|
|
24
|
+
"react": ">=19.0.0",
|
|
25
|
+
"react-syntax-highlighter": ">=15.0.0"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"install:inspector": "bash scripts/install.sh",
|
|
29
|
+
"uninstall:inspector": "bash scripts/uninstall.sh",
|
|
30
|
+
"update:inspector": "bash scripts/update.sh",
|
|
31
|
+
"lint": "eslint . --max-warnings=0",
|
|
32
|
+
"test": "bun test",
|
|
33
|
+
"typecheck": "tsc --noEmit"
|
|
34
|
+
},
|
|
35
|
+
"exports": {
|
|
36
|
+
".": "./index.ts",
|
|
37
|
+
"./types": "./types.ts",
|
|
38
|
+
"./api/source": "./api-source-route.ts",
|
|
39
|
+
"./plugins/data-src-plugin": "./plugins/data-src-plugin.ts",
|
|
40
|
+
"./package.json": "./package.json"
|
|
41
|
+
},
|
|
42
|
+
"bin": {
|
|
43
|
+
"fab-inspector": "./cli/init.mjs"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"*.ts",
|
|
47
|
+
"*.tsx",
|
|
48
|
+
"plugins/",
|
|
49
|
+
"cli/",
|
|
50
|
+
"README.md"
|
|
51
|
+
],
|
|
52
|
+
"sideEffects": false,
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { ElementInfo, SnippetData } from './types';
|
|
2
|
+
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
3
|
+
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
4
|
+
|
|
5
|
+
function CopyButton({ text, label }: { text: string; label?: string }) {
|
|
6
|
+
const copy = () => navigator.clipboard.writeText(text).catch(() => {});
|
|
7
|
+
return (
|
|
8
|
+
<button
|
|
9
|
+
onClick={copy}
|
|
10
|
+
className="text-[10px] text-[#58A6FF] hover:text-[#79C0FF] transition-colors cursor-pointer"
|
|
11
|
+
title={label ?? 'Копировать'}
|
|
12
|
+
>
|
|
13
|
+
{label ?? 'копировать'}
|
|
14
|
+
</button>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function SourceSection({ source }: { source: NonNullable<ElementInfo['source']> }) {
|
|
19
|
+
const copy = () =>
|
|
20
|
+
navigator.clipboard.writeText(`${source.file}:${source.line}`).catch(() => {});
|
|
21
|
+
return (
|
|
22
|
+
<div className="px-4 py-2.5 bg-[#0D1117] border-b border-[#30363D]">
|
|
23
|
+
<div className="flex items-center justify-between mb-1">
|
|
24
|
+
<div className="text-[11px] font-semibold text-[#58A6FF] uppercase tracking-wider">
|
|
25
|
+
Источник
|
|
26
|
+
</div>
|
|
27
|
+
<CopyButton text={`${source.file}:${source.line}`} />
|
|
28
|
+
</div>
|
|
29
|
+
<div
|
|
30
|
+
className="font-mono text-xs text-[#E6EDF3] cursor-pointer hover:text-[#58A6FF] transition-colors break-all"
|
|
31
|
+
onClick={copy}
|
|
32
|
+
title="Кликните, чтобы скопировать"
|
|
33
|
+
>
|
|
34
|
+
{source.file}
|
|
35
|
+
<span className="text-[#58A6FF]">:{source.line}</span>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ClassesSection({ classes }: { classes: string }) {
|
|
42
|
+
const copy = () => navigator.clipboard.writeText(classes).catch(() => {});
|
|
43
|
+
return (
|
|
44
|
+
<div className="px-4 py-2.5 border-b border-[#30363D]">
|
|
45
|
+
<div className="flex items-center justify-between mb-1">
|
|
46
|
+
<div className="text-[11px] font-semibold text-[#8B949E] uppercase tracking-wider">Classes</div>
|
|
47
|
+
<CopyButton text={classes} />
|
|
48
|
+
</div>
|
|
49
|
+
<div
|
|
50
|
+
className="font-mono text-xs text-[#E6EDF3] bg-[#0D1117] rounded px-2.5 py-1.5 break-all cursor-pointer hover:bg-[#161B22] transition-colors border border-[#30363D]"
|
|
51
|
+
onClick={copy}
|
|
52
|
+
title="Кликните, чтобы скопировать"
|
|
53
|
+
>
|
|
54
|
+
{classes.length > 300 ? classes.slice(0, 300) + '...' : classes}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function TextSection({ text }: { text: string }) {
|
|
61
|
+
const copy = () => navigator.clipboard.writeText(text).catch(() => {});
|
|
62
|
+
return (
|
|
63
|
+
<div className="px-4 py-2.5 border-b border-[#30363D]">
|
|
64
|
+
<div className="flex items-center justify-between mb-1">
|
|
65
|
+
<div className="text-[11px] font-semibold text-[#8B949E] uppercase tracking-wider">Text</div>
|
|
66
|
+
<CopyButton text={text} />
|
|
67
|
+
</div>
|
|
68
|
+
<div
|
|
69
|
+
className="text-xs text-[#E6EDF3] bg-[#0D1117] rounded px-2.5 py-1.5 cursor-pointer hover:bg-[#161B22] transition-colors border border-[#30363D]"
|
|
70
|
+
onClick={copy}
|
|
71
|
+
title="Кликните, чтобы скопировать текст"
|
|
72
|
+
>
|
|
73
|
+
{text}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function CssPathSection({ cssPath }: { cssPath: string }) {
|
|
80
|
+
return (
|
|
81
|
+
<div className="px-4 py-2.5 border-b border-[#30363D]">
|
|
82
|
+
<div className="flex items-center justify-between mb-1">
|
|
83
|
+
<div className="text-[11px] font-semibold text-[#8B949E] uppercase tracking-wider">CSS Path</div>
|
|
84
|
+
<CopyButton text={cssPath} />
|
|
85
|
+
</div>
|
|
86
|
+
<div
|
|
87
|
+
className="font-mono text-[11px] text-[#E6EDF3] bg-[#0D1117] rounded px-2.5 py-1.5 break-all cursor-pointer hover:bg-[#161B22] transition-colors border border-[#30363D]"
|
|
88
|
+
onClick={() => navigator.clipboard.writeText(cssPath).catch(() => {})}
|
|
89
|
+
title="Кликните, чтобы скопировать CSS-путь"
|
|
90
|
+
>
|
|
91
|
+
{cssPath}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function HtmlSection({ outerHTML }: { outerHTML: string }) {
|
|
98
|
+
return (
|
|
99
|
+
<div className="px-4 py-2.5 border-b border-[#30363D]">
|
|
100
|
+
<div className="flex items-center justify-between mb-1">
|
|
101
|
+
<div className="text-[11px] font-semibold text-[#8B949E] uppercase tracking-wider">HTML</div>
|
|
102
|
+
<CopyButton text={outerHTML} />
|
|
103
|
+
</div>
|
|
104
|
+
<pre
|
|
105
|
+
className="font-mono text-[11px] text-[#E6EDF3] bg-[#0D1117] rounded px-2.5 py-1.5 overflow-x-auto whitespace-pre-wrap break-all cursor-pointer hover:bg-[#161B22] transition-colors max-h-40 border border-[#30363D]"
|
|
106
|
+
onClick={() => navigator.clipboard.writeText(outerHTML).catch(() => {})}
|
|
107
|
+
title="Кликните, чтобы скопировать HTML"
|
|
108
|
+
>
|
|
109
|
+
{outerHTML}
|
|
110
|
+
</pre>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function StylesSection({ styles }: { styles: Record<string, string> }) {
|
|
116
|
+
return (
|
|
117
|
+
<div className="px-4 py-2.5 border-b border-[#30363D]">
|
|
118
|
+
<div className="text-[11px] font-semibold text-[#8B949E] uppercase tracking-wider mb-1.5">
|
|
119
|
+
Styles
|
|
120
|
+
</div>
|
|
121
|
+
<div className="grid grid-cols-2 gap-1.5">
|
|
122
|
+
<div className="bg-[#0D1117] rounded px-2 py-1 text-[11px] font-mono text-[#E6EDF3] border border-[#30363D]">
|
|
123
|
+
{styles.width} x {styles.height}
|
|
124
|
+
</div>
|
|
125
|
+
<div className="bg-[#0D1117] rounded px-2 py-1 text-[11px] font-mono text-[#E6EDF3] border border-[#30363D]">
|
|
126
|
+
{styles.fontSize}
|
|
127
|
+
</div>
|
|
128
|
+
<div className="bg-[#0D1117] rounded px-2 py-1 text-[11px] font-mono text-[#E6EDF3] border border-[#30363D]">
|
|
129
|
+
weight: {styles.fontWeight}
|
|
130
|
+
</div>
|
|
131
|
+
<div className="bg-[#0D1117] rounded px-2 py-1 text-[11px] font-mono text-[#E6EDF3] border border-[#30363D]">
|
|
132
|
+
lh: {styles.lineHeight}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div className="mt-1.5 flex items-center gap-1.5 bg-[#0D1117] rounded px-2 py-1 text-[11px] font-mono text-[#E6EDF3] border border-[#30363D]">
|
|
136
|
+
<span
|
|
137
|
+
className="inline-block w-3 h-3 rounded-sm border border-[#30363D] flex-shrink-0"
|
|
138
|
+
style={{ backgroundColor: styles.color }}
|
|
139
|
+
/>
|
|
140
|
+
{styles.color}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function SnippetSection({
|
|
147
|
+
source,
|
|
148
|
+
snippet,
|
|
149
|
+
snippetLoading,
|
|
150
|
+
}: {
|
|
151
|
+
source: NonNullable<ElementInfo['source']>;
|
|
152
|
+
snippet: SnippetData | null;
|
|
153
|
+
snippetLoading: boolean;
|
|
154
|
+
}) {
|
|
155
|
+
const ext = source.file.split('.').pop() || '';
|
|
156
|
+
const lang = ['tsx', 'jsx'].includes(ext) ? 'tsx' : 'typescript';
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div className="px-4 py-2.5">
|
|
160
|
+
<div className="text-[11px] font-semibold text-[#8B949E] uppercase tracking-wider mb-1.5">
|
|
161
|
+
Код ({source.file.split('/').pop()})
|
|
162
|
+
</div>
|
|
163
|
+
{snippetLoading && <div className="text-xs text-[#6E7681] py-2">Загрузка...</div>}
|
|
164
|
+
{snippet && (
|
|
165
|
+
<div className="bg-[#0D1117] rounded-md overflow-hidden border border-[#30363D]">
|
|
166
|
+
<div className="flex items-center justify-between px-3 py-1.5 bg-[#161B22] border-b border-[#30363D]">
|
|
167
|
+
<span className="text-[#6E7681] text-[10px] font-mono">
|
|
168
|
+
{snippet.snippet.startLine}–{snippet.snippet.startLine + snippet.snippet.lines.length - 1} из {snippet.totalLines}
|
|
169
|
+
</span>
|
|
170
|
+
<CopyButton text={snippet.snippet.lines.join('\n')} label="копировать" />
|
|
171
|
+
</div>
|
|
172
|
+
<SyntaxHighlighter
|
|
173
|
+
language={lang}
|
|
174
|
+
style={vscDarkPlus}
|
|
175
|
+
showLineNumbers
|
|
176
|
+
startingLineNumber={snippet.snippet.startLine}
|
|
177
|
+
lineProps={(lineNumber) => ({
|
|
178
|
+
style: lineNumber === snippet.snippet.highlightLine
|
|
179
|
+
? { background: 'rgba(56, 139, 253, 0.15)', display: 'block' }
|
|
180
|
+
: { display: 'block' },
|
|
181
|
+
})}
|
|
182
|
+
customStyle={{
|
|
183
|
+
background: 'transparent',
|
|
184
|
+
padding: '8px',
|
|
185
|
+
margin: 0,
|
|
186
|
+
fontSize: '12px',
|
|
187
|
+
lineHeight: '1.6',
|
|
188
|
+
}}
|
|
189
|
+
lineNumberStyle={{
|
|
190
|
+
color: '#484F58',
|
|
191
|
+
minWidth: '2.5em',
|
|
192
|
+
paddingRight: '1em',
|
|
193
|
+
}}
|
|
194
|
+
codeTagProps={{ style: { fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace' } }}
|
|
195
|
+
>
|
|
196
|
+
{snippet.snippet.lines.join('\n')}
|
|
197
|
+
</SyntaxHighlighter>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
{!snippetLoading && !snippet && (
|
|
201
|
+
<div className="text-xs text-[#6E7681] py-2">
|
|
202
|
+
Не удалось загрузить исходный код (только в dev-режиме)
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* data-src Injector Plugin for Turbopack (Next.js 15.3+)
|
|
3
|
+
*
|
|
4
|
+
* Автоматически добавляет data-src="file:line" атрибут на JSX-элементы.
|
|
5
|
+
*
|
|
6
|
+
* Подключение в next.config.ts:
|
|
7
|
+
* import { dataSrcPlugin } from './src/components/inspector/plugins/data-src-plugin';
|
|
8
|
+
*
|
|
9
|
+
* const nextConfig = {
|
|
10
|
+
* experimental: {
|
|
11
|
+
* turbo: {
|
|
12
|
+
* plugins: [dataSrcPlugin()],
|
|
13
|
+
* },
|
|
14
|
+
* },
|
|
15
|
+
* };
|
|
16
|
+
*
|
|
17
|
+
* Пропускает: node_modules, inspector/, строки с комментариями и декларациями.
|
|
18
|
+
* Только dev-режим (Next.js не вызывает turbo plugins в production build).
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ API экспериментальное и может измениться в следующих версиях Next.js.
|
|
21
|
+
* При проблемах — добавляйте data-src вручную.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type TransformResult = { code: string };
|
|
25
|
+
|
|
26
|
+
export const dataSrcPlugin = () => ({
|
|
27
|
+
name: 'data-src-injector',
|
|
28
|
+
|
|
29
|
+
setup(api: {
|
|
30
|
+
transform: (
|
|
31
|
+
opts: { filter: RegExp },
|
|
32
|
+
handler: (args: { code: string; resource: string }) => TransformResult,
|
|
33
|
+
) => void;
|
|
34
|
+
}) {
|
|
35
|
+
api.transform({ filter: /\.(tsx|jsx)$/ }, ({ code, resource }) => {
|
|
36
|
+
if (
|
|
37
|
+
resource.includes('node_modules') ||
|
|
38
|
+
resource.includes('/inspector/')
|
|
39
|
+
) {
|
|
40
|
+
return { code };
|
|
41
|
+
}
|
|
42
|
+
return injectDataSrc(code, resource);
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function injectDataSrc(code: string, resource: string): TransformResult {
|
|
48
|
+
const lines = code.split('\n');
|
|
49
|
+
const relativePath = toRelativePath(resource);
|
|
50
|
+
const result: string[] = [];
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < lines.length; i++) {
|
|
53
|
+
const line = lines[i];
|
|
54
|
+
const lineNum = i + 1;
|
|
55
|
+
|
|
56
|
+
if (isJsxOpeningTag(line) && !hasDataSrc(line)) {
|
|
57
|
+
result.push(injectOnLine(line, relativePath, lineNum));
|
|
58
|
+
} else {
|
|
59
|
+
result.push(line);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { code: result.join('\n') };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function injectOnLine(line: string, path: string, lineNum: number): string {
|
|
67
|
+
const attr = ` data-src="${path}:${lineNum}"`;
|
|
68
|
+
return line.replace(
|
|
69
|
+
/(<[A-Za-z][A-Za-z0-9-]*)(\s|>)/,
|
|
70
|
+
(_match, tag: string, after: string) => {
|
|
71
|
+
if (after === '>') return `${tag}${attr}>`;
|
|
72
|
+
return `${tag}${attr} `;
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isJsxOpeningTag(line: string): boolean {
|
|
78
|
+
const t = line.trimStart();
|
|
79
|
+
if (
|
|
80
|
+
t.startsWith('//') ||
|
|
81
|
+
t.startsWith('*') ||
|
|
82
|
+
t.startsWith('/*') ||
|
|
83
|
+
t.startsWith('import ') ||
|
|
84
|
+
t.startsWith('export ') ||
|
|
85
|
+
t.startsWith('type ') ||
|
|
86
|
+
t.startsWith('interface ') ||
|
|
87
|
+
t.startsWith('function ') ||
|
|
88
|
+
t.startsWith('const ') ||
|
|
89
|
+
t.startsWith('let ') ||
|
|
90
|
+
t.startsWith('var ') ||
|
|
91
|
+
t.startsWith('{') ||
|
|
92
|
+
t.startsWith('}') ||
|
|
93
|
+
t.startsWith('return ') ||
|
|
94
|
+
t.startsWith('if ') ||
|
|
95
|
+
t.startsWith('else')
|
|
96
|
+
) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return /<[A-Za-z][A-Za-z0-9-]*[\s>]/.test(t);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hasDataSrc(line: string): boolean {
|
|
103
|
+
return /data-src\s*=/.test(line);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function toRelativePath(resource: string): string {
|
|
107
|
+
const idx = resource.indexOf('/src/');
|
|
108
|
+
if (idx !== -1) return resource.slice(idx + 1);
|
|
109
|
+
return resource.split('/').pop() || resource;
|
|
110
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { AnimatePresence } from 'framer-motion';
|
|
4
|
+
import { useElementInspector } from './use-element-inspector';
|
|
5
|
+
import { usePanelDrag } from './use-panel-drag';
|
|
6
|
+
import { HighlightOverlay } from './highlight-overlay';
|
|
7
|
+
import { InspectorPanel } from './inspector-panel';
|
|
8
|
+
import { InspectorFab } from './inspector-fab';
|
|
9
|
+
|
|
10
|
+
// Dev-only guard: FAB не должен попасть в production-bundle.
|
|
11
|
+
// Это страховка на случай, если потребитель забыл поставить пакет
|
|
12
|
+
// в devDependencies или не убрал импорт из layout для прод-сборки.
|
|
13
|
+
// В проде next/build подставляет NODE_ENV=production и tree-shaking
|
|
14
|
+
// вырезает весь модуль целиком (sideEffects: false в package.json).
|
|
15
|
+
const IS_DEV = process.env.NODE_ENV === 'development';
|
|
16
|
+
|
|
17
|
+
export function SelectElementFab() {
|
|
18
|
+
const {
|
|
19
|
+
active,
|
|
20
|
+
elementInfo,
|
|
21
|
+
panelPos,
|
|
22
|
+
setPanelPos,
|
|
23
|
+
highlightBox,
|
|
24
|
+
snippet,
|
|
25
|
+
snippetLoading,
|
|
26
|
+
toggleActive,
|
|
27
|
+
closePanel,
|
|
28
|
+
} = useElementInspector();
|
|
29
|
+
|
|
30
|
+
const { isDragging, handleDragStart } = usePanelDrag(panelPos, setPanelPos);
|
|
31
|
+
|
|
32
|
+
if (!IS_DEV) return null;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
{active && highlightBox && <HighlightOverlay highlightBox={highlightBox} />}
|
|
37
|
+
|
|
38
|
+
<AnimatePresence>
|
|
39
|
+
{active && elementInfo && (
|
|
40
|
+
<InspectorPanel
|
|
41
|
+
elementInfo={elementInfo}
|
|
42
|
+
panelPos={panelPos}
|
|
43
|
+
isDragging={isDragging}
|
|
44
|
+
onDragStart={handleDragStart}
|
|
45
|
+
onClose={closePanel}
|
|
46
|
+
snippet={snippet}
|
|
47
|
+
snippetLoading={snippetLoading}
|
|
48
|
+
/>
|
|
49
|
+
)}
|
|
50
|
+
</AnimatePresence>
|
|
51
|
+
|
|
52
|
+
<InspectorFab
|
|
53
|
+
active={active}
|
|
54
|
+
onToggle={toggleActive}
|
|
55
|
+
showTooltip={active && !elementInfo}
|
|
56
|
+
/>
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface SourceInfo {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface BoxModel {
|
|
7
|
+
marginTop: string;
|
|
8
|
+
marginRight: string;
|
|
9
|
+
marginBottom: string;
|
|
10
|
+
marginLeft: string;
|
|
11
|
+
paddingTop: string;
|
|
12
|
+
paddingRight: string;
|
|
13
|
+
paddingBottom: string;
|
|
14
|
+
paddingLeft: string;
|
|
15
|
+
borderTop: string;
|
|
16
|
+
borderRight: string;
|
|
17
|
+
borderBottom: string;
|
|
18
|
+
borderLeft: string;
|
|
19
|
+
width: string;
|
|
20
|
+
height: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ElementInfo {
|
|
24
|
+
tag: string;
|
|
25
|
+
id: string;
|
|
26
|
+
classes: string;
|
|
27
|
+
rect: DOMRect;
|
|
28
|
+
text: string;
|
|
29
|
+
outerHTML: string;
|
|
30
|
+
cssPath: string;
|
|
31
|
+
computedStyles: Record<string, string>;
|
|
32
|
+
boxModel: BoxModel | null;
|
|
33
|
+
source: SourceInfo | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SnippetData {
|
|
37
|
+
file: string;
|
|
38
|
+
line: number;
|
|
39
|
+
totalLines: number;
|
|
40
|
+
snippet: {
|
|
41
|
+
startLine: number;
|
|
42
|
+
lines: string[];
|
|
43
|
+
highlightLine: number;
|
|
44
|
+
};
|
|
45
|
+
}
|