This repository has been archived by the owner on Jun 11, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #163 from Galooshi/no-crop
Wait for images to load
- Loading branch information
Showing
11 changed files
with
400 additions
and
341 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,4 @@ | ||
Gemfile.lock | ||
node_modules | ||
lib/happo/public/HappoApp.bundle.js | ||
lib/happo/public/HappoApp.bundle.js.map | ||
lib/happo/public/*.bundle.* | ||
lib/happo/public/*.worker.* |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,220 @@ | ||
import getFullRect from './getFullRect'; | ||
import waitForImagesToRender from './waitForImagesToRender'; | ||
|
||
function handleError(currentExample, error) { | ||
console.error(error.stack); // eslint-disable-line no-console | ||
return { | ||
description: currentExample.description, | ||
error: error.message, | ||
}; | ||
} | ||
|
||
/** | ||
* @param {Function} func The happo.describe function from the current | ||
* example being rendered. This function takes a callback as an argument | ||
* that is called when it is done. | ||
* @return {Promise} | ||
*/ | ||
function tryAsync(func) { | ||
return new Promise((resolve, reject) => { | ||
// Safety valve: if the function does not finish after 3s, then something | ||
// went haywire and we need to move on. | ||
const timeout = setTimeout(() => { | ||
reject(new Error('Async callback was not invoked within timeout.')); | ||
}, 3000); | ||
|
||
// This function is called by the example when it is done executing. | ||
const doneCallback = () => { | ||
clearTimeout(timeout); | ||
resolve(); | ||
}; | ||
|
||
func(doneCallback); | ||
}); | ||
} | ||
|
||
window.happo = { | ||
defined: {}, | ||
fdefined: [], | ||
errors: [], | ||
|
||
define(description, func, options) { | ||
// Make sure we don't have a duplicate description | ||
if (this.defined[description]) { | ||
throw new Error( | ||
`Error while defining "${description}": Duplicate description detected`); | ||
} | ||
this.defined[description] = { | ||
description, | ||
func, | ||
options: options || {}, | ||
}; | ||
}, | ||
|
||
fdefine(description, func, options) { | ||
this.define(description, func, options); // add the example | ||
this.fdefined.push(description); | ||
}, | ||
|
||
/** | ||
* @return {Array.<Object>} | ||
*/ | ||
getAllExamples() { | ||
const descriptions = this.fdefined.length ? | ||
this.fdefined : | ||
Object.keys(this.defined); | ||
|
||
return descriptions.map((description) => { | ||
const example = this.defined[description]; | ||
// We return a subset of the properties of an example (only those relevant | ||
// for happo_runner.rb). | ||
return { | ||
description: example.description, | ||
options: example.options, | ||
}; | ||
}); | ||
}, | ||
|
||
/** | ||
* Clean up the DOM for a rendered element that has already been processed. | ||
* This can be overridden by consumers to define their own clean out method, | ||
* which can allow for this to be used to unmount React components, for | ||
* example. | ||
*/ | ||
cleanOutElement() { | ||
}, | ||
|
||
/** | ||
* This function is called from Ruby asynchronously. Therefore, we need to | ||
* call doneFunc when the method has completed so that Ruby knows to continue. | ||
* | ||
* @param {String} exampleDescription | ||
* @param {Function} doneFunc injected by driver.execute_async_script in | ||
* happo/runner.rb | ||
*/ | ||
renderExample(exampleDescription, doneFunc) { | ||
const currentExample = this.defined[exampleDescription]; | ||
|
||
try { | ||
if (!currentExample) { | ||
throw new Error( | ||
`No example found with description "${exampleDescription}"`); | ||
} | ||
|
||
// Clear out the body of the document | ||
while (document.body.firstChild) { | ||
if (document.body.firstChild instanceof Element) { | ||
this.cleanOutElement(document.body.firstChild); | ||
} | ||
document.body.removeChild(document.body.firstChild); | ||
} | ||
|
||
const { func } = currentExample; | ||
if (func.length) { | ||
// The function takes an argument, which is a callback that is called | ||
// once it is done executing. This can be used to write functions that | ||
// have asynchronous code in them. | ||
tryAsync(func).then(() => { | ||
this.processExample(currentExample).then(doneFunc).catch(doneFunc); | ||
}).catch((error) => { | ||
doneFunc(handleError(currentExample, error)); | ||
}); | ||
} else { | ||
// The function does not take an argument, so we can run it | ||
// synchronously. | ||
const result = func(); | ||
|
||
if (result instanceof Promise) { | ||
// The function returned a promise, so we need to wait for it to | ||
// resolve before proceeding. | ||
result.then(() => { | ||
this.processExample(currentExample).then(doneFunc).catch(doneFunc); | ||
}).catch((error) => { | ||
doneFunc(handleError(currentExample, error)); | ||
}); | ||
} else { | ||
// The function did not return a promise, so we assume it gave us an | ||
// element that we can process immediately. | ||
this.processExample(currentExample).then(doneFunc).catch(doneFunc); | ||
} | ||
} | ||
} catch (error) { | ||
doneFunc(handleError(currentExample, error)); | ||
} | ||
}, | ||
|
||
/** | ||
* Gets the DOM elements that we will use as source for the snapshot. The | ||
* default version simply gets the direct children of document.body, but you | ||
* can override this method to better control the root nodes. | ||
* | ||
* @return {Array|NodeList} | ||
*/ | ||
getRootNodes() { | ||
return document.body.children; | ||
}, | ||
|
||
/** | ||
* @return {Promise} | ||
*/ | ||
processExample(currentExample) { | ||
return new Promise((resolve, reject) => { | ||
waitForImagesToRender().then(() => { | ||
try { | ||
const rootNodes = this.getRootNodes(); | ||
const { | ||
height, | ||
left, | ||
top, | ||
width, | ||
} = getFullRect(rootNodes); | ||
|
||
resolve({ | ||
description: currentExample.description, | ||
height, | ||
left, | ||
top, | ||
width, | ||
}); | ||
} catch (error) { | ||
reject(handleError(currentExample, error)); | ||
} | ||
}).catch((error) => { | ||
reject(handleError(currentExample, error)); | ||
}); | ||
}); | ||
}, | ||
}; | ||
|
||
window.addEventListener('load', () => { | ||
const matches = window.location.search.match(/description=([^&]*)/); | ||
if (!matches) { | ||
return; | ||
} | ||
const example = decodeURIComponent(matches[1]); | ||
window.happo.renderExample(example, () => {}); | ||
}); | ||
|
||
// We need to redefine a few global functions that halt execution. Without this, | ||
// there's a chance that the Ruby code can't communicate with the browser. | ||
window.alert = (message) => { | ||
console.log('`window.alert` called', message); // eslint-disable-line | ||
}; | ||
|
||
window.confirm = (message) => { | ||
console.log('`window.confirm` called', message); // eslint-disable-line | ||
return true; | ||
}; | ||
|
||
window.prompt = (message, value) => { | ||
console.log('`window.prompt` called', message, value); // eslint-disable-line | ||
return null; | ||
}; | ||
|
||
window.onerror = (message, url, lineNumber) => { | ||
window.happo.errors.push({ | ||
message, | ||
url, | ||
lineNumber, | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
import removeScrollbars from './removeScrollbars'; | ||
|
||
/** | ||
* Wrapper around Math.min to handle undefined values. | ||
*/ | ||
function min(a, b) { | ||
if (a === undefined) { | ||
return b; | ||
} | ||
return Math.min(a, b); | ||
} | ||
|
||
// This function takes a node and a box object that we will mutate. | ||
function getFullRectRecursive(node, box) { | ||
// Since we are already traversing through every node, let's piggyback on | ||
// that work and remove scrollbars to prevent spurious diffs. | ||
removeScrollbars(node); | ||
|
||
const rect = node.getBoundingClientRect(); | ||
|
||
/* eslint-disable no-param-reassign */ | ||
box.bottom = Math.max(box.bottom, rect.bottom); | ||
box.left = min(box.left, rect.left); | ||
box.right = Math.max(box.right, rect.right); | ||
box.top = min(box.top, rect.top); | ||
/* eslint-enable no-param-reassign */ | ||
|
||
for (let i = 0; i < node.children.length; i++) { | ||
getFullRectRecursive(node.children[i], box); | ||
} | ||
} | ||
|
||
// This function gets the full size of children in the document body, | ||
// including all descendent nodes. This allows us to ensure that the | ||
// screenshot includes absolutely positioned elements. It is important that | ||
// this is fast, since we may be iterating over a high number of nodes. | ||
export default function getFullRect(rootNodes) { | ||
// Set up the initial object that we will mutate in our recursive function. | ||
const box = { | ||
bottom: 0, | ||
left: undefined, | ||
right: 0, | ||
top: undefined, | ||
}; | ||
|
||
// If there are any children, we want to iterate over them recursively, | ||
// mutating our box object along the way to expand to include all descendent | ||
// nodes. | ||
// Remember! rootNodes can be either an Array or a NodeList. | ||
for (let i = 0; i < rootNodes.length; i++) { | ||
const node = rootNodes[i]; | ||
|
||
getFullRectRecursive(node, box); | ||
|
||
// getBoundingClientRect does not include margin, so we need to use | ||
// getComputedStyle. Since this is slow and the margin of descendent | ||
// elements is significantly less likely to matter, let's include the | ||
// margin only from the topmost nodes. | ||
const computedStyle = window.getComputedStyle(node); | ||
box.bottom += parseFloat(computedStyle.getPropertyValue('margin-bottom')); | ||
box.left -= parseFloat(computedStyle.getPropertyValue('margin-left')); | ||
box.right += parseFloat(computedStyle.getPropertyValue('margin-right')); | ||
box.top -= parseFloat(computedStyle.getPropertyValue('margin-top')); | ||
} | ||
|
||
// Since getBoundingClientRect() and margins can contain subpixel values, we | ||
// want to round everything before calculating the width and height to | ||
// ensure that we will take a screenshot of the entire component. | ||
box.bottom = Math.ceil(box.bottom); | ||
box.left = Math.floor(box.left); | ||
box.right = Math.ceil(box.right); | ||
box.top = Math.floor(box.top); | ||
|
||
// As the last step, we calculate the width and height for the box. This is | ||
// to avoid having to do them for every node. Before we do that however, we | ||
// cut off things that render off the screen to the top or left, since those | ||
// won't be in the screenshot file that we then crop from. If you're | ||
// wondering why right and bottom isn't "fixed" here too, it's because we | ||
// don't have to since the screenshot already includes overflowing content | ||
// on the bottom and right. | ||
box.left = Math.max(box.left, 0); | ||
box.top = Math.max(box.top, 0); | ||
|
||
box.width = box.right - box.left; | ||
box.height = box.bottom - box.top; | ||
|
||
return box; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
function isAutoOrScroll(overflow) { | ||
return overflow === 'auto' || overflow === 'scroll'; | ||
} | ||
|
||
// Scrollbars inside of elements may cause spurious visual diffs. To avoid | ||
// this issue, we can hide them automatically by styling the overflow to be | ||
// hidden. | ||
export default function removeScrollbars(node) { | ||
const isOverflowing = | ||
node.scrollHeight !== node.clientHeight | ||
|| node.scrollWidth !== node.clientWidth; | ||
|
||
if (!isOverflowing) { | ||
// This node has no overflowing content. We're returning early to prevent | ||
// calling getComputedStyle down below (which is an expensive operation). | ||
return; | ||
} | ||
|
||
const style = window.getComputedStyle(node); | ||
if ( | ||
isAutoOrScroll(style.getPropertyValue('overflow-y')) | ||
|| isAutoOrScroll(style.getPropertyValue('overflow-x')) | ||
|| isAutoOrScroll(style.getPropertyValue('overflow')) | ||
) { | ||
// We style this via node.style.cssText so that we can override any styles | ||
// that might already be `!important`. | ||
// eslint-disable-next-line no-param-reassign | ||
node.style.cssText += 'overflow: hidden !important'; | ||
} | ||
} |
Oops, something went wrong.