|
1 | 1 | // Disable ESLint rule for underscore dangle usage in this file (React internals) |
2 | 2 | /* eslint-disable no-underscore-dangle */ |
3 | 3 |
|
4 | | -import { |
5 | | - ComponentInfo, |
6 | | - CSSDetailLevel, |
7 | | - CSSProperties, |
8 | | - DEFAULT_CSS_LEVEL, |
9 | | - DEFAULT_TEXT_DETAIL, |
10 | | - ElementPosition, |
11 | | - TargetedElement, |
12 | | - TextDetailLevel, |
13 | | - TextSnapshots, |
14 | | - RawPointedDOMElement, |
15 | | -} from '@mcp-pointer/shared/types'; |
16 | | -import { CSS_LEVEL_FIELD_MAP } from '@mcp-pointer/shared/detail'; |
| 4 | +import { RawPointedDOMElement } from '@mcp-pointer/shared/types'; |
17 | 5 | import logger from './logger'; |
18 | 6 |
|
19 | | -export interface ReactSourceInfo { |
20 | | - fileName: string; |
21 | | - lineNumber?: number; |
22 | | - columnNumber?: number; |
23 | | -} |
24 | | - |
25 | | -export interface ElementSerializationOptions { |
26 | | - textDetail?: TextDetailLevel; |
27 | | - cssLevel?: CSSDetailLevel; |
28 | | -} |
29 | | - |
30 | | -function toKebabCase(property: string): string { |
31 | | - return property |
32 | | - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') |
33 | | - .replace(/_/g, '-') |
34 | | - .toLowerCase(); |
35 | | -} |
36 | | - |
37 | | -function toCamelCase(property: string): string { |
38 | | - return property |
39 | | - .replace(/^-+/, '') |
40 | | - .replace(/-([a-z])/g, (_, char: string) => char.toUpperCase()); |
41 | | -} |
42 | | - |
43 | | -function getStyleValue(style: CSSStyleDeclaration, property: string): string | undefined { |
44 | | - const camelValue = (style as any)[property]; |
45 | | - if (typeof camelValue === 'string' && camelValue.trim().length > 0) { |
46 | | - return camelValue; |
47 | | - } |
48 | | - |
49 | | - const kebab = toKebabCase(property); |
50 | | - const value = style.getPropertyValue(kebab); |
51 | | - if (typeof value === 'string' && value.trim().length > 0) { |
52 | | - return value; |
53 | | - } |
54 | | - |
55 | | - return undefined; |
56 | | -} |
57 | | - |
58 | | -function extractFullCSSProperties(style: CSSStyleDeclaration): Record<string, string> { |
59 | | - const properties: Record<string, string> = {}; |
60 | | - |
61 | | - for (let i = 0; i < style.length; i += 1) { |
62 | | - const property = style.item(i); |
63 | | - |
64 | | - if (property && !property.startsWith('-')) { |
65 | | - const value = style.getPropertyValue(property); |
66 | | - if (typeof value === 'string' && value.trim().length > 0) { |
67 | | - const camel = toCamelCase(property); |
68 | | - properties[camel] = value; |
69 | | - } |
70 | | - } |
71 | | - } |
72 | | - |
73 | | - return properties; |
74 | | -} |
75 | | - |
76 | | -function getElementCSSProperties( |
77 | | - style: CSSStyleDeclaration, |
78 | | - cssLevel: CSSDetailLevel, |
79 | | - fullCSS: Record<string, string>, |
80 | | -): CSSProperties | undefined { |
81 | | - if (cssLevel === 0) { |
82 | | - return undefined; |
83 | | - } |
84 | | - |
85 | | - if (cssLevel === 3) { |
86 | | - return fullCSS; |
87 | | - } |
88 | | - |
89 | | - const fields = CSS_LEVEL_FIELD_MAP[cssLevel]; |
90 | | - const properties: CSSProperties = {}; |
91 | | - |
92 | | - fields.forEach((property) => { |
93 | | - const value = getStyleValue(style, property); |
94 | | - if (value !== undefined) { |
95 | | - properties[property] = value; |
96 | | - } |
97 | | - }); |
98 | | - |
99 | | - return properties; |
100 | | -} |
101 | | - |
102 | | -function collectTextVariants(element: HTMLElement): TextSnapshots { |
103 | | - const visible = element.innerText || ''; |
104 | | - const full = element.textContent || visible; |
105 | | - |
106 | | - return { |
107 | | - visible, |
108 | | - full, |
109 | | - }; |
110 | | -} |
111 | | - |
112 | | -function resolveTextByDetail(variants: TextSnapshots, detail: TextDetailLevel): string | undefined { |
113 | | - if (detail === 'none') { |
114 | | - return undefined; |
115 | | - } |
116 | | - |
117 | | - if (detail === 'visible') { |
118 | | - return variants.visible; |
119 | | - } |
120 | | - |
121 | | - return variants.full || variants.visible; |
122 | | -} |
123 | | - |
124 | | -/** |
125 | | - * Get source file information from a DOM element's React component |
126 | | - */ |
127 | | -export function getSourceFromElement(element: HTMLElement): ReactSourceInfo | null { |
128 | | - // Find React Fiber key |
129 | | - const fiberKey = Object.keys(element).find((key) => key.startsWith('__reactFiber$') |
130 | | - || key.startsWith('__reactInternalInstance$')); |
131 | | - |
132 | | - if (!fiberKey) return null; |
133 | | - |
134 | | - const fiber = (element as any)[fiberKey]; |
135 | | - if (!fiber) return null; |
136 | | - |
137 | | - // Walk up fiber tree to find component fiber (skip DOM fibers) |
138 | | - let componentFiber = fiber; |
139 | | - while (componentFiber && typeof componentFiber.type === 'string') { |
140 | | - componentFiber = componentFiber.return; |
141 | | - } |
142 | | - |
143 | | - if (!componentFiber) return null; |
144 | | - |
145 | | - // Try multiple source locations (React version differences) |
146 | | - // React 18: _debugSource |
147 | | - if (componentFiber._debugSource) { |
148 | | - return { |
149 | | - fileName: componentFiber._debugSource.fileName, |
150 | | - lineNumber: componentFiber._debugSource.lineNumber, |
151 | | - columnNumber: componentFiber._debugSource.columnNumber, |
152 | | - }; |
153 | | - } |
154 | | - |
155 | | - // React 19: _debugInfo (often null) |
156 | | - if (componentFiber._debugInfo) { |
157 | | - return componentFiber._debugInfo; |
158 | | - } |
159 | | - |
160 | | - // Babel plugin: __source on element type |
161 | | - if (componentFiber.elementType?.__source) { |
162 | | - return { |
163 | | - fileName: componentFiber.elementType.__source.fileName, |
164 | | - lineNumber: componentFiber.elementType.__source.lineNumber, |
165 | | - columnNumber: componentFiber.elementType.__source.columnNumber, |
166 | | - }; |
167 | | - } |
168 | | - |
169 | | - // Alternative: _owner chain |
170 | | - if (componentFiber._debugOwner?._debugSource) { |
171 | | - return { |
172 | | - fileName: componentFiber._debugOwner._debugSource.fileName, |
173 | | - lineNumber: componentFiber._debugOwner._debugSource.lineNumber, |
174 | | - columnNumber: componentFiber._debugOwner._debugSource.columnNumber, |
175 | | - }; |
176 | | - } |
177 | | - |
178 | | - // Check pendingProps for __source |
179 | | - if (componentFiber.pendingProps?.__source) { |
180 | | - return { |
181 | | - fileName: componentFiber.pendingProps.__source.fileName, |
182 | | - lineNumber: componentFiber.pendingProps.__source.lineNumber, |
183 | | - columnNumber: componentFiber.pendingProps.__source.columnNumber, |
184 | | - }; |
185 | | - } |
186 | | - |
187 | | - return null; |
188 | | -} |
189 | | - |
190 | | -/** |
191 | | - * Extract React Fiber information from an element |
192 | | - */ |
193 | | -export function getReactFiberInfo(element: HTMLElement): ComponentInfo | undefined { |
194 | | - try { |
195 | | - // Use comprehensive source detection |
196 | | - const sourceInfo = getSourceFromElement(element); |
197 | | - |
198 | | - // Also get component name |
199 | | - const fiberKey = Object.keys(element).find((key) => key.startsWith('__reactFiber$') |
200 | | - || key.startsWith('__reactInternalInstance$')); |
201 | | - |
202 | | - if (fiberKey) { |
203 | | - const fiber = (element as any)[fiberKey]; |
204 | | - if (fiber) { |
205 | | - // Find component fiber |
206 | | - let componentFiber = fiber; |
207 | | - while (componentFiber && typeof componentFiber.type === 'string') { |
208 | | - componentFiber = componentFiber.return; |
209 | | - } |
210 | | - |
211 | | - if (componentFiber && componentFiber.type && typeof componentFiber.type === 'function') { |
212 | | - const componentName = componentFiber.type.displayName |
213 | | - || componentFiber.type.name |
214 | | - || 'Unknown'; |
215 | | - |
216 | | - let sourceFile: string | undefined; |
217 | | - if (sourceInfo) { |
218 | | - const fileName = sourceInfo.fileName.split('/').pop() || sourceInfo.fileName; |
219 | | - sourceFile = sourceInfo.lineNumber |
220 | | - ? `${fileName}:${sourceInfo.lineNumber}` |
221 | | - : fileName; |
222 | | - } |
223 | | - |
224 | | - const result = { |
225 | | - name: componentName, |
226 | | - sourceFile, |
227 | | - framework: 'react' as const, |
228 | | - }; |
229 | | - |
230 | | - logger.debug('🧬 Found React Fiber info:', result); |
231 | | - return result; |
232 | | - } |
233 | | - } |
234 | | - } |
235 | | - |
236 | | - return undefined; |
237 | | - } catch (error) { |
238 | | - logger.error('🚨 Error extracting Fiber info:', error); |
239 | | - return undefined; |
240 | | - } |
241 | | -} |
242 | | - |
243 | | -/** |
244 | | - * Extract all attributes from an HTML element |
245 | | - */ |
246 | | -export function getElementAttributes(element: HTMLElement): Record<string, string> { |
247 | | - const attributes: Record<string, string> = {}; |
248 | | - for (let i = 0; i < element.attributes.length; i += 1) { |
249 | | - const attr = element.attributes[i]; |
250 | | - attributes[attr.name] = attr.value; |
251 | | - } |
252 | | - return attributes; |
253 | | -} |
254 | | - |
255 | | -/** |
256 | | - * Generate a CSS selector for an element |
257 | | - */ |
258 | | -export function generateSelector(element: HTMLElement): string { |
259 | | - let selector = element.tagName.toLowerCase(); |
260 | | - if (element.id) selector += `#${element.id}`; |
261 | | - if (element.className) { |
262 | | - const classNameStr = typeof element.className === 'string' |
263 | | - ? element.className |
264 | | - : (element.className as any).baseVal || ''; |
265 | | - const classes = classNameStr.split(' ').filter((c: string) => c.trim()); |
266 | | - if (classes.length > 0) selector += `.${classes.join('.')}`; |
267 | | - } |
268 | | - return selector; |
269 | | -} |
270 | | - |
271 | | -/** |
272 | | - * Get element position relative to the page |
273 | | - */ |
274 | | -export function getElementPosition(element: HTMLElement): ElementPosition { |
275 | | - const rect = element.getBoundingClientRect(); |
276 | | - return { |
277 | | - x: rect.left + window.scrollX, |
278 | | - y: rect.top + window.scrollY, |
279 | | - width: rect.width, |
280 | | - height: rect.height, |
281 | | - }; |
282 | | -} |
283 | | - |
284 | | -/** |
285 | | - * Extract CSS classes from an element as an array |
286 | | - */ |
287 | | -export function getElementClasses(element: HTMLElement): string[] { |
288 | | - if (!element.className) return []; |
289 | | - const classNameStr = typeof element.className === 'string' |
290 | | - ? element.className |
291 | | - : (element.className as any).baseVal || ''; |
292 | | - return classNameStr.split(' ').filter((c: string) => c.trim()); |
293 | | -} |
294 | | - |
295 | | -export function adaptTargetToElement( |
296 | | - element: HTMLElement, |
297 | | - options: ElementSerializationOptions = {}, |
298 | | -): TargetedElement { |
299 | | - const textDetail = options.textDetail ?? DEFAULT_TEXT_DETAIL; |
300 | | - const cssLevel = options.cssLevel ?? DEFAULT_CSS_LEVEL; |
301 | | - |
302 | | - const textVariants = collectTextVariants(element); |
303 | | - const resolvedText = resolveTextByDetail(textVariants, textDetail); |
304 | | - |
305 | | - const computedStyle = window.getComputedStyle(element); |
306 | | - const fullCSS = extractFullCSSProperties(computedStyle); |
307 | | - const cssProperties = getElementCSSProperties(computedStyle, cssLevel, fullCSS); |
308 | | - |
309 | | - const target: TargetedElement = { |
310 | | - selector: generateSelector(element), |
311 | | - tagName: element.tagName, |
312 | | - id: element.id || undefined, |
313 | | - classes: getElementClasses(element), |
314 | | - attributes: getElementAttributes(element), |
315 | | - position: getElementPosition(element), |
316 | | - cssLevel, |
317 | | - cssProperties, |
318 | | - cssComputed: Object.keys(fullCSS).length > 0 ? fullCSS : undefined, |
319 | | - componentInfo: getReactFiberInfo(element), |
320 | | - timestamp: Date.now(), |
321 | | - url: window.location.href, |
322 | | - textDetail, |
323 | | - textVariants, |
324 | | - textContent: textVariants.full, |
325 | | - }; |
326 | | - |
327 | | - if (resolvedText !== undefined) { |
328 | | - target.innerText = resolvedText; |
329 | | - } |
330 | | - |
331 | | - if (!target.textContent && textVariants.visible) { |
332 | | - target.textContent = textVariants.visible; |
333 | | - } |
334 | | - |
335 | | - return target; |
336 | | -} |
337 | | - |
338 | 7 | /** |
339 | 8 | * Extract raw React Fiber from an element (if present) |
340 | 9 | */ |
|
0 commit comments