''' <summary>
''' Draws an animated highlight overlay over the specified web element
''' using javascript executed via <see cref="IJavaScriptExecutor"/>.
''' <para></para>
''' The overlay is automatically removed from the DOM after <paramref name="durationMs"/> milliseconds.
''' </summary>
'''
''' <example> This is a code example.
''' <code language="VB">
''' Dim elemment As IWebElement = driver.FindElement(By.Id("submit-btn"))
''' HighlightElement(driver, elemment,
''' labelText:="Click here",
''' labelForeColor:="red",
''' showArrow:=True,
''' arrowSize:=24,
''' arrowAlignment:=ContentAlignment.MiddleLeft,
''' arrowColor:="red",
''' borderColor:="red",
''' fillColor:="white",
''' fillOpacity:=0.4,
''' durationMs:=3000)
''' </code>
''' </example>
'''
''' <param name="driver">
''' Active <see cref="IWebDriver"/> instance. Must implement
''' <see cref="IJavaScriptExecutor"/>; otherwise an <see cref="InvalidCastException"/> will be thrown.
''' </param>
'''
''' <param name="element">
''' The DOM element to highlight. Its position is determined at runtime
''' via <c>getBoundingClientRect()</c>, accounting for current scroll offsets.
''' </param>
'''
''' <param name="labelText">
''' Optional. The text displayed centered inside the overlay box.
''' </param>
'''
''' <param name="labelFontSize">
''' Optional. The initial font size for <paramref name="labelText"/>, in pixels.
''' <para></para>
''' Note: The font size auto-shrinks down up to 6px if the text overflows the element's bounds.
''' <para></para>
''' Default value is <c>14</c>.
''' </param>
'''
''' <param name="labelForeColor">
''' Optional. The CSS color value for the label text (e.g. <c>"black"</c>, <c>"#fff"</c>).
''' <para></para>
''' Default value is <c>"black"</c>.
''' </param>
'''
''' <param name="showArrow">
''' Optional. A value indicating whether to render an animated arrow pointing
''' toward the element from the side set by <paramref name="arrowAlignment"/> parameter.
''' </param>
'''
''' <param name="arrowAlignment">
''' Optional. A <see cref="ContentAlignment"/> value that controls which side of the element
''' the arrow appears on and the direction it points.
''' <para></para>
''' Note: This value has no effect if <paramref name="showArrow"/> is <see langword="False"/>
''' <para></para>
''' Default value is <see cref="ContentAlignment.MiddleLeft"/>.
''' </param>
'''
''' <param name="arrowSize">
''' Optional. The square size (width x height) of the arrow bounding box, in pixels.
''' <para></para>
''' Default value is <c>30</c>.
''' </param>
'''
''' <param name="arrowColor">
''' Optional. The CSS color value for the arrow (e.g. <c>"black"</c>, <c>"#fff"</c>).
''' <para></para>
''' Default value is <c>"darkred"</c>.
''' </param>
'''
''' <param name="borderColor">
''' Optional. The CSS color value for the overlay border (e.g. <c>"black"</c>, <c>"#fff"</c>).
''' <para></para>
''' Default value is <c>"red"</c>.
''' </param>
'''
''' <param name="fillColor">
''' Optional. The CSS color value to fill the overlay background (e.g. <c>"black"</c>, <c>"#fff"</c>).
''' <para></para>
''' Default value is <c>"white"</c>.
''' </param>
'''
''' <param name="fillOpacity">
''' Optional. The opacity of the background overlay, from <c>0.0</c> (transparent) to <c>1.0</c> (opaque).
''' <para></para>
''' Default value is <c>0.4</c>.
''' </param>
'''
''' <param name="durationMs">
''' Optional. The duration before the overlay (and arrow, if shown) are removed from the DOM, in milliseconds.
''' <para></para>
''' Default value is <c>3000</c> (3 seconds).
''' </param>
<DebuggerStepThrough>
Public Shared Function HighlightElement(driver As IWebDriver,
element As IWebElement,
Optional labelText As String = Nothing,
Optional labelFontSize As Integer = 14,
Optional labelForeColor As String = "black",
Optional showArrow As Boolean = True,
Optional arrowAlignment As ContentAlignment = ContentAlignment.MiddleLeft,
Optional arrowSize As Integer = 30,
Optional arrowColor As String = "darkred",
Optional borderColor As String = "red",
Optional fillColor As String = "white",
Optional fillOpacity As Double = 0.4,
Optional durationMs As Integer = 3000) As Boolean
ArgumentNullException.ThrowIfNull(driver)
ArgumentNullException.ThrowIfNull(element)
Try
Dim probeHandle As String = driver.CurrentWindowHandle
Catch ex As Exception
Throw New InvalidOperationException("WebDriver session is no longer available.", ex)
End Try
Try
Dim probeTag As String = element.TagName
If Not element.Displayed Then
Return False
End If
Catch ex As StaleElementReferenceException
Throw New InvalidOperationException("Target element is stale or detached from the DOM.", ex)
Catch ex As WebDriverException
Throw New InvalidOperationException("Target element is not accessible through the WebDriver.", ex)
End Try
Dim js As IJavaScriptExecutor = TryCast(driver, IJavaScriptExecutor)
If js Is Nothing Then
Throw New NotSupportedException("The provided IWebDriver does not implement IJavaScriptExecutor.")
End If
Dim script As String = "
try {
return (function(el,
labelText, labelFontSize, labelForeColor,
showArrow, arrowAlignment, arrowSize, arrowColor,
borderColor, fillColor, fillOpacity,
durationMs) {
if (!el || !(el instanceof Element) || !el.isConnected) { return 'no-element'; }
let r = el.getBoundingClientRect();
if (!r || (r.width === 0 && r.height === 0)) { return 'zero-rect'; }
let sx = window.scrollX, sy = window.scrollY;
let cx = r.left + sx + r.width / 2;
let cy = r.top + sy + r.height / 2;
// Overlay box — positioned and sized to wrap the element with a small padding
let overlay = document.createElement('div');
overlay.style.cssText = [
'position:absolute',
'left:' + (r.left + sx - 5) + 'px',
'top:' + (r.top + sy - 5) + 'px',
'width:' + (r.width + 10) + 'px',
'height:' + (r.height + 10) + 'px',
'border:3px solid ' + borderColor,
'border-radius:8px',
'box-shadow:0 0 20px ' + borderColor,
'z-index:999998',
'pointer-events:none',
'display:flex',
'align-items:center',
'justify-content:center',
'box-sizing:border-box',
'overflow:hidden'
].join(';');
document.body.appendChild(overlay);
// Fill div — opacity only affects background, not the border
let fill = document.createElement('div');
fill.style.cssText = 'position:absolute;' +
'inset:0;' +
'background:' + fillColor + ';' +
'opacity:' + fillOpacity + ';' +
'z-index:0;';
overlay.appendChild(fill);
// Label — auto-shrinks font size until text fits inside the overlay
let lbl = null;
if (labelText) {
let lbl = document.createElement('div');
lbl.textContent = labelText;
lbl.style.cssText = 'position:relative;' +
'z-index:1;' +
'width:100%;' +
'box-sizing:border-box;' +
'white-space:normal;' +
'word-break:break-word;' +
'text-align:center;' +
'color:' + labelForeColor + ';' +
'font-size:' + labelFontSize + 'px;' +
'font-weight:bold;' +
'padding:2px 4px;' +
'border-radius:4px;';
overlay.appendChild(lbl);
let fs = labelFontSize;
while (fs > 6 && lbl.scrollHeight > overlay.clientHeight) {
fs--;
lbl.style.fontSize = fs + 'px';
}
}
// Arrow — SVG polygon rotated via CSS to point toward the element from its position.
// Two nested divs: arrowWrap handles position + bounce animation,
// arrowInner handles rotation so both transforms don't conflict.
let arrowWrap = null;
if (showArrow) {
let styleTag = document.createElement('style');
styleTag.textContent =
'@keyframes bML {from{transform:translateX( 0px)} to{transform:translateX(-8px)}}' +
'@keyframes bMR {from{transform:translateX( 0px)} to{transform:translateX( 8px)}}' +
'@keyframes bTC {from{transform:translateY( 0px)} to{transform:translateY(-8px)}}' +
'@keyframes bBC {from{transform:translateY( 0px)} to{transform:translateY( 8px)}}' +
'@keyframes bTL {from{transform:translate(0,0)} to{transform:translate(-6px,-6px)}}' +
'@keyframes bTR {from{transform:translate(0,0)} to{transform:translate( 6px,-6px)}}' +
'@keyframes bBL {from{transform:translate(0,0)} to{transform:translate(-6px, 6px)}}' +
'@keyframes bBR {from{transform:translate(0,0)} to{transform:translate( 6px, 6px)}}';
document.head.appendChild(styleTag);
// rot: CSS rotation so the arrow always points TOWARD the element from its side.
// anim: bounce keyframe name matching the arrow's side.
// Both are constant for the lifetime of the highlight — assigned once here,
// never inside updatePosition (which would restart the animation every tick).
let staticCfg = {
'MiddleLeft' : { rot: '0', anim:'bML' },
'MiddleRight' : { rot:'180', anim:'bMR' },
'TopCenter' : { rot: '90', anim:'bTC' },
'BottomCenter' : { rot:'270', anim:'bBC' },
'TopLeft' : { rot: '45', anim:'bTL' },
'TopRight' : { rot:'135', anim:'bTR' },
'BottomLeft' : { rot:'315', anim:'bBL' },
'BottomRight' : { rot:'225', anim:'bBR' }
}[arrowAlignment] || { rot:'0', anim:'bML' };
arrowWrap = document.createElement('div');
arrowWrap.style.cssText = 'position:absolute;' +
'width:' + arrowSize + 'px;' +
'height:' + arrowSize + 'px;' +
'z-index:999999;pointer-events:none;';
arrowWrap.style.animation = staticCfg.anim + ' 0.4s ease-in-out infinite alternate';
arrowInner = document.createElement('div');
arrowInner.style.cssText = 'width:' + arrowSize + 'px;' +
'height:' + arrowSize + 'px;' +
'transform:rotate(' + staticCfg.rot + 'deg);' +
'transform-origin:center center;';
// Arrow shape: rectangular shaft + concave arrowhead.
arrowInner.innerHTML = '<svg viewBox=\'0 0 48 40\' width=\'' + arrowSize + '\' height=\'' + arrowSize + '\' xmlns=\'http://www.w3.org/2000/svg\'>' +
'<polygon points=\'0,16 30,16 24,4 48,20 24,36 30,24 0,24\' fill=\'' + arrowColor + '\'/>' +
'</svg>';
arrowWrap.appendChild(arrowInner);
document.body.appendChild(arrowWrap);
}
// updatePosition — recalculates overlay and arrow placement on every tick,
// so the highlight stays glued to the element when the user scrolls,
// resizes the window, or the page reflows dynamically.
function updatePosition() {
try {
if (!el || !el.isConnected) { return; }
let r = el.getBoundingClientRect();
let sx = window.scrollX, sy = window.scrollY;
overlay.style.left = (r.left + sx - 5) + 'px';
overlay.style.top = (r.top + sy - 5) + 'px';
overlay.style.width = (r.width + 10) + 'px';
overlay.style.height = (r.height + 10) + 'px';
// Auto-shrink the label font size until it fits inside the overlay,
// recomputed on each tick because the box can be resized by the user.
if (lbl) {
let fs = labelFontSize;
lbl.style.fontSize = fs + 'px';
while (fs > 6 && lbl.scrollHeight > overlay.clientHeight) {
fs--;
lbl.style.fontSize = fs + 'px';
}
}
if (arrowWrap && arrowInner) {
// ax/ay: absolute position of the arrow bounding box, recomputed each tick.
// d: diagonal inset — compensates the 45° rotation so the visible tip sits
// exactly `marginDiagonal` pixels away from the element border, regardless of arrowSize.
// marginStraight: clearance for MiddleLeft/MiddleRight/TopCenter/BottomCenter.
// marginDiagonal: clearance for TopLeft/TopRight/BottomLeft/BottomRight.
let cx = r.left + sx + r.width / 2;
let cy = r.top + sy + r.height / 2;
let d = arrowSize * (0.5 - Math.SQRT2 / 4);
let marginStraight = 8;
let marginDiagonal = 4;
let cfg = {
'MiddleLeft' : { ax: r.left + sx - arrowSize - marginStraight, ay: cy - arrowSize / 2 },
'MiddleRight' : { ax: r.right + sx + marginStraight, ay: cy - arrowSize / 2 },
'TopCenter' : { ax: cx - arrowSize / 2, ay: r.top + sy - arrowSize - marginStraight },
'BottomCenter' : { ax: cx - arrowSize / 2, ay: r.bottom + sy + marginStraight },
'TopLeft' : { ax: r.left + sx - arrowSize + d - marginDiagonal, ay: r.top + sy - arrowSize + d - marginDiagonal },
'TopRight' : { ax: r.right + sx - d + marginDiagonal, ay: r.top + sy - arrowSize + d - marginDiagonal },
'BottomLeft' : { ax: r.left + sx - arrowSize + d - marginDiagonal, ay: r.bottom + sy - d + marginDiagonal },
'BottomRight' : { ax: r.right + sx - d + marginDiagonal, ay: r.bottom + sy - d + marginDiagonal }
}[arrowAlignment] || {
ax: r.left + sx - arrowSize - marginStraight, ay: cy - arrowSize / 2
};
arrowWrap.style.left = cfg.ax + 'px';
arrowWrap.style.top = cfg.ay + 'px';
// Note: animation and rotation are set ONCE outside this function —
// reassigning them each tick would restart the CSS animation and
// freeze the arrow on its first frame.
}
} catch (e) { /* swallow — next tick will retry */ }
}
updatePosition();
let intervalId = setInterval(updatePosition, 100);
// Cleanup — remove all injected DOM nodes after the specified duration
setTimeout(function() {
overlay.remove();
if (arrowWrap) arrowWrap.remove();
}, durationMs);
return 'ok';
})(arguments[0], arguments[1], arguments[2], arguments[3], arguments[4],
arguments[5], arguments[6], arguments[7], arguments[8], arguments[9],
arguments[10], arguments[11]);
} catch (e) {
return 'js-error:' + (e && e.message ? e.message : String(e));
}
"
Dim result As Object
Try
result = js.ExecuteScript(script, element,
labelText, labelFontSize, labelForeColor,
showArrow, arrowAlignment.ToString(), arrowSize, arrowColor,
borderColor, fillColor, fillOpacity.ToString(CultureInfo.InvariantCulture),
durationMs)
Catch ex As StaleElementReferenceException
Throw New InvalidOperationException("Element became stale during highlight script execution.", ex)
Catch ex As WebDriverException
Throw New InvalidOperationException("WebDriver failed to execute the highlight script.", ex)
End Try
Dim status As String = If(result?.ToString(), String.Empty)
Select Case status
Case "ok"
Return True
Case "no-element", "zero-rect"
Return False
Case Else
If status.StartsWith("js-error:", StringComparison.Ordinal) Then
Throw New InvalidOperationException($"Highlight javascript failed in the browser: {status.Substring(9)}")
End If
Throw New InvalidOperationException($"Highlight javascript returned an unexpected status: '{status}'.")
End Select
End Function