diff --git a/src/assets/style/entrance-transition.less b/src/assets/style/entrance-transition.less new file mode 100644 index 0000000..8b158b3 --- /dev/null +++ b/src/assets/style/entrance-transition.less @@ -0,0 +1,20 @@ +.entrance-fade-up-enter-active { + transition: opacity .4s ease, transform .4s ease; + transition-delay: var(--entrance-delay, 0ms); +} + +.entrance-fade-up-enter-from { + opacity: 0; + transform: translate3d(0, 1rem, 0); +} + +.entrance-fade-up-enter-to { + opacity: 1; + transform: translate3d(0, 0, 0); +} + +@media (prefers-reduced-motion: reduce) { + .entrance-fade-up-enter-active { + transition: none; + } +} diff --git a/src/directives/entrance-transition.ts b/src/directives/entrance-transition.ts new file mode 100644 index 0000000..0ad54aa --- /dev/null +++ b/src/directives/entrance-transition.ts @@ -0,0 +1,107 @@ +import type { ObjectDirective } from "vue"; + +const ENTRANCE_DURATION = 400; +const ACTIVE_CLASS = "entrance-fade-up-enter-active"; +const FROM_CLASS = "entrance-fade-up-enter-from"; +const TO_CLASS = "entrance-fade-up-enter-to"; +const STATE_KEY = Symbol("entrance-transition-state"); + +/** 入场动画指令支持的延迟值。 */ +export type EntranceTransitionValue = number | string | { delay?: number | string } | undefined; + +interface EntranceTransitionState { + frameId?: number; + timerId?: number; + delay: number; +} + +type EntranceTransitionElement = HTMLElement & { + [STATE_KEY]?: EntranceTransitionState; +}; + +function getDelay(value: EntranceTransitionValue): number { + const rawDelay = typeof value === "object" && value !== null ? value.delay : value; + const delay = Number(rawDelay ?? 0); + return Number.isFinite(delay) ? Math.max(0, delay) : 0; +} + +function setDelay(element: EntranceTransitionElement, delay: number): void { + element.style.setProperty("--entrance-delay", `${delay}ms`); +} + +function cleanup(element: EntranceTransitionElement): void { + const state = element[STATE_KEY]; + if (!state) { + return; + } + + if (state.frameId !== undefined) { + window.cancelAnimationFrame(state.frameId); + } + if (state.timerId !== undefined) { + window.clearTimeout(state.timerId); + } + + element.classList.remove(ACTIVE_CLASS, FROM_CLASS, TO_CLASS); + element.style.removeProperty("--entrance-delay"); + delete element[STATE_KEY]; +} + +/** 为元素添加一次性的渐入上移动画。 */ +export const VEntranceTransition: ObjectDirective = { + beforeMount(element, binding) { + if (!(element instanceof HTMLElement)) { + return; + } + + const target = element as EntranceTransitionElement; + const delay = getDelay(binding.value); + target[STATE_KEY] = { delay }; + setDelay(target, delay); + target.classList.add(ACTIVE_CLASS, FROM_CLASS); + }, + + mounted(element) { + if (!(element instanceof HTMLElement)) { + return; + } + + const target = element as EntranceTransitionElement; + const state = target[STATE_KEY]; + if (!state) { + return; + } + + state.frameId = window.requestAnimationFrame(() => { + state.frameId = window.requestAnimationFrame(() => { + state.frameId = undefined; + target.classList.remove(FROM_CLASS); + target.classList.add(TO_CLASS); + state.timerId = window.setTimeout(() => cleanup(target), ENTRANCE_DURATION + state.delay); + }); + }); + }, + + updated(element, binding) { + if (!(element instanceof HTMLElement) || binding.value === binding.oldValue) { + return; + } + + const target = element as EntranceTransitionElement; + const state = target[STATE_KEY]; + if (!state) { + return; + } + + state.delay = getDelay(binding.value); + setDelay(target, state.delay); + }, + + beforeUnmount(element) { + if (element instanceof HTMLElement) { + cleanup(element as EntranceTransitionElement); + } + } +}; + +export default VEntranceTransition; diff --git a/src/directives/index.ts b/src/directives/index.ts new file mode 100644 index 0000000..68c0c17 --- /dev/null +++ b/src/directives/index.ts @@ -0,0 +1,7 @@ +export { + default as VEntranceTransition +} from "./entrance-transition"; + +export type { + EntranceTransitionValue +} from "./entrance-transition"; diff --git a/src/index.ts b/src/index.ts index a1d09ef..1dc5d75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,11 @@ import "./assets/style/tdesign-custom.less"; +import "./assets/style/entrance-transition.less"; import "./components/tiptap-content.less"; import "timi-web/style.css"; import "../../timi-web/src/components/prism-code-block.less"; export * from "./components"; +export * from "./directives"; export { }; diff --git a/src/types/vue-global.d.ts b/src/types/vue-global.d.ts new file mode 100644 index 0000000..242f0ad --- /dev/null +++ b/src/types/vue-global.d.ts @@ -0,0 +1,10 @@ +import type { Directive } from "vue"; +import type { EntranceTransitionValue } from "../directives/entrance-transition"; + +declare module "vue" { + export interface GlobalDirectives { + entranceTransition: Directive; + } +} + +export {};