Browse Source

feat: add ripple directive

vben 4 years ago
parent
commit
2e79c9f37a

+ 4 - 0
CHANGELOG.zh_CN.md

@@ -1,5 +1,9 @@
 ## Wip
 ## Wip
 
 
+### ✨ Features
+
+- 新增 `v-ripple`水波纹指令
+
 ### 🐛 Bug Fixes
 ### 🐛 Bug Fixes
 
 
 - 修复混合模式下滚动条丢失问题
 - 修复混合模式下滚动条丢失问题

+ 0 - 0
src/setup/directives/index.ts → src/directives/index.ts


+ 0 - 0
src/setup/directives/loading.ts → src/directives/loading.ts


+ 0 - 0
src/setup/directives/permission.ts → src/directives/permission.ts


+ 0 - 0
src/setup/directives/repeatClick.ts → src/directives/repeatClick.ts


+ 21 - 0
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);
+}

+ 191 - 0
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;

+ 1 - 0
src/locales/lang/en/routes/demo/feat.ts

@@ -9,6 +9,7 @@ export default {
   copy: 'Clipboard',
   copy: 'Clipboard',
   msg: 'Message prompt',
   msg: 'Message prompt',
   watermark: 'Watermark',
   watermark: 'Watermark',
+  ripple: 'Ripple',
   fullScreen: 'Full Screen',
   fullScreen: 'Full Screen',
   errorLog: 'Error Log',
   errorLog: 'Error Log',
   tab: 'Tab with parameters',
   tab: 'Tab with parameters',

+ 1 - 0
src/locales/lang/zh_CN/routes/demo/feat.ts

@@ -9,6 +9,7 @@ export default {
   copy: '剪切板',
   copy: '剪切板',
   msg: '消息提示',
   msg: '消息提示',
   watermark: '水印',
   watermark: '水印',
+  ripple: '水波纹',
   fullScreen: '全屏',
   fullScreen: '全屏',
   errorLog: '错误日志',
   errorLog: '错误日志',
   tab: 'Tab带参',
   tab: 'Tab带参',

+ 1 - 1
src/main.ts

@@ -5,7 +5,7 @@ import router, { setupRouter } from '/@/router';
 import { setupStore } from '/@/store';
 import { setupStore } from '/@/store';
 import { setupAntd } from '/@/setup/ant-design-vue';
 import { setupAntd } from '/@/setup/ant-design-vue';
 import { setupErrorHandle } from '/@/setup/error-handle';
 import { setupErrorHandle } from '/@/setup/error-handle';
-import { setupGlobDirectives } from '/@/setup/directives';
+import { setupGlobDirectives } from '/@/directives';
 import { setupI18n } from '/@/setup/i18n';
 import { setupI18n } from '/@/setup/i18n';
 import { setupProdMockServer } from '../mock/_createProductionServer';
 import { setupProdMockServer } from '../mock/_createProductionServer';
 import { setApp } from '/@/setup/App';
 import { setApp } from '/@/setup/App';

+ 10 - 0
src/router/menus/modules/demo/feat.ts

@@ -6,6 +6,9 @@ const menu: MenuModule = {
   menu: {
   menu: {
     name: t('routes.demo.feat.feat'),
     name: t('routes.demo.feat.feat'),
     path: '/feat',
     path: '/feat',
+    tag: {
+      dot: true,
+    },
 
 
     children: [
     children: [
       {
       {
@@ -45,6 +48,13 @@ const menu: MenuModule = {
         name: t('routes.demo.feat.watermark'),
         name: t('routes.demo.feat.watermark'),
       },
       },
       {
       {
+        path: 'ripple',
+        name: t('routes.demo.feat.ripple'),
+        tag: {
+          content: 'new',
+        },
+      },
+      {
         path: 'full-screen',
         path: 'full-screen',
         name: t('routes.demo.feat.fullScreen'),
         name: t('routes.demo.feat.fullScreen'),
       },
       },

+ 8 - 0
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',
       path: 'full-screen',
       name: 'FullScreenDemo',
       name: 'FullScreenDemo',
       component: () => import('/@/views/demo/feat/full-screen/index.vue'),
       component: () => import('/@/views/demo/feat/full-screen/index.vue'),

+ 33 - 0
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>