Initial stab at 'View in DOM' button

This commit is contained in:
Brian Vaughn
2019-02-08 18:48:29 -05:00
parent baa6142801
commit a6126c9e27
5 changed files with 391 additions and 2 deletions
+37
View File
@@ -2,6 +2,7 @@
import EventEmitter from 'events';
import { __DEBUG__ } from '../constants';
import { hideOverlay, showOverlay } from './views/Highlighter';
import type { RendererID, RendererInterface } from './types';
import type { Bridge } from '../types';
@@ -26,12 +27,48 @@ export default class Agent extends EventEmitter {
bridge.addListener('shutdown', () => this.emit('shutdown'));
bridge.addListener('highlightElementInDOM', this.highlightElementInDOM);
bridge.addListener('inspectElement', this.inspectElement);
bridge.addListener('selectElement', this.selectElement);
// TODO Listen to bridge for things like selection.
// bridge.on('...'), this...);
}
highlightElementInDOM = ({
displayName,
id,
rendererID,
}: {
displayName: string,
id: number,
rendererID: number,
}) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
}
let node: HTMLElement | null = null;
if (
renderer !== null &&
typeof renderer.getNativeFromReactElement === 'function'
) {
node = ((renderer.getNativeFromReactElement(id): any): HTMLElement);
}
if (node != null) {
if (typeof node.scrollIntoView === 'function') {
// If the node isn't visible show it before highlighting it.
// We may want to reconsider this; it might be a little disruptive.
node.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
showOverlay(((node: any): HTMLElement), displayName);
} else {
hideOverlay();
}
};
inspectElement = ({ id, rendererID }: { id: number, rendererID: number }) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
+2 -2
View File
@@ -816,9 +816,9 @@ export function attach(
// The naming is confusing.
// They deal with opaque nodes (fibers), not elements.
function getNativeFromReactElement(fiber) {
function getNativeFromReactElement(id: number) {
try {
const primaryFiber = fiber;
const primaryFiber = getPrimaryFiber(idToFiberMap.get(id));
const hostInstance = renderer.findHostInstanceByFiber(primaryFiber);
return hostInstance;
} catch (err) {
+38
View File
@@ -0,0 +1,38 @@
// @flow
import Overlay from './Overlay';
const SHOW_DURATION = 2000;
let timeoutID: TimeoutID | null = null;
let overlay: Overlay | null = null;
export function hideOverlay() {
timeoutID = null;
if (overlay !== null) {
overlay.remove();
overlay = null;
}
}
export function showOverlay(
element: HTMLElement | null,
componentName: string = ''
) {
if (timeoutID !== null) {
clearTimeout(timeoutID);
}
if (element == null) {
return;
}
if (overlay === null) {
overlay = new Overlay();
}
overlay.inspect(element, componentName);
timeoutID = setTimeout(hideOverlay, SHOW_DURATION);
}
+298
View File
@@ -0,0 +1,298 @@
// @flow
import assign from 'object-assign';
type Rect = {
bottom: number,
height: number,
left: number,
right: number,
top: number,
width: number,
};
// Note that this component is not affected by the active Theme,
// because it highlights elements in the main Chrome window (outside of devtools).
// The colors below were chosen to roughly match those used by Chrome devtools.
export default class Overlay {
win: Object;
container: HTMLElement;
node: HTMLElement;
border: HTMLElement;
padding: HTMLElement;
content: HTMLElement;
tip: HTMLElement;
nameSpan: HTMLElement;
dimSpan: HTMLElement;
constructor() {
const doc = window.document;
this.win = window;
this.container = doc.createElement('div');
this.node = doc.createElement('div');
this.border = doc.createElement('div');
this.padding = doc.createElement('div');
this.content = doc.createElement('div');
this.border.style.borderColor = overlayStyles.border;
this.padding.style.borderColor = overlayStyles.padding;
this.content.style.backgroundColor = overlayStyles.background;
assign(this.node.style, {
borderColor: overlayStyles.margin,
pointerEvents: 'none',
position: 'fixed',
});
this.tip = doc.createElement('div');
assign(this.tip.style, {
backgroundColor: '#333740',
borderRadius: '2px',
fontFamily:
'"SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace',
fontWeight: 'bold',
padding: '3px 5px',
position: 'fixed',
fontSize: '12px',
});
this.nameSpan = doc.createElement('span');
this.tip.appendChild(this.nameSpan);
assign(this.nameSpan.style, {
color: '#ee78e6',
borderRight: '1px solid #aaaaaa',
paddingRight: '0.5rem',
marginRight: '0.5rem',
});
this.dimSpan = doc.createElement('span');
this.tip.appendChild(this.dimSpan);
assign(this.dimSpan.style, {
color: '#d7d7d7',
});
this.container.style.zIndex = '10000000';
this.node.style.zIndex = '10000000';
this.tip.style.zIndex = '10000000';
this.container.appendChild(this.node);
this.container.appendChild(this.tip);
this.node.appendChild(this.border);
this.border.appendChild(this.padding);
this.padding.appendChild(this.content);
doc.body.appendChild(this.container);
}
remove() {
if (this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
}
inspect(node: HTMLElement, name?: ?string) {
// We can't get the size of text nodes or comment nodes. React as of v15
// heavily uses comment nodes to delimit text.
if (node.nodeType !== Node.ELEMENT_NODE) {
return;
}
const box = getNestedBoundingClientRect(node, this.win);
const dims = getElementDimensions(node);
boxWrap(dims, 'margin', this.node);
boxWrap(dims, 'border', this.border);
boxWrap(dims, 'padding', this.padding);
assign(this.content.style, {
height:
box.height -
dims.borderTop -
dims.borderBottom -
dims.paddingTop -
dims.paddingBottom +
'px',
width:
box.width -
dims.borderLeft -
dims.borderRight -
dims.paddingLeft -
dims.paddingRight +
'px',
});
assign(this.node.style, {
top: box.top - dims.marginTop + 'px',
left: box.left - dims.marginLeft + 'px',
});
this.nameSpan.textContent = name || node.nodeName.toLowerCase();
this.dimSpan.textContent =
Math.round(box.width) + 'px × ' + Math.round(box.height) + 'px';
const tipPos = findTipPos(
{
top: box.top - dims.marginTop,
left: box.left - dims.marginLeft,
height: box.height + dims.marginTop + dims.marginBottom,
width: box.width + dims.marginLeft + dims.marginRight,
},
this.win
);
assign(this.tip.style, tipPos);
}
}
function findTipPos(dims, win) {
const tipHeight = 20;
const margin = 5;
let top;
if (dims.top + dims.height + tipHeight <= win.innerHeight) {
if (dims.top + dims.height < 0) {
top = margin;
} else {
top = dims.top + dims.height + margin;
}
} else if (dims.top - tipHeight <= win.innerHeight) {
if (dims.top - tipHeight - margin < margin) {
top = margin;
} else {
top = dims.top - tipHeight - margin;
}
} else {
top = win.innerHeight - tipHeight - margin;
}
top += 'px';
if (dims.left < 0) {
return { top, left: margin };
}
if (dims.left + 200 > win.innerWidth) {
return { top, right: margin };
}
return { top, left: dims.left + margin + 'px' };
}
function getElementDimensions(domElement) {
const calculatedStyle = window.getComputedStyle(domElement);
return {
borderLeft: +calculatedStyle.borderLeftWidth.match(/[0-9]*/)[0],
borderRight: +calculatedStyle.borderRightWidth.match(/[0-9]*/)[0],
borderTop: +calculatedStyle.borderTopWidth.match(/[0-9]*/)[0],
borderBottom: +calculatedStyle.borderBottomWidth.match(/[0-9]*/)[0],
marginLeft: +calculatedStyle.marginLeft.match(/[0-9]*/)[0],
marginRight: +calculatedStyle.marginRight.match(/[0-9]*/)[0],
marginTop: +calculatedStyle.marginTop.match(/[0-9]*/)[0],
marginBottom: +calculatedStyle.marginBottom.match(/[0-9]*/)[0],
paddingLeft: +calculatedStyle.paddingLeft.match(/[0-9]*/)[0],
paddingRight: +calculatedStyle.paddingRight.match(/[0-9]*/)[0],
paddingTop: +calculatedStyle.paddingTop.match(/[0-9]*/)[0],
paddingBottom: +calculatedStyle.paddingBottom.match(/[0-9]*/)[0],
};
}
// Get the window object for the document that a node belongs to,
// or return null if it cannot be found (node not attached to DOM,
// etc).
function getOwnerWindow(node: HTMLElement): typeof window | null {
if (!node.ownerDocument) {
return null;
}
return node.ownerDocument.defaultView;
}
// Get the iframe containing a node, or return null if it cannot
// be found (node not within iframe, etc).
function getOwnerIframe(node: HTMLElement): HTMLElement | null {
const nodeWindow = getOwnerWindow(node);
if (nodeWindow) {
return nodeWindow.frameElement;
}
return null;
}
// Get a bounding client rect for a node, with an
// offset added to compensate for its border.
function getBoundingClientRectWithBorderOffset(node: HTMLElement) {
const dimensions = getElementDimensions(node);
return mergeRectOffsets([
node.getBoundingClientRect(),
{
top: dimensions.borderTop,
left: dimensions.borderLeft,
bottom: dimensions.borderBottom,
right: dimensions.borderRight,
// This width and height won't get used by mergeRectOffsets (since this
// is not the first rect in the array), but we set them so that this
// object typechecks as a ClientRect.
width: 0,
height: 0,
},
]);
}
// Add together the top, left, bottom, and right properties of
// each ClientRect, but keep the width and height of the first one.
function mergeRectOffsets(rects: Array<Rect>): Rect {
return rects.reduce((previousRect, rect) => {
if (previousRect == null) {
return rect;
}
return {
top: previousRect.top + rect.top,
left: previousRect.left + rect.left,
width: previousRect.width,
height: previousRect.height,
bottom: previousRect.bottom + rect.bottom,
right: previousRect.right + rect.right,
};
});
}
// Calculate a boundingClientRect for a node relative to boundaryWindow,
// taking into account any offsets caused by intermediate iframes.
function getNestedBoundingClientRect(
node: HTMLElement,
boundaryWindow: typeof window
): Rect {
const ownerIframe = getOwnerIframe(node);
if (ownerIframe && ownerIframe !== boundaryWindow) {
const rects = [node.getBoundingClientRect()];
let currentIframe = ownerIframe;
let onlyOneMore = false;
while (currentIframe) {
const rect = getBoundingClientRectWithBorderOffset(currentIframe);
rects.push(rect);
currentIframe = getOwnerIframe(currentIframe);
if (onlyOneMore) {
break;
}
// We don't want to calculate iframe offsets upwards beyond
// the iframe containing the boundaryWindow, but we
// need to calculate the offset relative to the boundaryWindow.
if (currentIframe && getOwnerWindow(currentIframe) === boundaryWindow) {
onlyOneMore = true;
}
}
return mergeRectOffsets(rects);
} else {
return node.getBoundingClientRect();
}
}
function boxWrap(dims, what, node) {
assign(node.style, {
borderTopWidth: dims[what + 'Top'] + 'px',
borderLeftWidth: dims[what + 'Left'] + 'px',
borderRightWidth: dims[what + 'Right'] + 'px',
borderBottomWidth: dims[what + 'Bottom'] + 'px',
borderStyle: 'solid',
});
}
const overlayStyles = {
background: 'rgba(120, 170, 210, 0.7)',
padding: 'rgba(77, 200, 0, 0.3)',
margin: 'rgba(255, 155, 0, 0.3)',
border: 'rgba(255, 200, 50, 0.3)',
};
+16
View File
@@ -22,12 +22,27 @@ export type Props = {||};
export default function SelectedElement(_: Props) {
const { selectedElementID } = useContext(TreeContext);
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
const element =
selectedElementID !== null ? store.getElementByID(selectedElementID) : null;
const inspectedElement = useInspectedElement(selectedElementID);
const handleClick = useCallback(() => {
if (element !== null && selectedElementID !== null) {
const rendererID =
store.getRendererIDForElement(selectedElementID) || null;
if (rendererID !== null) {
bridge.send('highlightElementInDOM', {
displayName: element.displayName,
id: selectedElementID,
rendererID,
});
}
}
}, [bridge, selectedElementID, store]);
// TODO Make "view DOM" and "view source" buttons work
if (element === null) {
@@ -51,6 +66,7 @@ export default function SelectedElement(_: Props) {
<button
className={styles.IconButton}
onClick={handleClick}
title="Highlight this element in the page"
>
<ButtonIcon type="view-dom" />