packages/devtools/src/components/selectable-elements.tsx
import React from "react";
import debounce from "lodash/debounce";
import { createPortal } from "react-dom";
import { ApplyStyles } from "./apply-styles";
import { SelectorIcon } from "./icons/selector-button";
const SelectableElement = ({
element,
name,
onSelect,
}: {
element: HTMLElement;
name: string;
onSelect: (name: string) => void;
}) => {
const [position] = React.useState(() => {
const { top, left, width, height } = element.getBoundingClientRect();
const { scrollLeft, scrollTop } = document.documentElement;
const positionLeft = left + scrollLeft;
const positionTop = top + scrollTop;
return { left: positionLeft, top: positionTop, width, height };
});
const elementRef = React.useRef<HTMLButtonElement | null>(null);
React.useEffect(() => {
// use scroll event listener
const onScroll = debounce(
() => {
const { top, left, width, height } = element.getBoundingClientRect();
const { scrollLeft, scrollTop } = document.documentElement;
const positionLeft = left + scrollLeft;
const positionTop = top + scrollTop;
elementRef.current?.style.setProperty("left", `${positionLeft}px`);
elementRef.current?.style.setProperty("top", `${positionTop}px`);
elementRef.current?.style.setProperty("width", `${width}px`);
elementRef.current?.style.setProperty("height", `${height}px`);
elementRef.current?.style.setProperty("opacity", "1");
},
200,
{
leading: false,
trailing: true,
},
);
const opacityOnScroll = debounce(
() => {
elementRef.current?.style.setProperty("opacity", "0");
},
200,
{
leading: true,
trailing: false,
},
);
document.addEventListener("scroll", onScroll);
document.addEventListener("scroll", opacityOnScroll);
return () => {
document.removeEventListener("scroll", onScroll);
document.removeEventListener("scroll", opacityOnScroll);
};
}, [element]);
const placement = React.useMemo(() => {
const tooltipBaseSize = { width: 22, height: 22 };
const nameWidth = name.length * 7.5;
const tooltipSize = {
width: tooltipBaseSize.width + nameWidth,
height: tooltipBaseSize.height,
};
const gap = 4;
// outside top start
if (
position.top - tooltipSize.height > 0 &&
position.left + tooltipSize.width < window.innerWidth &&
position.width > tooltipSize.width
) {
return { left: gap / 2, top: tooltipSize.height * -1 - gap };
}
// inside top start
if (
position.height >= tooltipSize.height * 1.5 &&
position.width >= tooltipSize.width * 1.5
) {
return { left: 0 + gap, top: 0 + gap };
}
// outside left start
if (position.left - tooltipSize.width > 0) {
return { right: position.width + gap, top: 0 - 1 };
}
// outside right start
if (
position.left + position.width + tooltipSize.width <
window.innerWidth
) {
return { left: position.width + gap, top: 0 - 1 };
}
// outside bottom start
if (
position.top + position.height + tooltipSize.height <
document.documentElement.scrollHeight
) {
return { left: 0 - 1, top: position.height + gap };
}
return { left: 0, top: 0 };
}, [position, name.length]);
return (
<button
type="button"
className="selector-xray-box"
onClick={(event) => {
event?.preventDefault();
event?.stopPropagation();
onSelect(name);
}}
ref={elementRef}
style={{
position: "absolute",
...position,
}}
>
<div
style={{
position: "absolute",
...placement,
}}
className="selector-xray-info"
>
<span className="selector-xray-info-icon">
<SelectorIcon
width={12}
height={12}
style={{ pointerEvents: "none" }}
/>
</span>
<span className="selector-xray-info-title">{` ${name}`}</span>
</div>
</button>
);
};
export const SelectableElements = ({
elements,
onSelect,
}: {
elements: Array<{ element: HTMLElement; name: string }>;
onSelect: (name: string) => void;
}) => {
const [selectorBoxRoot, setSelectorBoxRoot] =
React.useState<HTMLElement | null>(null);
React.useEffect(() => {
if (!selectorBoxRoot) {
const element = document.createElement("div");
element.id = "selector-box-root";
document.body.appendChild(element);
setSelectorBoxRoot(element);
return () => {
document.body.removeChild(element);
setSelectorBoxRoot(null);
};
}
return () => 0;
}, []);
if (!selectorBoxRoot) return null;
return (
<>
{createPortal(
elements.map((element, idx) => (
<SelectableElement
key={`selector-element-${idx}-${element.name}`}
{...element}
onSelect={onSelect}
/>
)),
selectorBoxRoot,
)}
<ApplyStyles>
{
/* css */ `
.selector-xray-box {
display: flex;
margin: 0;
padding: 0;
appearance: none;
z-index: 9999;
border: 2px dashed #47EBEB;
border-radius: 6px;
background: rgba(71, 235, 235, 0.01);
transition: opacity 0.2s ease-in-out;
cursor: crosshair;
}
.selector-xray-info {
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
padding: 3px 0;
min-width: 22px;
height: 22px;
color: #14141F;
background: #47EBEB;
font-size: 12px;
line-height: 16px;
font-family: monospace;
border-radius: 11px;
}
.selector-xray-info-icon {
display: flex;
min-width: 22px;
justify-content: center;
align-items: center;
flex-shrink: 0;
}
.selector-xray-info-title {
display: block;
max-width: 0;
overflow: hidden;
transition-property: max-width, padding-right;
transition-duration: 0.2s;
transition-timing-function: ease-in-out;
transition-delay: 0.1s;
}
.selector-xray-box:hover .selector-xray-info-title {
max-width: 200px;
padding-right: 8px;
}
`
}
</ApplyStyles>
</>
);
};