diff --git a/devui/ripple/index.ts b/devui/ripple/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..255ea4163168f62986a80a0d72f24fd9e3f4a1a9 --- /dev/null +++ b/devui/ripple/index.ts @@ -0,0 +1,14 @@ +import type { App } from 'vue' +import RippleDirective from './src/ripple-directive' + +export { RippleDirective } + +export default { + title: 'Ripple 水波纹', + category: '通用', + status: undefined, // TODO: 组件若开发完成则填入"已完成",并删除该注释 + install(app: App): void { + + app.directive('Ripple', RippleDirective) + } +} diff --git a/devui/ripple/src/options.ts b/devui/ripple/src/options.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a5f916878ac0cdef2b491df1159a2e11c963303 --- /dev/null +++ b/devui/ripple/src/options.ts @@ -0,0 +1,80 @@ +interface IRippleDirectiveOptions { + /** + * + * @remarks + * Y* 你可以设置 ·currentColor· to 能够自动使用元素的文本颜色 + * + * @default + * 'currentColor' + */ + color: string + /** + * 第一次出现的透明度 + * + * @default + * 0.2 默认opacity 0.2 + */ + initialOpacity: number + /** + * 在透明度 结束的时候 stopped 的时候 我们设置透明度的大小 + * + * @default + * 0.1 + */ + finalOpacity: number + /** + * 动画持续事件 + * + * @default + * 0.4 + */ + duration: number + /** + * css 动画 从开始到结束 以相同的时间来执行动画 + * + * @default + * 'ease-out' + */ + easing: string + /** + * 取消延迟时间 + * + * @note + * 类似于 debounceTime + * @default + * 75 + */ + delayTime: number +} + +interface IRipplePluginOptions extends IRippleDirectiveOptions { + /** + * 用于覆盖指令的名称 + * + * @remarks + * + * @example + * + * @default + * 默认指令 ripple + */ + directive: string +} + +// 给可预见值 value 添加类型 + +interface IRippleDirectiveOptionWithBinding { + value: IRippleDirectiveOptions +} + +const DEFAULT_PLUGIN_OPTIONS: IRipplePluginOptions = { + directive: 'ripple', + color: 'currentColor', + initialOpacity: 0.2, + finalOpacity: 0.1, + duration: 0.8, + easing: 'ease-out', + delayTime: 75 +} + +export { DEFAULT_PLUGIN_OPTIONS, IRipplePluginOptions, IRippleDirectiveOptions, IRippleDirectiveOptionWithBinding } diff --git a/devui/ripple/src/ripple-directive.ts b/devui/ripple/src/ripple-directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7b4730c4d82a1a3058a9407ab8d7cf5247d878d --- /dev/null +++ b/devui/ripple/src/ripple-directive.ts @@ -0,0 +1,31 @@ +// can export function. 解构参数类型冗余 新定义insterface IRippleDirectiveOptionWithBinding +import { + DEFAULT_PLUGIN_OPTIONS, + IRippleDirectiveOptions, + IRippleDirectiveOptionWithBinding +} from './options' +import { ripple } from './v-ripple' +const optionMap = new WeakMap< + HTMLElement, + Partial | false +>() +const globalOptions = { ...DEFAULT_PLUGIN_OPTIONS } +export default { + mounted(el: HTMLElement, binding: IRippleDirectiveOptionWithBinding) { + optionMap.set(el, binding.value ?? {}) + + el.addEventListener('pointerdown', (event) => { + const options = optionMap.get(el) + + if (options === false) return + + ripple(event, el, { + ...globalOptions, + ...options + }) + }) + }, + updated(el: HTMLElement, binding: IRippleDirectiveOptionWithBinding) { + optionMap.set(el, binding.value ?? {}) + } +} diff --git a/devui/ripple/src/utils/create-container-element.ts b/devui/ripple/src/utils/create-container-element.ts new file mode 100644 index 0000000000000000000000000000000000000000..36cfee3e6d86f9eb367ef0046f1e1ec30923b503 --- /dev/null +++ b/devui/ripple/src/utils/create-container-element.ts @@ -0,0 +1,21 @@ +export const createContainer = ({ + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius +}: CSSStyleDeclaration): HTMLElement => { + const rippleContainer = document.createElement('div') + rippleContainer.style.top = '0' + rippleContainer.style.left = '0' + rippleContainer.style.width = '100%' + rippleContainer.style.height = '100%' + rippleContainer.style.position = 'absolute' + rippleContainer.style.borderRadius = `${borderTopLeftRadius} ${borderTopRightRadius} ${borderBottomRightRadius} ${borderBottomLeftRadius}` + rippleContainer.style.overflow = 'hidden' + rippleContainer.style.pointerEvents = 'none' + + // 兼容 ie 苹果 + rippleContainer.style.webkitMaskImage = '-webkit-radial-gradient(white, black)' + + return rippleContainer +} diff --git a/devui/ripple/src/utils/create-ripple-element.ts b/devui/ripple/src/utils/create-ripple-element.ts new file mode 100644 index 0000000000000000000000000000000000000000..87a69faf51fd8def787c1056c69c68407895cdf0 --- /dev/null +++ b/devui/ripple/src/utils/create-ripple-element.ts @@ -0,0 +1,23 @@ +import { IRippleDirectiveOptions } from '../options' + +export const createrippleElement = ( + x: number, + y: number, + size: number, + options: IRippleDirectiveOptions +): HTMLElement => { + const rippleElement = document.createElement('div') + + rippleElement.style.position = 'absolute' + rippleElement.style.width = `${size}px` + rippleElement.style.height = `${size}px` + rippleElement.style.top = `${y}px` + rippleElement.style.left = `${x}px` + rippleElement.style.background = options.color + rippleElement.style.borderRadius = '50%' + rippleElement.style.opacity = `${options.initialOpacity}` + rippleElement.style.transform = `translate(-50%,-50%) scale(0)` + rippleElement.style.transition = `transform ${options.duration}s ${options.easing}, opacity ${options.duration}s ${options.easing}` + + return rippleElement +} diff --git a/devui/ripple/src/utils/getdistance-tofurthestcorner.ts b/devui/ripple/src/utils/getdistance-tofurthestcorner.ts new file mode 100644 index 0000000000000000000000000000000000000000..cadb428d91c9135c0b3af67713d1000d1199ff95 --- /dev/null +++ b/devui/ripple/src/utils/getdistance-tofurthestcorner.ts @@ -0,0 +1,14 @@ +import { magnitude } from './magnitude' + +export function getDistanceToFurthestCorner( + x: number, + y: number, + { width, height }: DOMRect +): number { + // 获取点击目标的位置到块级作用域边界的距离 + const topLeft = magnitude(x, y, 0, 0) + const topRight = magnitude(x, y, width, 0) + const bottomLeft = magnitude(x, y, 0, height) + const bottomRight = magnitude(x, y, width, height) + return Math.max(topLeft, topRight, bottomLeft, bottomRight) +} diff --git a/devui/ripple/src/utils/getrelative-pointer.ts b/devui/ripple/src/utils/getrelative-pointer.ts new file mode 100644 index 0000000000000000000000000000000000000000..44d75b8127ab8c98ac90157fb603219f369f1154 --- /dev/null +++ b/devui/ripple/src/utils/getrelative-pointer.ts @@ -0,0 +1,7 @@ +export const getRelativePointer = ( + { x, y }: PointerEvent, + { top, left }: DOMRect +) => ({ + x: x - left, + y: y - top +}) diff --git a/devui/ripple/src/utils/magnitude.ts b/devui/ripple/src/utils/magnitude.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed2202498ff9d03f3179cbc690afa9f510e2f90a --- /dev/null +++ b/devui/ripple/src/utils/magnitude.ts @@ -0,0 +1,6 @@ +export function magnitude(x1: number, y1: number, x2: number, y2: number): number { + const deltaX = x1 - x2 + const deltaY = y1 - y2 + + return Math.sqrt(deltaX * deltaX + deltaY * deltaY) +} diff --git a/devui/ripple/src/utils/ripple-count.ts b/devui/ripple/src/utils/ripple-count.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d9720d1668bb1df058a8b019bd6ca42e6690b65 --- /dev/null +++ b/devui/ripple/src/utils/ripple-count.ts @@ -0,0 +1,23 @@ +const RIPPLE_COUNT = 'vRippleCountInternal' + +export function incrementRippleCount(el: HTMLElement) { + const count = getRippleCount(el) + setRippleCount(el, count + 1) +} + +export function decrementRippleCount(el: HTMLElement) { + const count = getRippleCount(el) + setRippleCount(el, count - 1) +} + +function setRippleCount(el: HTMLElement, count: number) { + el.dataset[RIPPLE_COUNT] = count.toString() +} + +export function getRippleCount(el: HTMLElement): number { + return parseInt(el.dataset[RIPPLE_COUNT] ?? '0', 10) +} + +export function deleteRippleCount(el: HTMLElement) { + delete el.dataset[RIPPLE_COUNT] +} diff --git a/devui/ripple/src/v-ripple.ts b/devui/ripple/src/v-ripple.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d7586cc8e90ad561e6ec49f65ce2a589272ded7 --- /dev/null +++ b/devui/ripple/src/v-ripple.ts @@ -0,0 +1,90 @@ +import { createContainer } from './utils/create-container-element' +import { createrippleElement } from './utils/create-ripple-element' +import { getDistanceToFurthestCorner } from './utils/getdistance-tofurthestcorner' +import { getRelativePointer } from './utils/getrelative-pointer' +import { + decrementRippleCount, + deleteRippleCount, + getRippleCount, + incrementRippleCount +} from './utils/ripple-count' +import { IRippleDirectiveOptions } from './options' +const MULTIPLE_NUMBER = 2.05 +const ripple = ( + event: PointerEvent, + el: HTMLElement, + options: IRippleDirectiveOptions +) => { + const rect = el.getBoundingClientRect() + const computedStyles = window.getComputedStyle(el) + const { x, y } = getRelativePointer(event, rect) + const size = MULTIPLE_NUMBER * getDistanceToFurthestCorner(x, y, rect) + + const rippleContainer = createContainer(computedStyles) + const rippleEl = createrippleElement(x, y, size, options) + + incrementRippleCount(el) + + let originalPositionValue = '' + if (computedStyles.position === 'static') { + if (el.style.position) originalPositionValue = el.style.position + el.style.position = 'relative' + } + + rippleContainer.appendChild(rippleEl) + el.appendChild(rippleContainer) + + let shouldDissolveripple = false + const releaseripple = (e?: any) => { + if (typeof e !== 'undefined') { + document.removeEventListener('pointerup', releaseripple) + document.removeEventListener('pointercancel', releaseripple) + } + + if (shouldDissolveripple) dissolveripple() + else shouldDissolveripple = true + } + + const dissolveripple = () => { + rippleEl.style.transition = 'opacity 150ms linear' + rippleEl.style.opacity = '0' + + setTimeout(() => { + rippleContainer.remove() + + decrementRippleCount(el) + + if (getRippleCount(el) === 0) { + deleteRippleCount(el) + el.style.position = originalPositionValue + } + }, 150) + } + + document.addEventListener('pointerup', releaseripple) + document.addEventListener('pointercancel', releaseripple) + + const token = setTimeout(() => { + document.removeEventListener('pointercancel', cancelripple) + + requestAnimationFrame(() => { + rippleEl.style.transform = `translate(-50%,-50%) scale(1)` + rippleEl.style.opacity = `${options.finalOpacity}` + + setTimeout(() => releaseripple(), options.duration * 1000) + }) + }, options.delayTime) + + const cancelripple = () => { + clearTimeout(token) + + rippleContainer.remove() + document.removeEventListener('pointerup', releaseripple) + document.removeEventListener('pointercancel', releaseripple) + document.removeEventListener('pointercancel', cancelripple) + } + + document.addEventListener('pointercancel', cancelripple) +} + +export { ripple } diff --git a/docs/components/ripple/index.md b/docs/components/ripple/index.md new file mode 100644 index 0000000000000000000000000000000000000000..0de25398db52926439dafe8c22ddb45240b1718d --- /dev/null +++ b/docs/components/ripple/index.md @@ -0,0 +1,155 @@ +# Ripple 水波纹指令 + +`v-ripple` 指令 用于用户动作交互场景, 可以应用于任何块级元素 + +### 使用 + +用户 可以在组件 或者 HTML 元素上任意使用 `v-ripple` 指令 使用基本的 `v-ripple` 指令, `v-ripple` 接收 一个对象 + +### + +
HTML元素 中使用 v-ripple
+ + +:::demo +```vue + + + + +``` +::: + +### 其他 + +### 自定义色彩 + +### + +您可以通过修改文本颜色来动态改变 Ripple 的颜色 默认 Ripple 颜色 跟随文本 颜色 + +### + + + +### + +当然 我们也可以 自己定义我们 想要的颜色 + +### + + + +### + +### 我们还可以应用 于 其他组件 +### 例如 Button + +:::demo +```vue + + + + +``` +::: +### 例如 Icon + +:::demo +```vue + + + + +``` +::: + + +### API + +| 参数 | 类型 | 默认 | 说明 | 跳转 Demo | 全局配置项 | +| :---------: | :------: | :-------: | :----------------------- | --------------------------------- | --------- | +| color | `string` | #00050 | 可选,默认当前文本颜色 | | +| initial-opacity | `number` | 0.1 | 可选,初始交互效果透明度大小 | | +| final-opacity | `number` | 0.1 | 可选,结束交互效果长按透明度大小 | | +| duration | `number` | 0.4s | 可选,持续时间 | | +| easing | `string` | ease-out | 可选,缓动动画 | | +| delay-time | `number` | 75ms | 可选,延迟debouceTime时间后调用 | + +