/**
* @file dom.js
* @module dom
*/
import document from 'global/document';
import window from 'global/window';
import log from './log.js';
import tsml from 'tsml';
import { isObject } from './obj';
import computedStyle from './computed-style';
import * as browser from './browser';
/**
* 检查字符串是否没有空格
*
* @param {string} str
* The string to check
*
* @return {boolean}
* - True if the string is non-blank
* - False otherwise
*
*/
function isNonBlankString(str) {
return typeof str === 'string' && /\S/.test(str);
}
/**
* 如果字符串有空格则抛出错误。用于让类方法和classList API 保持一致
*
* @param {string} str
* The string to check for whitespace.
*
* @throws {Error}
* Throws an error if there is whitespace in the string.
*
*/
function throwIfWhitespace(str) {
if (/\s/.test(str)) {
throw new Error('class has illegal whitespace characters');
}
}
/**
* 生成匹配元素className的正则表达式
*
* @param {string} className
* The className to generate the RegExp for.
*
* @return {RegExp}
* The RegExp that will check for a specific `className` in an elements
* className.
*/
function classRegExp(className) {
return new RegExp('(^|\\s)' + className + '($|\\s)');
}
/**
* 检查当前DOM是否为真实的DOM接口
*
* @return {Boolean}
*/
export function isReal() {
return (
// Both document and window will never be undefined thanks to `global`.
document === window.document &&
// In IE < 9, DOM methods return "object" as their type, so all we can
// confidently check is that it exists.
typeof document.createElement !== 'undefined'
);
}
/**
* 用过鸭子类型检查是否为DOM元素
*
* @param {Mixed} value
* The thing to check
*
* @return {boolean}
* - True if it is a DOM element
* - False otherwise
*/
export function isEl(value) {
return isObject(value) && value.nodeType === 1;
}
/**
* 检查当前DOM是否在iframe
*
* @return {boolean}
*
*/
export function isInFrame() {
// We need a try/catch here because Safari will throw errors when attempting
// to get either `parent` or `self`
try {
return window.parent !== window.self;
} catch (x) {
return true;
}
}
/**
* 通过传入的方法来查询DOM
*
* @param {string} method
* The method to create the query with.
*
* @return {Function}
* The query method
*/
function createQuerier(method) {
return function(selector, context) {
if (!isNonBlankString(selector)) {
return document[method](null);
}
if (isNonBlankString(context)) {
context = document.querySelector(context);
}
const ctx = isEl(context) ? context : document;
return ctx[method] && ctx[method](selector);
};
}
/**
* 用所给属性创建元素
*
* @param {string} [tagName='div']
* Name of tag to be created.
*
* @param {Object} [properties={}]
* Element properties to be applied.
*
* @param {Object} [attributes={}]
* Element attributes to be applied.
*
* @param {String|Element|TextNode|Array|Function} [content]
* Contents for the element (see: {@link dom:normalizeContent})
*
* @return {Element}
* The element that was created.
*/
export function createEl(
tagName = 'div',
properties = {},
attributes = {},
content
) {
const el = document.createElement(tagName);
Object.getOwnPropertyNames(properties).forEach(function(propName) {
const val = properties[propName];
// See #2176
// We originally were accepting both properties and attributes in the
// same object, but that doesn't work so well.
if (
propName.indexOf('aria-') !== -1 ||
propName === 'role' ||
propName === 'type'
) {
log.warn(tsml`Setting attributes in the second argument of createEl()
has been deprecated. Use the third argument instead.
createEl(type, properties, attributes). Attempting to set ${propName} to ${val}.`);
el.setAttribute(propName, val);
// Handle textContent since it's not supported everywhere and we have a
// method for it.
} else if (propName === 'textContent') {
textContent(el, val);
} else {
el[propName] = val;
}
});
Object.getOwnPropertyNames(attributes).forEach(function(attrName) {
el.setAttribute(attrName, attributes[attrName]);
});
if (content) {
appendContent(el, content);
}
return el;
}
/**
* 把文本插入元素,并且替换所有已存在的内容
*
* @param {Element} el
* The element to add text content into
*
* @param {string} text
* The text content to add.
*
* @return {Element}
* The element with added text content.
*/
export function textContent(el, text) {
if (typeof el.textContent === 'undefined') {
el.innerText = text;
} else {
el.textContent = text;
}
return el;
}
/**
* 在父元素向前插入子元素
*
* @param {Element} child
* Element to insert
*
* @param {Element} parent
* Element to insert child into
*/
export function prependTo(child, parent) {
if (parent.firstChild) {
parent.insertBefore(child, parent.firstChild);
} else {
parent.appendChild(child);
}
}
/**
* 检查元素是否有css class
*
* @param {Element} element
* Element to check
*
* @param {string} classToCheck
* Class name to check for
*
* @return {boolean}
* - True if the element had the class
* - False otherwise.
*
* @throws {Error}
* Throws an error if `classToCheck` has white space.
*/
export function hasClass(element, classToCheck) {
throwIfWhitespace(classToCheck);
if (element.classList) {
return element.classList.contains(classToCheck);
}
return classRegExp(classToCheck).test(element.className);
}
/**
* 给元素加对应class
*
* @param {Element} element
* Element to add class name to.
*
* @param {string} classToAdd
* Class name to add.
*
* @return {Element}
* The dom element with the added class name.
*/
export function addClass(element, classToAdd) {
if (element.classList) {
element.classList.add(classToAdd);
// Don't need to `throwIfWhitespace` here because `hasElClass` will do it
// in the case of classList not being supported.
} else if (!hasClass(element, classToAdd)) {
element.className = (element.className + ' ' + classToAdd).trim();
}
return element;
}
/**
* 移除元素对应class
*
* @param {Element} element
* Element to remove a class name from.
*
* @param {string} classToRemove
* Class name to remove
*
* @return {Element}
* The dom element with class name removed.
*/
export function removeClass(element, classToRemove) {
if (element.classList) {
element.classList.remove(classToRemove);
} else {
throwIfWhitespace(classToRemove);
element.className = element.className
.split(/\s+/)
.filter(function(c) {
return c !== classToRemove;
})
.join(' ');
}
return element;
}
/**
* 自定义 判断toggleElClass 的回调
*
* @callback Dom~PredicateCallback
* @param {Element} element
* The DOM element of the Component.
*
* @param {string} classToToggle
* The `className` that wants to be toggled
*
* @return {boolean|undefined}
* - If true the `classToToggle` will get added to `element`.
* - If false the `classToToggle` will get removed from `element`.
* - If undefined this callback will be ignored
*/
/**
* 通过判断class是否存在来给元素添加或移除对应类
*
* @param {Element} element
* The element to toggle a class name on.
*
* @param {string} classToToggle
* The class that should be toggled
*
* @param {boolean|PredicateCallback} [predicate]
* See the return value for {@link Dom~PredicateCallback}
*
* @return {Element}
* The element with a class that has been toggled.
*/
export function toggleClass(element, classToToggle, predicate) {
// This CANNOT use `classList` internally because IE does not support the
// second parameter to the `classList.toggle()` method! Which is fine because
// `classList` will be used by the add/remove functions.
const has = hasClass(element, classToToggle);
if (typeof predicate === 'function') {
predicate = predicate(element, classToToggle);
}
if (typeof predicate !== 'boolean') {
predicate = !has;
}
// If the necessary class operation matches the current state of the
// element, no action is required.
if (predicate === has) {
return;
}
if (predicate) {
addClass(element, classToToggle);
} else {
removeClass(element, classToToggle);
}
return element;
}
/**
* 给元素设置属性
*
* @param {Element} el
* Element to add attributes to.
*
* @param {Object} [attributes]
* Attributes to be applied.
*/
export function setAttributes(el, attributes) {
Object.getOwnPropertyNames(attributes).forEach(function(attrName) {
const attrValue = attributes[attrName];
if (
attrValue === null ||
typeof attrValue === 'undefined' ||
attrValue === false
) {
el.removeAttribute(attrName);
} else {
el.setAttribute(attrName, attrValue === true ? '' : attrValue);
}
});
}
/**
* 获取元素所有属性值,对于Boolean属性则返回true或false
*
* @param {Element} tag
* Element from which to get tag attributes.
*
* @return {Object}
* All attributes of the element.
*/
export function getAttributes(tag) {
const obj = {};
// known boolean attributes
// we can check for matching boolean properties, but older browsers
// won't know about HTML5 boolean attributes that we still read from
const knownBooleans =
',' +
'autoplay,controls,playsinline,loop,muted,default,defaultMuted' +
',';
if (tag && tag.attributes && tag.attributes.length > 0) {
const attrs = tag.attributes;
for (let i = attrs.length - 1; i >= 0; i--) {
const attrName = attrs[i].name;
let attrVal = attrs[i].value;
// check for known booleans
// the matching element property will return a value for typeof
if (
typeof tag[attrName] === 'boolean' ||
knownBooleans.indexOf(',' + attrName + ',') !== -1
) {
// the value of an included boolean attribute is typically an empty
// string ('') which would equal false if we just check for a false value.
// we also don't want support bad code like autoplay='false'
attrVal = attrVal !== null ? true : false;
}
obj[attrName] = attrVal;
}
}
return obj;
}
/**
* 获取元素对应属性值
*
* @param {Element} el
* A DOM element
*
* @param {string} attribute
* Attribute to get the value of
*
* @return {string}
* value of the attribute
*/
export function getAttribute(el, attribute) {
return el.getAttribute(attribute);
}
/**
* 设置属性值
*
* @param {Element} el
* A DOM element
*
* @param {string} attribute
* Attribute to set
*
* @param {string} value
* Value to set the attribute to
*/
export function setAttribute(el, attribute, value) {
el.setAttribute(attribute, value);
}
/**
* 移除属性值
*
* @param {Element} el
* A DOM element
*
* @param {string} attribute
* Attribute to remove
*/
export function removeAttribute(el, attribute) {
el.removeAttribute(attribute);
}
/**
* 拖动控件时阻止选择文本
*/
export function blockTextSelection() {
document.body.focus();
document.onselectstart = function() {
return false;
};
}
/**
* 取消禁用文本选择
*/
export function unblockTextSelection() {
document.onselectstart = function() {
return true;
};
}
/**
* 类似原生`getBoundingClientRect` 方法,使用前考虑兼容性。不支持老浏览器如IE8。
* 另外,一些浏览器不支持给 `ClientRect` / `DOMRect` 对象添加属性。通过浅拷贝标准属性(除了像 `x` `y` 这种广泛支持的属性) 来实现。
*
* @param {Element} el
* Element whose `ClientRect` we want to calculate.
*
* @return {Object|undefined}
* Always returns a plain
*/
export function getBoundingClientRect(el) {
if (el && el.getBoundingClientRect && el.parentNode) {
const rect = el.getBoundingClientRect();
const result = {};
['bottom', 'height', 'left', 'right', 'top', 'width'].forEach(k => {
if (rect[k] !== undefined) {
result[k] = rect[k];
}
});
if (!result.height) {
result.height = parseFloat(computedStyle(el, 'height'));
}
if (!result.width) {
result.width = parseFloat(computedStyle(el, 'width'));
}
return result;
}
}
/**
* 获取DOM元素在界面中的位置信息
*
* @typedef {Object} module:dom~Position
*
* @property {number} left
* Pixels to the left
*
* @property {number} top
* Pixels on top
*/
/**
* Offset Left.
* getBoundingClientRect technique from
* John Resig
*
* @see http://ejohn.org/blog/getboundingclientrect-is-awesome/
*
* @param {Element} el
* Element from which to get offset
*
* @return {module:dom~Position}
* The position of the element that was passed in.
*/
export function findPosition(el) {
let box;
if (el.getBoundingClientRect && el.parentNode) {
box = el.getBoundingClientRect();
}
if (!box) {
return {
left: 0,
top: 0
};
}
const docEl = document.documentElement;
const body = document.body;
const clientLeft = docEl.clientLeft || body.clientLeft || 0;
const scrollLeft = window.pageXOffset || body.scrollLeft;
const left = box.left + scrollLeft - clientLeft;
const clientTop = docEl.clientTop || body.clientTop || 0;
const scrollTop = window.pageYOffset || body.scrollTop;
const top = box.top + scrollTop - clientTop;
// Android sometimes returns slightly off decimal values, so need to round
return {
left: Math.round(left),
top: Math.round(top)
};
}
/**
* x and y coordinates for a dom element or mouse pointer
*
* @typedef {Object} Dom~Coordinates
*
* @property {number} x
* x coordinate in pixels
*
* @property {number} y
* y coordinate in pixels
*/
/**
* 基于元素的bottom和left坐标,获取鼠标在元素中的位置信息,返回一个包含x和y的坐标对象。
*
* @param {Element} el
* Element on which to get the pointer position on
*
* @param {EventTarget~Event} event
* Event object
*
* @return {Dom~Coordinates}
* A Coordinates object corresponding to the mouse position.
*
*/
export function getPointerPosition(el, event) {
const position = {};
const box = findPosition(el);
const boxW = el.offsetWidth;
const boxH = el.offsetHeight;
const boxY = box.top;
const boxX = box.left;
let pageY = event.pageY;
let pageX = event.pageX;
if (event.changedTouches) {
pageX = event.changedTouches[0].pageX;
pageY = event.changedTouches[0].pageY;
}
position.y = Math.max(0, Math.min(1, (boxY - pageY + boxH) / boxH));
position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
return position;
}
/**
* 通过鸭子类型来判断是否为文本节点
*
* @param {Mixed} value
* Check if this value is a text node.
*
* @return {boolean}
* - True if it is a text node
* - False otherwise
*/
export function isTextNode(value) {
return isObject(value) && value.nodeType === 3;
}
/**
* 清空元素内容
*
* @param {Element} el
* The element to empty children from
*
* @return {Element}
* The element with no children
*/
export function emptyEl(el) {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
return el;
}
/**
* 格式化插入DOM的内容
* 防止用`innerHTML`造成的xss攻击
* 内容可以多种类型和组合,如下
*
* @param {String|Element|TextNode|Array|Function} content
* - String: Normalized into a text node.
* - Element/TextNode: Passed through.
* - Array: A one-dimensional array of strings, elements, nodes, or functions
* (which return single strings, elements, or nodes).
* - Function: If the sole argument, is expected to produce a string, element,
* node, or array as defined above.
*
* @return {Array}
* All of the content that was passed in normalized.
*/
export function normalizeContent(content) {
// First, invoke content if it is a function. If it produces an array,
// that needs to happen before normalization.
if (typeof content === 'function') {
content = content();
}
// Next up, normalize to an array, so one or many items can be normalized,
// filtered, and returned.
return (Array.isArray(content) ? content : [content])
.map(value => {
// First, invoke value if it is a function to produce a new value,
// which will be subsequently normalized to a Node of some kind.
if (typeof value === 'function') {
value = value();
}
if (isEl(value) || isTextNode(value)) {
return value;
}
if (typeof value === 'string' && /\S/.test(value)) {
return document.createTextNode(value);
}
})
.filter(value => value);
}
/**
* 格式化文本并添加到元素
*
* @param {Element} el
* Element to append normalized content to.
*
*
* @param {String|Element|TextNode|Array|Function} content
* See the `content` argument of {@link dom:normalizeContent}
*
* @return {Element}
* The element with appended normalized content.
*/
export function appendContent(el, content) {
normalizeContent(content).forEach(node => el.appendChild(node));
return el;
}
/**
* 格式化内容,先清空再插入到元素
* Normalizes and inserts content into an element; this is identical to
* `appendContent()`, except it empties the element first.
*
* @param {Element} el
* Element to insert normalized content into.
*
* @param {String|Element|TextNode|Array|Function} content
* See the `content` argument of {@link dom:normalizeContent}
*
* @return {Element}
* The element with inserted normalized content.
*
*/
export function insertContent(el, content) {
return appendContent(emptyEl(el), content);
}
/**
* 检查是否为左键单击
*
* @param {EventTarget~Event} event
* Event object
*
* @return {boolean}
* - True if a left click
* - False if not a left click
*/
export function isSingleLeftClick(event) {
// Note: if you create something draggable, be sure to
// call it on both `mousedown` and `mousemove` event,
// otherwise `mousedown` should be enough for a button
if (event.button === undefined && event.buttons === undefined) {
// Why do we need `butttons` ?
// Because, middle mouse sometimes have this:
// e.button === 0 and e.buttons === 4
// Furthermore, we want to prevent combination click, something like
// HOLD middlemouse then left click, that would be
// e.button === 0, e.buttons === 5
// just `button` is not gonna work
// Alright, then what this block does ?
// this is for chrome `simulate mobile devices`
// I want to support this as well
return true;
}
if (event.button === 0 && event.buttons === undefined) {
// Touch screen, sometimes on some specific device, `buttons`
// doesn't have anything (safari on ios, blackberry...)
return true;
}
if (browser.IE_VERSION === 9) {
// Ignore IE9
return true;
}
if (event.button !== 0 || event.buttons !== 1) {
// This is the reason we have those if else block above
// if any special case we can catch and let it slide
// we do it above, when get to here, this definitely
// is-not-left-click
return false;
}
return true;
}
/**
* 用匹配选择器获取单个DOM元素,依赖于其他DOM的 `context`,默认为 `document`
*
* @param {string} selector
* A valid CSS selector, which will be passed to `querySelector`.
*
* @param {Element|String} [context=document]
* A DOM element within which to query. Can also be a selector
* string in which case the first matching element will be used
* as context. If missing (or no element matches selector), falls
* back to `document`.
*
* @return {Element|null}
* The element that was found or null.
*/
export const $ = createQuerier('querySelector');
/**
* 获取所有匹配选择器的元素
*
* @param {string} selector
* A valid CSS selector, which will be passed to `querySelectorAll`.
*
* @param {Element|String} [context=document]
* A DOM element within which to query. Can also be a selector
* string in which case the first matching element will be used
* as context. If missing (or no element matches selector), falls
* back to `document`.
*
* @return {NodeList}
* A element list of elements that were found. Will be empty if none were found.
*
*/
export const $$ = createQuerier('querySelectorAll');