@zargaryanvh/react-component-inspector 1.0.7 → 1.0.8

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.
@@ -5,6 +5,24 @@ import ContentCopyIcon from "@mui/icons-material/ContentCopy";
5
5
  import { useInspection } from "./InspectionContext";
6
6
  import { formatMetadataForClipboard, getParentWithGap, getTooltipHowToFindInfo } from "./inspection";
7
7
  import { parseInspectionMetadata } from "./autoInspection";
8
+ /**
9
+ * Helper: Clamp a tooltip rect to the viewport so it's always fully visible
10
+ */
11
+ const clampToViewport = (x, y, w, h, padding = 10) => {
12
+ const vw = window.innerWidth;
13
+ const vh = window.innerHeight;
14
+ let cx = x;
15
+ let cy = y;
16
+ if (cx + w > vw - padding)
17
+ cx = vw - w - padding;
18
+ if (cy + h > vh - padding)
19
+ cy = vh - h - padding;
20
+ if (cx < padding)
21
+ cx = padding;
22
+ if (cy < padding)
23
+ cy = padding;
24
+ return { x: cx, y: cy };
25
+ };
8
26
  /**
9
27
  * Helper: Get element text content (first 100 chars)
10
28
  */
@@ -84,49 +102,31 @@ const ComponentMetadataSection = ({ metadata }) => {
84
102
  */
85
103
  export const InspectionTooltip = () => {
86
104
  const { isInspectionActive, isLocked, isMobile, isMarginPaddingMode, hoveredComponent, hoveredElement } = useInspection();
87
- const [position, setPosition] = useState({ x: 0, y: 0 });
88
105
  const [stablePosition, setStablePosition] = useState(null);
89
106
  const [copied, setCopied] = useState(null);
107
+ const [tooltipSize, setTooltipSize] = useState({ w: 400, h: 300 });
90
108
  const tooltipRef = useRef(null);
91
- // Update position based on cursor, but keep it stable when locked or mouse is near tooltip
109
+ // Measure tooltip size so we can clamp it to the viewport precisely
92
110
  useEffect(() => {
93
- if (!isInspectionActive || !hoveredElement) {
94
- setStablePosition(null);
95
- return;
96
- }
97
- // If locked, don't update position at all - keep it completely fixed
98
- if (isLocked) {
111
+ if (!tooltipRef.current || typeof ResizeObserver === "undefined")
99
112
  return;
100
- }
101
- const updatePosition = (e) => {
102
- // If we have a stable position, check if mouse is near tooltip
103
- if (stablePosition && tooltipRef.current) {
104
- const tooltipRect = tooltipRef.current.getBoundingClientRect();
105
- const mouseX = e.clientX;
106
- const mouseY = e.clientY;
107
- // Create a "dead zone" around tooltip (80px padding for easier clicking)
108
- const deadZone = {
109
- left: tooltipRect.left - 80,
110
- right: tooltipRect.right + 80,
111
- top: tooltipRect.top - 80,
112
- bottom: tooltipRect.bottom + 80,
113
- };
114
- // If mouse is in dead zone, keep position stable (don't update)
115
- if (mouseX >= deadZone.left &&
116
- mouseX <= deadZone.right &&
117
- mouseY >= deadZone.top &&
118
- mouseY <= deadZone.bottom) {
119
- return; // Don't update position - keep it stable
120
- }
113
+ const el = tooltipRef.current;
114
+ const observer = new ResizeObserver(() => {
115
+ const r = el.getBoundingClientRect();
116
+ if (r.width > 0 && r.height > 0) {
117
+ setTooltipSize((prev) => prev.w === r.width && prev.h === r.height ? prev : { w: r.width, h: r.height });
121
118
  }
122
- // Mouse moved far from tooltip - update position
123
- setPosition({ x: e.clientX, y: e.clientY });
124
- // Clear stable position so it recalculates
119
+ });
120
+ observer.observe(el);
121
+ return () => observer.disconnect();
122
+ }, [isInspectionActive, hoveredComponent]);
123
+ // Reset stored cursor position when inspection turns off so we don't reuse
124
+ // a stale value on the next activation.
125
+ useEffect(() => {
126
+ if (!isInspectionActive || !hoveredElement) {
125
127
  setStablePosition(null);
126
- };
127
- window.addEventListener("mousemove", updatePosition);
128
- return () => window.removeEventListener("mousemove", updatePosition);
129
- }, [isInspectionActive, isLocked, hoveredElement, stablePosition]);
128
+ }
129
+ }, [isInspectionActive, hoveredElement]);
130
130
  // If no component metadata but we have an element, create basic metadata
131
131
  const displayComponent = hoveredComponent || (hoveredElement ? {
132
132
  componentName: hoveredElement.tagName.toLowerCase(),
@@ -136,84 +136,78 @@ export const InspectionTooltip = () => {
136
136
  propsSignature: "default",
137
137
  sourceFile: "DOM",
138
138
  } : null);
139
- // On mobile (or when locking) there is no cursor; position tooltip over/near the inspected element
140
- const positionFromElement = useMemo(() => {
141
- if (!hoveredElement || !document.body.contains(hoveredElement))
142
- return null;
139
+ // Compute anchor position once per element change. The value is intentionally
140
+ // NOT recomputed when tooltipSize updates (ResizeObserver may fire several
141
+ // times as the tooltip's content renders) — recomputing on every size update
142
+ // makes the tooltip visibly slide and shrink as it settles. Pin location is
143
+ // chosen as: below the element if there's room, otherwise above, right, left,
144
+ // and finally a fixed corner for elements that fill the viewport.
145
+ const tooltipSizeRef = useRef(tooltipSize);
146
+ tooltipSizeRef.current = tooltipSize;
147
+ const [anchoredPosition, setAnchoredPosition] = useState(null);
148
+ useEffect(() => {
149
+ if (!hoveredElement || !document.body.contains(hoveredElement)) {
150
+ setAnchoredPosition(null);
151
+ return;
152
+ }
143
153
  const padding = 10;
144
- const estimatedWidth = 400;
145
- const estimatedHeight = 200;
154
+ const gap = 8;
155
+ const { w, h } = tooltipSizeRef.current;
156
+ const vw = window.innerWidth;
157
+ const vh = window.innerHeight;
146
158
  const rect = hoveredElement.getBoundingClientRect();
147
- // Prefer below and to the right of the element, clamped to viewport
148
- let x = rect.left + 15;
149
- let y = rect.bottom + 10;
150
- if (x + estimatedWidth > window.innerWidth - padding)
151
- x = window.innerWidth - estimatedWidth - padding;
152
- if (x < padding)
153
- x = padding;
154
- if (y + estimatedHeight > window.innerHeight - padding)
155
- y = rect.top - estimatedHeight - 10;
156
- if (y < padding)
157
- y = padding;
158
- return { x, y };
159
- }, [hoveredElement]);
160
- // Calculate adjusted position to avoid going off-screen
161
- // Use stable position if available; on mobile or when locked use element-based position so tooltip appears over inspected area
162
- const adjustedPosition = useMemo(() => {
163
- if (!displayComponent) {
164
- return { x: position.x + 15, y: position.y + 15 };
165
- }
166
- // If we have a stable position, use it (don't recalculate)
167
- if (stablePosition) {
168
- return stablePosition;
169
- }
170
- // Mobile or just locked: place tooltip near the inspected element (not cursor)
171
- if ((isMobile || isLocked) && positionFromElement) {
172
- return positionFromElement;
159
+ const spaceBelow = vh - rect.bottom;
160
+ const spaceAbove = rect.top;
161
+ const spaceRight = vw - rect.right;
162
+ const spaceLeft = rect.left;
163
+ let x;
164
+ let y;
165
+ if (spaceBelow >= h + gap + padding) {
166
+ y = rect.bottom + gap;
167
+ x = Math.min(Math.max(rect.left, padding), vw - w - padding);
173
168
  }
174
- // Desktop: calculate from cursor position
175
- const padding = 10;
176
- let x = position.x + 15;
177
- let y = position.y + 15;
178
- // Adjust if tooltip would go off right edge (estimate width)
179
- const estimatedWidth = 400; // Approximate tooltip width
180
- if (x + estimatedWidth > window.innerWidth - padding) {
181
- x = position.x - estimatedWidth - 15;
169
+ else if (spaceAbove >= h + gap + padding) {
170
+ y = rect.top - h - gap;
171
+ x = Math.min(Math.max(rect.left, padding), vw - w - padding);
182
172
  }
183
- // Adjust if tooltip would go off bottom edge (estimate height)
184
- const estimatedHeight = 200; // Approximate tooltip height
185
- if (y + estimatedHeight > window.innerHeight - padding) {
186
- y = position.y - estimatedHeight - 15;
173
+ else if (spaceRight >= w + gap + padding) {
174
+ x = rect.right + gap;
175
+ y = Math.min(Math.max(rect.top, padding), vh - h - padding);
187
176
  }
188
- // Adjust if tooltip would go off left edge
189
- if (x < padding) {
190
- x = padding;
177
+ else if (spaceLeft >= w + gap + padding) {
178
+ x = rect.left - w - gap;
179
+ y = Math.min(Math.max(rect.top, padding), vh - h - padding);
191
180
  }
192
- // Adjust if tooltip would go off top edge
193
- if (y < padding) {
194
- y = padding;
181
+ else {
182
+ const cx = (rect.left + rect.right) / 2;
183
+ const cy = (rect.top + rect.bottom) / 2;
184
+ x = cx < vw / 2 ? vw - w - padding : padding;
185
+ y = cy < vh / 2 ? vh - h - padding : padding;
195
186
  }
196
- return { x, y };
197
- }, [position, displayComponent, stablePosition, isMobile, isLocked, positionFromElement]);
198
- // Set stable position once when tooltip first appears for a new element, or when locked
199
- const lastComponentIdRef = useRef(null);
187
+ setAnchoredPosition(clampToViewport(x, y, w, h));
188
+ }, [hoveredElement]);
189
+ const positionFromElement = anchoredPosition;
190
+ // Position is anchored to the hovered element's rect, not the cursor. This
191
+ // keeps the tooltip static while the cursor moves around inside one element
192
+ // (e.g. when inspecting a large container) and only repositions when the
193
+ // hovered element changes.
194
+ const adjustedPosition = useMemo(() => {
195
+ const w = tooltipSize.w;
196
+ const h = tooltipSize.h;
197
+ if (positionFromElement)
198
+ return positionFromElement;
199
+ return clampToViewport(15, 15, w, h);
200
+ }, [positionFromElement, tooltipSize]);
201
+ // When the lock is engaged we freeze whatever position was being shown at
202
+ // that moment so it doesn't follow subsequent element changes.
200
203
  useEffect(() => {
201
- const currentComponentId = displayComponent?.componentId || null;
202
- // When locked, ensure we have a stable position
203
- if (isLocked && !stablePosition && displayComponent && adjustedPosition.x > 0 && adjustedPosition.y > 0) {
204
+ if (isLocked && !stablePosition && adjustedPosition.x > 0 && adjustedPosition.y > 0) {
204
205
  setStablePosition(adjustedPosition);
205
- return;
206
206
  }
207
- // Only set stable position when component changes or when we don't have one yet
208
- if (currentComponentId !== lastComponentIdRef.current && !stablePosition && displayComponent && adjustedPosition.x > 0 && adjustedPosition.y > 0) {
209
- lastComponentIdRef.current = currentComponentId;
210
- // Set stable position after a small delay to ensure tooltip is rendered
211
- const timer = setTimeout(() => {
212
- setStablePosition(adjustedPosition);
213
- }, 50);
214
- return () => clearTimeout(timer);
207
+ if (!isLocked && stablePosition) {
208
+ setStablePosition(null);
215
209
  }
216
- }, [displayComponent?.componentId, adjustedPosition, stablePosition, displayComponent, isLocked]);
210
+ }, [isLocked, adjustedPosition, stablePosition]);
217
211
  const fallbackCopy = (text) => {
218
212
  const textarea = document.createElement("textarea");
219
213
  textarea.value = text;
@@ -334,7 +328,7 @@ export const InspectionTooltip = () => {
334
328
  backgroundColor: "rgba(18, 18, 18, 0.95)",
335
329
  backdropFilter: "blur(8px)",
336
330
  border: "1px solid rgba(255, 255, 255, 0.1)",
337
- transition: stablePosition ? "none" : "left 0.1s ease-out, top 0.1s ease-out",
331
+ transition: "none",
338
332
  }, children: [_jsxs(Box, { sx: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 1 }, children: [_jsxs(Box, { sx: { display: "flex", alignItems: "center", gap: 1 }, children: [_jsx(Typography, { variant: "subtitle2", sx: { color: "#fff", fontWeight: 600, fontSize: "0.875rem" }, children: "Component Inspector" }), isMarginPaddingMode && (_jsx(Typography, { variant: "caption", sx: { color: "#ff9800", fontSize: "0.65rem" }, children: "M/P mode" })), isLocked && (_jsx(Typography, { variant: "caption", sx: { color: "#4caf50", fontSize: "0.7rem", fontStyle: "italic" }, children: "(Locked - Release H to unlock)" }))] }), _jsxs(Box, { sx: { display: "flex", alignItems: "center", gap: 0.75 }, children: [_jsx(MuiTooltip, { title: copied === "component" ? "Copied!" : "Copy component", children: _jsx(IconButton, { size: "small", onClick: (e) => { e.preventDefault(); handleCopy("component"); }, onPointerDown: (e) => { e.preventDefault(); handleCopy("component"); }, sx: {
339
333
  color: copied === "component" ? "#4caf50" : "#fff",
340
334
  "&:hover": { backgroundColor: "rgba(255, 255, 255, 0.1)" },
@@ -1,92 +1,33 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
2
  import React from "react";
3
- import { useInspection } from "./InspectionContext";
4
3
  import { generateComponentId, formatPropsSignature, getComponentName, getNextInstanceIndex, } from "./inspection";
5
4
  /**
6
5
  * Wrapper component that adds inspection metadata to a component
7
6
  */
8
7
  export const InspectionWrapper = ({ componentName, variant, role, usagePath, props, sourceFile, children, }) => {
9
- const { isInspectionActive, setHoveredComponent } = useInspection();
10
8
  const instanceIndex = React.useMemo(() => getNextInstanceIndex(componentName), [componentName]);
11
- const handleMouseEnter = (e) => {
12
- if (!isInspectionActive)
13
- return;
14
- const target = e.currentTarget;
15
- const metadata = {
16
- componentName,
17
- componentId: generateComponentId(componentName, instanceIndex),
18
- variant,
19
- role,
20
- usagePath,
21
- instanceIndex,
22
- propsSignature: formatPropsSignature(props),
23
- sourceFile,
24
- };
25
- setHoveredComponent(metadata, target);
26
- };
27
- const handleMouseLeave = () => {
28
- if (!isInspectionActive)
29
- return;
30
- setHoveredComponent(null, null);
31
- };
32
- // Touch handlers for mobile
33
- const handleTouchStart = (e) => {
34
- if (!isInspectionActive)
35
- return;
36
- const target = e.currentTarget;
37
- const metadata = {
38
- componentName,
39
- componentId: generateComponentId(componentName, instanceIndex),
40
- variant,
41
- role,
42
- usagePath,
43
- instanceIndex,
44
- propsSignature: formatPropsSignature(props),
45
- sourceFile,
46
- };
47
- setHoveredComponent(metadata, target);
48
- };
49
- const handleTouchEnd = () => {
50
- // Don't clear on touch end - let autoInspection handle it
51
- };
52
- // Clone the child element and add inspection handlers
9
+ // Attach data-inspection-* attributes only; the global autoInspection mousemove
10
+ // handler is the single source of truth for hover tracking. This avoids the
11
+ // border flicker caused by competing mouseEnter/mouseLeave handlers firing
12
+ // back and forth when the cursor sits on the edge between two components.
53
13
  if (!React.isValidElement(children)) {
54
14
  return _jsx(_Fragment, { children: children });
55
15
  }
56
- const existingProps = (children.props || {});
57
- const existingOnMouseEnter = existingProps.onMouseEnter;
58
- const existingOnMouseLeave = existingProps.onMouseLeave;
59
- const existingOnTouchStart = existingProps.onTouchStart;
60
- const existingOnTouchEnd = existingProps.onTouchEnd;
61
- const childWithProps = React.cloneElement(children, {
62
- onMouseEnter: (e) => {
63
- handleMouseEnter(e);
64
- if (existingOnMouseEnter) {
65
- existingOnMouseEnter(e);
66
- }
67
- },
68
- onMouseLeave: (e) => {
69
- handleMouseLeave();
70
- if (existingOnMouseLeave) {
71
- existingOnMouseLeave(e);
72
- }
73
- },
74
- onTouchStart: (e) => {
75
- handleTouchStart(e);
76
- if (existingOnTouchStart) {
77
- existingOnTouchStart(e);
78
- }
79
- },
80
- onTouchEnd: (e) => {
81
- handleTouchEnd();
82
- if (existingOnTouchEnd) {
83
- existingOnTouchEnd(e);
84
- }
85
- },
86
- "data-inspection-id": generateComponentId(componentName, instanceIndex),
16
+ const componentId = generateComponentId(componentName, instanceIndex);
17
+ const propsSignature = formatPropsSignature(props);
18
+ const dataAttrs = {
19
+ "data-inspection-id": componentId,
87
20
  "data-inspection-name": componentName,
88
- });
89
- return _jsx(_Fragment, { children: childWithProps });
21
+ "data-inspection-usage-path": usagePath,
22
+ "data-inspection-instance": String(instanceIndex),
23
+ "data-inspection-props": propsSignature,
24
+ "data-inspection-file": sourceFile,
25
+ };
26
+ if (variant)
27
+ dataAttrs["data-inspection-variant"] = variant;
28
+ if (role)
29
+ dataAttrs["data-inspection-role"] = role;
30
+ return _jsx(_Fragment, { children: React.cloneElement(children, dataAttrs) });
90
31
  };
91
32
  /**
92
33
  * HOC to wrap a component with inspection capabilities
@@ -259,16 +259,52 @@ export const setupAutoInspection = (setHoveredComponent, isInspectionActive, isL
259
259
  const isTouchDevice = 'ontouchstart' in window ||
260
260
  navigator.maxTouchPoints > 0 ||
261
261
  /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
262
+ // De-dupe: if mousemove resolves to the same DOM element as last time, skip
263
+ // the state update. Combined with the throttle below this is what prevents
264
+ // the tooltip from flickering when the cursor sits on a border between two
265
+ // wrapped components and rapid mouseenter/leave events would otherwise toggle
266
+ // the hovered element back and forth.
267
+ let lastInspectedElement = null;
268
+ const dedupedSetHoveredComponent = (metadata, element) => {
269
+ if (element === lastInspectedElement)
270
+ return;
271
+ lastInspectedElement = element;
272
+ setHoveredComponent(metadata, element);
273
+ };
274
+ // Throttle mousemove processing to ~30 fps. DOM walking on every pixel of
275
+ // movement (the previous behavior) is wasted work and amplifies any border
276
+ // jitter into visible flicker.
277
+ let pendingTarget = null;
278
+ let throttleHandle = null;
279
+ const flushPendingTarget = () => {
280
+ throttleHandle = null;
281
+ const t = pendingTarget;
282
+ pendingTarget = null;
283
+ if (t)
284
+ inspectElement(t, isInspectionActive, isLocked, dedupedSetHoveredComponent);
285
+ };
262
286
  const handleMouseMove = (e) => {
263
- if (!isInspectionActive) {
287
+ if (!isInspectionActive)
264
288
  return;
265
- }
266
- // If locked, don't update on mouse move - keep current tooltip fixed
267
- if (isLocked) {
289
+ if (isLocked)
268
290
  return;
291
+ // Sticky: while the cursor is still within the bounds of the element we
292
+ // last anchored the tooltip to, do nothing. This stops the tooltip from
293
+ // sliding around as the deepest DOM element under the cursor changes
294
+ // pixel-by-pixel inside a large container.
295
+ if (lastInspectedElement && document.body.contains(lastInspectedElement)) {
296
+ const rect = lastInspectedElement.getBoundingClientRect();
297
+ if (e.clientX >= rect.left &&
298
+ e.clientX <= rect.right &&
299
+ e.clientY >= rect.top &&
300
+ e.clientY <= rect.bottom) {
301
+ return;
302
+ }
269
303
  }
270
- const target = e.target;
271
- inspectElement(target, isInspectionActive, isLocked, setHoveredComponent);
304
+ pendingTarget = e.target;
305
+ if (throttleHandle !== null)
306
+ return;
307
+ throttleHandle = window.setTimeout(flushPendingTarget, 32);
272
308
  };
273
309
  // Touch move handler for mobile devices
274
310
  const handleTouchMove = (e) => {
@@ -280,7 +316,7 @@ export const setupAutoInspection = (setHoveredComponent, isInspectionActive, isL
280
316
  const touch = e.touches[0];
281
317
  const target = document.elementFromPoint(touch.clientX, touch.clientY);
282
318
  if (target) {
283
- inspectElement(target, isInspectionActive, isLocked, setHoveredComponent);
319
+ inspectElement(target, isInspectionActive, isLocked, dedupedSetHoveredComponent);
284
320
  }
285
321
  };
286
322
  // Touch start handler for mobile - inspect on touch
@@ -294,17 +330,19 @@ export const setupAutoInspection = (setHoveredComponent, isInspectionActive, isL
294
330
  const touch = e.touches[0];
295
331
  const target = document.elementFromPoint(touch.clientX, touch.clientY);
296
332
  if (target) {
297
- inspectElement(target, isInspectionActive, isLocked, setHoveredComponent);
333
+ inspectElement(target, isInspectionActive, isLocked, dedupedSetHoveredComponent);
298
334
  }
299
335
  };
300
336
  const handleMouseLeave = () => {
301
337
  if (isInspectionActive && !isLocked) {
338
+ lastInspectedElement = null;
302
339
  setHoveredComponent(null, null);
303
340
  }
304
341
  };
305
342
  const handleTouchEnd = () => {
306
343
  // Clear inspection on touch end (unless locked)
307
344
  if (isInspectionActive && !isLocked) {
345
+ lastInspectedElement = null;
308
346
  setHoveredComponent(null, null);
309
347
  }
310
348
  };
@@ -320,6 +358,10 @@ export const setupAutoInspection = (setHoveredComponent, isInspectionActive, isL
320
358
  return () => {
321
359
  window.removeEventListener("mousemove", handleMouseMove);
322
360
  document.removeEventListener("mouseleave", handleMouseLeave);
361
+ if (throttleHandle !== null) {
362
+ clearTimeout(throttleHandle);
363
+ throttleHandle = null;
364
+ }
323
365
  if (isTouchDevice) {
324
366
  window.removeEventListener("touchmove", handleTouchMove);
325
367
  window.removeEventListener("touchstart", handleTouchStart);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zargaryanvh/react-component-inspector",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "A development tool for inspecting React components with AI-friendly metadata extraction",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",