From e7da2947177e98a60bb3b09a0dda74e470f87bf6 Mon Sep 17 00:00:00 2001 From: Stephan Meijer Date: Thu, 18 Jun 2020 17:41:00 +0200 Subject: [PATCH 01/12] chore: render preview in a sandbox --- .../src/content-script/highlighter/Overlay.js | 2 +- .../src/content-script/highlighter/index.js | 38 ++- .../src/content-script/highlighter/utils.js | 3 +- scripts/build-client.js | 2 +- src/components/Preview.js | 259 +++++++----------- src/components/PreviewHint.js | 2 +- src/components/Scrollable.js | 4 +- src/lib/queryAdvise.js | 6 +- src/sandbox.html | 13 + src/sandbox.js | 71 +++++ src/styles/app.pcss | 6 +- 11 files changed, 225 insertions(+), 181 deletions(-) create mode 100644 src/sandbox.html create mode 100644 src/sandbox.js diff --git a/devtools/src/content-script/highlighter/Overlay.js b/devtools/src/content-script/highlighter/Overlay.js index c77ef039..8e66376d 100644 --- a/devtools/src/content-script/highlighter/Overlay.js +++ b/devtools/src/content-script/highlighter/Overlay.js @@ -228,7 +228,7 @@ export default class Overlay { const name = elements[0].nodeName.toLowerCase(); const node = elements[0]; - const hook = node.ownerDocument.defaultView.__TESTING_PLAYGROUND__; + const hook = node.ownerDocument.defaultView.__TESTING_PLAYGROUND__ || {}; let tipData = { target: node, diff --git a/devtools/src/content-script/highlighter/index.js b/devtools/src/content-script/highlighter/index.js index e38f2478..95c0c3ee 100644 --- a/devtools/src/content-script/highlighter/index.js +++ b/devtools/src/content-script/highlighter/index.js @@ -26,6 +26,8 @@ export default function setupHighlighter({ onSelectNode = () => {}, } = {}) { let isInspecting = false; + let stopOnClick = true; + let blockEvents = true; Bridge.onMessage('CLEAR_HIGHLIGHTS', withMessageData(clearHighlights)); Bridge.onMessage('HIGHLIGHT_ELEMENTS', withMessageData(highlightElements)); @@ -33,8 +35,11 @@ export default function setupHighlighter({ Bridge.onMessage('START_INSPECTING', withMessageData(startInspecting)); Bridge.onMessage('STOP_INSPECTING', withMessageData(stopInspecting)); - function startInspecting() { + function startInspecting(options) { isInspecting = true; + stopOnClick = options.stopOnClick !== false; + blockEvents = options.blockEvents !== false; + addEventListeners(view); } @@ -51,6 +56,13 @@ export default function setupHighlighter({ } } + function stopPropagation(event) { + if (blockEvents) { + event.preventDefault(); + event.stopPropagation(); + } + } + function stopInspecting() { hideOverlay(); removeEventListeners(view); @@ -99,30 +111,27 @@ export default function setupHighlighter({ } function onClick(event) { - event.preventDefault(); - event.stopPropagation(); + stopPropagation(event); - stopInspecting(); + if (isInspecting && stopOnClick) { + stopInspecting(); + } } function onMouseEvent(event) { - event.preventDefault(); - event.stopPropagation(); + stopPropagation(event); } function onPointerDown(event) { - event.preventDefault(); - event.stopPropagation(); + stopPropagation(event); - selectNode(event.target); + selectNode(event.target, { trigger: 'click' }); } function onPointerOver(event) { - event.preventDefault(); - event.stopPropagation(); + stopPropagation(event); const target = event.target; - if (target.tagName === 'IFRAME') { try { if (!iframesListeningTo.has(target)) { @@ -136,12 +145,11 @@ export default function setupHighlighter({ } showOverlay([target], false); - selectNode(target); + selectNode(target, { trigger: 'hover' }); } function onPointerUp(event) { - event.preventDefault(); - event.stopPropagation(); + stopPropagation(event); } const selectNode = throttle( diff --git a/devtools/src/content-script/highlighter/utils.js b/devtools/src/content-script/highlighter/utils.js index fbe0f90b..711ca90d 100644 --- a/devtools/src/content-script/highlighter/utils.js +++ b/devtools/src/content-script/highlighter/utils.js @@ -64,7 +64,8 @@ export function mergeRectOffsets(rects) { // taking into account any offsets caused by intermediate iframes. export function getNestedBoundingClientRect(node, boundaryWindow) { const ownerIframe = getOwnerIframe(node); - if (ownerIframe && ownerIframe !== boundaryWindow) { + + if (ownerIframe && ownerIframe.contentWindow !== boundaryWindow) { const rects = [node.getBoundingClientRect()]; let currentIframe = ownerIframe; let onlyOneMore = false; diff --git a/scripts/build-client.js b/scripts/build-client.js index 90a5818d..d2d45cd4 100644 --- a/scripts/build-client.js +++ b/scripts/build-client.js @@ -67,7 +67,7 @@ async function main() { const dest = resolve('dist/client'); await remove(dest); - const entries = ['src/index.html', 'src/embed.js']; + const entries = ['src/index.html', 'src/embed.js', 'src/sandbox.html']; if (process.env.NODE_ENV === 'development') { entries.push('src/embed.html'); diff --git a/src/components/Preview.js b/src/components/Preview.js index 418b3bf7..acb0ba88 100644 --- a/src/components/Preview.js +++ b/src/components/Preview.js @@ -1,184 +1,127 @@ -import React, { - useState, - useEffect, - useRef, - useMemo, - useCallback, -} from 'react'; -import Scrollable from './Scrollable'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import PreviewHint from './PreviewHint'; import AddHtml from './AddHtml'; -import { getQueryAdvise } from '../lib'; +import { getRoles } from '@testing-library/dom'; +import debounce from 'lodash.debounce'; + +function getSandbox(ref) { + try { + const document = + ref.current?.contentDocument || + ref.current?.contentWindow?.document || + null; + + if (document) { + document.__SANDBOX_ROOT__ = + document.__SANDBOX_ROOT__ || document.getElementById('sandbox'); + + return { + document: document, + root: document.__SANDBOX_ROOT__, + }; + } + } catch (e) { + console.log( + 'iframe navigated away from this origin, we no longer have access to the document', + ); + } -function selectByCssPath(rootNode, cssPath) { - return rootNode?.querySelector(cssPath.toString().replace(/^body > /, '')); + return { document: null, root: null }; } -function Preview({ - markup, - accessibleRoles, - elements, - dispatch, - variant, - forwardedRef, -}) { - // Okay, listen up. `highlighted` can be a number of things, as I wanted to - // keep a single variable to represent the state. This to reduce bug count - // by creating out-of-sync states. - // - // 1. When the mouse pointer enters the preview area, `highlighted` changes - // to true. True indicates that the highlight no longer indicates the parsed - // element. - // 2. When the mouse pointer is pointing at an element, `highlighted` changes - // to the target element. A dom node. - // 3. When the mouse pointer leaves that element again, `highlighted` changse - // back to... true. Not to false! To indicate that we still want to use - // the mouse position to control the highlight. - // 4. Once the mouse leaves the preview area, `highlighted` switches to false. - // Indicating that the `parsed` element can be highlighted again. - const [highlighted, setHighlighted] = useState(false); - const [roles, setRoles] = useState([]); - const [scripts, setScripts] = useState([]); - const htmlRoot = useRef(); +function setInnerHTML(node, html) { + const doc = node.ownerDocument; + node.innerHTML = html; - const { suggestion } = getQueryAdvise({ - rootNode: htmlRoot?.current, - element: highlighted, - }); + for (let prevScript of node.querySelectorAll('script')) { + const newScript = doc.createElement('script'); - const refSetter = useCallback((node) => { - if (typeof forwardedRef === 'function') { - forwardedRef(node || null); + for (let [key, value] of prevScript.attributes) { + newScript[key] = value; } - htmlRoot.current = node; - }, []); + newScript.appendChild(doc.createTextNode(prevScript.innerHTML)); + prevScript.parentNode.replaceChild(newScript, prevScript); + } +} - useEffect(() => { - const container = document.createElement('div'); - container.innerHTML = markup; - const scriptsCollections = container.getElementsByTagName('script'); - const jsScripts = Array.from(scriptsCollections).filter( - (script) => script.type === 'text/javascript' || script.type === '', - ); - setScripts((scripts) => [ - ...scripts.filter((script) => - jsScripts - .map((jsScript) => jsScript.innerHTML) - .includes(script.innerHTML), - ), - ...jsScripts - .filter( - (jsScript) => - !scripts - .map((script) => script.innerHTML) - .includes(jsScript.innerHTML), - ) - .map((jsScript) => ({ - scriptCode: jsScript.innerHTML, - toBeRemoved: jsScript.outerHTML, - evaluated: false, - })), - ]); - }, [markup, setScripts]); - - const actualMarkup = useMemo( - () => - scripts.length - ? scripts.reduce( - (html, script) => html.replace(script.toBeRemoved, ''), - markup, - ) - : markup, - [scripts, markup], - ); +function Preview({ markup, variant, forwardedRef, dispatch }) { + const [roles, setRoles] = useState([]); + const [suggestion, setSuggestion] = useState(); - useEffect(() => { - if (htmlRoot.current && highlighted) { - scripts - .filter((script) => !script.evaluated) - .forEach((script) => { - try { - script.evaluated = true; - const executeScript = new Function(script.scriptCode); - executeScript(); - } catch (e) { - alert('Failing script inserted in markup!'); - } - }); - } - }, [highlighted, scripts, htmlRoot.current]); + const frameRef = useRef(); - useEffect(() => { - setRoles(Object.keys(accessibleRoles || {}).sort()); - }, [accessibleRoles]); + const refSetter = useCallback((node) => { + frameRef.current = node; + }, []); useEffect(() => { - if (highlighted) { - elements?.forEach((el) => { - const target = selectByCssPath(htmlRoot.current, el.cssPath); - target?.classList.remove('highlight'); - }); - highlighted.classList?.add('highlight'); - } else { - highlighted?.classList?.remove('highlight'); - - if (highlighted === false) { - elements?.forEach((el) => { - const target = selectByCssPath(htmlRoot.current, el.cssPath); - target?.classList.add('highlight'); - }); + const listener = ({ data: { source, event, payload } = {} }) => { + if (source !== 'testing-playground-sandbox') { + return; } - } - return () => highlighted?.classList?.remove('highlight'); - }, [highlighted, elements]); + switch (event) { + case 'SANDBOX_LOADED': { + const { root } = getSandbox(frameRef); - const handleClick = (event) => { - if (event.target === htmlRoot.current) { - return; - } + setInnerHTML(root, markup); + setRoles(Object.keys(getRoles(root) || {}).sort()); - event.preventDefault(); - const expression = - suggestion.expression || - '// No recommendation available.\n// Add some html attributes, or\n// use container.querySelector(…)'; - dispatch({ type: 'SET_QUERY', query: expression }); - }; - - const handleMove = (event) => { - const target = document.elementFromPoint(event.clientX, event.clientY); - if (target === highlighted) { - return; - } + if (typeof forwardedRef === 'function') { + forwardedRef(root); + } - if (target === htmlRoot) { - setHighlighted(true); - return; - } + break; + } - setHighlighted(target); - }; + case 'SELECT_NODE': { + dispatch({ type: 'SET_QUERY', query: payload.suggestion.expression }); + break; + } + + case 'HOVER_NODE': { + setSuggestion(payload.suggestion); + break; + } + } + }; + + window.addEventListener('message', listener, false); + return () => window.removeEventListener('message', listener); + }, [markup]); + + const reload = useCallback( + debounce(() => { + const { document } = getSandbox(frameRef); + + if (document) { + // When the user is using inline scripts, we need to clean the context before + // re-evaluation of the scripts. Otherwise he could get errors like + // `Identifier 'button' has already been declared` + document.location.reload(true); + } else { + // markup might have changed, while the iframe navigated to a different origin + // restore the origin, and use a cache breaker to make the iframe trigger a reload + frameRef.current.setAttribute('src', '/sandbox.html?' + Date.now()); + } + }, 500), + [frameRef], + ); + + useEffect(reload, [markup]); return markup ? ( -
setHighlighted(true)} - onMouseLeave={() => setHighlighted(false)} - > +
- -
- +