酸檸檬游標 cursor
滑鼠化作檸檬片,碰到標記的元素就酸到向內凹陷。(◞✱◟ )
靈感來自 Sour Lemon Meme。
技術關鍵字
| 名稱 | 描述 |
|---|---|
| Vue Directive | 自定義 Vue 指令,用於封裝 DOM 操作邏輯和重複行為 |
| Canvas ImageData | 以 createImageData/putImageData 逐像素寫入資料,常用於程序化生成圖樣 |
| SVG feDisplacementMap | 依位移圖的色值偏移取樣來源像素,可做出扭曲、凹陷、波動等效果 |
| SVG feColorMatrix | 以 4×5 矩陣重算每個像素的 RGBA,可調色、轉灰階或逐格插值動畫 |
| SVG feComposite | 依 Porter-Duff 或 arithmetic 模式合成兩張濾鏡圖層,可相乘疊加 |
使用範例
基本用法
掛上 cursor-sour-lemon 啟用檸檬游標,再用 v-sour-lemon="{ ... }" 標記要凹陷的元素。游標碰到標記元素即向內凹陷。
查看範例原始碼
<template>
<div class="flex flex-col items-center gap-8 py-10">
<!-- 拿起檸檬才掛上游標、啟用凹陷 -->
<base-checkbox
v-model="lemonPicked"
:label="t('pickLemon')"
class="example-ctrl"
/>
<!-- 優先 2×2,窄到擠不下才落為單欄垂直排列 -->
<div class="grid grid-cols-1 w-fit justify-items-center gap-10 sm:grid-cols-2">
<div
v-sour-lemon="paramMap.face"
class="card card--face"
>
<svg
viewBox="0 0 100 100"
width="120"
height="120"
aria-hidden="true"
>
<circle
cx="50"
cy="50"
r="46"
fill="#FFE25A"
stroke="#E0A800"
stroke-width="3"
/>
<circle
cx="27"
cy="60"
r="6.5"
fill="#FF8E66"
opacity="0.45"
/>
<circle
cx="73"
cy="60"
r="6.5"
fill="#FF8E66"
opacity="0.45"
/>
<circle
cx="36"
cy="44"
r="5"
fill="#4A3A12"
/>
<circle
cx="64"
cy="44"
r="5"
fill="#4A3A12"
/>
<path
d="M36 62 Q50 74 64 62"
fill="none"
stroke="#4A3A12"
stroke-width="4"
stroke-linecap="round"
/>
</svg>
</div>
<div
v-sour-lemon="paramMap.text"
class="card card--text"
>
<span class="max-w-[8rem] text-center text-balance text-xl text-slate-700 font-black leading-snug">
{{ t('anything') }}
</span>
</div>
<div
v-sour-lemon="paramMap.dots"
class="card card--dots"
>
<svg
viewBox="0 0 100 100"
width="120"
height="120"
aria-hidden="true"
>
<circle
v-for="dot in dotList"
:key="`${dot.x}-${dot.y}`"
:cx="dot.x"
:cy="dot.y"
r="3.5"
fill="#F59E0B"
/>
</svg>
</div>
<div
v-sour-lemon="paramMap.heart"
class="card card--heart"
>
<svg
viewBox="0 0 100 100"
width="120"
height="120"
aria-hidden="true"
>
<path
d="M50 86 C 50 86 14 60 14 34 C 14 22 24 16 34 18 C 42 20 48 28 50 34 C 52 28 58 20 66 18 C 76 16 86 22 86 34 C 86 60 50 86 50 86 Z"
fill="#EF4444"
/>
</svg>
</div>
</div>
<cursor-sour-lemon v-if="lemonPicked" />
</div>
</template>
<script setup lang="ts">
import type { SourLemonParams } from '../v-sour-lemon'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseCheckbox from '../../base-checkbox.vue'
import CursorSourLemon from '../cursor-sour-lemon.vue'
import { vSourLemon } from '../v-sour-lemon'
const { t } = useI18n()
const lemonPicked = ref(false)
const paramMap: Record<string, SourLemonParams> = {
face: { intensity: 1, range: 0.35, center: [0.5, 0.46] },
text: { intensity: 1, range: 0.6, center: [0.5, 0.46] },
dots: { intensity: 1, range: 0.5, center: [0.5, 0.46] },
heart: { intensity: 1, range: 0.6, center: [0.5, 0.46] },
}
const GRID_SIZE = 7
/** 7×7 點陣,吸縮時整片捲向中心 */
const dotList = computed(() =>
Array.from({ length: GRID_SIZE * GRID_SIZE }, (_, index) => ({
x: 10 + (index % GRID_SIZE) * 13.5,
y: 10 + Math.floor(index / GRID_SIZE) * 13.5,
})),
)
</script>
<style scoped lang="sass">
.card
display: flex
align-items: center
justify-content: center
width: 11rem
height: 11rem
border-radius: 1.5rem
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15)
&--face
background: #FFF7C2
&--text
background: #FDE68A
&--dots
background: #1E293B
&--heart
background: #FFE4E6
</style>
<i18n lang="json">
{
"zh-hant": {
"anything": "酸到凹掉 (◞✱◟ )",
"pickLemon": "拿起檸檬 🍋"
},
"en": {
"anything": "So sour it caves in (◞✱◟ )",
"pickLemon": "Pick up the lemon 🍋"
}
}
</i18n>原理
職責切成三塊,v-sour-lemon 指令只做登記,凹陷機構集中在共享 controller,cursor-sour-lemon 元件掌控檸檬呈現與啟用。
- 指令:
mounted登記元素與參數至 controller、updated更新參數、unmounted註銷,本身不碰濾鏡與動畫。 - controller:為每個登記元素各自注入一組 SVG 凹陷濾鏡,以 Canvas 生成表面圖(
ResizeObserver監看尺寸變化重算),並以pointerenter/pointerleave偵測檸檬是否碰到。所有元素共用一條 rAF 彈簧迴圈,收斂即停。 - 游標:以 VueUse 的
useMouse取得座標、useMousePressed偵測按壓,再 teleport 手繪 SVG 至游標位置;掛載時開啟全域啟用旗標並隱藏原生游標,卸載還原。唯有游標掛載、且檸檬碰到標記元素才凹陷。
凹陷與摺痕陰影出自同一個 SVG filter graph,位移與陰影各司其職,位移只管乾淨的徑向收縮,摺痕只在陰影現形。整體分四步:
- 用 Canvas 預先產生兩張表面圖:位移圖(R、G 通道編碼朝外的徑向位移)與陰影圖(灰階明暗,放射狀摺痕化作乾淨暗線)。兩張圖僅在尺寸或參數變化時重算。
feDisplacementMap依位移圖取樣外側內容,畫面朝中心聚攏,形成往中心吸陷的凹痕。同樣手法見於魚花。feColorMatrix依當前凹陷量在「白(無效果)↔ 完整陰影」間插值(out = depth·shading + (1 − depth)),動畫時逐格只改矩陣值,成本極低。feComposite operator="arithmetic"(k1=1)讓陰影相乘疊上收縮後的內容,摺痕暗線落在凹陷表面上。
陰影採遮蔽式暗化而非方向光。方向光打在放射脊上會變成半亮半暗的風車,不像摺痕。摺痕本質是凹槽的陰影,不論光從哪來槽底都暗,故直接依摺痕深度將凹槽壓成均勻暗線,細窄摺痕即細窄暗線。摺痕線帶隨機角度、深淺、粗細,以元素固定的種子產生,縮放重算都不亂跳,並在中心與邊緣淡出。
位移採有界徑向收縮,取樣半徑 σ(ρ) = range · t^a(t = ρ/range)恆 ≤ range,位移量則為 σ − ρ,邊界處(t=1)為原樣。因此只收縮範圍內的內容、絕不取樣到元素外部,外側透明區也不致捲進中心。
收縮指數為 a = 1 − intensity^(1/falloff),intensity=0 時 a=1(原樣),intensity=1 時 a=0,範圍內每個點都取樣到邊界,內容收乾後完全消失(留白內容則收成透明)。range 控制收縮半徑、falloff 控制多快逼近完全收縮。
檸檬碰到元素時 pointerenter 回報,再以彈簧動畫同步拉高 feDisplacementMap 的 scale 與陰影插值的凹陷量,讓元素酸到向內凹陷,離開則 Q 彈回原狀。
原始碼
API
v-sour-lemon
標記元素的指令參數:
/** 標記元素的酸檸檬參數,沿用原包裝器的扭曲設定 */
export interface SourLemonParams {
/** 收縮強度,0~1,1 可讓範圍內容完全收縮消失;收縮限制在範圍內、不吸入外部。@default 0.85 */
intensity?: number;
/** 扭曲範圍,0~1 相對元素半徑,越小越集中於中心。@default 0.8 */
range?: number;
/** 收縮曲線,越大 intensity 越快逼近完全收縮。@default 20 */
falloff?: number;
/** 扭曲中心 [x, y],0~1 相對元素寬高,[0.5, 0.5] 為正中間。@default [0.5, 0.5] */
center?: [number, number];
}cursor-sour-lemon Props
interface Props {
/** 檸檬片邊長,單位 px。@default 56 */
size?: number;
}