hale-commenting-system 3.5.2 → 3.6.0
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 +187 -8
- package/package.json +1 -1
- package/src/app/commenting-system/components/CommentOverlay.tsx +282 -14
- package/src/app/commenting-system/components/CommentPanel.tsx +67 -12
- package/src/app/commenting-system/components/CommentPin.tsx +87 -5
- package/src/app/commenting-system/components/FloatingWidget.tsx +79 -9
- package/src/app/commenting-system/components/JiraTab.tsx +295 -166
- package/src/app/commenting-system/contexts/CommentContext.tsx +77 -33
- package/src/app/commenting-system/index.ts +4 -1
- package/src/app/commenting-system/services/githubAdapter.ts +9 -2
- package/src/app/commenting-system/types/index.ts +14 -2
- package/src/app/commenting-system/utils/componentUtils.ts +242 -0
- package/src/app/commenting-system/utils/selectorUtils.ts +155 -0
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { Comment, Thread } from '../types';
|
|
2
|
+
import { Comment, Thread, ComponentMetadata } from '../types';
|
|
3
3
|
import { getStoredUser, githubAdapter, isGitHubConfigured } from '../services/githubAdapter';
|
|
4
4
|
|
|
5
5
|
interface CommentContextType {
|
|
6
6
|
threads: Thread[];
|
|
7
7
|
commentsEnabled: boolean;
|
|
8
8
|
setCommentsEnabled: (enabled: boolean) => void;
|
|
9
|
+
showPinsEnabled: boolean;
|
|
10
|
+
setShowPinsEnabled: (enabled: boolean) => void;
|
|
9
11
|
drawerPinnedOpen: boolean;
|
|
10
12
|
setDrawerPinnedOpen: (open: boolean) => void;
|
|
11
13
|
floatingWidgetMode: boolean;
|
|
12
14
|
setFloatingWidgetMode: (mode: boolean) => void;
|
|
13
|
-
addThread: (xPercent: number, yPercent: number, route: string, version?: string) => string;
|
|
15
|
+
addThread: (cssSelector: string, elementDescription: string, componentMetadata: ComponentMetadata | null, xPercent: number, yPercent: number, route: string, version?: string) => string;
|
|
14
16
|
addReply: (threadId: string, text: string, parentCommentId?: string) => void;
|
|
15
17
|
syncFromGitHub: (route: string, version?: string) => Promise<void>;
|
|
16
18
|
retrySync: () => Promise<void>;
|
|
@@ -103,6 +105,7 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
|
|
|
103
105
|
};
|
|
104
106
|
const STORAGE_KEY = 'hale_comment_threads_v1';
|
|
105
107
|
const COMMENTS_ENABLED_KEY = 'hale_comments_enabled_v1';
|
|
108
|
+
const SHOW_PINS_ENABLED_KEY = 'hale_show_pins_enabled_v1';
|
|
106
109
|
const DRAWER_PINNED_OPEN_KEY = 'hale_drawer_pinned_open_v1';
|
|
107
110
|
const FLOATING_WIDGET_MODE_KEY = 'hale_floating_widget_mode_v1';
|
|
108
111
|
const HIDDEN_ISSUES_KEY = 'hale_hidden_issue_numbers_v1';
|
|
@@ -191,6 +194,14 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
|
|
|
191
194
|
return false;
|
|
192
195
|
}
|
|
193
196
|
});
|
|
197
|
+
const [showPinsEnabled, setShowPinsEnabled] = React.useState<boolean>(() => {
|
|
198
|
+
try {
|
|
199
|
+
const raw = window.localStorage.getItem(SHOW_PINS_ENABLED_KEY);
|
|
200
|
+
return raw === 'true';
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
194
205
|
const [syncInFlightCount, setSyncInFlightCount] = React.useState(0);
|
|
195
206
|
const isSyncing = syncInFlightCount > 0;
|
|
196
207
|
const syncInFlightByKey = React.useRef<Map<string, Promise<void>>>(new Map());
|
|
@@ -236,7 +247,15 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
|
|
|
236
247
|
}
|
|
237
248
|
}, [floatingWidgetMode]);
|
|
238
249
|
|
|
239
|
-
|
|
250
|
+
React.useEffect(() => {
|
|
251
|
+
try {
|
|
252
|
+
window.localStorage.setItem(SHOW_PINS_ENABLED_KEY, String(showPinsEnabled));
|
|
253
|
+
} catch {
|
|
254
|
+
// ignore
|
|
255
|
+
}
|
|
256
|
+
}, [showPinsEnabled]);
|
|
257
|
+
|
|
258
|
+
const addThread = (cssSelector: string, elementDescription: string, componentMetadata: ComponentMetadata | null, xPercent: number, yPercent: number, route: string, version?: string): string => {
|
|
240
259
|
const threadId = `thread-${Date.now()}`;
|
|
241
260
|
const isConfigured = isGitHubConfigured();
|
|
242
261
|
|
|
@@ -244,6 +263,8 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
|
|
|
244
263
|
threadId,
|
|
245
264
|
route,
|
|
246
265
|
version,
|
|
266
|
+
cssSelector,
|
|
267
|
+
elementDescription,
|
|
247
268
|
xPercent: xPercent.toFixed(1),
|
|
248
269
|
yPercent: yPercent.toFixed(1),
|
|
249
270
|
isGitHubConfigured: isConfigured,
|
|
@@ -251,6 +272,9 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
|
|
|
251
272
|
|
|
252
273
|
const newThread: Thread = {
|
|
253
274
|
id: threadId,
|
|
275
|
+
cssSelector,
|
|
276
|
+
elementDescription,
|
|
277
|
+
componentMetadata: componentMetadata || undefined,
|
|
254
278
|
xPercent,
|
|
255
279
|
yPercent,
|
|
256
280
|
route,
|
|
@@ -329,29 +353,44 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
|
|
|
329
353
|
return threadId;
|
|
330
354
|
};
|
|
331
355
|
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
356
|
+
const parseMetadataFromIssueBody = (body: string): {
|
|
357
|
+
cssSelector?: string;
|
|
358
|
+
elementDescription?: string;
|
|
359
|
+
xPercent: number;
|
|
360
|
+
yPercent: number;
|
|
361
|
+
} => {
|
|
362
|
+
let cssSelector: string | undefined;
|
|
363
|
+
let elementDescription: string | undefined;
|
|
364
|
+
let xPercent = 0;
|
|
365
|
+
let yPercent = 0;
|
|
366
|
+
|
|
367
|
+
// Parse CSS Selector
|
|
368
|
+
const selectorMatch = body.match(/CSS Selector:\s*`([^`]+)`/i);
|
|
369
|
+
if (selectorMatch) {
|
|
370
|
+
cssSelector = selectorMatch[1];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Parse Target Component
|
|
374
|
+
const componentMatch = body.match(/Target Component:\s*`([^`]+)`/i);
|
|
375
|
+
if (componentMatch) {
|
|
376
|
+
elementDescription = componentMatch[1];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Parse Fallback Position (new format) or Coordinates (old format)
|
|
380
|
+
const fallbackMatch = body.match(/Fallback Position:\s*`?\(([\d.]+)%?,\s*([\d.]+)%?\)`?/i);
|
|
381
|
+
const coordMatch = body.match(/Coordinates:\s*`?\(([\d.]+)%?,\s*([\d.]+)%?\)`?/i);
|
|
382
|
+
|
|
383
|
+
const match = fallbackMatch || coordMatch;
|
|
384
|
+
if (match) {
|
|
385
|
+
const x = Number(match[1]);
|
|
386
|
+
const y = Number(match[2]);
|
|
387
|
+
if (!Number.isNaN(x) && !Number.isNaN(y)) {
|
|
388
|
+
xPercent = x;
|
|
389
|
+
yPercent = y;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
340
392
|
|
|
341
|
-
|
|
342
|
-
const labels = issue?.labels;
|
|
343
|
-
if (!Array.isArray(labels)) return null;
|
|
344
|
-
const names = labels
|
|
345
|
-
.map((l: any) => (typeof l === 'string' ? l : l?.name))
|
|
346
|
-
.filter((n: any) => typeof n === 'string') as string[];
|
|
347
|
-
const coord = names.find((n) => n.startsWith('coords:'));
|
|
348
|
-
if (!coord) return null;
|
|
349
|
-
const raw = coord.replace('coords:', '');
|
|
350
|
-
const parts = raw.split(',').map((p) => Number(p.trim()));
|
|
351
|
-
if (parts.length !== 2) return null;
|
|
352
|
-
const [x, y] = parts;
|
|
353
|
-
if (Number.isNaN(x) || Number.isNaN(y)) return null;
|
|
354
|
-
return { xPercent: x, yPercent: y };
|
|
393
|
+
return { cssSelector, elementDescription, xPercent, yPercent };
|
|
355
394
|
};
|
|
356
395
|
|
|
357
396
|
const syncFromGitHub = async (route: string, version?: string) => {
|
|
@@ -395,10 +434,7 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
|
|
|
395
434
|
const issueUrl = issue?.html_url as string | undefined;
|
|
396
435
|
if (!issueNumber) continue;
|
|
397
436
|
|
|
398
|
-
const
|
|
399
|
-
parseCoordsFromIssueBody(issue?.body || '') ||
|
|
400
|
-
parseCoordsFromIssueLabels(issue) ||
|
|
401
|
-
{ xPercent: 0, yPercent: 0 };
|
|
437
|
+
const metadata = parseMetadataFromIssueBody(issue?.body || '');
|
|
402
438
|
|
|
403
439
|
const commentsResult = await githubAdapter.fetchIssueComments(issueNumber);
|
|
404
440
|
const ghComments = commentsResult.success && commentsResult.data ? commentsResult.data : [];
|
|
@@ -429,8 +465,10 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
|
|
|
429
465
|
id: `gh-${issueNumber}`,
|
|
430
466
|
route,
|
|
431
467
|
version,
|
|
432
|
-
|
|
433
|
-
|
|
468
|
+
cssSelector: metadata.cssSelector,
|
|
469
|
+
elementDescription: metadata.elementDescription,
|
|
470
|
+
xPercent: metadata.xPercent,
|
|
471
|
+
yPercent: metadata.yPercent,
|
|
434
472
|
comments: mappedComments,
|
|
435
473
|
issueNumber,
|
|
436
474
|
issueUrl,
|
|
@@ -555,8 +593,10 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
|
|
|
555
593
|
if (!ensuredIssueNumber) {
|
|
556
594
|
const created = await githubAdapter.createIssue({
|
|
557
595
|
title: `Feedback: ${thread.route}`,
|
|
558
|
-
body: `Thread created from pin
|
|
596
|
+
body: `Thread created from pin${thread.elementDescription ? ` on ${thread.elementDescription}` : ''}.`,
|
|
559
597
|
route: thread.route,
|
|
598
|
+
cssSelector: thread.cssSelector,
|
|
599
|
+
elementDescription: thread.elementDescription,
|
|
560
600
|
xPercent: thread.xPercent,
|
|
561
601
|
yPercent: thread.yPercent,
|
|
562
602
|
version: thread.version,
|
|
@@ -947,8 +987,10 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
|
|
|
947
987
|
setThreads((prev) => prev.map((x) => (x.id === t.id ? { ...x, syncStatus: 'syncing', syncError: undefined } : x)));
|
|
948
988
|
const created = await githubAdapter.createIssue({
|
|
949
989
|
title: `Feedback: ${t.route}`,
|
|
950
|
-
body: `Thread created from pin
|
|
990
|
+
body: `Thread created from pin${t.elementDescription ? ` on ${t.elementDescription}` : ''}.`,
|
|
951
991
|
route: t.route,
|
|
992
|
+
cssSelector: t.cssSelector,
|
|
993
|
+
elementDescription: t.elementDescription,
|
|
952
994
|
xPercent: t.xPercent,
|
|
953
995
|
yPercent: t.yPercent,
|
|
954
996
|
version: t.version,
|
|
@@ -1012,6 +1054,8 @@ export const CommentProvider: React.FunctionComponent<{ children: React.ReactNod
|
|
|
1012
1054
|
threads,
|
|
1013
1055
|
commentsEnabled,
|
|
1014
1056
|
setCommentsEnabled,
|
|
1057
|
+
showPinsEnabled,
|
|
1058
|
+
setShowPinsEnabled,
|
|
1015
1059
|
drawerPinnedOpen,
|
|
1016
1060
|
setDrawerPinnedOpen,
|
|
1017
1061
|
floatingWidgetMode,
|
|
@@ -14,4 +14,7 @@ export { FloatingWidget } from './components/FloatingWidget';
|
|
|
14
14
|
export { githubAdapter, isGitHubConfigured } from './services/githubAdapter';
|
|
15
15
|
|
|
16
16
|
// Types
|
|
17
|
-
export type { Comment, Thread, SyncStatus, ThreadStatus } from './types';
|
|
17
|
+
export type { Comment, Thread, SyncStatus, ThreadStatus, ComponentMetadata } from './types';
|
|
18
|
+
|
|
19
|
+
// Utils
|
|
20
|
+
export { getComponentMetadata, getComponentPath, findNearestComponentElement } from './utils/componentUtils';
|
|
@@ -151,6 +151,8 @@ export const githubAdapter = {
|
|
|
151
151
|
title: string;
|
|
152
152
|
body: string;
|
|
153
153
|
route: string;
|
|
154
|
+
cssSelector?: string;
|
|
155
|
+
elementDescription?: string;
|
|
154
156
|
xPercent: number;
|
|
155
157
|
yPercent: number;
|
|
156
158
|
version?: string;
|
|
@@ -164,7 +166,9 @@ export const githubAdapter = {
|
|
|
164
166
|
const metadata = [
|
|
165
167
|
`- Route: \`${params.route}\``,
|
|
166
168
|
params.version ? `- Version: \`${params.version}\`` : null,
|
|
167
|
-
`-
|
|
169
|
+
params.cssSelector ? `- Target Component: \`${params.elementDescription || 'unknown'}\`` : null,
|
|
170
|
+
params.cssSelector ? `- CSS Selector: \`${params.cssSelector}\`` : null,
|
|
171
|
+
`- Fallback Position: \`(${params.xPercent.toFixed(1)}%, ${params.yPercent.toFixed(1)}%)\``,
|
|
168
172
|
]
|
|
169
173
|
.filter(Boolean)
|
|
170
174
|
.join('\n');
|
|
@@ -181,8 +185,11 @@ export const githubAdapter = {
|
|
|
181
185
|
const labels: string[] = [
|
|
182
186
|
'hale-comment',
|
|
183
187
|
`route:${params.route}`,
|
|
184
|
-
`coords:${Math.round(params.xPercent)},${Math.round(params.yPercent)}`,
|
|
185
188
|
];
|
|
189
|
+
if (params.cssSelector && params.elementDescription) {
|
|
190
|
+
labels.push(`component:${params.elementDescription}`);
|
|
191
|
+
}
|
|
192
|
+
labels.push(`coords:${Math.round(params.xPercent)},${Math.round(params.yPercent)}`);
|
|
186
193
|
if (params.version) labels.push(`version:${params.version}`);
|
|
187
194
|
await githubProxyRequest('POST', `/repos/${owner}/${repo}/issues/${data.number}/labels`, { labels });
|
|
188
195
|
} catch {
|
|
@@ -11,10 +11,22 @@ export interface Comment {
|
|
|
11
11
|
export type SyncStatus = 'synced' | 'local' | 'pending' | 'syncing' | 'error';
|
|
12
12
|
export type ThreadStatus = 'open' | 'closed';
|
|
13
13
|
|
|
14
|
+
export interface ComponentMetadata {
|
|
15
|
+
componentName?: string;
|
|
16
|
+
componentType?: string; // 'function' | 'class' | 'forwardRef' | 'memo' | 'lazy' | 'native' | 'unknown'
|
|
17
|
+
props?: Record<string, unknown>;
|
|
18
|
+
displayName?: string;
|
|
19
|
+
key?: string | number | null;
|
|
20
|
+
componentPath?: string[]; // Component tree path (e.g., ["App", "Dashboard", "Button"])
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
export interface Thread {
|
|
15
24
|
id: string;
|
|
16
|
-
|
|
17
|
-
|
|
25
|
+
cssSelector?: string; // CSS selector for target element
|
|
26
|
+
elementDescription?: string; // Simplified element name for display (e.g., "button.pf-c-button")
|
|
27
|
+
componentMetadata?: ComponentMetadata; // React component information (component-based)
|
|
28
|
+
xPercent: number; // Percentage from left (0-100) - used as fallback when element is deleted
|
|
29
|
+
yPercent: number; // Percentage from top (0-100) - used as fallback when element is deleted
|
|
18
30
|
route: string;
|
|
19
31
|
version?: string;
|
|
20
32
|
comments: Comment[];
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for React component detection and metadata extraction
|
|
3
|
+
* Similar to Chrome DevTools component inspection
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ComponentMetadata } from '../types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get React fiber node from a DOM element
|
|
10
|
+
* Uses React DevTools internal API if available, otherwise traverses up the DOM tree
|
|
11
|
+
*/
|
|
12
|
+
export function getFiberFromElement(element: Element | null): any {
|
|
13
|
+
if (!element) return null;
|
|
14
|
+
|
|
15
|
+
// Try React DevTools internal API first (if DevTools is installed)
|
|
16
|
+
const key = Object.keys(element).find((k) => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
|
|
17
|
+
if (key) {
|
|
18
|
+
return (element as any)[key];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Fallback: traverse up the DOM tree to find a React fiber
|
|
22
|
+
let current: Node | null = element;
|
|
23
|
+
while (current) {
|
|
24
|
+
const keys = Object.keys(current);
|
|
25
|
+
const fiberKey = keys.find((k) => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
|
|
26
|
+
if (fiberKey) {
|
|
27
|
+
return (current as any)[fiberKey];
|
|
28
|
+
}
|
|
29
|
+
current = current.parentNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get component name from a React fiber node
|
|
37
|
+
*/
|
|
38
|
+
export function getComponentName(fiber: any): string | undefined {
|
|
39
|
+
if (!fiber) return undefined;
|
|
40
|
+
|
|
41
|
+
// Try different fiber types
|
|
42
|
+
const type = fiber.type;
|
|
43
|
+
if (!type) return undefined;
|
|
44
|
+
|
|
45
|
+
// Function component
|
|
46
|
+
if (typeof type === 'function') {
|
|
47
|
+
return type.displayName || type.name || 'Anonymous';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Forward ref
|
|
51
|
+
if (type.$$typeof === Symbol.for('react.forward_ref')) {
|
|
52
|
+
return type.render?.displayName || type.render?.name || 'ForwardRef';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Memo
|
|
56
|
+
if (type.$$typeof === Symbol.for('react.memo')) {
|
|
57
|
+
const innerType = type.type;
|
|
58
|
+
if (typeof innerType === 'function') {
|
|
59
|
+
return innerType.displayName || innerType.name || 'Memo';
|
|
60
|
+
}
|
|
61
|
+
return 'Memo';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// String (native element)
|
|
65
|
+
if (typeof type === 'string') {
|
|
66
|
+
return type;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get component type (function, class, etc.)
|
|
74
|
+
*/
|
|
75
|
+
function getComponentType(fiber: any): ComponentMetadata['componentType'] {
|
|
76
|
+
if (!fiber) return 'unknown';
|
|
77
|
+
|
|
78
|
+
const type = fiber.type;
|
|
79
|
+
if (!type) return 'unknown';
|
|
80
|
+
|
|
81
|
+
if (typeof type === 'function') {
|
|
82
|
+
// Check if it's a class component
|
|
83
|
+
if (type.prototype && type.prototype.isReactComponent) {
|
|
84
|
+
return 'class';
|
|
85
|
+
}
|
|
86
|
+
return 'function';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (type.$$typeof === Symbol.for('react.forward_ref')) {
|
|
90
|
+
return 'forwardRef';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (type.$$typeof === Symbol.for('react.memo')) {
|
|
94
|
+
return 'memo';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (type.$$typeof === Symbol.for('react.lazy')) {
|
|
98
|
+
return 'lazy';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (typeof type === 'string') {
|
|
102
|
+
return 'native';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return 'unknown';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Extract component metadata from a DOM element
|
|
110
|
+
* Returns React component information if available
|
|
111
|
+
*/
|
|
112
|
+
export function getComponentMetadata(element: Element | null): ComponentMetadata | null {
|
|
113
|
+
if (!element) return null;
|
|
114
|
+
|
|
115
|
+
const fiber = getFiberFromElement(element);
|
|
116
|
+
if (!fiber) return null;
|
|
117
|
+
|
|
118
|
+
const componentName = getComponentName(fiber);
|
|
119
|
+
const componentType = getComponentType(fiber);
|
|
120
|
+
|
|
121
|
+
// Get props (may be null for native elements)
|
|
122
|
+
const props = fiber.memoizedProps || fiber.pendingProps || undefined;
|
|
123
|
+
|
|
124
|
+
// Get key
|
|
125
|
+
const key = fiber.key !== null && fiber.key !== undefined ? fiber.key : undefined;
|
|
126
|
+
|
|
127
|
+
// Get display name
|
|
128
|
+
const type = fiber.type;
|
|
129
|
+
const displayName =
|
|
130
|
+
(typeof type === 'function' && (type.displayName || type.name)) ||
|
|
131
|
+
(type?.$$typeof === Symbol.for('react.forward_ref') && (type.render?.displayName || type.render?.name)) ||
|
|
132
|
+
undefined;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
componentName,
|
|
136
|
+
componentType,
|
|
137
|
+
props: props ? sanitizeProps(props) : undefined,
|
|
138
|
+
displayName,
|
|
139
|
+
key,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Sanitize props for display (remove functions, circular refs, etc.)
|
|
145
|
+
*/
|
|
146
|
+
function sanitizeProps(props: Record<string, unknown>): Record<string, unknown> {
|
|
147
|
+
const sanitized: Record<string, unknown> = {};
|
|
148
|
+
const seen = new WeakSet();
|
|
149
|
+
|
|
150
|
+
const sanitize = (value: unknown, depth = 0): unknown => {
|
|
151
|
+
if (depth > 3) return '[Max Depth]'; // Prevent infinite recursion
|
|
152
|
+
if (value === null || value === undefined) return value;
|
|
153
|
+
if (typeof value === 'function') return '[Function]';
|
|
154
|
+
if (typeof value === 'symbol') return '[Symbol]';
|
|
155
|
+
if (typeof value === 'bigint') return `[BigInt: ${value}]`;
|
|
156
|
+
|
|
157
|
+
if (typeof value === 'object') {
|
|
158
|
+
if (seen.has(value)) return '[Circular]';
|
|
159
|
+
if (value instanceof Date) return value.toISOString();
|
|
160
|
+
if (value instanceof RegExp) return value.toString();
|
|
161
|
+
if (Array.isArray(value)) {
|
|
162
|
+
seen.add(value);
|
|
163
|
+
return value.map((item) => sanitize(item, depth + 1));
|
|
164
|
+
}
|
|
165
|
+
if (value instanceof Set) return `[Set(${value.size})]`;
|
|
166
|
+
if (value instanceof Map) return `[Map(${value.size})]`;
|
|
167
|
+
|
|
168
|
+
// React elements
|
|
169
|
+
if ((value as any).$$typeof) {
|
|
170
|
+
const type = (value as any).type;
|
|
171
|
+
if (typeof type === 'string') return `<${type} />`;
|
|
172
|
+
if (typeof type === 'function') return `<${type.displayName || type.name || 'Component'} />`;
|
|
173
|
+
return '[React Element]';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
seen.add(value);
|
|
177
|
+
const obj: Record<string, unknown> = {};
|
|
178
|
+
for (const [key, val] of Object.entries(value)) {
|
|
179
|
+
// Skip internal React props
|
|
180
|
+
if (key.startsWith('__') || key === 'ref' || key === 'key') continue;
|
|
181
|
+
obj[key] = sanitize(val, depth + 1);
|
|
182
|
+
}
|
|
183
|
+
return obj;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return value;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
for (const [key, value] of Object.entries(props)) {
|
|
190
|
+
// Skip internal React props
|
|
191
|
+
if (key.startsWith('__') || key === 'ref' || key === 'key') continue;
|
|
192
|
+
sanitized[key] = sanitize(value);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return sanitized;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get component hierarchy path (component tree path)
|
|
200
|
+
*/
|
|
201
|
+
export function getComponentPath(element: Element | null): string[] {
|
|
202
|
+
if (!element) return [];
|
|
203
|
+
|
|
204
|
+
const path: string[] = [];
|
|
205
|
+
let current: Element | null = element;
|
|
206
|
+
|
|
207
|
+
while (current) {
|
|
208
|
+
const fiber = getFiberFromElement(current);
|
|
209
|
+
if (fiber) {
|
|
210
|
+
const name = getComponentName(fiber);
|
|
211
|
+
if (name && name !== 'Anonymous') {
|
|
212
|
+
path.unshift(name);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
current = current.parentElement;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return path;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Find the nearest React component element (not a native DOM element)
|
|
223
|
+
*/
|
|
224
|
+
export function findNearestComponentElement(element: Element | null): Element | null {
|
|
225
|
+
if (!element) return null;
|
|
226
|
+
|
|
227
|
+
let current: Element | null = element;
|
|
228
|
+
|
|
229
|
+
while (current) {
|
|
230
|
+
const fiber = getFiberFromElement(current);
|
|
231
|
+
if (fiber) {
|
|
232
|
+
const type = fiber.type;
|
|
233
|
+
// If it's a React component (not a native element), return it
|
|
234
|
+
if (type && typeof type !== 'string') {
|
|
235
|
+
return current;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
current = current.parentElement;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return element; // Fallback to original element
|
|
242
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for generating and working with CSS selectors for element-based commenting
|
|
3
|
+
* Now includes React component detection for component-based commenting
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getComponentMetadata, getComponentPath, findNearestComponentElement } from './componentUtils';
|
|
7
|
+
import { ComponentMetadata } from '../types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a stable, unique CSS selector for a DOM element.
|
|
11
|
+
* Priority order:
|
|
12
|
+
* 1. data-testid or data-id attributes (most stable)
|
|
13
|
+
* 2. id attribute
|
|
14
|
+
* 3. Combination of tag + class + aria attributes
|
|
15
|
+
* 4. Fall back to nth-child path from body
|
|
16
|
+
*/
|
|
17
|
+
export function generateSelectorForElement(element: Element): string {
|
|
18
|
+
// Strategy 1: Use data-testid or data-id (testing attributes are most stable)
|
|
19
|
+
const testId = element.getAttribute('data-testid') || element.getAttribute('data-id');
|
|
20
|
+
if (testId) {
|
|
21
|
+
return `[data-testid="${testId}"]`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Strategy 2: Use id attribute
|
|
25
|
+
const id = element.getAttribute('id');
|
|
26
|
+
if (id) {
|
|
27
|
+
return `#${CSS.escape(id)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Strategy 3: Build selector from tag + class + aria attributes
|
|
31
|
+
const tagName = element.tagName.toLowerCase();
|
|
32
|
+
const classList = Array.from(element.classList);
|
|
33
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
34
|
+
const role = element.getAttribute('role');
|
|
35
|
+
const type = element.getAttribute('type');
|
|
36
|
+
|
|
37
|
+
// Build a selector with tag and primary class
|
|
38
|
+
let selector = tagName;
|
|
39
|
+
|
|
40
|
+
// Add first class if available (usually the main component class)
|
|
41
|
+
if (classList.length > 0) {
|
|
42
|
+
selector += `.${CSS.escape(classList[0])}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Add aria-label for uniqueness if available
|
|
46
|
+
if (ariaLabel) {
|
|
47
|
+
selector += `[aria-label="${CSS.escape(ariaLabel)}"]`;
|
|
48
|
+
} else if (role) {
|
|
49
|
+
// Fall back to role
|
|
50
|
+
selector += `[role="${CSS.escape(role)}"]`;
|
|
51
|
+
} else if (type) {
|
|
52
|
+
// For inputs, include type
|
|
53
|
+
selector += `[type="${CSS.escape(type)}"]`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Test if this selector is unique enough
|
|
57
|
+
const matches = document.querySelectorAll(selector);
|
|
58
|
+
if (matches.length === 1 && matches[0] === element) {
|
|
59
|
+
return selector;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Strategy 4: Fall back to nth-child path (less stable but guaranteed unique)
|
|
63
|
+
return getNthChildPath(element);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Generate nth-child based path from body to element
|
|
68
|
+
* This is a fallback when other strategies don't provide uniqueness
|
|
69
|
+
*/
|
|
70
|
+
function getNthChildPath(element: Element): string {
|
|
71
|
+
const path: string[] = [];
|
|
72
|
+
let current: Element | null = element;
|
|
73
|
+
|
|
74
|
+
while (current && current !== document.body && current.parentElement) {
|
|
75
|
+
const parent = current.parentElement;
|
|
76
|
+
const siblings = Array.from(parent.children);
|
|
77
|
+
const index = siblings.indexOf(current) + 1;
|
|
78
|
+
|
|
79
|
+
const tagName = current.tagName.toLowerCase();
|
|
80
|
+
const classList = Array.from(current.classList);
|
|
81
|
+
|
|
82
|
+
if (classList.length > 0) {
|
|
83
|
+
path.unshift(`${tagName}.${CSS.escape(classList[0])}:nth-child(${index})`);
|
|
84
|
+
} else {
|
|
85
|
+
path.unshift(`${tagName}:nth-child(${index})`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
current = parent;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return path.join(' > ');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get a simplified, human-readable description of an element for display.
|
|
96
|
+
* Prioritizes React component name if available, otherwise falls back to DOM element description.
|
|
97
|
+
* Format: "ComponentName" (React) or "tagName.primaryClass" (DOM)
|
|
98
|
+
* Examples: "Button", "Card", "button.pf-c-button", "div.pf-c-card", "input"
|
|
99
|
+
*/
|
|
100
|
+
export function getElementDescription(element: Element): string {
|
|
101
|
+
// Try to get React component name first
|
|
102
|
+
const componentMeta = getComponentMetadata(element);
|
|
103
|
+
if (componentMeta?.componentName && componentMeta.componentType !== 'native') {
|
|
104
|
+
return componentMeta.componentName;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Fall back to DOM element description
|
|
108
|
+
const tagName = element.tagName.toLowerCase();
|
|
109
|
+
const classList = Array.from(element.classList);
|
|
110
|
+
|
|
111
|
+
// If element has classes, use the first one (usually the main component class)
|
|
112
|
+
if (classList.length > 0) {
|
|
113
|
+
return `${tagName}.${classList[0]}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Fall back to just tag name
|
|
117
|
+
return tagName;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get component metadata for an element (React component information)
|
|
122
|
+
*/
|
|
123
|
+
export function getElementComponentMetadata(element: Element): ComponentMetadata | null {
|
|
124
|
+
// Try to find the nearest React component element
|
|
125
|
+
const componentElement = findNearestComponentElement(element);
|
|
126
|
+
if (!componentElement) return null;
|
|
127
|
+
|
|
128
|
+
const metadata = getComponentMetadata(componentElement);
|
|
129
|
+
if (!metadata) return null;
|
|
130
|
+
|
|
131
|
+
// Add component path
|
|
132
|
+
const path = getComponentPath(componentElement);
|
|
133
|
+
return {
|
|
134
|
+
...metadata,
|
|
135
|
+
componentPath: path.length > 0 ? path : undefined,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Query the DOM for an element matching the given CSS selector.
|
|
141
|
+
* Returns the element if found, null otherwise.
|
|
142
|
+
*/
|
|
143
|
+
export function findElementBySelector(selector: string | undefined): Element | null {
|
|
144
|
+
if (!selector) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
return document.querySelector(selector);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// Invalid selector
|
|
152
|
+
console.warn('Invalid CSS selector:', selector, error);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|