Commit 2e79c9f37adda4003e6b054561b26da69a762673

Authored by vben
1 parent aafbb052

feat: add ripple directive

CHANGELOG.zh_CN.md
1 1 ## Wip
2 2  
  3 +### ✨ Features
  4 +
  5 +- 新增 `v-ripple`水波纹指令
  6 +
3 7 ### 🐛 Bug Fixes
4 8  
5 9 - 修复混合模式下滚动条丢失问题
... ...
src/setup/directives/index.ts renamed to src/directives/index.ts
src/setup/directives/loading.ts renamed to src/directives/loading.ts
src/setup/directives/permission.ts renamed to src/directives/permission.ts
src/setup/directives/repeatClick.ts renamed to src/directives/repeatClick.ts
src/directives/ripple/index.less 0 → 100644
  1 +.ripple-container {
  2 + position: absolute;
  3 + top: 0;
  4 + left: 0;
  5 + width: 0;
  6 + height: 0;
  7 + overflow: hidden;
  8 + pointer-events: none;
  9 +}
  10 +
  11 +.ripple-effect {
  12 + position: relative;
  13 + z-index: 9999;
  14 + width: 1px;
  15 + height: 1px;
  16 + margin-top: 0;
  17 + margin-left: 0;
  18 + pointer-events: none;
  19 + border-radius: 50%;
  20 + transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
  21 +}
... ...
src/directives/ripple/index.ts 0 → 100644
  1 +import { Directive } from 'vue';
  2 +import './index.less';
  3 +export interface RippleOptions {
  4 + event: string;
  5 + transition: number;
  6 +}
  7 +
  8 +export interface RippleProto {
  9 + background?: string;
  10 + zIndex?: string;
  11 +}
  12 +
  13 +export type EventType = Event & MouseEvent & TouchEvent;
  14 +
  15 +const options: RippleOptions = {
  16 + event: 'mousedown',
  17 + transition: 400,
  18 +};
  19 +
  20 +const RippleDirective: Directive & RippleProto = {
  21 + beforeMount: (el: HTMLElement, binding) => {
  22 + if (binding.value === false) return;
  23 +
  24 + const bg = el.getAttribute('ripple-background');
  25 + setProps(Object.keys(binding.modifiers), options);
  26 +
  27 + const background = bg || RippleDirective.background;
  28 + const zIndex = RippleDirective.zIndex;
  29 +
  30 + el.addEventListener(options.event, (event: EventType) => {
  31 + rippler({
  32 + event,
  33 + el,
  34 + background,
  35 + zIndex,
  36 + });
  37 + });
  38 + },
  39 + updated(el, binding) {
  40 + if (!binding.value) {
  41 + el?.clearRipple?.();
  42 + return;
  43 + }
  44 + const bg = el.getAttribute('ripple-background');
  45 + el?.setBackground?.(bg);
  46 + },
  47 +};
  48 +
  49 +function rippler({
  50 + event,
  51 + el,
  52 + zIndex,
  53 + background,
  54 +}: { event: EventType; el: HTMLElement } & RippleProto) {
  55 + const targetBorder = parseInt(getComputedStyle(el).borderWidth.replace('px', ''));
  56 + const clientX = event.clientX || event.touches[0].clientX;
  57 + const clientY = event.clientY || event.touches[0].clientY;
  58 +
  59 + const rect = el.getBoundingClientRect();
  60 + const { left, top } = rect;
  61 + const { offsetWidth: width, offsetHeight: height } = el;
  62 + const { transition } = options;
  63 + const dx = clientX - left;
  64 + const dy = clientY - top;
  65 + const maxX = Math.max(dx, width - dx);
  66 + const maxY = Math.max(dy, height - dy);
  67 + const style = window.getComputedStyle(el);
  68 + const radius = Math.sqrt(maxX * maxX + maxY * maxY);
  69 + const border = targetBorder > 0 ? targetBorder : 0;
  70 +
  71 + const ripple = document.createElement('div');
  72 + const rippleContainer = document.createElement('div');
  73 +
  74 + // Styles for ripple
  75 +
  76 + Object.assign(ripple.style ?? {}, {
  77 + className: 'ripple',
  78 + marginTop: '0px',
  79 + marginLeft: '0px',
  80 + width: '1px',
  81 + height: '1px',
  82 + transition: `all ${transition}ms cubic-bezier(0.4, 0, 0.2, 1)`,
  83 + borderRadius: '50%',
  84 + pointerEvents: 'none',
  85 + position: 'relative',
  86 + zIndex: zIndex ?? '9999',
  87 + backgroundColor: background ?? 'rgba(0, 0, 0, 0.12)',
  88 + });
  89 +
  90 + // Styles for rippleContainer
  91 + Object.assign(rippleContainer.style ?? {}, {
  92 + className: 'ripple-container',
  93 + position: 'absolute',
  94 + left: `${0 - border}px`,
  95 + top: `${0 - border}px`,
  96 + height: '0',
  97 + width: '0',
  98 + pointerEvents: 'none',
  99 + overflow: 'hidden',
  100 + });
  101 +
  102 + const storedTargetPosition =
  103 + el.style.position.length > 0 ? el.style.position : getComputedStyle(el).position;
  104 +
  105 + if (storedTargetPosition !== 'relative') {
  106 + el.style.position = 'relative';
  107 + }
  108 +
  109 + rippleContainer.appendChild(ripple);
  110 + el.appendChild(rippleContainer);
  111 +
  112 + Object.assign(ripple.style, {
  113 + marginTop: `${dy}px`,
  114 + marginLeft: `${dx}px`,
  115 + });
  116 +
  117 + const {
  118 + borderTopLeftRadius,
  119 + borderTopRightRadius,
  120 + borderBottomLeftRadius,
  121 + borderBottomRightRadius,
  122 + } = style;
  123 + Object.assign(rippleContainer.style, {
  124 + width: `${width}px`,
  125 + height: `${height}px`,
  126 + direction: 'ltr',
  127 + borderTopLeftRadius,
  128 + borderTopRightRadius,
  129 + borderBottomLeftRadius,
  130 + borderBottomRightRadius,
  131 + });
  132 +
  133 + setTimeout(() => {
  134 + const wh = `${radius * 2}px`;
  135 + Object.assign(ripple.style ?? {}, {
  136 + width: wh,
  137 + height: wh,
  138 + marginLeft: `${dx - radius}px`,
  139 + marginTop: `${dy - radius}px`,
  140 + });
  141 + }, 0);
  142 +
  143 + function clearRipple() {
  144 + setTimeout(() => {
  145 + ripple.style.backgroundColor = 'rgba(0, 0, 0, 0)';
  146 + }, 250);
  147 +
  148 + setTimeout(() => {
  149 + rippleContainer?.parentNode?.removeChild(rippleContainer);
  150 + }, 850);
  151 + el.removeEventListener('mouseup', clearRipple, false);
  152 + el.removeEventListener('mouseleave', clearRipple, false);
  153 + el.removeEventListener('dragstart', clearRipple, false);
  154 + setTimeout(() => {
  155 + let clearPosition = true;
  156 + for (let i = 0; i < el.childNodes.length; i++) {
  157 + if ((el.childNodes[i] as any).className === 'ripple-container') {
  158 + clearPosition = false;
  159 + }
  160 + }
  161 +
  162 + if (clearPosition) {
  163 + el.style.position = storedTargetPosition !== 'static' ? storedTargetPosition : '';
  164 + }
  165 + }, options.transition + 260);
  166 + }
  167 +
  168 + if (event.type === 'mousedown') {
  169 + el.addEventListener('mouseup', clearRipple, false);
  170 + el.addEventListener('mouseleave', clearRipple, false);
  171 + el.addEventListener('dragstart', clearRipple, false);
  172 + } else {
  173 + clearRipple();
  174 + }
  175 +
  176 + (el as any).setBackground = (bgColor: string) => {
  177 + if (!bgColor) {
  178 + return;
  179 + }
  180 + ripple.style.backgroundColor = bgColor;
  181 + };
  182 +}
  183 +
  184 +function setProps(modifiers: { [key: string]: any }, props: Record<string, any>) {
  185 + modifiers.forEach((item: any) => {
  186 + if (isNaN(Number(item))) props.event = item;
  187 + else props.transition = item;
  188 + });
  189 +}
  190 +
  191 +export default RippleDirective;
... ...
src/locales/lang/en/routes/demo/feat.ts
... ... @@ -9,6 +9,7 @@ export default {
9 9 copy: 'Clipboard',
10 10 msg: 'Message prompt',
11 11 watermark: 'Watermark',
  12 + ripple: 'Ripple',
12 13 fullScreen: 'Full Screen',
13 14 errorLog: 'Error Log',
14 15 tab: 'Tab with parameters',
... ...
src/locales/lang/zh_CN/routes/demo/feat.ts
... ... @@ -9,6 +9,7 @@ export default {
9 9 copy: '剪切板',
10 10 msg: '消息提示',
11 11 watermark: '水印',
  12 + ripple: '水波纹',
12 13 fullScreen: '全屏',
13 14 errorLog: '错误日志',
14 15 tab: 'Tab带参',
... ...
src/main.ts
... ... @@ -5,7 +5,7 @@ import router, { setupRouter } from &#39;/@/router&#39;;
5 5 import { setupStore } from '/@/store';
6 6 import { setupAntd } from '/@/setup/ant-design-vue';
7 7 import { setupErrorHandle } from '/@/setup/error-handle';
8   -import { setupGlobDirectives } from '/@/setup/directives';
  8 +import { setupGlobDirectives } from '/@/directives';
9 9 import { setupI18n } from '/@/setup/i18n';
10 10 import { setupProdMockServer } from '../mock/_createProductionServer';
11 11 import { setApp } from '/@/setup/App';
... ...
src/router/menus/modules/demo/feat.ts
... ... @@ -6,6 +6,9 @@ const menu: MenuModule = {
6 6 menu: {
7 7 name: t('routes.demo.feat.feat'),
8 8 path: '/feat',
  9 + tag: {
  10 + dot: true,
  11 + },
9 12  
10 13 children: [
11 14 {
... ... @@ -45,6 +48,13 @@ const menu: MenuModule = {
45 48 name: t('routes.demo.feat.watermark'),
46 49 },
47 50 {
  51 + path: 'ripple',
  52 + name: t('routes.demo.feat.ripple'),
  53 + tag: {
  54 + content: 'new',
  55 + },
  56 + },
  57 + {
48 58 path: 'full-screen',
49 59 name: t('routes.demo.feat.fullScreen'),
50 60 },
... ...
src/router/routes/modules/demo/feat.ts
... ... @@ -87,6 +87,14 @@ const feat: AppRouteModule = {
87 87 },
88 88 },
89 89 {
  90 + path: 'ripple',
  91 + name: 'RippleDemo',
  92 + component: () => import('/@/views/demo/feat/ripple/index.vue'),
  93 + meta: {
  94 + title: t('routes.demo.feat.ripple'),
  95 + },
  96 + },
  97 + {
90 98 path: 'full-screen',
91 99 name: 'FullScreenDemo',
92 100 component: () => import('/@/views/demo/feat/full-screen/index.vue'),
... ...
src/views/demo/feat/ripple/index.vue 0 → 100644
  1 +<template>
  2 + <div class="p-4">
  3 + <div class="demo-box" v-ripple>content</div>
  4 + </div>
  5 +</template>
  6 +<script lang="ts">
  7 + import { defineComponent } from 'vue';
  8 + import { Alert } from 'ant-design-vue';
  9 + import RippleDirective from '/@/directives/ripple';
  10 + export default defineComponent({
  11 + components: { Alert },
  12 + directives: {
  13 + Ripple: RippleDirective,
  14 + },
  15 + setup() {
  16 + return {};
  17 + },
  18 + });
  19 +</script>
  20 +
  21 +<style lang="less" scoped>
  22 + .demo-box {
  23 + display: flex;
  24 + width: 300px;
  25 + height: 300px;
  26 + font-size: 24px;
  27 + color: #fff;
  28 + background: #408ede;
  29 + border-radius: 10px;
  30 + justify-content: center;
  31 + align-items: center;
  32 + }
  33 +</style>
... ...