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 @@ + + + + + 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