UNPKG

mdui.editor

Version:

Material Design 样式的富文本编辑器

1,852 lines (1,797 loc) 124 kB
/*! * mdui.editor 1.0.2 (https://github.com/zdhxiong/mdui.editor#readme) * Copyright 2019-2020 zdhxiong * Licensed under MIT */ function isFunction(target) { return typeof target === 'function'; } function isString(target) { return typeof target === 'string'; } function isNumber(target) { return typeof target === 'number'; } function isBoolean(target) { return typeof target === 'boolean'; } function isUndefined(target) { return typeof target === 'undefined'; } function isNull(target) { return target === null; } function isWindow(target) { return target instanceof Window; } function isDocument(target) { return target instanceof Document; } function isElement(target) { return target instanceof Element; } function isNode(target) { return target instanceof Node; } /** * 是否是 IE 浏览器 */ function isIE() { // @ts-ignore return !!window.document.documentMode; } function isArrayLike(target) { if (isFunction(target) || isWindow(target)) { return false; } return isNumber(target.length); } function isObjectLike(target) { return typeof target === 'object' && target !== null; } function toElement(target) { return isDocument(target) ? target.documentElement : target; } /** * 把用 - 分隔的字符串转为驼峰(如 box-sizing 转换为 boxSizing) * @param string */ function toCamelCase(string) { return string .replace(/^-ms-/, 'ms-') .replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); } /** * 把驼峰法转为用 - 分隔的字符串(如 boxSizing 转换为 box-sizing) * @param string */ function toKebabCase(string) { return string.replace(/[A-Z]/g, (replacer) => '-' + replacer.toLowerCase()); } /** * 获取元素的样式值 * @param element * @param name */ function getComputedStyleValue(element, name) { return window.getComputedStyle(element).getPropertyValue(toKebabCase(name)); } /** * 检查元素的 box-sizing 是否是 border-box * @param element */ function isBorderBox(element) { return getComputedStyleValue(element, 'box-sizing') === 'border-box'; } /** * 获取元素的 padding, border, margin 宽度(两侧宽度的和,单位为px) * @param element * @param direction * @param extra */ function getExtraWidth(element, direction, extra) { const position = direction === 'width' ? ['Left', 'Right'] : ['Top', 'Bottom']; return [0, 1].reduce((prev, _, index) => { let prop = extra + position[index]; if (extra === 'border') { prop += 'Width'; } return prev + parseFloat(getComputedStyleValue(element, prop) || '0'); }, 0); } /** * 获取元素的样式值,对 width 和 height 进行过处理 * @param element * @param name */ function getStyle(element, name) { // width、height 属性使用 getComputedStyle 得到的值不准确,需要使用 getBoundingClientRect 获取 if (name === 'width' || name === 'height') { const valueNumber = element.getBoundingClientRect()[name]; if (isBorderBox(element)) { return `${valueNumber}px`; } return `${valueNumber - getExtraWidth(element, name, 'border') - getExtraWidth(element, name, 'padding')}px`; } return getComputedStyleValue(element, name); } /** * 获取子节点组成的数组 * @param target * @param parent */ function getChildNodesArray(target, parent) { const tempParent = document.createElement(parent); tempParent.innerHTML = target; return [].slice.call(tempParent.childNodes); } /** * 始终返回 false 的函数 */ function returnFalse() { return false; } /** * 数值单位的 CSS 属性 */ const cssNumber = [ 'animationIterationCount', 'columnCount', 'fillOpacity', 'flexGrow', 'flexShrink', 'fontWeight', 'gridArea', 'gridColumn', 'gridColumnEnd', 'gridColumnStart', 'gridRow', 'gridRowEnd', 'gridRowStart', 'lineHeight', 'opacity', 'order', 'orphans', 'widows', 'zIndex', 'zoom', ]; function each(target, callback) { if (isArrayLike(target)) { for (let i = 0; i < target.length; i += 1) { if (callback.call(target[i], i, target[i]) === false) { return target; } } } else { const keys = Object.keys(target); for (let i = 0; i < keys.length; i += 1) { if (callback.call(target[keys[i]], keys[i], target[keys[i]]) === false) { return target; } } } return target; } /** * 为了使用模块扩充,这里不能使用默认导出 */ class JQ { constructor(arr) { this.length = 0; if (!arr) { return this; } each(arr, (i, item) => { // @ts-ignore this[i] = item; }); this.length = arr.length; return this; } } function get$() { const $ = function (selector) { if (!selector) { return new JQ(); } // JQ if (selector instanceof JQ) { return selector; } // function if (isFunction(selector)) { if (/complete|loaded|interactive/.test(document.readyState) && document.body) { selector.call(document, $); } else { document.addEventListener('DOMContentLoaded', () => selector.call(document, $), false); } return new JQ([document]); } // String if (isString(selector)) { const html = selector.trim(); // 根据 HTML 字符串创建 JQ 对象 if (html[0] === '<' && html[html.length - 1] === '>') { let toCreate = 'div'; const tags = { li: 'ul', tr: 'tbody', td: 'tr', th: 'tr', tbody: 'table', option: 'select', }; each(tags, (childTag, parentTag) => { if (html.indexOf(`<${childTag}`) === 0) { toCreate = parentTag; return false; } return; }); return new JQ(getChildNodesArray(html, toCreate)); } // 根据 CSS 选择器创建 JQ 对象 const isIdSelector = selector[0] === '#' && !selector.match(/[ .<>:~]/); if (!isIdSelector) { return new JQ(document.querySelectorAll(selector)); } const element = document.getElementById(selector.slice(1)); if (element) { return new JQ([element]); } return new JQ(); } if (isArrayLike(selector) && !isNode(selector)) { return new JQ(selector); } return new JQ([selector]); }; $.fn = JQ.prototype; return $; } const $ = get$(); function extend(target, object1, ...objectN) { objectN.unshift(object1); each(objectN, (_, object) => { each(object, (prop, value) => { if (!isUndefined(value)) { target[prop] = value; } }); }); return target; } $.fn.each = function (callback) { return each(this, callback); }; each(['add', 'remove', 'toggle'], (_, name) => { $.fn[`${name}Class`] = function (className) { if (name === 'remove' && !arguments.length) { return this.each((_, element) => { element.setAttribute('class', ''); }); } return this.each((i, element) => { if (!isElement(element)) { return; } const classes = (isFunction(className) ? className.call(element, i, element.getAttribute('class') || '') : className) .split(' ') .filter((name) => name); each(classes, (_, cls) => { element.classList[name](cls); }); }); }; }); each(['insertBefore', 'insertAfter'], (nameIndex, name) => { $.fn[name] = function (target) { const $element = nameIndex ? $(this.get().reverse()) : this; // 顺序和 jQuery 保持一致 const $target = $(target); const result = []; $target.each((index, target) => { if (!target.parentNode) { return; } $element.each((_, element) => { const newItem = index ? element.cloneNode(true) : element; const existingItem = nameIndex ? target.nextSibling : target; result.push(newItem); target.parentNode.insertBefore(newItem, existingItem); }); }); return $(nameIndex ? result.reverse() : result); }; }); /** * 是否不是 HTML 字符串(包裹在 <> 中) * @param target */ function isPlainText(target) { return (isString(target) && (target[0] !== '<' || target[target.length - 1] !== '>')); } each(['before', 'after'], (nameIndex, name) => { $.fn[name] = function (...args) { // after 方法,多个参数需要按参数顺序添加到元素后面,所以需要将参数顺序反向处理 if (nameIndex === 1) { args = args.reverse(); } return this.each((index, element) => { const targets = isFunction(args[0]) ? [args[0].call(element, index, element.innerHTML)] : args; each(targets, (_, target) => { let $target; if (isPlainText(target)) { $target = $(getChildNodesArray(target, 'div')); } else if (index && isElement(target)) { $target = $(target.cloneNode(true)); } else { $target = $(target); } $target[nameIndex ? 'insertAfter' : 'insertBefore'](element); }); }); }; }); function map(elements, callback) { let value; const ret = []; each(elements, (i, element) => { value = callback.call(window, element, i); if (value != null) { ret.push(value); } }); return [].concat(...ret); } $.fn.map = function (callback) { return new JQ(map(this, (element, i) => callback.call(element, i, element))); }; $.fn.clone = function () { return this.map(function () { return this.cloneNode(true); }); }; $.fn.is = function (selector) { let isMatched = false; if (isFunction(selector)) { this.each((index, element) => { if (selector.call(element, index, element)) { isMatched = true; } }); return isMatched; } if (isString(selector)) { this.each((_, element) => { if (isDocument(element) || isWindow(element)) { return; } // @ts-ignore const matches = element.matches || element.msMatchesSelector; if (matches.call(element, selector)) { isMatched = true; } }); return isMatched; } const $compareWith = $(selector); this.each((_, element) => { $compareWith.each((_, compare) => { if (element === compare) { isMatched = true; } }); }); return isMatched; }; $.fn.remove = function (selector) { return this.each((_, element) => { if (element.parentNode && (!selector || $(element).is(selector))) { element.parentNode.removeChild(element); } }); }; each(['prepend', 'append'], (nameIndex, name) => { $.fn[name] = function (...args) { return this.each((index, element) => { const childNodes = element.childNodes; const childLength = childNodes.length; const child = childLength ? childNodes[nameIndex ? childLength - 1 : 0] : document.createElement('div'); if (!childLength) { element.appendChild(child); } let contents = isFunction(args[0]) ? [args[0].call(element, index, element.innerHTML)] : args; // 如果不是字符串,则仅第一个元素使用原始元素,其他的都克隆自第一个元素 if (index) { contents = contents.map((content) => { return isString(content) ? content : $(content).clone(); }); } $(child)[nameIndex ? 'after' : 'before'](...contents); if (!childLength) { element.removeChild(child); } }); }; }); each(['attr', 'prop', 'css'], (nameIndex, name) => { function set(element, key, value) { // 值为 undefined 时,不修改 if (isUndefined(value)) { return; } switch (nameIndex) { // attr case 0: if (isNull(value)) { element.removeAttribute(key); } else { element.setAttribute(key, value); } break; // prop case 1: // @ts-ignore element[key] = value; break; // css default: key = toCamelCase(key); // @ts-ignore element.style[key] = isNumber(value) ? `${value}${cssNumber.indexOf(key) > -1 ? '' : 'px'}` : value; break; } } function get(element, key) { switch (nameIndex) { // attr case 0: // 属性不存在时,原生 getAttribute 方法返回 null,而 jquery 返回 undefined。这里和 jquery 保持一致 const value = element.getAttribute(key); return isNull(value) ? undefined : value; // prop case 1: // @ts-ignore return element[key]; // css default: return getStyle(element, key); } } $.fn[name] = function (key, value) { if (isObjectLike(key)) { each(key, (k, v) => { // @ts-ignore this[name](k, v); }); return this; } if (arguments.length === 1) { const element = this[0]; return isElement(element) ? get(element, key) : undefined; } return this.each((i, element) => { set(element, key, isFunction(value) ? value.call(element, i, get(element, key)) : value); }); }; }); /** * 过滤掉数组中的重复元素 * @param arr 数组 * @example ```js unique([1, 2, 12, 3, 2, 1, 2, 1, 1]); // [1, 2, 12, 3] ``` */ function unique(arr) { const result = []; each(arr, (_, val) => { if (result.indexOf(val) === -1) { result.push(val); } }); return result; } $.fn.children = function (selector) { const children = []; this.each((_, element) => { each(element.childNodes, (__, childNode) => { if (!isElement(childNode)) { return; } if (!selector || $(childNode).is(selector)) { children.push(childNode); } }); }); return new JQ(unique(children)); }; $.fn.slice = function (...args) { return new JQ([].slice.apply(this, args)); }; $.fn.eq = function (index) { const ret = index === -1 ? this.slice(index) : this.slice(index, +index + 1); return new JQ(ret); }; $.fn.first = function () { return this.eq(0); }; each(['val', 'html', 'text'], (nameIndex, name) => { const props = { 0: 'value', 1: 'innerHTML', 2: 'textContent', }; const propName = props[nameIndex]; function get($elements) { // text() 获取所有元素的文本 if (nameIndex === 2) { // @ts-ignore return map($elements, (element) => toElement(element)[propName]).join(''); } // 空集合时,val() 和 html() 返回 undefined if (!$elements.length) { return undefined; } // val() 和 html() 仅获取第一个元素的内容 const firstElement = $elements[0]; // select multiple 返回数组 if (nameIndex === 0 && $(firstElement).is('select[multiple]')) { return map($(firstElement).find('option:checked'), (element) => element.value); } // @ts-ignore return firstElement[propName]; } function set(element, value) { // text() 和 html() 赋值为 undefined,则保持原内容不变 // val() 赋值为 undefined 则赋值为空 if (isUndefined(value)) { if (nameIndex !== 0) { return; } value = ''; } if (nameIndex === 1 && isElement(value)) { value = value.outerHTML; } // @ts-ignore element[propName] = value; } $.fn[name] = function (value) { // 获取值 if (!arguments.length) { return get(this); } // 设置值 return this.each((i, element) => { const computedValue = isFunction(value) ? value.call(element, i, get($(element))) : value; // value 是数组,则选中数组中的元素,反选不在数组中的元素 if (nameIndex === 0 && Array.isArray(computedValue)) { // select[multiple] if ($(element).is('select[multiple]')) { map($(element).find('option'), (option) => (option.selected = computedValue.indexOf(option.value) > -1)); } // 其他 checkbox, radio 等元素 else { element.checked = computedValue.indexOf(element.value) > -1; } } else { set(element, computedValue); } }); }; }); $.fn.last = function () { return this.eq(-1); }; /** * 检查 container 元素内是否包含 contains 元素 * @param container 父元素 * @param contains 子元素 * @example ```js contains( document, document.body ); // true contains( document.getElementById('test'), document ); // false contains( $('.container').get(0), $('.contains').get(0) ); // false ``` */ function contains(container, contains) { return container !== contains && toElement(container).contains(contains); } /** * 把第二个数组的元素追加到第一个数组中,并返回合并后的数组 * @param first 第一个数组 * @param second 该数组的元素将被追加到第一个数组中 * @example ```js merge( [ 0, 1, 2 ], [ 2, 3, 4 ] ) // [ 0, 1, 2, 2, 3, 4 ] ``` */ function merge(first, second) { each(second, (_, value) => { first.push(value); }); return first; } $.fn.get = function (index) { return index === undefined ? [].slice.call(this) : this[index >= 0 ? index : index + this.length]; }; $.fn.find = function (selector) { const foundElements = []; this.each((_, element) => { merge(foundElements, $(element.querySelectorAll(selector)).get()); }); return new JQ(foundElements); }; // 存储事件 const handlers = {}; // 元素ID let mduiElementId = 1; /** * 为元素赋予一个唯一的ID */ function getElementId(element) { const key = '_mduiEventId'; // @ts-ignore if (!element[key]) { // @ts-ignore element[key] = ++mduiElementId; } // @ts-ignore return element[key]; } /** * 解析事件名中的命名空间 */ function parse(type) { const parts = type.split('.'); return { type: parts[0], ns: parts.slice(1).sort().join(' '), }; } /** * 命名空间匹配规则 */ function matcherFor(ns) { return new RegExp('(?:^| )' + ns.replace(' ', ' .* ?') + '(?: |$)'); } /** * 获取匹配的事件 * @param element * @param type * @param func * @param selector */ function getHandlers(element, type, func, selector) { const event = parse(type); return (handlers[getElementId(element)] || []).filter((handler) => handler && (!event.type || handler.type === event.type) && (!event.ns || matcherFor(event.ns).test(handler.ns)) && (!func || getElementId(handler.func) === getElementId(func)) && (!selector || handler.selector === selector)); } /** * 添加事件监听 * @param element * @param types * @param func * @param data * @param selector */ function add(element, types, func, data, selector) { const elementId = getElementId(element); if (!handlers[elementId]) { handlers[elementId] = []; } // 传入 data.useCapture 来设置 useCapture: true let useCapture = false; if (isObjectLike(data) && data.useCapture) { useCapture = true; } types.split(' ').forEach((type) => { if (!type) { return; } const event = parse(type); function callFn(e, elem) { // 因为鼠标事件模拟事件的 detail 属性是只读的,因此在 e._detail 中存储参数 const result = func.apply(elem, // @ts-ignore e._detail === undefined ? [e] : [e].concat(e._detail)); if (result === false) { e.preventDefault(); e.stopPropagation(); } } function proxyFn(e) { // @ts-ignore if (e._ns && !matcherFor(e._ns).test(event.ns)) { return; } // @ts-ignore e._data = data; if (selector) { // 事件代理 $(element) .find(selector) .get() .reverse() .forEach((elem) => { if (elem === e.target || contains(elem, e.target)) { callFn(e, elem); } }); } else { // 不使用事件代理 callFn(e, element); } } const handler = { type: event.type, ns: event.ns, func, selector, id: handlers[elementId].length, proxy: proxyFn, }; handlers[elementId].push(handler); element.addEventListener(handler.type, proxyFn, useCapture); }); } /** * 移除事件监听 * @param element * @param types * @param func * @param selector */ function remove(element, types, func, selector) { const handlersInElement = handlers[getElementId(element)] || []; const removeEvent = (handler) => { delete handlersInElement[handler.id]; element.removeEventListener(handler.type, handler.proxy, false); }; if (!types) { handlersInElement.forEach((handler) => removeEvent(handler)); } else { types.split(' ').forEach((type) => { if (type) { getHandlers(element, type, func, selector).forEach((handler) => removeEvent(handler)); } }); } } $.fn.off = function (types, selector, callback) { // types 是对象 if (isObjectLike(types)) { each(types, (type, fn) => { // this.off('click', undefined, function () {}) // this.off('click', '.box', function () {}) this.off(type, selector, fn); }); return this; } // selector 不存在 if (selector === false || isFunction(selector)) { callback = selector; selector = undefined; // this.off('click', undefined, function () {}) } // callback 传入 `false`,相当于 `return false` if (callback === false) { callback = returnFalse; } return this.each(function () { remove(this, types, callback, selector); }); }; $.fn.on = function (types, selector, data, callback, one) { // types 可以是 type/func 对象 if (isObjectLike(types)) { // (types-Object, selector, data) if (!isString(selector)) { // (types-Object, data) data = data || selector; selector = undefined; } each(types, (type, fn) => { // selector 和 data 都可能是 undefined // @ts-ignore this.on(type, selector, data, fn, one); }); return this; } if (data == null && callback == null) { // (types, fn) callback = selector; data = selector = undefined; } else if (callback == null) { if (isString(selector)) { // (types, selector, fn) callback = data; data = undefined; } else { // (types, data, fn) callback = data; data = selector; selector = undefined; } } if (callback === false) { callback = returnFalse; } else if (!callback) { return this; } // $().one() if (one) { // eslint-disable-next-line @typescript-eslint/no-this-alias const _this = this; const origCallback = callback; callback = function (event) { _this.off(event.type, selector, callback); // eslint-disable-next-line prefer-rest-params return origCallback.apply(this, arguments); }; } return this.each(function () { add(this, types, callback, data, selector); }); }; each(['appendTo', 'prependTo'], (nameIndex, name) => { $.fn[name] = function (target) { const extraChilds = []; const $target = $(target).map((_, element) => { const childNodes = element.childNodes; const childLength = childNodes.length; if (childLength) { return childNodes[nameIndex ? 0 : childLength - 1]; } const child = document.createElement('div'); element.appendChild(child); extraChilds.push(child); return child; }); const $result = this[nameIndex ? 'insertBefore' : 'insertAfter']($target); $(extraChilds).remove(); return $result; }; }); class CommonAbstract { /** * @param editor 编辑器实例 */ constructor(editor) { this.editor = editor; } /** * 工具栏 JQ 对象 */ get $toolbar() { return this.editor.$toolbar; } /** * 内容区域 JQ 对象 */ get $container() { return this.editor.$container; } /** * 选区实例 */ get selection() { return this.editor.selection; } /** * 命令实例 */ get command() { return this.editor.command; } } /** * 封装 document.execCommand 命令 */ class Command extends CommonAbstract { /** * 执行命令 * @param name * @param value */ do(name, value) { // 如果无选区,忽略 if (!this.selection.getRange()) { return; } // 恢复选区 this.selection.restore(); const customName = name; // 执行命令 // @ts-ignore if (this[customName]) { // @ts-ignore this[customName](value); } else { document.execCommand(name, false, value); } // 修改菜单状态 this.editor.menus.changeStatus(); // 最后,恢复选区保证光标在原来的位置闪烁 this.selection.saveRange(); this.selection.restore(); // 触发 onchange if (this.editor.change) { this.editor.change(); } } /** * 自定义 insertHTML 事件,在当前选区中插入指定 HTML * @param html */ // @ts-ignore insertHTML(html) { // W3C if (document.queryCommandSupported('insertHTML')) { document.execCommand('insertHTML', false, html); return; } const range = this.selection.getRange(); if (range.insertNode) { // IE range.deleteContents(); range.insertNode($(html)[0]); // @ts-ignore } else if (range.pasteHTML) { // IE <= 10 // @ts-ignore range.pasteHTML(html); } } /** * 用指定 HTML 替换当前选区的 root 元素 * @param html */ // @ts-ignore replaceRoot(html) { const $oldElem = this.selection.getRootElem(); const $newElem = $(html).insertAfter($oldElem); $oldElem.remove(); this.selection.createRangeByElem($newElem, false, true); this.selection.restore(); } /** * 在当前选区的 root 元素后面插入指定 html * @param html */ // @ts-ignore insertAfterRoot(html) { const $oldElem = this.selection.getRootElem(); const $newElem = $(html).insertAfter($oldElem); this.selection.createRangeByElem($newElem, false, true); this.selection.restore(); } /** * 在当前 $content 的最后追加 html * @param html */ // @ts-ignore appendHTML(html) { const $newElem = $(html).appendTo(this.$container); this.selection.createRangeByElem($newElem, false, true); this.selection.restore(); } /** * 插入 elem * @param $elem */ // @ts-ignore insertElem($elem) { const range = this.selection.getRange(); if (range.insertNode) { range.deleteContents(); range.insertNode($elem[0]); } } } class MenuAbstract extends CommonAbstract { /** * @param editor 编辑器实例 * @param $button 按钮 JQ 对象 */ constructor(editor, $button) { super(editor); this.$button = $button; } /** * 按钮是否激活 */ isActive() { return false; } } /** * 按钮图标 */ MenuAbstract.icon = ''; /** * 按钮名称 */ MenuAbstract.title = ''; /** * 激活按钮时,需要禁用的其他按钮 */ MenuAbstract.disable = []; /** * 原生命令抽象类 */ class MenuNativeAbstract extends MenuAbstract { onclick() { const isSelectionEmpty = this.selection.isEmpty(); if (isSelectionEmpty) { // 选区是空的,插入并选中一个“空白” this.selection.createEmptyRange(this.getElementName()); } // 执行 bold 命令 this.command.do(this.getCommandName()); if (isSelectionEmpty) { // 需要将选区折叠起来 this.selection.collapseRange(); this.selection.restore(); } } isActive() { return document.queryCommandState(this.getCommandName()); } } /** * 加粗 */ class Bold extends MenuNativeAbstract { getCommandName() { return 'bold'; } getElementName() { return 'strong'; } } Bold.icon = 'format_bold'; Bold.title = '粗体'; Bold.disable = ['image']; const $document = $(document); const $window = $(window); const $body = $('body'); // 避免页面加载完后直接执行css动画 // https://css-tricks.com/transitions-only-after-page-load/ setTimeout(() => $body.addClass('mdui-loaded')); const mdui = { $: $, }; $.fn.hasClass = function (className) { return this[0].classList.contains(className); }; /** * 值上面的 padding、border、margin 处理 * @param element * @param name * @param value * @param funcIndex * @param includeMargin * @param multiply */ function handleExtraWidth(element, name, value, funcIndex, includeMargin, multiply) { // 获取元素的 padding, border, margin 宽度(两侧宽度的和) const getExtraWidthValue = (extra) => { return (getExtraWidth(element, name.toLowerCase(), extra) * multiply); }; if (funcIndex === 2 && includeMargin) { value += getExtraWidthValue('margin'); } if (isBorderBox(element)) { // IE 为 box-sizing: border-box 时,得到的值不含 border 和 padding,这里先修复 // 仅获取时需要处理,multiply === 1 为 get if (isIE() && multiply === 1) { value += getExtraWidthValue('border'); value += getExtraWidthValue('padding'); } if (funcIndex === 0) { value -= getExtraWidthValue('border'); } if (funcIndex === 1) { value -= getExtraWidthValue('border'); value -= getExtraWidthValue('padding'); } } else { if (funcIndex === 0) { value += getExtraWidthValue('padding'); } if (funcIndex === 2) { value += getExtraWidthValue('border'); value += getExtraWidthValue('padding'); } } return value; } /** * 获取元素的样式值 * @param element * @param name * @param funcIndex 0: innerWidth, innerHeight; 1: width, height; 2: outerWidth, outerHeight * @param includeMargin */ function get(element, name, funcIndex, includeMargin) { const clientProp = `client${name}`; const scrollProp = `scroll${name}`; const offsetProp = `offset${name}`; const innerProp = `inner${name}`; // $(window).width() if (isWindow(element)) { // outerWidth, outerHeight 需要包含滚动条的宽度 return funcIndex === 2 ? element[innerProp] : toElement(document)[clientProp]; } // $(document).width() if (isDocument(element)) { const doc = toElement(element); return Math.max( // @ts-ignore element.body[scrollProp], doc[scrollProp], // @ts-ignore element.body[offsetProp], doc[offsetProp], doc[clientProp]); } const value = parseFloat(getComputedStyleValue(element, name.toLowerCase()) || '0'); return handleExtraWidth(element, name, value, funcIndex, includeMargin, 1); } /** * 设置元素的样式值 * @param element * @param elementIndex * @param name * @param funcIndex 0: innerWidth, innerHeight; 1: width, height; 2: outerWidth, outerHeight * @param includeMargin * @param value */ function set(element, elementIndex, name, funcIndex, includeMargin, value) { let computedValue = isFunction(value) ? value.call(element, elementIndex, get(element, name, funcIndex, includeMargin)) : value; if (computedValue == null) { return; } const $element = $(element); const dimension = name.toLowerCase(); // 特殊的值,不需要计算 padding、border、margin if (['auto', 'inherit', ''].indexOf(computedValue) > -1) { $element.css(dimension, computedValue); return; } // 其他值保留原始单位。注意:如果不使用 px 作为单位,则算出的值一般是不准确的 const suffix = computedValue.toString().replace(/\b[0-9.]*/, ''); const numerical = parseFloat(computedValue); computedValue = handleExtraWidth(element, name, numerical, funcIndex, includeMargin, -1) + (suffix || 'px'); $element.css(dimension, computedValue); } each(['Width', 'Height'], (_, name) => { each([`inner${name}`, name.toLowerCase(), `outer${name}`], (funcIndex, funcName) => { $.fn[funcName] = function (margin, value) { // 是否是赋值操作 const isSet = arguments.length && (funcIndex < 2 || !isBoolean(margin)); const includeMargin = margin === true || value === true; // 获取第一个元素的值 if (!isSet) { return this.length ? get(this[0], name, funcIndex, includeMargin) : undefined; } // 设置每个元素的值 return this.each((index, element) => set(element, index, name, funcIndex, includeMargin, margin)); }; }); }); $.fn.hide = function () { return this.each(function () { this.style.display = 'none'; }); }; const elementDisplay = {}; /** * 获取元素的初始 display 值,用于 .show() 方法 * @param nodeName */ function defaultDisplay(nodeName) { let element; let display; if (!elementDisplay[nodeName]) { element = document.createElement(nodeName); document.body.appendChild(element); display = getStyle(element, 'display'); element.parentNode.removeChild(element); if (display === 'none') { display = 'block'; } elementDisplay[nodeName] = display; } return elementDisplay[nodeName]; } /** * 显示指定元素 * @returns {JQ} */ $.fn.show = function () { return this.each(function () { if (this.style.display === 'none') { this.style.display = ''; } if (getStyle(this, 'display') === 'none') { this.style.display = defaultDisplay(this.nodeName); } }); }; $.fn.transitionEnd = function (callback) { // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this; const events = ['webkitTransitionEnd', 'transitionend']; function fireCallback(e) { if (e.target !== this) { return; } // @ts-ignore callback.call(this, e); each(events, (_, event) => { that.off(event, fireCallback); }); } each(events, (_, event) => { that.on(event, fireCallback); }); return this; }; const dataNS = '_mduiElementDataStorage'; /** * 在元素上设置键值对数据 * @param element * @param object */ function setObjectToElement(element, object) { // @ts-ignore if (!element[dataNS]) { // @ts-ignore element[dataNS] = {}; } each(object, (key, value) => { // @ts-ignore element[dataNS][toCamelCase(key)] = value; }); } function data(element, key, value) { // 根据键值对设置值 // data(element, { 'key' : 'value' }) if (isObjectLike(key)) { setObjectToElement(element, key); return key; } // 根据 key、value 设置值 // data(element, 'key', 'value') if (!isUndefined(value)) { setObjectToElement(element, { [key]: value }); return value; } // 获取所有值 // data(element) if (isUndefined(key)) { // @ts-ignore return element[dataNS] ? element[dataNS] : {}; } // 从 dataNS 中获取指定值 // data(element, 'key') key = toCamelCase(key); // @ts-ignore if (element[dataNS] && key in element[dataNS]) { // @ts-ignore return element[dataNS][key]; } return undefined; } const rbrace = /^(?:{[\w\W]*\}|\[[\w\W]*\])$/; // 从 `data-*` 中获取的值,需要经过该函数转换 function getData(value) { if (value === 'true') { return true; } if (value === 'false') { return false; } if (value === 'null') { return null; } if (value === +value + '') { return +value; } if (rbrace.test(value)) { return JSON.parse(value); } return value; } // 若 value 不存在,则从 `data-*` 中获取值 function dataAttr(element, key, value) { if (isUndefined(value) && element.nodeType === 1) { const name = 'data-' + toKebabCase(key); value = element.getAttribute(name); if (isString(value)) { try { value = getData(value); } catch (e) { } } else { value = undefined; } } return value; } $.fn.data = function (key, value) { // 获取所有值 if (isUndefined(key)) { if (!this.length) { return undefined; } const element = this[0]; const resultData = data(element); // window, document 上不存在 `data-*` 属性 if (element.nodeType !== 1) { return resultData; } // 从 `data-*` 中获取值 const attrs = element.attributes; let i = attrs.length; while (i--) { if (attrs[i]) { let name = attrs[i].name; if (name.indexOf('data-') === 0) { name = toCamelCase(name.slice(5)); resultData[name] = dataAttr(element, name, resultData[name]); } } } return resultData; } // 同时设置多个值 if (isObjectLike(key)) { return this.each(function () { data(this, key); }); } // value 传入了 undefined if (arguments.length === 2 && isUndefined(value)) { return this; } // 设置值 if (!isUndefined(value)) { return this.each(function () { data(this, key, value); }); } // 获取值 if (!this.length) { return undefined; } return dataAttr(this[0], key, data(this[0], key)); }; $.hideOverlay = function (force = false) { const $overlay = $('.mdui-overlay'); if (!$overlay.length) { return; } let level = force ? 1 : $overlay.data('_overlay_level'); if (level > 1) { $overlay.data('_overlay_level', --level); return; } $overlay .data('_overlay_level', 0) .removeClass('mdui-overlay-show') .data('_overlay_is_deleted', true) .transitionEnd(() => { if ($overlay.data('_overlay_is_deleted')) { $overlay.remove(); } }); }; $.lockScreen = function () { // 不直接把 body 设为 box-sizing: border-box,避免污染全局样式 const newBodyWidth = $body.width(); let level = $body.data('_lockscreen_level') || 0; $body .addClass('mdui-locked') .width(newBodyWidth) .data('_lockscreen_level', ++level); }; $.fn.reflow = function () { return this.each(function () { return this.clientLeft; }); }; $.showOverlay = function (zIndex) { let $overlay = $('.mdui-overlay'); if ($overlay.length) { $overlay.data('_overlay_is_deleted', false); if (!isUndefined(zIndex)) { $overlay.css('z-index', zIndex); } } else { if (isUndefined(zIndex)) { zIndex = 2000; } $overlay = $('<div class="mdui-overlay">') .appendTo(document.body) .reflow() .css('z-index', zIndex); } let level = $overlay.data('_overlay_level') || 0; return $overlay.data('_overlay_level', ++level).addClass('mdui-overlay-show'); }; $.throttle = function (fn, delay = 16) { let timer = null; return function (...args) { if (isNull(timer)) { timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); } }; }; $.unlockScreen = function (force = false) { let level = force ? 1 : $body.data('_lockscreen_level'); if (level > 1) { $body.data('_lockscreen_level', --level); return; } $body.data('_lockscreen_level', 0).removeClass('mdui-locked').width(''); }; $.fn.trigger = function (type, extraParameters) { const event = parse(type); let eventObject; const eventParams = { bubbles: true, cancelable: true, }; const isMouseEvent = ['click', 'mousedown', 'mouseup', 'mousemove'].indexOf(event.type) > -1; if (isMouseEvent) { // Note: MouseEvent 无法传入 detail 参数 eventObject = new MouseEvent(event.type, eventParams); } else { eventParams.detail = extraParameters; eventObject = new CustomEvent(event.type, eventParams); } // @ts-ignore eventObject._detail = extraParameters; // @ts-ignore eventObject._ns = event.ns; return this.each(function () { this.dispatchEvent(eventObject); }); }; /** * 触发组件上的事件 * @param eventName 事件名 * @param componentName 组件名 * @param target 在该元素上触发事件 * @param instance 组件实例 * @param parameters 事件参数 */ function componentEvent(eventName, componentName, target, instance, parameters) { if (!parameters) { parameters = {}; } // @ts-ignore parameters.inst = instance; const fullEventName = `${eventName}.mdui.${componentName}`; // jQuery 事件 // @ts-ignore if (typeof jQuery !== 'undefined') { // @ts-ignore jQuery(target).trigger(fullEventName, parameters); } const $target = $(target); // mdui.jq 事件 $target.trigger(fullEventName, parameters); const eventParams = { bubbles: true, cancelable: true, detail: parameters, }; const eventObject = new CustomEvent(fullEventName, eventParams); // @ts-ignore eventObject._detail = parameters; $target[0].dispatchEvent(eventObject); } const container = {}; function queue(name, func) { if (isUndefined(container[name])) { container[name] = []; } if (isUndefined(func)) { return container[name]; } container[name].push(func); } /** * 从队列中移除第一个函数,并执行该函数 * @param name 队列满 */ function dequeue(name) { if (isUndefined(container[name])) { return; } if (!container[name].length) { return; } const func = container[name].shift(); func(); } const DEFAULT_OPTIONS = { history: true, overlay: true, modal: false, closeOnEsc: true, closeOnCancel: true, closeOnConfirm: true, destroyOnClosed: false, }; /** * 当前显示的对话框实例 */ let currentInst = null; /** * 队列名 */ const queueName = '_mdui_dialog'; /** * 窗口是否已锁定 */ let isLockScreen = false; /** * 遮罩层元素 */ let $overlay; class Dialog { constructor(selector, options = {}) { /** * 配置参数 */ this.options = extend({}, DEFAULT_OPTIONS); /** * 当前 dialog 的状态 */ this.state = 'closed'; /** * dialog 元素是否是动态添加的 */ this.append = false; this.$element = $(selector).first(); // 如果对话框元素没有在当前文档中,则需要添加 if (!contains(document.body, this.$element[0])) { this.append = true; $body.append(this.$element); } extend(this.options, options); // 绑定取消按钮事件 this.$element.find('[mdui-dialog-cancel]').each((_, cancel) => { $(cancel).on('click', () => { this.triggerEvent('cancel'); if (this.options.closeOnCancel) { this.close(); } }); }); // 绑定确认按钮事件 this.$element.find('[mdui-dialog-confirm]').each((_, confirm) => { $(confirm).on('click', () => { this.triggerEvent('confirm'); if (this.options.closeOnConfirm) { this.close(); } }); }); // 绑定关闭按钮事件 this.$element.find('[mdui-dialog-close]').each((_, close) => { $(close).on('click', () => this.close()); }); } /** * 触发组件事件 * @param name */ triggerEvent(name) { componentEvent(name, 'dialog', this.$element, this); } /** * 窗口宽度变化,或对话框内容变化时,调整对话框位置和对话框内的滚动条 */ readjust() { if (!currentInst) { return; } const $element = currentInst.$element; const $title = $element.children('.mdui-dialog-title'); const $content = $element.children('.mdui-dialog-content'); const $actions = $element.children('.mdui-dialog-actions'); // 调整 dialog 的 top 和 height 值 $element.height(''); $content.height(''); const elementHeight = $element.height(); $element.css({ top: `${($window.height() - elementHeight) / 2}px`, height: `${elementHeight}px`, }); // 调整 mdui-dialog-content 的高度 $content.innerHeight(elementHeight - ($title.innerHeight() || 0) - ($actions.innerHeight() || 0)); } /** * hashchange 事件触发时关闭对话框 */ hashchangeEvent() { if (window.location.hash.substring(1).indexOf('mdui-dialog') < 0) { currentInst.close(true); } } /** * 点击遮罩层关闭对话框 * @param event */ overlayClick(event) { if ($(event.target).hasClass('mdui-overlay') && currentInst) { currentInst.close(); } } /** * 动画结束回调 */ transitionEnd() { if (this.$element.hasClass('mdui-dialog-open')) { this.state = 'opened'; this.triggerEvent('opened'); } else { this.state = 'closed'; this.triggerEvent('closed'); this.$element.hide(); // 所有对话框都关闭,且当前没有打开的对话框时,解锁屏幕 if (!queue(queueName).length && !currentInst && isLockScreen) { $.unlockScreen(); isLockScreen = false; } $window.off('resize', $.throttle(this.readjust, 100)); if (this.options.destroyOnClosed) { this.destroy(); } } } /** * 打开指定对话框 */ doOpen() { currentInst = this; if (!isLockScreen) { $.lockScreen(); isLockScreen = true; } this.$element.show(); this.readjust(); $window.on('resize', $.throttle(this.readjust, 100)); // 打开消息框 this.state = 'opening'; this.triggerEvent('open'); this.$element .addClass('mdui-dialog-open') .transitionEnd(() => this.transitionEnd()); // 不存在遮罩层元素时,添加遮罩层 if (!$overlay) { $overlay = $.showOverlay(5100); } // 点击遮罩层时是否关闭对话框 if (this.options.modal) { $overlay.off('click', this.overlayClick); } else { $overlay.on('click', this.overlayClick); } // 是否显示遮罩层,不显示时,把遮罩层背景透明 $overlay.css('opacity', this.options.overlay ? '' : 0); if (this.options.history) { // 如果 hash 中原来就有 mdui-dialog,先删除,避免后退历史纪录后仍然有 mdui-dialog 导致无法关闭 // 包括 mdui-dialog 和 &mdui-dialog 和 ?mdui-dialog let hash = window.location.hash.substring(1); if (hash.indexOf('mdui-dialog') > -1) { hash = hash.replace(/[&?]?mdui-dialog/g, ''); } // 后退按钮关闭对话框 if (hash) { window.location.hash = `${hash}${hash.indexOf('?') > -1 ? '&' : '?'}mdui-dialog`; } else { window.location.hash = 'mdui-dialog'; } $window.on('hashchange', this.hashchangeEvent); } } /** * 当前对话框是否为打开状态 */ isOpen() { return this.state === 'opening' || this.state === 'opened'; } /** * 打开对话框 */ open() { if (this.isOpen()) { return; } // 如果当前有正在打开或已经打开的对话框,或队列不为空,则先加入队列,等旧对话框开始关闭时再打开 if ((currentInst && (currentInst.state === 'opening' || currentInst.state === 'opened')) || queue(queueName).length) { queue(queueName, () => this.doOpen()); return; } this.doOpen(); } /** * 关闭对话框 */ close(historyBack = false) { // historyBack 是否需要后退历史纪录,默认为 `false`。该参数仅内部使用 // 为 `false` 时是通过 js 关闭,需要后退一个历史记录 // 为 `true` 时是通过后退按钮关闭,不需要后退历史记录 // setTimeout 的作用是: // 当同时关闭一个对话框