| 1 | import type { ClicksElement, RawAtValue } from '@slidev/types' |
| 2 | import type { App, DirectiveBinding } from 'vue' |
| 3 | import { computed, watchEffect } from 'vue' |
| 4 | import { |
| 5 | CLASS_VCLICK_ANIMATION_PREFIX, |
| 6 | CLASS_VCLICK_CURRENT, |
| 7 | CLASS_VCLICK_HIDDEN, |
| 8 | CLASS_VCLICK_HIDDEN_EXP, |
| 9 | CLASS_VCLICK_PRIOR, |
| 10 | CLASS_VCLICK_TARGET, |
| 11 | injectionClicksContext, |
| 12 | injectionFrontmatter, |
| 13 | } from '../constants' |
| 14 | import { configs } from '../env' |
| 15 | import { directiveInject } from '../utils' |
| 16 | |
| 17 | const RE_WHITESPACE_OR_COMMA = /[\s,]+/ |
| 18 | |
| 19 | function syncAnimationClasses(el: HTMLElement, animations: string[]) { |
| 20 | const targetClasses = animations.map(a => `${CLASS_VCLICK_ANIMATION_PREFIX}${a}`) |
| 21 | el.classList.forEach((c) => { |
| 22 | if (c.startsWith(CLASS_VCLICK_ANIMATION_PREFIX) && !targetClasses.includes(c)) |
| 23 | el.classList.remove(c) |
| 24 | }) |
| 25 | targetClasses.forEach(c => el.classList.add(c)) |
| 26 | } |
| 27 | |
| 28 | export function createVClickDirectives() { |
| 29 | return { |
| 30 | install(app: App) { |
| 31 | app.directive<HTMLElement, RawAtValue>('click', { |
| 32 | // @ts-expect-error extra prop |
| 33 | name: 'v-click', |
| 34 | |
| 35 | mounted(el, dir) { |
| 36 | const resolved = resolveClick(el, dir, dir.value) |
| 37 | if (resolved == null) |
| 38 | return |
| 39 | |
| 40 | el.classList.toggle(CLASS_VCLICK_TARGET, true) |
| 41 | |
| 42 | // Expose the resolved clicks info to the element to make it easier to understand and debug |
| 43 | el.dataset.slidevClicksStart = String(resolved.start) |
| 44 | if (Number.isFinite(resolved.end)) |
| 45 | el.dataset.slidevClicksEnd = String(resolved.end) |
| 46 | |
| 47 | // @ts-expect-error extra prop |
| 48 | el.watchStopHandle = watchEffect(() => { |
| 49 | const active = resolved.isActive.value |
| 50 | const current = resolved.isCurrent.value |
| 51 | const prior = active && !current |
| 52 | |
| 53 | if (resolved.flagHide) { |
| 54 | el.classList.toggle(CLASS_VCLICK_HIDDEN, active) |
| 55 | el.classList.toggle(CLASS_VCLICK_HIDDEN_EXP, active) |
| 56 | } |
| 57 | else { |
| 58 | el.classList.toggle(CLASS_VCLICK_HIDDEN, !active) |
| 59 | } |
| 60 | |
| 61 | syncAnimationClasses(el, resolved.flagAnimations.value) |
| 62 | |
| 63 | el.classList.toggle(CLASS_VCLICK_CURRENT, current) |
| 64 | el.classList.toggle(CLASS_VCLICK_PRIOR, prior) |
| 65 | }) |
| 66 | }, |
| 67 | unmounted, |
| 68 | }) |
| 69 | |
| 70 | app.directive<HTMLElement, RawAtValue>('after', { |
| 71 | // @ts-expect-error extra prop |
| 72 | name: 'v-after', |
| 73 | |
| 74 | mounted(el, dir) { |
| 75 | const resolved = resolveClick(el, dir, '+0') |
| 76 | if (resolved == null) |
| 77 | return |
| 78 | |
| 79 | el.classList.toggle(CLASS_VCLICK_TARGET, true) |
| 80 | |
| 81 | // @ts-expect-error extra prop |
| 82 | el.watchStopHandle = watchEffect(() => { |
| 83 | const active = resolved.isActive.value |
| 84 | const current = resolved.isCurrent.value |
| 85 | const prior = active && !current |
| 86 | |
| 87 | if (resolved.flagHide) { |
| 88 | el.classList.toggle(CLASS_VCLICK_HIDDEN, active) |
| 89 | el.classList.toggle(CLASS_VCLICK_HIDDEN_EXP, active) |
| 90 | } |
| 91 | else { |
| 92 | el.classList.toggle(CLASS_VCLICK_HIDDEN, !active) |
| 93 | } |
| 94 | |
| 95 | syncAnimationClasses(el, resolved.flagAnimations.value) |
| 96 | |
| 97 | el.classList.toggle(CLASS_VCLICK_CURRENT, current) |
| 98 | el.classList.toggle(CLASS_VCLICK_PRIOR, prior) |
| 99 | }) |
| 100 | }, |
| 101 | unmounted, |
| 102 | }) |
| 103 | |
| 104 | app.directive<HTMLElement, RawAtValue>('click-hide', { |
| 105 | // @ts-expect-error extra prop |
| 106 | name: 'v-click-hide', |
| 107 | |
| 108 | mounted(el, dir) { |
| 109 | const resolved = resolveClick(el, dir, dir.value, true) |
| 110 | if (resolved == null) |
| 111 | return |
| 112 | |
| 113 | el.classList.toggle(CLASS_VCLICK_TARGET, true) |
| 114 | |
| 115 | // @ts-expect-error extra prop |
| 116 | el.watchStopHandle = watchEffect(() => { |
| 117 | const active = resolved.isActive.value |
| 118 | const current = resolved.isCurrent.value |
| 119 | const prior = active && !current |
| 120 | |
| 121 | el.classList.toggle(CLASS_VCLICK_HIDDEN, active) |
| 122 | el.classList.toggle(CLASS_VCLICK_HIDDEN_EXP, active) |
| 123 | |
| 124 | syncAnimationClasses(el, resolved.flagAnimations.value) |
| 125 | |
| 126 | el.classList.toggle(CLASS_VCLICK_CURRENT, current) |
| 127 | el.classList.toggle(CLASS_VCLICK_PRIOR, prior) |
| 128 | }) |
| 129 | }, |
| 130 | unmounted, |
| 131 | }) |
| 132 | }, |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | export const resolvedClickMap = new Map<ClicksElement, ReturnType<typeof resolveClick>>() |
| 137 | |
| 138 | export function resolveClick(el: Element | string, dir: DirectiveBinding<any>, value: RawAtValue, explicitHide = false) { |
| 139 | const ctx = directiveInject(dir, injectionClicksContext)?.value |
| 140 | const frontmatter = directiveInject(dir, injectionFrontmatter) |
| 141 | |
| 142 | if (!el || !ctx) |
| 143 | return null |
| 144 | |
| 145 | const flagHide = explicitHide || (dir.modifiers.hide !== false && dir.modifiers.hide != null) |
| 146 | |
| 147 | /** |
| 148 | * Resolves the animation presets for this element. |
| 149 | * Priority: directive modifiers (stacked) > slide frontmatter > global config. |
| 150 | * Modifiers allow composition, e.g., v-click.fade.up.scale. |
| 151 | */ |
| 152 | const elModifiers = Object.keys({ ...dir.modifiers }).filter(m => m !== 'hide') |
| 153 | const flagAnimations = computed(() => { |
| 154 | if (elModifiers.length > 0) |
| 155 | return elModifiers |
| 156 | const preset = frontmatter?.clickAnimation || configs.clickAnimation |
| 157 | if (preset) |
| 158 | return preset.split(RE_WHITESPACE_OR_COMMA).filter(Boolean) |
| 159 | return [] |
| 160 | }) |
| 161 | |
| 162 | const info = ctx.calculate(value) |
| 163 | if (!info) |
| 164 | return null |
| 165 | |
| 166 | ctx.register(el, info) |
| 167 | |
| 168 | const isShown = computed(() => flagHide ? !info.isActive.value : info.isActive.value) |
| 169 | const visibilityState = computed(() => { |
| 170 | if (isShown.value) |
| 171 | return 'shown' |
| 172 | if (Number.isFinite(info.end)) |
| 173 | return ctx.current < info.start ? 'before' : 'after' |
| 174 | else |
| 175 | return flagHide ? 'after' : 'before' |
| 176 | }) |
| 177 | |
| 178 | const resolved = { |
| 179 | ...info, |
| 180 | isShown, |
| 181 | visibilityState, |
| 182 | flagHide, |
| 183 | flagAnimations, |
| 184 | } |
| 185 | resolvedClickMap.set(el, resolved) |
| 186 | return resolved |
| 187 | } |
| 188 | |
| 189 | function unmounted(el: HTMLElement, dir: DirectiveBinding<any>) { |
| 190 | el.classList.toggle(CLASS_VCLICK_TARGET, false) |
| 191 | const ctx = directiveInject(dir, injectionClicksContext)?.value |
| 192 | ctx?.unregister(el) |
| 193 | // @ts-expect-error extra prop |
| 194 | el.watchStopHandle?.() |
| 195 | } |
| 196 |