Web: Components: Replaced package react-avatar-editor to with local code

This commit is contained in:
Alexey Safronov 2020-09-15 22:33:19 +03:00
parent 4d3bc676ce
commit 0f704b7f5e
9 changed files with 1008 additions and 3 deletions

View File

@ -107,8 +107,6 @@
"punycode": "^2.1.1",
"rc-tree": "^2.1.3",
"react-autosize-textarea": "^7.0.0",
"react-avatar-edit": "^0.8.3",
"react-avatar-editor": "11.0.6",
"react-custom-scrollbars": "^4.2.1",
"react-dropzone": "^10.2.1",
"react-lifecycles-compat": "^3.0.4",

View File

@ -1,7 +1,7 @@
import React from "react";
import styled from "styled-components";
import Dropzone from "react-dropzone";
import ReactAvatarEditor from "react-avatar-editor";
import ReactAvatarEditor from "./react-avatar-editor";
import PropTypes from "prop-types";
import Avatar from "../../avatar/index";
import accepts from "attr-accept";

View File

@ -0,0 +1,760 @@
/* eslint-env browser, node */
import PropTypes from "prop-types";
import React from "react";
import loadImageURL from "./utils/load-image-url";
import loadImageFile from "./utils/load-image-file";
const makeCancelable = promise => {
let hasCanceled_ = false;
const wrappedPromise = new Promise((resolve, reject) => {
/* eslint-disable prefer-promise-reject-errors */
promise.then(
val => (hasCanceled_ ? reject({ isCanceled: true }) : resolve(val)),
error => (hasCanceled_ ? reject({ isCanceled: true }) : reject(error))
);
/* eslint-enable */
});
return {
promise: wrappedPromise,
cancel() {
hasCanceled_ = true;
}
};
};
const isTouchDevice = !!(
typeof window !== "undefined" &&
typeof navigator !== "undefined" &&
("ontouchstart" in window || navigator.msMaxTouchPoints > 0)
);
const isFileAPISupported = typeof File !== "undefined";
const isPassiveSupported = () => {
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
let passiveSupported = false;
try {
const options = Object.defineProperty({}, "passive", {
get: function() {
passiveSupported = true;
}
});
window.addEventListener("test", options, options);
window.removeEventListener("test", options, options);
} catch (err) {
passiveSupported = false;
}
return passiveSupported;
};
const draggableEvents = {
touch: {
react: {
down: "onTouchStart",
mouseDown: "onMouseDown",
drag: "onTouchMove",
move: "onTouchMove",
mouseMove: "onMouseMove",
up: "onTouchEnd",
mouseUp: "onMouseUp"
},
native: {
down: "touchstart",
mouseDown: "mousedown",
drag: "touchmove",
move: "touchmove",
mouseMove: "mousemove",
up: "touchend",
mouseUp: "mouseup"
}
},
desktop: {
react: {
down: "onMouseDown",
drag: "onDragOver",
move: "onMouseMove",
up: "onMouseUp"
},
native: {
down: "mousedown",
drag: "dragStart",
move: "mousemove",
up: "mouseup"
}
}
};
const deviceEvents = isTouchDevice
? draggableEvents.touch
: draggableEvents.desktop;
let pixelRatio =
typeof window !== "undefined" && window.devicePixelRatio
? window.devicePixelRatio
: 1;
// Draws a rounded rectangle on a 2D context.
const drawRoundedRect = (context, x, y, width, height, borderRadius) => {
if (borderRadius === 0) {
context.rect(x, y, width, height);
} else {
const widthMinusRad = width - borderRadius;
const heightMinusRad = height - borderRadius;
context.translate(x, y);
context.arc(
borderRadius,
borderRadius,
borderRadius,
Math.PI,
Math.PI * 1.5
);
context.lineTo(widthMinusRad, 0);
context.arc(
widthMinusRad,
borderRadius,
borderRadius,
Math.PI * 1.5,
Math.PI * 2
);
context.lineTo(width, heightMinusRad);
context.arc(
widthMinusRad,
heightMinusRad,
borderRadius,
Math.PI * 2,
Math.PI * 0.5
);
context.lineTo(borderRadius, height);
context.arc(
borderRadius,
heightMinusRad,
borderRadius,
Math.PI * 0.5,
Math.PI
);
context.translate(-x, -y);
}
};
const defaultEmptyImage = {
x: 0.5,
y: 0.5
};
class AvatarEditor extends React.Component {
static propTypes = {
scale: PropTypes.number,
rotate: PropTypes.number,
image: PropTypes.oneOfType([
PropTypes.string,
...(isFileAPISupported ? [PropTypes.instanceOf(File)] : [])
]),
border: PropTypes.oneOfType([
PropTypes.number,
PropTypes.arrayOf(PropTypes.number)
]),
borderRadius: PropTypes.number,
width: PropTypes.number,
height: PropTypes.number,
position: PropTypes.shape({
x: PropTypes.number,
y: PropTypes.number
}),
color: PropTypes.arrayOf(PropTypes.number),
crossOrigin: PropTypes.oneOf(["", "anonymous", "use-credentials"]),
onLoadFailure: PropTypes.func,
onLoadSuccess: PropTypes.func,
onImageReady: PropTypes.func,
onImageChange: PropTypes.func,
onMouseUp: PropTypes.func,
onMouseMove: PropTypes.func,
onPositionChange: PropTypes.func,
disableBoundaryChecks: PropTypes.bool,
disableHiDPIScaling: PropTypes.bool,
disableCanvasRotation: PropTypes.bool
};
static defaultProps = {
scale: 1,
rotate: 0,
border: 25,
borderRadius: 0,
width: 200,
height: 200,
color: [0, 0, 0, 0.5],
onLoadFailure() {},
onLoadSuccess() {},
onImageReady() {},
onImageChange() {},
onMouseUp() {},
onMouseMove() {},
onPositionChange() {},
disableBoundaryChecks: false,
disableHiDPIScaling: false,
disableCanvasRotation: true
};
constructor(props) {
super(props);
this.canvas = null;
}
state = {
drag: false,
my: null,
mx: null,
image: defaultEmptyImage
};
componentDidMount() {
// scaling by the devicePixelRatio can impact performance on mobile as it creates a very large canvas. This is an override to increase performance.
if (this.props.disableHiDPIScaling) {
pixelRatio = 1;
}
const context = this.canvas.getContext("2d");
if (this.props.image) {
this.loadImage(this.props.image);
}
this.paint(context);
if (document) {
const passiveSupported = isPassiveSupported();
const thirdArgument = passiveSupported ? { passive: false } : false;
const nativeEvents = deviceEvents.native;
document.addEventListener(
nativeEvents.move,
this.handleMouseMove,
thirdArgument
);
document.addEventListener(
nativeEvents.up,
this.handleMouseUp,
thirdArgument
);
if (isTouchDevice) {
document.addEventListener(
nativeEvents.mouseMove,
this.handleMouseMove,
thirdArgument
);
document.addEventListener(
nativeEvents.mouseUp,
this.handleMouseUp,
thirdArgument
);
}
}
}
componentDidUpdate(prevProps, prevState) {
if (
(this.props.image && this.props.image !== prevProps.image) ||
this.props.width !== prevProps.width ||
this.props.height !== prevProps.height
) {
this.loadImage(this.props.image);
} else if (!this.props.image && prevState.image !== defaultEmptyImage) {
this.clearImage();
}
const context = this.canvas.getContext("2d");
context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.paint(context);
this.paintImage(context, this.state.image, this.props.border);
if (
prevProps.image !== this.props.image ||
prevProps.width !== this.props.width ||
prevProps.height !== this.props.height ||
prevProps.position !== this.props.position ||
prevProps.scale !== this.props.scale ||
prevProps.rotate !== this.props.rotate ||
prevState.my !== this.state.my ||
prevState.mx !== this.state.mx ||
prevState.image.x !== this.state.image.x ||
prevState.image.y !== this.state.image.y
) {
this.props.onImageChange();
}
}
componentWillUnmount() {
if (document) {
const nativeEvents = deviceEvents.native;
document.removeEventListener(
nativeEvents.move,
this.handleMouseMove,
false
);
document.removeEventListener(nativeEvents.up, this.handleMouseUp, false);
if (isTouchDevice) {
document.removeEventListener(
nativeEvents.mouseMove,
this.handleMouseMove,
false
);
document.removeEventListener(
nativeEvents.mouseUp,
this.handleMouseUp,
false
);
}
}
}
isVertical() {
return !this.props.disableCanvasRotation && this.props.rotate % 180 !== 0;
}
getBorders(border = this.props.border) {
return Array.isArray(border) ? border : [border, border];
}
getDimensions() {
const { width, height, rotate, border } = this.props;
const canvas = {};
const [borderX, borderY] = this.getBorders(border);
const canvasWidth = width;
const canvasHeight = height;
if (this.isVertical()) {
canvas.width = canvasHeight;
canvas.height = canvasWidth;
} else {
canvas.width = canvasWidth;
canvas.height = canvasHeight;
}
canvas.width += borderX * 2;
canvas.height += borderY * 2;
return {
canvas,
rotate,
width,
height,
border
};
}
getImage() {
// get relative coordinates (0 to 1)
const cropRect = this.getCroppingRect();
const image = this.state.image;
// get actual pixel coordinates
cropRect.x *= image.resource.width;
cropRect.y *= image.resource.height;
cropRect.width *= image.resource.width;
cropRect.height *= image.resource.height;
// create a canvas with the correct dimensions
const canvas = document.createElement("canvas");
if (this.isVertical()) {
canvas.width = cropRect.height;
canvas.height = cropRect.width;
} else {
canvas.width = cropRect.width;
canvas.height = cropRect.height;
}
// draw the full-size image at the correct position,
// the image gets truncated to the size of the canvas.
const context = canvas.getContext("2d");
context.translate(canvas.width / 2, canvas.height / 2);
context.rotate((this.props.rotate * Math.PI) / 180);
context.translate(-(canvas.width / 2), -(canvas.height / 2));
if (this.isVertical()) {
context.translate(
(canvas.width - canvas.height) / 2,
(canvas.height - canvas.width) / 2
);
}
context.drawImage(image.resource, -cropRect.x, -cropRect.y);
return canvas;
}
/**
* Get the image scaled to original canvas size.
* This was default in 4.x and is now kept as a legacy method.
*/
getImageScaledToCanvas() {
const { width, height } = this.getDimensions();
const canvas = document.createElement("canvas");
if (this.isVertical()) {
canvas.width = height;
canvas.height = width;
} else {
canvas.width = width;
canvas.height = height;
}
// don't paint a border here, as it is the resulting image
this.paintImage(canvas.getContext("2d"), this.state.image, 0, 1);
return canvas;
}
getXScale() {
const canvasAspect = this.props.width / this.props.height;
const imageAspect = this.state.image.width / this.state.image.height;
return Math.min(1, canvasAspect / imageAspect);
}
getYScale() {
const canvasAspect = this.props.height / this.props.width;
const imageAspect = this.state.image.height / this.state.image.width;
return Math.min(1, canvasAspect / imageAspect);
}
getCroppingRect() {
const position = this.props.position || {
x: this.state.image.x,
y: this.state.image.y
};
const width = (1 / this.props.scale) * this.getXScale();
const height = (1 / this.props.scale) * this.getYScale();
const croppingRect = {
x: position.x - width / 2,
y: position.y - height / 2,
width,
height
};
let xMin = 0;
let xMax = 1 - croppingRect.width;
let yMin = 0;
let yMax = 1 - croppingRect.height;
// If the cropping rect is larger than the image, then we need to change
// our maxima & minima for x & y to allow the image to appear anywhere up
// to the very edge of the cropping rect.
const isLargerThanImage =
this.props.disableBoundaryChecks || width > 1 || height > 1;
if (isLargerThanImage) {
xMin = -croppingRect.width;
xMax = 1;
yMin = -croppingRect.height;
yMax = 1;
}
return {
...croppingRect,
x: Math.max(xMin, Math.min(croppingRect.x, xMax)),
y: Math.max(yMin, Math.min(croppingRect.y, yMax))
};
}
loadImage(image) {
if (isFileAPISupported && image instanceof File) {
this.loadingImage = makeCancelable(loadImageFile(image))
.promise.then(this.handleImageReady)
.catch(this.props.onLoadFailure);
} else if (typeof image === "string") {
this.loadingImage = makeCancelable(
loadImageURL(image, this.props.crossOrigin)
)
.promise.then(this.handleImageReady)
.catch(this.props.onLoadFailure);
}
}
handleImageReady = image => {
const imageState = this.getInitialSize(image.width, image.height);
imageState.resource = image;
imageState.x = 0.5;
imageState.y = 0.5;
this.setState({ drag: false, image: imageState }, this.props.onImageReady);
this.props.onLoadSuccess(imageState);
};
getInitialSize(width, height) {
let newHeight;
let newWidth;
const dimensions = this.getDimensions();
const canvasRatio = dimensions.height / dimensions.width;
const imageRatio = height / width;
if (canvasRatio > imageRatio) {
newHeight = this.getDimensions().height;
newWidth = width * (newHeight / height);
} else {
newWidth = this.getDimensions().width;
newHeight = height * (newWidth / width);
}
return {
height: newHeight,
width: newWidth
};
}
clearImage = () => {
const context = this.canvas.getContext("2d");
context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.setState({
image: defaultEmptyImage
});
};
paintImage(context, image, border, scaleFactor = pixelRatio) {
if (image.resource) {
const position = this.calculatePosition(image, border);
context.save();
context.translate(context.canvas.width / 2, context.canvas.height / 2);
context.rotate((this.props.rotate * Math.PI) / 180);
context.translate(
-(context.canvas.width / 2),
-(context.canvas.height / 2)
);
if (this.isVertical()) {
context.translate(
(context.canvas.width - context.canvas.height) / 2,
(context.canvas.height - context.canvas.width) / 2
);
}
context.scale(scaleFactor, scaleFactor);
context.globalCompositeOperation = "destination-over";
context.drawImage(
image.resource,
position.x,
position.y,
position.width,
position.height
);
context.restore();
}
}
calculatePosition(image, border) {
image = image || this.state.image;
const [borderX, borderY] = this.getBorders(border);
const croppingRect = this.getCroppingRect();
const width = image.width * this.props.scale;
const height = image.height * this.props.scale;
let x = -croppingRect.x * width;
let y = -croppingRect.y * height;
if (this.isVertical()) {
x += borderY;
y += borderX;
} else {
x += borderX;
y += borderY;
}
return {
x,
y,
height,
width
};
}
paint(context) {
context.save();
context.scale(pixelRatio, pixelRatio);
context.translate(0, 0);
context.fillStyle = "rgba(" + this.props.color.slice(0, 4).join(",") + ")";
let borderRadius = this.props.borderRadius;
const dimensions = this.getDimensions();
const [borderSizeX, borderSizeY] = this.getBorders(dimensions.border);
const height = dimensions.canvas.height;
const width = dimensions.canvas.width;
// clamp border radius between zero (perfect rectangle) and half the size without borders (perfect circle or "pill")
borderRadius = Math.max(borderRadius, 0);
borderRadius = Math.min(
borderRadius,
width / 2 - borderSizeX,
height / 2 - borderSizeY
);
context.beginPath();
// inner rect, possibly rounded
drawRoundedRect(
context,
borderSizeX,
borderSizeY,
width - borderSizeX * 2,
height - borderSizeY * 2,
borderRadius
);
context.rect(width, 0, -width, height); // outer rect, drawn "counterclockwise"
context.fill("evenodd");
context.restore();
}
handleMouseDown = e => {
e = e || window.event;
// if e is a touch event, preventDefault keeps
// corresponding mouse events from also being fired
// later.
e.preventDefault();
this.setState({
drag: true,
mx: null,
my: null
});
};
handleMouseUp = () => {
if (this.state.drag) {
this.setState({ drag: false });
this.props.onMouseUp();
}
};
handleMouseMove = e => {
e = e || window.event;
if (this.state.drag === false) {
return;
}
e.preventDefault(); // stop scrolling on iOS Safari
const mousePositionX = e.targetTouches
? e.targetTouches[0].pageX
: e.clientX;
const mousePositionY = e.targetTouches
? e.targetTouches[0].pageY
: e.clientY;
const newState = {
mx: mousePositionX,
my: mousePositionY
};
let rotate = this.props.rotate;
rotate %= 360;
rotate = rotate < 0 ? rotate + 360 : rotate;
if (this.state.mx && this.state.my) {
const mx = this.state.mx - mousePositionX;
const my = this.state.my - mousePositionY;
const width = this.state.image.width * this.props.scale;
const height = this.state.image.height * this.props.scale;
let { x: lastX, y: lastY } = this.getCroppingRect();
lastX *= width;
lastY *= height;
// helpers to calculate vectors
const toRadians = degree => degree * (Math.PI / 180);
const cos = Math.cos(toRadians(rotate));
const sin = Math.sin(toRadians(rotate));
const x = lastX + mx * cos + my * sin;
const y = lastY + -mx * sin + my * cos;
const relativeWidth = (1 / this.props.scale) * this.getXScale();
const relativeHeight = (1 / this.props.scale) * this.getYScale();
const position = {
x: x / width + relativeWidth / 2,
y: y / height + relativeHeight / 2
};
this.props.onPositionChange(position);
newState.image = {
...this.state.image,
...position
};
}
this.setState(newState);
this.props.onMouseMove(e);
};
setCanvas = canvas => {
this.canvas = canvas;
};
render() {
const {
scale,
rotate,
image,
border,
borderRadius,
width,
height,
position,
color,
// eslint-disable-next-line react/prop-types
style,
crossOrigin,
onLoadFailure,
onLoadSuccess,
onImageReady,
onImageChange,
onMouseUp,
onMouseMove,
onPositionChange,
disableBoundaryChecks,
disableHiDPIScaling,
...rest
} = this.props;
const dimensions = this.getDimensions();
const defaultStyle = {
width: dimensions.canvas.width,
height: dimensions.canvas.height,
cursor: this.state.drag ? "grabbing" : "grab",
touchAction: "none"
};
const attributes = {
width: dimensions.canvas.width * pixelRatio,
height: dimensions.canvas.height * pixelRatio,
style: {
...defaultStyle,
...style
}
};
attributes[deviceEvents.react.down] = this.handleMouseDown;
if (isTouchDevice) {
attributes[deviceEvents.react.mouseDown] = this.handleMouseDown;
}
return <canvas ref={this.setCanvas} {...attributes} {...rest} />;
}
}
export default AvatarEditor;

View File

@ -0,0 +1,17 @@
/* eslint-env browser, node */
import loadImageURL from "./load-image-url";
export default function loadImageFile(imageFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => {
try {
const image = loadImageURL(e.target.result);
resolve(image);
} catch (e) {
reject(e);
}
};
reader.readAsDataURL(imageFile);
});
}

View File

@ -0,0 +1,21 @@
/* eslint-env browser, node */
function isDataURL(str) {
if (str === null) {
return false;
}
const regex = /^\s*data:([a-z]+\/[a-z]+(;[a-z-]+=[a-z-]+)?)?(;base64)?,[a-z0-9!$&',()*+;=\-._~:@/?%\s]*\s*$/i;
return !!str.match(regex);
}
export default function loadImageURL(imageURL, crossOrigin) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
if (isDataURL(imageURL) === false && crossOrigin) {
image.crossOrigin = crossOrigin;
}
image.src = imageURL;
});
}

View File

@ -0,0 +1,24 @@
/* global DOMParser:false */
/*
* This method uses DOMParser to parse an HTML string into
* a document. By using this approach we avoid the potential
* for XSS attacks on consumers of this component, which would
* be created by parsing the string via a detached DOM fragment,
* as in this latter case script in onload attributes will be
* run in the context of the host page.
*
* The drawback to this approach is that browser support is not
* as wide - IE10 and up along with evergreen browsers.
*/
const parseDOM = str => {
const parser = typeof DOMParser === "undefined" ? null : new DOMParser();
if (!parser) {
return null;
}
return parser.parseFromString(str, "text/html");
};
export default parseDOM;

View File

@ -0,0 +1,53 @@
/* eslint-env jest */
import parseDOM from "./parse-dom";
describe("parseDOM", () => {
let realDOMParser, result;
beforeEach(() => {
realDOMParser = global.DOMParser;
});
afterEach(() => {
global.DOMParser = realDOMParser;
});
describe("DOMParser available", () => {
let parseFromString;
beforeEach(() => {
parseFromString = jest.fn().mockReturnValue("%document%");
global.DOMParser = jest.fn(() => ({ parseFromString }));
result = parseDOM('<div id="test"></div>');
});
it("creates a new DOMParser", () => {
expect(global.DOMParser).toHaveBeenCalled();
});
it("calls parseFromString with the passed-in string", () => {
expect(parseFromString).toHaveBeenCalledWith(
'<div id="test"></div>',
"text/html"
);
});
it("returns the value returned by parseFromString", () => {
expect(result).toBe("%document%");
});
});
describe("No DOMParser available", () => {
beforeEach(() => {
global.DOMParser = undefined;
result = parseDOM('<div id="test"></div>');
});
it("retuns null", () => {
expect(result).toBeNull();
});
});
});

View File

@ -0,0 +1,27 @@
import parseDOM from "./parse-dom";
/*
* Retrieves image URL from collection of data transfer
* items, if one is present. As the item will contain
* an HTML string containing an img element, it's
* necessary to parse the HTML and then pull the src
* attribute off the image.
*/
const retrieveImageURL = (dataTransferItems, callback) => {
for (let i = 0; i < dataTransferItems.length; i++) {
const item = dataTransferItems[i];
if (item.type === "text/html") {
item.getAsString(value => {
const doc = parseDOM(value);
const img = doc.querySelector("img");
if (img && img.src) {
callback(img.src);
}
});
break;
}
}
};
export default retrieveImageURL;

View File

@ -0,0 +1,105 @@
/* eslint-env jest */
describe("retrieveImageURL", () => {
let retrieveImageURL, mockParseDOM, mockQuerySelector;
beforeEach(() => {
mockQuerySelector = jest.fn();
mockParseDOM = jest.fn();
jest.mock("./parse-dom", () => mockParseDOM);
retrieveImageURL = require("./retrieve-image-url").default;
mockParseDOM = require("./parse-dom");
});
it('runs getAsString on the first item with type "text/html"', () => {
const items = [
{ getAsString: jest.fn(), type: "something/else" },
{ getAsString: jest.fn(), type: "text/html" }
];
retrieveImageURL(items, () => {});
expect(items[0].getAsString).not.toHaveBeenCalled();
expect(items[1].getAsString).toHaveBeenCalled();
});
it('does not run getAsString on later items with type "text/html"', () => {
const items = [
{ getAsString: jest.fn(), type: "text/html" },
{ getAsString: jest.fn(), type: "text/html" }
];
retrieveImageURL(items, () => {});
expect(items[0].getAsString).toHaveBeenCalled();
expect(items[1].getAsString).not.toHaveBeenCalled();
});
describe("with html returned in getAsString callback", () => {
let callback, invokeGetAsStringCallback;
beforeEach(() => {
const items = [{ getAsString: jest.fn(), type: "text/html" }];
callback = jest.fn();
mockParseDOM.mockReturnValue({
querySelector: mockQuerySelector
});
retrieveImageURL(items, callback);
invokeGetAsStringCallback = () =>
items[0].getAsString.mock.calls[0][0]('<div id="test-fragment"></div>');
});
it("creates a document using parseDOM", () => {
invokeGetAsStringCallback();
expect(mockParseDOM).toHaveBeenCalledWith(
'<div id="test-fragment"></div>'
);
});
it("searches for img elements", () => {
invokeGetAsStringCallback();
expect(mockQuerySelector).toHaveBeenCalledWith("img");
});
describe("if the document contains an img with a src attribute", () => {
beforeEach(() => {
mockQuerySelector.mockReturnValue({
src: "http://placekitten.com/100/100"
});
invokeGetAsStringCallback();
});
it("should invoke the callback passed to retrieveImageURL with the value of the src attribute", () => {
expect(callback).toHaveBeenCalledWith("http://placekitten.com/100/100");
});
});
describe("if the document contains an img without a src attribute", () => {
beforeEach(() => {
mockQuerySelector.mockReturnValue({});
invokeGetAsStringCallback();
});
it("should invoke the callback passed to retrieveImageURL with the value of the src attribute", () => {
expect(callback).not.toHaveBeenCalled();
});
});
describe("if the documetn does not contain an img", () => {
beforeEach(() => {
mockQuerySelector.mockReturnValue(null);
invokeGetAsStringCallback();
});
it("should invoke the callback passed to retrieveImageURL with the value of the src attribute", () => {
expect(callback).not.toHaveBeenCalled();
});
});
});
});