diff --git a/locales/en.yaml b/locales/en.yaml index b9b1b4187..37830a4e3 100644 --- a/locales/en.yaml +++ b/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 diff --git a/locales/zh-CN.yaml b/locales/zh-CN.yaml index 800a8bbe2..bb95d6ed3 100644 --- a/locales/zh-CN.yaml +++ b/locales/zh-CN.yaml @@ -90,6 +90,7 @@ menus: hsMenuTree: 菜单树结构 hsVideoFrame: 视频帧截取-wasm版 hsWavesurfer: 音频可视化 + hsRipple: 波纹(Ripple) hsOptimize: 防抖、截流、复制、长按指令 hsWatermark: 水印 hsPrint: 打印 diff --git a/src/directives/index.ts b/src/directives/index.ts index f4238c9af..3be2c5c1d 100644 --- a/src/directives/index.ts +++ b/src/directives/index.ts @@ -2,3 +2,4 @@ export * from "./auth"; export * from "./copy"; export * from "./longpress"; export * from "./optimize"; +export * from "./ripple"; diff --git a/src/directives/ripple/index.scss b/src/directives/ripple/index.scss new file mode 100644 index 000000000..061c82c9a --- /dev/null +++ b/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; + } + } +} diff --git a/src/directives/ripple/index.ts b/src/directives/ripple/index.ts new file mode 100644 index 000000000..06ff25f26 --- /dev/null +++ b/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 { + 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 +}; diff --git a/src/router/modules/able.ts b/src/router/modules/able.ts index a162bd730..fa5021b20 100644 --- a/src/router/modules/able.ts +++ b/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", diff --git a/src/views/able/ripple.vue b/src/views/able/ripple.vue new file mode 100644 index 000000000..21a574a96 --- /dev/null +++ b/src/views/able/ripple.vue @@ -0,0 +1,71 @@ + + + diff --git a/types/global.d.ts b/types/global.d.ts index 2fd717364..08315eddb 100644 --- a/types/global.d.ts +++ b/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; + }; + } }