diff --git a/CHANGELOG.zh_CN.md b/CHANGELOG.zh_CN.md index c053de5..24af661 100644 --- a/CHANGELOG.zh_CN.md +++ b/CHANGELOG.zh_CN.md @@ -1,5 +1,9 @@ ## Wip +### ✨ Features + +- 新增 `v-ripple`水波纹指令 + ### 🐛 Bug Fixes - 修复混合模式下滚动条丢失问题 diff --git a/src/setup/directives/index.ts b/src/directives/index.ts index 0329eb6..0329eb6 100644 --- a/src/setup/directives/index.ts +++ b/src/directives/index.ts diff --git a/src/setup/directives/loading.ts b/src/directives/loading.ts index 20c6e44..20c6e44 100644 --- a/src/setup/directives/loading.ts +++ b/src/directives/loading.ts diff --git a/src/setup/directives/permission.ts b/src/directives/permission.ts index 8c37c3e..8c37c3e 100644 --- a/src/setup/directives/permission.ts +++ b/src/directives/permission.ts diff --git a/src/setup/directives/repeatClick.ts b/src/directives/repeatClick.ts index d4ef150..d4ef150 100644 --- a/src/setup/directives/repeatClick.ts +++ b/src/directives/repeatClick.ts diff --git a/src/directives/ripple/index.less b/src/directives/ripple/index.less new file mode 100644 index 0000000..9c0718e --- /dev/null +++ b/src/directives/ripple/index.less @@ -0,0 +1,21 @@ +.ripple-container { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + overflow: hidden; + pointer-events: none; +} + +.ripple-effect { + position: relative; + z-index: 9999; + width: 1px; + height: 1px; + margin-top: 0; + margin-left: 0; + pointer-events: none; + border-radius: 50%; + transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} diff --git a/src/directives/ripple/index.ts b/src/directives/ripple/index.ts new file mode 100644 index 0000000..8863b91 --- /dev/null +++ b/src/directives/ripple/index.ts @@ -0,0 +1,191 @@ +import { Directive } from 'vue'; +import './index.less'; +export interface RippleOptions { + event: string; + transition: number; +} + +export interface RippleProto { + background?: string; + zIndex?: string; +} + +export type EventType = Event & MouseEvent & TouchEvent; + +const options: RippleOptions = { + event: 'mousedown', + transition: 400, +}; + +const RippleDirective: Directive & RippleProto = { + beforeMount: (el: HTMLElement, binding) => { + if (binding.value === false) return; + + const bg = el.getAttribute('ripple-background'); + setProps(Object.keys(binding.modifiers), options); + + const background = bg || RippleDirective.background; + const zIndex = RippleDirective.zIndex; + + el.addEventListener(options.event, (event: EventType) => { + rippler({ + event, + el, + background, + zIndex, + }); + }); + }, + updated(el, binding) { + if (!binding.value) { + el?.clearRipple?.(); + return; + } + const bg = el.getAttribute('ripple-background'); + el?.setBackground?.(bg); + }, +}; + +function rippler({ + event, + el, + zIndex, + background, +}: { event: EventType; el: HTMLElement } & RippleProto) { + const targetBorder = parseInt(getComputedStyle(el).borderWidth.replace('px', '')); + const clientX = event.clientX || event.touches[0].clientX; + const clientY = event.clientY || event.touches[0].clientY; + + const rect = el.getBoundingClientRect(); + const { left, top } = rect; + const { offsetWidth: width, offsetHeight: height } = el; + const { transition } = options; + const dx = clientX - left; + const dy = clientY - top; + const maxX = Math.max(dx, width - dx); + const maxY = Math.max(dy, height - dy); + const style = window.getComputedStyle(el); + const radius = Math.sqrt(maxX * maxX + maxY * maxY); + const border = targetBorder > 0 ? targetBorder : 0; + + const ripple = document.createElement('div'); + const rippleContainer = document.createElement('div'); + + // Styles for ripple + + Object.assign(ripple.style ?? {}, { + className: 'ripple', + marginTop: '0px', + marginLeft: '0px', + width: '1px', + height: '1px', + transition: `all ${transition}ms cubic-bezier(0.4, 0, 0.2, 1)`, + borderRadius: '50%', + pointerEvents: 'none', + position: 'relative', + zIndex: zIndex ?? '9999', + backgroundColor: background ?? 'rgba(0, 0, 0, 0.12)', + }); + + // Styles for rippleContainer + Object.assign(rippleContainer.style ?? {}, { + className: 'ripple-container', + position: 'absolute', + left: `${0 - border}px`, + top: `${0 - border}px`, + height: '0', + width: '0', + pointerEvents: 'none', + overflow: 'hidden', + }); + + const storedTargetPosition = + el.style.position.length > 0 ? el.style.position : getComputedStyle(el).position; + + if (storedTargetPosition !== 'relative') { + el.style.position = 'relative'; + } + + rippleContainer.appendChild(ripple); + el.appendChild(rippleContainer); + + Object.assign(ripple.style, { + marginTop: `${dy}px`, + marginLeft: `${dx}px`, + }); + + const { + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + } = style; + Object.assign(rippleContainer.style, { + width: `${width}px`, + height: `${height}px`, + direction: 'ltr', + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + }); + + setTimeout(() => { + const wh = `${radius * 2}px`; + Object.assign(ripple.style ?? {}, { + width: wh, + height: wh, + marginLeft: `${dx - radius}px`, + marginTop: `${dy - radius}px`, + }); + }, 0); + + function clearRipple() { + setTimeout(() => { + ripple.style.backgroundColor = 'rgba(0, 0, 0, 0)'; + }, 250); + + setTimeout(() => { + rippleContainer?.parentNode?.removeChild(rippleContainer); + }, 850); + el.removeEventListener('mouseup', clearRipple, false); + el.removeEventListener('mouseleave', clearRipple, false); + el.removeEventListener('dragstart', clearRipple, false); + setTimeout(() => { + let clearPosition = true; + for (let i = 0; i < el.childNodes.length; i++) { + if ((el.childNodes[i] as any).className === 'ripple-container') { + clearPosition = false; + } + } + + if (clearPosition) { + el.style.position = storedTargetPosition !== 'static' ? storedTargetPosition : ''; + } + }, options.transition + 260); + } + + if (event.type === 'mousedown') { + el.addEventListener('mouseup', clearRipple, false); + el.addEventListener('mouseleave', clearRipple, false); + el.addEventListener('dragstart', clearRipple, false); + } else { + clearRipple(); + } + + (el as any).setBackground = (bgColor: string) => { + if (!bgColor) { + return; + } + ripple.style.backgroundColor = bgColor; + }; +} + +function setProps(modifiers: { [key: string]: any }, props: Record<string, any>) { + modifiers.forEach((item: any) => { + if (isNaN(Number(item))) props.event = item; + else props.transition = item; + }); +} + +export default RippleDirective; diff --git a/src/locales/lang/en/routes/demo/feat.ts b/src/locales/lang/en/routes/demo/feat.ts index 0c0875e..0636b2e 100644 --- a/src/locales/lang/en/routes/demo/feat.ts +++ b/src/locales/lang/en/routes/demo/feat.ts @@ -9,6 +9,7 @@ export default { copy: 'Clipboard', msg: 'Message prompt', watermark: 'Watermark', + ripple: 'Ripple', fullScreen: 'Full Screen', errorLog: 'Error Log', tab: 'Tab with parameters', diff --git a/src/locales/lang/zh_CN/routes/demo/feat.ts b/src/locales/lang/zh_CN/routes/demo/feat.ts index 621af0e..5b651a1 100644 --- a/src/locales/lang/zh_CN/routes/demo/feat.ts +++ b/src/locales/lang/zh_CN/routes/demo/feat.ts @@ -9,6 +9,7 @@ export default { copy: '剪切板', msg: '消息提示', watermark: '水印', + ripple: '水波纹', fullScreen: '全屏', errorLog: '错误日志', tab: 'Tab带参', diff --git a/src/main.ts b/src/main.ts index d30c812..136d252 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,7 @@ import router, { setupRouter } from '/@/router'; import { setupStore } from '/@/store'; import { setupAntd } from '/@/setup/ant-design-vue'; import { setupErrorHandle } from '/@/setup/error-handle'; -import { setupGlobDirectives } from '/@/setup/directives'; +import { setupGlobDirectives } from '/@/directives'; import { setupI18n } from '/@/setup/i18n'; import { setupProdMockServer } from '../mock/_createProductionServer'; import { setApp } from '/@/setup/App'; diff --git a/src/router/menus/modules/demo/feat.ts b/src/router/menus/modules/demo/feat.ts index ee435fe..dc1b2b4 100644 --- a/src/router/menus/modules/demo/feat.ts +++ b/src/router/menus/modules/demo/feat.ts @@ -6,6 +6,9 @@ const menu: MenuModule = { menu: { name: t('routes.demo.feat.feat'), path: '/feat', + tag: { + dot: true, + }, children: [ { @@ -45,6 +48,13 @@ const menu: MenuModule = { name: t('routes.demo.feat.watermark'), }, { + path: 'ripple', + name: t('routes.demo.feat.ripple'), + tag: { + content: 'new', + }, + }, + { path: 'full-screen', name: t('routes.demo.feat.fullScreen'), }, diff --git a/src/router/routes/modules/demo/feat.ts b/src/router/routes/modules/demo/feat.ts index 6a11734..bc80caa 100644 --- a/src/router/routes/modules/demo/feat.ts +++ b/src/router/routes/modules/demo/feat.ts @@ -87,6 +87,14 @@ const feat: AppRouteModule = { }, }, { + path: 'ripple', + name: 'RippleDemo', + component: () => import('/@/views/demo/feat/ripple/index.vue'), + meta: { + title: t('routes.demo.feat.ripple'), + }, + }, + { path: 'full-screen', name: 'FullScreenDemo', component: () => import('/@/views/demo/feat/full-screen/index.vue'), diff --git a/src/views/demo/feat/ripple/index.vue b/src/views/demo/feat/ripple/index.vue new file mode 100644 index 0000000..eb5ad16 --- /dev/null +++ b/src/views/demo/feat/ripple/index.vue @@ -0,0 +1,33 @@ +<template> + <div class="p-4"> + <div class="demo-box" v-ripple>content</div> + </div> +</template> +<script lang="ts"> + import { defineComponent } from 'vue'; + import { Alert } from 'ant-design-vue'; + import RippleDirective from '/@/directives/ripple'; + export default defineComponent({ + components: { Alert }, + directives: { + Ripple: RippleDirective, + }, + setup() { + return {}; + }, + }); +</script> + +<style lang="less" scoped> + .demo-box { + display: flex; + width: 300px; + height: 300px; + font-size: 24px; + color: #fff; + background: #408ede; + border-radius: 10px; + justify-content: center; + align-items: center; + } +</style>