From 457c1f41f2b2ee5fbcf2808448a43c7f4bf37dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E5=90=9B=E5=81=A5?= <40288193@qq.com> Date: Sun, 26 Jan 2025 09:25:01 +0800 Subject: [PATCH] refactor renderless/common to @opentiny/utils (#2845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(utils): 完成common中,外层函数的迁移 * fix(utils): 迁移common/deps目录的文件 * fix(hooks): 修复指令包和hooks包的链接指向 * fix(renderless): fix --- packages/utils/src/after-leave/index.ts | 44 + packages/utils/src/array/index.ts | 240 +++++ packages/utils/src/bigInt/index.ts | 415 ++++++++ packages/utils/src/browser/index.ts | 100 ++ packages/utils/src/calendar/index.ts | 158 +++ packages/utils/src/common/index.ts | 264 +++++ packages/utils/src/crypt/index.ts | 2 +- packages/utils/src/dataset/index.ts | 142 +++ packages/utils/src/date-util/index.ts | 310 ++++++ packages/utils/src/date/index.ts | 538 +++++++++++ packages/utils/src/debounce/index.ts | 17 + packages/utils/src/decimal/index.ts | 262 +++++ packages/utils/src/dom/index.ts | 307 ++++++ packages/utils/src/espace-ctrl/index.ts | 419 ++++++++ packages/utils/src/event/index.ts | 85 ++ packages/utils/src/fastdom/async.ts | 59 ++ packages/utils/src/fastdom/index.ts | 16 + packages/utils/src/fastdom/sandbox.ts | 75 ++ packages/utils/src/fastdom/singleton.ts | 118 +++ packages/utils/src/fecha/index.ts | 342 +++++++ packages/utils/src/form/index.ts | 6 + packages/utils/src/fullscreen/apis.ts | 197 ++++ packages/utils/src/fullscreen/index.ts | 4 + packages/utils/src/fullscreen/screenfull.ts | 200 ++++ packages/utils/src/function/index.ts | 27 + packages/utils/src/globalConfig/index.ts | 14 + packages/utils/src/index.ts | 233 ++++- packages/utils/src/logger/index.ts | 2 +- packages/utils/src/memorize/index.ts | 160 ++++ packages/utils/src/object/index.ts | 436 +++++++++ packages/utils/src/popper/index.ts | 905 ++++++++++++++++++ packages/utils/src/popup-manager/index.ts | 260 +++++ packages/utils/src/prop-util/index.ts | 39 + packages/utils/src/resize-event/index.ts | 58 ++ packages/utils/src/resize-observer/index.ts | 608 ++++++++++++ packages/utils/src/scroll-into-view/index.ts | 43 + packages/utils/src/scroll-width/index.ts | 50 + packages/utils/src/string/index.ts | 834 ++++++++++++++++ packages/utils/src/throttle/index.ts | 89 ++ packages/utils/src/touch-emulator/index.ts | 124 +++ packages/utils/src/touch/index.ts | 51 + packages/utils/src/tree-model/index.ts | 5 + packages/utils/src/tree-model/node.ts | 627 ++++++++++++ packages/utils/src/tree-model/tree-store.ts | 414 ++++++++ packages/utils/src/tree-model/util.ts | 33 + packages/utils/src/type/index.ts | 166 ++++ packages/utils/src/upload-ajax/index.ts | 113 +++ packages/utils/src/validate/index.ts | 20 + packages/utils/src/validate/messages.ts | 73 ++ packages/utils/src/validate/rules/enum.ts | 23 + packages/utils/src/validate/rules/index.ts | 27 + packages/utils/src/validate/rules/pattern.ts | 31 + packages/utils/src/validate/rules/range.ts | 68 ++ packages/utils/src/validate/rules/required.ts | 20 + packages/utils/src/validate/rules/type.ts | 128 +++ .../utils/src/validate/rules/whitespace.ts | 19 + packages/utils/src/validate/schema.ts | 398 ++++++++ packages/utils/src/validate/util.ts | 291 ++++++ .../utils/src/validate/validations/array.ts | 35 + .../utils/src/validate/validations/date.ts | 47 + .../utils/src/validate/validations/enum.ts | 36 + .../utils/src/validate/validations/float.ts | 35 + .../utils/src/validate/validations/index.ts | 55 ++ .../utils/src/validate/validations/integer.ts | 35 + .../utils/src/validate/validations/method.ts | 34 + .../utils/src/validate/validations/number.ts | 39 + .../utils/src/validate/validations/pattern.ts | 34 + .../src/validate/validations/required.ts | 21 + .../utils/src/validate/validations/string.ts | 47 + .../utils/src/validate/validations/type.ts | 42 + packages/utils/src/window.ts | 9 - packages/vue-directive/package.json | 17 +- packages/vue-directive/src/clickoutside.ts | 117 +++ packages/vue-directive/src/infinite-scroll.ts | 223 +++++ .../vue-directive/src/observe-visibility.ts | 129 +++ packages/vue-directive/src/repeat-click.ts | 45 + packages/vue-hooks/package.json | 13 +- packages/vue-hooks/src/useEventListener.ts | 65 ++ packages/vue-hooks/src/useInstanceSlots.ts | 29 + packages/vue-hooks/src/useRect.ts | 25 + packages/vue-hooks/src/useRelation.ts | 130 +++ packages/vue-hooks/src/useTouch.ts | 74 ++ packages/vue-hooks/src/useUserAgent.ts | 18 + packages/vue-hooks/src/useWindowSize.ts | 25 + packages/vue-hooks/src/vue-emitter.ts | 48 + packages/vue-hooks/src/vue-popper.ts | 256 +++++ packages/vue-hooks/src/vue-popup.ts | 196 ++++ 87 files changed, 12557 insertions(+), 31 deletions(-) create mode 100644 packages/utils/src/after-leave/index.ts create mode 100644 packages/utils/src/array/index.ts create mode 100644 packages/utils/src/bigInt/index.ts create mode 100644 packages/utils/src/browser/index.ts create mode 100644 packages/utils/src/calendar/index.ts create mode 100644 packages/utils/src/common/index.ts create mode 100644 packages/utils/src/dataset/index.ts create mode 100644 packages/utils/src/date-util/index.ts create mode 100644 packages/utils/src/date/index.ts create mode 100644 packages/utils/src/debounce/index.ts create mode 100644 packages/utils/src/decimal/index.ts create mode 100644 packages/utils/src/dom/index.ts create mode 100644 packages/utils/src/espace-ctrl/index.ts create mode 100644 packages/utils/src/event/index.ts create mode 100644 packages/utils/src/fastdom/async.ts create mode 100644 packages/utils/src/fastdom/index.ts create mode 100644 packages/utils/src/fastdom/sandbox.ts create mode 100644 packages/utils/src/fastdom/singleton.ts create mode 100644 packages/utils/src/fecha/index.ts create mode 100644 packages/utils/src/form/index.ts create mode 100644 packages/utils/src/fullscreen/apis.ts create mode 100644 packages/utils/src/fullscreen/index.ts create mode 100644 packages/utils/src/fullscreen/screenfull.ts create mode 100644 packages/utils/src/function/index.ts create mode 100644 packages/utils/src/globalConfig/index.ts create mode 100644 packages/utils/src/memorize/index.ts create mode 100644 packages/utils/src/object/index.ts create mode 100644 packages/utils/src/popper/index.ts create mode 100644 packages/utils/src/popup-manager/index.ts create mode 100644 packages/utils/src/prop-util/index.ts create mode 100644 packages/utils/src/resize-event/index.ts create mode 100644 packages/utils/src/resize-observer/index.ts create mode 100644 packages/utils/src/scroll-into-view/index.ts create mode 100644 packages/utils/src/scroll-width/index.ts create mode 100644 packages/utils/src/string/index.ts create mode 100644 packages/utils/src/throttle/index.ts create mode 100644 packages/utils/src/touch-emulator/index.ts create mode 100644 packages/utils/src/touch/index.ts create mode 100644 packages/utils/src/tree-model/index.ts create mode 100644 packages/utils/src/tree-model/node.ts create mode 100644 packages/utils/src/tree-model/tree-store.ts create mode 100644 packages/utils/src/tree-model/util.ts create mode 100644 packages/utils/src/type/index.ts create mode 100644 packages/utils/src/upload-ajax/index.ts create mode 100644 packages/utils/src/validate/index.ts create mode 100644 packages/utils/src/validate/messages.ts create mode 100644 packages/utils/src/validate/rules/enum.ts create mode 100644 packages/utils/src/validate/rules/index.ts create mode 100644 packages/utils/src/validate/rules/pattern.ts create mode 100644 packages/utils/src/validate/rules/range.ts create mode 100644 packages/utils/src/validate/rules/required.ts create mode 100644 packages/utils/src/validate/rules/type.ts create mode 100644 packages/utils/src/validate/rules/whitespace.ts create mode 100644 packages/utils/src/validate/schema.ts create mode 100644 packages/utils/src/validate/util.ts create mode 100644 packages/utils/src/validate/validations/array.ts create mode 100644 packages/utils/src/validate/validations/date.ts create mode 100644 packages/utils/src/validate/validations/enum.ts create mode 100644 packages/utils/src/validate/validations/float.ts create mode 100644 packages/utils/src/validate/validations/index.ts create mode 100644 packages/utils/src/validate/validations/integer.ts create mode 100644 packages/utils/src/validate/validations/method.ts create mode 100644 packages/utils/src/validate/validations/number.ts create mode 100644 packages/utils/src/validate/validations/pattern.ts create mode 100644 packages/utils/src/validate/validations/required.ts create mode 100644 packages/utils/src/validate/validations/string.ts create mode 100644 packages/utils/src/validate/validations/type.ts delete mode 100644 packages/utils/src/window.ts create mode 100644 packages/vue-directive/src/clickoutside.ts create mode 100644 packages/vue-directive/src/infinite-scroll.ts create mode 100644 packages/vue-directive/src/observe-visibility.ts create mode 100644 packages/vue-directive/src/repeat-click.ts create mode 100644 packages/vue-hooks/src/useEventListener.ts create mode 100644 packages/vue-hooks/src/useInstanceSlots.ts create mode 100644 packages/vue-hooks/src/useRect.ts create mode 100644 packages/vue-hooks/src/useRelation.ts create mode 100644 packages/vue-hooks/src/useTouch.ts create mode 100644 packages/vue-hooks/src/useUserAgent.ts create mode 100644 packages/vue-hooks/src/useWindowSize.ts create mode 100644 packages/vue-hooks/src/vue-emitter.ts create mode 100644 packages/vue-hooks/src/vue-popper.ts create mode 100644 packages/vue-hooks/src/vue-popup.ts diff --git a/packages/utils/src/after-leave/index.ts b/packages/utils/src/after-leave/index.ts new file mode 100644 index 0000000000..692d99fed3 --- /dev/null +++ b/packages/utils/src/after-leave/index.ts @@ -0,0 +1,44 @@ +/* eslint-disable prefer-rest-params */ +/* eslint-disable prefer-spread */ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +const AfterLave = 'after-leave' +const Speed = 300 + +export function afterLeave(instance, callback, speed = Speed, once = false) { + if (!instance || !callback) { + throw new Error('instance & callback is required') + } + + let called = false + + const eventCallback = function () { + if (called) { + return + } + + called = true + + if (typeof callback === 'function') { + callback.apply(null, arguments) + } + } + + if (once) { + instance.$once(AfterLave, eventCallback) + } else { + instance.$on(AfterLave, eventCallback) + } + + setTimeout(eventCallback, speed + 100) +} diff --git a/packages/utils/src/array/index.ts b/packages/utils/src/array/index.ts new file mode 100644 index 0000000000..1b74476881 --- /dev/null +++ b/packages/utils/src/array/index.ts @@ -0,0 +1,240 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { SORT } from '../common' +import { isSame } from '../type' +import { getObj } from '../object' + +/** + * 返回在数组中可以找到一个给定元素的第一个索引,如果不存在,则返回-1。 TINY_NO_NEED 现在数组有 findIndex + * 修复数组原生的 indexOf 方法不能判断 NaN 的问题 + * + * let arr1 = [1, 2, 3, 4] + * let arr2 = [1, 2, NaN, 4] + * indexOf(arr1, 2) // 1 + * indexOf(arr2, NaN) // 2 + * + * @param {Array} arr 要查找的数组 + * @param {Object} data 需要查找的数据 + * @param {Function} [predicate] 断言函数,缺省为 isSame, 两个参数为数组的元素和查找的数据 + * @returns {Number} + */ +export const indexOf = (arr, data, predicate = isSame) => { + if (Array.isArray(arr) && typeof predicate === 'function') { + for (let i = 0, len = arr.length; i < len; i++) { + if (predicate(arr[i], data)) { + return i + } + } + } + + return -1 +} + +/** + * 在数组里查找对象,调用自定义的断言函数。 + * + * let arr = [1, 2, 3, 4] + * find(arr, function (value) { return value > 2 }) // 3 + * + * @param {Array} arr 要查找的数组 + * @param {Function} predicate 断言函数 + * @returns {Object} + */ +export const find = (arr, predicate) => { + const index = indexOf(arr, undefined, predicate) + return index !== -1 ? arr[index] : undefined +} + +/** + * 从数组中删除指定元素,并返回该数组。 + * + * let arr1 = [1, 2, 3, 4] + * let arr2 = [1, 2, NaN, 4] + * remove(arr1, 2, 2) // [1, 4] + * remove(arr2, NaN) // [1, 2, 4] + * + * @param {Array} arr 源数组 + * @param {Object} data 需要删除的数据 + * @param {Number} count 删除元素个数,默认为 1 + * @returns {Array} + */ +export const remove = (arr, data, count = 1) => { + if (Array.isArray(arr) && arr.length) { + const index = indexOf(arr, data) + if (index > -1) { + arr.splice(index, count) + } + } + + return arr +} + +/** + * 对象数组自定义排序,并返回该数组。 + * + * sort([ {a:100}, {a:1}, {a:NaN}, {a:10} ], 'a') // [ {a:1}, {a:10}, {a:100}, {a:NaN} ] + * sort([ {a:100}, {a:1}, {a:NaN}, {a:10} ], 'a','desc') // [ {a:100}, {a:10}, {a:1}, {a:NaN} ] + * + * @param {Array} arr 需要排序的对象数组 + * @param {string} field 要排序的对象字段 + * @param {String} sort 排序方向,取值为 "asc" 或 "desc" + * @returns {Array} 排好序的对象数组 + */ +export const sort = (arr, field, sort = SORT.Asc) => { + if (Array.isArray(arr) && arr.length > 1) { + arr.sort((x, y) => { + const compare = sort === SORT.Asc ? [1, -1] : [-1, 1] + const xField = getObj(x, field) + const yField = getObj(y, field) + + if (isNaN(xField)) { + return sort === SORT.Asc ? 1 : -1 + } else if (isNaN(yField)) { + return -1 + } + + return xField > yField ? compare[0] : compare[1] + }) + } + + return arr +} + +/** + * 向数组中添加不重复的数据,并返回该数组。 + * + * let arr = [ 1, 2, NaN, 4] + * push(arr, 1) // [ 1, 2, NaN, 4] + * push(arr, NaN) // [ 1, 2, NaN, 4] + * push(arr, 5) // [ 1, 2, NaN, 4, 5] + * + * @param {Array} arr 源数组 + * @param {Object} data 需要增加的数据 + * @returns {Array} + */ +export const push = (arr, data) => { + if (Array.isArray(arr) && !arr.some((value) => isSame(value, data))) { + arr.push(data) + } + + return arr +} + +/** + * 去除数组中的重复的值,并返回新数组。 + * + * let arr = [ 1, NaN, 2, NaN, 2, 3, 4] + * unique(arr) // [ 1, NaN, 2, 3, 4] + * + * @param {Array} arr + * @returns {Array} + */ +export const unique = (arr) => { + if (Array.isArray(arr)) { + const array = [] + + for (let i = 0, len = arr.length; i < len; i++) { + const value = arr[i] + if (indexOf(array, value) === -1) { + array.push(value) + } + } + + return array + } + + return arr +} + +const extend = (to, _from) => { + Object.keys(_from).forEach((key) => (to[key] = _from[key])) + + return to +} + +/** + * 数组转对象 + * + * let arr = [ { key1: value1 }, { key2: value2 } ] + * toObject(arr) // { key1: value1, key2: value2 } + * + * @param {Array} arr + * @returns {Object} + */ +export const toObject = (arr) => { + const res = {} + + for (let i = 0; i < arr.length; i++) { + if (arr[i]) { + extend(res, arr[i]) + } + } + + return res +} + +/** + * 将 id 与 pid 构成的扁平数据转换成 children 的树状数据 + * + * let data = [{ id: 100, pId: 0, label: '首页'}, { id: 101, pId: 100, label: '指南'}] + * transformPidToChildren(data) // [ 0: { id: 100, label: "首页", children: [ 0: { id: 101, label: "指南" } ] } ] + * + * @param {Array} data id 与 pid 构成的扁平数据的数组 + * @param {String} [pidName] pid 的属性名,缺省为 pId + * @param {String} [childrenName] children 的属性名,缺省为 children + * @param {String} [idName] id 的属性名,缺省为 id + * @returns {Array} + */ +export const transformPidToChildren = (data, pidName = 'pId', childrenName = 'children', idName = 'id') => { + const result = [] + + Array.isArray(data) && + data.forEach((item) => { + if (item[pidName] === '0') { + result.push(item) + } else { + const parent = find(data, (i) => i[idName] === item[pidName]) + + if (!parent) { + return + } + + if (!parent[childrenName]) { + parent[childrenName] = [] + } + + parent[childrenName].push(item) + } + + delete item[pidName] + }) + + return result +} + +/** + * 将pid标识的普通数组转换树结构数据 + * @param {*} data + * @param {*} key + * @param {*} parentKey + */ +export const transformTreeData = (data, key = 'id', parentKey = 'pId') => { + if (!Array.isArray(data)) { + data = [data] + } + + data = data.map((item) => ({ ...item })) + + const treeData = transformPidToChildren(data, parentKey, 'children', key) + return treeData +} diff --git a/packages/utils/src/bigInt/index.ts b/packages/utils/src/bigInt/index.ts new file mode 100644 index 0000000000..6def08f417 --- /dev/null +++ b/packages/utils/src/bigInt/index.ts @@ -0,0 +1,415 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { fillChar } from '../string' +import { isBrowser } from '../browser' + +const BigInt = isBrowser ? window.BigInt : global.BigInt + +export function supportBigInt() { + return typeof BigInt === 'function' +} + +export function trimNumber(numStr) { + let string = numStr.toString().trim() + let negative = string.startsWith('-') + + if (negative) { + string = string.slice(1) + } + + string = string // Remove decimal 0. `1.000` => `1.`, `1.100` => `1.1` + .replace(/(\.\d*[^0])0*$/, '$1') // Remove useless decimal. `1.` => `1` + .replace(/\.0*$/, '') // Remove integer 0. `0001` => `1`, 000.1' => `.1` + .replace(/^0+/, '') + + if (string.startsWith('.')) { + string = '0'.concat(string) + } + + let trimStr = string || '0' + let splitNumber = trimStr.split('.') + let integerStr = splitNumber[0] || '0' + let decimalStr = splitNumber[1] || '0' + + if (integerStr === '0' && decimalStr === '0') { + negative = false + } + + let negativeStr = negative ? '-' : '' + + return { + negative, + negativeStr, + trimStr, + integerStr, + decimalStr, + fullStr: ''.concat(negativeStr).concat(trimStr) + } +} + +export function isE(number) { + let str = String(number) + return !isNaN(Number(str)) && ~str.indexOf('e') +} + +export function validateNumber(num) { + if (typeof num === 'number') { + return !isNaN(num) + } // Empty + + if (!num) { + return false + } + + return ( + // Normal type: 11.28 + /^\s*-?\d+(\.\d+)?\s*$/.test(num) || // Pre-number: 1. + /^\s*-?\d+\.\s*$/.test(num) || // Post-number: .1 + /^\s*-?\.\d+\s*$/.test(num) + ) +} + +/** + * [Legacy] Convert 1e-9 to 0.000000001. + * This may lose some precision if user really want 1e-9. + */ + +export function getNumberPrecision(number) { + let numStr = String(number) + + if (isE(number)) { + let precision = Number(numStr.slice(numStr.indexOf('e-') + 2)) + let decimalMatch = numStr.match(/\.(\d+)/) + + if (decimalMatch === null || decimalMatch === undefined ? undefined : decimalMatch[1]) { + precision += decimalMatch[1].length + } + + return precision + } + + return ~numStr.indexOf('.') && validateNumber(numStr) ? numStr.length - numStr.indexOf('.') - 1 : 0 +} + +/** + * Convert number (includes scientific notation) to -xxx.yyy format + */ + +export function num2str(number) { + let numStr = String(number) + + if (isE(number)) { + if (number > Number.MAX_SAFE_INTEGER) { + return String(supportBigInt() ? BigInt(number).toString() : Number.MAX_SAFE_INTEGER) + } + + if (number < Number.MIN_SAFE_INTEGER) { + return String(supportBigInt() ? BigInt(number).toString() : Number.MIN_SAFE_INTEGER) + } + + numStr = number.toFixed(getNumberPrecision(numStr)) + } + + return trimNumber(numStr).fullStr +} + +function pluginDecimal(decimal) { + if (!decimal.add) { + Object.assign(decimal, { + add: decimal.plus, + lessEquals: decimal.isLessThan, + equals: decimal.isEqualTo + }) + } + + return decimal +} + +const DecimalCls = { + CLS: null +} + +export function getMiniDecimal(value, decimal) { + // We use BigInt here. Will fallback to Number if not support. + if (!DecimalCls.CLS) { + setDecimalClass(decimal) + } + + return pluginDecimal(new DecimalCls.CLS(value)) +} + +export class BigIntDecimal { + constructor(value) { + if ((!value && value !== 0) || !String(value).trim()) { + this.empty = true + return + } + + this.origin = String(value) + this.negative = undefined + this.integer = undefined + this.decimal = undefined + this.decimalLen = undefined + this.empty = undefined + this.nan = undefined + + if (value === '-') { + this.nan = true + + return + } + + let mergedValue = isE(value) ? Number(value) : value + + if (typeof mergedValue !== 'string') { + num2str(mergedValue) + } + const f = Function + const convertBigInt = (str) => { + // 将以多个零开头的整数前置零清空 '0000000000000003e+21' --> '3e+21' ,解决BigInt(0000000000000003e+21)报错问题 + const validStr = str.replace(/^0+/, '') || '0' + return f(`return BigInt('${validStr}')`)() + } + if (validateNumber(mergedValue)) { + const trimRet = trimNumber(mergedValue) + this.negative = trimRet.negative + const numbers = trimRet.trimStr.split('.') + this.integer = !numbers[0].includes('e') ? BigInt(numbers[0]) : numbers[0] + const decimalStr = numbers[1] || '0' + + // 如果小数点后有科学计数法,需要特殊处理,如果是正常数字则保留之前逻辑 + this.decimal = decimalStr.includes('e') ? convertBigInt(decimalStr) : BigInt(decimalStr) + this.decimalLen = decimalStr.length + } else { + this.nan = true + } + } + + getDecimalStr() { + return this.decimal.toString().padStart(this.decimalLen, '0') + } + + getIntegerStr() { + return this.integer.toString() + } + + getMark() { + return this.negative ? '-' : '' + } + + /** + * Align BigIntDecimal with same decimal length. e.g. 12.3 + 5 = 1230000 + * This is used for add function only. + */ + alignDecimal(decimalLength) { + const string = `${this.getMark()}${this.getIntegerStr()}${this.getDecimalStr().padEnd(decimalLength, '0')}` + + return BigInt(string) + } + + add(value) { + if (this.isInvalidate()) { + return new BigIntDecimal(value) + } + + const offsetObj = new BigIntDecimal(value) + if (offsetObj.isInvalidate()) { + return this + } + + const maxDecimalLength = Math.max(this.getDecimalStr().length, offsetObj.getDecimalStr().length) + const offsetAlignedDecimal = offsetObj.alignDecimal(maxDecimalLength) + const myAlignedDecimal = this.alignDecimal(maxDecimalLength) + + const valueStr = `${myAlignedDecimal + offsetAlignedDecimal}` + + const { negativeStr: str, trimStr } = trimNumber(valueStr) + const hydrateValueStr = `${str}${trimStr.padStart(maxDecimalLength + 1, '0')}` + + return getMiniDecimal(`${hydrateValueStr.slice(0, -maxDecimalLength)}.${hydrateValueStr.slice(-maxDecimalLength)}`) + } + + negate() { + const clone = new BigIntDecimal(this.toString()) + clone.negative = !clone.negative + + return clone + } + + isNaN() { + return this.nan + } + + isEmpty() { + return this.empty + } + + isInvalidate() { + return this.isEmpty() || this.isNaN() + } + + lessEquals(target) { + return this.add(target.negate().toString()).toNumber() <= 0 + } + + equals(target) { + return this.toString() === (target && target.toString()) + } + + toNumber() { + if (this.isNaN()) { + return NaN + } + + return Number(this.toString()) + } + + toString(safe = true) { + if (!safe) { + return this.origin + } + + if (this.isInvalidate()) { + return '' + } + + return trimNumber(`${this.getMark()}${this.getIntegerStr()}.${this.getDecimalStr()}`).fullStr + } +} + +export class NumberDecimal { + constructor(value = '') { + if ((!value && value !== 0) || !String(value).trim()) { + this.empty = true + return + } + this.origin = '' + this.number = undefined + this.empty = undefined + + this.origin = String(value) + this.number = Number(value) + } + + negate() { + return new NumberDecimal(-this.toNumber()) + } + + add(value) { + if (this.isInvalidate()) { + return new NumberDecimal(value) + } + + const target = Number(value) + + if (isNaN(target)) { + return this + } + + const number = this.number + target + + if (number < Number.MIN_SAFE_INTEGER) { + return new NumberDecimal(Number.MIN_SAFE_INTEGER) + } + + if (number > Number.MAX_SAFE_INTEGER) { + return new NumberDecimal(Number.MAX_SAFE_INTEGER) + } + + const maxPrecision = Math.max(getNumberPrecision(target), getNumberPrecision(this.number)) + return new NumberDecimal(number.toFixed(maxPrecision)) + } + + isNaN() { + return isNaN(this.number) + } + + isEmpty() { + return this.empty + } + + isInvalidate() { + return this.isEmpty() || this.isNaN() + } + + equals(target) { + return this.toNumber() === (target && target.toNumber()) + } + + lessEquals(target) { + return this.add(target.negate().toString()).toNumber() <= 0 + } + + toNumber() { + return this.number + } + + toString(safe = true) { + if (!safe) { + return this.origin + } + + if (this.isInvalidate()) { + return '' + } + + return num2str(this.number) + } +} + +export const setDecimalClass = function (decimaljs) { + DecimalCls.CLS = supportBigInt() ? BigIntDecimal : typeof decimaljs === 'function' ? decimaljs : NumberDecimal +} + +export function lessEquals(value1, value2) { + return getMiniDecimal(value1).lessEquals(getMiniDecimal(value2)) +} + +export function equalsDecimal(value1, value2) { + return getMiniDecimal(value1).equals(getMiniDecimal(value2)) +} + +export function toFixed(numStr, precision, rounding = 5) { + if (numStr === '') { + return '' + } + const separatorStr = '.' + const { negativeStr, integerStr, decimalStr } = trimNumber(numStr) + const precisionDecimalStr = `${separatorStr}${decimalStr}` + const numberWithoutDecimal = `${negativeStr}${integerStr}` + + if (precision >= 0) { + // We will get last + 1 number to check if need advanced number + const advancedNum = Number(decimalStr[precision]) + + if (advancedNum >= rounding && rounding !== 0) { + const advancedDecimal = getMiniDecimal(`${integerStr}${separatorStr}${decimalStr}`).add( + `0.${fillChar('', precision, true)}${10 - advancedNum}` + ) + + return toFixed(negativeStr + advancedDecimal.toString(), precision, 0) + } + + if (precision === 0) { + return numberWithoutDecimal + } + + return `${numberWithoutDecimal}${separatorStr}${fillChar(decimalStr, precision, true).slice(0, precision)}` + } + + if (precisionDecimalStr === '.0') { + return numberWithoutDecimal + } + + return `${numberWithoutDecimal}${precisionDecimalStr}` +} diff --git a/packages/utils/src/browser/index.ts b/packages/utils/src/browser/index.ts new file mode 100644 index 0000000000..e8e8654690 --- /dev/null +++ b/packages/utils/src/browser/index.ts @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +const getIEVersion = () => { + let version = 8 + if (!!document.addEventListener && !!window.performance) { + version = 9 + if (!!window.atob && !!window.matchMedia) { + version = 10 + if (!window.attachEvent && !document.all) { + version = 11 + } + } + } + return version +} + +const isEdge = (browser) => { + if (browser.chrome && ~navigator.userAgent.indexOf('Edg')) { + browser.name = 'edge' + browser.edge = true + delete browser.chrome + } else if (!document.documentMode && !!window.StyleMedia) { + browser.name = 'edge' + browser.edge = true + } +} + +export const isBrowser = + typeof window !== 'undefined' && typeof document !== 'undefined' && window.document === document + +export const globalEnvironment = isBrowser ? window : global + +export const browser = (() => { + const browser = { + name: undefined, + version: undefined, + isDoc: typeof document !== 'undefined', + isMobile: false, + isPC: true, + isNode: typeof window === 'undefined' + } + + if (isBrowser) { + const isMobile = /(Android|webOS|iPhone|iPad|iPod|SymbianOS|BlackBerry|Windows Phone)/.test(navigator.userAgent) + + browser.isMobile = isMobile + browser.isPC = !isMobile + + let matches + + if (!!window.chrome && (!!window.chrome.webstore || /^Google\b/.test(window.navigator.vendor))) { + browser.name = 'chrome' + browser.chrome = true + matches = navigator.userAgent.match(/chrome\/(\d+)/i) + browser.version = !!matches && !!matches[1] && parseInt(matches[1], 10) + matches = undefined + } else if (!!document.all || !!document.documentMode) { + browser.name = 'ie' + browser.version = getIEVersion() + browser.ie = true + } else if (typeof window.InstallTrigger !== 'undefined') { + browser.name = 'firefox' + browser.firefox = true + } else if (Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0) { + browser.name = 'safari' + browser.safari = true + } else if ((!!window.opr && !!window.opr.addons) || !!window.opera) { + browser.name = 'opera' + browser.opera = true + } + + isEdge(browser) + + if (!~['ie', 'chrome'].indexOf(browser.name)) { + const reg = browser.name + '/(\\d+)' + matches = navigator.userAgent.match(new RegExp(reg, 'i')) + browser.version = !!matches && !!matches[1] && parseInt(matches[1], 10) + matches = undefined + } + + if (browser.isDoc) { + const bodyEl = document.body || document.documentElement + ;['webkit', 'khtml', 'moz', 'ms', 'o'].forEach((core) => { + browser['-' + core] = !!bodyEl[core + 'MatchesSelector'] + }) + } + } + + return browser +})() diff --git a/packages/utils/src/calendar/index.ts b/packages/utils/src/calendar/index.ts new file mode 100644 index 0000000000..de66fe255b --- /dev/null +++ b/packages/utils/src/calendar/index.ts @@ -0,0 +1,158 @@ +import { isLeapYear } from '../date' + +/** + * 获取指定年月的总天数 + * @method + * @param {Number} year - 年 + * @param {Number} month - 月 + * @returns {Number} - 总天数 + */ +export const getDays = (year, month) => { + return [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] +} + +/** + * 根据日期获取星期 + * @method + * @param {Number} year - 年 + * @param {Number} month - 月 + * @param {Number} month - 日 + * @returns {Number} - 星期 + */ +export const getWeek = (year, month, day) => new Date(`${year}/${month}/${day}`).getDay() + +/** + * 上月日期 + * @method + * @param {Number} year - 年 + * @param {Number} month - 月 + * @returns {Object} - 年月 + */ +export const lastMonth = (year, month) => { + // 年月转换成整型 + year = +year + month = +month + + if (month === 1) { + year-- + month = 12 + } else { + month-- + } + + return { year, month } +} + +/** + * 下月日期 + * @method + * @param {Number} year - 年 + * @param {Number} month - 月 + * @returns {Object} - 年月 + */ +export const nextMonth = (year, month) => { + // 年月转换成整型 + year = +year + month = +month + + if (month === 12) { + year++ + month = 1 + } else { + month++ + } + + return { year, month } +} + +/** + * 获取日历数据 + * @method + * @param {Number} year - 年 + * @param {Number} month - 月 + * @returns {Object} - 日历 + */ +export const getCalendar = (year, month) => { + if (year && month && month <= 12) { + const days = getDays(year, month) + const firstWeek = getWeek(year, month, 1) + const lastWeek = getWeek(year, month, days) + const last = lastMonth(year, month) + const next = nextMonth(year, month) + const lastDays = getDays(last.year, last.month) + + let remainDays = 0 + const totalDays = days + firstWeek + 7 - lastWeek - 1 + + // 补齐日期不足6行的(日期固定为6行) + if (totalDays / 7 < 6 && totalDays / 7 >= 5) { + remainDays = 6 * 7 - totalDays + } + + return { + last: { + year: last.year, + month: last.month, + start: lastDays - (firstWeek - 1), + end: lastDays + }, + current: { + year, + month, + start: 1, + end: days + }, + next: { + year: next.year, + month: next.month, + start: 1, + end: 7 - lastWeek - 1 + remainDays + } + } + } +} + +/** + * 将一维数组转换成 7*N 的二维数组 + * @method + * @param {Array} array - 一维数据 + * @returns {Array} - 7*N 日历数据 + */ +export const transformArray = (array) => { + const result = [] + let index = 0 + + if (array && array.length) { + const length = array.length / 7 + + for (let i = 0; i < length; i++) { + result[i] = [] + + for (let j = 0; j < 7; j++) { + result[i][j] = array[index++] + } + } + } + + return result +} + +/** + * 时间转换成年月日时分秒 + * @method + * @param {Number | String} time - 时间戳或标准的日期字符 + * @returns {Object} - 年月日时分秒 + */ +export const parseDate = (time) => { + /* istanbul ignore next */ + const date = new Date(time && typeof time === 'number' ? time : 0) + + return { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + hours: date.getHours(), + minutes: date.getMinutes(), + seconds: date.getSeconds() + } +} diff --git a/packages/utils/src/common/index.ts b/packages/utils/src/common/index.ts new file mode 100644 index 0000000000..9e1f5a68ce --- /dev/null +++ b/packages/utils/src/common/index.ts @@ -0,0 +1,264 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +export const KEY_CODE = { + Backspace: 8, + Tab: 9, + Clear: 12, + Enter: 13, + Shift: 16, + Control: 17, + Alt: 18, + CapsLock: 20, + Escape: 27, + Space: 32, + PageUp: 33, + PageDown: 34, + End: 35, + Home: 36, + 'ArrowLeft': 37, + 'ArrowUp': 38, + 'ArrowRight': 39, + 'ArrowDown': 40, + Insert: 45, + Delete: 46, + Colon: 58, + Semicolon: 59, + LessThan: 60, + Equals: 61, + GreaterThan: 62, + QuestionMark: 63, + AtMark: 64, + KeyA: 65, + KeyB: 66, + KeyC: 67, + KeyD: 68, + 'KeyE': 69, + 'KeyF': 70, + 'KeyG': 71, + 'KeyH': 72, + KeyI: 73, + KeyJ: 74, + KeyK: 75, + KeyL: 76, + KeyM: 77, + KeyN: 78, + KeyO: 79, + KeyP: 80, + KeyQ: 81, + 'KeyR': 82, + 'KeyS': 83, + 'KeyT': 84, + 'KeyU': 85, + KeyV: 86, + KeyW: 87, + KeyX: 88, + KeyY: 89, + KeyZ: 90, + 'Digit0': 48, + 'Digit1': 49, + 'Digit2': 50, + 'Digit3': 51, + Digit4: 52, + Digit5: 53, + Digit6: 54, + Digit7: 55, + Digit8: 56, + Digit9: 57, + 'F1': 112, + 'F2': 113, + 'F3': 114, + 'F4': 115, + F5: 116, + F6: 117, + F7: 118, + F8: 119, + F9: 120, + F10: 121, + F11: 122, + F12: 123, + 'NumLock': 144, + 'Numpad0': 96, + 'Numpad1': 97, + 'Numpad2': 98, + Numpad3: 99, + Numpad4: 100, + Numpad5: 101, + Numpad6: 102, + Numpad7: 103, + Numpad8: 104, + Numpad9: 105, + 'NumpadMultiply': 106, + 'NumpadAdd': 107, + 'NumpadEnter': 13, + 'NumpadSubtract': 109, + NumpadDecimal: 110, + NumpadDivide: 111, + NumpadComma: 190 +} + +export const POSITION = { Left: 'left', Right: 'right', Top: 'top', Bottom: 'bottom' } + +// 只使用在 array.ts中, 待移除 +export const SORT = { Asc: 'asc', Desc: 'desc' } + +export const REFRESH_INTERVAL = 100 + +export const IPTHRESHOLD = { Min: 0, Max: 255, NonNumeric: 25 } + +export const DATE = { + FullDatetime: 'yyyy-MM-dd hh:mm:ss.SSS', + LongDatetime: 'yyyy-MM-dd hh:mm:ss', + Datetime: 'yyyy-MM-dd hh:mm', + Date: 'yyyy-MM-dd', + FullTime: 'hh:mm:ss.SSS', + LongTime: 'hh:mm:ss', + Time: 'hh:mm', + YearMonth: 'yyyy-MM' +} + +const TriggerTypes = + 'date,datetime,time,time-select,week,month,year,years,yearrange,daterange,monthrange,timerange,datetimerange,dates,quarter' + +export const DATEPICKER = { + Day: 'day', + Date: 'date', + Dates: 'dates', + Year: 'year', + Years: 'years', + YearRange: 'yearrange', + PanelYearNum: 12, + Month: 'month', + Week: 'week', + Normal: 'normal', + Today: 'today', + PreMonth: 'pre-month', + NextMonth: 'next-month', + YearI18n: 'ui.datepicker.year', + List: [38, 40, 37, 39], + YearObj: { 38: -4, 40: 4, 37: -1, 39: 1 }, + WeekObj: { 38: -1, 40: 1, 37: -1, 39: 1 }, + DayObj: { 38: -7, 40: 7, 37: -1, 39: 1 }, + Aviailable: 'available', + Default: 'default', + Current: 'current', + InRange: 'in-range', + StartDate: 'start-date', + EndDate: 'end-date', + Selected: 'selected', + Disabled: 'disabled', + Range: 'range', + fullMonths: 'January,February,March,April,May,June,July,August,September,October,November,December'.split(','), + fullWeeks: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], + MonhtList: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'], + Weeks: ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'], + PlacementMap: { + left: 'bottom-start', + center: 'bottom', + right: 'bottom-end' + }, + QuarterMap: { + 0: 0, + 1: 3, + 2: 6, + 3: 9 + }, + MonthQuarterMap: { + 0: 1, + 3: 2, + 6: 3, + 9: 4 + }, + TriggerTypes: TriggerTypes.split(','), + DateFormats: { + year: 'yyyy', + years: 'yyyy', + yearrange: 'yyyy', + month: 'yyyy-MM', + time: 'HH:mm:ss', + week: 'yyyywWW', + date: 'yyyy-MM-dd', + timerange: 'HH:mm:ss', + monthrange: 'yyyy-MM', + daterange: 'yyyy-MM-dd', + datetime: 'yyyy-MM-dd HH:mm:ss', + datetimerange: 'yyyy-MM-dd HH:mm:ss' + }, + Time: 'time', + TimeRange: 'timerange', + Quarter: 'quarter', + IconTime: 'icon-time', + IconDate: 'icon-Calendar', + DateRange: 'daterange', + DateTimeRange: 'datetimerange', + MonthRange: 'monthrange', + TimeSelect: 'time-select', + TimesTamp: 'timestamp', + DateTime: 'datetime', + SelectbaleRange: 'selectableRange', + Start: '09:00', + End: '18:00', + Step: '00:30', + CompareOne: '-1:-1', + CompareHundred: '100:100', + selClass: '.selected', + queryClass: '.tiny-picker-panel__content', + disableClass: '.time-select-item:not(.disabled)', + defaultClass: '.default', + Qurtyli: '[data-tag="li"]', + MappingKeyCode: { 40: 1, 38: -1 }, + DatePicker: 'DatePicker', + TimePicker: 'TimePicker' +} + +export const BROWSER_NAME = { + IE: 'ie', + Edge: 'edge', + Chrome: 'chrome', + Firefox: 'firefox' +} + +export const MOUSEDELTA = 120 + +export const VALIDATE_STATE = { + Validating: 'validating', + Success: 'success', + Error: 'error' +} + +export const CASCADER = { + SvgStr: ' { + const filterArr = {} + + Object.keys(filters).forEach((property) => { + const { type, value } = filters[property] + + if (type === 'enum') { + filterArr[property] = { type: value.map(() => 0), value } + + if (value.length > 1) { + filters[property].relation = 'or' + } + } + + if (type === 'input') { + const { relation, text } = value + + filterArr[property] = { + type: [relation === 'startwith' ? 8 : relation === 'equals' ? 0 : 6], + value: text + } + } + }) + + return JSON.stringify(filterArr) +} + +/** + * 根据命名空间取对象的值 + * + * @param {*} obj + * @param {*} names + */ +const getNsObj = (obj, names) => { + const arr = Array.isArray(names) ? names : names.split('.') + const curkey = arr.shift() + const curObj = obj[curkey] + + if (isObject(curObj) && arr.length) { + return getNsObj(curObj, arr) + } + + return curObj +} + +const handlerArgs = (options, args) => { + if (args) { + const { page, sort, filters } = args + const { currentPage, pageSize } = page || {} + const filterStr = getFilterStr(filters || {}) + const orderBy = sort && sort.property ? sort.property + ' ' + sort.order : '' + + options.url = format(options.url, { + curPage: currentPage, + pageSize, + filterStr, + orderBy + }) + } +} + +const transForm = (response, tree) => { + const { result, pageVO } = response + const { key = 'id', parentKey } = tree || {} + let data = result || response + + if (parentKey) { + data = transformTreeData(data, key, parentKey) + } + + return pageVO ? { result: data, page: { total: pageVO.totalRows } } : data +} + +/** + * dataset简单数据处理 + * @param {*} dataset + * @param {*} service + */ +export const getDataset = ({ dataset, service, tree }, args) => + new Promise((resolve, reject) => { + const { source, value, api } = dataset || {} + const $service = service || (dataset && dataset.service) + if (Array.isArray(dataset)) { + return resolve(transForm(dataset, tree)) + } + if (Array.isArray(value)) { + return resolve(transForm(value, tree)) + } + if (!$service) { + return resolve([]) + } + if (isObject(source) && source.url) { + const { type = 'GET', data, beforeRequest, afterRequest, success, hideErr, url, method, ...options } = source + options.url = url + options.method = method || type.toLocaleLowerCase() + const mergeTarget = options.method === 'get' ? 'params' : 'data' + options[mergeTarget] = data || {} + const afterRequestFn = afterRequest || success + const config = { ...options } + handlerArgs(config, args) + beforeRequest && beforeRequest(config, args) + $service.network + .request(config) + .then((response) => { + afterRequestFn && afterRequestFn(response.data) + resolve(transForm(response.data, tree)) + }) + .catch((error) => { + hideErr || reject(error) + }) + } else if (api) { + const fetchFn = getNsObj($service, api.name) + fetchFn && + fetchFn({ ...api.data, ...args }) + .then((response) => { + resolve(transForm(response, tree)) + }) + .catch((error) => { + reject(error) + }) + } + }) diff --git a/packages/utils/src/date-util/index.ts b/packages/utils/src/date-util/index.ts new file mode 100644 index 0000000000..49fe6ee084 --- /dev/null +++ b/packages/utils/src/date-util/index.ts @@ -0,0 +1,310 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { fecha } from '../fecha' +import { isNull } from '../type' +import { isLeapYear } from '../date' +import { DATEPICKER } from '../index' + +const weeks = DATEPICKER.Weeks +const months = DATEPICKER.MonhtList +const defaultYMD = DATEPICKER.DateFormats.date +const defaultHMS = DATEPICKER.DateFormats.time + +const newArray = (start, end) => { + let res = [] + + for (let i = start; i <= end; i++) { + res.push(i) + } + + return res +} + +export const getI18nSettings = (t) => ({ + dayNamesShort: weeks.map((week) => t(`ui.datepicker.weeks.${week}`)), + dayNames: weeks.map((week) => t(`ui.datepicker.weeks.${week}`)), + monthNamesShort: months.map((month) => t(`ui.datepicker.months.${month}`)), + monthNames: months.map((month, index) => t(`ui.datepicker.month${index + 1}`)), + amPm: ['am', 'pm'] +}) + +export const isDate = function (date) { + if (isNull(date)) { + return false + } + if (isNaN(new Date(date).getTime())) { + return false + } + if (Array.isArray(date)) { + return false + } + + return true +} + +export const toDate = (date) => (isDate(date) ? new Date(date) : null) + +export const isDateObject = (val) => val instanceof Date + +export const formatDate = (date, format, t) => { + date = toDate(date) + if (!date) { + return '' + } + + return fecha.format(date, format || defaultYMD, getI18nSettings(t)) +} + +export const parseDate = (string, format, t) => fecha.parse(string, format || defaultYMD, getI18nSettings(t)) + +export const getDayCountOfMonth = (year, month) => { + if (~[3, 5, 8, 10].indexOf(month)) { + return 30 + } + + if (month === 1) { + return isLeapYear(year) ? 29 : 28 + } + + return 31 +} + +export const getDayCountOfYear = (year) => (isLeapYear(year) ? 366 : 365) + +export const getFirstDayOfMonth = (date) => { + const temp = new Date(date.getTime()) + temp.setDate(1) + return temp.getDay() +} + +export const prevDate = (date, amount = 1) => new Date(date.getFullYear(), date.getMonth(), date.getDate() - amount) + +export const nextDate = (date, amount = 1) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount) + +export const getStartDateOfMonth = (year, month, offsetDay = 0) => { + const res = new Date(year, month, 1) + const day = res.getDay() + const _day = day === 0 ? 7 : day + + const offset = _day + offsetDay <= 0 ? 7 + _day : _day + return prevDate(res, offset) +} + +export const getWeekNumber = (src) => { + if (!isDate(src)) { + return null + } + + const date = new Date(src.getTime()) + + date.setHours(0, 0, 0, 0) + date.setDate(date.getDate() + 3 - ((date.getDay() + 6) % 7)) + + const week1 = new Date(date.getFullYear(), 0, 4) + + return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7) +} + +export const getRangeHours = (ranges = []) => { + const hours = [] + let disHours = [] + + ranges.forEach((range) => { + const value = range.map((date) => date.getHours()) + disHours = disHours.concat(newArray(value[0], value[1])) + }) + + let isDisabled + + if (disHours.length) { + isDisabled = (i) => !~disHours.indexOf(i) + } else { + isDisabled = () => false + } + + for (let i = 0; i < 24; i++) { + hours[i] = isDisabled(i) + } + + return hours +} + +const setRangeData = (arr, start, end, value) => { + for (let i = start; i < end; i++) { + arr[i] = value + } +} + +// eslint-disable-next-line prefer-spread +export const range = (length) => Array.apply(null, { length }).map((_, n) => n) + +export const getMonthDays = (date) => { + const temp = new Date(date.getFullYear(), date.getMonth() + 1, 0) + const days = temp.getDate() + + return range(days).map((_, index) => index + 1) +} + +export const getPrevMonthLastDays = (date, amount) => { + if (amount <= 0) { + return [] + } + + const timeValue = new Date(date.getTime()) + timeValue.setDate(0) + const lastDay = timeValue.getDate() + + return range(amount).map((_, index) => lastDay - (amount - index - 1)) +} + +export const getRangeMinutes = (ranges, hour) => { + const sixty = 60 + const minutes = new Array(sixty) + + if (ranges.length > 0) { + ranges.forEach((range) => { + const [startDate, endDate] = range + const startHour = startDate.getHours() + const startMinute = startDate.getMinutes() + const endHour = endDate.getHours() + const endMinute = endDate.getMinutes() + const equealStartHour = startHour === hour + + if (equealStartHour && endHour !== hour) { + setRangeData(minutes, startMinute, sixty, true) + } else if (equealStartHour && endHour === hour) { + setRangeData(minutes, startMinute, endMinute + 1, true) + } else if (!equealStartHour && endHour === hour) { + setRangeData(minutes, 0, endMinute + 1, true) + } else if (startHour < hour && endHour > hour) { + setRangeData(minutes, 0, sixty, true) + } + }) + } else { + setRangeData(minutes, 0, sixty, true) + } + + return minutes +} + +export const modifyDate = (date, y, m, d) => { + date = toDate(date) + + return new Date(y, m, d, date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()) +} + +export const modifyTime = (date, h, m, s) => { + date = toDate(date) + + return new Date(date.getFullYear(), date.getMonth(), date.getDate(), h, m, s, date.getMilliseconds()) +} + +export const modifyWithTimeString = (date, time, t) => { + if (isNull(date) || !time) { + return date + } + + time = parseDate(time, defaultHMS, t) + return modifyTime(date, time.getHours(), time.getMinutes(), time.getSeconds()) +} + +export const clearTime = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()) + +export const clearMilliseconds = (date) => + new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds(), + 0 + ) + +export const limitTimeRange = (date, ranges, format = defaultHMS) => { + if (ranges.length === 0) { + return date + } + + const normalizeDate = (date) => fecha.parse(fecha.format(date, format), format) + + const ndate = normalizeDate(date) + const nranges = ranges.map((range) => range.map(normalizeDate)) + + if (nranges.some((nrange) => ndate >= nrange[0] && ndate <= nrange[1])) { + return date + } + + let minDate = nranges[0][0] + let maxDate = minDate + + nranges.forEach((nrange) => { + let minTempDate = (minDate = new Date(Math.min(nrange[0], minDate))) + maxDate = new Date(Math.max(nrange[1], minTempDate)) + }) + + const ret = ndate < minDate ? minDate : maxDate + return modifyDate(ret, date.getFullYear(), date.getMonth(), date.getDate()) +} + +export const timeWithinRange = (date, selectableRange, format) => { + const limitedDate = limitTimeRange(date, selectableRange, format) + return limitedDate.getTime() === date.getTime() +} + +export const changeYearMonthAndClampDate = (date, year, month) => { + const monthDate = Math.min(date.getDate(), getDayCountOfMonth(year, month)) + return modifyDate(date, year, month, monthDate) +} + +export const nextMonth = (date) => { + const year = date.getFullYear() + const month = date.getMonth() + const isLast = month === 11 + + return isLast ? changeYearMonthAndClampDate(date, year + 1, 0) : changeYearMonthAndClampDate(date, year, month + 1) +} + +export const prevMonth = (date) => { + const year = date.getFullYear() + const month = date.getMonth() + const isFirst = month === 0 + + return isFirst ? changeYearMonthAndClampDate(date, year - 1, 11) : changeYearMonthAndClampDate(date, year, month - 1) +} + +export const nextYear = (date, next = 1) => { + const year = date.getFullYear() + const month = date.getMonth() + + return changeYearMonthAndClampDate(date, year + next, month) +} + +export const prevYear = (date, prev = 1) => { + const year = date.getFullYear() + const month = date.getMonth() + + return changeYearMonthAndClampDate(date, year - prev, month) +} + +export const extractTimeFormat = (dateFormat) => + dateFormat.replace(/\W?D{1,2}|\W?Do|\W?d{1,4}|\W?M{1,4}|\W?y{2,4}/g, '').trim() + +export const extractDateFormat = (dateFormat) => + dateFormat + .replace(/\W?m{1,2}|\W?ZZ/g, '') + .replace(/\W?h{1,2}|\W?s{1,3}|\W?a/gi, '') + .trim() + +export const validateRangeInOneMonth = (startDate, endDate) => + startDate.getMonth() === endDate.getMonth() && startDate.getFullYear() === endDate.getFullYear() diff --git a/packages/utils/src/date/index.ts b/packages/utils/src/date/index.ts new file mode 100644 index 0000000000..6f8adb859f --- /dev/null +++ b/packages/utils/src/date/index.ts @@ -0,0 +1,538 @@ +/* eslint-disable prefer-rest-params */ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { isDate, isNumber, isNumeric } from '../type' +import { fillChar } from '../string' + +const daysInMonths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] +const yyyymmddReg = new RegExp( + '^(\\d{4})(/|-)(((0)?[1-9])|(1[0-2]))((/|-)(((0)?[1-9])|([1-2][0-9])|(3[0-1])))' + + '?( ((0)?[0-9]|1[0-9]|20|21|22|23):([0-5]?[0-9])((:([0-5]?[0-9]))?(.([0-9]{1,6}))?)?)?$' +) +const mmddyyyyReg = new RegExp( + '^(((0)?[1-9])|(1[0-2]))(/|-)(((0)?[1-9])|([1-2][0-9])|(3[0-1]))?(/|-)?(\\d{4})' + + '( ((0)?[0-9]|1[0-9]|20|21|22|23):([0-5]?[0-9])((:([0-5]?[0-9]))?(.([0-9]{1,6}))?)?)?$' +) +const iso8601Reg = new RegExp( + '^(\\d{4})-(((0)?[1-9])|(1[0-2]))-(((0)?[1-9])|([1-2][0-9])|(3[0-1]))T' + + '(((0)?[0-9]|1[0-9]|20|21|22|23):([0-5]?[0-9])((:([0-5]?[0-9]))?(.([0-9]{1,6}))?)?)?(Z|([+-])' + + '((0)?[0-9]|1[0-9]|20|21|22|23):?([0-5]?[0-9]))$' +) + +const dateFormatRegs = { + 'y{1,4}': /y{1,4}/, + 'M{1,2}': /M{1,2}/, + 'd{1,2}': /d{1,2}/, + 'h{1,2}': /h{1,2}/, + 'H{1,2}': /H{1,2}/, + 'm{1,2}': /m{1,2}/, + 's{1,2}': /s{1,2}/, + 'S{1,3}': /S{1,3}/, + 'Z{1,1}': /Z{1,1}/ +} + +const maxDateValues = { + YEAR: 9999, + MONTH: 11, + DATE: 31, + HOUR: 23, + MINUTE: 59, + SECOND: 59, + MILLISECOND: 999 +} + +const timezone1 = '-12:00,-11:00,-10:00,-09:30,-08:00,-07:00,-06:00,-05:00,-04:30,-04:00,-03:30,-02:00,-01:00' +const timezone2 = '-00:00,+00:00,+01:00,+02:00,+03:00,+03:30,+04:00,+04:30,+05:00,+05:30,+05:45,+06:00' +const timezone3 = '+06:30,+07:00,+08:00,+09:00,+10:00,+10:30,+11:00,+11:30,+12:00,+12:45,+13:00,+14:00' +const timezones = [].concat(timezone1.split(','), timezone2.split(','), timezone3.split(',')) + +const getTimezone = (date) => { + const timezoneoffset = 0 - date.getTimezoneOffset() / 60 + let timezone + + if (timezoneoffset === 0) { + timezone = 'Z' + } else if (timezoneoffset > 0) { + timezone = '+' + (timezoneoffset > 10 ? timezoneoffset : '0' + timezoneoffset) + '00' + } else { + timezone = (timezoneoffset < -10 ? timezoneoffset : '-0' + -timezoneoffset) + '00' + } + + return timezone +} + +/** + * 判断年份是否为闰年。 + * + * isLeapYear(2017) // false + * isLeapYear(2000) // true + * + * @param {Number} year 年份 + * @returns {Boolean} + */ +export const isLeapYear = (year) => year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0) + +const getMilliseconds = (milliseconds) => + milliseconds > maxDateValues.MILLISECOND ? Number(String(milliseconds).substring(0, 3)) : milliseconds + +const getDateFromData = ({ year, month, date, hours, minutes, seconds, milliseconds }) => { + let daysInMonth = daysInMonths[month] + + if (isLeapYear(year) && month === 1) { + daysInMonth += 1 + } + + if (date <= daysInMonth) { + return new Date(year, month, date, hours, minutes, seconds, getMilliseconds(milliseconds)) + } +} + +const yyyymmddDateParser = (m) => { + /* istanbul ignore else */ + if (m.length === 23) { + const year = Number(m[1]) + const month = m[3] - 1 + const date = Number(m[9] || 1) + const hours = m[15] || 0 + const minutes = m[17] || 0 + const seconds = m[20] || 0 + const milliseconds = m[22] || 0 + + return getDateFromData({ + date, + year, + hours, + month, + seconds, + minutes, + milliseconds + }) + } +} + +const mmddyyyyDateParser = (m) => { + /* istanbul ignore else */ + if (m.length === 22) { + const year = Number(m[12]) + const month = m[1] - 1 + const date = Number(m[6] || 1) + const hours = m[14] || 0 + const minutes = m[16] || 0 + const seconds = m[19] || 0 + const milliseconds = m[21] || 0 + + return getDateFromData({ + year, + month, + date, + hours, + minutes, + seconds, + milliseconds + }) + } +} + +const iso8601DateParser = (m) => { + if (m.length !== 25) { + return + } + const year = Number(m[1]) + const month = m[2] - 1 + const date = Number(m[6]) + const offset = new Date(year, month, date).getTimezoneOffset() + const hours = m[12] || 0 + const minutes = m[14] || 0 + const seconds = m[17] || 0 + const milliseconds = m[19] || 0 + let timeZone = m[20] + const sign = m[21] + const offsetHours = m[22] || 0 + const offsetMinutes = m[24] || 0 + let daysInMonth = daysInMonths[month] + let actHours + let actMinutes + if (isLeapYear(year) && month === 1) { + daysInMonth += 1 + } + if (date <= daysInMonth) { + if (timeZone === 'Z') { + actHours = hours - offset / 60 + actMinutes = minutes + } else { + if (!timeZone.includes(':')) { + timeZone = timeZone.substr(0, 3) + ':' + timeZone.substr(3) + } + if (!timezones.includes(timeZone)) { + return + } + + actHours = sign === '+' ? hours - offsetHours - offset / 60 : Number(hours) + Number(offsetHours) - offset / 60 + actMinutes = sign === '+' ? minutes - offsetMinutes : Number(minutes) + Number(offsetMinutes) + } + + return new Date(year, month, date, actHours, actMinutes, seconds, getMilliseconds(milliseconds)) + } +} + +const dateParsers = [ + [yyyymmddReg, yyyymmddDateParser], + [mmddyyyyReg, mmddyyyyDateParser], + [iso8601Reg, iso8601DateParser] +] + +const parseDate = (str) => { + for (let i = 0, len = dateParsers.length; i < len; i++) { + const m = dateParsers[i][0].exec(str) + + if (m && m.length > 0) { + return dateParsers[i][1](m) + } + } +} + +const matchDateArray = (arr, value, text) => { + if (text) { + switch (text) { + case 'yyyy': + case 'yy': + arr[0] = value + break + case 'M': + case 'MM': + arr[1] = value - 1 + break + case 'd': + case 'dd': + arr[2] = value + break + case 'h': + case 'hh': + arr[3] = value + break + case 'm': + case 'mm': + arr[4] = value + break + case 's': + case 'ss': + arr[5] = value + break + case 'S': + case 'SS': + case 'SSS': + arr[6] = value + break + default: + break + } + } +} + +const getDateArray = (str, dateFormat) => { + const arr = [0, -1, 0, 0, 0, 0] + + if (str.length !== dateFormat.length) { + return arr + } + + let valuePos = 0 + let textPos = 0 + + for (let i = 0, len = str.length; i < len; i++) { + const charValue = str.substr(i, 1) + const notNum = isNaN(Number(charValue)) || charValue.trim() === '' + + if ((notNum && charValue === dateFormat.substr(i, 1)) || i === len - 1) { + let value + let text + + if (notNum) { + value = str.substring(valuePos, i) + valuePos = i + 1 + const end = dateFormat.indexOf(charValue, textPos) + + text = dateFormat.substring(textPos, end === -1 ? dateFormat.length : end) + + textPos = end + 1 + } else { + value = str.substring(valuePos, len) + text = dateFormat.substring(textPos, len) + } + + if (value.length === text.length || value) { + matchDateArray(arr, value, text) + } + } + } + + return arr +} + +const invalideTime = (time, min, max) => isNaN(time) || time < min || time > max + +const invalideValue = ({ year, month, date, hours, minutes, seconds, milliseconds }) => + invalideTime(year, 0, maxDateValues.YEAR) || + invalideTime(month, 0, maxDateValues.MONTH) || + invalideTime(date, 0, maxDateValues.DATE) || + invalideTime(hours, 0, maxDateValues.HOUR) || + invalideTime(minutes, 0, maxDateValues.MINUTE) || + invalideTime(seconds, 0, maxDateValues.SECOND) || + invalideTime(milliseconds, 0, maxDateValues.MILLISECOND) + +const innerParse = (value, dateFormat) => { + if (typeof dateFormat === 'string') { + const arr = getDateArray(value, dateFormat) + const year = Number(arr[0]) + const month = Number(arr[1]) + const date = Number(arr[2] || 1) + const hours = Number(arr[3] || 0) + const minutes = Number(arr[4] || 0) + const seconds = Number(arr[5] || 0) + const milliseconds = Number(arr[6] || 0) + + if ( + invalideValue({ + year, + month, + date, + hours, + minutes, + seconds, + milliseconds + }) + ) { + return + } + + return getDateFromData({ + year, + date, + month, + minutes, + hours, + milliseconds, + seconds + }) + } else { + return parseDate(value) + } +} + +/** + * 将字符串或数字转换成 Date 类型。 + * + * toDate('2008/02/02') // new Date(2008, 1, 2) + * toDate(Date.UTC(2008, 1, 2)) // new Date(Date.UTC(2008, 1, 2)) + * toDate('2008/2/2', 'yyyy/M/d') // new Date(2008, 1, 2) + * toDate('2008/02') // new Date(2008, 1, 1) + * toDate('02/2008') // new Date(2008, 1, 1) + * toDate('2008-02-01T20:08+08:00') // new Date(Date.UTC(2008, 0, 31, 16)) + * toDate('2008-02-01T04:08-08:00') // new Date(Date.UTC(2008, 1, 1, 8)) + * + * @param {String|Number} value 日期类型字符串或数字 + * @param {String} [dateFormat] 转换格式 + * + * 当 value 为字符串类型时,如果不提供,则尽可能按常见格式去解析。 + * 常见格式为 yyyy[/-]MM[/-]dd hh:mm:ss.SSS, MM[/-]dd [/-]yyyy hh:mm:ss.SSS 及 ISO8601 时间格式。 + * + * 如果提供,则按具体格式严格匹配解析,并且年份必须为4位。 + * - yyyy 代表年份 + * - M 或 MM 代表1位或2位的月份 + * - d 或 dd 代表1位或2位的天数 + * - h 或 hh 代表24小时的1位或2位的小时 + * - m 或 mm 代表1位或2位的分钟, + * - s 或 ss 代表1位或2位的秒 + * - S 或 SS 或 SSS 代表1位或2位或3位的毫秒 + * + * @param {String} [minDate] 最小时间,默认为 0001-01-01 00:00:00.000 + * @returns {Date} + */ +export const toDate = (value, dateFormat, minDate) => { + let date + + if (isNumber(value)) { + date = new Date(value) + } else if (typeof value === 'string') { + date = innerParse(value, dateFormat) + } + + if (minDate) { + const min = (minDate && toDate(minDate)) || new Date(1, 1, 1, 0, 0, 0) + return date && date < min ? min : date + } + + return date +} + +/** + * 将 Date 实例转换成日期字符串。 + * 当 date 为日期字符串时,如果只有2个参数,则第2个参数为格式化后的格式化字符串 + * 如果有3个参数,则第2个参数为转换的格式化参数,第3个参数为格式化后的格式化参数 + * + * let date = new Date(2014, 4, 4, 1, 2, 3, 4) + * format(date) // "2014/05/04 01:02:03" + * format(date, 'yyyy/MM/dd hh:mm:ss.SSS') // "2014/05/04 01:02:03.004" + * format(date, 'yyyy/MM/dd hh:mm:ss.SSSZ') // "2014/05/04 01:02:03.004+0800" + * format(date, 'yyyy年MM月dd日 hh时mm分ss秒SSS毫秒') // "2014年05月04日 01时02分03秒004毫秒" + * format('2008/01/02', 'yyyy/MM/dd hh:mm:ss.SSS') // "2008/02/02 00:00:00.000" + * format('2014/01/02/03/04/05/06', 'yyyy/MM/dd/hh/mm/ss', 'yyyy年MM月dd日 hh时mm分ss秒') // "2014年01月02日 03时04分05秒006毫秒" + * + * @param {Date|String} date Date 实例或日期字符串 + * @param {String} [dateFormat='yyyy/MM/dd hh:mm:ss'] 转换格式 + * + * 常见格式为 yyyy[/-]MM[/-]dd hh:mm:ss.SSS, MM[/-]dd [/-]yyyy hh:mm:ss.SSS 及 ISO8601 时间格式。 + * + * 如果提供,则按具体格式严格匹配解析,并且年份必须为4位。 + * - yyyy 代表年份 + * - M 或 MM 代表1位或2位的月份 + * - d 或 dd 代表1位或2位的天数 + * - h 或 hh 代表24小时的1位或2位的小时 + * - m 或 mm 代表1位或2位的分钟, + * - s 或 ss 代表1位或2位的秒 + * - S 或 SS 或 SSS 代表1位或2位或3位的毫秒 + * + * @returns {String} + */ +export const format = function (date, dateFormat = 'yyyy/MM/dd hh:mm:ss') { + if (isDate(date)) { + if (typeof dateFormat === 'string') { + const o = { + 'y{1,4}': date.getFullYear(), + 'M{1,2}': date.getMonth() + 1, + 'd{1,2}': date.getDate(), + 'h{1,2}': date.getHours(), + 'H{1,2}': date.getHours(), + 'm{1,2}': date.getMinutes(), + 's{1,2}': date.getSeconds(), + 'S{1,3}': date.getMilliseconds(), + 'Z{1,1}': getTimezone(date) + } + + Object.keys(o).forEach((k) => { + const m = dateFormat.match(dateFormatRegs[k]) + + if (k && m && m.length) { + dateFormat = dateFormat.replace(m[0], k === 'Z{1,1}' ? o[k] : fillChar(o[k].toString(), m[0].length)) + } + }) + + return dateFormat + } + } else if (typeof date === 'string' && arguments.length >= 2) { + let afterFormat = dateFormat + + if (arguments.length === 2) { + dateFormat = undefined + } else { + afterFormat = arguments[2] + } + + const dateValue = toDate(date, dateFormat) + return dateValue ? format(dateValue, afterFormat) : '' + } +} + +/** + * 将当前操作的时间变更时区,主要用于转换一个其他时区的时间。 + * + * var date = new Date(2017, 0, 1) + * getDateWithNewTimezone(date, 0, -2) + * + * @param {Date} date Date 实例或日期字符串 + * @param {Number} otz 原时区 -12~13 + * @param {Number} ntz 目标时区 -12~13 默认为当前时区 + * @param {Boolean} TimezoneOffset 时区偏移量 + * @returns {Date} + */ +export const getDateWithNewTimezone = (date, otz, ntz, timezoneOffset = 0) => { + if (!isDate(date) || !isNumeric(otz) || !isNumeric(ntz) || !isNumeric(timezoneOffset)) { + return + } + + const otzOffset = -otz * 60 + const ntzOffset = -ntz * 60 + const dstOffeset = timezoneOffset * 60 + const utc = date.getTime() + otzOffset * 60000 + + return new Date(utc - (ntzOffset - dstOffeset) * 60000) +} + +/** + * 按时区将 Date 实例转换成字符串。 + * + * toDateStr(new Date(2017, 0, 1, 12, 30), 'yyyy/MM/dd hh:mm', 3) // "2017/01/01 15:30" + * toDateStr('2008/01/02', 'yyyy/MM/dd hh:mm', 3) // "2008/01/02 03:00" + * + * @param {Date|String} date Date 实例或日期字符串 + * @param {String} dateFormat 转换格式 + * @param {Number} [timezone] 时区 + * @returns {String} + */ +export const toDateStr = (date, dateFormat, timezone) => { + if (date && isNumeric(timezone)) { + timezone = parseFloat(parseFloat(timezone).toFixed(2)) + + date = getDateWithNewTimezone(isDate(date) ? date : new Date(toDate(date)), 0, timezone) + } + + return format(date, dateFormat) +} + +/** + * 获取日期所在周的第一天,默认周一为第一天(可扩展周日为第一天)。 + * + * getWeekOfFirstDay() // 返回当前日期所在周的周一同一时间 + * getWeekOfFirstDay(true) // 返回当前日期所在周的周日同一时间 + * getWeekOfFirstDay(new Date(2019, 8, 5)) // new Date(2019, 8, 2) + * getWeekOfFirstDay(new Date(2019, 8, 5)), true) // new Date(2019, 8, 1) + * + * @param {Date} [date=new Date()] date 日期实例,默认当天 + * @param {Boolean} [isSunFirst] 是否设置周日为第一天,非必填 + * @returns {Date} + */ +export const getWeekOfFirstDay = (date, isSunFirst) => { + typeof date === 'boolean' && (isSunFirst = date) + isDate(date) || (date = new Date()) + + const day = date.getDay() + let dayOfMonth = date.getDate() + + if (day === 0) { + !isSunFirst && (dayOfMonth -= 6) + } else { + dayOfMonth = dayOfMonth - day + (!isSunFirst && 1) + } + + return new Date(date.getFullYear(), date.getMonth(), dayOfMonth) +} + +const TZRE = /(-|\+)(\d{2}):?(\d{2})$/ + +export const getLocalTimezone = () => 0 - new Date().getTimezoneOffset() / 60 + +export const getStrTimezone = (value) => { + const localTimeZone = getLocalTimezone() + const match = typeof value === 'string' && value.match(TZRE) + + if (match) { + const minoffset = Number(match[2]) + Number(match[3]) / 60 + value = minoffset * `${match[1]}1` + } + + if (isNumber(value) && value >= -12 && value <= 12) { + return value + } + + return localTimeZone +} diff --git a/packages/utils/src/debounce/index.ts b/packages/utils/src/debounce/index.ts new file mode 100644 index 0000000000..21dee86fac --- /dev/null +++ b/packages/utils/src/debounce/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { throttle } from '../throttle' + +export function debounce(delay, atBegin, callback?: Function) { + return callback === undefined ? throttle(delay, atBegin, false) : throttle(delay, callback, atBegin !== false) +} diff --git a/packages/utils/src/decimal/index.ts b/packages/utils/src/decimal/index.ts new file mode 100644 index 0000000000..74217a1a8f --- /dev/null +++ b/packages/utils/src/decimal/index.ts @@ -0,0 +1,262 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { getMiniDecimal, toFixed as roundFixed } from '../bigInt' + +// 待移除, 已经从bigInt导出过了 +export { roundFixed } + +const DECIMAL_SEPARATOR = '.' + +const asInteger = (number) => { + const tokens = number.split(DECIMAL_SEPARATOR) + const integer = tokens[0] + const fractional = tokens[1] + let value + let exp + + if (fractional) { + value = parseInt(number.split(DECIMAL_SEPARATOR).join(''), 10) + exp = fractional.length * -1 + } else { + const trailingZeros = integer.match(/0+$/) + if (trailingZeros) { + const length = trailingZeros[0].length + value = integer.substr(0, integer.length - length) + exp = length + } else { + value = integer + exp = 0 + } + } + + return { value, exp } +} + +const zero = (exp) => { + let result + + if (exp <= 0) { + result = '' + } else if (String.prototype.repeat) { + result = '0'.repeat(exp) + } else { + result = ((times) => { + const zeros = [] + + for (let i = 0; i < times; i++) { + zeros.push(0) + } + return zeros.join('') + })(exp) + } + + return result +} + +const negExp = (str, position) => { + position = Math.abs(position) + + const offset = position - str.length + let sep = DECIMAL_SEPARATOR + + if (offset >= 0) { + str = zero(offset) + str + sep = '0.' + } + + const length = str.length + const dif = length - position + const head = str.substr(0, dif) + const tail = str.substring(dif, length) + + return head + sep + tail +} + +const posExp = (str, exp) => String(str + zero(exp)) + +const format = (num, exp) => (exp >= 0 ? posExp : negExp)(String(num), exp) + +/** + * Decimal 类,解决 JS 的计算精度问题。 + * + * // 加法运算 1.1 + 2.2 = 3.3000000000000003 + * Decimal.add(1.1, 2.2).toNumber() // 3.3 + * new Decimal('1.1').add('2.2').toString() // "3.3" + * + * // 减法运算 0.3 - 0.1 = 0.19999999999999998 + * Decimal.sub(0.3, 0.1).toNumber() // 0.2 + * new Decimal('0.3').sub('0.1').toString() // "0.2" + * + * // 乘法运算 4.01 * 2.01 = 8.060099999999998 + * Decimal.mul(4.01, 2.01).toNumber() // 8.0601 + * new Decimal('4.01').mul('2.01').toString() // "8.0601" + * + * // 除法运算 0.3 / 0.1 = 2.9999999999999996 + * Decimal.div(0.3, 0.1).toNumber() // 3 + * new Decimal('0.3').div('0.1').toString() // "3" + * + * @param {Number|String|} num 数字或字符串代表的数字 + * @returns {Number} + */ +export function Decimal(num) { + if (!this || this.constructor !== Decimal) { + return new Decimal(num) + } + + if (num instanceof Decimal) { + return num + } + + this.internal = String(num) + this.asInt = asInteger(this.internal) + this.add = (target) => { + const operands = [this, new Decimal(target)] + operands.sort((x, y) => x.asInt.exp - y.asInt.exp) + const smallest = operands[0].asInt.exp + const biggest = operands[1].asInt.exp + const x = Number(format(operands[1].asInt.value, biggest - smallest)) + const y = Number(operands[0].asInt.value) + + return new Decimal(format(String(x + y), smallest)) + } + + this.sub = (target) => new Decimal(this.add(target * -1)) + this.mul = (target) => { + target = new Decimal(target) + const result = String(this.asInt.value * target.asInt.value) + const exp = this.asInt.exp + target.asInt.exp + + return new Decimal(format(result, exp)) + } + + this.div = (target) => { + target = new Decimal(target) + + const smallest = Math.min(this.asInt.exp, target.asInt.exp) + const absSmallest = 10 ** Math.abs(smallest) + const x = Decimal.mul(absSmallest, this) + const y = Decimal.mul(absSmallest, target) + + return new Decimal(x / y) + } + + this.toString = () => this.internal + this.toNumber = () => Number(this.internal) +} + +Decimal.add = (a, b) => new Decimal(a).add(b) +Decimal.mul = (a, b) => new Decimal(a).mul(b) +Decimal.sub = (a, b) => new Decimal(a).sub(b) +Decimal.div = (a, b) => new Decimal(a).div(b) + +/** + * 使用定点表示法表示给定数字的字符串,解决 JS 的计算精度问题。 + * + * toFixed(1.1 + 2.2, 2) // "3.30" + * toFixed(0.3 - 0.1, 2) // "0.20" + * toFixed(4.01 * 2.01, 4) // "8.0601" + * toFixed(0.3 / 0.1, 2) // "3.00" + * toFixed(0.0001, 2) // "0.00" + * toFixed(0.0001, 3) // "0.000" + * toFixed(0.0001, 4) // "0.0001" + * toFixed(0.0001, 5) // "0.00010" + * toFixed(-0.0001, 2) // "0.00" + * toFixed(-0.0001, 3) // "0.000" + * toFixed(-0.0001, 4) // "-0.0001" + * toFixed(-0.0001, 5) // "-0.00010" + * + * @param {Number} num 需精确计算的数字 + * @param {Number} [fraction=0] 浮点数的小数部分,默认0位 + * @returns {String} + */ +export const toFixed = (num, fraction = 0) => { + const sign = num < 0 ? '-' : '' + + num = Math.abs(num) + + const npmPow = num.toString().length < (2 ** 53).toString().length - 1 ? 10 ** fraction : 10 ** (fraction - 1) + const result = new Decimal(Math.round(new Decimal(num).mul(npmPow))).div(npmPow).toString() + + const numResult = Number(result) + + return numResult ? sign + numResult.toFixed(fraction) : numResult.toFixed(fraction) +} + +const formatInteger = (value, { secondaryGroupSize = 3, groupSize = 0, groupSeparator = ',' }) => { + const negative = /^-\d+/.test(value) + let result = negative ? value.slice(1) : value + const secSize = secondaryGroupSize || groupSize + + if (groupSize && result.length > groupSize) { + let left = result.slice(0, 0 - groupSize) + const right = result.slice(0 - groupSize) + + left = left.replace(new RegExp(`\\B(?=(\\d{${secSize}})+(?!\\d))`, 'g'), groupSeparator) + result = `${left}${groupSeparator}${right}` + } + + return `${negative ? '-' : ''}${result}` +} + +const reverseString = (str) => { + const arr = [] + + for (let i = 0; i < str.length; i++) { + arr.push(str[i]) + } + + return arr.reverse().join('') +} + +const formatDecimal = (num, { fractionGroupSize = 0, fractionGroupSeparator = '\xA0' }) => { + const RE = new RegExp(`\\B(?=(\\d{${fractionGroupSize}})+(?!\\d))`, 'g') + + return reverseString(reverseString(num).replace(RE, fractionGroupSeparator)) +} + +export const formatNumber = (value, format = {}) => { + const { fraction, rounding, prefix = '', decimalSeparator = '.', suffix = '' } = format + let reslut = getMiniDecimal(value) + + if (reslut.isNaN() || !reslut.toString()) { + return value + } + + reslut = roundFixed(reslut.toString(), fraction, rounding) + + format.zeroize === false && reslut.match(/\./) && (reslut = reslut.replace(/\.?0+$/g, '')) + + const number = reslut + .toString() + .split('.') + .slice(0, 2) + .map((str, index) => (index ? formatDecimal(str, format) : formatInteger(str, format))) + .join(decimalSeparator) + + return `${prefix}${number}${suffix}` +} + +export const recoverNumber = (number, format = {}) => { + const { prefix = '', suffix = '', decimalSeparator = '.' } = format + let result = number + + if (typeof number === 'string') { + result = number + .replace(new RegExp(`^${prefix}(.+)${suffix}$`), ($1, $2) => $2) + .split(decimalSeparator) + .map((s) => s.replace(/[^\d]/g, '')) + .join('.') + } + + return Number(result) +} diff --git a/packages/utils/src/dom/index.ts b/packages/utils/src/dom/index.ts new file mode 100644 index 0000000000..7b5ec4ed76 --- /dev/null +++ b/packages/utils/src/dom/index.ts @@ -0,0 +1,307 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { hasOwn, isNull } from '../type' +import { globalConfig } from '../globalConfig' + +export const isServer = typeof window === 'undefined' +const SPECIAL_CHARS_REGEXP = /([:\-_]+(.))/g +const MOZ_HACK_REGEXP = /^moz([A-Z])/ + +/** 处理style的名字。 + * 把 moz : - _ 等位置,转换为大写驼峰格式, 比如 :camelCase("moz:moz_abc:def-hjk_lmnOpqRst") = MozMozAbcDefHjkLmnOpqRst + */ +const camelCase = (name: string) => + name + .replace(SPECIAL_CHARS_REGEXP, (_, separator, letter, offset) => (offset ? letter.toUpperCase() : letter)) + .replace(MOZ_HACK_REGEXP, 'Moz$1') + +/** 绑定事件 */ +export const on = (el: EventTarget, event: any, handler: (this: HTMLElement, ev: any) => any, options = false) => { + if (el && event && handler) { + el.addEventListener(event, handler, options) + } +} +/** 移除事件 */ +export const off = (el: EventTarget, event: any, handler: (this: HTMLElement, ev: any) => any, options = false) => { + if (el && event) { + el.removeEventListener(event, handler, options) + } +} + +/** 执行一次就立即移除事件 */ +export const once = (el: HTMLElement, event: any, fn: (this: HTMLElement, ev: any) => any) => { + const listener = function () { + if (fn) { + // eslint-disable-next-line prefer-rest-params + fn.apply(this, arguments) + } + + off(el, event, listener) + } + + on(el, event, listener) +} + +/** 判断是否有class, 只能查询单个类名, 且不能有空格 */ +export const hasClass = (el: HTMLElement, clazz: string) => { + if (!el || !clazz) { + return false + } + + if (clazz.includes(' ')) { + throw new Error('className should not contain space.') + } + + if (el.classList) { + return el.classList.contains(clazz) + } +} + +/** 给el添加一组classes, clazz 允许为用空格分隔的多个类名 */ +export const addClass = (el: HTMLElement, clazz = '') => { + if (!el) { + return + } + + const classes = clazz.split(' ').filter((name) => name) + + classes.forEach((clsName) => el.classList.add(clsName)) +} + +/** 移除el上的classes, clazz 允许为用空格分隔的多个类名 */ +export const removeClass = (el: HTMLElement, clazz: string) => { + if (!el || !clazz) { + return + } + + const classes = clazz.split(' ').filter((name) => name) + + classes.forEach((clsName) => el.classList.remove(clsName)) +} + +/** 查询元素的style的值。 优先找el.style, 找不到则调用getComputedStyle(el) */ +export const getStyle = (el: HTMLElement, styleName: string) => { + if (isServer) { + return + } + if (!el || !styleName) { + return null + } + + styleName = camelCase(styleName) + + if (styleName === 'float') { + styleName = 'cssFloat' + } + + try { + if (el.style[styleName]) { + return el.style[styleName] + } + + const computed = window.getComputedStyle(el) + return computed ? computed[styleName] : null + } catch (e) { + return el.style[styleName] + } +} + +/** 给元素赋值style。 + * @param name 当它是对象时,遍历所有属性;当它是字符串时,需要传入第3个参数 value + */ +export const setStyle = (el: HTMLElement, name: string | object, value?: any) => { + if (!el || !name) { + return + } + + if (typeof name === 'object') { + for (const prop in name) { + if (hasOwn.call(name, prop)) { + setStyle(el, prop, name[prop]) + } + } + } else { + name = camelCase(name) + + el.style[name as string] = value + } +} + +/** 判断元素是否有滚动的style TINY_NO_USED + * @param vertical true时,只判断overflow-y属性; false时,只判断overflow-x属性; 不传入时,只判断overflow属性! + */ +export const isScroll = (el: HTMLElement, vertical?: boolean) => { + if (isServer) { + return + } + + /** 是否需要判断方向 + * 它的值为false: 当vertical = null / undefinded。 + * 它的值为 true: 当vertical =true /false + */ + const determinedDirection = !isNull(vertical) + let overflow + + if (determinedDirection) { + overflow = vertical ? getStyle(el, 'overflow-y') : getStyle(el, 'overflow-x') + } else { + overflow = getStyle(el, 'overflow') + } + + return overflow.match(/(scroll|auto)/) +} + +/** 查找离元素最近的父级滚动元素 + * @param vertical true时,只判断overflow-y属性; false时,只判断overflow-x属性; 不传入时,只判断overflow属性! + */ +export const getScrollContainer = (el: HTMLElement, vertical?: boolean) => { + if (isServer) { + return + } + + let parent = el + + while (parent) { + if (~[window, document, document.documentElement].indexOf(parent)) { + return window + } + + if (isScroll(parent, vertical)) { + return parent + } + + parent = parent.parentNode as any + } + + return parent +} + +/** 判断是否 el 完全在 container 中。 四个边有重合都不行,必须完全在里面。 */ +export const isInContainer = (el: HTMLElement, container: HTMLElement) => { + if (isServer || !el || !container) { + return false + } + + const elRect = el.getBoundingClientRect() + let containerRect + + if (~[window, document, document.documentElement].indexOf(container) || isNull(container)) { + containerRect = { + top: 0, + right: window.innerWidth, + bottom: window.innerHeight, + left: 0 + } + } else { + containerRect = container.getBoundingClientRect() + } + + return ( + elRect.top < containerRect.bottom && + elRect.bottom > containerRect.top && + elRect.right > containerRect.left && + elRect.left < containerRect.right + ) +} + +/** 查询页面的位置和尺寸 + * @returns scrollTop : document 或 body的滚动位置 + * @returns scrollLeft : document 或 body的滚动位置 + * @returns visibleHeight : 可视区高度 (不含滚动条) + * @returns visibleWidth : 可视区宽度(不含滚动条) + */ +export const getDomNode = () => { + const viewportWindow = globalConfig.viewportWindow || window + let documentElement = viewportWindow.document.documentElement + let bodyElem = viewportWindow.document.body + + return { + scrollTop: documentElement.scrollTop || bodyElem.scrollTop, + scrollLeft: documentElement.scrollLeft || bodyElem.scrollLeft, + visibleHeight: documentElement.clientHeight || bodyElem.clientHeight, + visibleWidth: documentElement.clientWidth || bodyElem.clientWidth + } +} + +export const getScrollTop = (el) => { + const top = 'scrollTop' in el ? el.scrollTop : el.pageYOffset + // iOS scroll bounce cause minus scrollTop + return Math.max(top, 0) +} + +export const stopPropagation = (event) => event.stopPropagation() + +export const preventDefault = (event, isStopPropagation) => { + /* istanbul ignore else */ + if (typeof event.cancelable !== 'boolean' || event.cancelable) { + event.preventDefault() + } + + if (isStopPropagation) { + stopPropagation(event) + } +} + +const overflowScrollReg = /scroll|auto|overlay/i +const defaultRoot = isServer ? undefined : window + +const isElement = (node) => node.tagName !== 'HTML' && node.tagName !== 'BODY' && node.nodeType === 1 + +export const getScrollParent = (el, root = defaultRoot) => { + let node = el + + while (node && node !== root && isElement(node)) { + const { overflowY } = window.getComputedStyle(node) + + if (overflowScrollReg.test(overflowY)) { + return node + } + + node = node.parentNode + } + + return root +} + +export const useScrollParent = + ({ onMounted, ref, watch }) => + (elRef, root = defaultRoot) => { + const scrollParent = ref() + const setScrollParent = () => (scrollParent.value = getScrollParent(elRef.value, root)) + + watch(elRef, setScrollParent) + onMounted(() => elRef.value && setScrollParent()) + + return scrollParent + } + +// 判断body的后代元素是否是隐藏的 +export const isDisplayNone = (elm) => { + if (isServer) return false + + if (elm) { + const computedStyle = getComputedStyle(elm) + + if (computedStyle.getPropertyValue('position') === 'fixed') { + if (computedStyle.getPropertyValue('display') === 'none') { + return true + } else if (elm.parentNode !== document.body) { + return isDisplayNone(elm.parentNode) + } + } else { + return elm.offsetParent === null + } + } + + return false +} diff --git a/packages/utils/src/espace-ctrl/index.ts b/packages/utils/src/espace-ctrl/index.ts new file mode 100644 index 0000000000..fdf7adb6f7 --- /dev/null +++ b/packages/utils/src/espace-ctrl/index.ts @@ -0,0 +1,419 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ +import { isBrowser } from '../browser' + +let ws = null +const url = 'ws://localhost' +const ports = [27197, 27198, 27199] +let index = 0 +let connected +let pollingInterval = 1000 +let timeout = 30000 +let cid = 0 +let callbacks = {} +let pollingTimer +let userStatus = {} +const heartbeatInterval = 20 * 1000 +let heartbeatTimer = null +let connectTimer = null +let apiTimers = {} +let events = {} + +let out = {} + +let error = () => undefined +let ready = () => undefined + +const clearCallback = function (cid) { + clearTimeout(apiTimers[cid]) + + delete callbacks[cid] + delete apiTimers[cid] +} + +const onopen = function () { + connectTimer = setTimeout(() => { + ws.close() + }, 5000) +} + +const send = function (argv, cb) { + let id = cid++ + id = String(id) + argv.cid = id + + if (!connected) { + cb && setTimeout(cb, 0, { ok: false, message: 'eSpace is not logged in.' }) + return + } + + if (typeof cb === 'function') { + callbacks[id] = cb + + apiTimers[id] = setTimeout(() => { + cb({ ok: false, message: 'time out' }) + clearCallback(id) + }, timeout) + } + + ws.send(JSON.stringify(argv)) +} + +const sendHeartbeat = function () { + heartbeatTimer = setTimeout(() => { + if (connected) { + send( + { + type: 'heartbeat' + }, + () => { + sendHeartbeat() + } + ) + } else { + clearTimeout(heartbeatTimer) + } + }, heartbeatInterval) +} + +const connectionSucceeded = function (data) { + connected = true + + sendHeartbeat() + clearTimeout(pollingTimer) + clearTimeout(connectTimer) + ready(data) +} + +const onmessage = function (evt) { + let data = evt.data + if (typeof data !== 'string') { + return + } + + data = data.replace(/^\d+/, '') + if (!data) { + return + } + + try { + data = JSON.parse(data) + } catch (e) { + return !e + } + + if (connected) { + let event = events[data.type] + + if (event) { + return event(data.data) + } + + let cid = data.cid + let cb = callbacks[cid] + + if (cb) { + if (data.ok) { + cb(null, data.data) + } else { + cb({ ok: data.ok }) + } + + clearCallback(cid) + } + } else { + if (data.type === 'eSpace-ctrl-connection-success') { + connectionSucceeded(data.data) + } else { + ws.close() + } + } +} + +const bindEvents = function () { + ws.onopen = onopen + ws.onclose = onclose + ws.onmessage = onmessage +} + +const connect = function (interval) { + pollingTimer = setTimeout(() => { + if (index >= ports.length) { + index = 0 + } + + ws = new WebSocket(url + ':' + ports[index++]) + + bindEvents() + }, interval || 0) +} + +const onclose = function () { + if (connected || typeof connected === 'undefined') { + connected = false + error() + } + + connect(pollingInterval) +} + +out.init = function (conf) { + if (conf) { + timeout = conf.timeout || 30000 + pollingInterval = conf.pollingInterval || 0 + } + + connect() +} + +out.ready = function (cb) { + ready = cb +} + +out.error = function (cb) { + error = cb +} + +const attrToArr = function (name, total, object) { + let result = [] + + for (let i = 0; i < total; i++) { + let attrName = name + if (i) { + attrName += i + } + + let attrVal = object[attrName] + if (attrVal) { + result.push(attrVal) + } + } + + return result +} + +/** + * 事件绑定 + * @param {String} event 事件名 + * @param {Function} hander 事件处理函数 + * + * example: + * out.on('user-status-change', function (data){ + * // do something + * }) + */ +out.on = function (event, hander) { + events[event] = hander +} + +/** + * 获取用户信息 + * @param {String|Array} accounts 单个帐号或者帐号数组 + * @param {Function} cb 回调函数 + */ +out.getUserInfo = function (account, cb) { + const fn = function (err, data) { + if (err) { + return cb(err) + } + + const formatInfo = function (user) { + return { + account: user.account, + name: user.name, + mobile: attrToArr('mobile', 6, user), + 'office_phone': attrToArr('office_phone', 6, user), + 'home_phone': user.home_phone, + 'ip_phone': user.ip_phone, + 'other_phone': user.other_phone + } + } + + if (data.account) { + cb(null, formatInfo(data)) + } else { + let result = {} + + for (let p in data) { + if (Object.prototype.hasOwnProperty.call(data, p)) { + let user = data[p] + result[p] = user ? formatInfo(user) : user + } + } + cb(null, result) + } + } + + send( + { + type: 'get-user-info', + param: account + }, + fn + ) +} + +/** + * 订阅用户状态 + * @param {String|Array} accounts 单个帐号或者帐号数组 + * @param {Function} cb 回调函数 + */ +out.subscribeUserStatus = function (accounts, cb) { + if (Array.isArray(accounts)) { + accounts.forEach((account) => { + userStatus[account] = true + }) + } + + send( + { + type: 'subscribe-user-status', + param: accounts + }, + cb + ) +} + +/** + * 拉起单人语音 + * @param {String} account 帐号 + * @param {String} num 可选,电话号码或voip + * @param {Function} cb 回调函数 + */ +out.eSpaceCall = function (account, num, cb) { + send( + { + type: 'espace-call', + param: { + account, + number: num + } + }, + cb + ) +} + +/** + * 拉起单人语音 + * @param {String} account 帐号 + * @param {Function} cb 回调函数 + */ +out.eSpaceCallByAccount = function (account, cb) { + send( + { + type: 'espace-call', + param: { + account + } + }, + cb + ) +} + +/** + * 拉起单人语音 + * @param {String} number 电话号码 + * @param {Function} cb 回调函数 + */ +out.eSpaceCallByNumber = function (number, cb) { + send( + { + type: 'espace-call', + param: { + number + } + }, + cb + ) +} + +/** + * 拉起单聊IM窗口 + * @param {String} account 帐号 + * @param {Function} cb 回调函数 + */ +out.showImDialog = function (account, cb) { + send( + { + type: 'show-espace-im-dialog', + param: account + }, + cb + ) +} + +/** + * 拉起群组IM窗口 + * @param {String} gid 群组id + * @param {Function} cb 回调函数 + */ +out.showGroupDialog = function (gid, cb) { + send( + { + type: 'show-espace-im-group-dialog', + param: gid + }, + cb + ) +} + +/** + * 添加到联系人列表 + * @param {String} account 帐号 + * @param {Function} cb 回调函数 + */ +out.addContactList = function (account, cb) { + send( + { + type: 'add-contact-list', + param: account + }, + cb + ) +} + +if (!isBrowser || !window.WebSocket) { + const notFn = function () { + return undefined + } + + for (let api in out) { + if (Object.prototype.hasOwnProperty.call(out, api)) { + let fn = out[api] + + if (typeof fn === 'function') { + out[api] = notFn + } + } + } +} + +let initialized = false + +export function init() { + if (!initialized && isBrowser) { + localStorage.setItem('eSpaceCtrl_initialized', 0) + out.init({ timeout: 3000, pollingInterval: 1000 }) + out.ready(() => { + localStorage.setItem('eSpaceCtrl_initialized', 1) + }) + out.error(() => { + localStorage.setItem('eSpaceCtrl_initialized', 0) + }) + + initialized = true + } + + return out +} + +export const espaceCtrl = out diff --git a/packages/utils/src/event/index.ts b/packages/utils/src/event/index.ts new file mode 100644 index 0000000000..76d9e731f7 --- /dev/null +++ b/packages/utils/src/event/index.ts @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +/** + * 触发事件,并返回是否在事件中执行了 preventDefault 方法,支持事件传递附加参数。 + * + * // 触发事件,返回 false 则退出 + * if (!emitEvent(emit, 'before', 1)) { + * return + * } + * + * // @before='before' 定义事件执行的函数 + * function before(event, value) { + * // value: 1 + * event.preventDefault() // 通知事件宿主停止执行 + * } + * + * @param {Function} emit 触发事件的函数 + * @param {String} name 事件的名称 + * @returns {Boolean} + */ +export const emitEvent = (emit, name, ...args) => { + let cancel = false + + if (typeof emit === 'function' && typeof name === 'string') { + const event = document.createEvent('HTMLEvents') + + event.initEvent(name, false, true) + event.preventDefault = () => { + cancel = true + } + + args.unshift(event) + args.unshift(name) + // eslint-disable-next-line prefer-spread + emit.apply(null, args) + } + + return !cancel +} + +/** + * webComponent中,有些事件的target会代理到webComponent根元素上,导致无法获取正确的target + * + * @param event 浏览器事件 + * @returns 正确的target + */ +export const getActualTarget = (e) => { + if (!e || !e.target) { + return null + } + return e.target.shadowRoot && e.composed ? e.composedPath()[0] || e.target : e.target +} + +export const correctTarget = (event, target?: EventTarget) => { + let newTarget + + if (event.target === null && target) { + newTarget = target + } else { + const nodeList = event.composedPath() + if (event.target !== nodeList[0]) { + newTarget = nodeList[0] + } + } + + if (newTarget) { + Object.defineProperty(event, 'target', { + get() { + return newTarget + }, + enumerable: true, + configurable: true + }) + } +} diff --git a/packages/utils/src/fastdom/async.ts b/packages/utils/src/fastdom/async.ts new file mode 100644 index 0000000000..de8035facb --- /dev/null +++ b/packages/utils/src/fastdom/async.ts @@ -0,0 +1,59 @@ +/** + * FastDom + * + * Eliminates layout thrashing + * by batching DOM read/write + * interactions. + * + * @author Wilson Page + * @author Kornel Lesinski + */ + +import fastdomSingleton from './singleton' + +const create = (promised, type, fn, ctx) => { + const tasks = promised._tasks + const fastdom = promised.fastdom + let task + + const promise = new Promise(function (resolve, reject) { + task = fastdom[type](function () { + tasks.delete(promise) + + try { + resolve(ctx ? fn.call(ctx) : fn()) + } catch (e) { + reject(e) + } + }, ctx) + }) + + tasks.set(promise, task) + + return promise +} + +const exports = { + initialize() { + this._tasks = new Map() + }, + + mutate(fn, ctx) { + return create(this, 'mutate', fn, ctx) + }, + + measure(fn, ctx) { + return create(this, 'measure', fn, ctx) + }, + + clear(promise) { + const tasks = this._tasks + const task = tasks.get(promise) + this.fastdom.clear(task) + tasks.delete(promise) + } +} + +const fastdomAsync = fastdomSingleton.extend(exports) + +export default fastdomAsync diff --git a/packages/utils/src/fastdom/index.ts b/packages/utils/src/fastdom/index.ts new file mode 100644 index 0000000000..c657751558 --- /dev/null +++ b/packages/utils/src/fastdom/index.ts @@ -0,0 +1,16 @@ +/** + * FastDom + * + * Eliminates layout thrashing + * by batching DOM read/write + * interactions. + * + * @author Wilson Page + * @author Kornel Lesinski + */ + +import fastdom from './singleton' +import fastdomAsync from './async' +import fastdomSandbox from './sandbox' + +export { fastdom, fastdomAsync, fastdomSandbox } diff --git a/packages/utils/src/fastdom/sandbox.ts b/packages/utils/src/fastdom/sandbox.ts new file mode 100644 index 0000000000..28ce7ddd91 --- /dev/null +++ b/packages/utils/src/fastdom/sandbox.ts @@ -0,0 +1,75 @@ +/** + * FastDom + * + * Eliminates layout thrashing + * by batching DOM read/write + * interactions. + * + * @author Wilson Page + * @author Kornel Lesinski + */ + +import fastdomSingleton from './singleton' + +const clearAll = (fastdom, tasks) => { + let i = tasks.length + + while (i--) { + fastdom.clear(tasks[i]) + tasks.splice(i, 1) + } +} + +const remove = (array, item) => { + const index = array.indexOf(item) + return !!~index && !!array.splice(index, 1) +} + +class Sandbox { + constructor(fastdom) { + this.fastdom = fastdom + this.tasks = [] + } + + measure(fn, ctx) { + const tasks = this.tasks + const task = this.fastdom.measure(function () { + tasks.splice(tasks.indexOf(task)) + return fn.call(ctx) + }) + + tasks.push(task) + + return task + } + + mutate(fn, ctx) { + const tasks = this.tasks + const task = this.fastdom.mutate(function () { + tasks.splice(tasks.indexOf(task)) + return fn.call(ctx) + }) + + this.tasks.push(task) + + return task + } + + clear(task) { + if (!arguments.length) clearAll(this.fastdom, this.tasks) + + remove(this.tasks, task) + + return this.fastdom.clear(task) + } +} + +const exports = { + sandbox() { + return new Sandbox(this.fastdom) + } +} + +const fastdomSandbox = fastdomSingleton.extend(exports) + +export default fastdomSandbox diff --git a/packages/utils/src/fastdom/singleton.ts b/packages/utils/src/fastdom/singleton.ts new file mode 100644 index 0000000000..1f765b14c6 --- /dev/null +++ b/packages/utils/src/fastdom/singleton.ts @@ -0,0 +1,118 @@ +/** + * FastDom + * + * Eliminates layout thrashing + * by batching DOM read/write + * interactions. + * + * @author Wilson Page + * @author Kornel Lesinski + */ +import { isBrowser } from '../browser' + +const RAF = (function () { + if (isBrowser) { + return window.requestAnimationFrame.bind(window) + } + return function (callback) { + setTimeout(() => callback(Date.now()), 1000 / 60) + } +})() + +const scheduleFlush = (fastdom) => { + if (!fastdom.scheduled) { + fastdom.scheduled = true + fastdom.raf(flush.bind(null, fastdom)) + } +} + +const flush = (fastdom) => { + const { reads, writes } = fastdom + let error + + try { + fastdom.runTasks(reads) + fastdom.runTasks(writes) + } catch (e) { + error = e + } + + fastdom.scheduled = false + + if (reads.length || writes.length) scheduleFlush(fastdom) + + if (error) { + if (fastdom.catch) { + fastdom.catch(error) + } else { + throw error + } + } +} + +const remove = (array, item) => { + const index = array.indexOf(item) + return !!~index && !!array.splice(index, 1) +} + +const mixin = (target, source) => { + for (let key in source) { + if (Object.hasOwnProperty.call(source, key)) target[key] = source[key] + } +} + +class FastDom { + constructor() { + this.reads = [] + this.writes = [] + this.raf = RAF.bind(window) + } + + runTasks(tasks) { + let task + // eslint-disable-next-line no-cond-assign + while ((task = tasks.shift())) task() + } + + measure(fn, ctx) { + const task = !ctx ? fn : fn.bind(ctx) + + this.reads.push(task) + + scheduleFlush(this) + + return task + } + + mutate(fn, ctx) { + const task = !ctx ? fn : fn.bind(ctx) + + this.writes.push(task) + + scheduleFlush(this) + + return task + } + + clear(task) { + return remove(this.reads, task) || remove(this.writes, task) + } + + extend(props) { + if (!props || typeof props !== 'object') throw new Error('[TINY][FastDom] expected object') + + const child = Object.create(this) + + mixin(child, props) + + child.fastdom = this + + if (child.initialize) child.initialize() + + return child + } +} + +const fastdomSingleton = new FastDom() + +export default fastdomSingleton diff --git a/packages/utils/src/fecha/index.ts b/packages/utils/src/fecha/index.ts new file mode 100644 index 0000000000..84de219f96 --- /dev/null +++ b/packages/utils/src/fecha/index.ts @@ -0,0 +1,342 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { DATEPICKER } from '../index' +import { isNull, isDate } from '../type' + +const fecha = {} +const digitsReg = ['\\d\\d?', '\\d{3}', '\\d{4}'] +const twoDigits = digitsReg[0] +const threeDigits = digitsReg[1] +const fourDigits = digitsReg[2] +const word = '[^\\s]+' +const literal = /\[([^]*?)\]/gm +const noop = () => undefined +const formats = { + shortDate: 'M/D/yy', + mediumDate: 'MMM d, yyyy', + longDate: 'MMMM d, yyyy', + fullDate: 'dddd, MMMM d, yyyy', + default: 'ddd MMM dd yyyy HH:mm:ss', + shortTime: 'HH:mm', + mediumTime: 'HH:mm:ss', + longTime: 'HH:mm:ss.SSS' +} + +const shorten = (arr, sLen) => { + let newArr = [] + + for (let i = 0, len = arr.length; i < len; i++) { + newArr.push(arr[i].substr(0, sLen)) + } + + return newArr +} + +const monthUpdate = (arrName) => (date, value, i18n) => { + const index = i18n[arrName].indexOf(value.charAt(0).toUpperCase() + value.substr(1).toLowerCase()) + + if (~index) { + date.month = index + } +} + +const pad = (val, len) => { + val = String(val) + len = len || 2 + + while (val.length < len) { + val = '0' + val + } + + return val +} + +const regexEscape = (str) => str.replace(/[|\\{()[^$+*?.-]/g, '\\$&') + +const fullTimeReg = /d{1,4}|M{1,4}|yy(?:yy)?|S{1,3}|Do|ZZ|([HhMsDm])\1?|[aA]|"[^"]*"|'[^']*'/g +const dayNames = DATEPICKER.fullWeeks +const monthNames = DATEPICKER.fullMonths +const monthNamesShort = shorten(monthNames, 3) +const dayNamesShort = shorten(dayNames, 3) +const parts = ['th', 'st', 'nd', 'rd'] + +fecha.i18n = { + dayNames, + monthNames, + dayNamesShort, + monthNamesShort, + amPm: ['am', 'pm'], + doFn: (D) => D + parts[D % 10 > 3 ? 0 : ((D - (D % 10) !== 10) * D) % 10] +} + +const formatFlags = { + D: (dateObj) => dateObj.getDay(), + DD: (dateObj) => pad(dateObj.getDay()), + Do: (dateObj, i18n) => i18n.doFn(dateObj.getDate()), + d: (dateObj) => dateObj.getDate(), + dd: (dateObj) => pad(dateObj.getDate()), + ddd: (dateObj, i18n) => i18n.dayNamesShort[dateObj.getDay()], + dddd: (dateObj, i18n) => i18n.dayNames[dateObj.getDay()], + M: (dateObj) => dateObj.getMonth() + 1, + MM: (dateObj) => pad(dateObj.getMonth() + 1), + MMM: (dateObj, i18n) => i18n.monthNamesShort[dateObj.getMonth()], + MMMM: (dateObj, i18n) => i18n.monthNames[dateObj.getMonth()], + yy: (dateObj) => pad(String(dateObj.getFullYear()), 4).substr(2), + yyyy: (dateObj) => pad(dateObj.getFullYear(), 4), + h: (dateObj) => dateObj.getHours() % 12 || 12, + hh: (dateObj) => pad(dateObj.getHours() % 12 || 12), + H: (dateObj) => dateObj.getHours(), + HH: (dateObj) => pad(dateObj.getHours()), + m: (dateObj) => dateObj.getMinutes(), + mm: (dateObj) => pad(dateObj.getMinutes()), + s: (dateObj) => dateObj.getSeconds(), + ss: (dateObj) => pad(dateObj.getSeconds()), + S: (dateObj) => Math.round(dateObj.getMilliseconds() / 100), + SS: (dateObj) => pad(Math.round(dateObj.getMilliseconds() / 10), 2), + SSS: (dateObj) => pad(dateObj.getMilliseconds(), 3), + a: (dateObj, i18n) => (dateObj.getHours() < 12 ? i18n.amPm[0] : i18n.amPm[1]), + A: (dateObj, i18n) => (dateObj.getHours() < 12 ? i18n.amPm[0].toUpperCase() : i18n.amPm[1].toUpperCase()), + ZZ: (dateObj) => { + const offset = dateObj.getTimezoneOffset() + return (offset > 0 ? '-' : '+') + pad(Math.floor(Math.abs(offset) / 60) * 100 + (Math.abs(offset) % 60), 4) + } +} + +const parseFlags = { + d: [ + twoDigits, + (date, value) => { + date.day = value + } + ], + Do: [ + twoDigits + word, + (date, value) => { + date.day = parseInt(value, 10) + } + ], + M: [ + twoDigits, + (date, value) => { + date.month = value - 1 + } + ], + yy: [ + twoDigits, + (date, value) => { + const now = new Date() + const cent = Number(String(now.getFullYear()).substr(0, 2)) + date.year = String(value > 68 ? cent - 1 : cent) + value + } + ], + h: [ + twoDigits, + (date, value) => { + date.hour = value + } + ], + m: [ + twoDigits, + (date, value) => { + date.minute = value + } + ], + s: [ + twoDigits, + (date, value) => { + date.second = value + } + ], + yyyy: [ + fourDigits, + (date, value) => { + date.year = value + } + ], + S: [ + '\\d', + (date, value) => { + date.millisecond = value * 100 + } + ], + SS: [ + '\\d{2}', + (date, value) => { + date.millisecond = value * 10 + } + ], + SSS: [ + threeDigits, + (date, value) => { + date.millisecond = value + } + ], + D: [twoDigits, noop], + ddd: [word, noop], + MMM: [word, monthUpdate('monthNamesShort')], + MMMM: [word, monthUpdate('monthNames')], + a: [ + word, + (date, value, i18n) => { + const val = value.toLowerCase() + if (val === i18n.amPm[0]) { + date.isPm = false + } else if (val === i18n.amPm[1]) { + date.isPm = true + } + } + ], + ZZ: [ + '[^\\s]*?[\\+\\-]\\d\\d:?\\d\\d|[^\\s]*?Z', + (date, value) => { + let parts = String(value).match(/([+-]|\d\d)/gi) + let minutes + + if (parts) { + minutes = Number(parts[1] * 60) + parseInt(parts[2], 10) + date.timezoneOffset = parts[0] === '+' ? minutes : -minutes + } + } + ] +} + +const fmts = ['A', 'DD', 'dd', 'mm', 'hh', 'MM', 'ss', 'hh', 'H', 'HH'] + +fecha.masks = formats +parseFlags.dddd = parseFlags.ddd + +fmts.forEach((name) => { + if (name === 'MM') { + parseFlags[name] = parseFlags[name.substr(0, 1)] + } else { + parseFlags[name] = parseFlags[name.substr(0, 1).toLowerCase()] + } +}) + +fecha.format = (dateObj, mask, i18nSettings) => { + const i18n = i18nSettings || fecha.i18n + + if (typeof dateObj === 'number') { + dateObj = new Date(dateObj) + } + + if (!isDate(dateObj) || isNaN(dateObj.getTime())) { + throw new Error('Invalid Date in fecha.format') + } + + mask = fecha.masks[mask] || mask || fecha.masks.default + + let literals = [] + + mask = mask.replace(literal, ($0, $1) => { + literals.push($1) + return '@@@' + }) + + mask = mask.replace(fullTimeReg, ($0) => + $0 in formatFlags ? formatFlags[$0](dateObj, i18n) : $0.slice(1, $0.length - 1) + ) + + return mask.replace(/@@@/g, () => literals.shift()) +} + +const getNewFormat = (format, parseInfo) => { + let literals = [] + + let newFormat = regexEscape(format).replace(fullTimeReg, ($0) => { + if (parseFlags[$0]) { + const info = parseFlags[$0] + parseInfo.push(info[1]) + + return '(' + info[0] + ')' + } + + return $0 + }) + + newFormat = newFormat.replace(/@@@/g, () => literals.shift()) + + return newFormat +} + +const getDate = (dateInfo) => { + let date + const today = new Date() + + if (!isNull(dateInfo.timezoneOffset)) { + dateInfo.minute = Number(dateInfo.minute || 0) - Number(dateInfo.timezoneOffset) + + const { year, month, day, hour, minute, second, millisecond } = dateInfo + + date = new Date( + Date.UTC(year || today.getFullYear(), month || 0, day || 1, hour || 0, minute || 0, second || 0, millisecond || 0) + ) + } else { + const { year, month, day, hour, minute, second, millisecond } = dateInfo + + date = new Date( + year || today.getFullYear(), + month || 0, + day || 1, + hour || 0, + minute || 0, + second || 0, + millisecond || 0 + ) + } + return date +} + +fecha.parse = (dateStr, format, i18nSettings) => { + const i18n = i18nSettings || fecha.i18n + + if (typeof format !== 'string') { + throw new TypeError('Invalid format in fecha.parse') + } + + format = fecha.masks[format] || format + + if (dateStr.length > 1000) { + return null + } + + let dateInfo = {} + let parseInfo = [] + let literals = [] + + format = format.replace(literal, ($0, $1) => { + literals.push($1) + return '@@@' + }) + + const newFormat = getNewFormat(format, parseInfo) + const matches = dateStr.match(new RegExp(newFormat, 'i')) + + if (!matches) { + return null + } + + for (let i = 1, len = matches.length; i < len; i++) { + parseInfo[i - 1](dateInfo, matches[i], i18n) + } + + if (dateInfo.isPm === true && !isNull(dateInfo.hour) && Number(dateInfo.hour) !== 12) { + dateInfo.hour = Number(dateInfo.hour) + 12 + } else if (dateInfo.isPm === false && Number(dateInfo.hour) === 12) { + dateInfo.hour = 0 + } + + return getDate(dateInfo) +} + +export { fecha } diff --git a/packages/utils/src/form/index.ts b/packages/utils/src/form/index.ts new file mode 100644 index 0000000000..bc9e675983 --- /dev/null +++ b/packages/utils/src/form/index.ts @@ -0,0 +1,6 @@ +export const FORM_ITEM = 'FormItem' + +export const FORM_EVENT = { + change: 'form.change', + blur: 'form.blur' +} diff --git a/packages/utils/src/fullscreen/apis.ts b/packages/utils/src/fullscreen/apis.ts new file mode 100644 index 0000000000..64fb08cbdb --- /dev/null +++ b/packages/utils/src/fullscreen/apis.ts @@ -0,0 +1,197 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { extend } from '../object' +import { on, off } from '../dom' +import screenfull from './screenfull' + +const defaults = { + callback: () => undefined, + fullscreenClass: 'fullscreen', + pageOnly: false, + teleport: false +} + +let token +let parentNode + +const setStyle = (element, style) => { + element.style.position = style.position + element.style.left = style.left + element.style.top = style.top + element.style.width = style.width + element.style.height = style.height + element.style.zIndex = style.zIndex +} + +const resetElement = (api) => { + const targetEle = api.targetElement + + if (targetEle) { + // 移除全屏class + targetEle.classList.remove(api.opts.fullscreenClass) + + if (api.opts.teleport || api.opts.pageOnly) { + if (api.opts.teleport && parentNode) { + // 还原位置 + parentNode.insertBefore(targetEle, token) + parentNode.removeChild(token) + } + // 移除样式 + if (targetEle.__styleCache) { + setStyle(targetEle, targetEle.__styleCache) + } + } + } +} + +const setTargetStyle = (target, options) => { + const { position, left, top, width, height, zIndex } = target.style + // 添加全屏class + target.classList.add(options.fullscreenClass) + // teleport或者网页全屏时,为目标元素添加全屏样式 + if (options.teleport || options.pageOnly) { + const style = { + position: 'fixed', + left: '0', + top: '0', + width: '100%', + height: '100%' + } + + target.__styleCache = { position, left, top, width, height, zIndex } + options.zIndex && (style.zIndex = options.zIndex) + setStyle(target, style) + } +} + +const getOptions = (screenfull, options, target) => { + options = extend({}, defaults, options) + + // body不可teleport + if (target === document.body) { + options.teleport = false + } + // 不支持全屏api则自动启用网页全屏 + if (!screenfull.isEnabled) { + options.pageOnly = true + } + return options +} + +const api = { + targetElement: null, + opts: null, + isEnabled: screenfull.isEnabled, + isFullscreen: false, + toggle(target, options, force) { + if (force === undefined) { + // 如果已经是全屏状态,则退出 + return !this.isFullscreen ? this.request(target, options) : this.exit() + } + + return force ? this.request(target, options) : this.exit() + }, + request(targetEle, options) { + if (this.isFullscreen) { + return Promise.resolve() + } + // 默认全屏对象为body + if (!targetEle) { + targetEle = document.body + } + + this.opts = getOptions(screenfull, options, targetEle) + + setTargetStyle(targetEle, this.opts) + // teleport:将目标元素挪到body下,并在原地留一个标记用于还原 + if (this.opts.teleport) { + parentNode = targetEle.parentNode + + if (parentNode) { + token = document.createComment('fullscreen-token') + parentNode.insertBefore(token, targetEle) + document.body.appendChild(targetEle) + } + } + + if (this.opts.pageOnly) { + // 网页全屏模式 按键回调 + const keypressCallback = (e) => { + if (e.key === 'Escape') { + off(document, 'keyup', keypressCallback) + this.exit() + } + } + + this.isFullscreen = true + this.targetElement = targetEle + + off(document, 'keyup', keypressCallback) + on(document, 'keyup', keypressCallback) + + if (this.opts.callback) { + this.opts.callback(this.isFullscreen) + } + + return Promise.resolve() + } else { + // 全屏api模式 全屏api事件回调 + const fullScreenCallback = () => { + if (!screenfull.isFullscreen) { + // 退出全屏时解绑回调 + screenfull.off('change', fullScreenCallback) + resetElement(this) + } + + this.isFullscreen = screenfull.isFullscreen + + this.targetElement = !this.opts.teleport ? screenfull.targetElement : targetEle || null + + if (this.opts.callback) { + this.opts.callback(screenfull.isFullscreen) + } + } + + screenfull.on('change', fullScreenCallback) + + return screenfull.request(this.opts.teleport ? document.body : targetEle) + } + }, + exit() { + if (!this.isFullscreen) { + return Promise.resolve() + } + + if (this.opts.pageOnly) { + resetElement(this) + + this.isFullscreen = false + this.targetElement = null + + if (this.opts.callback) { + this.opts.callback(this.isFullscreen) + } + + return Promise.resolve() + } + + return screenfull.exit() + } +} + +// 向下兼容 +api.support = api.isEnabled +api.getState = () => api.isFullscreen +api.enter = api.request + +export default api diff --git a/packages/utils/src/fullscreen/index.ts b/packages/utils/src/fullscreen/index.ts new file mode 100644 index 0000000000..7e76db8da2 --- /dev/null +++ b/packages/utils/src/fullscreen/index.ts @@ -0,0 +1,4 @@ +import FullscreenApi from './apis' +import sf from './screenfull' + +export { FullscreenApi, sf } diff --git a/packages/utils/src/fullscreen/screenfull.ts b/packages/utils/src/fullscreen/screenfull.ts new file mode 100644 index 0000000000..e808c1a2bd --- /dev/null +++ b/packages/utils/src/fullscreen/screenfull.ts @@ -0,0 +1,200 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { on, off } from '../dom' +import { isBrowser } from '../browser' + +const fullscreenApi = [ + 'fullscreenElement', + 'fullscreenEnabled', + 'requestFullscreen', + 'exitFullscreen', + 'fullscreenchange', + 'fullscreenerror' +] + +const fullscreenApiMoz = [ + 'mozFullScreenElement', + 'mozFullScreenEnabled', + 'mozRequestFullScreen', + 'mozCancelFullScreen', + 'mozfullscreenchange', + 'mozfullscreenerror' +] + +const fullscreenApiWebkit = [ + 'webkitFullscreenElement', + 'webkitFullscreenEnabled', + 'webkitRequestFullscreen', + 'webkitExitFullscreen', + 'webkitfullscreenchange', + 'webkitfullscreenerror' +] + +const fullscreenApiMs = [ + 'msFullscreenElement', + 'msFullscreenEnabled', + 'msRequestFullscreen', + 'msExitFullscreen', + 'MSFullscreenChange', + 'MSFullscreenError' +] + +const fullscreenApiMap = [fullscreenApi, fullscreenApiWebkit, fullscreenApiMoz, fullscreenApiMs] + +const document = typeof window !== 'undefined' && typeof window.document !== 'undefined' ? window.document : {} + +let fullscreenEvents = null + +const getFullScreenEvents = () => { + if (!isBrowser) return + + for (let i = 0, len = fullscreenApiMap.length; i < len; i++) { + let eventName = fullscreenApiMap[i] + + if (eventName && eventName[1] in document) { + fullscreenEvents = {} + + for (i = 0; i < eventName.length; i++) { + fullscreenEvents[fullscreenApiMap[0][i]] = eventName[i] + } + + return + } + } +} + +getFullScreenEvents() + +const eventNameMap = { + change: fullscreenEvents && fullscreenEvents.fullscreenchange, + error: fullscreenEvents && fullscreenEvents.fullscreenerror +} + +const screenfull = { + request(element, options) { + return new Promise((resolve, reject) => { + const onFullscreenEntered = () => { + this.off('change', onFullscreenEntered) + resolve() + } + + this.on('change', onFullscreenEntered) + + element = element || (isBrowser ? document.documentElement : null) + + if (element && fullscreenEvents && element[fullscreenEvents.requestFullscreen]) { + const promiseReturn = element[fullscreenEvents.requestFullscreen](options) + + if (promiseReturn instanceof Promise) { + promiseReturn.then(onFullscreenEntered).catch(reject) + } + } else { + reject(new Error('Fullscreen API not supported or element is null.')) + } + }) + }, + exit() { + return new Promise((resolve, reject) => { + if (!this.isFullscreen) { + resolve() + return + } + + const onFullscreenExit = () => { + this.off('change', onFullscreenExit) + resolve() + } + + this.on('change', onFullscreenExit) + + if (isBrowser && fullscreenEvents && document[fullscreenEvents.exitFullscreen]) { + const promiseReturn = document[fullscreenEvents.exitFullscreen]() + + if (promiseReturn instanceof Promise) { + promiseReturn.then(onFullscreenExit).catch(reject) + } + } else { + reject(new Error('Fullscreen API not supported.')) + } + }) + }, + toggle(element, options) { + return this.isFullscreen ? this.exit() : this.request(element, options) + }, + onchange(callback) { + this.on('change', callback) + }, + onerror(callback) { + this.on('error', callback) + }, + on(event, callback) { + const eventName = eventNameMap[event] + + if (eventName && isBrowser) { + on(document, eventName, callback) + } + }, + off(event, callback) { + const eventName = eventNameMap[event] + + if (eventName && isBrowser) { + off(document, eventName, callback) + } + }, + raw: fullscreenEvents || {} +} + +// 处理屏幕全屏状态的方法 +if (isBrowser) { + Object.defineProperties(screenfull, { + isFullscreen: { + get() { + return !!document[fullscreenEvents && fullscreenEvents.fullscreenElement] + } + }, + element: { + enumerable: true, + get() { + return document[fullscreenEvents && fullscreenEvents.fullscreenElement] + } + }, + isEnabled: { + enumerable: true, + get() { + return !!document[fullscreenEvents && fullscreenEvents.fullscreenEnabled] + } + } + }) +} else { + Object.defineProperties(screenfull, { + isFullscreen: { + get() { + return false + } + }, + element: { + enumerable: true, + get() { + return null + } + }, + isEnabled: { + enumerable: true, + get() { + return false + } + } + }) +} + +export default screenfull diff --git a/packages/utils/src/function/index.ts b/packages/utils/src/function/index.ts new file mode 100644 index 0000000000..1837cf34a0 --- /dev/null +++ b/packages/utils/src/function/index.ts @@ -0,0 +1,27 @@ +import { isPromise } from '../type' + +export const noop = () => {} + +export const callInterceptor = (interceptor, { args = [], done, canceled, error }) => { + if (interceptor) { + const returnVal = interceptor(...args) + + if (isPromise(returnVal)) { + returnVal + .then((value) => { + if (value) { + done() + } else if (canceled) { + canceled() + } + }) + .catch(error || noop) + } else if (returnVal) { + done() + } else if (canceled) { + canceled() + } + } else { + done() + } +} diff --git a/packages/utils/src/globalConfig/index.ts b/packages/utils/src/globalConfig/index.ts new file mode 100644 index 0000000000..bd33d8f4f1 --- /dev/null +++ b/packages/utils/src/globalConfig/index.ts @@ -0,0 +1,14 @@ +export const isWeb = () => + typeof window !== 'undefined' && typeof document !== 'undefined' && window.document === document + +/** 获取globalThis. 在web上, window===globalThis 在node.js中, global=== globalThis。 + * 所以该函数没必要存在,待移除 + */ +export const getWindow = () => (isWeb() ? window : global) + +// 需要微前端的用户传入该变量 +export const globalConfig = { + viewportWindow: null // 获取真实视口的window,解决在微前端中某些bug +} + +export const getViewportWindow = () => globalConfig.viewportWindow || window diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 871f86f491..a6cd61e0da 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,11 +1,232 @@ import xss from './xss' import logger from './logger' import crypt from './crypt' +import ResizeObserver from './resize-observer' -export { xss, logger, crypt } +export { xss, logger, crypt, ResizeObserver } -export default { - xss, - logger, - crypt -} +export { getWindow, isWeb, globalConfig, getViewportWindow } from './globalConfig' +export { getDays, getWeek, lastMonth, nextMonth, getCalendar, transformArray, parseDate } from './calendar' +export { + isLeapYear, + toDate, + format as formatDate, + getDateWithNewTimezone, + toDateStr, + getWeekOfFirstDay, + getLocalTimezone, + getStrTimezone +} from './date' + +export { + toString, + hasOwn, + isNull, + typeOf, + isObject, + isFunction, + isPlainObject, + isEmptyObject, + isNumber, + isNumeric, + isDate, + isSame, + isRegExp, + isPromise +} from './type' + +export { + formatTypes, + escapeChars, + isNullOrEmpty, + camelize, + capitalize, + hyphenate, + toJson, + getLength, + fillChar, + random, + guid, + escapeHtml, + escape, + fieldFormat, + format as formatString, + truncate, + tryToConvert, + toInt, + tryToInt, + toNumber, + tryToNumber, + toDecimal, + tryToDecimal, + toCurrency, + tryToCurrency, + toBoolValue, + toRate, + toFileSize, + formatFileSize, + isKorean, + omitText +} from './string' + +// 待转移到globalConfig +export { isBrowser, globalEnvironment, browser } from './browser' + +export { roundFixed, Decimal, toFixed as toFixedDecimal, formatNumber, recoverNumber } from './decimal' +export { each, getObj, setObj, copyField, copyArray, isEqual, isEachEqual, extend, toJsonStr, merge } from './object' + +export { + supportBigInt, + trimNumber, + isE, + validateNumber, + getNumberPrecision, + num2str, + getMiniDecimal, + BigIntDecimal, + NumberDecimal, + setDecimalClass, + lessEquals, + equalsDecimal, + toFixed as toFixedBigInt +} from './bigInt' + +export { getDataset } from './dataset' +export { indexOf, find, remove, sort, push, unique, toObject, transformPidToChildren, transformTreeData } from './array' + +// 原来common的index.ts 的定义 都是全局变量, 像 DATEPICKER等, 应该移到各自使用的组件内部中去, 待移除 +export { + KEY_CODE, + POSITION, + SORT, + REFRESH_INTERVAL, + IPTHRESHOLD, + DATE, + DATEPICKER, + BROWSER_NAME, + MOUSEDELTA, + VALIDATE_STATE, + CASCADER, + version +} from './common' + +// 待移除 ,写到各自组件内部中去 +export { FORM_ITEM, FORM_EVENT } from './form' + +export { Validator } from './validate' + +export { emitEvent, getActualTarget, correctTarget } from './event' + +export { noop, callInterceptor } from './function' + +// 当真有人这么用的吗? 待移除 +export { + unknownProp, + numericProp, + truthProp, + makeRequiredProp, + makeArrayProp, + makeNumberProp, + makeNumericProp, + makeStringProp, + makeStringValidProp +} from './prop-util' + +export { fastdom, fastdomAsync, fastdomSandbox } from './fastdom' + +// 待移除。 移到fullscreen组件 内部去, 或起个更好的名字 +export { FullscreenApi, sf } from './fullscreen' + +export { NODE_KEY, getNodeKey, markNodeData, getChildState, Node, TreeStore } from './tree-model' + +// 待移除, 移到loading中去, 或起个更好的名字 +export { afterLeave } from './after-leave' + +// 原来位置 common/deps/data.ts +export { fecha } from './fecha' + +// 与 date.ts 合并一下, 有几个重名变量,待整理, 如果功能一致就合并 +export { + getI18nSettings, + isDate as isDate1, + toDate as toDate1, + isDateObject, + formatDate as formatDate1, + parseDate as parseDate1, + getDayCountOfMonth, + getDayCountOfYear, + getFirstDayOfMonth, + prevDate, + nextDate, + getStartDateOfMonth, + getWeekNumber, + getRangeHours, + range, + getMonthDays, + getPrevMonthLastDays, + getRangeMinutes, + modifyDate, + modifyTime, + modifyWithTimeString, + clearTime, + clearMilliseconds, + limitTimeRange, + timeWithinRange, + changeYearMonthAndClampDate, + nextMonth as nextMonth1, + prevMonth, + nextYear, + prevYear, + extractTimeFormat, + extractDateFormat, + validateRangeInOneMonth +} from './date-util' + +export { debounce } from './debounce' +export { throttle } from './throttle' + +export { + isServer, + on, + off, + once, + hasClass, + addClass, + removeClass, + getStyle, + setStyle, + isScroll, + getScrollContainer, + isInContainer, + getDomNode, + getScrollTop, + stopPropagation, + preventDefault, + getScrollParent, + useScrollParent, + isDisplayNone +} from './dom' + +// 待移除 到组件内部中去 +export { init as initEspace, espaceCtrl } from './espace-ctrl' + +export { Memorize } from './memorize' + +// 待修改名称 +export { getScrollParent as getScrollParent1, Popper } from './popper' + +export { PopupManager } from './popup-manager' + +export { addResizeListener, removeResizeListener } from './resize-event' + +// 这些为什么不移到dom中去呢 +export { scrollWidth } from './scroll-width' + +export { scrollIntoView } from './scroll-into-view' + +// 待改造成hooks +export { getDirection, touchStart, touchMove, resetTouchStatus } from './touch' + +export { emulate } from './touch-emulator' + +export { uploadAjax } from './upload-ajax' diff --git a/packages/utils/src/logger/index.ts b/packages/utils/src/logger/index.ts index e2a235d18a..ea605c9920 100644 --- a/packages/utils/src/logger/index.ts +++ b/packages/utils/src/logger/index.ts @@ -1,4 +1,4 @@ -import { getWindow } from '../window' +import { getWindow } from '../globalConfig' const _win: any = getWindow() /** 使用 logger.xxx 代替 window.console.xxx, 避免语法警告 */ diff --git a/packages/utils/src/memorize/index.ts b/packages/utils/src/memorize/index.ts new file mode 100644 index 0000000000..a7896c001b --- /dev/null +++ b/packages/utils/src/memorize/index.ts @@ -0,0 +1,160 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +export class Memorize { + constructor(value, options = {}) { + if (value && typeof value === 'object') { + options = value + } else { + value = [] + } + + if (typeof options.key !== 'string' || !options.key) { + throw new Error('Memorize Initialization error.') + } + + this._prefix = 'tiny_memorize_' + this._customField1 = 'frequency' + this._customField2 = 'time' + this._sortBy = (options.sortBy || 'frequency').toUpperCase() + this._sort = (options.sort || 'desc').toUpperCase() + this._dataKey = options.dataKey || 'value' + this._highlightClass = options.highlightClass || 'memorize-highlight' + this._highlightNum = options.highlightNum || Infinity + this._cacheNum = options.cacheNum || Infinity + this._serialize = options.serialize || JSON.stringify + this._deserialize = options.deserialize || JSON.parse + this.setKey(options.key) + this.assemble(value) + } + + setKey(storeKey) { + this._storeKey = this._prefix + (storeKey || Number(new Date())) + } + + getValue(isSort = true) { + const storeVlue = window.localStorage[this._storeKey] || '' + + if (storeVlue) { + try { + const list = this._deserialize(storeVlue) + return isSort ? this.sort(list) : list + } catch (e) { + return [] + } + } + + return [] + } + + setValue(value) { + try { + window.localStorage.setItem(this._storeKey, this._serialize(value)) + } catch (e) { + throw new Error('Memorize set localStorage error.') + } + } + + clear() { + window.localStorage.removeItem(this._storeKey) + } + + add(dataKey) { + const list = this.getValue(false) + const newData = { + key: dataKey + } + + newData[this._customField1] = 1 + newData[this._customField2] = Number(new Date()) + + if (list.length < this._cacheNum) { + list.push(newData) + this.setValue(list) + } + } + + updateByKey(dataKey) { + let isChanged = false + const list = this.getValue(false) + + list.some((item) => { + if (item.key === dataKey) { + item[this._customField1] = (item[this._customField1] || 0) + 1 + item[this._customField2] = Number(new Date()) + isChanged = true + + return true + } + + return false + }) + + isChanged ? this.setValue(list) : this.add(dataKey) + } + + sort(list) { + if (Array.isArray(list)) { + return list.sort((x, y) => { + const isDesc = this._sort === 'DESC' + const compare = isDesc ? [-1, 1] : [1, -1] + + const sortField = this._sortBy === 'FREQUENCY' ? this._customField1 : this._customField2 + + const xField = x[sortField] + const yField = y[sortField] + + if (isNaN(xField)) { + return isDesc ? -1 : 1 + } else if (isNaN(yField)) { + return -1 + } + + return xField > yField ? compare[0] : compare[1] + }) + } else { + return list + } + } + + assemble(list) { + const storeValue = this.getValue(true) + if (!(Array.isArray(list) && list.length) || !storeValue.length) { + return list + } + + let matchCount = 0 + const handler = (storeItem) => (listItem, index) => { + if (listItem[this._dataKey] === storeItem.key) { + matchCount++ + list.splice(index, 1) + + if (matchCount <= this._highlightNum) { + listItem._highlightClass = this._highlightClass + } + + list.unshift(listItem) + + return true + } + + return false + } + + for (let i = storeValue.length - 1; i > -1; i--) { + const storeItem = storeValue[i] + list.some(handler(storeItem)) + } + + return list + } +} diff --git a/packages/utils/src/object/index.ts b/packages/utils/src/object/index.ts new file mode 100644 index 0000000000..7dbfabf25c --- /dev/null +++ b/packages/utils/src/object/index.ts @@ -0,0 +1,436 @@ +/* eslint-disable no-cond-assign */ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { hasOwn, typeOf, isObject, isPlainObject, isNull } from '../type' + +/** + * 将对象的每个属性值进行循环处理。 + * + * let obj = { name: 'jacky', age: 28, job: 'coder', dept: 'it' } + * each(obj, function (field, value) { + * if (field === 'name') { + * // do something + * } + * }) + * + * @param {Object} obj 要处理的对象 + * @param {Function} handle 进行循环处理的函数,函数返回false, 则跳出循环 + */ +export const each = (obj: object, handle: (key: string, value?: any) => boolean) => { + if (typeof handle !== 'function') { + return + } + for (const name in obj) { + if (hasOwn.call(obj, name)) { + if (handle(name, obj[name]) === false) { + break + } + } + } +} + +/** 支持深度合并对象 + * 【首参为true】,则每一层是对象就合并, 但是简单值或数组时,就后面覆盖过来 + * 【首参为对象】,则仅第一层合并, 类似Object.assign + * 合并对象中,有非object类型的,统统忽略! + * @returns {Object} + */ + +// eslint-disable-next-line import/no-mutable-exports +let extend: (deep: boolean | object, ...values: object[]) => object + +/** + * 通过路径,获得对象指向位置的值。 + * + * getObj({ a: { b: 1 } }, 'a.b') // 1 + * getObj({ a: { b: 1 } }, 'data.a.b', true) // 1 + * getObj({ a: { b: undefined } }, 'a.b') // undefined + * + * @param {Object} data 查找数据源 + * @param {String} names 查找属性命名空间字符串 + * @param {Boolean} [isExceptRoot] 是否排除 names 的第一个节点,默认 false + * @returns {Object} + */ +export const getObj = (data: object, names: string, isExceptRoot?: boolean) => { + if (!data || !isPlainObject(data) || !names || typeof names !== 'string') { + return + } + + const nameArr = names.split('.') + + let obj = data + const len = nameArr.length + + if (len > 1) { + const startIndex = isExceptRoot ? 1 : 0 + + for (let i = startIndex; i < len; i++) { + obj = obj[nameArr[i]] + + if (isNull(obj)) { + return obj + } + } + + return obj + } else { + return obj[nameArr[0]] + } +} + +/** + * 通过路径,设置对象指向位置的值。 + * + * let obj = { limit: 5, data: { a: 1, b: 2 }, info: { a: 1, b: 2 } } + * setObj(obj, 'limit', 10) // obj.limit = 10 + * setObj(obj, 'data', { c: 3 }, true) // obj.data = { a: 1, b: 2, c: 3 } + * setObj(obj, 'info', { c: 3 }) // obj.info = { c: 3 } + * setObj(obj, 'info.c', { d: 4 }, true) // obj.info = { c: { d: 4 } } + * setObj(obj, 'info.c', { e: 5 }, true) // obj.info = { c: { d: 4, e: 5 } } + * + * @param {Object} data 设置数据源 + * @param {String} names 查找属性命名空间字符串 + * @param {Object} value 设置的值 + * @param {boolean} [isMerge] 是否覆盖还是合并,默认覆盖 + * @returns {Object} + */ +export const setObj = (data: object, names: string, value: any, isMerge) => { + if (!data || !isPlainObject(data) || !names || typeof names !== 'string') { + return data + } + + const nameArr = names.split('.') + + const obj = data + let len = nameArr.length + let item = nameArr[0] + + if (len > 1) { + len-- + + let tmpl = obj + let name, target + + for (let i = 0; i < len; i++) { + name = nameArr[i] + target = tmpl[name] + + if (target === null || !isPlainObject(target)) { + tmpl[name] = {} + target = tmpl[name] + } + + tmpl = target + } + + item = nameArr[len] + + isMerge + ? isPlainObject(tmpl[item]) + ? extend(true, tmpl[item], value) + : (tmpl[item] = value) + : (tmpl[item] = value) + } else { + isMerge + ? isPlainObject(obj[item]) + ? extend(true, obj[item], value) // + : (obj[item] = value) + : (obj[item] = value) + } + + return obj +} + +/** + * 根据指定的字段属性名,复制对应的数据。 + * + * let obj = { a: 1, b: '2', c: [3, 4, 5], d: { e: 'good' } } + * copyField(obj, ['a', 'b']) // { a: 1, b: '2' } + * copyField(obj, ['a', 'b'], false, true) // { c: [3, 4, 5], d: { e: 'good' } } + * + * @param {Object} data 源数据,合并数据源 + * @param {Array} [fields] 指定的值得命名空间字符串的数值。 不传入,默认为克隆一份数据出来 + * @param {Boolean} [isMerge] 是否覆盖还是合并,默认false覆盖 + * @param {Boolean} [isExclude] 是否排除指定的fields复制,默认false + * @returns {Array} + */ +export const copyField = (data: object, fields?: string[], isMerge?: boolean, isExclude?: boolean) => { + const setValue = (obj, result, name, key, isMerge?) => { + const include = key.indexOf(name) === 0 + const keySplit = key.split(name) + const hasNextDot = keySplit[1] && keySplit[1].indexOf('.') === 0 + + if (name === key || (include && hasNextDot)) { + if (name !== key) { + each(getObj(obj, name), (field) => { + setValue(obj, result, `${name}.${field}`, key) + return true + }) + } + } else { + if (fields && !fields.includes(name)) { + setObj(result, name, getObj(obj, name), isMerge) + } + } + } + const innerCopyFields = (obj, fields, isMerge, isExclude) => { + const result = {} + + if (isExclude) { + each(obj, (name) => fields.forEach((key) => setValue(obj, result, name, key, isMerge))) + } else { + fields.forEach((field) => setObj(result, field, getObj(obj, field), isMerge)) + } + + return result + } + + if (isPlainObject(data)) { + return Array.isArray(fields) + ? innerCopyFields(data, fields, isMerge, isExclude) + : extend(isMerge !== false, {}, data) + } + + return data +} + +/** + * 复制数组数据,数据如包含对象,则深度复制,并返回一个新数组,如果不是数组则直接返回原对象。 + * + * let arr1 = [ 1, 2, { name: 'jacky' } ] + * let arr2 = copyArray(arr1) + */ +export const copyArray = (arr: any[]) => { + return Array.isArray(arr) ? arr.map((item) => copyField(item)) : arr +} + +/** + * 对象复制,支持深度复制,修复 $.extend 数组复制的问题, 参数同 $.extend。 + * + * let obj1 = { a: 1, b: 2 } + * let obj2 = { c: 3, d: 4 } + * extend(obj1, obj2) // { a: 1, b: 2, c: 3, d: 4 } + * + * @param {Boolean} deep 如果是 true,合并成为递归(又叫做深拷贝)。仅支持 true | 空 + * @param {Object} target 对象扩展,这将接收新的属性。 + * @param {Object} object1 一个对象,它包含额外的属性合并到第一个参数。 + * @param {Object} objectN 包含额外的属性合并到第一个参数 + * @returns {Object} + */ + +const deepCopy = (target, name, deep, copy, src) => { + let copyIsArray + if (deep && copy && (isPlainObject(copy) || (copyIsArray = Array.isArray(copy)))) { + if (copyIsArray) { + copyIsArray = false + target[name] = copyArray(copy) + } else { + const clone = src && isPlainObject(src) ? src : {} + target[name] = extend(deep, clone, copy) + } + } else if (copy !== undefined) { + try { + target[name] = copy + } catch (e) { + // do nothing + } + } +} + +extend = function (...args) { + const length = args.length + let target = args[0] || {} + let i = 1 + let deep = false + + if (typeOf(target) === 'boolean') { + deep = target as boolean + target = args[i] || {} + i++ + } + + if (!isObject(target) && typeOf(target) !== 'function') { + target = {} + } + + for (; i < length; i++) { + const options = args[i] + + if (options !== null && isObject(options)) { + const names = Object.keys(options) + + for (const name of names) { + const src = target[name] + const copy = options[name] + + if (target !== copy) { + deepCopy(target, name, deep, copy, src) + } + } + } + } + + return target +} as any + +/** + * 可深层比较两个对象或两个数组是否相等。注意以源对象为比较基础 + * + * isEachEqual({a: 1}, {a: 1, b: 2}) // true + * isEachEqual({a: 1, b: 2}, {a: 1}) // false (以源对象为比较基础,所以它是false) + * isEachEqual({a: 1, b: {c: 3, d: 4}}, {a: 1, b: {c: 3, d: 5}}) // false + * + * @param {Object} data1 数据源对象 + * @param {Object} data2 对比目标对象 + * @param {Boolean} [deep] 是否深度遍历,默认为true + * @returns {Boolean} + */ +// eslint-disable-next-line import/no-mutable-exports +let isEachEqual: (data1: any, data2: any, deep?: boolean) => boolean + +/** + * 可深层比较两个对象是否相等。 + * 与`isEachEqual` 区别是: + * 1、2个对象交换位置,判断2次 + * 2、它可以指定要比较的属性分支["a.b","a.c"] (整个系统使用fields这块功能) + * + * isEqual({ a: { b: 1 } }, { a: { b: 1, c: 2 } }, false, [ 'a.b' ]) // false + * isEqual({ a: { b: 1 } }, { a: { b: 1, c: 2 } }, true, [ 'a.b' ]) // true + * + * @param {Object} sourceData 源对象 + * @param {Object} targetData 目标对象 + * @param {Boolean} [deep] 是否深度比较,默认为true + * @param {Array} [fields] 指定需要比较的字段的数组 + * @returns {Boolean} + */ +export const isEqual: (sourceData: object, targetData: object, deep?: boolean, fields?: string[]) => boolean = ( + sourceData: object, + targetData: object, + deep?: boolean, + fields?: string[] +) => { + if (typeOf(sourceData) === typeOf(targetData)) { + deep = deep !== false + + if (Array.isArray(fields)) { + const _sourceData = copyField(sourceData, fields) + const _targetData = copyField(targetData, fields) + + return isEqual(_sourceData, _targetData, deep) as boolean + } + + // 此处交换了位置判断 + const source = isEachEqual(sourceData, targetData, deep) + const target = isEachEqual(targetData, sourceData, deep) + + return source && target + } + + return false +} + +isEachEqual = (data1: any, data2: any, deep?: boolean) => { + if (!isPlainObject(data1)) { + // 当是数组的情况 + if (!Array.isArray(data1)) { + return data1 === data2 + } + if (data1.length !== data2.length) { + return false + } + + for (let i = 0, length = data1.length; i < length; i++) { + const result = isEqual(data1[i], data2[i], deep) + + if (!result) { + return false + } + } + + return true + } + // 对象的情况 + let bEqual = true + const names = Object.keys(data1) + + for (const name of names) { + if (hasOwn.call(data2, name)) { + const _data1 = data1[name] + const _data2 = data2[name] + + if ((deep && isObject(_data1)) || Array.isArray(_data1)) { + bEqual = isEachEqual(_data1, _data2, deep) + } else { + bEqual = _data1 === _data2 + } + } else { + bEqual = false + } + + if (bEqual === false) { + break + } + } + + return bEqual +} + +export { isEachEqual, extend } + +/** + * 将json对象序列化为字符串。 ----------重复实现待移除 + * + * let obj = { a: 1, b: 2 } + * toJsonStr(obj) // '{"a":1,"b":2}' + * obj.prop = obj + * toJsonStr(obj) // undefined 递归引用自己了,所以catch了 + * toJsonStr(null) // 'null' + * + * @param {Object} obj + * @returns {String} + */ +export const toJsonStr = (obj: any) => { + try { + return JSON.stringify(obj) + } catch (e) { + return undefined + } +} + +/** + * 将一个或多个源对象简单合并到目标对象中,合并时排除非 OwnProperty 及 undefined 属性。 + * 只处理第一层,功能基本等同于 Object.assign + * + * merge({ a: 1 }, { b: { c: 2 } }, { d: 3 }) // { a: 1, b: { c: 2 }, d: 3 } + * + * @param {Object} target 目标对象 + * @param {Object} [source] 源对象 + * @returns {Object} + */ +export const merge = function (target: object, ...rest: object[]) { + for (let i = 0, len = rest.length; i < len; i++) { + const source = rest[i] || {} + + for (const prop in source) { + if (hasOwn.call(source, prop)) { + const value = source[prop] + + if (value !== undefined) { + target[prop] = value + } + } + } + } + + return target +} diff --git a/packages/utils/src/popper/index.ts b/packages/utils/src/popper/index.ts new file mode 100644 index 0000000000..f72916cbdf --- /dev/null +++ b/packages/utils/src/popper/index.ts @@ -0,0 +1,905 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { on, off, isDisplayNone } from '../dom' +import { PopupManager } from '../popup-manager' +import { globalConfig } from '../globalConfig' +import { typeOf } from '../type' +import { isBrowser } from '../browser' + +const positions = ['left', 'right', 'top', 'bottom'] +const modifiers = ['shift', 'offset', 'preventOverflow', 'keepTogether', 'arrow', 'flip', 'applyStyle'] + +const DEFAULTS = { + arrowOffset: 0, + arrowElement: '[x-arrow]', + boundariesElement: 'viewport', + boundariesPadding: 5, + flipBehavior: 'flip', // 全局没有修改过它,所以它一直是flip + forceAbsolute: false, + gpuAcceleration: true, + offset: 0, + placement: 'bottom', + preventOverflowOrder: positions, + modifiers, // 此处是string数组, 构造函数调用之后转为函数数组 + updateHiddenPopperOnScroll: false // 滚动过程中是否更新隐藏的弹出层位置 +} + +/** 用 styles 对象赋值el.style */ +const setStyle = (el: HTMLElement, styles: object) => { + const isNumeric = (n) => n !== '' && !isNaN(parseFloat(n)) && isFinite(n) + + Object.keys(styles).forEach((prop) => { + let unit = '' + + if (~['width', 'height', 'top', 'right', 'bottom', 'left'].indexOf(prop) && isNumeric(styles[prop])) { + unit = 'px' + } + + el.style[prop] = styles[prop] + unit + }) +} + +/** 查找el的 offsetParent ,找不到则默认为 */ +const getOffsetParent = (el: HTMLElement) => { + let offsetParent = el.offsetParent as HTMLElement + + return offsetParent === window.document.body || !offsetParent ? window.document.documentElement : offsetParent +} + +/** 查找基本元素的计算属性值 */ +const getStyleComputedProperty = (el: HTMLElement, property: string) => { + if (!el || el.nodeType !== 1) { + return + } + + let css = window.getComputedStyle(el, null) + + return css[property] +} + +/** 向上查找,判断是不是某一层级有fixed */ +const isFixed = (el: HTMLElement) => { + if (el === window.document.body) { + return false + } + + if (getStyleComputedProperty(el, 'position') === 'fixed') { + return true + } + + return el.parentNode ? isFixed(el.parentNode as HTMLElement) : false +} + +/** 在页面上的相对视口位置。 也就是说滚动条会影响它的值 */ +const getBoundingClientRect = (el: HTMLElement) => { + let rect = el.getBoundingClientRect() + + return { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + width: rect.right - rect.left, + height: rect.bottom - rect.top + } +} + +/** 判断el的overflow是不是可能滚动的 */ +const isScrollElement = (el: HTMLElement) => { + const scrollTypes = ['scroll', 'auto'] + + return ( + scrollTypes.includes(getStyleComputedProperty(el, 'overflow')) || + scrollTypes.includes(getStyleComputedProperty(el, 'overflow-x')) || + scrollTypes.includes(getStyleComputedProperty(el, 'overflow-y')) + ) +} + +/** 设置transform等样式后,fixed定位不再相对于视口,使用1X1PX透明元素获取fixed定位相对于视口的修正偏移量。 */ +const getAdjustOffset = (parent: HTMLElement) => { + const placeholder = document.createElement('div') + setStyle(placeholder, { + opacity: 0, + position: 'fixed', + width: 1, + height: 1, + top: 0, + left: 0, + 'z-index': '-99' + }) + parent.appendChild(placeholder) + const result = getBoundingClientRect(placeholder) + parent.removeChild(placeholder) + return result +} + +/** 查找滚动父元素,只找第一个就返回 */ +export const getScrollParent: (el: HTMLElement) => HTMLElement = (el) => { + let parent = el.parentNode + + if (!parent) { + return el + } + + if (parent === window.document) { + if (window.document.body.scrollTop || window.document.body.scrollLeft) { + return window.document.body as HTMLElement + } + return window.document.documentElement + } + + if (isScrollElement(parent as any)) { + return parent as HTMLElement + } + + return getScrollParent(parent as any) +} + +/** 计算 el 在父元素中的定位 */ +const getOffsetRectRelativeToCustomParent = ( + el: HTMLElement, + parent: HTMLElement, + fixed: boolean, + popper: HTMLElement +) => { + let { top, left, width, height } = getBoundingClientRect(el) + + // 如果是fixed定位,需计算要修正的偏移量。 + if (fixed) { + if (popper.parentElement) { + const { top: adjustTop, left: adjustLeft } = getAdjustOffset(popper.parentElement) + top -= adjustTop + left -= adjustLeft + } + return { + top, + left, + bottom: top + height, + right: left + width, + width, + height + } + } + + let parentRect = getBoundingClientRect(parent) + let rect = { + top: top - parentRect.top, + left: left - parentRect.left, + bottom: top - parentRect.top + height, + right: left - parentRect.left + width, + width, + height + } + + return rect +} + +const getScrollTopValue = (el: HTMLElement) => + el === document.body ? Math.max(document.documentElement.scrollTop, document.body.scrollTop) : el.scrollTop + +const getScrollLeftValue = (el: HTMLElement) => + el === document.body ? Math.max(document.documentElement.scrollLeft, document.body.scrollLeft) : el.scrollLeft + +const getMaxWH = (body, html) => { + const height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight) + const width = Math.max(body.scrollWidth, body.offsetWidth, html.clientWidth, html.scrollWidth, html.offsetWidth) + return { width, height } +} + +/** 计算元素的margin盒子的大小 */ +const getOuterSizes = (el: HTMLElement) => { + let _display = el.style.display + let _visibility = el.style.visibility + + el.style.display = 'block' + el.style.visibility = 'hidden' + + let styles = window.getComputedStyle(el) + let x = parseFloat(styles.marginTop) + parseFloat(styles.marginBottom) + let y = parseFloat(styles.marginLeft) + parseFloat(styles.marginRight) + let result = { width: el.offsetWidth + y, height: el.offsetHeight + x } + + el.style.display = _display + el.style.visibility = _visibility + + return result +} + +/** 把字符串位置替换为反方向, 例如: left替换为right */ +const getOppositePlacement = (placement: string) => { + let hash = { left: 'right', right: 'left', bottom: 'top', top: 'bottom' } + + return placement.replace(/left|right|bottom|top/g, (matched) => hash[matched]) +} + +/** 克隆popperOffsets,并补上 right,bottom的值 */ +const getPopperClientRect = (popperOffsets: PopperOffsets) => { + let offsets = { ...popperOffsets } + + offsets.right = offsets.left + offsets.width + offsets.bottom = offsets.top + offsets.height + + return offsets +} + +/** 收集所有的带scroll的父元素到一个数组中 */ +const getAllScrollParents: (el: HTMLElement, parents?: HTMLElement[]) => HTMLElement[] = ( + el: HTMLElement, + parents = [] as HTMLElement[] +) => { + const parent = el.parentNode as HTMLElement + + if (parent) { + isScrollElement(parent) && parents.push(parent) + // 如果祖先元素是fixed,则不再继续往上查找 + if (getStyleComputedProperty(parent, 'position') === 'fixed') { + return parents + } + return getAllScrollParents(parent, parents) as HTMLElement[] + } + + return parents +} + +/** 返回当前元素的offset值 */ +const getOffsetRect = (el: HTMLElement) => { + const elementRect = { + width: el.offsetWidth, + height: el.offsetHeight, + left: el.offsetLeft, + top: el.offsetTop, + right: 0, + bottom: 0 + } + + elementRect.right = elementRect.left + elementRect.width + elementRect.bottom = elementRect.top + elementRect.height + + return elementRect +} + +/** 阻止popper层上的 wheel事件 */ +const stopFn = (ev: Event) => { + ev.stopPropagation() +} + +/** 全局的resize观察器, 监听popper的大小改变 */ +const resizeOb = + isBrowser && typeof ResizeObserver === 'function' + ? new ResizeObserver((entries) => { + entries.forEach((entry) => { + if (entry.target.popperVm && entry.contentRect.height > 50) { + entry.target.popperVm.update() + } + }) + }) + : null + +interface PopperOptions { + arrowOffset: number + arrowElement: string + boundariesElement: string | HTMLElement + boundariesPadding: number + flipBehavior: string + forceAbsolute: boolean + gpuAcceleration: boolean + offset: number + placement: string + preventOverflowOrder: string[] + modifiers: string[] + modifierFns: Function[] // 区分2个modifiers变量 + removeOnDestroy?: boolean // destory时,是否移除popper dom + bubbling?: boolean // 是否给所有祖先的scroll都监听上 + adjustArrow?: boolean // 是否校正。 只有tooltip使用它个属性了 +} +interface PopperState { + position: 'absolute' | 'fixed' + updateCallback?: (data: UpdateData) => void + scrollTarget: HTMLElement | null + scrollTargets: HTMLElement[] | null + updateBoundFn: () => void + scrollUpdate: () => void +} + +interface ReferenceOffsets { + top: number + left: number + bottom: number + right: number + width: number + height: number +} +export interface PopperOffsets { + position: 'absolute' | 'fixed' + top: number + left: number + bottom: number + right: number + width: number + height: number +} +interface arrowOffsets { + top: number + left: number +} +/** update时的data变量 */ +export interface UpdateData { + instance: Popper + styles: {} + placement: string + _originalPlacement: string + + offsets: { + popper: PopperOffsets + reference: ReferenceOffsets + arrow?: arrowOffsets + } + arrowElement: HTMLElement + + boundaries: { + right: number + left: number + top: number + bottom: number + } + flipped?: boolean +} +/** Popper 类是用于处理 reference 和 popper 两个dom,让popper悬浮的功能 + * 调用后就popper就'absolute' | 'fixed' 定位,并立即计算一次popper后的位置,并绑定scroll 和 resize事件! + */ +export class Popper { + _reference: HTMLElement + _popper: HTMLElement + state: PopperState + _options: PopperOptions + modifiers: Record = {} + /** 每次update, 计算popper的大小并缓存 */ + popperOuterSize = null as unknown as { width: number; height: number } + + constructor(reference: HTMLElement, popper: HTMLElement, options: PopperOptions) { + this._reference = reference + this._popper = popper + this.state = {} as PopperState + + this._options = { ...DEFAULTS, ...options } as any + + this._options.modifierFns = modifiers.map((modifier) => { + return this[modifier] + }) + + if (isBrowser) { + this._popper.setAttribute('x-placement', this._options.placement) + this.state.position = this._getPopperPositionByRefernce(this._reference) + setStyle(this._popper, { position: this.state.position, top: 0 }) + if (this._popper) { + this._popper.popperVm = this + resizeOb && resizeOb.observe(this._popper) + } + this.update() + this._setupEventListeners() + } + } + + destroy() { + this._popper.removeAttribute('x-placement') + + // 记录 _oldreference 就是为了保留之前的top,left, 但必要性并不大,因为此时强行把popper给display:none了。 + // 它的位置也并不重要了。 所以 _oldreference TINY_NO_NEED + // const popperStyle = (this._reference === this._oldreference && this._oldreference._popper) || {} + + this._popper.style.display = 'none' + // this._popper.style.position = '' + // this._popper.style.top = popperStyle.top || '' + // this._popper.style.left = popperStyle.left || '' + // this._popper.style.transform = '' + this._removeEventListeners() + + /** 只有示例中,用到这个属性了。 由于8个组件默认值没有用,所以默认popper是display:none的状态,留在了页面上 */ + this._options.removeOnDestroy && this._popper.remove() + + return this + } + + onUpdate(callback) { + this.state.updateCallback = callback + return this + } + + update() { + let data = { instance: this, styles: {} } as unknown as UpdateData + + this.stopEventBubble() // 每次更新都检查 + + this.popperOuterSize = null as unknown as { width: number; height: number } + data.placement = data._originalPlacement = this._options.placement + data.offsets = this._getRefPopOffsets(this._popper, this._reference, data.placement) + + data.boundaries = this._getBoundaries(data, this._options.boundariesPadding, this._options.boundariesElement) + + data = this.runModifiers(data, this._options.modifierFns) + + typeof this.state.updateCallback === 'function' && this.state.updateCallback(data) + } + + // 阻止popper的mousewheel等事件冒泡。 通过 onxxx 绑定,是为了避免重复绑定事件 + stopEventBubble() { + if (!this._popper) return + + if (!this._popper.onmousewheel) this._popper.onmousewheel = stopFn // onmousewheel 是非标准属性 + if (!this._popper.onwheel) this._popper.onwheel = stopFn + } + + /** 按顺序执行Modifiers, 如果传入终点modifier,则执行到指定位置 */ + runModifiers(data: UpdateData, modifiers: Function[], ends?: Function) { + let modifiersToRun = modifiers.slice() + const _options = this._options + + if (ends !== undefined) { + modifiersToRun = this._options.modifierFns.slice( + 0, + _options.modifierFns.findIndex((m) => m === ends) + ) + } + + modifiersToRun.forEach((modifier) => { + if (typeOf(modifier) === 'function') { + data = modifier.call(this, data) + } + }) + + return data + } + + // 此时才把offsets.popper 赋值给popper dom, offsets.array赋值给array dom + applyStyle(data: UpdateData) { + let styles: any = { position: data.offsets.popper.position } + let left = Math.round(data.offsets.popper.left) + let top = Math.round(data.offsets.popper.top) + + // 加速模式时,使用transform, 否则使用left,top + if (this._options.gpuAcceleration) { + styles.transform = `translate3d(${left}px, ${top}px, 0)` + Object.assign(styles, { top: 0, left: 0 }) + } else { + Object.assign(styles, { top, left }) + } + + Object.assign(styles, data.styles) + + setStyle(this._popper, styles) + + this._popper.setAttribute('x-placement', data.placement) + + if (data.offsets.arrow) { + setStyle(data.arrowElement, data.offsets.arrow) + } + + return data + } + + // 判断 placement是不是2段式的,是则处理一下偏移。 修改data.offsets.popper的值 + shift(data: UpdateData) { + let placement = data.placement + let basePlacement = placement.split('-')[0] + let shiftVariation = placement.split('-')[1] + + if (shiftVariation) { + let { top, left, height, width } = data.offsets.reference + let popper = getPopperClientRect(data.offsets.popper) + + let shiftOffsets = { + y: { + start: { top }, + end: { top: top + height - popper.height } + }, + x: { + start: { left }, + end: { left: left + width - popper.width } + } + } + + let axis = ~['bottom', 'top'].indexOf(basePlacement) ? 'x' : 'y' + + data.offsets.popper = Object.assign(popper, shiftOffsets[axis][shiftVariation]) + } + + return data + } + + // 校正popper的位置在boundaries 的内部 + preventOverflow(data: UpdateData) { + // popover嵌套多层级时,防止第三个placement=top属性失效 + if (this._options.ignoreBoundaries) { + return data + } + + let order = this._options.preventOverflowOrder + let popper = getPopperClientRect(data.offsets.popper) + + let check = { + top: () => { + let { top } = popper + + if (top < data.boundaries.top) { + top = Math.max(top, data.boundaries.top) + } + + return { top } + }, + right: () => { + let { left } = popper + + if (popper.right > data.boundaries.right) { + left = Math.min(left, data.boundaries.right - popper.width) + } + + return { left } + }, + bottom: () => { + let { top } = popper + + if (popper.bottom > data.boundaries.bottom) { + top = Math.min(top, data.boundaries.bottom - popper.height) + } + + return { top } + }, + left: () => { + let { left } = popper + + if (popper.left < data.boundaries.left) { + left = Math.max(left, data.boundaries.left) + } + + return { left } + } + } + + order.forEach((direction) => { + data.offsets.popper = Object.assign(popper, check[direction]()) + }) + return data + } + + // 校正popper的位置在reference的边上。 如果2个分离了,重新调整popper的位置。 可能是担心 modifiers.offset 带来的副作用吧 + keepTogether(data: UpdateData) { + let popper = getPopperClientRect(data.offsets.popper) + let reference = data.offsets.reference + + if (popper.right < Math.floor(reference.left)) { + data.offsets.popper.left = Math.floor(reference.left) - popper.width + } + + if (popper.left > Math.floor(reference.right)) { + data.offsets.popper.left = Math.floor(reference.right) + } + + if (popper.bottom < Math.floor(reference.top)) { + data.offsets.popper.top = Math.floor(reference.top) - popper.height + } + + if (popper.top > Math.floor(reference.bottom)) { + data.offsets.popper.top = Math.floor(reference.bottom) + } + + return data + } + + // 根据flip的策略,计算当前应该显示的位置。 空间不够要计算出flip的位置。 可能是担心preventOverflow 时,造成pop, reference会重叠。 重叠了就要flip一下 + flip(data: UpdateData) { + // 只翻转一次,避免重复的flip + if (data.flipped && data.placement === data._originalPlacement) { + return data + } + + const placements = data.placement.split('-') + let placement = placements[0] + let placementOpposite = getOppositePlacement(placement) + let variation = placements[1] || '' + let flipOrderArr = [placement, placementOpposite] + + flipOrderArr.forEach((step, index) => { + if (placement !== step || flipOrderArr.length === index + 1) { + return + } + + placement = data.placement.split('-')[0] + placementOpposite = getOppositePlacement(placement) + + let popperOffsets = getPopperClientRect(data.offsets.popper) + // 变量起名不佳。 此处分2种情况: placement是right', 'bottom 或 left,top + let a = ~['right', 'bottom'].indexOf(placement) + let p = Math.floor(data.offsets.reference[placement]) + let po = Math.floor(popperOffsets[placementOpposite]) + + // 如果right, ref.right > pop.left + // bottom, ref.bottom > pop.top + // left, ref.left < pop.left + // top, ref.top < pop.bottom + // 则进行flip + if ((a && p > po) || (!a && p < po)) { + data.flipped = true + data.placement = flipOrderArr[index + 1] + + if (variation) { + data.placement += `-${variation}` + } + + data.offsets.popper = this._getRefPopOffsets(this._popper, this._reference, data.placement).popper + + data = this.runModifiers(data, this._options.modifierFns, this.flip) + } + }) + return data + } + + // 根据入参option上的offset, 给data.offset.popper进行校正 + offset(data: UpdateData) { + let offset = this._options.offset + let popper = data.offsets.popper + + if (~data.placement.indexOf('left')) { + popper.top -= offset + } else if (~data.placement.indexOf('right')) { + popper.top += offset + } else if (~data.placement.indexOf('top')) { + popper.left -= offset + } else if (~data.placement.indexOf('bottom')) { + popper.left += offset + } + + return data + } + + // 计算arrow的位置,保存在data.offsets.arrow ={top,left} + arrow(data: UpdateData) { + let arrow: string | HTMLElement = this._options.arrowElement // 小三角的dom + let arrowOffset = this._options.arrowOffset // 入参里的值,可能为 Infinity + + if (typeof arrow === 'string') { + arrow = this._popper.querySelector(arrow) as HTMLElement + } + + if (!arrow || !this._popper.contains(arrow)) { + return data + } + + let arrowStyle = {} as arrowOffsets + let placement = data.placement.split('-')[0] // 以下以 placement = right 为例 + let popper = getPopperClientRect(data.offsets.popper) // 整个popper的dom屏幕尺寸。(popper到底是right-start还是right-end,此时已经计算好了的。所以最后那里不需要校正) + let reference = data.offsets.reference // tiny-form-item__content 元素的屏幕尺寸。 不包含label + let isVertical = ~['left', 'right'].indexOf(placement) // true + let calcProp = isVertical ? 'height' : 'width' // calcProp:height + let opSide = isVertical ? 'bottom' : 'right' // opSide:bottom + let altSide = isVertical ? 'left' : 'top' // altSide:left left是无用的那个值 + let side = isVertical ? 'top' : 'left' // side:top + + let popperRect = this.popperOuterSize ? this.popperOuterSize : (this.popperOuterSize = getOuterSizes(this._popper)) // popper的大小 + let arrowRect = getOuterSizes(arrow) // arrow的大小 {height: 11,width: 5} + let arrowSize = arrowRect[calcProp] // 11 + + // 如果reference 比 popper 更靠上,则popper上移到 ref.bottom - arrowSize (上边缘对齐) + if (reference[opSide] - arrowSize < popper[side]) { + data.offsets.popper[side] -= popper[side] - (reference[opSide] - arrowSize) + } + // 如果reference 比 popper 更靠下,则popper下移到 ref.bottom + arrowSize(下边缘对齐) + if (reference[side] + arrowSize > popper[opSide]) { + data.offsets.popper[side] += reference[side] + arrowSize - popper[opSide] + } + // 如果arrowOffset有值,则center为ref的上边+arrowOffset。 此例arrowOffset为Infinity, center也为无穷大。 + let center = reference[side] + (arrowOffset || reference[calcProp] / 2 - arrowSize / 2) + let sideValue = center - popper[side] + + // 猜测是上下边距留下8px的距离。 确保箭头不太靠顶靠底。 + // 此时sideValue为“popper的顶- 箭头 - 8px” 的位置。 + sideValue = Math.max(Math.min(popper[calcProp] - arrowSize - 8, sideValue), 8) + arrowStyle[side] = sideValue + arrowStyle[altSide] = '' + + // adjustArrow此处还要校正一下,但不明白为什么只校正left, 不校正top的位置? + const params = this._options.placement.split('-') + if (this._options.adjustArrow && ~['top', 'bottom'].indexOf(params[0]) && side === 'left') { + if (params[1] === 'start') { + arrowStyle.left = 8 + } else if (!params[1]) { + arrowStyle.left = (popperRect.width - arrowRect.width) / 2 + } + } + + data.offsets.arrow = arrowStyle + data.arrowElement = arrow + + return data + } + + /** 判断 reference 的 offsetParent 元素是fix还是abs, 这个值会赋值给popper 的dom */ + _getPopperPositionByRefernce(reference: HTMLElement) { + if (this._options.forceAbsolute) { + return 'absolute' + } + + let isParentFixed = isFixed(reference) + return isParentFixed ? 'fixed' : 'absolute' + } + + /** 实时计算一下popper, reference的 位置信息, 用于 */ + _getRefPopOffsets(popper, reference, placement) { + placement = placement.split('-')[0] + let popperOffsets = { position: this.state.position } as PopperOffsets + + let isParentFixed = popperOffsets.position === 'fixed' + let referenceOffsets = getOffsetRectRelativeToCustomParent( + reference, + getOffsetParent(popper), + isParentFixed, + popper + ) + + // 利用 popperOuterSize 来减少一次outerSize的计算 + const { width, height } = this.popperOuterSize + ? this.popperOuterSize + : (this.popperOuterSize = getOuterSizes(popper)) + + if (~['right', 'left'].indexOf(placement)) { + popperOffsets.top = referenceOffsets.top + referenceOffsets.height / 2 - height / 2 + + if (placement === 'left') { + popperOffsets.left = referenceOffsets.left - width + } else { + popperOffsets.left = referenceOffsets.right + } + } else { + popperOffsets.left = referenceOffsets.left + referenceOffsets.width / 2 - width / 2 + + if (placement === 'top') { + popperOffsets.top = referenceOffsets.top - height + } else { + popperOffsets.top = referenceOffsets.bottom + } + } + + popperOffsets.width = width + popperOffsets.height = height + + return { + popper: popperOffsets, + reference: referenceOffsets + } + } + + _setupEventListeners() { + this.state.updateBoundFn = this.update.bind(this) + this.state.scrollUpdate = () => { + if (this._options.updateHiddenPopperOnScroll) { + this.state.updateBoundFn() + } else { + if (isDisplayNone(this._reference)) return + this.state.updateBoundFn() + } + } + + on(window, 'resize', this.state.updateBoundFn) + + if (this._options.boundariesElement !== 'window') { + let target: HTMLElement = this._options.scrollParent || getScrollParent(this._reference) + const customTargets = [] + + // 如果下拉框组件存在于多端表单中,需要同时监听上一层scroll元素的滚动 + if (target?.dataset?.tag?.includes('-form')) { + customTargets.push(target) + let realTarget = getScrollParent(target) + if (realTarget === window.document.body || realTarget === window.document.documentElement) { + realTarget = window as any + } + customTargets.push(realTarget) + } + + if (target === window.document.body || target === window.document.documentElement) { + target = window as any + } + + this.state.scrollTarget = target + + // 只有bubbling时,才启用所有祖先监听,根源在此。 getAll..Parents函数只有这一处调用 + if (this._options.bubbling || PopupManager.globalScroll) { + let targets = getAllScrollParents(this._reference) + + this.state.scrollTargets = targets || [] + targets.forEach((target) => { + on(target, 'scroll', this.state.scrollUpdate) + }) + } else { + if (customTargets.length) { + this.state.scrollTargets = customTargets + customTargets.forEach((target) => { + on(target, 'scroll', this.state.scrollUpdate) + }) + } else { + on(target, 'scroll', this.state.scrollUpdate) + } + } + } + } + + _removeEventListeners() { + off(window, 'resize', this.state.updateBoundFn) + + if (this._options.boundariesElement !== 'window' && this.state.scrollTarget) { + off(this.state.scrollTarget, 'scroll', this.state.scrollUpdate) + this.state.scrollTarget = null + + // 移除祖先监听 + if (this._options.bubbling || PopupManager.globalScroll) { + let targets = this.state.scrollTargets || [] + + targets.forEach((target) => { + off(target, 'scroll', this.state.scrollUpdate) + }) + this.state.scrollTargets = null + } + } + + this.state.updateBoundFn = null as any + this.state.scrollUpdate = null as any + } + + /** 实时计算一下Boundary的位置 */ + _getBoundaries(data: UpdateData, padding: number, boundariesElement: string | HTMLElement) { + let boundaries = { right: 0, left: 0, top: 0, bottom: 0 } + + if (boundariesElement === 'window' || boundariesElement === 'body') { + let body = window.document.body + let html = window.document.documentElement + let { width, height } = getMaxWH(body, html) + + boundaries = { top: 0, right: width, bottom: height, left: 0 } + } else if (boundariesElement === 'viewport') { + let offsetParent = getOffsetParent(this._popper) + let scrollParent = getScrollParent(this._popper) + let offsetParentRect = getOffsetRect(offsetParent) + let isFixed = data.offsets.popper.position === 'fixed' + const noScroll = isFixed || (!this._options.appendToBody && ['right', 'left'].includes(this._options.placement)) + let scrollTop = noScroll ? 0 : getScrollTopValue(scrollParent) + let scrollLeft = noScroll ? 0 : getScrollLeftValue(scrollParent) + + // PopupManager.viewportWindow是为了兼容之前已经采用此方法兼容微前端的用户,后续需要采用globalConfig.viewportWindow + const viewportWindow = globalConfig.viewportWindow || PopupManager.viewportWindow || window + boundaries = { + top: 0 - (offsetParentRect.top - scrollTop), + right: viewportWindow.document.documentElement.clientWidth - (offsetParentRect.left - scrollLeft), + bottom: viewportWindow.document.documentElement.clientHeight - (offsetParentRect.top - scrollTop), + left: 0 - (offsetParentRect.left - scrollLeft) + } + } else { + if (getOffsetParent(this._popper) === boundariesElement) { + const { clientWidth, clientHeight } = boundariesElement + + boundaries = { + right: clientWidth, + bottom: clientHeight, + top: 0, + left: 0 + } + } else { + boundaries = getOffsetRect(boundariesElement as HTMLElement) + } + } + + boundaries.right -= padding + boundaries.left += padding + boundaries.bottom = boundaries.bottom - padding + boundaries.top = boundaries.top + padding + + return boundaries + } +} diff --git a/packages/utils/src/popup-manager/index.ts b/packages/utils/src/popup-manager/index.ts new file mode 100644 index 0000000000..289b93560d --- /dev/null +++ b/packages/utils/src/popup-manager/index.ts @@ -0,0 +1,260 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { KEY_CODE } from '../common' +import { addClass, removeClass, on } from '../dom' + +const isServer = typeof window === 'undefined' + +const instances = {} as Record + +const classes = { + leave: 'v-modal-leave', + enter: 'v-modal-enter', + modal: 'v-modal' +} +export interface IModalStack { + id: string + zIndex: number + modalClass: string +} +const removeStack = (modalStack: IModalStack[], id: string) => { + for (let i = modalStack.length - 1; i >= 0; i--) { + if (modalStack[i].id === id) { + modalStack.splice(i, 1) + break + } + } +} + +/** 判断PopupManager.modalDom 是否有。 没有则创建一个div, 并绑上 touchmove, click */ +let getModal: () => HTMLElement + +export const PopupManager = { + step: 2, + zIndex: 2000, + globalScroll: false, // 是否打开全局滚动监听 + modalFade: true, + modalStack: [] as IModalStack[], + modalDom: null as unknown as HTMLElement, // 当前model挂载的div. + hasModal: false, // 当前是否有Modal + popLockClass: 'popup-parent--hidden', + oldBodyBorder: '', + viewportWindow: null, + fixBodyBorder() { + const barWidth = window.innerWidth - document.documentElement.clientWidth + if (barWidth) { + this.oldBodyBorder = document.documentElement.style.borderRight + document.body.style.borderRight = `${barWidth}px solid transparent` + } + }, + resetBodyBorder() { + document.body.style.borderRight = this.oldBodyBorder + this.oldBodyBorder = '' + }, + /** 全局反注册 */ + deregister: (id: string) => { + if (id) { + instances[id] = null + delete instances[id] + } + }, + /** 返回全局实例 */ + getInstance: (id: string) => instances[id], + /** 全局注册 仅vue-popup.ts中使用,instance就是vm, 把vm注册到 vm._popupId 这个键值上 */ + register: (id: string, instance: any) => { + if (id && instance) { + instances[id] = instance + } + }, + nextZIndex: () => { + const zIndex = PopupManager.zIndex + PopupManager.zIndex += PopupManager.step + return zIndex + }, + /** 打开遮罩层, 仅vue-popup.ts中使用。 dom = vm.$el 或者 undefined (appendtoBody时) */ + openModal(id: string, zIndex: number, dom: HTMLElement | undefined, modalClass: string, modalFade: boolean) { + if (isServer) { + return + } + if (!id || zIndex === undefined) { + return + } + + this.modalFade = modalFade + + // 查找stack中,是否已经有id。 有了就直接退出函数 + for (let i = 0, len = this.modalStack.length; i < len; i++) { + const modal = this.modalStack[i] + + if (modal.id === id) { + return + } + } + + // 查询或创建一个modalDom----遮罩层, 为其赋值所有class ,style + const modalDom = getModal() + + addClass(modalDom, classes.modal) + + if (this.modalFade && !PopupManager.hasModal) { + addClass(modalDom, classes.enter) + } + + if (modalClass) { + const classArr = modalClass.trim().split(/\s+/) + classArr.forEach((cls) => addClass(modalDom, cls)) + } + + setTimeout(() => { + removeClass(modalDom, classes.enter) + }, 200) + + if (zIndex) { + modalDom.style.zIndex = zIndex.toString() + } + + modalDom.style.display = '' + modalDom.tabIndex = 0 + + // 查找父节点, + let parentNode + + if (dom && dom.parentNode && dom.parentNode.nodeType !== 11) { + // fragment = 11 + parentNode = dom.parentNode + } else { + parentNode = document.body + } + + parentNode.appendChild(modalDom) + + this.modalStack.push({ id, zIndex, modalClass }) + }, + /** 点击背景遮罩层时,调用栈顶的popup,调用它的close() */ + doOnModalClick: () => { + const modalStack = PopupManager.modalStack + const topPopup = modalStack[modalStack.length - 1] + if (!topPopup) { + return + } + + const instance = PopupManager.getInstance(topPopup.id) + if (instance && instance.closeOnClickModal) { + typeof instance.close === 'function' && instance.close() + } + }, + closeModal(id: string) { + const modalStack = this.modalStack + const modalDom = getModal() + + if (modalStack.length > 0) { + const topPopup = modalStack[modalStack.length - 1] + + if (topPopup.id === id) { + if (topPopup.modalClass) { + const classArr = topPopup.modalClass.trim().split(/\s+/) + classArr.forEach((cls) => removeClass(modalDom, cls)) + } + + modalStack.pop() + + const stackSize = modalStack.length + + if (stackSize > 0) { + modalDom.style.zIndex = modalStack[stackSize - 1].zIndex.toString() + } + } else { + removeStack(modalStack, id) + } + } + + if (modalStack.length === 0) { + this.modalFade && addClass(modalDom, classes.leave) + removeClass(document.body, this.popLockClass) + this.resetBodyBorder() + + setTimeout(() => { + if (modalStack.length === 0) { + if (modalDom.parentNode) { + modalDom.parentNode.removeChild(modalDom) + } + + modalDom.style.display = 'none' + PopupManager.modalDom = null as unknown as HTMLElement + } + + removeClass(modalDom, classes.leave) + }, 200) + } + } +} + +getModal = () => { + if (isServer) { + return null as unknown as HTMLElement + } + + let modalDom: HTMLElement = PopupManager.modalDom as any + + if (modalDom) { + PopupManager.hasModal = true + } else { + PopupManager.hasModal = false + modalDom = document.createElement('div') + PopupManager.modalDom = modalDom + + // 屏蔽touch, + modalDom.addEventListener( + 'touchmove', + (event) => { + event.preventDefault() + event.stopPropagation() + }, + { passive: true } + ) + + on(modalDom, 'click', () => { + PopupManager.doOnModalClick() + }) + } + + return modalDom +} + +if (!isServer) { + // 点esc时,关闭栈顶Popup。 也就是说组件内不用关心esc了, 这里统一接管了 + on(window, 'keydown', (event: KeyboardEvent) => { + if (event.keyCode === KEY_CODE.Escape) { + const modalStack = PopupManager.modalStack + + if (modalStack.length > 0) { + const topPopup = modalStack[modalStack.length - 1] + if (!topPopup) { + return + } + const topPopupVm = PopupManager.getInstance(topPopup.id) + + // 只有Dialog-box有 closeOnPressEscape , 所以它只是为 Dialog-box 服务 + if (topPopupVm && topPopupVm.closeOnPressEscape) { + if (topPopupVm.handleClose) { + topPopupVm.handleClose('esc') + } else if (topPopupVm.handleAction) { + topPopupVm.handleAction('cancel') + } else { + topPopupVm.close() + } + } + } + } + }) +} diff --git a/packages/utils/src/prop-util/index.ts b/packages/utils/src/prop-util/index.ts new file mode 100644 index 0000000000..4cb818c755 --- /dev/null +++ b/packages/utils/src/prop-util/index.ts @@ -0,0 +1,39 @@ +export const unknownProp = null + +export const numericProp = [Number, String] + +export const truthProp = { + type: Boolean, + default: true +} + +export const makeRequiredProp = (type) => ({ + type, + required: true +}) + +export const makeArrayProp = () => ({ + type: Array, + default: () => [] +}) + +export const makeNumberProp = (defaultVal) => ({ + type: Number, + default: defaultVal +}) + +export const makeNumericProp = (defaultVal) => ({ + type: numericProp, + default: defaultVal +}) + +export const makeStringProp = (defaultVal) => ({ + type: String, + default: defaultVal +}) + +export const makeStringValidProp = (defaultVal, optionals = []) => ({ + type: String, + default: defaultVal, + validator: (val) => optionals.includes(val) +}) diff --git a/packages/utils/src/resize-event/index.ts b/packages/utils/src/resize-event/index.ts new file mode 100644 index 0000000000..36538261a7 --- /dev/null +++ b/packages/utils/src/resize-event/index.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import ResizeObserver from '../resize-observer' + +const isServer = typeof window === 'undefined' +const cacheKey = '__resizeListeners__' + +/* istanbul ignore next */ +const resizeHandler = (entries) => { + entries.forEach((entry) => { + const listeners = entry.target[cacheKey] || [] + + if (listeners.length) { + listeners.forEach((fn) => { + fn() + }) + } + }) +} + +/* istanbul ignore next */ +export const addResizeListener = (el, fn) => { + if (isServer) { + return + } + + if (!el[cacheKey]) { + el[cacheKey] = [] + el.__ro__ = new ResizeObserver(resizeHandler) + el.__ro__.observe(el) + } + + el[cacheKey].push(fn) +} + +/* istanbul ignore next */ +export const removeResizeListener = (el, fn) => { + if (!el || !el[cacheKey]) { + return + } + + el[cacheKey].splice(el[cacheKey].indexOf(fn), 1) + + if (!el[cacheKey].length) { + el.__ro__.disconnect() + delete el.__ro__ + } +} diff --git a/packages/utils/src/resize-observer/index.ts b/packages/utils/src/resize-observer/index.ts new file mode 100644 index 0000000000..81c1ae31c3 --- /dev/null +++ b/packages/utils/src/resize-observer/index.ts @@ -0,0 +1,608 @@ +/* eslint-disable prefer-rest-params */ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { on, off } from '../dom' +import { isBrowser } from '../browser' + +const MapShim = (function () { + if (typeof Map !== 'undefined') { + return Map + } + + const getIndex = (arr, key) => { + let result = -1 + + arr.some((entry, index) => { + if (entry[0] === key) { + result = index + return true + } + + return false + }) + + return result + } + + return (function () { + function obClass() { + this.__entries__ = [] + } + + Object.defineProperty(obClass.prototype, 'size', { + get() { + return this.__entries__.length + }, + enumerable: true, + configurable: true + }) + + obClass.prototype.get = function (key) { + const index = getIndex(this.__entries__, key) + const entry = this.__entries__[index] + + return entry && entry[1] + } + + obClass.prototype.set = function (key, value) { + const index = getIndex(this.__entries__, key) + + if (~index) { + this.__entries__[index][1] = value + } else { + this.__entries__.push([key, value]) + } + } + + obClass.prototype.delete = function (key) { + const entries = this.__entries__ + const index = getIndex(entries, key) + + if (~index) { + entries.splice(index, 1) + } + } + + obClass.prototype.clear = function () { + this.__entries__.splice(0) + } + + obClass.prototype.has = function (key) { + return !!~getIndex(this.__entries__, key) + } + + obClass.prototype.forEach = function (callback, ctx) { + if (ctx === undefined) { + ctx = null + } + + for (let _i = 0, _a = this.__entries__; _i < _a.length; _i++) { + const entry = _a[_i] + + callback.call(ctx, entry[1], entry[0]) + } + } + + return obClass + })() +})() + +const func = isBrowser ? window.Function : global.Function + +const global$1 = (function () { + const isMath = (val) => val.Math === Math + + if (typeof global !== 'undefined' && isMath(global)) { + return global + } + + if (typeof self !== 'undefined' && isMath(self)) { + return self + } + + if (typeof window !== 'undefined' && isMath(window)) { + return window + } + return func('return this')() +})() + +const requestAnimationFrame$1 = (function () { + if (typeof requestAnimationFrame === 'function') { + return requestAnimationFrame.bind(global$1) + } + + return function (callback) { + return setTimeout(() => callback(Date.now()), 1000 / 60) + } +})() + +let trailingTimeout = 2 + +function throttle(callback, delayTime) { + let leading = false + let trailing = false + let lastCallTime = 0 + let proxy + + const resolvePending = () => { + if (leading) { + leading = false + callback() + } + + trailing && proxy() + } + + const timeoutCallback = () => { + requestAnimationFrame$1(resolvePending) + } + + proxy = () => { + const timeStamps = Date.now() + + if (leading) { + if (timeStamps - lastCallTime < trailingTimeout) { + return + } + + trailing = true + } else { + leading = true + trailing = false + + setTimeout(timeoutCallback, delayTime) + } + + lastCallTime = timeStamps + } + + return proxy +} + +const REFRESH_DELAY = 20 +const transitionKeys = ['top', 'right', 'bottom', 'left', 'width', 'height', 'size', 'weight'] +const mutationObserverSupported = typeof MutationObserver !== 'undefined' +const ResizeObserverController = (function () { + function ResizeObserverController() { + this.observers_ = [] + this.connected_ = false + this.mutationEventsAdded_ = false + this.mutationsObserver_ = null + this.onTransitionEnd_ = this.onTransitionEnd_.bind(this) + this.refresh = throttle(this.refresh.bind(this), REFRESH_DELAY) + } + + ResizeObserverController.prototype.addObserver = function (observer) { + !~this.observers_.indexOf(observer) && this.observers_.push(observer) + !this.connected_ && this.connect_() + } + + ResizeObserverController.prototype.removeObserver = function (observer) { + const observers = this.observers_ + const index = observers.indexOf(observer) + + ~index && observers.splice(index, 1) + + if (!observers.length && this.connected_) { + this.disconnect_() + } + } + + ResizeObserverController.prototype.refresh = function () { + const changesDetected = this.updateObservers_() + + changesDetected && this.refresh() + } + + ResizeObserverController.prototype.updateObservers_ = function () { + const activeObservers = this.observers_.filter((observer) => { + observer.gatherActive() + return observer.hasActive() + }) + + activeObservers.forEach((observer) => observer.broadcastActive()) + + return activeObservers.length > 0 + } + + ResizeObserverController.prototype.connect_ = function () { + if (!isBrowser || this.connected_) { + return + } + + on(document, 'transitionend', this.onTransitionEnd_) + on(window, 'resize', this.refresh) + + if (mutationObserverSupported) { + this.mutationsObserver_ = new MutationObserver(this.refresh) + + const options = { + attributes: true, + childList: true, + characterData: true, + subtree: true + } + + this.mutationsObserver_.observe(document, options) + } else { + on(document, 'DOMSubtreeModified', this.refresh) + this.mutationEventsAdded_ = true + } + + this.connected_ = true + } + + ResizeObserverController.prototype.disconnect_ = function () { + if (!isBrowser || !this.connected_) { + return + } + + off(document, 'transitionend', this.onTransitionEnd_) + off(window, 'resize', this.refresh) + + this.mutationsObserver_ && this.mutationsObserver_.disconnect() + + if (this.mutationEventsAdded_) { + off(document, 'DOMSubtreeModified', this.refresh) + } + + this.mutationsObserver_ = null + this.mutationEventsAdded_ = false + this.connected_ = false + } + + ResizeObserverController.prototype.onTransitionEnd_ = function (_a) { + const _b = _a.propertyName + const propertyName = _b === undefined ? '' : _b + const isReflowProperty = transitionKeys.some((key) => !!~propertyName.indexOf(key)) + + isReflowProperty && this.refresh() + } + + ResizeObserverController.getInstance = function () { + if (!this._instance) { + this._instance = new ResizeObserverController() + } + + return this._instance + } + + ResizeObserverController._instance = null + return ResizeObserverController +})() + +const defineConfigurable = function (target, props) { + for (let i = 0, a = Object.keys(props); i < a.length; i++) { + const key = a[i] + + Object.defineProperty(target, key, { + value: props[key], + configurable: true, + writable: false, + enumerable: false + }) + } + + return target +} + +const createRectInit = function (x, y, width, height) { + return { x, y, width, height } +} + +const getWindowOf = function (target) { + const ownerGlobal = target && target.ownerDocument && target.ownerDocument.defaultView + return ownerGlobal || global$1 +} + +const emptyRect = createRectInit(0, 0, 0, 0) +const toFloat = (value) => parseFloat(value) || 0 + +const getBordersSize = function (styles) { + let positions = [] + + for (let i = 1; i < arguments.length; i++) { + positions[i - 1] = arguments[i] + } + + return positions.reduce((size, position) => { + const value = styles[`border-${position}-width`] + + return size + toFloat(value) + }, 0) +} + +const getPaddings = function (styles) { + const positions = ['top', 'right', 'bottom', 'left'] + let paddings = {} + + for (let i = 0, pos = positions; i < pos.length; i++) { + const position = pos[i] + const value = styles[`padding-${position}`] + + paddings[position] = toFloat(value) + } + + return paddings +} + +const getSVGContentRect = function (target) { + const bbox = target.getBBox() + return createRectInit(0, 0, bbox.width, bbox.height) +} + +const isDocumentElement = function (target) { + return target === getWindowOf(target).document.documentElement +} + +const getHTMLElementContentRect = function (target) { + const clientWidth = target.clientWidth + const clientHeight = target.clientHeight + + if (!clientHeight && !clientWidth) { + return emptyRect + } + + const styles = getWindowOf(target).getComputedStyle(target) + const paddings = getPaddings(styles) + + const vertPad = paddings.top + paddings.bottom + const horizPad = paddings.left + paddings.right + + let width = toFloat(styles.width) + let height = toFloat(styles.height) + + if (styles.boxSizing === 'border-box') { + if (Math.round(height + vertPad) !== clientHeight) { + height -= getBordersSize(styles, 'top', 'bottom') + vertPad + } + + if (Math.round(width + horizPad) !== clientWidth) { + width -= getBordersSize(styles, 'left', 'right') + horizPad + } + } + + if (!isDocumentElement(target)) { + const horizScrollbar = Math.round(height + vertPad) - clientHeight + const vertScrollbar = Math.round(width + horizPad) - clientWidth + + if (Math.abs(horizScrollbar) !== 1) { + height -= horizScrollbar + } + + if (Math.abs(vertScrollbar) !== 1) { + width -= vertScrollbar + } + } + + return createRectInit(paddings.left, paddings.top, width, height) +} + +const isSVGGraphicsElement = (function () { + if (typeof SVGGraphicsElement !== 'undefined') { + return (target) => target instanceof getWindowOf(target).SVGGraphicsElement + } + + return (target) => target instanceof getWindowOf(target).SVGElement && typeof target.getBBox === 'function' +})() + +const getContentRect = function (target) { + if (!isBrowser) { + return emptyRect + } + + if (isSVGGraphicsElement(target)) { + return getSVGContentRect(target) + } + + return getHTMLElementContentRect(target) +} + +const createReadOnlyRect = function (_a) { + const x = _a.x + const y = _a.y + const width = _a.width + const height = _a.height + const Constr = typeof DOMRectReadOnly !== 'undefined' ? DOMRectReadOnly : Object + const rect = Object.create(Constr.prototype) + + defineConfigurable(rect, { + x, + y, + width, + height, + top: y, + right: x + width, + bottom: height + y, + left: x + }) + + return rect +} + +const ResizeObservation = (function () { + function ResizeObservation(target) { + this.broadcastWidth = 0 + this.broadcastHeight = 0 + this.contentRect_ = createRectInit(0, 0, 0, 0) + this.target = target + } + + ResizeObservation.prototype.broadcastRect = function () { + const rect = this.contentRect_ + this.broadcastWidth = rect.width + this.broadcastHeight = rect.height + + return rect + } + + ResizeObservation.prototype.isActive = function () { + const rect = getContentRect(this.target) + this.contentRect_ = rect + + return rect.width !== this.broadcastWidth || rect.height !== this.broadcastHeight + } + + return ResizeObservation +})() + +const ResizeObserverEntry = (function () { + function ResizeObserverEntry(target, rectInit) { + const contentRect = createReadOnlyRect(rectInit) + + defineConfigurable(this, { target, contentRect }) + } + + return ResizeObserverEntry +})() + +const ResizeObserverSPI = (function () { + function ResizeObserverSPI(callback, controller, callbackCtx) { + this.observations_ = new MapShim() + this.activeObservations_ = [] + + if (typeof callback !== 'function') { + throw new TypeError('[TINY-Resize] The callback provided as parameter 1 is not a function.') + } + + this.callback_ = callback + this.controller_ = controller + this.callbackCtx_ = callbackCtx + } + + ResizeObserverSPI.prototype.observe = function (target) { + if (!arguments.length) { + throw new TypeError('[TINY-Resize] 1 argument required, but only 0 present.') + } + + if (typeof Element === 'undefined' || !(Element instanceof Object)) { + return + } + + if (!(target instanceof getWindowOf(target).Element)) { + throw new TypeError('[TINY-Resize] parameter 1 is not of type "Element".') + } + + const obserVations = this.observations_ + + if (obserVations.has(target)) { + return + } + + obserVations.set(target, new ResizeObservation(target)) + + this.controller_.addObserver(this) + this.controller_.refresh() + } + + ResizeObserverSPI.prototype.unobserve = function (target) { + if (!arguments.length) { + throw new TypeError('[TINY-Resize]1 argument required, but only 0 present.') + } + + if (typeof Element === 'undefined' || !(Element instanceof Object)) { + return + } + + if (!(target instanceof getWindowOf(target).Element)) { + throw new TypeError('[TINY-Resize] parameter 1 is not of type "Element".') + } + + const obserVations = this.observations_ + + if (!obserVations.has(target)) { + return + } + + obserVations.delete(target) + + !obserVations.size && this.controller_.removeObserver(this) + } + + ResizeObserverSPI.prototype.gatherActive = function () { + const me = this + this.clearActive() + + this.observations_.forEach((observation) => { + observation.isActive() && me.activeObservations_.push(observation) + }) + } + + ResizeObserverSPI.prototype.disconnect = function () { + this.clearActive() + this.observations_.clear() + this.controller_.removeObserver(this) + } + + ResizeObserverSPI.prototype.broadcastActive = function () { + if (!this.hasActive()) { + return + } + + const ctx = this.callbackCtx_ + const entries = this.activeObservations_.map( + (observation) => new ResizeObserverEntry(observation.target, observation.broadcastRect()) + ) + + this.callback_.call(ctx, entries, ctx) + this.clearActive() + } + + ResizeObserverSPI.prototype.hasActive = function () { + return this.activeObservations_.length > 0 + } + + ResizeObserverSPI.prototype.clearActive = function () { + this.activeObservations_.splice(0) + } + + return ResizeObserverSPI +})() + +const observers = typeof WeakMap !== 'undefined' ? new WeakMap() : new MapShim() + +const ResizeObserver = (function () { + function ResizeObserver(callback) { + if (!(this instanceof ResizeObserver)) { + throw new TypeError('[TINY-Resize] Cannot call a class as a function.') + } + + if (!arguments.length) { + throw new TypeError('[TINY-Resize] 1 argument required, but only 0 present.') + } + + const controller = ResizeObserverController.getInstance() + const observer = new ResizeObserverSPI(callback, controller, this) + + observers.set(this, observer) + } + + return ResizeObserver +})() +;['observe', 'unobserve', 'disconnect'].forEach((method) => { + ResizeObserver.prototype[method] = function () { + let _a + + return (_a = observers.get(this))[method].apply(_a, arguments) + } +}) + +const index = (function () { + if (typeof global$1.ResizeObserver !== 'undefined') { + return global$1.ResizeObserver + } + + return ResizeObserver +})() + +export default index diff --git a/packages/utils/src/scroll-into-view/index.ts b/packages/utils/src/scroll-into-view/index.ts new file mode 100644 index 0000000000..b7c101b8d8 --- /dev/null +++ b/packages/utils/src/scroll-into-view/index.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +const isServer = typeof window === 'undefined' + +export const scrollIntoView = (container, selected) => { + if (isServer) { + return + } + + if (!selected) { + container.scrollTop = 0 + return + } + + const offsetParents = [] + let { offsetParent, offsetTop, offsetHeight } = selected + + while (offsetParent && container !== offsetParent && container.contains(offsetParent)) { + offsetParents.push(offsetParent) + offsetParent = offsetParent.offsetParent + } + + const top = offsetTop + offsetParents.reduce((prev, curr) => prev + curr.offsetTop, 0) + const bottom = top + offsetHeight + const viewRectTop = container.scrollTop + const viewRectBottom = viewRectTop + container.clientHeight + + if (top < viewRectTop) { + container.scrollTop = top + } else if (bottom > viewRectBottom) { + container.scrollTop = bottom - container.clientHeight + } +} diff --git a/packages/utils/src/scroll-width/index.ts b/packages/utils/src/scroll-width/index.ts new file mode 100644 index 0000000000..9c4ec0b56d --- /dev/null +++ b/packages/utils/src/scroll-width/index.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +let scrollBarWidth: number +const isServer = typeof window === 'undefined' + +// 通过构造2层div,计算出来滚动条的宽度,并全局缓存值 +export function scrollWidth() { + if (isServer) { + return 0 + } + if (scrollBarWidth !== undefined) { + return scrollBarWidth + } + + const container = document.createElement('div') + container.className = 'tiny-scrollbar' + container.style.visibility = 'hidden' + container.style.position = 'absolute' + container.style.top = '-9999px' + + const outer = document.createElement('div') + outer.className = 'tiny-scrollbar__wrap' + outer.style.width = '100px' + + container.appendChild(outer) + document.body.appendChild(container) + const widthNoScroll = outer.offsetWidth + outer.style.overflow = 'scroll' + + const inner = document.createElement('div') + inner.style.width = '100%' + + outer.appendChild(inner) + + const widthWithScroll = inner.offsetWidth + ;(outer.parentNode as HTMLElement).removeChild(outer) + scrollBarWidth = widthNoScroll - widthWithScroll + + return scrollBarWidth +} diff --git a/packages/utils/src/string/index.ts b/packages/utils/src/string/index.ts new file mode 100644 index 0000000000..2be9f35b35 --- /dev/null +++ b/packages/utils/src/string/index.ts @@ -0,0 +1,834 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { isPlainObject, isNumber, isNumeric, isNull } from '../type' +import { getObj, toJsonStr } from '../object' +import { toFixed, Decimal } from '../decimal' +import { globalEnvironment, isBrowser } from '../browser' + +/** + * 文本替换格式类型 + */ +export const formatTypes = { + text: 'text', + url: 'url', + html: 'html', + tmpl: 'tmpl' +} + +/** + * 字符对应的字符编码 + */ +export const escapeChars = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '[': '[', + ']': ']' +} + +/** + * 判断是否为null、undefined、空字符串 + * + * isNullOrEmpty('') // true + * + * @param {Object} value 需判断的对象 + * @return {Boolean} + */ +export const isNullOrEmpty = (value) => + value === null || value === undefined || (typeof value === 'string' && value.trim().length === 0) + +function cached(fn) { + let cache = Object.create(null) + + return function cachedFn(str) { + const hit = cache[str] + return hit || (cache[str] = fn(str)) + } +} + +const camelizeRE = /-(\w)/g +export const camelize = cached((str) => str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''))) + +/** + * 将字符串首写字母大写。 + * + * capitalize('hello') // "Hello" + * + * @param {String} string 要转换的字符串 + * @returns {String} + */ +export const capitalize = cached((str) => str.charAt(0).toUpperCase() + str.slice(1)) + +const hyphenateRE = /\B([A-Z])/g +export const hyphenate = cached((str) => str.replace(hyphenateRE, '-$1').toLowerCase()) + +/** + * 解析Json字符串成对象。 + * + * let str = '{ "value": "v1", "text": "t1" }' + * toJson(str) // { value: 'v1', text: 't1' } + * + * @param {String} string 要解析的Json字符串 + * @returns {Object} + */ +export const toJson = (string) => { + try { + return JSON.parse(string) + } catch (e) { + return undefined + } +} + +function getLengthInUtf16(string) { + const len = string.length + let count = 0 + + for (let i = 0; i < len; i++) { + let charCode = string.charCodeAt(i) + + /* istanbul ignore else */ + if (charCode <= 0xffff) { + count += 2 + } else { + count += 4 + } + } + + return count +} + +function getLengthInUtf8(string) { + const len = string.length + let count = 0 + + for (let i = 0; i < len; i++) { + let charCode = string.charCodeAt(i) + + /* istanbul ignore else */ + if (charCode <= 0x007f) { + count += 1 + } else if (charCode <= 0x07ff) { + count += 2 + } else if (charCode <= 0xffff) { + count += 3 + } else { + count += 4 + } + } + + return count +} + +function getLengthDefault(string) { + const len = string.length + let count = 0 + + for (let i = 0; i < len; i++) { + count++ + + if (string.charCodeAt(i) >> 8) { + count++ + } + } + + return count +} + +/** + * 计算字符串长度或所占的内存字节数。 + * 默认计算方式(中文算两个长度,数字字母算一个),也可指定为 'basic','UTF-16','UTF-8',或自定义的计算规则。 + * + * getLength('12ED') // => 4 + * getLength('深圳') // => 4 + * getLength('好a','basic') // '好a' => 2,'a' => 1 + * getLength('好a','UTF-8') // 好a' => 4,'a' => 1 + * getLength('好a','UTF-16') //'好a' => 4,'a' => 2 + * getLength(str, function (str) { + * return (str + 'xx').length + * }) + * + * UTF-8 是一种可变长度的 Unicode 编码格式,使用一至四个字节为每个字符编码 + * + * 000000 - 00007F(128个代码) 0zzzzzzz(00-7F) 一个字节 + * 000080 - 0007FF(1920个代码) 110yyyyy(C0-DF) 10zzzzzz(80-BF) 两个字节 + * 000800 - 00D7FF 注: Unicode在范围 D800-DFFF 中不存在任何字符 + * 00E000 - 00FFFF(61440个代码) 1110xxxx(E0-EF) 10yyyyyy 10zzzzzz 三个字节 + * 010000 - 10FFFF(1048576个代码) 11110www(F0-F7) 10xxxxxx 10yyyyyy 10zzzzzz 四个字节 + * + * + * 定义参考 http://zh.wikipedia.org/wiki/UTF-8 + * + * UTF-16 大部分使用两个字节编码,编码超出 65535 的使用四个字节 + * + * 000000 - 00FFFF 两个字节 + * 010000 - 10FFFF 四个字节 + * + * 定义参考 http://zh.wikipedia.org/wiki/UTF-16 + * + * @param {String} string + * @param {String|Function} regular 长度规则:'basic'、'UTF-16'、'UTF-8'或自定义的计算规则函数 + * @return {Number} + */ +export const getLength = (string, regular) => { + if (!string || typeof string !== 'string') { + return 0 + } + + let count = 0 + + if (typeof regular === 'string') { + regular = regular.toLowerCase() + + if (regular === 'utf-16' || regular === 'utf16') { + count = getLengthInUtf16(string) + } else if (regular === 'utf-8' || regular === 'utf8') { + count = getLengthInUtf8(string) + } else { + count = string.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '_').length + } + } else if (typeof regular === 'function') { + return regular(string) + } else { + count = getLengthDefault(string) + } + + return count +} + +/** + * 填充字符串,根据填充模式参数,在字符串前面或后面补充字附到指定的长度。 + * + * fillChar('1', 3) // "001" + * fillChar('1', 3, true) // "100" + * fillChar('1', 3, true, ' ') // "1 " + * + * @param {String} string 被填充的字符串 + * @param {Number} length 填充到某个长度 + * @param {Boolean} [append=false] 是否在后面填充,默认为在前面填充 + * @param {String} [chr="0"] 填充的字符,默认为0 + * @returns {String} + */ +export const fillChar = (string, length, append, chr = '0') => { + if (typeof string === 'string' && typeof chr === 'string' && isNumber(length)) { + let len = string.length - length + + if (len > 0) { + return append ? string.substr(0, length) : string.substr(len, length) + } else { + const appendStr = [] + len = Math.abs(len) / chr.length + + for (; len > 0; len--) { + appendStr.push(chr) + } + + const s = appendStr.join('') + + return append ? string + s : s + string + } + } +} + +export const random = () => { + let MAX_UINT32_PLUS_ONE = 4294967296 + return globalEnvironment.crypto.getRandomValues(new globalEnvironment.Uint32Array(1))[0] / MAX_UINT32_PLUS_ONE +} + +/** + * 生成一个guid。 + * + * guid('#') // #16722423 + * + * @param {String} [prefix] guid前缀,可选值,默认为空字符串 + * @param {Number} [length] 生成的guid的长度,可选值,默认为8 + * @returns {String} + */ +export const guid = (prefix = '', length = 8) => prefix + random().toString().substr(2, length) +/** + * 将HTML字符串进行编码。 + * + * escapeHtml('
&&
') // "<div>&&</div>" + * + * @param {String} string 要编码的字符串 + * @param {Boolean} [isReplaceSpace] 是否替换空格 + * @returns {String} + */ +export const escapeHtml = (string, isReplaceSpace) => { + if (!string || typeof string !== 'string') { + return string + } + + string = string.replace(/[&<>"']/g, (chr) => escapeChars[chr]) + return isReplaceSpace ? string.replace(/\s/g, ' ') : string +} + +/** + * 将URL字符串进行转义。 + * + * let str = '< >& []""' + "'" + * escape(str) // "< >& []""'" + * escape(str, 'uri', true) // "%3C%20%3E%26%20%5B%5D%22%22'" + * escape(str, true) // "< >& []""'" + * escape(str, 'html', true) // "< >& []""'" + * escape(str, 'prop', true) // "< >& []""'" + * + * @param {String} string 需要转换的字符串 + * @param {String} [escapeType] 转换类型,可选值:uri, html, prop + * @param {Boolean} [isReplaceSpace] 是否替换空格, 默认不替换 + * @returns {String} + */ +export const escape = (string, escapeType, isReplaceSpace) => { + if (!string || typeof string !== 'string') { + return string + } + + if (typeof escapeType === 'boolean') { + isReplaceSpace = !!escapeType + } + + if (escapeType === 'uri') { + return encodeURIComponent(string) + } else if (escapeType === 'html') { + return escapeHtml(string, isReplaceSpace) + } else if (escapeType === 'prop') { + string = escapeHtml(string, isReplaceSpace) + return string.replace(/[[\]]/g, (chr) => escapeChars[chr]) + } + + string = string.replace(/[<>"]/g, (chr) => escapeChars[chr]) + return isReplaceSpace ? string.replace(/\s/g, ' ') : string +} + +const getFormat = ({ sign, format, hasSign }) => { + switch (sign) { + case '#': + format = formatTypes.text + break + case '@': + format = formatTypes.url + break + case '$': + format = formatTypes.html + break + case '%': + format = formatTypes.tmpl + break + default: + hasSign = false + break + } + + return { format, hasSign } +} + +/** + * 使用具体的对象字段代替字符串中的字段占位符。 + * + * fieldFormat('url:{{url}}', { url: 'http://abc.com/a&b' }) // "url:http://abc.com/a&b" + * fieldFormat('url:{{#url}}', { url: 'http://abc.com/a&b' }) // "url:http://abc.com/a&b" + * fieldFormat('url:{{@url}}', { url: 'http://abc.com/a&b' }) // "url:http%3A%2F%2Fabc.com%2Fa%26b" + * fieldFormat('url:{{$url}}', { url: 'http://abc.com/a&b' }) // "url:http://abc.com/a&b" + * fieldFormat('url:{{%url}}', { url: 'http://abc.com/a&b' }) // "url:{{http://abc.com/a&b}}" + * + * @param {String} string 要替换的字符串模板 + * @param {Object} data 要替换模板的数据 + * @param {String} [type="html"] 替换的类型:"text"、"url"、"tmpl"、"html",默认"html" + * @returns {String} + */ +export const fieldFormat = (string, data, type = 'html') => { + if (typeof string === 'string') { + return string.replace(/(\/)?\{\{([\s\S]*?)}}/g, (match, slash = '', offset = '') => { + const sign = offset.substr(0, 1) + let hasSign = true + let format = formatTypes.html + let ret = getFormat({ sign, format, hasSign }) + + format = ret.format + hasSign = ret.hasSign + + if (hasSign) { + offset = (offset || '').substring(1) + } else if (type) { + format = type + } + + let value = getObj(data, offset) + + if (isNull(value)) { + value = '' + } + + if (format === formatTypes.tmpl) { + value = `{{${value}}}` + } else { + if (format === formatTypes.url) { + value = encodeURIComponent(value) + } else { + value = format === formatTypes.html ? escapeHtml(value) : value + } + } + + return format === formatTypes.url && value.length === 0 ? '' : slash + value + }) + } +} + +const getFormatText = () => (str, reg, args, format) => + str.replace(reg, (m, i, j, k) => { + if (!isNullOrEmpty(i) && !isNullOrEmpty(k)) { + return `{${j}}` + } + + const value = args[j] + const string = isPlainObject(value) ? toJsonStr(value) : value + + if (isNullOrEmpty(value)) { + return '' + } + + return typeof value === 'string' && typeof format === 'function' ? format(string) : string + }) + +const getResult = ({ type, res, formatText, string, reg, args }) => { + if (type === formatTypes.url) { + res = formatText(string, reg, args, encodeURIComponent) + } else if (type === formatTypes.html) { + res = formatText(string, reg, args, escapeHtml) + } else { + res = formatText(string, reg, args) + } + + return res +} + +const judgForFunc = (args, formatTypes, type) => { + const lastArg = args[args.length - 1] + + if (lastArg !== formatTypes.text && lastArg !== formatTypes.url && lastArg !== formatTypes.html) { + args = Array.prototype.slice.call(args, 1) + } else { + args = Array.prototype.slice.call(args, 1, args.length - 1) + type = lastArg + } + + return { args, type } +} + +const checkParam = ({ data, args, type, _arguments }) => { + if (Array.isArray(data)) { + args = data + } else { + const judgObj = judgForFunc(_arguments, formatTypes, type) + + args = judgObj.args + type = judgObj.type + } + + return { args, type } +} + +/** + * 使用具体的值替换字符串中的数字占位符。 + * + * format('{0}', 1) // "1" + * format('{0}', 1, 'text') // "1" + * format('{0}{1}', [1, 2, 'text']) // "12" + * format('age:{{age}}', { age: 20 }) // "age:20" + * format('\\{0\\}{1}', [0, 1]) // "{0}1" + * format('{0}', [{ age: 20 }]) // "{"age":20}" + * format('{0}', [ 'http://abc.com/a&b' ], 'url') // "http%3A%2F%2Fabc.com%2Fa%26b" + * format('{0}', [ '
&&
' ], 'html') // "<div>&&</div>" + * + * @param {String} string 要替换的字符串模板 + * @param {Object|Array|String} data 要替换模板的数据 + * @param {String} [type="text"] 替换的类型:"text"、"url"、"html",默认"text" + * @returns {String} + */ +export const format = function (string, data, type = 'text') { + if (typeof string !== 'string' || arguments.length < 2) { + return string + } + + let args, res + + if (isPlainObject(data)) { + return fieldFormat(string, data, type) + } + + // eslint-disable-next-line prefer-rest-params + const ret = checkParam({ data, args, type, _arguments: arguments }) + + args = ret.args + type = ret.type + + const reg = /(\\)?\{(\d+)(\\)?}/g + const formatText = getFormatText() + + res = getResult({ type, res, formatText, string, reg, args }) + + return res +} + +const getTruthyValue = ({ string, length, ellipsis }) => { + const flag = typeof string === 'string' && isNumber(length) && length < string.length + const truthyValue = flag && format(ellipsis, string.substr(0, length)) + + return { flag, truthyValue } +} + +/** + * 将字符串按指定长度截断。 + * + * truncate('abc', 5) // "abc" + * truncate('abc', 2) // "ab..." + * + * @param {String} string 要截断的字符串 + * @param {Number} length 要截断的长度 + * @param {String} [ellipsis="{0}..."] 截断类型,通常携带{0}占位符 + * @returns {String} + */ +export const truncate = (string, length, ellipsis = '{0}...') => { + const { flag, truthyValue } = getTruthyValue({ string, length, ellipsis }) + + return flag ? truthyValue : string +} + +/** + * 尝试按指定函数转换字符串,如果转换结果为 NaN,则返回 defaultValue。 + * + * tryToConvert(toInt, null, 0) // 0 + * + * @param {Function} convert 指定的转换的函数 + * @param {Number|String} defaultValue 若为 NaN 时,返回的缺省值 + * @param {Number|String} value 要转换的字符串或多个参数 + * @returns {Number|String} + */ +export const tryToConvert = (convert, defaultValue, ...args) => { + // eslint-disable-next-line prefer-spread + const result = convert.apply(null, args) + return isNaN(result) ? defaultValue : result +} + +/** + * 将字符串解析成十进制整数。 + * + * toInt(100) // 100 + * toInt('100.01') // 100 + * + * @param {Number|String} value 要解析的字符串 + * @returns {Number} + */ +export const toInt = (value) => + isNumber(value) ? Number(value.toFixed(0)) : typeof value === 'string' ? parseInt(value, 10) : NaN + +/** + * 尝试将字符串解析成十进制整数。如果 value 是个无效的整数,则返回 defaultValue。 + * + * tryToInt(100) // 100 + * tryToInt('100.01') // 100 + * tryToInt(null, 100) // 100 + * + * @param {Number|String} value 要解析的字符串 + * @param {Number|String} defaultValue 若为 NaN 时,返回的缺省值 + * @returns {Number|String} + */ +export const tryToInt = (value, defaultValue) => tryToConvert(toInt, defaultValue, value) + +/** + * 将字符串解析成数值。 + * + * toNumber(100) // 100 + * toNumber('100.01') // 100.01 + * + * @param {Number|String} value 要解析的字符串 + * @returns {Number} + */ +export const toNumber = (value) => (isNumber(value) ? value : typeof value === 'string' ? parseFloat(value) : NaN) + +/** + * 尝试将字符串解析成数值。如果 value 是个无效的数字,则返回 defaultValue。 + * + * tryToNumber(100) // 100 + * tryToNumber('100.01') // 100.01 + * tryToNumber(null, 100) // 100 + * + * @param {Number|String} value 要解析的字符串 + * @param {Number|String} defaultValue 若为 NaN 时,返回的缺省值 + * @returns {Number|String} + */ +export const tryToNumber = (value, defaultValue) => tryToConvert(toNumber, defaultValue, value) + +/** + * 将字符串解析成浮点数。 + * + * toDecimal(100) // "100.00" + * toDecimal("100.01", 2) // "100.01" + * toDecimal(0.8 - 0.6, 2, true) // "0.2" + * toDecimal(0.8 - 0.6, 2, false) // "0.20" + * + * @param {Number|String} value 要解析的数字或字符串 + * @param {Number} [fraction=2] 浮点数的小数部分,默认2位 + * @param {Boolean} [isTruncate=false] 是否截断,默认为四舍五入,不截断 + * @returns {String} + */ +export const toDecimal = (value, fraction = 2, isTruncate = false) => { + let result = NaN + + if (isNumber(value)) { + result = value + } + + if (typeof value === 'string') { + const val = parseFloat(value) + if (!isNaN(val)) { + result = val + } + } + + if (isNumber(result)) { + if (isTruncate) { + result = toFixed( + value + .toString() + .split('.') + .slice(0, 2) + .map((str, index) => (index ? str.slice(0, fraction) : str)) + .join('.'), + fraction + ) + } else { + result = toFixed(result, fraction) + } + } + + return result +} + +/** + * 尝试将字符串解析成浮点数。如果 value 是个无效的浮点数,则返回 defaultValue。 + * + * tryToDecimal(100) // "100.00" + * tryToDecimal("100.01", 2) // "100.01" + * tryToDecimal(0.8 - 0.6, 2, true) // "0.2" + * tryToDecimal(0.8 - 0.6, 2, false) // "0.20" + * tryToDecimal(null, 2, false, 100) // 100 + * + * @param {Number|String} value 要解析的数字或字符串 + * @param {Number} [fraction=2] 浮点数的小数部分,默认2位 + * @param {Boolean} [isTruncate=false] 是否截断,默认为四舍五入,不截断 + * @param {Number|String} [defaultValue] 若为 NaN 时,返回的缺省值 + * @returns {Number|String} + */ +export const tryToDecimal = (value, fraction, isTruncate, defaultValue) => + tryToConvert(toDecimal, defaultValue, value, fraction, isTruncate) + +/** + * 将数字或字符串转换成货币格式。 + * + * toCurrency(100) // "100.00" + * toCurrency(100, 2) // "100.00" + * toCurrency(1234.56) // "1,234.56" + * toCurrency(100, 2, '${0}') // "$100.00" + * + * @param {Number|String} value 要解析的数字或字符串 + * @param {Number} [fraction=2] 浮点数的小数部分,默认2位 + * @param {String} [placeholder] 货币符号,占位符格式,例如 "${0}" + * @param {Boolean} [isTruncate=false] 是否截断,默认为四舍五入,不截断 + * @returns {String} + */ +export const toCurrency = (value, fraction, placeholder, isTruncate) => { + if (isNumeric(value)) { + let val = toDecimal(Number(value), fraction, isTruncate) + val = String(val).replace(/(^|[^\w.])(\d{4,})/g, ($0, $1, $2) => $1 + $2.replace(/\d(?=(?:\d\d\d)+(?!\d))/g, '$&,')) + return placeholder ? format(placeholder, val) : val + } + + return NaN +} + +/** + * 尝试将数字转换成货币格式。如果 value 是个无效的金额,则返回 defaultValue。 + * + * tryToCurrency(100) // "100.00" + * tryToCurrency(100, 2) // "100.00" + * tryToCurrency(1234.56) // "1,234.56" + * tryToCurrency(100, 2, '${0}') // "$100.00" + * tryToCurrency(null, 3, '¥{0}', '金额错误') // "金额错误" + * + * @param {Number|String} value 要转换的数值 + * @param {Number} [fraction=2] 浮点数的小数部分,默认2位 + * @param {String} [placeholder] 货币符号,占位符格式,例如 "${0}" + * @param {Number|String} [defaultValue] 若为 NaN 时,返回的缺省值 + * @returns {Number|String} + */ +export const tryToCurrency = (value, fraction, placeholder, defaultValue) => + isNaN(toNumber(value)) ? defaultValue : toCurrency(value, fraction, placeholder) + +/** + * 转换成布尔值或0(表示false),1(表示true)。 + * + * toBoolValue(1) // 1 + * toBoolValue(true) // true + * toBoolValue('true') // true + * toBoolValue({}) // true + * toBoolValue('') // false + * + * @param {Number|String|Boolean} value 要转换的值 + * @returns {Boolean|number} + */ +export const toBoolValue = (value) => { + if (isNumber(value)) { + return value ? 1 : 0 + } else if (isNull(value) || value === 'false') { + return false + } else if (value === 'true') { + return true + } else if (typeof value === 'boolean') { + return value + } + + return !!value +} + +/** + * 将数值按百分比显示。 + * + * toRate(0.1) // "10.00%" + * toRate(10, 100, 2) // "10.00%" + * + * @param {Number} value 要转换的值 + * @param {Number} [total=1] 百分比基数,默认为1 + * @param {Number} [fraction=2] 数值的小数部分,默认为2 + * @returns {String} + */ +export const toRate = (value, total = 1, fraction = 2) => + isNumber(value) && isNumber(total) ? toDecimal(Decimal(value).mul(100).div(total).toNumber(), fraction) + '%' : value + +/** + * 文件大小值 单位互相转换。 + * + * toFileSize(1024) // "1.00KB" + * toFileSize(1024, 'B') // "1024.00B" + * toFileSize(1024, 'KB', 'B') // "1.00KB" + * toFileSize(1024, 'MB', 'KB') // "1.00MB" + * + * @param {Number} value 文件大小数值 + * @param {String} unit 转换后的单位 + * @param {String} [currUnit] 当前大小单位,默认为B,值可为B、KB、MB、GB、TB、PB、EB、ZB、YB + * @returns {String} + */ +export const toFileSize = (value, unit, currUnit) => { + if (isNumeric(value)) { + value = Number(value) + + if (value === 0) { + return `0${currUnit || unit || 'B'}` + } + + const fileSize = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + + let index = fileSize.indexOf(currUnit) + if (index > -1) { + for (let i = 0; i < index; i++) { + value *= 1024 + } + } + + index = fileSize.indexOf(unit) + if (index < 0) { + index = fileSize.length - 1 + } + + let level = 0 + for (let i = 0; i < index && (value <= -1024 || value >= 1024); i++) { + value /= 1024.0 + level++ + } + + return toDecimal(value, 2) + fileSize[level] + } + + return value +} + +/** + * 文件大小值,单位自动转化,最多保留2位小数 + * + * formatFileSize(17252 * 1024) // "16.84M" + * formatFileSize(200 * 1024, 'M') // "200G" + * + * @param {Number} size 文件大小数值 + * @param {String} [baseUnit] 当前大小单位,默认为 B,值可为 B、K、M、G、T、P、E、Z、Y + * @returns {String} 转化后的文件大小和单位 + */ +export const formatFileSize = (size, baseUnit = '') => { + if ([undefined, null].includes(size)) { + return '' + } else if (!isNumber(size) || size <= 0) { + return size + baseUnit + } + + const unitArr = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + let unitIndex = Math.max(unitArr.indexOf((baseUnit + '').toLocaleUpperCase()), 0) + + while (size >= 1024 && unitIndex < unitArr.length - 1) { + size = size / 1024.0 + unitIndex++ + } + + while (size < 1 && unitIndex > 0) { + size = size * 1024 + unitIndex-- + } + + return parseFloat(toDecimal(size, 2, true)) + ' ' + unitArr[unitIndex] +} + +/** + * 检查文本中是否包含韩文 + * @param {String} text + */ +export const isKorean = (text) => /([(\uAC00-\uD7AF)|(\u3130-\u318F)])+/gi.test(text) + +/** + * 对字符串进行省略截取 + * @param {*} text 待处理的字符串 + * @param {*} font 字符集,例如 '14px Arial' + * @param {*} w 字符串显示最大长度 + * @returns obj obj.t为处理后字符串,obj.o为是否已省略标志 + */ +export const omitText = (text: string, font: string, w: number) => { + let t: string + if (!isBrowser) return { t: text, o: false } + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + ctx.font = font + + let metric = ctx.measureText(text) + + if (metric.width < w) { + return { t: text, o: false } + } else { + for (let i = -1; ; i--) { + t = text.slice(0, i) + '...' + metric = ctx.measureText(t) + + if (metric.width < w) { + return { t, o: true } + } + } + } +} diff --git a/packages/utils/src/throttle/index.ts b/packages/utils/src/throttle/index.ts new file mode 100644 index 0000000000..392936de88 --- /dev/null +++ b/packages/utils/src/throttle/index.ts @@ -0,0 +1,89 @@ +/* eslint-disable prefer-rest-params */ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +/** + * Throttle execution of a function. Especially useful for rate limiting + * execution of handlers on events like resize and scroll. + * + * @param {Number} delay A zero-or-greater delay in milliseconds. For event callbacks, + * values around 100 or 250 (or even higher) are most useful. + * @param {Boolean} [noTrailing] Optional, defaults to false. If noTrailing is true, + * callback will only execute every `delay` milliseconds while the + * throttled-function is being called. If noTrailing is false or unspecified, + * callback will be executed one final time + * after the last throttled-function call. + * (After the throttled-function has not been called for `delay` milliseconds, + * the internal counter is reset) + * @param {Function} callback A function to be executed after delay milliseconds. + * The `this` context and all arguments are passed through, as-is, + * to `callback` when the throttled-function is executed. + * @param {Boolean} [debounceMode] If `debounceMode` is true (at begin), + * schedule `clear` to execute after `delay` ms. + * If `debounceMode` is false (at end), + * schedule `callback` to execute after `delay` ms. + * + * @return {Function} A new, throttled, function. + */ + +export function throttle(delay, noTrailing, callback, debounceMode?: string): Function { + let timeoutID + let lastExec = 0 + + if (typeof noTrailing !== 'boolean') { + debounceMode = callback + callback = noTrailing + noTrailing = undefined + } + + function wrapper() { + const me = this + const elapsed = new Date().valueOf() - lastExec + const args = arguments + + function exec() { + lastExec = new Date().valueOf() + callback.apply(me, args) + } + + function clear() { + timeoutID = undefined + } + + if (debounceMode && !timeoutID) { + exec() + } + + if (timeoutID) { + clearTimeout(timeoutID) + } + + const isUndMode = debounceMode === undefined + + if (isUndMode && elapsed > delay) { + exec() + } else if (noTrailing !== true) { + timeoutID = setTimeout(debounceMode ? clear : exec, isUndMode ? delay - elapsed : delay) + } + } + + function cancel() { + if (timeoutID) { + clearTimeout(timeoutID) + timeoutID = null + } + } + + wrapper._cancel = cancel + + return wrapper +} diff --git a/packages/utils/src/touch-emulator/index.ts b/packages/utils/src/touch-emulator/index.ts new file mode 100644 index 0000000000..bc890f9dcc --- /dev/null +++ b/packages/utils/src/touch-emulator/index.ts @@ -0,0 +1,124 @@ +import { isBrowser } from '../browser' + +let emulated = false +let initiated = false +let eventTarget = null +let mouseTarget = null + +const matches = isBrowser ? Element.prototype.matches : null + +const closest = (el, s) => { + if (isBrowser) { + do { + if (matches.call(el, s)) return el + el = el.parentElement || el.parentNode + } while (el !== null && el.nodeType === 1) + } + return null +} + +class Touch { + constructor(target, identifier, pos, deltaX, deltaY) { + this.target = target + this.identifier = identifier + + deltaX = deltaX || 0 + deltaY = deltaY || 0 + + this.pageX = pos.pageX + deltaX + this.pageY = pos.pageY + deltaY + this.screenX = pos.screenX + deltaX + this.screenY = pos.screenY + deltaY + this.clientX = pos.clientX + deltaX + this.clientY = pos.clientY + deltaY + this.offsetX = pos.offsetX + deltaX + this.offsetY = pos.offsetY + deltaY + } +} + +const TouchList = () => { + const touchList = [] + + touchList.item = (index) => touchList[index] || null + touchList.identifiedTouch = (id) => touchList[id + 1] || null + + return touchList +} + +const createTouchList = (mouseEv) => { + const touchList = TouchList() + touchList.push(new Touch(eventTarget, 1, mouseEv, 0, 0)) + return touchList +} + +const getActiveTouches = (mouseEv) => { + if (mouseEv.type === 'mouseup') { + return TouchList() + } + + return createTouchList(mouseEv) +} + +const triggerTouch = (eventName, mouseEv) => { + const touchEvent = document.createEvent('Event') + + touchEvent.initEvent(eventName, true, true) + + touchEvent.altKey = mouseEv.altKey + touchEvent.metaKey = mouseEv.metaKey + touchEvent.ctrlKey = mouseEv.ctrlKey + touchEvent.shiftKey = mouseEv.shiftKey + + touchEvent.changedTouches = createTouchList(mouseEv) + touchEvent.targetTouches = getActiveTouches(mouseEv) + touchEvent.touches = getActiveTouches(mouseEv) + // 模拟事件标记 + touchEvent.isTinySimulate = true + + eventTarget.dispatchEvent(touchEvent) +} + +const onMouse = (touchType) => (ev) => { + if (ev.type === 'mousedown') { + initiated = true + } + + if (ev.type === 'mouseup') { + initiated = false + } + + if (ev.type === 'mousemove' && !initiated) { + return + } + + if (ev.type === 'mousedown' || !mouseTarget) { + mouseTarget = ev.target + } + + eventTarget = closest(mouseTarget, '[data-tiny-touch-simulate-container]') + + if (eventTarget && eventTarget.dispatchEvent) { + triggerTouch(touchType, ev) + } + + if (ev.type === 'mouseup') { + eventTarget = null + mouseTarget = null + } +} + +const touchEmulator = () => { + window.addEventListener('mousedown', onMouse('touchstart'), true) + window.addEventListener('mousemove', onMouse('touchmove'), true) + window.addEventListener('mouseup', onMouse('touchend'), true) +} + +export const emulate = () => { + if (isBrowser) { + const supportTouch = 'ontouchstart' in window + if (!emulated && !supportTouch) { + emulated = true + touchEmulator() + } + } +} diff --git a/packages/utils/src/touch/index.ts b/packages/utils/src/touch/index.ts new file mode 100644 index 0000000000..50b05f62a6 --- /dev/null +++ b/packages/utils/src/touch/index.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +const MIN_DISTANCE = 10 + +export const getDirection = (x, y) => { + if (x > y && x > MIN_DISTANCE) { + return 'horizontal' + } + + if (y > x && y > MIN_DISTANCE) { + return 'vertical' + } + + return '' +} + +export const touchStart = (state) => (event) => { + resetTouchStatus(state) + + state.startX = event.touches[0].clientX + state.startY = event.touches[0].clientY +} + +export const touchMove = (state) => (event) => { + const touch = event.touches[0] + + state.deltaX = touch.clientX - state.startX + state.deltaY = touch.clientY - state.startY + state.offsetX = Math.abs(state.deltaX) + state.offsetY = Math.abs(state.deltaY) + + state.direction = state.direction || getDirection(state.offsetX, state.offsetY) +} + +export const resetTouchStatus = (state) => { + state.direction = '' + state.deltaX = 0 + state.deltaY = 0 + state.offsetX = 0 + state.offsetY = 0 +} diff --git a/packages/utils/src/tree-model/index.ts b/packages/utils/src/tree-model/index.ts new file mode 100644 index 0000000000..0e1947a51a --- /dev/null +++ b/packages/utils/src/tree-model/index.ts @@ -0,0 +1,5 @@ +export { NODE_KEY, getNodeKey, markNodeData } from './util' + +export { getChildState, Node } from './node' + +export { TreeStore } from './tree-store' diff --git a/packages/utils/src/tree-model/node.ts b/packages/utils/src/tree-model/node.ts new file mode 100644 index 0000000000..58f8d9b5fe --- /dev/null +++ b/packages/utils/src/tree-model/node.ts @@ -0,0 +1,627 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { merge } from '../object' +import { indexOf } from '../array' +import { hasOwn, typeOf } from '../type' +import { markNodeData, NODE_KEY } from './util' + +const defaultChildrenKey = 'children' +const defaultIsLeafKey = 'isLeaf' + +const getPropertyFromData = (node, prop) => { + const props = node.store.props + const dataData = node.data || {} + const config = props[prop] + + if (typeOf(config) === 'string') { + return dataData[config] + } else if (typeOf(config) === 'function') { + return config(dataData, node) + } else if (typeof config === 'undefined') { + const dataProp = dataData[prop] + return dataProp === undefined ? '' : dataProp + } +} + +export const getChildState = (node) => { + let all = true + let none = true + let allWithoutDisable = true + + for (let i = 0, len = node.length; i < len; i++) { + const { checked, disabled, indeterminate } = node[i] + + if (checked !== true || indeterminate) { + all = false + + if (!disabled) { + allWithoutDisable = false + } + } + + if (checked !== false || indeterminate) { + none = false + } + } + + const half = !all && !none + + return { all, none, allWithoutDisable, half } +} + +const reInitChecked = (node) => { + const childNodes = node.childNodes + + if (childNodes.length === 0) { + return + } + + const { all, none, half } = getChildState(childNodes) + + if (all) { + Object.assign(node, { checked: true, indeterminate: false }) + } else if (half) { + Object.assign(node, { checked: false, indeterminate: true }) + } else if (none) { + Object.assign(node, { checked: false, indeterminate: false }) + } + + const parent = node.parent + + if (!parent || parent.level === 0) { + return + } + + !node.store.checkStrictly && reInitChecked(parent) +} + +let nodeIdSeed = 0 + +export class Node { + constructor(options) { + this.init(options) + + const store = this.store + + if (!store) { + throw new Error('[TINY-Tree][Node]store is required!') + } + + store.registerNode(this) + + const props = store.props + + if (props && typeof props.isLeaf !== 'undefined') { + const isLeaf = getPropertyFromData(this, defaultIsLeafKey) + + if (typeof isLeaf === 'boolean') { + this.isLeafByUser = isLeaf + } + } + + this.initExpandState() + + if (!Array.isArray(this.data)) { + markNodeData(this, this.data) + } + + if (!this.data) { + return + } + + this.expandByDefaultKeys() + + const { key, lazy, currentNodeKey } = store + + if (key && currentNodeKey !== undefined && this.key === currentNodeKey) { + store.currentNode = this + store.currentNode.isCurrent = true + } + + lazy && store._initDefaultCheckedNode(this) + + this.updateLeafState() + } + + initExpandState() { + const { store, data, level } = this + + if (store.lazy !== true && data) { + this.setData(data) + + if (store.defaultExpandAll) { + this.expanded = true + this.updateMethod(this, 'expanded') + } + } else if (level > 0 && store.lazy && store.defaultExpandAll) { + this.expand() + } + } + + init(options) { + this.id = nodeIdSeed++ + this.checked = false + this.indeterminate = false + this.expanded = false + this.visible = true + this.isCurrent = false + this.text = null + this.data = null + this.parent = null + this.updateMethod = () => {} + + Object.keys(options).forEach((key) => { + if (hasOwn.call(options, key)) { + this[key] = options[key] + } + }) + const isLeafKey = this.store?.props?.isLeaf || defaultIsLeafKey + this.isLeaf = !!(this.data && this.data[isLeafKey]) + this.loaded = this.isLeaf + this.loading = false + this.childNodes = [] + this.level = this.parent ? this.parent.level + 1 : 0 + } + + expandByDefaultKeys() { + const { defaultExpandedKeys, key, autoExpandParent } = this.store + + if (key && defaultExpandedKeys && ~defaultExpandedKeys.indexOf(this.key)) { + this.expand(null, autoExpandParent) + } + } + + setData(data) { + if (!Array.isArray(data)) { + markNodeData(this, data) + } + + this.data = data + this.childNodes = [] + let children + + if (this.level === 0 && Array.isArray(this.data)) { + children = this.data + } else { + children = getPropertyFromData(this, defaultChildrenKey) || [] + } + + for (let i = 0, len = children.length; i < len; i++) { + const data = children[i] + + this.insertChild({ data }) + } + } + + get key() { + const { store, data } = this + const nodeKey = store.key + + if (data) { + return data[nodeKey] + } + + return null + } + + get label() { + return getPropertyFromData(this, 'label') + } + + get disabled() { + return getPropertyFromData(this, 'disabled') + } + + get nextSibling() { + const parent = this.parent + + if (parent) { + const childNodes = parent.childNodes + const index = childNodes.indexOf(this) + + if (~index) { + return childNodes[index + 1] + } + } + + return null + } + + get previousSibling() { + const parent = this.parent + + if (parent) { + const childNodes = parent.childNodes + const index = childNodes.indexOf(this) + + if (~index) { + return index > 0 ? childNodes[index - 1] : null + } + } + + return null + } + + remove() { + const parent = this.parent + + parent && parent.removeChild(this) + } + + contains(target, deep = true) { + const walkTree = (parent) => { + const children = parent.childNodes || [] + let isContain = false + + for (let i = 0, len = children.length; i < len; i++) { + const child = children[i] + + if (child === target || (deep && walkTree(child))) { + isContain = true + break + } + } + + return isContain + } + + return walkTree(this) + } + + insertChild(child, index, batch) { + if (!child) { + throw new Error('[TINY-Tree] insertChild error: child is required.') + } + + const insertNode = ({ arr, index, item }) => { + if (typeof index === 'undefined' || index < 0) { + arr.push(item) + } else { + arr.splice(index, 0, item) + } + } + + if (!(child instanceof Node)) { + if (!batch) { + const children = this.getChildren(true) || [] + + if (!~children.indexOf(child.data)) { + insertNode({ arr: children, index, item: child.data }) + } + } + + merge(child, { parent: this, store: this.store }) + + child = new Node(child) + } + + child.level = this.level + 1 + + insertNode({ arr: this.childNodes, index, item: child }) + + this.updateLeafState() + + return child + } + + insertBefore(child, beforeNode) { + let index + + if (beforeNode) { + index = this.childNodes.indexOf(beforeNode) + } + + this.insertChild(child, index) + } + + insertAfter(child, afterNode) { + let index + + if (afterNode) { + index = this.childNodes.indexOf(afterNode) + if (~index) { + index += 1 + } + } + + this.insertChild(child, index) + } + + removeChild(child) { + const children = this.getChildren() || [] + let index = children.indexOf(child.data) + + if (~index) { + children.splice(index, 1) + } + + index = this.childNodes.indexOf(child) + + if (~index) { + this.store && this.store.deregisterNode(child) + child.parent = null + this.childNodes.splice(index, 1) + } + + this.updateLeafState() + } + + removeChildByData(data) { + let removeNode = null + + for (let i = 0, len = this.childNodes.length; i < len; i++) { + const child = this.childNodes[i] + + if (child.data === data) { + removeNode = child + break + } + } + + removeNode && this.removeChild(removeNode) + } + + expand(callback, expandParent) { + const expandNodes = () => { + if (expandParent) { + let parentNode = this.parent + + while (parentNode.level > 0) { + parentNode.expanded = true + parentNode.updateMethod(parentNode, 'expanded') + parentNode = parentNode.parent + } + } + + this.expanded = true + this.updateMethod(this, 'expanded') + callback && callback() + } + + if (this.shouldLoadData()) { + this.loadData((data) => { + if (Array.isArray(data)) { + if (this.checked) { + this.setChecked(true, true) + } else if (!this.store.checkStrictly) { + reInitChecked(this) + } + expandNodes() + } + }) + } else { + expandNodes() + } + } + + doCreateChildren(array, defaultProps = {}) { + array.forEach((data) => { + this.insertChild(merge({ data }, defaultProps), undefined, true) + }) + } + + collapse() { + this.expanded = false + this.updateMethod(this, 'expanded') + } + + shouldLoadData() { + return this.store.lazy === true && this.store.load && !this.loaded + } + + updateLeafState() { + const { store, loaded, isLeafByUser } = this + const lazy = store.lazy + + if (lazy === true && loaded !== true && typeof isLeafByUser !== 'undefined') { + this.isLeaf = isLeafByUser + return + } + + const childs = this.childNodes + + if (!lazy || (lazy === true && loaded === true)) { + this.isLeaf = !childs || childs.length === 0 + return + } + + this.isLeaf = false + } + + getChildren(forceInit = false) { + const { level, data } = this + + if (level === 0) { + return data + } + + if (!data) { + return null + } + + const props = this.store.props + let childrenKey = defaultChildrenKey + + if (props) { + childrenKey = props.children || defaultChildrenKey + } + + if (data[childrenKey] === undefined) { + data[childrenKey] = null + } + + if (forceInit && !data[childrenKey]) { + data[childrenKey] = [] + } + + return data[childrenKey] + } + + setChecked(value, isDeepChecked, recursion, passValue, checkEasily) { + this.checked = value === true + this.indeterminate = value === 'half' + + const { checkStrictly, checkDescendants } = this.store + + if (checkStrictly && !checkEasily) { + return + } + + let ret = this.setCheckedInner({ + checkDescendants, + value, + isDeepChecked, + passValue, + checkEasily + }) + let returnFlag = ret.returnFlag + passValue = ret.passValue + value = ret.value + + if (returnFlag || (checkStrictly && checkEasily)) { + return + } + + const parentNode = this.parent + if (!parentNode || parentNode.level === 0) { + return + } + + if (!recursion) { + reInitChecked(parentNode) + } + } + + setCheckedInner({ checkDescendants, value, isDeepChecked, passValue, checkEasily }) { + let returnFlag = false + + if (this.shouldLoadData() && !checkDescendants) { + return { value, passValue, returnFlag } + } + + const { all, allWithoutDisable } = getChildState(this.childNodes) + + if (!this.isLeaf && !all && allWithoutDisable && !checkEasily) { + this.checked = false + value = false + } + + const batchSetChecked = () => { + if (isDeepChecked) { + const childNodes = this.childNodes + + for (let i = 0, len = childNodes.length; i < len; i++) { + const childNode = childNodes[i] + + passValue = passValue || value !== false + + const isCheck = childNode.disabled ? childNode.checked : passValue + + childNode.setChecked(isCheck, isDeepChecked, true, passValue, checkEasily) + } + + const { half, all } = getChildState(childNodes) + + if (!all && !checkEasily) { + this.checked = all + this.indeterminate = half + } + } + } + + if (this.shouldLoadData()) { + const afterLoad = () => { + batchSetChecked() + reInitChecked(this) + } + + this.loadData(afterLoad, { checked: value !== false }) + + returnFlag = true + } else { + batchSetChecked() + } + + return { value, passValue, returnFlag } + } + + updateChildren() { + const children = this.getChildren() || [] + const oldChildren = this.childNodes.map((child) => child.data) + const newChildrenMap = {} + const newChildren = [] + + children.forEach((item, index) => { + const key = item[NODE_KEY] + const isNodeExists = !!key && indexOf(oldChildren, key, (item, data) => item[NODE_KEY] === data) >= 0 + + if (isNodeExists) { + newChildrenMap[key] = { index, data: item } + } else { + newChildren.push({ index, data: item }) + } + }) + + if (!this.store.lazy) { + oldChildren.forEach((item) => { + if (!newChildrenMap[item[NODE_KEY]]) { + this.removeChildByData(item) + } + }) + } + + newChildren.forEach(({ data, index }) => { + this.insertChild({ data }, index) + }) + + this.updateLeafState() + } + + loadData(callback, defaultProps = {}) { + const { lazy, load } = this.store + + if (lazy === true && load && !this.loaded && (!this.loading || Object.keys(defaultProps).length)) { + this.loading = true + + this.store.load(this, (children) => { + this.loading = false + this.loaded = true + this.childNodes = [] + + this.doCreateChildren(children, defaultProps) + this.updateLeafState() + + callback && callback.call(this, children) + typeof this.store.afterLoad === 'function' && this.store.afterLoad({ data: children }) + }) + } else { + callback && callback.call(this) + } + } + + getPathData(key) { + const nodes = [key ? this.data[key] : this.data] + let parentNode = this.parent + + while (parentNode && parentNode.parent) { + nodes.unshift(key ? parentNode.data[key] : parentNode.data) + parentNode = parentNode.parent + } + + return nodes + } + + getPathText(key, separator = ',') { + return (this.getPathData(key) || []).join(separator) + } +} diff --git a/packages/utils/src/tree-model/tree-store.ts b/packages/utils/src/tree-model/tree-store.ts new file mode 100644 index 0000000000..472f569832 --- /dev/null +++ b/packages/utils/src/tree-model/tree-store.ts @@ -0,0 +1,414 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { hasOwn, isNull } from '../type' +import { getNodeKey } from './util' +import { Node } from './node' + +export class TreeStore { + constructor(options) { + this.currentNode = null + this.currentNodeKey = null + + for (let option in options) { + if (hasOwn.call(options, option)) { + this[option] = options[option] + } + } + + this.nodesMap = {} + + this.root = new Node({ data: this.data, store: this }) + + if (this.lazy && this.load) { + this.load(this.root, (data) => { + this.root.doCreateChildren(data) + this._initDefaultCheckedNodes() + typeof this.afterLoad === 'function' && this.afterLoad({ data, init: true }) + }) + } else { + this._initDefaultCheckedNodes() + } + } + + getMappingData(data) { + const props = this.props || {} + const mapping = {} + + for (let key in props) { + if (hasOwn.call(props, key)) { + mapping[key] = data[props[key]] + } + } + + return { ...data, ...mapping } + } + + filter(value) { + const { lazy, filterNodeMethod, getMappingData } = this + + const walkTree = (node) => { + const childNodes = node.root ? node.root.childNodes : node.childNodes + + childNodes.forEach((child) => { + // 筛选时需要添加mapping字段,但是不能修改用户的数据 + const mappingData = getMappingData.call(this, child.data) + child.visible = filterNodeMethod.call(child, value, mappingData, child) + + walkTree(child) + }) + + if (!node.visible && childNodes.length) { + let allHidden = !childNodes.some(({ visible }) => visible) + + if (node.root) { + node.root.visible = allHidden === false + } else { + node.visible = allHidden === false + } + } + + if (!value) { + return + } + if (node.visible && !node.isLeaf && !lazy) { + node.expand() + } + } + + walkTree(this) + } + + setData(newVal) { + if (newVal !== this.root.data) { + this.root.setData(newVal) + this._initDefaultCheckedNodes() + } else { + this.root.updateChildren() + } + } + + getNode(data) { + if (data instanceof Node) { + return data + } + + const nodeKey = typeof data !== 'object' ? data : getNodeKey(this.key, data) + + return this.nodesMap[nodeKey] || null + } + + insertBefore(data, insertData) { + const refNode = this.getNode(insertData) + refNode.parent.insertBefore({ data }, refNode) + } + + insertAfter(data, insertData) { + const refNode = this.getNode(insertData) + refNode.parent.insertAfter({ data }, refNode) + } + + remove(data, isSaveChildNode, isNode) { + const treeNode = isNode ? data : this.getNode(data) + + if (treeNode && treeNode.parent) { + if (treeNode === this.currentNode) { + this.currentNode = null + } + + if (isSaveChildNode && treeNode.childNodes) { + treeNode.childNodes.forEach((child) => { + treeNode.parent.insertChild({ data: child.data }) + }) + } + + treeNode.parent.removeChild(treeNode) + } + } + + append(data, parentData, index) { + const parentNode = parentData ? this.getNode(parentData) : this.root + + if (parentNode) { + const child = parentNode.insertChild({ data }, index) + data._isNewNode && this.registerNode(child) + } + } + + setDefaultCheckedKey(newValue) { + if (newValue !== this.defaultCheckedKeys) { + this.defaultCheckedKeys = newValue + this._initDefaultCheckedNodes() + } + } + + _initDefaultCheckedNodes() { + const defaultCheckedKeys = this.defaultCheckedKeys || [] + const nodesMap = this.nodesMap + + defaultCheckedKeys.forEach((checkedKey) => { + const node = nodesMap[checkedKey] + + node && node.setChecked(true, !this.checkStrictly) + }) + } + + _initDefaultCheckedNode(node) { + const defaultCheckedKeys = this.defaultCheckedKeys || [] + + ~defaultCheckedKeys.indexOf(node.key) && node.setChecked(true, !this.checkStrictly) + } + + getCheckedKeys(leafOnly = false) { + return this.getCheckedNodes(leafOnly).map((node) => (node || {})[this.key]) + } + + getHalfCheckedKeys() { + return this.getHalfCheckedNodes().map((node) => (node || {})[this.key]) + } + + deregisterNode(node) { + const key = this.key + if (!key || !node || !node.data) { + return + } + + node.childNodes.forEach((child) => { + this.deregisterNode(child) + }) + + delete this.nodesMap[node.key] + } + + registerNode(node) { + const key = this.key + if (!key || !node || !node.data) { + return + } + + const nodeKey = node.key + if (nodeKey !== undefined) { + this.nodesMap[nodeKey] = node + } + } + + getCheckedNodes(leafOnly = false, includeHalfChecked = false, isNode = false) { + const checkedNodes = [] + + const walkTree = (node) => { + const childNodes = node.root ? node.root.childNodes : node.childNodes + + childNodes.forEach((child) => { + const { checked, indeterminate, isLeaf, data } = child + + if ((checked || (includeHalfChecked && indeterminate)) && (!leafOnly || (leafOnly && isLeaf))) { + checkedNodes.push(isNode ? child : data) + } + + walkTree(child) + }) + } + + walkTree(this) + + return checkedNodes + } + + getHalfCheckedNodes() { + const nodes = [] + + const walkTree = (node) => { + const childNodes = node.root ? node.root.childNodes : node.childNodes + + childNodes.forEach((child) => { + const { indeterminate, data } = child + + indeterminate && nodes.push(data) + + walkTree(child) + }) + } + + walkTree(this) + + return nodes + } + + _getAllNodes() { + const allNodes = [] + const nodesMap = this.nodesMap + + Object.keys(nodesMap).forEach((nodeKey) => { + hasOwn.call(nodesMap, nodeKey) && allNodes.push(nodesMap[nodeKey]) + }) + + return allNodes + } + + updateChildren(key, data) { + const node = this.nodesMap[key] + if (!node) { + return + } + + const childNodes = node.childNodes + + for (let i = childNodes.length - 1; i >= 0; i--) { + this.remove(childNodes[i].data) + } + + for (let i = 0, len = data.length; i < len; i++) { + const child = data[i] + this.append(child, node.data) + } + } + + _setCheckedKeys(key, leafOnly = false, checkedKeys = {}) { + const nodes = this._getAllNodes().sort((prevNode, nextNode) => nextNode.level - prevNode.level) + const cache = Object.create(null) + const keys = Object.keys(checkedKeys) + + nodes.forEach((node) => { + node.setChecked(false, false) + }) + + for (let i = 0, len = nodes.length; i < len; i++) { + const node = nodes[i] + const nodeKey = node.data[key].toString() + let checked = ~keys.indexOf(nodeKey) + + if (!checked) { + if (node.checked && !cache[nodeKey]) { + node.setChecked(false, false) + } + } else { + let parentNode = node.parent + + while (parentNode && parentNode.level > 0) { + cache[parentNode.data[key]] = true + parentNode = parentNode.parent + } + + if (node.isLeaf || this.checkStrictly) { + node.setChecked(true, false) + } else if (leafOnly) { + node.setChecked(false, false) + + const walkTree = (node) => { + const childNodes = node.childNodes + + childNodes.forEach((child) => { + !child.isLeaf && child.setChecked(false, false) + + walkTree(child) + }) + } + + walkTree(node) + } else { + node.setChecked(true, true) + } + } + } + } + + setDefaultExpandedKeys(keys) { + keys = keys || [] + this.defaultExpandedKeys = keys + + keys.forEach((key) => { + const node = this.getNode(key) + node && node.expand(null, this.autoExpandParent) + }) + } + + setCheckedKeys(keys, leafOnly = false) { + this.defaultCheckedKeys = keys + const checkedKeys = {} + + keys.forEach((key) => { + checkedKeys[key] = true + }) + + this._setCheckedKeys(this.key, leafOnly, checkedKeys) + } + + setCheckedNodes(array, leafOnly = false) { + const key = this.key + const checkedKeys = {} + + array.forEach((item) => { + checkedKeys[(item || {})[key]] = true + }) + + this._setCheckedKeys(key, leafOnly, checkedKeys) + } + + setChecked(data, checked, deep) { + const node = this.getNode(data) + + node && node.setChecked(!!checked, deep) + } + + setCurrentNode(currentNode) { + const prevNode = this.currentNode + + if (prevNode) { + prevNode.isCurrent = false + } + + this.currentNode = currentNode + + if (currentNode) { + this.currentNode.isCurrent = true + } + } + + getCurrentNode() { + return this.currentNode + } + + setCurrentNodeKey(key) { + if (isNull(key)) { + this.currentNode && (this.currentNode.isCurrent = false) + this.currentNode = null + + return + } + + const node = this.getNode(key) + + node && this.setCurrentNode(node) + } + + setUserCurrentNode(node) { + const key = node[this.key] + const currNode = this.nodesMap[key] + + this.setCurrentNode(currNode) + } + + getData(data) { + return (this.getNode(data) || {}).data + } + + getAllData() { + const children = this.props.children + const walkTree = (nodes) => { + return nodes.map((node) => { + return { ...node.data, [children]: walkTree(node.childNodes) } + }) + } + + return walkTree(this.root.childNodes) + } +} diff --git a/packages/utils/src/tree-model/util.ts b/packages/utils/src/tree-model/util.ts new file mode 100644 index 0000000000..8fca51aaa1 --- /dev/null +++ b/packages/utils/src/tree-model/util.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +export const NODE_KEY = '$treeNodeId' + +export const getNodeKey = function (key, data) { + if (!key) { + return data[NODE_KEY] + } + return data[key] +} + +export const markNodeData = function (node, data) { + if (!data || data[NODE_KEY]) { + return + } + + Object.defineProperty(data, NODE_KEY, { + value: node.id, + enumerable: false, + configurable: false, + writable: false + }) +} diff --git a/packages/utils/src/type/index.ts b/packages/utils/src/type/index.ts new file mode 100644 index 0000000000..6d78d183da --- /dev/null +++ b/packages/utils/src/type/index.ts @@ -0,0 +1,166 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +export const toString = Object.prototype.toString +export const hasOwn = Object.prototype.hasOwnProperty + +const getProto = Object.getPrototypeOf +const fnToString = hasOwn.toString +const ObjectFunctionString = fnToString.call(Object) + +const class2type = { + '[object Error]': 'error', + '[object Object]': 'object', + '[object RegExp]': 'regExp', + '[object Date]': 'date', + '[object Array]': 'array', + '[object Function]': 'function', + '[object AsyncFunction]': 'asyncFunction', + '[object String]': 'string', + '[object Number]': 'number', + '[object Boolean]': 'boolean' +} + +/** 判断是否为 null / undefined */ +export const isNull = (x: any) => x === null || x === undefined + +/** + * 返回 JavaScript 对象的类型。 + * + * 如果对象是 undefined 或 null,则返回相应的'undefined'或'null'。 + * + * 其他一切都将返回它的类型'object'。 + * + * typeOf( undefined ) === 'undefined' + * typeOf() === 'undefined' + * typeOf( window.notDefined ) === 'undefined' + * typeOf( null ) === 'null' + * typeOf( true ) === 'boolean' + * typeOf( 3 ) === 'number' + * typeOf( "test" ) === 'string' + * typeOf( function (){} ) === 'function' + * typeOf( [] ) === 'array' + * typeOf( new Date() ) === 'date' + * typeOf( new Error() ) === 'error' + * typeOf( /test/ ) === 'regExp' + * + */ +export const typeOf: (obj: any) => string = (obj) => + isNull(obj) ? String(obj) : class2type[toString.call(obj)] || 'object' + +/** + * 判断对象是否为 object 类型。 + * + * isObject({}) // true + */ +export const isObject = (obj: any) => typeOf(obj) === 'object' + +/** + * 判断对象是否为 function 类型。 + * + * isObject(function (){) // true + + */ +export const isFunction = (fn: any) => ['asyncFunction', 'function'].includes(typeOf(fn)) + +/** + * 判断对象是否为简单对象。 + * + * 即不是 HTML 节点对象,也不是 window 对象,而是纯粹的对象(通过 '{}' 或者 'new Object' 创建的)。 + * + * let obj = {} + * isPlainObject(obj) //true + */ +export const isPlainObject = (obj: any) => { + if (!obj || toString.call(obj) !== '[object Object]') { + return false + } + + const proto = getProto(obj) + if (!proto) { + return true + } + + const Ctor = hasOwn.call(proto, 'constructor') && proto.constructor + return typeof Ctor === 'function' && fnToString.call(Ctor) === ObjectFunctionString +} + +/** + * 检查对象是否为空(不包含任何属性)。 + * + * let obj = {} + * isEmptyObject(obj) // true + */ +export const isEmptyObject = (obj: any) => { + const type = typeOf(obj) + + if (type === 'object' || type === 'array') { + for (const name in obj) { + if (hasOwn.call(obj, name)) { + return false + } + } + } + + return true +} + +/** + * 判断对象是否为数字类型。 + * + * isNumber(369) // true + */ +export const isNumber = (value: any) => typeof value === 'number' && isFinite(value) + +/** + * 判断对象是否代表一个数值。 + * + * isNumeric('-10') // true + * isNumeric(16) // true + * isNumeric(0xFF) // true + * isNumeric('0xFF') // true + * isNumeric('8e5') // true + * isNumeric(3.1415) // true + * isNumeric(+10) // true + * isNumeric('') // false + * isNumeric({}) // false + * isNumeric(NaN) // false + * isNumeric(null) // false + * isNumeric(true) // false + * isNumeric(Infinity) // false + * isNumeric(undefined) // false + */ +export const isNumeric = (value: any) => value - parseFloat(value) >= 0 + +/** + * 判断对象是否为日期类型。 + * + * let date = new Date() + * isDate(date) // true + */ +export const isDate = (value) => typeOf(value) === 'date' + +/** + * 判断两个值是否值相同且类型相同。 + * + * 注:在 JavaScript 里 NaN === NaN 为 false,因此不能简单的用 === 来判断。 + * + * isSame(1, 1) // true + * isSame(NaN, NaN) // true + */ +export const isSame = (x: any, y: any) => + x === y || (typeof x === 'number' && typeof y === 'number' && isNaN(x) && isNaN(y)) + +/** 判断是否是正则表达式 */ +export const isRegExp = (value: any) => typeOf(value) === 'regExp' + +export const isPromise = (val) => isObject(val) && isFunction(val.then) && isFunction(val.catch) diff --git a/packages/utils/src/upload-ajax/index.ts b/packages/utils/src/upload-ajax/index.ts new file mode 100644 index 0000000000..65b0321564 --- /dev/null +++ b/packages/utils/src/upload-ajax/index.ts @@ -0,0 +1,113 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { hasOwn } from '../type' +import xss from '../xss' + +const getBody = (xhr) => { + const text = xhr.responseText || xhr.response + + if (!text) { + return text + } + + try { + return JSON.parse(text) + } catch (e) { + return text + } +} + +const getError = (action, option, xhr) => { + let errorText + + if (xhr.response) { + errorText = xhr.response.error || xhr.response + } else if (xhr.responseText) { + errorText = xhr.responseText + } else { + errorText = `fail to post ${action} ${xhr.status}` + } + + const error = new Error(errorText) + + error.status = xhr.status + error.method = 'post' + error.url = action + + return error +} + +export const uploadAjax = (option) => { + if (typeof XMLHttpRequest === 'undefined') { + return + } + + const xhr = new XMLHttpRequest() + const action = xss.filterUrl(option.action) + + if (xhr.upload) { + xhr.upload.onprogress = (event) => { + if (event.total > 0) { + event.percent = (event.loaded / event.total) * 100 + } + + option.onProgress(event) + } + } + + const formData = new FormData() + + if (option.data) { + Object.keys(option.data).forEach((key) => { + formData.append(key, option.data[key]) + }) + } + + if (Array.isArray(option.file)) { + option.file.forEach((file) => { + formData.append(option.filename, file, file.name) + }) + } else { + formData.append(option.filename, option.file, option.file.name) + } + + xhr.onerror = (event) => { + option.onError(event) + } + + xhr.onload = () => { + if (xhr.status < 200 || xhr.status >= 300) { + return option.onError(getError(action, option, xhr)) + } + + option.onSuccess(getBody(xhr)) + } + + xhr.open('post', action, true) + + if (option.withCredentials && 'withCredentials' in xhr) { + xhr.withCredentials = true + } + + const headers = option.headers || {} + + for (let header in headers) { + if (hasOwn.call(headers, header) && headers[header] !== null) { + xhr.setRequestHeader(header, headers[header]) + } + } + + xhr.send(formData) + + return xhr +} diff --git a/packages/utils/src/validate/index.ts b/packages/utils/src/validate/index.ts new file mode 100644 index 0000000000..da7232cb66 --- /dev/null +++ b/packages/utils/src/validate/index.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import Schema from './schema' +import validators from './validations/index' +import getDefaultMessage from './messages' + +Schema.validators = validators +Schema.getDefaultMessage = getDefaultMessage + +export const Validator = Schema diff --git a/packages/utils/src/validate/messages.ts b/packages/utils/src/validate/messages.ts new file mode 100644 index 0000000000..5d24a33225 --- /dev/null +++ b/packages/utils/src/validate/messages.ts @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +const getTypesObj = (translate) => ({ + string: translate('validation.types.string'), + method: translate('validation.types.method'), + array: translate('validation.types.array'), + object: translate('validation.types.object'), + number: translate('validation.types.number'), + date: translate('validation.types.date'), + boolean: translate('validation.types.boolean'), + integer: translate('validation.types.integer'), + float: translate('validation.types.float'), + regexp: translate('validation.types.regexp'), + email: translate('validation.types.email'), + url: translate('validation.types.url'), + hex: translate('validation.types.hex'), + digits: translate('validation.types.digits'), + time: translate('validation.types.time'), + dateYM: translate('validation.types.dateYM'), + dateYMD: translate('validation.types.dateYMD'), + dateTime: translate('validation.types.dateTime'), + longDateTime: translate('validation.types.longDateTime'), + version: translate('validation.types.version'), + speczh: translate('validation.types.speczh'), + specialch: translate('validation.types.specialch'), + specialch2: translate('validation.types.hex'), + acceptImg: translate('validation.types.acceptImg'), + acceptFile: translate('validation.types.acceptFile'), + fileSize: translate('validation.types.fileSize') +}) +export default (translate = (value) => value) => ({ + default: translate('validation.default'), + required: translate('validation.required'), + enum: translate('validation.enum'), + whitespace: translate('validation.whitespace'), + date: { + format: translate('validation.date.format'), + parse: translate('validation.date.parse'), + invalid: translate('validation.date.invalid') + }, + types: getTypesObj(translate), + string: { + len: translate('validation.string.len'), + min: translate('validation.string.min'), + max: translate('validation.string.max'), + range: translate('validation.string.range') + }, + number: { + len: translate('validation.number.len'), + min: translate('validation.number.min'), + max: translate('validation.number.max'), + range: translate('validation.number.range') + }, + array: { + len: translate('validation.array.len'), + min: translate('validation.array.min'), + max: translate('validation.array.max'), + range: translate('validation.array.range') + }, + pattern: { + mismatch: translate('validation.pattern.mismatch') + } +}) diff --git a/packages/utils/src/validate/rules/enum.ts b/packages/utils/src/validate/rules/enum.ts new file mode 100644 index 0000000000..3252a033be --- /dev/null +++ b/packages/utils/src/validate/rules/enum.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import * as util from '../util' + +const ENUM = 'enum' + +export default function (rule, checkValue, source, errors, options) { + rule[ENUM] = Array.isArray(rule[ENUM]) ? rule[ENUM] : [] + + if (!rule[ENUM].includes(checkValue)) { + errors.push(util.format(options.messages[ENUM], '', rule[ENUM].join(', '))) + } +} diff --git a/packages/utils/src/validate/rules/index.ts b/packages/utils/src/validate/rules/index.ts new file mode 100644 index 0000000000..3da1a6ffdd --- /dev/null +++ b/packages/utils/src/validate/rules/index.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import type from './type' +import range from './range' +import enumRule from './enum' +import pattern from './pattern' +import required from './required' +import whitespace from './whitespace' + +export default { + type, + range, + pattern, + required, + whitespace, + enum: enumRule +} diff --git a/packages/utils/src/validate/rules/pattern.ts b/packages/utils/src/validate/rules/pattern.ts new file mode 100644 index 0000000000..1f8d568261 --- /dev/null +++ b/packages/utils/src/validate/rules/pattern.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import * as util from '../util' + +export default function (rule, checkValue, source, errors, options) { + if (rule.pattern) { + if (rule.pattern instanceof RegExp) { + rule.pattern.lastIndex = 0 + + if (!rule.pattern.test(checkValue)) { + errors.push(util.format(options.messages.pattern.mismatch, '', checkValue, rule.pattern)) + } + } else if (typeof rule.pattern === 'string') { + const _pattern = new RegExp(rule.pattern) + + if (!_pattern.test(checkValue)) { + errors.push(util.format(options.messages.pattern.mismatch, '', checkValue, rule.pattern)) + } + } + } +} diff --git a/packages/utils/src/validate/rules/range.ts b/packages/utils/src/validate/rules/range.ts new file mode 100644 index 0000000000..12eafae2eb --- /dev/null +++ b/packages/utils/src/validate/rules/range.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import * as util from '../util' +import { isNumber } from '../../type' +import { getLength } from '../../string' + +function getErro({ min, max, val, key, rule, errors, util, options }) { + if (min && !max && val < rule.min) { + errors.push(util.format(options.messages[key].min, '', rule.min)) + } else if (max && !min && val > rule.max) { + errors.push(util.format(options.messages[key].max, '', rule.max)) + } else if (min && max && (val < rule.min || val > rule.max)) { + errors.push(util.format(options.messages[key].range, '', rule.min, rule.max)) + } +} + +export default function (rule, checkValue, source, errors, options) { + const len = isNumber(rule.len) + const min = isNumber(rule.min) + const max = isNumber(rule.max) + let val = checkValue + let key: string | null = null + const num = isNumber(Number(checkValue)) + const str = typeof checkValue === 'string' + const arr = Array.isArray(checkValue) + + if (num) { + key = 'number' + } else if (str) { + key = 'string' + } else if (arr) { + key = 'array' + } + + if (!key) { + return false + } + + if (arr) { + val = checkValue.length + } + + if (str) { + val = getLength(checkValue, 'string') + } + + if (rule.type === 'number') { + val = checkValue + } + + if (len) { + if (val !== rule.len) { + errors.push(util.format(options.messages[key].len, '', rule.len)) + } + } else { + getErro({ min, max, val, key, rule, errors, util, options }) + } +} diff --git a/packages/utils/src/validate/rules/required.ts b/packages/utils/src/validate/rules/required.ts new file mode 100644 index 0000000000..725888563c --- /dev/null +++ b/packages/utils/src/validate/rules/required.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import * as util from '../util' +import { hasOwn } from '../../type' + +export default function ({ rule, checkValue, source, errors, options, type }) { + if (rule.required && (!hasOwn.call(source, rule.field) || util.isEmptyValue(checkValue, type || rule.type))) { + errors.push(util.format(options.messages.required, '')) + } +} diff --git a/packages/utils/src/validate/rules/type.ts b/packages/utils/src/validate/rules/type.ts new file mode 100644 index 0000000000..01fa7a61a3 --- /dev/null +++ b/packages/utils/src/validate/rules/type.ts @@ -0,0 +1,128 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import * as util from '../util' +import required from './required' +import { format } from '../../date' +import { isNullOrEmpty } from '../../string' +import { isNumber, isObject, isDate, typeOf } from '../../type' + +const emailReg1 = '^(([^<>()\\[\\]\\\\.,;:\\s@"]+(\\.[^<>()\\[\\]\\\\.,;:\\s@"]+)*)|(".+"))' +const emailReg = new RegExp( + emailReg1 + '@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,6}))$' +) + +const pattern = { + acceptImg: /\.(png|jpe?g|gif)$/, + acceptFile: /\.(doc?x|xls?x|ppt?x|txt)$/, + email: emailReg, + fileSize: /^\d+(\.\d+)?[KMGTPEZY]?B$/i, + hex: /^#?([a-f0-9]{6}|[a-f0-9]{3})$/i, + speczh: /^[0-9a-zA-Z_\u4E00-\u9FA5]+$/, + specialch: /^[0-9a-zA-Z_\-.]+$/, + specialch2: /^[0-9a-zA-Z_-]+$/, + url: /^(([a-zA-Z]{3,}):)?\/\/([\w-]+\.)+[\w]+(\/[a-zA-Z- ./?%&=]*)?/i, + version: /^\d+\.\d+(\.\d+)*$/ +} + +const types = { + integer: (value) => types.number(value) && /^[-]?[\d]+$/.test(value), + float: (value) => types.number(value) && !types.integer(value), + array: Array.isArray, + regexp(value) { + if (value instanceof RegExp) { + return true + } + try { + return !!new RegExp(value) + } catch (e) { + return false + } + }, + date: isDate, + number: (value) => isNumber(Number(value)), + object: (value) => isObject(value) && !types.array(value), + method: (value) => typeOf(value) === 'function', + + email: (value) => isNullOrEmpty(value) || (!!value.match(pattern.email) && value.length < 255), + + url: (value) => isNullOrEmpty(value) || !!value.match(pattern.url), + hex: (value) => isNullOrEmpty(value) || !!value.match(pattern.hex), + digits: (value) => isNullOrEmpty(value) || /^\d+$/.test(value), + + time: (value) => isNullOrEmpty(value) || /^((0)[0-9]|1[0-9]|20|21|22|23):([0-5][0-9])$/.test(value), + + dateYM: (value) => isNullOrEmpty(value) || format(value, 'yyyy-MM') === value, + + dateYMD: (value) => isNullOrEmpty(value) || format(value, 'yyyy-MM-dd') === value, + + dateTime: (value) => isNullOrEmpty(value) || format(value, 'yyyy-MM-dd hh:mm') === value, + + longDateTime: (value) => isNullOrEmpty(value) || format(value, 'yyyy-MM-dd hh:mm:ss') === value, + + version: (value) => isNullOrEmpty(value) || !!value.match(pattern.version), + speczh: (value) => isNullOrEmpty(value) || !!value.match(pattern.speczh), + + specialch: (value) => isNullOrEmpty(value) || !!value.match(pattern.specialch), + + specialch2: (value) => isNullOrEmpty(value) || !!value.match(pattern.specialch2), + + acceptImg: (value) => isNullOrEmpty(value) || !!value.match(pattern.acceptImg), + + acceptFile: (value) => isNullOrEmpty(value) || !!value.match(pattern.acceptFile), + + fileSize: (value) => isNullOrEmpty(value) || !!value.match(pattern.fileSize) +} + +export default function (rule, value, source, errors, options) { + if (rule.required && value === undefined) { + required(rule, value, source, errors, options) + return + } + + const custom = [ + 'array', + 'acceptImg', + 'acceptFile', + 'date', + 'digits', + 'dateTime', + 'dateYM', + 'dateYMD', + 'email', + 'float', + 'fileSize', + 'hex', + 'integer', + 'longDateTime', + 'method', + 'number', + 'object', + 'regexp', + 'speczh', + 'specialch', + 'specialch2', + 'time', + 'version', + 'url' + ] + + const ruleType = rule.type + + if (custom.includes(ruleType)) { + if (!types[ruleType](value)) { + errors.push(util.format(options.messages.types[ruleType], '', rule.type)) + } + } else if (ruleType && typeof value !== rule.type) { + errors.push(util.format(options.messages.types[ruleType], '', rule.type)) + } +} diff --git a/packages/utils/src/validate/rules/whitespace.ts b/packages/utils/src/validate/rules/whitespace.ts new file mode 100644 index 0000000000..fe4f7789ba --- /dev/null +++ b/packages/utils/src/validate/rules/whitespace.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import * as util from '../util' + +export default function (rule, checkValue, source, errors, options) { + if (/^\s+$/.test(checkValue) || checkValue === '') { + errors.push(util.format(options.messages.whitespace, '')) + } +} diff --git a/packages/utils/src/validate/schema.ts b/packages/utils/src/validate/schema.ts new file mode 100644 index 0000000000..cc4fd371a7 --- /dev/null +++ b/packages/utils/src/validate/schema.ts @@ -0,0 +1,398 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { format, complementError, asyncMap, warning, deepMerge, convertFieldsError } from './util' +import { hasOwn, isFunction } from '../type' + +function Schema(descriptor, translate) { + Schema.getSystemMessage = () => Schema.getDefaultMessage(translate) + Schema.messages = Schema.getSystemMessage(translate) + Schema.systemMessages = Schema.messages + + this.rules = null + this._messages = Schema.systemMessages + this.define(descriptor) +} + +/** + * @description 封装一下调用方传入的回调 + * @param validCallback + * @returns 新的回调 + */ +const getCompleteFn = (validCallback) => (results) => { + let idx + let errors = [] + let fields = {} + + function addValid(eror) { + if (Array.isArray(eror)) { + errors = errors.concat(...eror) + } else { + errors.push(eror) + } + } + + for (idx = 0; idx < results.length; idx++) { + addValid(results[idx]) + } + + if (errors.length) { + fields = convertFieldsError(errors) + } else { + errors = null + fields = null + } + + validCallback(errors, fields) +} + +/** + * @description 判断是否有嵌套的检验规则 + */ +const isDeep = (rule, data) => { + let deep = + (rule.type === 'object' || rule.type === 'array') && + (typeof rule.fields === 'object' || typeof rule.defaultField === 'object') + + deep = deep && (rule.required || (!rule.required && data.value)) + + return deep +} + +/** + * @description 为嵌套规则新构造一个校验实例 + */ +const getFieldsSchema = (rule, data) => { + let schema = {} + function addFullfield(key, item) { + return { + ...item, + fullField: `${rule.fullField}.${key}` + } + } + + if (rule.defaultField) { + for (const k in data.value) { + if (hasOwn.call(data.value, k)) { + schema[k] = rule.defaultField + } + } + } + + schema = { + ...schema, + ...data.rule.fields + } + + for (const f in schema) { + if (hasOwn.call(schema, f)) { + const fieldSchema = Array.isArray(schema[f]) ? schema[f] : [schema[f]] + + schema[f] = fieldSchema.map(addFullfield.bind(null, f)) + } + } + + return schema +} + +/** + * @description 工具方法,将入参变成数组 + */ +const arrayed = (failds) => { + if (!Array.isArray(failds)) { + failds = [failds] + } + + return failds +} + +/** + * @description 处理嵌套规则的错误message + */ +const getRequiredErrorFeilds = ({ rule, failds, options }) => { + if (rule.message) { + failds = [].concat(rule.message).map(complementError(rule)) + } else if (options.error) { + failds = [options.error(rule, format(options.messages.required, rule.field))] + } else { + failds = [] + } + + return failds +} + +/** + * @description 处理嵌套规则的option + */ +const setDataRuleOptions = ({ data, options }) => { + if (data.rule.options) { + let { messages, error } = options + + Object.assign(data.rule.options, { messages, error }) + } +} + +/** + * @description 处理嵌套规则的回调函数 + */ +const getValidateCallback = + ({ failds, doIt }) => + (errs) => { + const finalErrors = [] + + if (failds && failds.length) { + finalErrors.push(...failds) + } + + if (errs && errs.length) { + finalErrors.push(...errs) + } + + doIt(finalErrors.length ? finalErrors : null) + } + +/** + * @description 单条检验规则的回调函数 + */ +const asyncCallback = + (options, rule, errorFields, doIt, data) => + (e = []) => { + let failds = e + const deep = isDeep(rule, data) + + failds = arrayed(failds) + + if (!options.suppressWarning && failds.length) { + Schema.warning('async-validator:', failds) + } + + if (failds.length && rule.message) { + failds = [].concat(rule.message) + } + + failds = failds.map(complementError(rule)) + + if (options.first && failds.length) { + errorFields[rule.field] = 1 + return doIt(failds) + } + + if (deep) { + if (rule.required && !data.value) { + failds = getRequiredErrorFeilds({ rule, failds, options }) + + return doIt(failds) + } + const schema = new Schema(getFieldsSchema(rule, data)) + schema.messages(options.messages) + setDataRuleOptions({ data, options }) + schema.validate(data.value, data.rule.options || options, getValidateCallback({ failds, doIt })) + } else { + doIt(failds) + } + } + +Schema.prototype = { + messages(messages) { + if (messages) { + this._messages = deepMerge(Schema.getSystemMessage(), messages) + } + + return this._messages + }, + /** 格式化检验规则并添加到实例上。 + * rules格式化后的数据结构: { key: [ { type: 'xx', ...others } ] } + */ + define(rules) { + if (!rules) { + throw new Error('Cannot configure a schema with no rules') + } + + if (Array.isArray(rules) || typeof rules !== 'object') { + throw new TypeError('Rules must be an object') + } + + this.rules = {} + let rule + + Object.keys(rules).forEach((key) => { + if (hasOwn.call(rules, key)) { + rule = rules[key] + this.rules[key] = Array.isArray(rule) ? rule : [rule] + } + }) + }, + /** 将检验规则和源数据合并转化成新数据 + * rules: { key: [rule1, rule2] }, source: { key: sourceData, key2: sourceData2 } + * series { key: [{ rule: rule1, value: sourceData, source, field: key }, ...] } + */ + getSeries(options, source, source_) { + let arr + let value + const series = {} + const keys = options.keys || Object.keys(this.rules) + + keys.forEach((key) => { + arr = this.rules[key] + value = source[key] + + arr.forEach((r) => { + let rule = r + + if (typeof rule.transform === 'function') { + if (source === source_) { + source = { ...source } + } + + source[key] = rule.transform(value) + value = source[key] + } + + if (typeof rule === 'function') { + rule = { validator: rule } + } else { + rule = { ...rule } + } + + rule.validator = this.getValidationMethod(rule) + rule.field = key + rule.fullField = rule.fullField || key + rule.type = this.getType(rule) + + options.custom && Object.assign(rule, options.custom) + + if (!rule.validator) { + return + } + + series[key] = series[key] || [] + series[key].push({ rule, value, source, field: key }) + }) + }) + + return series + }, + mergeMessage(options) { + if (options.messages) { + let messages = this.messages() + + if (messages === Schema.systemMessages) { + messages = Schema.getSystemMessage() + } + + deepMerge(messages, options.messages) + + options.messages = messages + } else { + options.messages = this.messages() + } + }, + validate(source_, o = {}, oc = () => undefined) { + let source = source_ + let options = o + let validCallback = oc + if (typeof options === 'function') { + validCallback = options + options = {} + } + if (!this.rules || Object.keys(this.rules).length === 0) { + validCallback && validCallback() + return Promise.resolve() + } + const complete = getCompleteFn(validCallback) + this.mergeMessage(options) + const seriesData = this.getSeries(options, source, source_) + const errorFields = {} + return asyncMap( + seriesData, + options, + (data, doIt) => { + const rule = data.rule + const validHandler = asyncCallback(options, rule, errorFields, doIt, data) + let validResult + if (rule.asyncValidator) { + validResult = rule.asyncValidator(rule, data.value, validHandler, data.source, options) + } else if (rule.validator) { + validResult = rule.validator(rule, data.value, validHandler, data.source, options) + if (validResult === true) { + validHandler() + } else if (validResult === false) { + validHandler(rule.message || `${rule.field} fails`) + } else if (Array.isArray(validResult)) { + validHandler(validResult) + } else if (validResult instanceof Error) { + validHandler(validResult.message) + } + } + if (validResult && validResult.then) { + validResult.then( + () => validHandler(), + (e) => validHandler(e) + ) + } + }, + (results) => { + complete(results) + } + ) + }, + getValidationMethod(rule) { + if (isFunction(rule.validator)) { + return rule.validator + } + + const ruleKeys = Object.keys(rule) + const messageIndex = ruleKeys.indexOf('message') + + if (messageIndex > -1) { + ruleKeys.splice(messageIndex, 1) + } + + if (ruleKeys.length === 1 && ruleKeys[0] === 'required') { + return Schema.validators.required + } + + return Schema.validators[this.getType(rule)] || false + }, + getType(rule) { + if (rule.type === undefined && rule.pattern instanceof RegExp) { + rule.type = 'pattern' + } + + if (typeof rule.validator !== 'function' && rule.type && !hasOwn.call(Schema.validators, rule.type)) { + throw new Error(format('Unknown rule type %s', rule.type)) + } + + return rule.type || 'string' + } +} + +/** 注册的新类型的检验 */ +Schema.register = (type, validator) => { + if (typeof validator !== 'function') { + throw new TypeError('Cannot register a validator by type, validator is not a function') + } + + Schema.validators[type] = validator +} + +Schema.validators = {} + +Schema.warning = warning + +Schema.messages = {} + +Schema.systemMessages = {} + +Schema.getDefaultMessage = () => undefined + +export default Schema diff --git a/packages/utils/src/validate/util.ts b/packages/utils/src/validate/util.ts new file mode 100644 index 0000000000..6217f98f40 --- /dev/null +++ b/packages/utils/src/validate/util.ts @@ -0,0 +1,291 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { hasOwn, isNull } from '../type' +import logger from '../logger' + +const formatRegExp = /%[sdj%]/g + +export const warning = () => undefined + +/** + * @description 转换返回错误的数据结构 + */ +export function convertFieldsError(errors) { + if (!errors || !errors.length) { + return null + } + + const fields = {} + + errors.forEach((error) => { + const field = error.field + fields[field] = fields[field] || [] + fields[field].push(error) + }) + + return fields +} + +/** + * @description 生成校验错误的提示信息 + * @param i18nTemplate 带占位符的字符串 + * @param rest 替换占位符的字符串 + * 例:format('%s 必须等于 %s', 'A', 'B') 返回 A 必须等于 B + */ +export function format(i18nTemplate: Function | string, ...rest: string[]) { + if (typeof i18nTemplate === 'function') { + return i18nTemplate(...rest) + } + + if (typeof i18nTemplate === 'string') { + let i = 0 + const len = rest.length + let str = String(i18nTemplate).replace(formatRegExp, (matchChar) => { + if (matchChar === '%%') { + return '%' + } + + if (i >= len) { + return matchChar + } + + switch (matchChar) { + case '%j': + try { + return JSON.stringify(rest[i++]) + } catch (e) { + return '[Circular]' + } + case '%d': + return Number(rest[i++]) + case '%s': + return String(rest[i++]) + default: + return matchChar + } + }) + + return str + } + + return i18nTemplate +} + +/** + * @description 判断是否string类型 + */ +function isNativeStringType(type) { + return [ + 'string', + 'url', + 'hex', + 'email', + 'pattern', + 'digits', + 'time', + 'dateYMD', + 'longDateTime', + 'dateTime', + 'dateYM', + 'version', + 'speczh', + 'specialch', + 'specialch2', + 'acceptImg', + 'acceptFile', + 'fileSize' + ].includes(type) +} +/** + * @description 判断对应的类型是否是空值 + */ +export function isEmptyValue(data, dataType) { + if (isNull(data)) { + return true + } + + if (dataType === 'array' && Array.isArray(data) && !data.length) { + return true + } + + if (isNativeStringType(dataType) && typeof data === 'string' && !data) { + return true + } + + return false +} + +/** TINY_DUP type.ts TINY_NO_USED */ +export function isEmptyObject(data) { + return Object.keys(data).length === 0 +} + +/** + * @description 并行处理校验规则 + */ +function asyncParallelArray(arrData, func, callback) { + let count = 0 + const results = [] + const arrLength = arrData.length + + function checkCount(errors) { + results.push(...errors) + + count++ + + if (count === arrLength) { + callback(results) + } + } + + arrData.forEach((rule) => { + func(rule, checkCount) + }) +} + +/** + * @description 串行处理校验规则 + */ +function asyncSerialArray(arr, fn, cb) { + let idx = 0 + const arrLength = arr.length + + function checkNext(errorList) { + if (errorList && errorList.length) { + cb(errorList) + return + } + + const original = idx + idx = idx + 1 + + if (original < arrLength) { + fn(arr[original], checkNext) + } else { + cb([]) + } + } + + checkNext([]) +} + +/** + * @description 将一层数据平铺开 + */ +function flattenObjArr(objArr) { + const result = [] + + Object.keys(objArr).forEach((item) => { + result.push(...objArr[item]) + }) + + return result +} + +/** + * @description 转换返回错误的数据结构 + */ +export function asyncMap(objArray, option, func, callback) { + if (option.first) { + const pending = new Promise((resolve, reject) => { + const errorFn = reject + const next = (errors) => { + callback(errors) + return errors.length ? errorFn({ errors, fields: convertFieldsError(errors) }) : resolve() + } + const flattenArr = flattenObjArr(objArray) + asyncSerialArray(flattenArr, func, next) + }) + + // 校验器会报告中,errors fields 同时存在,属于正常,不打印; 代码真异常才打印。 + pending.catch((error) => (error.errors && error.fields) || logger.error(error)) + return pending + } + + let firstFields = option.firstFields || [] + + if (firstFields === true) { + firstFields = Object.keys(objArray) + } + + let total = 0 + const objArrayKeys = Object.keys(objArray) + const objArrLength = objArrayKeys.length + const results = [] + const pending = new Promise((resolve, reject) => { + const errorFn = reject + const next = (errors) => { + results.push(...errors) + total++ + if (total === objArrLength) { + callback(results) + return results.length ? errorFn({ errors: results, fields: convertFieldsError(results) }) : resolve() + } + } + + objArrayKeys.forEach((key) => { + const arr = objArray[key] + if (firstFields.includes(key)) { + asyncSerialArray(arr, func, next) + } else { + asyncParallelArray(arr, func, next) + } + }) + }) + + // 校验器会报告中,errors fields 同时存在,属于正常,不打印; 代码真异常才打印。 + pending.catch((error) => (error.errors && error.fields) || logger.error(error)) + + return pending +} + +/** + * @description 处理返回的错误 + */ +export function complementError(rule) { + return (onError) => { + if (onError && onError.message) { + onError.field = onError.field || rule.fullField + return onError + } + + return { + message: typeof onError === 'function' ? onError() : onError, + field: onError.field || rule.fullField + } + } +} + +/** + * @description 深度合并 + */ +export function deepMerge(target, sources) { + if (!sources) { + return target + } + for (const source in sources) { + if (hasOwn.call(sources, source)) { + const value = sources[source] + + if (typeof value === 'object' && typeof target[source] === 'object') { + target[source] = { + ...target[source], + ...value + } + } else { + target[source] = value + } + } + } + return target +} diff --git a/packages/utils/src/validate/validations/array.ts b/packages/utils/src/validate/validations/array.ts new file mode 100644 index 0000000000..6f470b8a40 --- /dev/null +++ b/packages/utils/src/validate/validations/array.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import rules from '../rules/index' +import { isEmptyValue } from '../util' +import { hasOwn } from '../../type' + +export default function (rule, checkValue, callback, source, options) { + const errors = [] + const validate = rule.required || (!rule.required && hasOwn.call(source, rule.field)) + + if (validate) { + if (isEmptyValue(checkValue, 'array') && !rule.required) { + return callback() + } + + rules.required({ rule, checkValue, source, errors, options, type: 'array' }) + + if (!isEmptyValue(checkValue, 'array')) { + rules.type(rule, checkValue, source, errors, options) + rules.range(rule, checkValue, source, errors, options) + } + } + + callback(errors) +} diff --git a/packages/utils/src/validate/validations/date.ts b/packages/utils/src/validate/validations/date.ts new file mode 100644 index 0000000000..a73661409f --- /dev/null +++ b/packages/utils/src/validate/validations/date.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import rules from '../rules/index' +import { isEmptyValue } from '../util' +import { hasOwn } from '../../type' + +export default function (rule, checkValue, callback, source, options) { + const errors = [] + const validate = rule.required || (!rule.required && hasOwn.call(source, rule.field)) + const isValidDateStr = (value) => value && typeof value === 'string' && new Date(value).toString() !== 'Invalid Date' + + if (validate) { + if (isEmptyValue(checkValue) && !rule.required) { + return callback() + } + + rules.required({ rule, checkValue, source, errors, options }) + + if (!isEmptyValue(checkValue)) { + let dateObject + + if (typeof checkValue === 'number' || isValidDateStr(checkValue)) { + dateObject = new Date(checkValue) + } else { + dateObject = checkValue + } + + rules.type(rule, dateObject, source, errors, options) + + if (dateObject && typeof dateObject.getTime === 'function') { + rules.range(rule, dateObject.getTime(), source, errors, options) + } + } + } + + callback(errors) +} diff --git a/packages/utils/src/validate/validations/enum.ts b/packages/utils/src/validate/validations/enum.ts new file mode 100644 index 0000000000..155c023f11 --- /dev/null +++ b/packages/utils/src/validate/validations/enum.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import rules from '../rules/index' +import { isEmptyValue } from '../util' +import { hasOwn } from '../../type' + +const ENUM = 'enum' + +export default function (rule, checkValue, callback, source, options) { + const errors = [] + const validate = rule.required || (!rule.required && hasOwn.call(source, rule.field)) + + if (validate) { + if (isEmptyValue(checkValue) && !rule.required) { + return callback() + } + + rules.required({ rule, checkValue, source, errors, options }) + + if (checkValue !== undefined) { + rules[ENUM](rule, checkValue, source, errors, options) + } + } + + callback(errors) +} diff --git a/packages/utils/src/validate/validations/float.ts b/packages/utils/src/validate/validations/float.ts new file mode 100644 index 0000000000..db58179a87 --- /dev/null +++ b/packages/utils/src/validate/validations/float.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import rules from '../rules/index' +import { isEmptyValue } from '../util' +import { hasOwn } from '../../type' + +export default function (rule, checkValue, cb, source, options) { + const errors = [] + const validate = rule.required || (!rule.required && hasOwn.call(source, rule.field)) + + if (validate) { + if (isEmptyValue(checkValue) && !rule.required) { + return cb() + } + + rules.required({ rule, checkValue, source, errors, options }) + + if (checkValue !== undefined) { + rules.type(rule, checkValue, source, errors, options) + rules.range(rule, checkValue, source, errors, options) + } + } + + cb(errors) +} diff --git a/packages/utils/src/validate/validations/index.ts b/packages/utils/src/validate/validations/index.ts new file mode 100644 index 0000000000..aa7c924daa --- /dev/null +++ b/packages/utils/src/validate/validations/index.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import date from './date' +import type from './type' +import float from './float' +import array from './array' +import string from './string' +import method from './method' +import number from './number' +import integer from './integer' +import pattern from './pattern' +import required from './required' +import enumValidator from './enum' + +export default { + date, + float, + array, + string, + method, + number, + integer, + pattern, + required, + hex: type, + url: type, + time: type, + email: type, + digits: type, + dateYM: type, + speczh: type, + dateYMD: type, + version: type, + fileSize: type, + regexp: method, + object: method, + dateTime: type, + specialch: type, + boolean: method, + acceptImg: type, + specialch2: type, + acceptFile: type, + longDateTime: type, + enum: enumValidator +} diff --git a/packages/utils/src/validate/validations/integer.ts b/packages/utils/src/validate/validations/integer.ts new file mode 100644 index 0000000000..d890b4a7bc --- /dev/null +++ b/packages/utils/src/validate/validations/integer.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import rules from '../rules/index' +import { isEmptyValue } from '../util' +import { hasOwn } from '../../type' + +export default function (rule, checkValue, callback, source, options) { + const errors = [] + const validate = rule.required || (!rule.required && hasOwn.call(source, rule.field)) + + if (validate) { + if (isEmptyValue(checkValue) && !rule.required) { + return callback() + } + + rules.required({ rule, checkValue, source, errors, options }) + + if (checkValue !== undefined && checkValue !== '') { + rules.type(rule, checkValue, source, errors, options) + rules.range(rule, checkValue, source, errors, options) + } + } + + callback(errors) +} diff --git a/packages/utils/src/validate/validations/method.ts b/packages/utils/src/validate/validations/method.ts new file mode 100644 index 0000000000..6ccfbc0c78 --- /dev/null +++ b/packages/utils/src/validate/validations/method.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import rules from '../rules/index' +import { isEmptyValue } from '../util' +import { hasOwn } from '../../type' + +export default function (rule, checkValue, callback, source, options) { + const validate = rule.required || (!rule.required && hasOwn.call(source, rule.field)) + const errors = [] + + if (validate) { + if (!rule.required && isEmptyValue(checkValue)) { + return callback() + } + + rules.required({ rule, checkValue, source, errors, options }) + + if (checkValue !== undefined) { + rules.type(rule, checkValue, source, errors, options) + } + } + + callback(errors) +} diff --git a/packages/utils/src/validate/validations/number.ts b/packages/utils/src/validate/validations/number.ts new file mode 100644 index 0000000000..8e457cfced --- /dev/null +++ b/packages/utils/src/validate/validations/number.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import rules from '../rules/index' +import { isEmptyValue } from '../util' +import { hasOwn } from '../../type' + +export default function (rule, checkValue, callback, source, options) { + const errors = [] + const validate = rule.required || (!rule.required && hasOwn.call(source, rule.field)) + + if (validate) { + if (checkValue === '') { + checkValue = undefined + } + + if (!rule.required && isEmptyValue(checkValue)) { + return callback() + } + + rules.required({ rule, checkValue, source, errors, options }) + + if (checkValue !== undefined) { + rules.type(rule, checkValue, source, errors, options) + rules.range(rule, checkValue, source, errors, options) + } + } + + callback(errors) +} diff --git a/packages/utils/src/validate/validations/pattern.ts b/packages/utils/src/validate/validations/pattern.ts new file mode 100644 index 0000000000..1f914f458c --- /dev/null +++ b/packages/utils/src/validate/validations/pattern.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import rules from '../rules/index' +import { isEmptyValue } from '../util' +import { hasOwn } from '../../type' + +export default function (rule, checkValue, callback, source, options) { + const errors = [] + const validate = rule.required || (!rule.required && hasOwn.call(source, rule.field)) + + if (validate) { + if (isEmptyValue(checkValue, 'string') && !rule.required) { + return callback() + } + + rules.required({ rule, checkValue, source, errors, options }) + + if (!isEmptyValue(checkValue, 'string')) { + rules.pattern(rule, checkValue, source, errors, options) + } + } + + callback(errors) +} diff --git a/packages/utils/src/validate/validations/required.ts b/packages/utils/src/validate/validations/required.ts new file mode 100644 index 0000000000..11fae17f3b --- /dev/null +++ b/packages/utils/src/validate/validations/required.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import rules from '../rules/index' + +export default function (rule, checkValue, callback, source, options) { + const errors = [] + const type = Array.isArray(checkValue) ? 'array' : typeof checkValue + + rules.required({ rule, checkValue, source, errors, options, type }) + callback(errors) +} diff --git a/packages/utils/src/validate/validations/string.ts b/packages/utils/src/validate/validations/string.ts new file mode 100644 index 0000000000..23b73f39a1 --- /dev/null +++ b/packages/utils/src/validate/validations/string.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import rules from '../rules/index' +import { isEmptyValue } from '../util' +import { hasOwn } from '../../type' + +export default function (rule, checkValue, callback, source, options) { + const errors = [] + const validate = rule.required || (!rule.required && hasOwn.call(source, rule.field)) + + if (validate) { + if (isEmptyValue(checkValue, 'string') && !rule.required) { + return callback() + } + + rules.required({ + rule, + checkValue, + source, + errors, + options, + type: 'string' + }) + + if (!isEmptyValue(checkValue, 'string')) { + rules.type(rule, checkValue, source, errors, options) + rules.range(rule, checkValue, source, errors, options) + rules.pattern(rule, checkValue, source, errors, options) + + if (rule.whitespace === true) { + rules.whitespace(rule, checkValue, source, errors, options) + } + } + } + + callback(errors) +} diff --git a/packages/utils/src/validate/validations/type.ts b/packages/utils/src/validate/validations/type.ts new file mode 100644 index 0000000000..ef5d3111de --- /dev/null +++ b/packages/utils/src/validate/validations/type.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import rules from '../rules/index' +import { isEmptyValue } from '../util' +import { hasOwn } from '../../type' + +export default function (rule, checkValue, callback, source, options) { + const ruleType = rule.type + const errors = [] + const validate = rule.required || (!rule.required && hasOwn.call(source, rule.field)) + + if (validate) { + if (isEmptyValue(checkValue, ruleType) && !rule.required) { + return callback() + } + + rules.required({ + rule, + checkValue, + source, + errors, + options, + type: ruleType + }) + + if (!isEmptyValue(checkValue, ruleType)) { + rules.type(rule, checkValue, source, errors, options) + } + } + + callback(errors) +} diff --git a/packages/utils/src/window.ts b/packages/utils/src/window.ts deleted file mode 100644 index 3b18aacad2..0000000000 --- a/packages/utils/src/window.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const isWeb = () => - typeof window !== 'undefined' && typeof document !== 'undefined' && window.document === document - -export const getWindow = () => (isWeb() ? window : global) - -export default { - getWindow, - isWeb -} diff --git a/packages/vue-directive/package.json b/packages/vue-directive/package.json index d8641da229..e501a2fd9e 100644 --- a/packages/vue-directive/package.json +++ b/packages/vue-directive/package.json @@ -2,8 +2,11 @@ "name": "@opentiny/vue-directive", "version": "3.21.0", "description": "", - "module": "index.ts", + "author": "", + "license": "ISC", + "keywords": [], "main": "index.ts", + "module": "index.ts", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, @@ -11,10 +14,8 @@ "access": "public" }, "dependencies": { - "@opentiny/vue-tooltip": "workspace:~", - "@opentiny/vue-common": "workspace:~" - }, - "keywords": [], - "author": "", - "license": "ISC" -} + "@opentiny/utils": "workspace:~", + "@opentiny/vue-common": "workspace:~", + "@opentiny/vue-tooltip": "workspace:~" + } +} \ No newline at end of file diff --git a/packages/vue-directive/src/clickoutside.ts b/packages/vue-directive/src/clickoutside.ts new file mode 100644 index 0000000000..129ee8b451 --- /dev/null +++ b/packages/vue-directive/src/clickoutside.ts @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { on } from '@opentiny/utils' + +const isServer = typeof window === 'undefined' +const nodeList = [] +const nameSpace = '@@clickoutsideContext' +let startClick +let seed = 0 + +if (!isServer) { + on(document, 'mousedown', (event) => { + startClick = event + nodeList + .filter((node) => node[nameSpace].mousedownTrigger) + .forEach((node) => node[nameSpace].documentHandler(event, startClick)) + }) + + on(document, 'mouseup', (event) => { + nodeList + .filter((node) => !node[nameSpace].mousedownTrigger) + .forEach((node) => node[nameSpace].documentHandler(event, node[nameSpace]?.mouseupTrigger ? event : startClick)) + startClick = null + }) +} + +const createDocumentHandler = (el, binding, vnode) => + function (mouseup = {}, mousedown = {}) { + let popperElm = vnode.context.popperElm || (vnode.context.state && vnode.context.state.popperElm) + + if ( + !mouseup?.target || + !mousedown?.target || + el.contains(mouseup.target) || + el.contains(mousedown.target) || + el === mouseup.target || + (popperElm && (popperElm.contains(mouseup.target) || popperElm.contains(mousedown.target))) + ) { + return + } + + if (binding.expression && el[nameSpace].methodName && vnode.context[el[nameSpace].methodName]) { + vnode.context[el[nameSpace].methodName]() + } else { + el[nameSpace].bindingFn && el[nameSpace].bindingFn() + } + } + +/** + * v-clickoutside + * @desc 点击元素外面才会触发的事件 + * @example + * 两个修饰符,mousedown、mouseup + * 当没有修饰符时,需要同时满足在目标元素外同步按下和释放鼠标才会触发回调。 + * ```html + *
// 在元素外部点击时触发 + *
// 在元素外部按下鼠标时触发 + *
// 在元素外部松开鼠标时触发 + * ``` + */ +export default { + bind: (el, binding, vnode) => { + nodeList.push(el) + const id = seed++ + const { modifiers, expression, value } = binding + const { mousedown = false, mouseup = false } = modifiers || {} + el[nameSpace] = { + id, + documentHandler: createDocumentHandler(el, binding, vnode), + methodName: expression, + bindingFn: value, + mousedownTrigger: mousedown, + mouseupTrigger: mouseup + } + }, + + update: (el, binding, vnode) => { + const { modifiers, expression, value } = binding + const { mousedown = false, mouseup = false } = modifiers || {} + el[nameSpace].documentHandler = createDocumentHandler(el, binding, vnode) + el[nameSpace].methodName = expression + el[nameSpace].bindingFn = value + el[nameSpace].mousedownTrigger = mousedown + el[nameSpace].mouseupTrigger = mouseup + }, + + unbind: (el) => { + if (el.nodeType !== Node.ELEMENT_NODE) { + return + } + + let len = nodeList.length + + for (let i = 0; i < len; i++) { + if (nodeList[i][nameSpace].id === el[nameSpace].id) { + nodeList.splice(i, 1) + break + } + } + + if (nodeList.length === 0 && startClick) { + startClick = null + } + + delete el[nameSpace] + } +} diff --git a/packages/vue-directive/src/infinite-scroll.ts b/packages/vue-directive/src/infinite-scroll.ts new file mode 100644 index 0000000000..adf1a1e599 --- /dev/null +++ b/packages/vue-directive/src/infinite-scroll.ts @@ -0,0 +1,223 @@ +import { throttle } from '@opentiny/utils' + +const CONTEXT_KEY = '@@infinitescrollContext' +const OBSERVER_CHECK_INTERVAL = 50 +const ATTR_DEFAULT_DELAY = 200 +const ATTR_DEFAULT_DISTANCE = 0 + +const attrs = { + delay: { type: Number, default: ATTR_DEFAULT_DELAY }, + disabled: { type: Boolean, default: false }, + distance: { type: Number, default: ATTR_DEFAULT_DISTANCE }, + immediate: { type: Boolean, default: true } +} + +const isNull = (val) => val === null + +const parseAttrValue = (attrVal, type, defaultVal) => { + if (isNull(attrVal)) return defaultVal + + if (type === Boolean) { + return attrVal !== 'false' + } else if (type === Number) { + return Number(attrVal) + } +} + +const computeScrollOptions = (el, instance) => + Object.entries(attrs).reduce((accumulator, [name, option]) => { + const { type, default: defaultValue } = option + const attrKey = `infinite-scroll-${name}` + const $attrValue = instance.$el.getAttribute(attrKey) + const attrValue = el.getAttribute(attrKey) + let value + + if ((isNull(attrValue) && isNull($attrValue)) || !isNull(attrValue)) { + value = parseAttrValue(attrValue, type, defaultValue) + } + + if (isNull(attrValue) && !isNull($attrValue)) { + value = parseAttrValue($attrValue, type, defaultValue) + } + + accumulator[name] = Number.isNaN(value) ? defaultValue : value + + return accumulator + }, {}) + +const stopObserver = (el) => { + const { observer } = el[CONTEXT_KEY] + + if (observer) { + observer.disconnect() + delete el[CONTEXT_KEY].observer + } +} + +const accumOffsetTop = (el) => { + let totalOffset = 0 + let parent = el + + while (parent) { + totalOffset += parent.offsetTop + parent = parent.offsetParent + } + + return totalOffset +} + +const distanceOffsetTop = (el, containerEl) => Math.abs(accumOffsetTop(el) - accumOffsetTop(containerEl)) + +const scroller = (el, cb) => { + const { container, containerEl, instance, observer, lastScrollTop } = el[CONTEXT_KEY] + const { disabled, distance } = computeScrollOptions(el, instance) + const { clientHeight, scrollHeight, scrollTop } = containerEl + const deltaTop = scrollTop - lastScrollTop + + el[CONTEXT_KEY].lastScrollTop = scrollTop + + if (observer || disabled || deltaTop < 0) return + + let isTrigger = false + + if (container === el) { + isTrigger = scrollHeight - (clientHeight + scrollTop) <= distance + } else { + const { clientTop, scrollHeight: height } = el + const offsetTop = distanceOffsetTop(el, containerEl) + + isTrigger = scrollTop + clientHeight >= offsetTop + clientTop + height - distance + } + + if (isTrigger) { + cb.call(instance) + } +} + +function observerChecker(el, cb) { + const { containerEl, instance } = el[CONTEXT_KEY] + const { disabled } = computeScrollOptions(el, instance) + + if (disabled || containerEl.clientHeight === 0) return + + if (containerEl.scrollHeight <= containerEl.clientHeight) { + cb.call(instance) + } else { + stopObserver(el) + } +} + +const cached = (fn) => { + const cache = Object.create(null) + return (str) => cache[str] || (cache[str] = fn(str)) +} + +const camelizeRE = /-(\w)/g + +const camelize = cached((str) => str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''))) + +/** TINY_DUP dom.ts */ +const getElementStyle = (elem, styleKey) => { + if (!elem || !styleKey) return '' + + let key = camelize(styleKey) + + if (key === 'float') key = 'cssFloat' + + try { + const styleValue = elem.style[key] + + if (styleValue) return styleValue + + const computedStyle = document.defaultView ? document.defaultView.getComputedStyle(elem, '') : null + + return computedStyle ? computedStyle[key] : '' + } catch (e) { + return elem.style[key] + } +} +/** TINY_DUP dom.ts */ +const canScroll = (el, isVertical) => { + const overflowKey = { undefined: 'overflow', true: 'overflow-y', false: 'overflow-x' }[String(isVertical)] + const overflowVal = getElementStyle(el, overflowKey) + return ['scroll', 'auto', 'overlay'].some((s) => overflowVal.includes(s)) +} +/** TINY_DUP dom.ts */ +export const getScrollContainer = (el, isVertical) => { + let parentEl = el + + while (parentEl) { + if ([window, document, document.documentElement].includes(parentEl)) return window + + if (canScroll(parentEl, isVertical)) return parentEl + + parentEl = parentEl.parentNode + } + + return parentEl +} + +const bind = (el, binding, vnode) => { + const instance = binding.instance || vnode.context + const { value: cb } = binding + + if (typeof cb !== 'function') { + throw new TypeError('[TINY Error][InfiniteScroll] "v-infinite-scroll" binding value must be a function') + } + + instance.$nextTick(() => { + const { delay, immediate } = computeScrollOptions(el, instance) + const container = getScrollContainer(el, true) + const containerEl = container === window ? document.documentElement : container + const onScroll = throttle(delay, scroller.bind(null, el, cb)) + + if (!container) return + + el[CONTEXT_KEY] = { instance, container, containerEl, delay, cb, onScroll, lastScrollTop: containerEl.scrollTop } + + if (immediate) { + const observer = new MutationObserver(throttle(OBSERVER_CHECK_INTERVAL, observerChecker.bind(null, el, cb))) + + el[CONTEXT_KEY].observer = observer + + observer.observe(el, { childList: true, subtree: true }) + + observerChecker(el, cb) + } + + container.addEventListener('scroll', onScroll) + }) +} + +const update = (el, binding, vnode) => { + if (!el[CONTEXT_KEY]) { + const instance = binding.instance || vnode.context + + return instance.$nextTick() + } else { + const { containerEl, cb, observer } = el[CONTEXT_KEY] + + if (containerEl.clientHeight && observer) { + observerChecker(el, cb) + } + } +} + +const unbind = (el) => { + const { container, onScroll } = el[CONTEXT_KEY] + + if (container) container.removeEventListener('scroll', onScroll) + + stopObserver(el) +} + +const InfiniteScroll = { + bind, + update, + unbind, + beforeMount: bind, + updated: update, + unmounted: unbind +} + +export default InfiniteScroll diff --git a/packages/vue-directive/src/observe-visibility.ts b/packages/vue-directive/src/observe-visibility.ts new file mode 100644 index 0000000000..92dee00a4c --- /dev/null +++ b/packages/vue-directive/src/observe-visibility.ts @@ -0,0 +1,129 @@ +import { throttle, isEqual } from '@opentiny/utils' + +const CONTEXT_KEY = '@@observevisibilityContext' + +const processOptions = (value) => { + let options + + if (typeof value === 'function') { + options = { callback: value } + } else { + options = value + } + + return options +} + +const createObserver = ({ options, instance, state }) => { + if (state.observer) { + destroyObserver(state) + } + + if (state.frozen) return + + state.options = processOptions(options) + state.callback = (result, entry) => { + state.options.callback(result, entry) + + if (result && state.options.once) { + state.frozen = true + destroyObserver(state) + } + } + + if (state.callback && state.options.throttle) { + state.callback = throttle(state.options.throttleDelay || 20, state.callback) + } + + state.observer = new IntersectionObserver((entries) => { + let entry = entries[0] + + if (entries.length > 1) { + const intersectingEntry = entries.find((e) => e.isIntersecting) + + if (intersectingEntry) { + entry = intersectingEntry + } + } + + if (state.callback) { + state.callback(entry.isIntersecting, entry) + } + }, state.options.intersection) + + instance.$nextTick(() => { + if (state.observer) { + state.observer.observe(state.el) + } + }) +} + +const destroyObserver = (state) => { + if (state.observer) { + state.observer.disconnect() + state.observer = null + } + + if (state.callback) { + state.callback = null + } +} + +const createVisibilityState = ({ el, options, instance }) => { + const state = { el, observer: null, frozen: false } + + createObserver({ options, instance, state }) + + return state +} + +const bind = (el, { value, instance }, { context }) => { + if (!value) return + + if (typeof IntersectionObserver === 'undefined') { + throw new TypeError('[TINY Error][ObserveVisibility] IntersectionObserver API is not available in your browser') + } else { + instance = instance || context + + el[CONTEXT_KEY] = createVisibilityState({ el, options: value, instance }) + } +} + +const update = (el, { value, oldValue, instance }, { context }) => { + if (isEqual(value, oldValue)) return + + const state = el[CONTEXT_KEY] + + if (!value) { + unbind(el) + return + } + + instance = instance || context + + if (state) { + createObserver({ options: value, instance, state }) + } else { + bind(el, { value, instance }, { context }) + } +} + +const unbind = (el) => { + const state = el[CONTEXT_KEY] + + if (state) { + destroyObserver(state) + delete el[CONTEXT_KEY] + } +} + +const ObserveVisibility = { + bind, + update, + unbind, + beforeMount: bind, + updated: update, + unmounted: unbind +} + +export default ObserveVisibility diff --git a/packages/vue-directive/src/repeat-click.ts b/packages/vue-directive/src/repeat-click.ts new file mode 100644 index 0000000000..493c9af849 --- /dev/null +++ b/packages/vue-directive/src/repeat-click.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { on, once } from '@opentiny/utils' + +export default (el, binding) => { + // fix issue#919 + const LONG_PRESS_INTERVAL = 200 + + let interval = null + let startTime + + const handler = () => { + typeof binding.value === 'function' && binding.value.apply() + } + + const clear = () => { + if (Date.now() - startTime < LONG_PRESS_INTERVAL) { + handler() + } + + clearInterval(interval) + interval = null + } + + on(el, 'mousedown', (e) => { + if (e.button !== 0) { + return + } + + startTime = Date.now() + once(document, 'mouseup', clear) + clearInterval(interval) + interval = setInterval(handler, LONG_PRESS_INTERVAL) + }) +} diff --git a/packages/vue-hooks/package.json b/packages/vue-hooks/package.json index d096f7bbc9..3abffd7377 100644 --- a/packages/vue-hooks/package.json +++ b/packages/vue-hooks/package.json @@ -2,8 +2,11 @@ "name": "@opentiny/vue-hooks", "version": "3.21.0", "description": "", - "module": "index.ts", + "author": "", + "license": "ISC", + "keywords": [], "main": "index.ts", + "module": "index.ts", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, @@ -12,9 +15,7 @@ }, "dependencies": { "@floating-ui/dom": "^1.6.9", + "@opentiny/utils": "workspace:~", "@opentiny/vue-common": "workspace:~" - }, - "keywords": [], - "author": "", - "license": "ISC" -} + } +} \ No newline at end of file diff --git a/packages/vue-hooks/src/useEventListener.ts b/packages/vue-hooks/src/useEventListener.ts new file mode 100644 index 0000000000..b71ac6d22d --- /dev/null +++ b/packages/vue-hooks/src/useEventListener.ts @@ -0,0 +1,65 @@ +import { on, off, isServer } from '@opentiny/utils' + +export const onMountedOrActivated = + ({ onMounted, onActivated, nextTick }) => + (hook) => { + let mounted + + onMounted(() => { + hook() + nextTick(() => (mounted = true)) + }) + onActivated(() => mounted && hook()) + } + +export const useEventListener = + ({ unref, isRef, watch, nextTick, onMounted, onUnmounted, onActivated, onDeactivated }) => + (type, listener, options = {}) => { + if (isServer) return + + const { target = window, passive = false, capture = false } = options + + let cleaned = false + let attached + + const add = (target) => { + if (cleaned) return + + const element = unref(target) + + if (element && !attached) { + on(element, type, listener, { capture, passive }) + attached = true + } + } + + const remove = (target) => { + if (cleaned) return + + const element = unref(target) + + if (element && attached) { + off(element, type, listener, { capture, passive }) + attached = false + } + } + + onUnmounted(() => remove(target)) + onDeactivated(() => remove(target)) + onMountedOrActivated({ onMounted, onActivated, nextTick })(() => add(target)) + + let stopWatch + + if (isRef(target)) { + stopWatch = watch(target, (val, oldVal) => { + remove(oldVal) + add(val) + }) + } + + return () => { + stopWatch && stopWatch() + remove(target) + cleaned = true + } + } diff --git a/packages/vue-hooks/src/useInstanceSlots.ts b/packages/vue-hooks/src/useInstanceSlots.ts new file mode 100644 index 0000000000..5be240e638 --- /dev/null +++ b/packages/vue-hooks/src/useInstanceSlots.ts @@ -0,0 +1,29 @@ +export const useInstanceSlots = + ({ getCurrentInstance, isVue2, nextTick, onUnmounted }) => + () => { + const publicInstance = getCurrentInstance().proxy + + /** + * 在 Vue2,$scopedSlots 内容是插槽方法,$slots 内容是执行后的虚拟节点,使用 $slots 实践中发现会导致插槽丢失响应性,应该使用 $scopedSlots + * 在 Vue3,$scopedSlots 是 undefined,$slots 内容是插槽方法,在渲染函数中使用 undefined 的 $scopedSlots 会出现警告提示 + * 为了兼容 Vue2 和 Vue3,以及消除警告提示,这里在 Vue3 实例上定义 $scopedSlots 为 null + */ + if (!isVue2) { + Object.defineProperty(publicInstance, '$scopedSlots', { configurable: true, value: null }) + } + + Object.defineProperty(publicInstance, 'instanceSlots', { + configurable: true, + get: () => publicInstance.$scopedSlots || publicInstance.$slots + }) + + onUnmounted(() => { + nextTick(() => { + if (!isVue2) { + delete publicInstance.$scopedSlots + } + + delete publicInstance.instanceSlots + }) + }) + } diff --git a/packages/vue-hooks/src/useRect.ts b/packages/vue-hooks/src/useRect.ts new file mode 100644 index 0000000000..eaec63154d --- /dev/null +++ b/packages/vue-hooks/src/useRect.ts @@ -0,0 +1,25 @@ +const isWindow = (val) => val === window +const makeDOMRect = (width, height) => ({ + top: 0, + left: 0, + width, + right: width, + height, + bottom: height +}) + +export const useRect = (unref) => (elOrRef) => { + const el = unref(elOrRef) + + if (isWindow(el)) { + const width = el.innerWidth + const height = el.innerHeight + return makeDOMRect(width, height) + } + + if (el && el.getBoundingClientRect) { + return el.getBoundingClientRect() + } + + return makeDOMRect(0, 0) +} diff --git a/packages/vue-hooks/src/useRelation.ts b/packages/vue-hooks/src/useRelation.ts new file mode 100644 index 0000000000..772718eb8e --- /dev/null +++ b/packages/vue-hooks/src/useRelation.ts @@ -0,0 +1,130 @@ +import { noop } from '@opentiny/utils' +import { onMountedOrActivated as createHook } from './useEventListener' + +/** + * 处理组件嵌套的组合式 API + * relationKey 关系树上的父子组件使用同一个关系名称 + * relationContainer 子组件顺序由关系容器确定,由根组件提供,可以不使用,子组件顺序就是组件创建顺序 + * onChange 子组件顺序改变后的回调处理,由根组件提供,可以不使用 + * childrenKey 在组件关系树上的所有实例中定义的子组件引用名称,默认是 instanceChildren + * delivery 根组件向下分发的内容 + */ +export const useRelation = + ({ + computed, + getCurrentInstance, + inject, + markRaw, + nextTick, + onMounted, + onActivated, + onUnmounted, + provide, + reactive, + toRef + }) => + ({ relationKey, relationContainer, onChange, childrenKey, delivery } = {}) => { + if (!relationKey) { + throw new Error('[TINY Error] must exist.') + } + + const instance = getCurrentInstance() + const state = reactive({ children: [], indexInParent: -1 }) + const injectValue = inject(relationKey, null) + // 收集所有的子组件刷新回调 + let callbacks = [] + + if (injectValue) { + const { link, unlink, callbacks: injectCbs, childrenKey: injectKey, delivery: injectDelivery } = injectValue + + callbacks = injectCbs + childrenKey = childrenKey || injectKey || 'instanceChildren' + delivery = injectDelivery + + state.indexInParent = link(instance) + + onUnmounted(() => unlink(instance)) + } else { + childrenKey = childrenKey || 'instanceChildren' + + const onMountedOrActivated = createHook({ onMounted, onActivated, nextTick }) + const changeHandler = onChange ? () => nextTick(onChange) : noop + + let relationMO + + nextTick(() => { + // 在 mounted 之后,如果表示子组件关系的 dom 元素存在,就创建 MutationObserver 观察它的子树改变 + const targetNode = typeof relationContainer === 'function' ? relationContainer() : relationContainer + + if (targetNode) { + relationMO = new MutationObserver((mutationList, observer) => { + const flattenNodes = [] + // 对关系容器 dom 子树进行平铺处理 + flattenChildNodes(targetNode.childNodes, flattenNodes) + // 使用平铺的 dom 子树更新子组件顺序 + callbacks.forEach((callback) => callback(flattenNodes, mutationList, observer)) + // 执行后续组件 change 处理 + changeHandler() + }) + + relationMO.observe(targetNode, { attributes: true, childList: true, subtree: true }) + } + }) + + onMountedOrActivated(() => changeHandler()) + + onUnmounted(() => { + if (relationMO) { + relationMO.disconnect() + relationMO = null + } + + callbacks = null + }) + } + + const link = (child) => { + const childPublic = child.proxy + + state.children.push(markRaw(childPublic)) + + return computed(() => state.children.indexOf(childPublic)) + } + + const unlink = (child) => { + const index = state.children.indexOf(child.proxy) + + if (index > -1) { + state.children.splice(index, 1) + } + } + + // 刷新子组件顺序 + callbacks.push((flattenNodes) => sortPublicInstances(state.children, flattenNodes)) + + provide(relationKey, { link, unlink, callbacks, childrenKey, delivery }) + + // 在 Public Instance 上定义子组件数组,并且在组件卸载时移除 + Object.defineProperty(instance.proxy, childrenKey, { configurable: true, get: () => state.children }) + + onUnmounted(() => delete instance.proxy[childrenKey]) + + // 返回子组件数组 ref、在父级中的位置索引 ref 和接收到的分发内容 + return { children: toRef(state, 'children'), index: toRef(state, 'indexInParent'), delivery } + } + +const flattenChildNodes = (childNodes, result) => { + if (childNodes.length) { + childNodes.forEach((childNode) => { + result.push(childNode) + + if (childNode.childNodes) { + flattenChildNodes(childNode.childNodes, result) + } + }) + } +} + +const sortPublicInstances = (instances, flattenNodes) => { + instances.sort((a, b) => flattenNodes.indexOf(a.$el) - flattenNodes.indexOf(b.$el)) +} diff --git a/packages/vue-hooks/src/useTouch.ts b/packages/vue-hooks/src/useTouch.ts new file mode 100644 index 0000000000..a9e26f8f0b --- /dev/null +++ b/packages/vue-hooks/src/useTouch.ts @@ -0,0 +1,74 @@ +const TAP_OFFSET = 5 + +const getDirection = (x, y) => { + if (x > y) return 'horizontal' + if (y > x) return 'vertical' + return '' +} + +const touchEvent = (event) => event.touches[0] + +export const useTouch = (ref) => () => { + const startX = ref(0) + const startY = ref(0) + const deltaX = ref(0) + const deltaY = ref(0) + const offsetX = ref(0) + const offsetY = ref(0) + const direction = ref('') + const isTap = ref(true) + + const isVertical = () => direction.value === 'vertical' + const isHorizontal = () => direction.value === 'horizontal' + + const reset = () => { + deltaX.value = 0 + deltaY.value = 0 + offsetX.value = 0 + offsetY.value = 0 + direction.value = '' + isTap.value = true + } + + const start = (event) => { + reset() + const touch = touchEvent(event) + startX.value = touch.clientX + startY.value = touch.clientY + } + + const move = (event) => { + const touch = touchEvent(event) + // safari back will set clientX to negative number + deltaX.value = (touch.clientX < 0 ? 0 : touch.clientX) - startX.value + deltaY.value = touch.clientY - startY.value + offsetX.value = Math.abs(deltaX.value) + offsetY.value = Math.abs(deltaY.value) + + // lock direction when distance is greater than a certain value + const LOCK_DIRECTION_DISTANCE = 10 + if (!direction.value || (offsetX.value < LOCK_DIRECTION_DISTANCE && offsetY.value < LOCK_DIRECTION_DISTANCE)) { + direction.value = getDirection(offsetX.value, offsetY.value) + } + + if (isTap.value && (offsetX.value > TAP_OFFSET || offsetY.value > TAP_OFFSET)) { + isTap.value = false + } + } + + return { + move, + start, + reset, + isVertical, + isHorizontal, + startX, + startY, + deltaX, + deltaY, + offsetX, + offsetY, + direction, + isTap + } +} diff --git a/packages/vue-hooks/src/useUserAgent.ts b/packages/vue-hooks/src/useUserAgent.ts new file mode 100644 index 0000000000..bc1654fad5 --- /dev/null +++ b/packages/vue-hooks/src/useUserAgent.ts @@ -0,0 +1,18 @@ +import { isServer } from '@opentiny/utils' + +function getIsIOS() { + if (isServer) return false + return ( + window.navigator && + window.navigator.userAgent && + (/iP(?:ad|hone|od)/.test(window.navigator.userAgent) || + // The new iPad Pro Gen3 does not identify itself as iPad, but as Macintosh. + // https://github.com/vueuse/vueuse/issues/3577 + (window.navigator.maxTouchPoints > 2 && /iPad|Macintosh/.test(window.navigator.userAgent))) + ) +} + +export const useUserAgent = () => { + const isIOS = getIsIOS() + return { isIOS } +} diff --git a/packages/vue-hooks/src/useWindowSize.ts b/packages/vue-hooks/src/useWindowSize.ts new file mode 100644 index 0000000000..b74758fc6b --- /dev/null +++ b/packages/vue-hooks/src/useWindowSize.ts @@ -0,0 +1,25 @@ +import { on, isServer } from '@opentiny/utils' + +let width +let height + +export const useWindowSize = (ref) => () => { + if (!width) { + width = ref(0) + height = ref(0) + + if (!isServer) { + const update = () => { + width.value = window.innerWidth + height.value = window.innerHeight + } + + update() + + on(window, 'resize', update, { passive: true }) + on(window, 'orientationchange', update, { passive: true }) + } + } + + return { width, height } +} diff --git a/packages/vue-hooks/src/vue-emitter.ts b/packages/vue-hooks/src/vue-emitter.ts new file mode 100644 index 0000000000..14e47acd68 --- /dev/null +++ b/packages/vue-hooks/src/vue-emitter.ts @@ -0,0 +1,48 @@ +/* eslint-disable prefer-spread */ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +export default (vm) => { + const broadcast = (vm, componentName, eventName, params) => { + vm.$children.forEach((child) => { + const name = child.$options.componentName + + if (name === componentName) { + child.$emit(eventName, params) + } else { + broadcast(child, componentName, eventName, params) + } + }) + } + + return { + dispatch(componentName, eventName, params) { + let parent = vm.$parent || vm.$root + let name = parent.$options.componentName + + while (parent && !parent.$attrs.novalid && (!name || name !== componentName)) { + parent = parent.$parent + + if (parent) { + name = parent.$options.componentName + } + } + + if (parent) { + parent.$emit.apply(parent, [eventName].concat(params)) + } + }, + broadcast(componentName, eventName, params) { + broadcast(vm, componentName, eventName, params) + } + } +} diff --git a/packages/vue-hooks/src/vue-popper.ts b/packages/vue-hooks/src/vue-popper.ts new file mode 100644 index 0000000000..b0b213ca1f --- /dev/null +++ b/packages/vue-hooks/src/vue-popper.ts @@ -0,0 +1,256 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { PopupManager, Popper as PopperJS, on, off, isDisplayNone } from '@opentiny/utils' + +// todo +import type { ISharedRenderlessFunctionParams } from 'types/shared.type' + +export interface IPopperState { + popperJS: Popper + appended: boolean + popperElm: HTMLElement + showPopper: boolean + referenceElm: HTMLElement + currentPlacement: string +} + +type IPopperInputParams = ISharedRenderlessFunctionParams & { + api: { open: Function; close: Function } + state: IPopperState + props: any +} + +/** 给 popper 的click添加stop, 阻止冒泡 */ +const stop = (e: Event) => e.stopPropagation() + +const isServer = typeof window === 'undefined' + +// 由于多个组件传入reference元素的方式不同,所以这里从多处查找。 +const getReference = ({ state, props, vm, slots }: Pick) => { + let reference = + state.referenceElm || props.reference || (vm.$refs.reference && vm.$refs.reference.$el) || vm.$refs.reference + + if (!reference && slots.reference && slots.reference()[0]) { + state.referenceElm = slots.reference()[0].elm || slots.reference()[0].el + reference = state.referenceElm + } + + return reference +} + +const getReferMaxZIndex = (reference) => { + if (!reference || !reference.nodeType) return + + let getZIndex = (dom) => parseInt(window.getComputedStyle(dom).zIndex, 10) || 0 + let max = getZIndex(reference) + let z + + do { + reference = reference.parentNode + + if (reference) { + z = getZIndex(reference) + } else { + break + } + + max = z > max ? z : max + } while (reference !== document.body) + + return max + 1 + '' +} + +export default (options: IPopperInputParams) => { + const { + parent, + emit, + nextTick, + onBeforeUnmount, + onDeactivated, + props, + watch, + reactive, + vm, + slots, + toRefs, + popperVmRef + } = options + const state = reactive({ + popperJS: null as any, + appended: false, // arrow 是否添加 + popperElm: null as any, + showPopper: props.manual ? Boolean(props.modelValue) : false, + referenceElm: null as any, + currentPlacement: '' + }) + + /** 创建箭头函数 */ + const appendArrow = (el: HTMLElement) => { + if (state.appended) { + return + } + + state.appended = true + const div = document.createElement('div') + + div.setAttribute('x-arrow', '') + div.className = 'popper__arrow' + el.appendChild(div) + } + + // 如果触发源是隐藏的,其弹出层也设置为隐藏。组件可以通过 props.popperOptions.followReferenceHide = true/false来控制 + const followHide = (popperInstance: PopperJS) => { + const { followReferenceHide = true } = props?.popperOptions || {} + const { _popper: popper, _reference: reference } = popperInstance + + if (followReferenceHide && isDisplayNone(reference)) { + popper.style.display = 'none' + } + } + + const nextZIndex = (reference) => { + return props.zIndex === 'relative' ? getReferMaxZIndex(reference) : PopupManager.nextZIndex() + } + + const createPopper = (dom) => { + if (isServer) { + return + } + + state.currentPlacement = state.currentPlacement || props.placement + + if (!/^(top|bottom|left|right)(-start|-end)?$/g.test(state.currentPlacement)) { + return + } + + const options = props.popperOptions || { gpuAcceleration: false } + state.popperElm = state.popperElm || props.popper || vm.$refs.popper || popperVmRef.popper || dom + const popper = state.popperElm + let reference = getReference({ state, props, vm, slots }) + + if (!popper || !reference || reference.nodeType !== Node.ELEMENT_NODE) { + return + } + + if (props.visibleArrow) { + appendArrow(popper) + } + + // 使用的组件比较多,所以 appendToBody popperAppendToBody 这2个属性都要监听 + if (props.appendToBody || props.popperAppendToBody) { + document.body.appendChild(state.popperElm) + } else { + // 只有tooltip 传入parent了 + parent && parent.$el && parent.$el.appendChild(state.popperElm) + options.forceAbsolute = true + } + + options.placement = state.currentPlacement + options.offset = props.offset || 0 + options.arrowOffset = props.arrowOffset || 0 + options.adjustArrow = props.adjustArrow || false + options.appendToBody = props.appendToBody || props.popperAppendToBody + + // 创建一个popperJS, 内部会立即调用一次update() 并 applyStyle等操作 + state.popperJS = new PopperJS(reference, popper, options) + // 1、所有使用vue-popper的都有该事件;2、有的组件会多次触发 created + emit('created', state) + + if (typeof options.onUpdate === 'function') { + state.popperJS.onUpdate(options.onUpdate) + } + + state.popperJS._popper.style.zIndex = nextZIndex(state.popperJS._reference) + followHide(state.popperJS) + on(state.popperElm, 'click', stop) + } + + /** 第一次 updatePopper 的时候,才真正执行创建 + * popperElmOrTrue===true的场景仅在select组件动态更新面版时,不更新zIndex + */ + const updatePopper = (popperElmOrTrue?: HTMLElement) => { + if (popperElmOrTrue && popperElmOrTrue !== true) { + state.popperElm = popperElmOrTrue + } + + const popperJS = state.popperJS + if (popperJS) { + // Tiny 新增,在动态切换renference时,需要实时获取最新的触发源 + popperJS._reference = getReference({ state, props, vm, slots }) + popperJS.update() + + // 每次递增 z-index + if (popperJS._popper && popperElmOrTrue !== true) { + popperJS._popper.style.zIndex = nextZIndex(popperJS._reference) + followHide(state.popperJS) + } + } else { + createPopper(popperElmOrTrue && popperElmOrTrue !== true ? popperElmOrTrue : undefined) + } + } + + /** 调用state.popperJS.destroy()。 默认不会移除popper dom + * doDestroy() 默认执行的条件是: state.popperJS 有值且 state.showPopper = false. + * 当state.showPopper 为true时, 需要 doDestroy(true)! + */ + const doDestroy = (forceDestroy?: boolean) => { + if (!state.popperJS || (state.showPopper && !forceDestroy)) { + return + } + state.popperJS.destroy() // 并未移除popper的dom + state.popperJS = null as any + } + + /** remove时,执行真的移除popper dom操作。 */ + const destroyPopper = (remove: 'remove' | boolean) => { + if (remove) { + // 当popper中嵌套popper时,内层popper被移除后不会重新创建,因此onDeactivated不将内层popper移除 + if (state.popperElm && state.popperElm.parentNode === document.body) { + off(state.popperElm, 'click', stop) + state.popperElm.remove() + } + } + } + + // 注意: 一直以来,state.showPopper 为false时,并未调用doDestory. 像popover只是依赖这个值来 给reference元素 v-show一下 + watch( + () => state.showPopper, + (val) => { + if (props.disabled) { + return + } + if (val) { + nextTick(updatePopper) + } + props.trigger === 'manual' && emit('update:modelValue', val) + } + ) + + onBeforeUnmount(() => { + nextTick(() => { + doDestroy(true) + if (props.appendToBody || props.popperAppendToBody) { + destroyPopper('remove') + } + }) + }) + + onDeactivated(() => { + doDestroy(true) + if (props.appendToBody || props.popperAppendToBody) { + destroyPopper('remove') + } + }) + + return { updatePopper, destroyPopper, doDestroy, ...toRefs(state) } +} diff --git a/packages/vue-hooks/src/vue-popup.ts b/packages/vue-hooks/src/vue-popup.ts new file mode 100644 index 0000000000..23100757c7 --- /dev/null +++ b/packages/vue-hooks/src/vue-popup.ts @@ -0,0 +1,196 @@ +/** + * Copyright (c) 2022 - present TinyVue Authors. + * Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +import { merge, PopupManager, addClass } from '@opentiny/utils' + +// todo +import type { ISharedRenderlessFunctionParams } from 'types/shared.type' + +let idSeed = 1 +const isServer = typeof window === 'undefined' + +export interface IPopupState { + opened: boolean + rendered: boolean +} +type IPopupInputParams = ISharedRenderlessFunctionParams & { + api: { open: Function; close: Function } + state: IPopupState + props: any +} + +const setWatchFn = ({ + onMounted, + onBeforeUnmount, + watch, + vm, + api, + props, + state, + nextTick +}: Pick< + IPopupInputParams, + 'onMounted' | 'onBeforeUnmount' | 'watch' | 'vm' | 'api' | 'props' | 'state' | 'nextTick' +>) => { + onMounted(() => { + vm._popupId = `popup-${idSeed++}` + PopupManager.register(vm._popupId, vm) + }) + + onBeforeUnmount(() => { + PopupManager.deregister(vm._popupId) + PopupManager.closeModal(vm._popupId) + }) + + watch( + () => props.visible, + (val) => { + if (val) { + if (vm._opening) { + return + } + if (state.rendered) { + api.open() + } else { + state.rendered = true + nextTick(() => { + api.open() + }) + } + } else { + api.close() + } + } + ) +} + +const openFn = + ({ state, vm }: Pick) => + (options: any) => { + if (!state.rendered) { + state.rendered = true + } + + const props: any = merge({}, vm.$props || vm, options) + + if (vm._closeTimer) { + clearTimeout(vm._closeTimer) + vm._closeTimer = null + } + + clearTimeout(vm._openTimer) + + // 复用doOpen + const doOpen = () => { + if (isServer || state.opened) { + return + } + + vm._opening = true + + const dom = vm.$el + const modal = props.modal + const zIndex = props.zIndex + + if (zIndex) { + PopupManager.zIndex = zIndex + } + + if (modal) { + if (vm._closing) { + PopupManager.closeModal(vm._popupId) + vm._closing = false + } + + PopupManager.openModal( + vm._popupId, + PopupManager.nextZIndex(), + props.modalAppendToBody ? undefined : dom, + props.modalClass, + props.modalFade + ) + + if (props.lockScroll) { + // 必须先计算宽度,再添加popLockClass。 下面2行不能交换 + PopupManager.fixBodyBorder() + addClass(document.body, PopupManager.popLockClass) + } + } + + if (getComputedStyle(dom).position === 'static') { + dom.style.position = 'absolute' + } + + dom.style.zIndex = PopupManager.nextZIndex().toString() + state.opened = true + + vm._opening = false + } + const openDelay = Number(props.openDelay) + + if (openDelay > 0) { + vm._openTimer = setTimeout(() => { + vm._openTimer = null + doOpen() + }, openDelay) + } else { + doOpen() + } + } +const closeFn = + ({ state, vm }: Pick) => + () => { + if (vm._openTimer !== null) { + clearTimeout(vm._openTimer) + vm._openTimer = null + } + + clearTimeout(vm._closeTimer) + + // 复用 doClose + const doClose = () => { + vm._closing = true + + state.opened = false + PopupManager.closeModal(vm._popupId) + vm._closing = false + } + + const closeDelay = Number(vm.closeDelay) + + if (closeDelay > 0) { + vm._closeTimer = setTimeout(() => { + vm._closeTimer = null + doClose() + }, closeDelay) + } else { + doClose() + } + } + +/** vue-popup 只是dialog-box 自己使用的包, 封装了一些state和几个方法,处理mount,unmount 和watch。 它内部封装了 PopupManager 的调用! + * 计划:drawer/image 等组件均使用该函数 + */ +export default (options: IPopupInputParams) => { + const { api, nextTick, onBeforeUnmount, onMounted, props, reactive, toRefs, vm, watch } = options + const state = reactive({ + opened: false, + rendered: false + }) + + setWatchFn({ onMounted, onBeforeUnmount, watch, vm, api, props, state, nextTick }) + + const open = openFn({ state, vm }) + const close = closeFn({ state, vm }) + + return { open, close, PopupManager, ...toRefs(state) } +}