diff --git a/CHANGELOG.zh_CN.md b/CHANGELOG.zh_CN.md index c053de5e0..24af661e1 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 similarity index 100% rename from src/setup/directives/index.ts rename to src/directives/index.ts diff --git a/src/setup/directives/loading.ts b/src/directives/loading.ts similarity index 100% rename from src/setup/directives/loading.ts rename to src/directives/loading.ts diff --git a/src/setup/directives/permission.ts b/src/directives/permission.ts similarity index 100% rename from src/setup/directives/permission.ts rename to src/directives/permission.ts diff --git a/src/setup/directives/repeatClick.ts b/src/directives/repeatClick.ts similarity index 100% rename from src/setup/directives/repeatClick.ts rename to src/directives/repeatClick.ts diff --git a/src/directives/ripple/index.less b/src/directives/ripple/index.less new file mode 100644 index 000000000..9c0718edc --- /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 000000000..8863b9120 --- /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) { + 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 0c0875e9c..0636b2e8d 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 621af0e89..5b651a1c0 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 d30c812c0..136d25236 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 ee435fec2..dc1b2b40e 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: [ { @@ -44,6 +47,13 @@ const menu: MenuModule = { path: 'watermark', 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 6a1173436..bc80caa43 100644 --- a/src/router/routes/modules/demo/feat.ts +++ b/src/router/routes/modules/demo/feat.ts @@ -86,6 +86,14 @@ const feat: AppRouteModule = { title: t('routes.demo.feat.watermark'), }, }, + { + path: 'ripple', + name: 'RippleDemo', + component: () => import('/@/views/demo/feat/ripple/index.vue'), + meta: { + title: t('routes.demo.feat.ripple'), + }, + }, { path: 'full-screen', name: 'FullScreenDemo', diff --git a/src/views/demo/feat/ripple/index.vue b/src/views/demo/feat/ripple/index.vue new file mode 100644 index 000000000..eb5ad169c --- /dev/null +++ b/src/views/demo/feat/ripple/index.vue @@ -0,0 +1,33 @@ + + + +