diff --git a/.vscode/settings.json b/.vscode/settings.json
index b556859e..b146b33f 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -120,5 +120,8 @@
"esbuild"
],
"commentTranslate.multiLineMerge": true,
- "typescript.suggest.autoImports": false
+ "typescript.suggest.autoImports": false,
+ "[typescript]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ }
}
diff --git a/apps/admin/src/pages/demo/feat/ripple.vue b/apps/admin/src/pages/demo/feat/ripple.vue
new file mode 100644
index 00000000..df98a9c2
--- /dev/null
+++ b/apps/admin/src/pages/demo/feat/ripple.vue
@@ -0,0 +1,23 @@
+
+
+
+
+ content
+
+
+
+
diff --git a/packages/directives/index.ts b/packages/directives/index.ts
index a9c2fdae..89d11a57 100644
--- a/packages/directives/index.ts
+++ b/packages/directives/index.ts
@@ -1 +1,2 @@
export { clickOutside } from './src/click-outside'
+export { rippleDirective } from './src/ripple/index'
diff --git a/packages/directives/package.json b/packages/directives/package.json
index 4c0fd9a9..afee1f3e 100644
--- a/packages/directives/package.json
+++ b/packages/directives/package.json
@@ -13,5 +13,8 @@
"dependencies": {
"@vben/utils": "workspace:*",
"vue": "3.3.4"
+ },
+ "devDependencies": {
+ "@vben/types": "workspace:*"
}
}
diff --git a/packages/directives/src/ripple/index.less b/packages/directives/src/ripple/index.less
new file mode 100644
index 00000000..70a1c3f5
--- /dev/null
+++ b/packages/directives/src/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;
+ transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
+ border-radius: 50%;
+ pointer-events: none;
+}
diff --git a/packages/directives/src/ripple/index.ts b/packages/directives/src/ripple/index.ts
new file mode 100644
index 00000000..b9efb540
--- /dev/null
+++ b/packages/directives/src/ripple/index.ts
@@ -0,0 +1,197 @@
+import type { 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: Event) => {
+ rippler({
+ event: event as EventType,
+ 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
+ ripple.className = 'ripple'
+
+ Object.assign(ripple.style ?? {}, {
+ 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
+ rippleContainer.className = 'ripple-container'
+ Object.assign(rippleContainer.style ?? {}, {
+ 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 Recordable).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 Recordable).setBackground = (bgColor: string) => {
+ if (!bgColor) {
+ return
+ }
+ ripple.style.backgroundColor = bgColor
+ }
+}
+
+function setProps(modifiers: Recordable, props: Recordable) {
+ modifiers.forEach((item: Recordable) => {
+ if (isNaN(Number(item))) props.event = item
+ else props.transition = item
+ })
+}
+
+export { rippleDirective }
diff --git a/packages/locale/src/lang/en/routes.ts b/packages/locale/src/lang/en/routes.ts
index 9efd85c2..af714be4 100644
--- a/packages/locale/src/lang/en/routes.ts
+++ b/packages/locale/src/lang/en/routes.ts
@@ -54,5 +54,8 @@ export default {
role: 'Role management',
},
strength: 'Password strength',
+ feat: {
+ ripple: 'Ripple',
+ },
},
}
diff --git a/packages/locale/src/lang/zh-CN/routes.ts b/packages/locale/src/lang/zh-CN/routes.ts
index 78e6697a..1bc52dd9 100644
--- a/packages/locale/src/lang/zh-CN/routes.ts
+++ b/packages/locale/src/lang/zh-CN/routes.ts
@@ -55,5 +55,8 @@ export default {
role: '角色管理',
},
strength: '密码强度组件',
+ feat: {
+ ripple: '水波纹',
+ },
},
}
diff --git a/packages/router/src/routes/modules/demo/feat.ts b/packages/router/src/routes/modules/demo/feat.ts
index f3e91987..955c3026 100644
--- a/packages/router/src/routes/modules/demo/feat.ts
+++ b/packages/router/src/routes/modules/demo/feat.ts
@@ -4,15 +4,23 @@ const feat: RouteRecordItem = {
path: '/feat',
name: 'Feat',
component: LAYOUT,
- redirect: '/feat/index',
+ redirect: '/feat/ripple',
meta: {
orderNo: 4,
title: '功能',
icon: 'ri:function-line',
- root: true
+ root: true,
},
- children: []
+ children: [
+ {
+ path: 'ripple',
+ name: 'RippleDemo',
+ component: () => import('@/pages/demo/feat/ripple.vue'),
+ meta: {
+ title: 'routes.demo.feat.ripple',
+ },
+ },
+ ],
}
export default feat
-
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 19289bc6..80fff61e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -361,6 +361,9 @@ importers:
'@vben/utils':
specifier: workspace:*
version: link:../utils
+ '@zxcvbn-ts/core':
+ specifier: ^3.0.4
+ version: 3.0.4
qrcode:
specifier: ^1.5.3
version: 1.5.3
@@ -422,6 +425,10 @@ importers:
vue:
specifier: 3.3.4
version: 3.3.4
+ devDependencies:
+ '@vben/types':
+ specifier: workspace:*
+ version: link:../types
packages/grid-layouts:
dependencies:
@@ -4954,6 +4961,12 @@ packages:
- vue
dev: false
+ /@zxcvbn-ts/core@3.0.4:
+ resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==}
+ dependencies:
+ fastest-levenshtein: 1.0.16
+ dev: false
+
/JSONStream@1.3.5:
resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
hasBin: true