@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.
@@ -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
+ &lt;/&gt;
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
+ }