Browse Source

feat: 新增v-ripple指令(水波纹效果) (#956)

* feat: 新增v-ripple指令(水波纹效果)

* feat: 新增波纹demo
Leet 1 year ago
parent
commit
0f0fbdac51

+ 1 - 0
locales/en.yaml

@@ -90,6 +90,7 @@ menus:
   hsMenuTree: Menu Tree
   hsVideoFrame: Video Frame Capture
   hsWavesurfer: Audio Visualization
+  hsRipple: Ripple
   hsOptimize: Debounce、Throttle、Copy、Longpress Directives
   hsWatermark: Water Mark
   hsPrint: Print

+ 1 - 0
locales/zh-CN.yaml

@@ -90,6 +90,7 @@ menus:
   hsMenuTree: 菜单树结构
   hsVideoFrame: 视频帧截取-wasm版
   hsWavesurfer: 音频可视化
+  hsRipple: 波纹(Ripple)
   hsOptimize: 防抖、截流、复制、长按指令
   hsWatermark: 水印
   hsPrint: 打印

+ 1 - 0
src/directives/index.ts

@@ -2,3 +2,4 @@ export * from "./auth";
 export * from "./copy";
 export * from "./longpress";
 export * from "./optimize";
+export * from "./ripple";

+ 48 - 0
src/directives/ripple/index.scss

@@ -0,0 +1,48 @@
+/* stylelint-disable-next-line scss/dollar-variable-colon-space-after */
+$ripple-animation-transition-in:
+  transform 0.4s cubic-bezier(0, 0, 0.2, 1),
+  opacity 0.2s cubic-bezier(0, 0, 0.2, 1) !default;
+$ripple-animation-transition-out: opacity 0.5s cubic-bezier(0, 0, 0.2, 1) !default;
+$ripple-animation-visible-opacity: 0.25 !default;
+
+.v-ripple {
+  &__container {
+    position: absolute;
+    top: 0;
+    left: 0;
+    z-index: 0;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    pointer-events: none;
+    border-radius: inherit;
+    contain: strict;
+  }
+
+  &__animation {
+    position: absolute;
+    top: 0;
+    left: 0;
+    overflow: hidden;
+    pointer-events: none;
+    background: currentcolor;
+    border-radius: 50%;
+    opacity: 0;
+    will-change: transform, opacity;
+
+    &--enter {
+      opacity: 0;
+      transition: none;
+    }
+
+    &--in {
+      opacity: $ripple-animation-visible-opacity;
+      transition: $ripple-animation-transition-in;
+    }
+
+    &--out {
+      opacity: 0;
+      transition: $ripple-animation-transition-out;
+    }
+  }
+}

+ 234 - 0
src/directives/ripple/index.ts

@@ -0,0 +1,234 @@
+import "./index.scss";
+import { isObject } from "@pureadmin/utils";
+import type { Directive, DirectiveBinding } from "vue";
+
+interface RippleOptions {
+  class?: string;
+  center?: boolean;
+  circle?: boolean;
+}
+
+export interface RippleDirectiveBinding
+  extends Omit<DirectiveBinding, "modifiers" | "value"> {
+  value?: boolean | { class: string };
+  modifiers: {
+    center?: boolean;
+    circle?: boolean;
+  };
+}
+
+function transform(el: HTMLElement, value: string) {
+  el.style.transform = value;
+  el.style.webkitTransform = value;
+}
+
+const calculate = (
+  e: PointerEvent,
+  el: HTMLElement,
+  value: RippleOptions = {}
+) => {
+  const offset = el.getBoundingClientRect();
+
+  // 获取点击位置距离 el 的垂直和水平距离
+  let localX = e.clientX - offset.left;
+  let localY = e.clientY - offset.top;
+
+  let radius = 0;
+  let scale = 0.3;
+  // 计算点击位置到 el 顶点最远距离,即为圆的最大半径(勾股定理)
+  if (el._ripple?.circle) {
+    scale = 0.15;
+    radius = el.clientWidth / 2;
+    radius = value.center
+      ? radius
+      : radius + Math.sqrt((localX - radius) ** 2 + (localY - radius) ** 2) / 4;
+  } else {
+    radius = Math.sqrt(el.clientWidth ** 2 + el.clientHeight ** 2) / 2;
+  }
+
+  // 中心点坐标
+  const centerX = `${(el.clientWidth - radius * 2) / 2}px`;
+  const centerY = `${(el.clientHeight - radius * 2) / 2}px`;
+
+  // 点击位置坐标
+  const x = value.center ? centerX : `${localX - radius}px`;
+  const y = value.center ? centerY : `${localY - radius}px`;
+
+  return { radius, scale, x, y, centerX, centerY };
+};
+
+const ripples = {
+  show(e: PointerEvent, el: HTMLElement, value: RippleOptions = {}) {
+    if (!el?._ripple?.enabled) {
+      return;
+    }
+
+    // 创建 ripple 元素和 ripple 父元素
+    const container = document.createElement("span");
+    const animation = document.createElement("span");
+
+    container.appendChild(animation);
+    container.className = "v-ripple__container";
+
+    if (value.class) {
+      container.className += ` ${value.class}`;
+    }
+
+    const { radius, scale, x, y, centerX, centerY } = calculate(e, el, value);
+
+    // ripple 圆大小
+    const size = `${radius * 2}px`;
+
+    animation.className = "v-ripple__animation";
+    animation.style.width = size;
+    animation.style.height = size;
+
+    el.appendChild(container);
+
+    // 获取目标元素样式表
+    const computed = window.getComputedStyle(el);
+    // 防止 position 被覆盖导致 ripple 位置有问题
+    if (computed && computed.position === "static") {
+      el.style.position = "relative";
+      el.dataset.previousPosition = "static";
+    }
+
+    animation.classList.add("v-ripple__animation--enter");
+    animation.classList.add("v-ripple__animation--visible");
+    transform(
+      animation,
+      `translate(${x}, ${y}) scale3d(${scale},${scale},${scale})`
+    );
+    animation.dataset.activated = String(performance.now());
+
+    setTimeout(() => {
+      animation.classList.remove("v-ripple__animation--enter");
+      animation.classList.add("v-ripple__animation--in");
+      transform(animation, `translate(${centerX}, ${centerY}) scale3d(1,1,1)`);
+    }, 0);
+  },
+
+  hide(el: HTMLElement | null) {
+    if (!el?._ripple?.enabled) return;
+
+    const ripples = el.getElementsByClassName("v-ripple__animation");
+
+    if (ripples.length === 0) return;
+    const animation = ripples[ripples.length - 1] as HTMLElement;
+
+    if (animation.dataset.isHiding) return;
+    else animation.dataset.isHiding = "true";
+
+    const diff = performance.now() - Number(animation.dataset.activated);
+    const delay = Math.max(250 - diff, 0);
+
+    setTimeout(() => {
+      animation.classList.remove("v-ripple__animation--in");
+      animation.classList.add("v-ripple__animation--out");
+
+      setTimeout(() => {
+        const ripples = el.getElementsByClassName("v-ripple__animation");
+        if (ripples.length === 1 && el.dataset.previousPosition) {
+          el.style.position = el.dataset.previousPosition;
+          delete el.dataset.previousPosition;
+        }
+
+        if (animation.parentNode?.parentNode === el)
+          el.removeChild(animation.parentNode);
+      }, 300);
+    }, delay);
+  }
+};
+
+function isRippleEnabled(value: any): value is true {
+  return typeof value === "undefined" || !!value;
+}
+
+function rippleShow(e: PointerEvent) {
+  const value: RippleOptions = {};
+  const element = e.currentTarget as HTMLElement | undefined;
+
+  if (!element?._ripple || element._ripple.touched) return;
+
+  value.center = element._ripple.centered;
+  if (element._ripple.class) {
+    value.class = element._ripple.class;
+  }
+
+  ripples.show(e, element, value);
+}
+
+function rippleHide(e: Event) {
+  const element = e.currentTarget as HTMLElement | null;
+  if (!element?._ripple) return;
+
+  window.setTimeout(() => {
+    if (element._ripple) {
+      element._ripple.touched = false;
+    }
+  });
+  ripples.hide(element);
+}
+
+function updateRipple(
+  el: HTMLElement,
+  binding: RippleDirectiveBinding,
+  wasEnabled: boolean
+) {
+  const { value, modifiers } = binding;
+  const enabled = isRippleEnabled(value);
+  if (!enabled) {
+    ripples.hide(el);
+  }
+
+  el._ripple = el._ripple ?? {};
+  el._ripple.enabled = enabled;
+  el._ripple.centered = modifiers.center;
+  el._ripple.circle = modifiers.circle;
+  if (isObject(value) && value.class) {
+    el._ripple.class = value.class;
+  }
+
+  if (enabled && !wasEnabled) {
+    el.addEventListener("pointerdown", rippleShow);
+    el.addEventListener("pointerup", rippleHide);
+  } else if (!enabled && wasEnabled) {
+    removeListeners(el);
+  }
+}
+
+function removeListeners(el: HTMLElement) {
+  el.removeEventListener("pointerdown", rippleShow);
+  el.removeEventListener("pointerup", rippleHide);
+}
+
+function mounted(el: HTMLElement, binding: RippleDirectiveBinding) {
+  updateRipple(el, binding, false);
+}
+
+function unmounted(el: HTMLElement) {
+  delete el._ripple;
+  removeListeners(el);
+}
+
+function updated(el: HTMLElement, binding: RippleDirectiveBinding) {
+  if (binding.value === binding.oldValue) {
+    return;
+  }
+
+  const wasEnabled = isRippleEnabled(binding.oldValue);
+  updateRipple(el, binding, wasEnabled);
+}
+
+/**
+ * @description 指令 v-ripple
+ * @use 用法如下
+ * 1. v-ripple 代表启用基本的 ripple 功能
+ * 2. v-ripple="{ class: 'text-red' }" 代表自定义 ripple 颜色,支持 tailwindcss,生效样式是 color
+ * 3. v-ripple.center 代表从中心扩散
+ */
+export const Ripple: Directive = {
+  mounted,
+  unmounted,
+  updated
+};

+ 9 - 0
src/router/modules/able.ts

@@ -42,6 +42,15 @@ export default {
         title: $t("menus.hsExcel")
       }
     },
+    {
+      path: "/components/ripple",
+      name: "Ripple",
+      component: () => import("@/views/able/ripple.vue"),
+      meta: {
+        title: $t("menus.hsRipple"),
+        extraIcon: "IF-pure-iconfont-new svg"
+      }
+    },
     {
       path: "/able/debounce",
       name: "Debounce",

+ 71 - 0
src/views/able/ripple.vue

@@ -0,0 +1,71 @@
+<script setup lang="ts">
+defineOptions({
+  name: "Ripple"
+});
+</script>
+
+<template>
+  <el-card shadow="never">
+    <template #header>
+      <div class="font-medium">波纹(Ripple)</div>
+    </template>
+    <div class="mb-5">组件中的波纹</div>
+    <el-alert
+      title="v-ripple在某些组件中使用波纹特效会异常,这是因为v-ripple指令只能作用于当前元素,某些组件有多层元素嵌套,且目标元素没在顶层,所以会导致特效异常"
+      type="warning"
+      :closable="false"
+    />
+    <el-space wrap class="my-5">
+      <el-button v-ripple>Default</el-button>
+      <el-button v-ripple type="primary">Primary</el-button>
+      <el-button v-ripple type="success">Success</el-button>
+      <el-button v-ripple type="info">Info</el-button>
+      <el-button v-ripple type="warning">Warning</el-button>
+      <el-button v-ripple type="danger">Danger</el-button>
+    </el-space>
+    <el-card v-ripple class="mb-5 w-[510px] select-none" shadow="hover">
+      卡片
+    </el-card>
+
+    <div class="mb-5">
+      只要在组件或HTML元素上使用v-ripple指令,就可以启用基本的ripple功能
+    </div>
+    <div
+      v-ripple
+      class="mb-5 text-center shadow-md rounded-md p-8 text-lg select-none"
+    >
+      HTML元素
+    </div>
+    <span
+      v-ripple
+      class="inline-block shadow-md rounded-md p-8 text-lg select-none"
+    >
+      行内元素需要添加display: block或display: inline-block才能生效
+    </span>
+
+    <div class="my-5">
+      当使用v-ripple.center时,将始终从目标的中心处产生波纹
+    </div>
+    <div
+      v-ripple.center
+      class="mb-5 text-center shadow-md rounded-md p-8 text-lg select-none"
+    >
+      始终从中心触发波纹
+    </div>
+
+    <div class="mb-5">
+      使用v-ripple="{ class: '' }"添加类来自定义波纹颜色,支持tailwindcss
+    </div>
+    <el-alert
+      title="自定义样式生效为文字颜色,例如:color: 'red';"
+      type="warning"
+      :closable="false"
+    />
+    <div
+      v-ripple="{ class: 'text-red-500' }"
+      class="my-5 text-center shadow-md rounded-md p-4 text-lg select-none"
+    >
+      自定义波纹颜色
+    </div>
+  </el-card>
+</template>

+ 14 - 0
types/global.d.ts

@@ -180,4 +180,18 @@ declare global {
     $storage: ResponsiveStorage;
     $config: PlatformConfigs;
   }
+
+  /**
+   * 扩展 `Elemet`
+   */
+  interface Element {
+    // v-ripple 作用于 src/directives/ripple/index.ts 文件
+    _ripple?: {
+      enabled?: boolean;
+      centered?: boolean;
+      class?: string;
+      circle?: boolean;
+      touched?: boolean;
+    };
+  }
 }